shoko 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +4 -0
  3. data/.bundle/config.bak +3 -0
  4. data/.rspec_status +42 -0
  5. data/.rubocop.yml +124 -0
  6. data/Gemfile +19 -0
  7. data/LICENSE +21 -0
  8. data/README.md +82 -0
  9. data/Rakefile +29 -0
  10. data/bin/start +15 -0
  11. data/lib/shoko/adapters/book_sources/document_service.rb +201 -0
  12. data/lib/shoko/adapters/book_sources/download_service.rb +95 -0
  13. data/lib/shoko/adapters/book_sources/epub/epub_resource_loader.rb +137 -0
  14. data/lib/shoko/adapters/book_sources/epub/parsers/html_processor.rb +151 -0
  15. data/lib/shoko/adapters/book_sources/epub/parsers/metadata_extractor.rb +53 -0
  16. data/lib/shoko/adapters/book_sources/epub/parsers/opf/entry_reader.rb +77 -0
  17. data/lib/shoko/adapters/book_sources/epub/parsers/opf/metadata_extractor.rb +67 -0
  18. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_context.rb +86 -0
  19. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_index.rb +75 -0
  20. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_scanner.rb +47 -0
  21. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_extractor.rb +46 -0
  22. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_label_resolver.rb +83 -0
  23. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_list_item.rb +55 -0
  24. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_result.rb +8 -0
  25. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_selector.rb +100 -0
  26. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_source_locator.rb +93 -0
  27. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_traversal.rb +103 -0
  28. data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_walker.rb +56 -0
  29. data/lib/shoko/adapters/book_sources/epub/parsers/opf_processor.rb +102 -0
  30. data/lib/shoko/adapters/book_sources/epub/parsers/xhtml_content_parser.rb +661 -0
  31. data/lib/shoko/adapters/book_sources/epub/parsers/xml_text_normalizer.rb +41 -0
  32. data/lib/shoko/adapters/book_sources/epub_document.rb +253 -0
  33. data/lib/shoko/adapters/book_sources/epub_finder/directory_scanner.rb +134 -0
  34. data/lib/shoko/adapters/book_sources/epub_finder/scanner_context.rb +28 -0
  35. data/lib/shoko/adapters/book_sources/epub_finder.rb +161 -0
  36. data/lib/shoko/adapters/book_sources/epub_importer.rb +268 -0
  37. data/lib/shoko/adapters/book_sources/gutendex_client.rb +150 -0
  38. data/lib/shoko/adapters/book_sources/library_scanner.rb +93 -0
  39. data/lib/shoko/adapters/book_sources/source_fingerprint.rb +57 -0
  40. data/lib/shoko/adapters/input/annotations/mouse_handler.rb +84 -0
  41. data/lib/shoko/adapters/input/command_bridge.rb +148 -0
  42. data/lib/shoko/adapters/input/command_factory.rb +255 -0
  43. data/lib/shoko/adapters/input/commands.rb +60 -0
  44. data/lib/shoko/adapters/input/dispatcher.rb +69 -0
  45. data/lib/shoko/adapters/input/input_controller.rb +250 -0
  46. data/lib/shoko/adapters/input/key_definitions.rb +108 -0
  47. data/lib/shoko/adapters/input/validators/file_path_validator.rb +81 -0
  48. data/lib/shoko/adapters/input/validators/terminal_size_validator.rb +76 -0
  49. data/lib/shoko/adapters/monitoring/logger.rb +150 -0
  50. data/lib/shoko/adapters/monitoring/perf_tracer.rb +183 -0
  51. data/lib/shoko/adapters/monitoring/performance_monitor.rb +110 -0
  52. data/lib/shoko/adapters/output/clipboard/clipboard_service.rb +125 -0
  53. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/image_builder.rb +149 -0
  54. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/text_wrapper.rb +149 -0
  55. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/tokenizer.rb +91 -0
  56. data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler.rb +144 -0
  57. data/lib/shoko/adapters/output/formatting/formatting_service/plain_lines_builder.rb +54 -0
  58. data/lib/shoko/adapters/output/formatting/formatting_service.rb +247 -0
  59. data/lib/shoko/adapters/output/formatting/wrapping_service.rb +228 -0
  60. data/lib/shoko/adapters/output/instrumentation_service.rb +52 -0
  61. data/lib/shoko/adapters/output/kitty/image_transcoder.rb +71 -0
  62. data/lib/shoko/adapters/output/kitty/kitty_graphics.rb +114 -0
  63. data/lib/shoko/adapters/output/kitty/kitty_image_renderer.rb +239 -0
  64. data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders.rb +139 -0
  65. data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders_diacritic_codepoints.txt +26 -0
  66. data/lib/shoko/adapters/output/notification_service.rb +58 -0
  67. data/lib/shoko/adapters/output/render_registry.rb +45 -0
  68. data/lib/shoko/adapters/output/rendering/models/line_geometry.rb +60 -0
  69. data/lib/shoko/adapters/output/rendering/models/page_rendering_context.rb +22 -0
  70. data/lib/shoko/adapters/output/rendering/models/render_params.rb +28 -0
  71. data/lib/shoko/adapters/output/rendering/models/rendering_context.rb +58 -0
  72. data/lib/shoko/adapters/output/terminal/buffer.rb +275 -0
  73. data/lib/shoko/adapters/output/terminal/constants/terminal_defaults.rb +11 -0
  74. data/lib/shoko/adapters/output/terminal/input/decoder.rb +347 -0
  75. data/lib/shoko/adapters/output/terminal/input.rb +161 -0
  76. data/lib/shoko/adapters/output/terminal/output.rb +105 -0
  77. data/lib/shoko/adapters/output/terminal/terminal.rb +167 -0
  78. data/lib/shoko/adapters/output/terminal/terminal_sanitizer.rb +243 -0
  79. data/lib/shoko/adapters/output/terminal/terminal_service.rb +138 -0
  80. data/lib/shoko/adapters/output/terminal/text_metrics.rb +273 -0
  81. data/lib/shoko/adapters/output/ui/builders/page_setup_builder.rb +47 -0
  82. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/footer_renderer.rb +80 -0
  83. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/geometry.rb +61 -0
  84. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/note_renderer.rb +86 -0
  85. data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay_component.rb +234 -0
  86. data/lib/shoko/adapters/output/ui/components/annotations_overlay/list_renderer.rb +142 -0
  87. data/lib/shoko/adapters/output/ui/components/annotations_overlay_component.rb +185 -0
  88. data/lib/shoko/adapters/output/ui/components/base_component.rb +110 -0
  89. data/lib/shoko/adapters/output/ui/components/component_interface.rb +80 -0
  90. data/lib/shoko/adapters/output/ui/components/content_component.rb +61 -0
  91. data/lib/shoko/adapters/output/ui/components/enhanced_popup_menu.rb +191 -0
  92. data/lib/shoko/adapters/output/ui/components/footer_component.rb +120 -0
  93. data/lib/shoko/adapters/output/ui/components/header_component.rb +46 -0
  94. data/lib/shoko/adapters/output/ui/components/layouts/horizontal.rb +63 -0
  95. data/lib/shoko/adapters/output/ui/components/layouts/vertical.rb +73 -0
  96. data/lib/shoko/adapters/output/ui/components/main_menu_component.rb +103 -0
  97. data/lib/shoko/adapters/output/ui/components/reading/base_view_renderer.rb +199 -0
  98. data/lib/shoko/adapters/output/ui/components/reading/config_helpers.rb +42 -0
  99. data/lib/shoko/adapters/output/ui/components/reading/help_renderer.rb +62 -0
  100. data/lib/shoko/adapters/output/ui/components/reading/inline_segment_highlighter.rb +144 -0
  101. data/lib/shoko/adapters/output/ui/components/reading/kitty_image_line_renderer.rb +262 -0
  102. data/lib/shoko/adapters/output/ui/components/reading/line_content_composer.rb +114 -0
  103. data/lib/shoko/adapters/output/ui/components/reading/line_drawer.rb +87 -0
  104. data/lib/shoko/adapters/output/ui/components/reading/line_geometry_builder.rb +41 -0
  105. data/lib/shoko/adapters/output/ui/components/reading/rendered_lines_recorder.rb +64 -0
  106. data/lib/shoko/adapters/output/ui/components/reading/single_view_renderer.rb +156 -0
  107. data/lib/shoko/adapters/output/ui/components/reading/split_view_renderer.rb +221 -0
  108. data/lib/shoko/adapters/output/ui/components/reading/view_renderer_factory.rb +20 -0
  109. data/lib/shoko/adapters/output/ui/components/reading/wrapped_lines_fetcher.rb +139 -0
  110. data/lib/shoko/adapters/output/ui/components/rect.rb +15 -0
  111. data/lib/shoko/adapters/output/ui/components/render_style.rb +84 -0
  112. data/lib/shoko/adapters/output/ui/components/screen_component.rb +24 -0
  113. data/lib/shoko/adapters/output/ui/components/screens/annotation_detail_screen_component.rb +175 -0
  114. data/lib/shoko/adapters/output/ui/components/screens/annotation_edit_screen_component.rb +221 -0
  115. data/lib/shoko/adapters/output/ui/components/screens/annotation_editor_screen_component.rb +205 -0
  116. data/lib/shoko/adapters/output/ui/components/screens/annotation_rendering_helpers.rb +190 -0
  117. data/lib/shoko/adapters/output/ui/components/screens/annotations_screen_component.rb +266 -0
  118. data/lib/shoko/adapters/output/ui/components/screens/base_screen_component.rb +49 -0
  119. data/lib/shoko/adapters/output/ui/components/screens/browse_screen_component.rb +319 -0
  120. data/lib/shoko/adapters/output/ui/components/screens/download_books_screen_component.rb +340 -0
  121. data/lib/shoko/adapters/output/ui/components/screens/library_screen_component.rb +205 -0
  122. data/lib/shoko/adapters/output/ui/components/screens/loading_overlay_component.rb +49 -0
  123. data/lib/shoko/adapters/output/ui/components/screens/menu_screen_component.rb +107 -0
  124. data/lib/shoko/adapters/output/ui/components/screens/settings_screen_component.rb +238 -0
  125. data/lib/shoko/adapters/output/ui/components/sidebar/annotations_tab_renderer.rb +159 -0
  126. data/lib/shoko/adapters/output/ui/components/sidebar/bookmarks_tab_renderer.rb +139 -0
  127. data/lib/shoko/adapters/output/ui/components/sidebar/tab_header_component.rb +157 -0
  128. data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_renderer.rb +111 -0
  129. data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_support.rb +1606 -0
  130. data/lib/shoko/adapters/output/ui/components/sidebar_panel_component.rb +217 -0
  131. data/lib/shoko/adapters/output/ui/components/surface.rb +88 -0
  132. data/lib/shoko/adapters/output/ui/components/tooltip_overlay_component.rb +224 -0
  133. data/lib/shoko/adapters/output/ui/components/ui/box_drawer.rb +32 -0
  134. data/lib/shoko/adapters/output/ui/components/ui/list_helpers.rb +33 -0
  135. data/lib/shoko/adapters/output/ui/components/ui/overlay_layout.rb +79 -0
  136. data/lib/shoko/adapters/output/ui/components/ui/text_utils.rb +46 -0
  137. data/lib/shoko/adapters/output/ui/constants/highlighting.rb +21 -0
  138. data/lib/shoko/adapters/output/ui/constants/messages.rb +12 -0
  139. data/lib/shoko/adapters/output/ui/constants/themes.rb +79 -0
  140. data/lib/shoko/adapters/output/ui/constants/ui_constants.rb +85 -0
  141. data/lib/shoko/adapters/output/ui/rendering/frame_coordinator.rb +42 -0
  142. data/lib/shoko/adapters/output/ui/rendering/reader_render_coordinator.rb +169 -0
  143. data/lib/shoko/adapters/output/ui/rendering/render_pipeline.rb +55 -0
  144. data/lib/shoko/adapters/storage/atomic_file_writer.rb +43 -0
  145. data/lib/shoko/adapters/storage/background_worker.rb +66 -0
  146. data/lib/shoko/adapters/storage/book_cache_pipeline.rb +653 -0
  147. data/lib/shoko/adapters/storage/cache/epub/memory_cache.rb +99 -0
  148. data/lib/shoko/adapters/storage/cache/epub/persistence.rb +131 -0
  149. data/lib/shoko/adapters/storage/cache/epub/serializer/deserialize.rb +225 -0
  150. data/lib/shoko/adapters/storage/cache/epub/serializer/helpers.rb +63 -0
  151. data/lib/shoko/adapters/storage/cache/epub/serializer/serialize.rb +83 -0
  152. data/lib/shoko/adapters/storage/cache/epub/serializer.rb +5 -0
  153. data/lib/shoko/adapters/storage/cache/epub/source_reference.rb +58 -0
  154. data/lib/shoko/adapters/storage/cache_paths.rb +21 -0
  155. data/lib/shoko/adapters/storage/cache_pointer_manager.rb +60 -0
  156. data/lib/shoko/adapters/storage/config_paths.rb +30 -0
  157. data/lib/shoko/adapters/storage/epub_cache.rb +195 -0
  158. data/lib/shoko/adapters/storage/file_writer_service.rb +47 -0
  159. data/lib/shoko/adapters/storage/json_cache_store/chapters.rb +141 -0
  160. data/lib/shoko/adapters/storage/json_cache_store/layouts.rb +67 -0
  161. data/lib/shoko/adapters/storage/json_cache_store/manifest.rb +42 -0
  162. data/lib/shoko/adapters/storage/json_cache_store/payload_helpers.rb +113 -0
  163. data/lib/shoko/adapters/storage/json_cache_store/resources.rb +84 -0
  164. data/lib/shoko/adapters/storage/json_cache_store.rb +167 -0
  165. data/lib/shoko/adapters/storage/lazy_file_string.rb +65 -0
  166. data/lib/shoko/adapters/storage/pagination_cache.rb +127 -0
  167. data/lib/shoko/adapters/storage/recent_files.rb +78 -0
  168. data/lib/shoko/adapters/storage/repositories/annotation_repository.rb +182 -0
  169. data/lib/shoko/adapters/storage/repositories/base_repository.rb +81 -0
  170. data/lib/shoko/adapters/storage/repositories/bookmark_repository.rb +132 -0
  171. data/lib/shoko/adapters/storage/repositories/cached_library_repository.rb +129 -0
  172. data/lib/shoko/adapters/storage/repositories/config_repository.rb +262 -0
  173. data/lib/shoko/adapters/storage/repositories/progress_repository.rb +166 -0
  174. data/lib/shoko/adapters/storage/repositories/storage/annotation_file_store.rb +128 -0
  175. data/lib/shoko/adapters/storage/repositories/storage/bookmark_file_store.rb +109 -0
  176. data/lib/shoko/adapters/storage/repositories/storage/file_store_utils.rb +20 -0
  177. data/lib/shoko/adapters/storage/repositories/storage/progress_file_store.rb +59 -0
  178. data/lib/shoko/application/annotation_editor_overlay_session.rb +138 -0
  179. data/lib/shoko/application/cli.rb +134 -0
  180. data/lib/shoko/application/controllers/menu/input_controller.rb +189 -0
  181. data/lib/shoko/application/controllers/menu/state_controller.rb +642 -0
  182. data/lib/shoko/application/controllers/menu_controller.rb +469 -0
  183. data/lib/shoko/application/controllers/mouseable_reader.rb +377 -0
  184. data/lib/shoko/application/controllers/reader_controller.rb +449 -0
  185. data/lib/shoko/application/controllers/state_controller.rb +410 -0
  186. data/lib/shoko/application/controllers/ui_controller.rb +782 -0
  187. data/lib/shoko/application/dependency_container.rb +301 -0
  188. data/lib/shoko/application/infrastructure/event_bus.rb +80 -0
  189. data/lib/shoko/application/infrastructure/observer_state_store.rb +136 -0
  190. data/lib/shoko/application/infrastructure/state_store.rb +413 -0
  191. data/lib/shoko/application/main_menu/menu_progress_presenter.rb +83 -0
  192. data/lib/shoko/application/pending_jump_handler.rb +122 -0
  193. data/lib/shoko/application/reader_lifecycle.rb +65 -0
  194. data/lib/shoko/application/reader_startup_orchestrator.rb +113 -0
  195. data/lib/shoko/application/selectors/config_selectors.rb +62 -0
  196. data/lib/shoko/application/selectors/menu_selectors.rb +62 -0
  197. data/lib/shoko/application/selectors/reader_selectors.rb +186 -0
  198. data/lib/shoko/application/state/actions/base_action.rb +24 -0
  199. data/lib/shoko/application/state/actions/quit_to_menu_action.rb +16 -0
  200. data/lib/shoko/application/state/actions/switch_reader_mode_action.rb +22 -0
  201. data/lib/shoko/application/state/actions/toggle_view_mode_action.rb +31 -0
  202. data/lib/shoko/application/state/actions/update_annotation_editor_overlay_action.rb +27 -0
  203. data/lib/shoko/application/state/actions/update_annotations_action.rb +20 -0
  204. data/lib/shoko/application/state/actions/update_annotations_overlay_action.rb +27 -0
  205. data/lib/shoko/application/state/actions/update_bookmarks_action.rb +20 -0
  206. data/lib/shoko/application/state/actions/update_chapter_action.rb +24 -0
  207. data/lib/shoko/application/state/actions/update_config_action.rb +22 -0
  208. data/lib/shoko/application/state/actions/update_field_helpers.rb +26 -0
  209. data/lib/shoko/application/state/actions/update_menu_action.rb +21 -0
  210. data/lib/shoko/application/state/actions/update_message_action.rb +35 -0
  211. data/lib/shoko/application/state/actions/update_page_action.rb +21 -0
  212. data/lib/shoko/application/state/actions/update_pagination_state_action.rb +21 -0
  213. data/lib/shoko/application/state/actions/update_popup_menu_action.rb +27 -0
  214. data/lib/shoko/application/state/actions/update_reader_meta_action.rb +21 -0
  215. data/lib/shoko/application/state/actions/update_reader_mode_action.rb +20 -0
  216. data/lib/shoko/application/state/actions/update_rendered_lines_action.rb +40 -0
  217. data/lib/shoko/application/state/actions/update_selection_action.rb +27 -0
  218. data/lib/shoko/application/state/actions/update_selections_action.rb +21 -0
  219. data/lib/shoko/application/state/actions/update_sidebar_action.rb +34 -0
  220. data/lib/shoko/application/state/actions/update_ui_loading_action.rb +23 -0
  221. data/lib/shoko/application/ui/reader_view_model_builder.rb +74 -0
  222. data/lib/shoko/application/ui/view_models/reader_view_model.rb +177 -0
  223. data/lib/shoko/application/unified_application.rb +48 -0
  224. data/lib/shoko/application/use_cases/catalog_service.rb +117 -0
  225. data/lib/shoko/application/use_cases/commands/annotation_editor_commands.rb +105 -0
  226. data/lib/shoko/application/use_cases/commands/application_commands.rb +208 -0
  227. data/lib/shoko/application/use_cases/commands/base_command.rb +166 -0
  228. data/lib/shoko/application/use_cases/commands/bookmark_commands.rb +114 -0
  229. data/lib/shoko/application/use_cases/commands/conditional_navigation_commands.rb +57 -0
  230. data/lib/shoko/application/use_cases/commands/menu_commands.rb +170 -0
  231. data/lib/shoko/application/use_cases/commands/navigation_commands.rb +183 -0
  232. data/lib/shoko/application/use_cases/commands/reader_commands.rb +46 -0
  233. data/lib/shoko/application/use_cases/commands/sidebar_commands.rb +55 -0
  234. data/lib/shoko/application/use_cases/settings_service.rb +123 -0
  235. data/lib/shoko/core/events/annotation_events.rb +94 -0
  236. data/lib/shoko/core/events/base_domain_event.rb +169 -0
  237. data/lib/shoko/core/events/bookmark_events.rb +41 -0
  238. data/lib/shoko/core/events/domain_event_bus.rb +163 -0
  239. data/lib/shoko/core/events/progress_events.rb +108 -0
  240. data/lib/shoko/core/models/bookmark.rb +36 -0
  241. data/lib/shoko/core/models/bookmark_data.rb +10 -0
  242. data/lib/shoko/core/models/chapter.rb +25 -0
  243. data/lib/shoko/core/models/content_block.rb +44 -0
  244. data/lib/shoko/core/models/reader_settings.rb +20 -0
  245. data/lib/shoko/core/models/selection_anchor.rb +73 -0
  246. data/lib/shoko/core/models/toc_entry.rb +14 -0
  247. data/lib/shoko/core/ports/annotation_repository.rb +0 -0
  248. data/lib/shoko/core/ports/book_repository.rb +0 -0
  249. data/lib/shoko/core/ports/book_source.rb +0 -0
  250. data/lib/shoko/core/ports/bookmark_repository.rb +0 -0
  251. data/lib/shoko/core/ports/cache.rb +0 -0
  252. data/lib/shoko/core/ports/input_handler.rb +0 -0
  253. data/lib/shoko/core/ports/renderer.rb +0 -0
  254. data/lib/shoko/core/ports/storage.rb +0 -0
  255. data/lib/shoko/core/services/annotation_service.rb +102 -0
  256. data/lib/shoko/core/services/base_service.rb +60 -0
  257. data/lib/shoko/core/services/bookmark_service.rb +267 -0
  258. data/lib/shoko/core/services/coordinate_service.rb +265 -0
  259. data/lib/shoko/core/services/layout_service.rb +95 -0
  260. data/lib/shoko/core/services/navigation/absolute_change_applier.rb +96 -0
  261. data/lib/shoko/core/services/navigation/absolute_layout.rb +101 -0
  262. data/lib/shoko/core/services/navigation/absolute_strategy.rb +179 -0
  263. data/lib/shoko/core/services/navigation/context_builder.rb +52 -0
  264. data/lib/shoko/core/services/navigation/context_helpers.rb +63 -0
  265. data/lib/shoko/core/services/navigation/dynamic_change_applier.rb +50 -0
  266. data/lib/shoko/core/services/navigation/dynamic_strategy.rb +51 -0
  267. data/lib/shoko/core/services/navigation/image_offset_snapper.rb +150 -0
  268. data/lib/shoko/core/services/navigation/nav_context.rb +27 -0
  269. data/lib/shoko/core/services/navigation/state_updater.rb +29 -0
  270. data/lib/shoko/core/services/navigation/strategy_factory.rb +20 -0
  271. data/lib/shoko/core/services/navigation_service.rb +150 -0
  272. data/lib/shoko/core/services/page_calculator_service.rb +242 -0
  273. data/lib/shoko/core/services/pagination/internal/absolute_page_map_builder.rb +28 -0
  274. data/lib/shoko/core/services/pagination/internal/chapter_cache.rb +60 -0
  275. data/lib/shoko/core/services/pagination/internal/dynamic_page_map_builder.rb +157 -0
  276. data/lib/shoko/core/services/pagination/internal/layout_metrics_calculator.rb +73 -0
  277. data/lib/shoko/core/services/pagination/internal/page_hydrator.rb +145 -0
  278. data/lib/shoko/core/services/pagination/internal/pagination_workflow.rb +152 -0
  279. data/lib/shoko/core/services/pagination/page_info_calculator.rb +247 -0
  280. data/lib/shoko/core/services/pagination/pagination_cache_preloader.rb +173 -0
  281. data/lib/shoko/core/services/pagination/pagination_coordinator.rb +202 -0
  282. data/lib/shoko/core/services/pagination/pagination_orchestrator.rb +291 -0
  283. data/lib/shoko/core/services/pagination.rb +10 -0
  284. data/lib/shoko/core/services/progress_helper.rb +22 -0
  285. data/lib/shoko/core/services/selection_service.rb +126 -0
  286. data/lib/shoko/core/validator.rb +76 -0
  287. data/lib/shoko/shared/errors.rb +97 -0
  288. data/lib/shoko/shared/version.rb +5 -0
  289. data/lib/shoko/test_support/terminal_double.rb +175 -0
  290. data/lib/shoko/test_support/test_mode.rb +78 -0
  291. data/lib/shoko.rb +279 -0
  292. data/lib/zip.rb +732 -0
  293. data/zip.rb +5 -0
  294. metadata +370 -0
@@ -0,0 +1,1606 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ require_relative '../../../terminal/text_metrics.rb'
6
+ require_relative '../../../../../core/models/toc_entry.rb'
7
+
8
+ module Shoko
9
+ module Adapters::Output::Ui::Components
10
+ module Sidebar
11
+ SCROLLBAR_WIDTH = 1
12
+ RIGHT_MARGIN = 2
13
+
14
+ # Orchestrates rendering of all components
15
+ class ComponentOrchestrator
16
+ def initialize(context)
17
+ @context = context
18
+ end
19
+
20
+ def render
21
+ return EmptyStateRenderer.new(@context).render if @context.entries.empty?
22
+
23
+ HeaderRenderer.new(@context).render
24
+ FilterInputRenderer.new(@context).render if @context.filter_active?
25
+ EntriesListRenderer.new(@context).render
26
+ ScrollbarRenderer.new(@context).render
27
+ end
28
+ end
29
+
30
+ EntriesCache = Struct.new(:full, :visible, :visible_indices, keyword_init: true)
31
+
32
+ # Encapsulates all rendering context and state
33
+ class RenderContext
34
+ include Adapters::Output::Ui::Constants::UI
35
+
36
+ attr_reader :surface, :bounds, :state, :document, :wrap_cache
37
+
38
+ def initialize(surface, bounds, state, document, wrap_cache: nil, entries_cache: nil)
39
+ @surface = surface
40
+ @bounds = bounds
41
+ @state = state
42
+ @document = document
43
+ @wrap_cache = wrap_cache || {}
44
+ @entries_cache = entries_cache
45
+ end
46
+
47
+ def entries
48
+ return @entries if @entries
49
+ return cached_entries if @entries_cache
50
+
51
+ @entries = EntriesCalculator.new(self).calculate
52
+ end
53
+
54
+ def selected_index
55
+ @selected_index ||= SelectedIndexCalculator.new(entries).calculate
56
+ end
57
+
58
+ def filter_active?
59
+ state.get(%i[reader sidebar_toc_filter_active])
60
+ end
61
+
62
+ def filter_text
63
+ state.get(%i[reader sidebar_toc_filter]) || ''
64
+ end
65
+
66
+ def collapsed_indices
67
+ raw = state.get(%i[reader sidebar_toc_collapsed])
68
+ Array(raw).map(&:to_i)
69
+ end
70
+
71
+ def collapsed_set
72
+ @collapsed_set ||= Set.new(collapsed_indices)
73
+ end
74
+
75
+ def collapse_enabled?
76
+ !filter_active?
77
+ end
78
+
79
+ def metrics
80
+ @metrics ||= calculate_metrics
81
+ end
82
+
83
+ def write(row, col, text)
84
+ surface.write(bounds, row, col, text)
85
+ end
86
+
87
+ def scroll_metrics
88
+ @scroll_metrics ||= EntriesScrollMetrics.new(self)
89
+ end
90
+
91
+ def entries_layout
92
+ @entries_layout ||= EntriesListLayout.new(self)
93
+ end
94
+
95
+ private
96
+
97
+ def cached_entries
98
+ @entries = EntriesCollection.new(
99
+ full: @entries_cache.full,
100
+ visible: @entries_cache.visible,
101
+ visible_indices: @entries_cache.visible_indices,
102
+ selected_full_index: selected_full_index_for(@entries_cache.full)
103
+ )
104
+ end
105
+
106
+ def selected_full_index_for(entries)
107
+ raw_index = state.get(%i[reader sidebar_toc_selected]) || 0
108
+ max_index = [entries.length - 1, 0].max
109
+ raw_index.to_i.clamp(0, max_index)
110
+ end
111
+
112
+ def calculate_metrics
113
+ Metrics.new(
114
+ x: 1,
115
+ y: 1,
116
+ width: bounds.width,
117
+ height: bounds.height
118
+ )
119
+ end
120
+ end
121
+
122
+ # Calculates the selected index in visible list
123
+ class SelectedIndexCalculator
124
+ def initialize(entries)
125
+ @entries = entries
126
+ end
127
+
128
+ def calculate
129
+ selected_entry = full_entries[selected_full_index]
130
+ find_visible_index(selected_entry)
131
+ end
132
+
133
+ private
134
+
135
+ def full_entries
136
+ @entries.full
137
+ end
138
+
139
+ def visible_entries
140
+ @entries.visible
141
+ end
142
+
143
+ def selected_full_index
144
+ @entries.selected_full_index
145
+ end
146
+
147
+ def find_visible_index(selected_entry)
148
+ return 0 unless selected_entry
149
+
150
+ visible_entries.index(selected_entry) || 0
151
+ end
152
+ end
153
+
154
+ # Calculates entries collection with filtering
155
+ class EntriesCalculator
156
+ def initialize(context)
157
+ @context = context
158
+ end
159
+
160
+ def calculate
161
+ full_entries = DocumentEntriesExtractor.new(@context.document).extract
162
+ filtered = apply_filter(full_entries)
163
+ index_map = build_index_map(full_entries)
164
+ visible = apply_collapse(filtered, full_entries, index_map)
165
+ visible_indices = visible.map { |entry| index_map[entry.object_id] }.compact
166
+
167
+ EntriesCollection.new(
168
+ full: full_entries,
169
+ visible: visible,
170
+ visible_indices: visible_indices,
171
+ selected_full_index: calculate_selected_index(full_entries)
172
+ )
173
+ end
174
+
175
+ private
176
+
177
+ def apply_filter(entries)
178
+ return entries unless @context.filter_active?
179
+
180
+ EntryFilter.new(entries, @context.filter_text).filter
181
+ end
182
+
183
+ def apply_collapse(entries, full_entries, index_map)
184
+ return entries unless @context.collapse_enabled?
185
+
186
+ collapsed = @context.collapsed_set
187
+ return entries if collapsed.empty?
188
+
189
+ CollapsedEntriesFilter.new(entries, full_entries, index_map, collapsed).filter
190
+ end
191
+
192
+ def build_index_map(entries)
193
+ entries.each_with_index.to_h { |entry, idx| [entry.object_id, idx] }
194
+ end
195
+
196
+ def calculate_selected_index(entries)
197
+ raw_index = @context.state.get(%i[reader sidebar_toc_selected]) || 0
198
+ max_index = [entries.length - 1, 0].max
199
+ raw_index.to_i.clamp(0, max_index)
200
+ end
201
+ end
202
+
203
+ # Extracts entries from document
204
+ class DocumentEntriesExtractor
205
+ def initialize(document)
206
+ @document = NullDocument.wrap(document)
207
+ end
208
+
209
+ def extract
210
+ toc_entries = @document.toc_entries
211
+ return toc_entries unless toc_entries.empty?
212
+
213
+ create_fallback_entries
214
+ end
215
+
216
+ private
217
+
218
+ def create_fallback_entries
219
+ chapters = @document.chapters
220
+ FallbackEntriesBuilder.build(chapters)
221
+ end
222
+ end
223
+
224
+ # Null object pattern for missing documents
225
+ class NullDocument
226
+ EMPTY_ARRAY = [].freeze
227
+ EMPTY_HASH = {}.freeze
228
+
229
+ def self.wrap(document)
230
+ return document if document
231
+
232
+ new
233
+ end
234
+
235
+ def toc_entries
236
+ EMPTY_ARRAY
237
+ end
238
+
239
+ def chapters
240
+ EMPTY_ARRAY
241
+ end
242
+
243
+ def metadata
244
+ EMPTY_HASH
245
+ end
246
+
247
+ def title
248
+ nil
249
+ end
250
+ end
251
+
252
+ # Builds fallback entries from chapters
253
+ module FallbackEntriesBuilder
254
+ def self.build(chapters)
255
+ chapters.each_with_index.map do |chapter, idx|
256
+ create_entry(chapter, idx)
257
+ end
258
+ end
259
+
260
+ def self.create_entry(chapter, index)
261
+ Core::Models::TOCEntry.new(
262
+ title: chapter.title || "Chapter #{index + 1}",
263
+ href: nil,
264
+ level: 1,
265
+ chapter_index: index,
266
+ navigable: true
267
+ )
268
+ end
269
+ end
270
+
271
+ # Metrics for layout calculations
272
+ Metrics = Struct.new(:x, :y, :width, :height, keyword_init: true)
273
+
274
+ # Collection of entries with selection state
275
+ class EntriesCollection
276
+ attr_reader :full, :visible, :visible_indices, :selected_full_index
277
+
278
+ def initialize(full:, visible:, visible_indices:, selected_full_index:)
279
+ @full = full
280
+ @visible = visible
281
+ @visible_indices = visible_indices
282
+ @selected_full_index = selected_full_index
283
+ end
284
+
285
+ def empty?
286
+ visible.empty?
287
+ end
288
+
289
+ def count
290
+ full.length
291
+ end
292
+ end
293
+
294
+ # Resolves document from dependencies
295
+ class DocumentResolver
296
+ def initialize(dependencies)
297
+ @dependencies = NullDependencies.wrap(dependencies)
298
+ end
299
+
300
+ def resolve
301
+ @dependencies.resolve(:document)
302
+ end
303
+ end
304
+
305
+ # Null object for dependencies
306
+ class NullDependencies
307
+ def self.wrap(dependencies)
308
+ return dependencies if dependencies
309
+
310
+ new
311
+ end
312
+
313
+ def resolve(_key)
314
+ nil
315
+ end
316
+ end
317
+
318
+ # Filters TOC entries based on search term
319
+ class EntryFilter
320
+ def initialize(entries, filter_text)
321
+ @entries = entries
322
+ @filter_text = filter_text.to_s.strip
323
+ end
324
+
325
+ def filter
326
+ return @entries if @filter_text.empty?
327
+
328
+ matching_indices = MatchingIndicesFinder.new(@entries, @filter_text).find
329
+ return [] if matching_indices.empty?
330
+
331
+ select_matching_entries(matching_indices)
332
+ end
333
+
334
+ private
335
+
336
+ def select_matching_entries(matching_indices)
337
+ @entries.select.with_index { |_, idx| matching_indices.include?(idx) }
338
+ end
339
+ end
340
+
341
+ # Removes descendants of collapsed entries from the visible list
342
+ class CollapsedEntriesFilter
343
+ def initialize(entries, full_entries, index_map, collapsed)
344
+ @entries = entries
345
+ @full_entries = full_entries
346
+ @index_map = index_map
347
+ @collapsed = collapsed
348
+ end
349
+
350
+ def filter
351
+ visible = []
352
+ skip_levels = []
353
+
354
+ @entries.each do |entry|
355
+ level = entry.level
356
+ skip_levels.pop while skip_levels.any? && level <= skip_levels.last
357
+ next if skip_levels.any?
358
+
359
+ visible << entry
360
+ full_index = @index_map[entry.object_id]
361
+ next unless full_index
362
+ next unless @collapsed.include?(full_index)
363
+ next unless EntryHierarchy.children?(@full_entries, full_index)
364
+
365
+ skip_levels << level
366
+ end
367
+
368
+ visible
369
+ end
370
+ end
371
+
372
+ # Finds indices of matching entries and their ancestors
373
+ class MatchingIndicesFinder
374
+ def initialize(entries, filter_text)
375
+ @entries = entries
376
+ @filter_text = filter_text.downcase
377
+ end
378
+
379
+ def find
380
+ required = Set.new
381
+ find_matches(required)
382
+ required
383
+ end
384
+
385
+ private
386
+
387
+ def find_matches(required)
388
+ @entries.each_with_index do |entry, idx|
389
+ next unless entry_matches?(entry)
390
+
391
+ required << idx
392
+ add_ancestor_indices(idx, required)
393
+ end
394
+ end
395
+
396
+ def entry_matches?(entry)
397
+ entry.title.to_s.downcase.include?(@filter_text)
398
+ end
399
+
400
+ def add_ancestor_indices(start_idx, required)
401
+ ancestor_finder = AncestorFinder.new(@entries, start_idx)
402
+ ancestor_finder.find_all.each { |idx| required << idx }
403
+ end
404
+ end
405
+
406
+ # Finds ancestor entries in tree structure
407
+ class AncestorFinder
408
+ def initialize(entries, start_idx)
409
+ @entries = entries
410
+ @start_idx = start_idx
411
+ @start_level = entries[start_idx].level
412
+ end
413
+
414
+ def find_all
415
+ ancestors = []
416
+ tracker = LevelTracker.new(@start_level)
417
+
418
+ scan_backwards do |idx|
419
+ break if tracker.finished?
420
+
421
+ process_ancestor(idx, tracker, ancestors)
422
+ end
423
+
424
+ ancestors
425
+ end
426
+
427
+ private
428
+
429
+ def scan_backwards(&)
430
+ (@start_idx - 1).downto(0, &)
431
+ end
432
+
433
+ def process_ancestor(idx, tracker, ancestors)
434
+ ancestor_level = @entries[idx].level
435
+ return unless tracker.ancestor?(ancestor_level)
436
+
437
+ ancestors << idx
438
+ tracker.descend_to(ancestor_level)
439
+ end
440
+ end
441
+
442
+ # Tracks level traversal for ancestor finding
443
+ class LevelTracker
444
+ def initialize(start_level)
445
+ @current_level = start_level
446
+ @target_level = start_level - 1
447
+ end
448
+
449
+ def finished?
450
+ @target_level.negative?
451
+ end
452
+
453
+ def ancestor?(level)
454
+ level < @current_level
455
+ end
456
+
457
+ def descend_to(level)
458
+ @current_level = level
459
+ @target_level = level - 1
460
+ end
461
+ end
462
+
463
+ # Renders empty state message
464
+ class EmptyStateRenderer
465
+ include Adapters::Output::Ui::Constants::UI
466
+
467
+ MESSAGES = [
468
+ 'No chapters found',
469
+ '',
470
+ 'Content may still be loading',
471
+ ].freeze
472
+
473
+ def initialize(context)
474
+ @context = context
475
+ end
476
+
477
+ def render
478
+ MESSAGES.each_with_index do |message, index|
479
+ write_centered_message(message, index)
480
+ end
481
+ end
482
+
483
+ private
484
+
485
+ def write_centered_message(message, offset)
486
+ x_pos = calculate_x_position(message)
487
+ y_pos = start_y + offset
488
+ styled_text = "#{COLOR_TEXT_DIM}#{message}#{Terminal::ANSI::RESET}"
489
+
490
+ @context.write(y_pos, x_pos, styled_text)
491
+ end
492
+
493
+ def calculate_x_position(message)
494
+ msg_width = Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(message)
495
+ [(@context.metrics.width - msg_width) / 2, 2].max
496
+ end
497
+
498
+ def start_y
499
+ ((@context.metrics.height - MESSAGES.length) / 2) + 1
500
+ end
501
+ end
502
+
503
+ # Renders header with title and entry count
504
+ class HeaderRenderer
505
+ include Adapters::Output::Ui::Constants::UI
506
+
507
+ def initialize(context)
508
+ @context = context
509
+ end
510
+
511
+ def render
512
+ writer = HeaderWriter.new(@context)
513
+ writer.write_title(title_content)
514
+ writer.write_subtitle(subtitle_content) if should_show_subtitle?
515
+ writer.write_divider
516
+
517
+ @context.metrics.y + 2
518
+ end
519
+
520
+ private
521
+
522
+ def title_content
523
+ TitleExtractor.new(@context.document).extract
524
+ end
525
+
526
+ def subtitle_content
527
+ SubtitleFormatter.new(@context.entries.count).format
528
+ end
529
+
530
+ def should_show_subtitle?
531
+ subtitle_width = Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(subtitle_content.plain)
532
+ @context.metrics.width > subtitle_width + 2
533
+ end
534
+ end
535
+
536
+ # Extracts and formats title from document
537
+ class TitleExtractor
538
+ DEFAULT_TITLE = 'CONTENTS'
539
+
540
+ def initialize(document)
541
+ @document = NullDocument.wrap(document)
542
+ end
543
+
544
+ def extract
545
+ title = extract_title_text
546
+ return default_content if title.empty?
547
+
548
+ TitleContent.new(title.strip.upcase)
549
+ end
550
+
551
+ private
552
+
553
+ def default_content
554
+ @default_content ||= TitleContent.new(DEFAULT_TITLE)
555
+ end
556
+
557
+ def extract_title_text
558
+ metadata_title = @document.metadata.fetch(:title, nil)
559
+ metadata_title || @document.title || ''
560
+ end
561
+ end
562
+
563
+ # Represents styled title content
564
+ class TitleContent
565
+ include Adapters::Output::Ui::Constants::UI
566
+
567
+ attr_reader :plain
568
+
569
+ def initialize(plain_text)
570
+ @plain = plain_text
571
+ end
572
+
573
+ def styled
574
+ "#{Terminal::ANSI::BOLD}#{COLOR_TEXT_ACCENT}#{@plain}#{Terminal::ANSI::RESET}"
575
+ end
576
+
577
+ def width
578
+ Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(@plain)
579
+ end
580
+ end
581
+
582
+ # Formats subtitle with entry count
583
+ class SubtitleFormatter
584
+ def initialize(count)
585
+ @count = count
586
+ end
587
+
588
+ def format
589
+ SubtitleContent.new("#{@count} entries")
590
+ end
591
+ end
592
+
593
+ # Represents styled subtitle content
594
+ class SubtitleContent
595
+ include Adapters::Output::Ui::Constants::UI
596
+
597
+ attr_reader :plain
598
+
599
+ def initialize(plain_text)
600
+ @plain = plain_text
601
+ end
602
+
603
+ def styled
604
+ "#{COLOR_TEXT_DIM}#{@plain}#{Terminal::ANSI::RESET}"
605
+ end
606
+
607
+ def width
608
+ Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(@plain)
609
+ end
610
+ end
611
+
612
+ # Writes header components to surface
613
+ class HeaderWriter
614
+ include Adapters::Output::Ui::Constants::UI
615
+
616
+ def initialize(context)
617
+ @context = context
618
+ @metrics = context.metrics
619
+ @last_title_width = 0
620
+ end
621
+
622
+ def write_title(title_content)
623
+ @context.write(y_pos, x_pos + 1, title_content.styled)
624
+ @last_title_width = title_content.width
625
+ end
626
+
627
+ def write_subtitle(subtitle_content)
628
+ col = calculate_subtitle_column(subtitle_content)
629
+ @context.write(y_pos, col, subtitle_content.styled)
630
+ end
631
+
632
+ def write_divider
633
+ width = [@metrics.width - 2, 0].max
634
+ divider = "#{COLOR_TEXT_DIM}#{'─' * width}#{Terminal::ANSI::RESET}"
635
+ @context.write(y_pos + 1, x_pos + 1, divider)
636
+ write_right_junction
637
+ end
638
+
639
+ private
640
+
641
+ def calculate_subtitle_column(subtitle_content)
642
+ min_col = x_pos + 1 + @last_title_width + 2
643
+ right_col = x_pos + @metrics.width - subtitle_content.width - 1
644
+ [right_col, min_col].max
645
+ end
646
+
647
+ def y_pos
648
+ @metrics.y
649
+ end
650
+
651
+ def x_pos
652
+ @metrics.x
653
+ end
654
+
655
+ def write_right_junction
656
+ junction_col = x_pos + @metrics.width - 1
657
+ return if junction_col < x_pos
658
+
659
+ glyph = "#{COLOR_TEXT_DIM}┤#{Terminal::ANSI::RESET}"
660
+ @context.write(y_pos + 1, junction_col, glyph)
661
+ end
662
+ end
663
+
664
+ # Renders filter input field
665
+ class FilterInputRenderer
666
+ include Adapters::Output::Ui::Constants::UI
667
+
668
+ def initialize(context)
669
+ @context = context
670
+ end
671
+
672
+ def render
673
+ write_input_line
674
+ write_help_text
675
+ start_y + 2
676
+ end
677
+
678
+ private
679
+
680
+ def write_input_line
681
+ prompt = "#{COLOR_TEXT_ACCENT}SEARCH ▸#{Terminal::ANSI::RESET} "
682
+ @context.write(start_y, x_pos, "#{prompt}#{styled_input_text}")
683
+ end
684
+
685
+ def write_help_text
686
+ help = "#{COLOR_TEXT_DIM}ESC cancel#{Terminal::ANSI::RESET}"
687
+ @context.write(start_y + 1, x_pos, help)
688
+ end
689
+
690
+ def styled_input_text
691
+ base = "#{COLOR_TEXT_PRIMARY}#{@context.filter_text}#{Terminal::ANSI::RESET}"
692
+ cursor = @context.filter_active? ? "#{Terminal::ANSI::REVERSE} #{Terminal::ANSI::RESET}" : ''
693
+ base + cursor
694
+ end
695
+
696
+ def start_y
697
+ @context.metrics.y + 2
698
+ end
699
+
700
+ def x_pos
701
+ @context.metrics.x + 1
702
+ end
703
+ end
704
+
705
+ # Calculates layout information for TOC entries
706
+ class EntriesListLayout
707
+ attr_reader :content_start_y, :available_height, :max_width
708
+
709
+ def initialize(context)
710
+ @context = context
711
+ @content_start_y = compute_content_start_y
712
+ @available_height = compute_available_height
713
+ @max_width = compute_max_width
714
+ end
715
+
716
+ def visible_items
717
+ return [] if @context.entries.empty? || @available_height <= 0
718
+
719
+ viewport = create_viewport_config
720
+ VisibleItemsCalculator.new(
721
+ @context.entries.visible,
722
+ @context.entries.visible_indices,
723
+ @context.selected_index,
724
+ viewport,
725
+ full_entries: @context.entries.full,
726
+ collapsed_set: @context.collapsed_set,
727
+ filter_active: @context.filter_active?,
728
+ wrap_cache: @context.wrap_cache,
729
+ line_index: line_index
730
+ ).calculate
731
+ end
732
+
733
+ def item_at(row)
734
+ visible_items.find do |item|
735
+ row >= item.screen_y && row < (item.screen_y + item.visible_height)
736
+ end
737
+ end
738
+
739
+ def total_height
740
+ line_index.total_height
741
+ end
742
+
743
+ def line_index
744
+ @line_index ||= LineIndex.new(@context.entries.visible, @max_width, @context.wrap_cache)
745
+ end
746
+
747
+ private
748
+
749
+ def create_viewport_config
750
+ ViewportConfig.new(
751
+ start_y: @content_start_y,
752
+ height: @available_height,
753
+ max_width: @max_width
754
+ )
755
+ end
756
+
757
+ def compute_content_start_y
758
+ base = @context.metrics.y + 2
759
+ base += 2 if @context.filter_active?
760
+ base
761
+ end
762
+
763
+ def compute_available_height
764
+ metrics = @context.metrics
765
+ total = metrics.height - (@content_start_y - metrics.y)
766
+ [total, 0].max
767
+ end
768
+
769
+ def compute_max_width
770
+ [@context.metrics.width - 2 - SCROLLBAR_WIDTH - RIGHT_MARGIN, 0].max
771
+ end
772
+ end
773
+
774
+ # Computes scroll metrics for TOC entries within the content viewport
775
+ class EntriesScrollMetrics
776
+ attr_reader :track_start_y, :track_height, :thumb_start_y, :thumb_height, :total_items,
777
+ :total_height, :viewport_height, :viewport_start, :max_start,
778
+ :scrollbar_start_col, :scrollbar_end_col, :visible_indices,
779
+ :selected_full_index, :selected_visible_index, :navigable_indices
780
+
781
+ def initialize(context)
782
+ @context = context
783
+ @layout = context.entries_layout
784
+ @visible_entries = context.entries.visible
785
+ @visible_indices = context.entries.visible_indices
786
+ @total_items = @visible_entries.length
787
+ @total_height = @layout.total_height
788
+ @viewport_height = @layout.available_height
789
+ @scrollbar_end_col = context.metrics.width
790
+ @scrollbar_start_col = [@scrollbar_end_col - SCROLLBAR_WIDTH + 1, 1].max
791
+ @track_start_y = @layout.content_start_y
792
+ @track_height = @layout.available_height
793
+ @max_start = [@total_height - @viewport_height, 0].max
794
+ @selected_visible_index = context.selected_index
795
+ @selected_full_index = context.entries.selected_full_index
796
+ @viewport_start = calculate_viewport_start
797
+ @thumb_height = calculate_thumb_height
798
+ @thumb_start_y = calculate_thumb_start
799
+ @navigable_indices = build_navigable_indices
800
+ @nav_positions = build_nav_positions
801
+ end
802
+
803
+ def scrollable?
804
+ @track_height.positive? && @total_height.positive?
805
+ end
806
+
807
+ def absolute_scrollbar_start_col
808
+ @context.bounds.x + @scrollbar_start_col - 1
809
+ end
810
+
811
+ def absolute_scrollbar_end_col
812
+ @context.bounds.x + @scrollbar_end_col - 1
813
+ end
814
+
815
+ def absolute_track_start_y
816
+ @context.bounds.y + @track_start_y - 1
817
+ end
818
+
819
+ def absolute_track_end_y
820
+ absolute_track_start_y + @track_height - 1
821
+ end
822
+
823
+ def absolute_thumb_start_y
824
+ @context.bounds.y + @thumb_start_y - 1
825
+ end
826
+
827
+ def hit_scrollbar?(abs_col, abs_row)
828
+ return false unless scrollable?
829
+
830
+ abs_col.between?(absolute_scrollbar_start_col, absolute_scrollbar_end_col) &&
831
+ abs_row.between?(absolute_track_start_y, absolute_track_end_y)
832
+ end
833
+
834
+ def row_in_track?(abs_row)
835
+ return false unless scrollable?
836
+
837
+ abs_row.between?(absolute_track_start_y, absolute_track_end_y)
838
+ end
839
+
840
+ def hit_thumb?(abs_col, abs_row)
841
+ return false unless hit_scrollbar?(abs_col, abs_row)
842
+ return false unless @thumb_height.positive?
843
+
844
+ abs_row.between?(absolute_thumb_start_y, absolute_thumb_start_y + @thumb_height - 1)
845
+ end
846
+
847
+ def full_index_for_abs_row(abs_row)
848
+ full_index_for_row(abs_row - @context.bounds.y + 1)
849
+ end
850
+
851
+ def full_index_for_row(local_row)
852
+ return nil unless scrollable?
853
+ return nil if @visible_indices.empty?
854
+ return @visible_indices.first if @max_start <= 0 || @track_height <= 1
855
+
856
+ clamped = [local_row - @track_start_y, 0].max
857
+ clamped = [clamped, @track_height - 1].min
858
+ ratio = clamped.to_f / (@track_height - 1)
859
+ viewport_start = (ratio * @max_start).round
860
+ target_line = viewport_start + (@viewport_height / 2.0)
861
+ target_index = @layout.line_index.entry_index_for_line(target_line) || 0
862
+ @visible_indices[target_index]
863
+ end
864
+
865
+ def nav_position_for(full_index)
866
+ @nav_positions[full_index]
867
+ end
868
+
869
+ private
870
+
871
+ def calculate_viewport_start
872
+ return 0 if @total_height <= @viewport_height || @viewport_height <= 0
873
+
874
+ selected_index = @selected_visible_index.to_i.clamp(0, @visible_entries.length - 1)
875
+ selected_offset = @layout.line_index.offset_for(selected_index)
876
+ selected_height = @layout.line_index.height_for(selected_index)
877
+ selected_center = selected_offset + (selected_height / 2.0)
878
+
879
+ raw = selected_center - (@viewport_height / 2.0)
880
+ raw = [raw, 0].max
881
+ [raw.round, @max_start].min
882
+ end
883
+
884
+ def calculate_thumb_height
885
+ return 0 unless scrollable?
886
+ return @track_height if @max_start <= 0
887
+ height = (@viewport_height.to_f / @total_height) * @track_height
888
+ [height.round, 1].max
889
+ end
890
+
891
+ def calculate_thumb_start
892
+ return @track_start_y unless scrollable?
893
+ return @track_start_y if @max_start <= 0 || @track_height <= @thumb_height
894
+
895
+ offset = ((@viewport_start.to_f / @max_start) * (@track_height - @thumb_height)).round
896
+ @track_start_y + offset
897
+ end
898
+
899
+ def build_navigable_indices
900
+ navigable = []
901
+ @visible_entries.each_with_index do |entry, idx|
902
+ navigable << @visible_indices[idx] if entry&.chapter_index
903
+ end
904
+ navigable.empty? ? @visible_indices.dup : navigable
905
+ end
906
+
907
+ def build_nav_positions
908
+ positions = {}
909
+ @navigable_indices.each_with_index { |idx, pos| positions[idx] = pos }
910
+ positions
911
+ end
912
+
913
+ end
914
+
915
+ # Renders list of TOC entries
916
+ class EntriesListRenderer
917
+ def initialize(context)
918
+ @context = context
919
+ end
920
+
921
+ def render
922
+ @context.entries_layout.visible_items.each { |item| render_entry_item(item) }
923
+ end
924
+
925
+ private
926
+
927
+ def render_entry_item(item)
928
+ EntryRenderer.new(@context, item).render
929
+ end
930
+ end
931
+
932
+ # Renders a scrollbar at the right edge of the TOC content area
933
+ class ScrollbarRenderer
934
+ include Adapters::Output::Ui::Constants::UI
935
+
936
+ TRACK_CHAR = '░'
937
+ THUMB_CHAR = '█'
938
+
939
+ def initialize(context)
940
+ @context = context
941
+ end
942
+
943
+ def render
944
+ metrics = @context.scroll_metrics
945
+ return unless metrics.scrollable?
946
+
947
+ draw_track(metrics)
948
+ draw_thumb(metrics)
949
+ end
950
+
951
+ private
952
+
953
+ def draw_track(metrics)
954
+ track_end = metrics.track_start_y + metrics.track_height - 1
955
+ line = "#{COLOR_TEXT_DIM}#{TRACK_CHAR * SCROLLBAR_WIDTH}#{Terminal::ANSI::RESET}"
956
+ metrics.track_start_y.upto(track_end) do |row|
957
+ @context.write(row, metrics.scrollbar_start_col, line)
958
+ end
959
+ end
960
+
961
+ def draw_thumb(metrics)
962
+ return unless metrics.thumb_height.positive?
963
+
964
+ thumb_end = metrics.thumb_start_y + metrics.thumb_height - 1
965
+ line = "#{COLOR_TEXT_ACCENT}#{THUMB_CHAR * SCROLLBAR_WIDTH}#{Terminal::ANSI::RESET}"
966
+ metrics.thumb_start_y.upto(thumb_end) do |row|
967
+ @context.write(row, metrics.scrollbar_start_col, line)
968
+ end
969
+ end
970
+ end
971
+
972
+ # Configuration for viewport
973
+ ViewportConfig = Struct.new(:start_y, :height, :max_width, keyword_init: true)
974
+
975
+ # Calculates wrapped lines and widths for entries
976
+ class EntryLayoutHelper
977
+ def self.wrap_lines(entry, max_width, wrap_cache)
978
+ width = available_width(entry, max_width)
979
+ return [''] if width <= 0
980
+
981
+ cache = wrap_cache
982
+ key = [entry.object_id, width]
983
+ if cache
984
+ cache[key] ||= Shoko::Adapters::Output::Terminal::TextMetrics.wrap_plain_text(formatted_title(entry), width)
985
+ else
986
+ Shoko::Adapters::Output::Terminal::TextMetrics.wrap_plain_text(formatted_title(entry), width)
987
+ end
988
+ end
989
+
990
+ def self.line_count(entry, max_width, wrap_cache)
991
+ wrap_lines(entry, max_width, wrap_cache).length
992
+ end
993
+
994
+ def self.available_width(entry, max_width)
995
+ width = max_width - width_without_title(entry)
996
+ [width, 0].max
997
+ end
998
+
999
+ def self.width_without_title(entry)
1000
+ level = entry.level.to_i
1001
+ level = 0 if level.negative?
1002
+ (level * 2) + 2
1003
+ end
1004
+
1005
+ def self.formatted_title(entry)
1006
+ EntryTitleFormatter.format(entry)
1007
+ end
1008
+
1009
+ private_class_method :available_width, :width_without_title, :formatted_title
1010
+ end
1011
+
1012
+ # Precomputes line offsets for variable-height entries
1013
+ class LineIndex
1014
+ attr_reader :total_height
1015
+
1016
+ def initialize(entries, max_width, wrap_cache)
1017
+ @offsets = []
1018
+ @heights = []
1019
+ total = 0
1020
+
1021
+ entries.each do |entry|
1022
+ @offsets << total
1023
+ height = EntryLayoutHelper.line_count(entry, max_width, wrap_cache)
1024
+ @heights << height
1025
+ total += height
1026
+ end
1027
+
1028
+ @total_height = total
1029
+ end
1030
+
1031
+ def height_for(index)
1032
+ @heights[index] || 0
1033
+ end
1034
+
1035
+ def offset_for(index)
1036
+ @offsets[index] || 0
1037
+ end
1038
+
1039
+ def entry_index_for_line(line)
1040
+ return nil if @offsets.empty?
1041
+ return 0 if @total_height <= 0
1042
+
1043
+ line = line.to_i
1044
+ line = 0 if line.negative?
1045
+ line = @total_height - 1 if line >= @total_height
1046
+
1047
+ low = 0
1048
+ high = @offsets.length - 1
1049
+ while low <= high
1050
+ mid = (low + high) / 2
1051
+ if @offsets[mid] <= line
1052
+ return mid if mid == @offsets.length - 1 || @offsets[mid + 1] > line
1053
+
1054
+ low = mid + 1
1055
+ else
1056
+ high = mid - 1
1057
+ end
1058
+ end
1059
+
1060
+ 0
1061
+ end
1062
+ end
1063
+
1064
+ # Calculates which entries are visible in viewport
1065
+ class VisibleItemsCalculator
1066
+ def initialize(entries, visible_indices, selected_index, viewport, full_entries:,
1067
+ collapsed_set:, filter_active:, wrap_cache:, line_index:)
1068
+ @entries = entries
1069
+ @visible_indices = visible_indices
1070
+ @selected_index = selected_index
1071
+ @viewport = viewport
1072
+ @full_entries = full_entries
1073
+ @collapsed_set = collapsed_set
1074
+ @filter_active = filter_active
1075
+ @wrap_cache = wrap_cache
1076
+ @line_index = line_index
1077
+ end
1078
+
1079
+ def calculate
1080
+ return [] if @entries.empty? || @viewport.height <= 0
1081
+ return [] if @line_index.total_height <= 0
1082
+
1083
+ viewport_start = viewport_start_line
1084
+ start_index = @line_index.entry_index_for_line(viewport_start) || 0
1085
+ start_offset = viewport_start - @line_index.offset_for(start_index)
1086
+ items = []
1087
+ remaining = @viewport.height
1088
+ screen_y = @viewport.start_y
1089
+ idx = start_index
1090
+ offset = start_offset
1091
+
1092
+ while idx < @entries.length && remaining.positive?
1093
+ entry = @entries[idx]
1094
+ full_index = @visible_indices[idx]
1095
+ config = ItemConfig.new(
1096
+ item_entries: @entries,
1097
+ entry: entry,
1098
+ index: idx,
1099
+ full_index: full_index,
1100
+ selected_index: @selected_index,
1101
+ max_width: @viewport.max_width,
1102
+ full_entries: @full_entries,
1103
+ collapsed_set: @collapsed_set,
1104
+ filter_active: @filter_active,
1105
+ wrap_cache: @wrap_cache
1106
+ )
1107
+ item = VisibleEntryItem.new(config)
1108
+ height = item.height
1109
+ visible_height = [height - offset, remaining].min
1110
+ items << item.with_screen_position(screen_y, offset, visible_height)
1111
+ screen_y += visible_height
1112
+ remaining -= visible_height
1113
+ offset = 0
1114
+ idx += 1
1115
+ end
1116
+
1117
+ items
1118
+ end
1119
+
1120
+ private
1121
+
1122
+ def viewport_start_line
1123
+ total_height = @line_index.total_height
1124
+ return 0 if total_height <= @viewport.height || @viewport.height <= 0
1125
+
1126
+ selected_index = @selected_index.to_i.clamp(0, @entries.length - 1)
1127
+ selected_offset = @line_index.offset_for(selected_index)
1128
+ selected_height = @line_index.height_for(selected_index)
1129
+ selected_center = selected_offset + (selected_height / 2.0)
1130
+
1131
+ raw_start = selected_center - (@viewport.height / 2.0)
1132
+ raw_start = [raw_start, 0].max
1133
+ max_start = [total_height - @viewport.height, 0].max
1134
+ [raw_start.round, max_start].min
1135
+ end
1136
+ end
1137
+
1138
+ # Configuration for creating visible entry items
1139
+ ItemConfig = Struct.new(
1140
+ :item_entries, :entry, :index, :full_index, :selected_index, :max_width,
1141
+ :full_entries, :collapsed_set, :filter_active, :wrap_cache,
1142
+ keyword_init: true
1143
+ )
1144
+
1145
+ # Represents a single entry item with rendering info
1146
+ class VisibleEntryItem
1147
+ attr_reader :entry, :index, :full_index, :max_width
1148
+
1149
+ def initialize(config)
1150
+ @config = config
1151
+ @entry = config.entry
1152
+ @index = config.index
1153
+ @full_index = config.full_index
1154
+ @max_width = config.max_width
1155
+ end
1156
+
1157
+ def with_screen_position(screen_y, start_offset, visible_height)
1158
+ PositionedEntryItem.new(self, screen_y, start_offset, visible_height)
1159
+ end
1160
+
1161
+ def selected?
1162
+ index == @config.selected_index
1163
+ end
1164
+
1165
+ def height
1166
+ wrapped_lines.length
1167
+ end
1168
+
1169
+ def wrapped_lines
1170
+ @wrapped_lines ||= EntryLayoutHelper.wrap_lines(@entry, @max_width, @config.wrap_cache)
1171
+ end
1172
+
1173
+ def components
1174
+ @components ||= EntryComponents.new(
1175
+ @config.item_entries,
1176
+ @entry,
1177
+ @index,
1178
+ full_entries: @config.full_entries,
1179
+ full_index: @config.full_index,
1180
+ collapsed_set: @config.collapsed_set,
1181
+ filter_active: @config.filter_active
1182
+ )
1183
+ end
1184
+
1185
+ end
1186
+
1187
+ # Item with screen position
1188
+ class PositionedEntryItem
1189
+ attr_reader :screen_y, :start_offset, :visible_height
1190
+
1191
+ def initialize(item, screen_y, start_offset, visible_height)
1192
+ @item = item
1193
+ @screen_y = screen_y
1194
+ @start_offset = start_offset
1195
+ @visible_height = visible_height
1196
+ end
1197
+
1198
+ def entry
1199
+ @item.entry
1200
+ end
1201
+
1202
+ def index
1203
+ @item.index
1204
+ end
1205
+
1206
+ def full_index
1207
+ @item.full_index
1208
+ end
1209
+
1210
+ def max_width
1211
+ @item.max_width
1212
+ end
1213
+
1214
+ def selected?
1215
+ @item.selected?
1216
+ end
1217
+
1218
+ def height
1219
+ @visible_height
1220
+ end
1221
+
1222
+ def wrapped_lines
1223
+ @item.wrapped_lines
1224
+ end
1225
+
1226
+ def components
1227
+ @item.components
1228
+ end
1229
+ end
1230
+
1231
+ # Renders a single TOC entry
1232
+ class EntryRenderer
1233
+ include Adapters::Output::Ui::Constants::UI
1234
+
1235
+ def initialize(context, item)
1236
+ @context = context
1237
+ @item = item
1238
+ end
1239
+
1240
+ def render
1241
+ render_lines
1242
+ end
1243
+
1244
+ private
1245
+
1246
+ def render_lines
1247
+ formatter = EntryFormatter.new(@item)
1248
+ lines = formatter.lines
1249
+ start = @item.start_offset
1250
+ visible = @item.visible_height
1251
+ lines_to_render = lines.slice(start, visible) || []
1252
+
1253
+ lines_to_render.each_with_index do |line, offset|
1254
+ y_pos = @item.screen_y + offset
1255
+ write_gutter(y_pos)
1256
+ write_content(y_pos, line)
1257
+ end
1258
+ end
1259
+
1260
+ def write_gutter(y_pos)
1261
+ gutter = gutter_symbol + Terminal::ANSI::RESET
1262
+ @context.write(y_pos, @context.metrics.x, gutter)
1263
+ end
1264
+
1265
+ def gutter_symbol
1266
+ @item.selected? ? "#{COLOR_TEXT_ACCENT}│" : "#{COLOR_TEXT_DIM}│"
1267
+ end
1268
+
1269
+ def write_content(y_pos, line)
1270
+ @context.write(y_pos, @context.metrics.x + 2, line)
1271
+ end
1272
+ end
1273
+
1274
+ # Formats entry text with tree structure
1275
+ class EntryFormatter
1276
+ include Adapters::Output::Ui::Constants::UI
1277
+
1278
+ def initialize(item)
1279
+ @item = item
1280
+ @components = item.components
1281
+ end
1282
+
1283
+ def lines
1284
+ builder = EntryLineBuilder.new(@components, @item.wrapped_lines)
1285
+ @item.selected? ? builder.build_selected : builder.build
1286
+ end
1287
+ end
1288
+
1289
+ # Builds multi-line entry strings
1290
+ class EntryLineBuilder
1291
+ include Adapters::Output::Ui::Constants::UI
1292
+
1293
+ def initialize(components, wrapped_lines)
1294
+ @components = components
1295
+ @entry = components.entry
1296
+ @wrapped_lines = wrapped_lines
1297
+ end
1298
+
1299
+ def build
1300
+ build_lines { |line, idx| format_line(line, idx) }
1301
+ end
1302
+
1303
+ def build_selected
1304
+ build_lines { |line, idx| format_selected_line(line, idx) }
1305
+ end
1306
+
1307
+ private
1308
+
1309
+ def build_lines
1310
+ @wrapped_lines.map.with_index do |line, idx|
1311
+ yield(line, idx)
1312
+ end
1313
+ end
1314
+
1315
+ def format_line(line, idx)
1316
+ idx.zero? ? format_first_line(line) : format_continuation_line(line)
1317
+ end
1318
+
1319
+ def format_selected_line(line, idx)
1320
+ plain = idx.zero? ? plain_first_line(line) : plain_continuation_line(line)
1321
+ "#{Terminal::ANSI::BG_GREY}#{Terminal::ANSI::WHITE}#{plain}#{Terminal::ANSI::RESET}"
1322
+ end
1323
+
1324
+ def format_first_line(line)
1325
+ parts = []
1326
+ prefix = @components.prefix
1327
+ parts << colorize(prefix, COLOR_TEXT_DIM) unless prefix.empty?
1328
+
1329
+ if @components.icon_present?
1330
+ parts << colorize(@components.icon, EntryStyler.icon_color(@entry))
1331
+ parts << ' '
1332
+ end
1333
+
1334
+ parts << colorize(line, EntryStyler.title_color(@entry))
1335
+ parts.join
1336
+ end
1337
+
1338
+ def format_continuation_line(line)
1339
+ prefix = @components.continuation_prefix
1340
+ styled_prefix = prefix.empty? ? '' : colorize(prefix, COLOR_TEXT_DIM)
1341
+ "#{styled_prefix}#{colorize(line, EntryStyler.title_color(@entry))}"
1342
+ end
1343
+
1344
+ def plain_first_line(line)
1345
+ spacer = @components.icon_present? ? ' ' : ''
1346
+ "#{@components.prefix}#{@components.icon}#{spacer}#{line}"
1347
+ end
1348
+
1349
+ def plain_continuation_line(line)
1350
+ "#{@components.continuation_prefix}#{line}"
1351
+ end
1352
+
1353
+ def colorize(text, color)
1354
+ return text if text.empty? || color.nil?
1355
+
1356
+ "#{color}#{text}#{Terminal::ANSI::RESET}"
1357
+ end
1358
+ end
1359
+
1360
+ # Calculates components of an entry (prefix, icon, title)
1361
+ class EntryComponents
1362
+ attr_reader :prefix, :icon, :title, :entry, :continuation_prefix
1363
+
1364
+ def initialize(item_entries, entry, index, full_entries:, full_index:, collapsed_set:, filter_active:)
1365
+ @entry = entry
1366
+ @prefix = TreeFormatter.prefix(item_entries, index, entry.level)
1367
+ @icon = IconSelector.select(
1368
+ full_entries,
1369
+ entry,
1370
+ full_index,
1371
+ collapsed_set: collapsed_set,
1372
+ filter_active: filter_active
1373
+ )
1374
+ @title = EntryTitleFormatter.format(entry)
1375
+ @continuation_prefix = IndentCalculator.new(
1376
+ item_entries,
1377
+ index,
1378
+ entry.level,
1379
+ icon_present: icon_present?
1380
+ ).build
1381
+ end
1382
+
1383
+ def icon_present?
1384
+ !@icon.empty?
1385
+ end
1386
+
1387
+ def width_without_title
1388
+ prefix_width + icon_width + spacer_width
1389
+ end
1390
+
1391
+ private
1392
+
1393
+ def spacer_width
1394
+ icon_present? ? 1 : 0
1395
+ end
1396
+
1397
+ def prefix_width
1398
+ Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(@prefix)
1399
+ end
1400
+
1401
+ def icon_width
1402
+ Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(@icon)
1403
+ end
1404
+ end
1405
+
1406
+ # Formats entry titles
1407
+ module EntryTitleFormatter
1408
+ def self.format(entry)
1409
+ text = entry.title || 'Untitled'
1410
+ entry.level.zero? ? text.upcase : text
1411
+ end
1412
+ end
1413
+
1414
+ # Formats tree structure prefix for entries
1415
+ class TreeFormatter
1416
+ def self.prefix(item_entries, index, level)
1417
+ return '' if level <= 0
1418
+
1419
+ PrefixBuilder.new(item_entries, index, level).build
1420
+ end
1421
+
1422
+ def self.continuation_prefix(item_entries, index, level)
1423
+ return '' if level <= 0
1424
+
1425
+ ContinuationPrefixBuilder.new(item_entries, index, level).build
1426
+ end
1427
+ end
1428
+
1429
+ # Calculates indentation for wrapped lines
1430
+ class IndentCalculator
1431
+ def initialize(item_entries, index, level, icon_present:)
1432
+ @item_entries = item_entries
1433
+ @index = index
1434
+ @level = level
1435
+ @icon_present = icon_present
1436
+ end
1437
+
1438
+ def build
1439
+ prefix = TreeFormatter.continuation_prefix(@item_entries, @index, @level)
1440
+ prefix + (@icon_present ? ' ' : '')
1441
+ end
1442
+ end
1443
+
1444
+ # Builds continuation prefix from segments
1445
+ class ContinuationPrefixBuilder
1446
+ def initialize(item_entries, index, level)
1447
+ @item_entries = item_entries
1448
+ @index = index
1449
+ @level = level
1450
+ end
1451
+
1452
+ def build
1453
+ (1..@level).map { |depth| segment_for_depth(depth) }.join
1454
+ end
1455
+
1456
+ private
1457
+
1458
+ def segment_for_depth(depth)
1459
+ TreeAnalyzer.ancestor_continues?(@item_entries, @index, depth) ? '│ ' : ' '
1460
+ end
1461
+ end
1462
+
1463
+ # Builds tree prefix from segments
1464
+ class PrefixBuilder
1465
+ def initialize(item_entries, index, level)
1466
+ @item_entries = item_entries
1467
+ @index = index
1468
+ @level = level
1469
+ end
1470
+
1471
+ def build
1472
+ (1..@level).map { |depth| segment_for_depth(depth) }.join
1473
+ end
1474
+
1475
+ private
1476
+
1477
+ def segment_for_depth(depth)
1478
+ TreeSegment.new(@item_entries, @index, depth, @level).format
1479
+ end
1480
+ end
1481
+
1482
+ # Represents a single tree segment
1483
+ class TreeSegment
1484
+ def initialize(item_entries, index, depth, current_level)
1485
+ @item_entries = item_entries
1486
+ @index = index
1487
+ @depth = depth
1488
+ @current_level = current_level
1489
+ end
1490
+
1491
+ def format
1492
+ at_current_level? ? branch_segment : continuation_segment
1493
+ end
1494
+
1495
+ private
1496
+
1497
+ def at_current_level?
1498
+ @depth == @current_level
1499
+ end
1500
+
1501
+ def branch_segment
1502
+ TreeAnalyzer.last_child?(@item_entries, @index) ? '└─' : '├─'
1503
+ end
1504
+
1505
+ def continuation_segment
1506
+ TreeAnalyzer.ancestor_continues?(@item_entries, @index, @depth) ? '│ ' : ' '
1507
+ end
1508
+ end
1509
+
1510
+ # Analyzes tree structure relationships
1511
+ class TreeAnalyzer
1512
+ def self.last_child?(item_entries, index)
1513
+ analyzer = SiblingAnalyzer.new(item_entries, index)
1514
+ analyzer.last_child?
1515
+ end
1516
+
1517
+ def self.ancestor_continues?(item_entries, index, depth)
1518
+ analyzer = AncestorContinuationAnalyzer.new(item_entries, index, depth)
1519
+ analyzer.continues?
1520
+ end
1521
+ end
1522
+
1523
+ # Analyzes sibling relationships
1524
+ class SiblingAnalyzer
1525
+ def initialize(item_entries, index)
1526
+ @item_entries = item_entries
1527
+ @index = index
1528
+ @current_level = item_entries[index].level
1529
+ end
1530
+
1531
+ def last_child?
1532
+ (@index + 1).upto(@item_entries.length - 1) do |next_index|
1533
+ next_level = @item_entries[next_index].level
1534
+ return false if next_level == @current_level
1535
+ return true if next_level < @current_level
1536
+ end
1537
+
1538
+ true
1539
+ end
1540
+ end
1541
+
1542
+ # Analyzes ancestor continuation
1543
+ class AncestorContinuationAnalyzer
1544
+ def initialize(item_entries, index, depth)
1545
+ @item_entries = item_entries
1546
+ @index = index
1547
+ @depth = depth
1548
+ end
1549
+
1550
+ def continues?
1551
+ (@index + 1).upto(@item_entries.length - 1) do |next_index|
1552
+ next_level = @item_entries[next_index].level
1553
+ return true if next_level == @depth
1554
+ return false if next_level < @depth
1555
+ end
1556
+
1557
+ false
1558
+ end
1559
+ end
1560
+
1561
+ # Provides hierarchy helpers for TOC entries
1562
+ class EntryHierarchy
1563
+ def self.children?(entries, index)
1564
+ next_entry = entries[index + 1]
1565
+ return false unless next_entry
1566
+
1567
+ next_entry.level > entries[index].level
1568
+ end
1569
+ end
1570
+
1571
+ # Selects appropriate icon for entry
1572
+ class IconSelector
1573
+ def self.select(full_entries, _entry, full_index, collapsed_set:, filter_active:)
1574
+ return ' ' unless EntryHierarchy.children?(full_entries, full_index)
1575
+
1576
+ collapsed = !filter_active && collapsed_set.include?(full_index)
1577
+ collapsed ? '▶' : '▼'
1578
+ end
1579
+ end
1580
+
1581
+ # Provides styling colors for entries
1582
+ class EntryStyler
1583
+ include Adapters::Output::Ui::Constants::UI
1584
+
1585
+ def self.icon_color(entry)
1586
+ ICON_COLORS[entry.level] || COLOR_TEXT_DIM
1587
+ end
1588
+
1589
+ def self.title_color(entry)
1590
+ TITLE_COLORS[entry.level] || COLOR_TEXT_SECONDARY
1591
+ end
1592
+
1593
+ ICON_COLORS = {
1594
+ 0 => COLOR_TEXT_ACCENT,
1595
+ 1 => COLOR_TEXT_SECONDARY,
1596
+ }.freeze
1597
+
1598
+ TITLE_COLORS = {
1599
+ 0 => "#{Terminal::ANSI::BOLD}#{COLOR_TEXT_PRIMARY}",
1600
+ 1 => COLOR_TEXT_PRIMARY,
1601
+ }.freeze
1602
+ end
1603
+
1604
+ end
1605
+ end
1606
+ end