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,782 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../adapters/output/ui/components/annotations_overlay_component.rb'
4
+ require_relative '../../adapters/output/ui/components/annotation_editor_overlay_component.rb'
5
+
6
+ module Shoko
7
+ module Application::Controllers
8
+ # Handles all UI-related functionality: modes, overlays, popups, sidebar
9
+ class UIController
10
+ # Raised when required dependencies are missing for a UI action.
11
+ class MissingDependencyError < StandardError; end
12
+
13
+ # Builds the annotation editor screen component for annotation editor mode.
14
+ class AnnotationEditorMode
15
+ def initialize(controller, dependencies)
16
+ @controller = controller
17
+ @dependencies = dependencies
18
+ end
19
+
20
+ def build_component(**)
21
+ Shoko::Adapters::Output::Ui::Components::Screens::AnnotationEditorScreenComponent.new(
22
+ @controller,
23
+ **,
24
+ dependencies: @dependencies
25
+ )
26
+ end
27
+ end
28
+
29
+ def initialize(state, dependencies)
30
+ @state = state
31
+ @dependencies = dependencies
32
+ @current_mode = nil
33
+ end
34
+
35
+ def switch_mode(mode, **)
36
+ annotation_editor_mode =
37
+ mode == :annotation_editor ? AnnotationEditorMode.new(self, @dependencies) : nil
38
+ close_annotations_overlay unless annotation_editor_mode
39
+ close_annotation_editor_overlay unless annotation_editor_mode
40
+ @state.dispatch(Shoko::Application::Actions::UpdateReaderModeAction.new(mode))
41
+
42
+ # Rendered via screen/sidebar components; no standalone mode component
43
+ @current_mode = annotation_editor_mode&.build_component(**)
44
+
45
+ # Keep input dispatcher in sync with mode to prevent cross-mode key leaks
46
+ begin
47
+ input_controller = @dependencies.resolve(:input_controller)
48
+ input_controller.activate_for_mode(mode) if input_controller.respond_to?(:activate_for_mode)
49
+ rescue StandardError
50
+ # If not available, ignore; read mode remains default
51
+ end
52
+ end
53
+
54
+ def open_toc
55
+ toggle_sidebar(:toc)
56
+ rescue StandardError => e
57
+ set_message("TOC error: #{e.message}", 3)
58
+ end
59
+
60
+ def open_bookmarks
61
+ toggle_sidebar(:bookmarks)
62
+ end
63
+
64
+ def open_annotations_tab
65
+ toggle_sidebar(:annotations)
66
+ end
67
+
68
+ def open_annotations
69
+ overlay = Application::Selectors::ReaderSelectors.annotations_overlay(@state)
70
+ if overlay&.visible?
71
+ close_annotations_overlay
72
+ else
73
+ show_annotations_overlay
74
+ end
75
+ end
76
+
77
+ def open_annotation_editor_overlay(text:, range:, chapter_index:, annotation: nil)
78
+ show_annotation_editor_overlay(text: text,
79
+ range: range,
80
+ chapter_index: chapter_index,
81
+ annotation: annotation)
82
+ end
83
+
84
+ private
85
+
86
+ # Unified sidebar toggling for :toc, :annotations, :bookmarks
87
+ def toggle_sidebar(tab)
88
+ close_annotations_overlay
89
+ if sidebar_visible?
90
+ return close_sidebar_with_restore(tab) if sidebar_open_for?(tab)
91
+
92
+ switch_sidebar_tab(tab)
93
+ else
94
+ open_sidebar_for(tab)
95
+ end
96
+ end
97
+
98
+ def sidebar_open_for?(tab)
99
+ @state.get(%i[reader sidebar_visible]) &&
100
+ @state.get(%i[reader sidebar_active_tab]) == tab
101
+ end
102
+
103
+ def close_sidebar_with_restore(tab)
104
+ prev_mode = @state.get(%i[reader sidebar_prev_view_mode])
105
+ if prev_mode
106
+ @state.dispatch(
107
+ Shoko::Application::Actions::UpdateConfigAction.new(view_mode: prev_mode)
108
+ )
109
+ @state.dispatch(
110
+ Shoko::Application::Actions::UpdateSelectionsAction.new(sidebar_prev_view_mode: nil)
111
+ )
112
+ end
113
+ @state.dispatch(Shoko::Application::Actions::UpdateSidebarAction.new(visible: false))
114
+ @state.dispatch(Shoko::Application::Actions::UpdateReaderModeAction.new(:read))
115
+ set_message("#{tab.to_s.capitalize} closed", 1) unless tab == :toc
116
+ end
117
+
118
+ def open_sidebar_for(tab)
119
+ # Store current view and force single-page view
120
+ @state.dispatch(
121
+ Shoko::Application::Actions::UpdateSelectionsAction.new(
122
+ sidebar_prev_view_mode: @state.get(%i[config view_mode])
123
+ )
124
+ )
125
+ @state.dispatch(
126
+ Shoko::Application::Actions::UpdateConfigAction.new(view_mode: :single)
127
+ )
128
+
129
+ updates = { active_tab: tab, visible: true }
130
+ case tab
131
+ when :toc
132
+ doc = safe_resolve(:document)
133
+ entries = toc_entries_for(doc)
134
+ collapsed = toc_collapsed_for(entries)
135
+ current_chapter = (@state.get(%i[reader current_chapter]) || 0).to_i
136
+ selected = toc_index_for_chapter(entries, current_chapter)
137
+ updates[:toc_collapsed] = collapsed
138
+ updates[:toc_selected] = ensure_visible_toc_selection(entries, collapsed, selected)
139
+ when :annotations
140
+ updates[:annotations_selected] =
141
+ @state.get(%i[reader sidebar_annotations_selected]) || 0
142
+ when :bookmarks
143
+ updates[:bookmarks_selected] =
144
+ @state.get(%i[reader sidebar_bookmarks_selected]) || 0
145
+ end
146
+
147
+ @state.dispatch(Shoko::Application::Actions::UpdateSidebarAction.new(updates))
148
+ @state.dispatch(Shoko::Application::Actions::UpdateReaderModeAction.new(:read))
149
+ set_message("#{tab.to_s.capitalize} opened", 1) unless tab == :toc
150
+ end
151
+
152
+ def switch_sidebar_tab(tab)
153
+ return unless sidebar_visible?
154
+
155
+ current_tab = @state.get(%i[reader sidebar_active_tab])
156
+ return if current_tab == tab
157
+
158
+ updates = { active_tab: tab }
159
+ case tab
160
+ when :toc
161
+ doc = safe_resolve(:document)
162
+ entries = toc_entries_for(doc)
163
+ collapsed = toc_collapsed_for(entries)
164
+ selected = @state.get(%i[reader sidebar_toc_selected])
165
+ if selected.nil?
166
+ current_chapter = (@state.get(%i[reader current_chapter]) || 0).to_i
167
+ selected = toc_index_for_chapter(entries, current_chapter)
168
+ end
169
+ updates[:toc_collapsed] = collapsed
170
+ updates[:toc_selected] = ensure_visible_toc_selection(entries, collapsed, selected)
171
+ when :annotations
172
+ updates[:annotations_selected] = @state.get(%i[reader sidebar_annotations_selected]) || 0
173
+ when :bookmarks
174
+ updates[:bookmarks_selected] = @state.get(%i[reader sidebar_bookmarks_selected]) || 0
175
+ end
176
+
177
+ @state.dispatch(Shoko::Application::Actions::UpdateSidebarAction.new(updates))
178
+ end
179
+
180
+ public
181
+
182
+ def activate_sidebar_tab(tab)
183
+ if sidebar_visible?
184
+ switch_sidebar_tab(tab)
185
+ else
186
+ open_sidebar_for(tab)
187
+ end
188
+ rescue StandardError => e
189
+ set_message("Sidebar error: #{e.message}", 3)
190
+ end
191
+
192
+ def handle_sidebar_toc_click(index)
193
+ return unless sidebar_visible?
194
+ return unless index.is_a?(Integer)
195
+
196
+ doc = safe_resolve(:document)
197
+ entries = toc_entries_for(doc)
198
+ return if entries.empty?
199
+ return unless index.between?(0, entries.length - 1)
200
+
201
+ collapsed = toc_collapsed_for(entries)
202
+ updates = { toc_selected: index }
203
+
204
+ if toc_entry_has_children?(entries, index)
205
+ collapsed = toggle_toc_collapsed(collapsed, index)
206
+ updates[:toc_collapsed] = collapsed
207
+ updates[:toc_selected] = ensure_visible_toc_selection(entries, collapsed, index)
208
+ end
209
+
210
+ @state.dispatch(Shoko::Application::Actions::UpdateSidebarAction.new(updates))
211
+ end
212
+
213
+ def set_sidebar_toc_selected(index)
214
+ return unless sidebar_visible?
215
+
216
+ doc = safe_resolve(:document)
217
+ entries = toc_entries_for(doc)
218
+ return if entries.empty?
219
+
220
+ idx = index.to_i.clamp(0, entries.length - 1)
221
+ collapsed = toc_collapsed_for(entries)
222
+ idx = ensure_visible_toc_selection(entries, collapsed, idx)
223
+
224
+ updates = { toc_selected: idx }
225
+ updates[:toc_collapsed] = collapsed if collapsed != @state.get(%i[reader sidebar_toc_collapsed])
226
+ @state.dispatch(Shoko::Application::Actions::UpdateSidebarAction.new(updates))
227
+ end
228
+
229
+ def show_help
230
+ switch_mode(:help)
231
+ end
232
+
233
+ def toggle_view_mode
234
+ @state.dispatch(Shoko::Application::Actions::ToggleViewModeAction.new)
235
+ end
236
+
237
+ def increase_line_spacing
238
+ modes = %i[compact normal relaxed]
239
+ current = modes.index(@state.get(%i[config line_spacing])) || 1
240
+ return unless current < 2
241
+
242
+ @state.dispatch(Shoko::Application::Actions::UpdateConfigAction.new(line_spacing: modes[current + 1]))
243
+ @state.dispatch(Shoko::Application::Actions::UpdatePageAction.new(last_width: 0))
244
+ end
245
+
246
+ def decrease_line_spacing
247
+ modes = %i[compact normal relaxed]
248
+ current = modes.index(@state.get(%i[config line_spacing])) || 1
249
+ return unless current.positive?
250
+
251
+ @state.dispatch(Shoko::Application::Actions::UpdateConfigAction.new(line_spacing: modes[current - 1]))
252
+ @state.dispatch(Shoko::Application::Actions::UpdatePageAction.new(last_width: 0))
253
+ end
254
+
255
+ def toggle_page_numbering_mode
256
+ current_mode = @state.get(%i[config page_numbering_mode])
257
+ new_mode = current_mode == :absolute ? :dynamic : :absolute
258
+ @state.dispatch(Shoko::Application::Actions::UpdateConfigAction.new(page_numbering_mode: new_mode))
259
+ set_message("Page numbering: #{new_mode}")
260
+ end
261
+
262
+ # Sidebar navigation helpers
263
+ def sidebar_down
264
+ update_sidebar_selection(+1)
265
+ end
266
+
267
+ def sidebar_up
268
+ update_sidebar_selection(-1)
269
+ end
270
+
271
+ def sidebar_select
272
+ return unless sidebar_visible?
273
+
274
+ case @state.get(%i[reader sidebar_active_tab])
275
+ when :toc
276
+ doc = safe_resolve(:document)
277
+ entries = toc_entries_for(doc)
278
+ selected_entry_index = (@state.get(%i[reader sidebar_toc_selected]) || 0).to_i
279
+ selected_entry_index = selected_entry_index.clamp(0, [entries.length - 1, 0].max)
280
+ chapter_index = entries[selected_entry_index]&.chapter_index
281
+ return unless chapter_index
282
+
283
+ nav_service = @dependencies.resolve(:navigation_service)
284
+ nav_service.jump_to_chapter(chapter_index)
285
+
286
+ # Close the sidebar and restore previous view mode if it was stored
287
+ prev_mode = @state.get(%i[reader sidebar_prev_view_mode])
288
+ if prev_mode
289
+ @state.dispatch(Shoko::Application::Actions::UpdateConfigAction.new(view_mode: prev_mode))
290
+ @state.dispatch(Shoko::Application::Actions::UpdateSelectionsAction.new(sidebar_prev_view_mode: nil))
291
+ end
292
+ @state.dispatch(Shoko::Application::Actions::UpdateSidebarAction.new(visible: false))
293
+ @state.dispatch(Shoko::Application::Actions::UpdateReaderModeAction.new(:read))
294
+ when :bookmarks
295
+ bookmarks = @state.get(%i[reader bookmarks]) || []
296
+ selected = (@state.get(%i[reader sidebar_bookmarks_selected]) || 0).to_i
297
+ selected = selected.clamp(0, [bookmarks.length - 1, 0].max)
298
+ bookmark = bookmarks[selected]
299
+ return unless bookmark
300
+
301
+ bookmark_service = safe_resolve(:bookmark_service)
302
+ if bookmark_service
303
+ bookmark_service.jump_to_bookmark(bookmark)
304
+ safe_resolve(:state_controller)&.save_progress
305
+ end
306
+ close_sidebar_with_restore(:bookmarks)
307
+ when :annotations
308
+ annotations = @state.get(%i[reader annotations]) || []
309
+ selected = (@state.get(%i[reader sidebar_annotations_selected]) || 0).to_i
310
+ selected = selected.clamp(0, [annotations.length - 1, 0].max)
311
+ annotation = annotations[selected]
312
+ return unless annotation
313
+
314
+ state_controller = safe_resolve(:state_controller)
315
+ state_controller&.jump_to_annotation(annotation)
316
+ close_sidebar_with_restore(:annotations)
317
+ end
318
+ end
319
+
320
+ def handle_popup_action(action_data)
321
+ # Handle both old string-based actions and new action objects
322
+ action_type = action_data.is_a?(Hash) ? action_data[:action] : action_data
323
+
324
+ case action_type
325
+ when :create_annotation, 'Create Annotation'
326
+ handle_create_annotation_action(action_data)
327
+ when :copy_to_clipboard, 'Copy to Clipboard'
328
+ handle_copy_to_clipboard_action(action_data)
329
+ end
330
+
331
+ skip_editor = %i[create_annotation].include?(action_type) || action_type == 'Create Annotation'
332
+ cleanup_popup_state(skip_editor: skip_editor)
333
+ end
334
+
335
+ def cleanup_popup_state(skip_editor: false)
336
+ @state.dispatch(Shoko::Application::Actions::ClearPopupMenuAction.new)
337
+ @state.dispatch(Shoko::Application::Actions::ClearSelectionAction.new)
338
+ close_annotations_overlay
339
+ close_annotation_editor_overlay unless skip_editor
340
+ # Also reset any mouse-driven selection held outside state (MouseableReader)
341
+ begin
342
+ reader_controller = @dependencies.resolve(:reader_controller)
343
+ reader_controller&.send(:clear_selection!)
344
+ rescue StandardError
345
+ # Best-effort; ignore if not available
346
+ end
347
+ end
348
+
349
+ # Refresh annotations from persistence into state
350
+ def refresh_annotations
351
+ state_controller = @dependencies.resolve(:state_controller)
352
+ state_controller.refresh_annotations if state_controller.respond_to?(:refresh_annotations)
353
+ rescue StandardError
354
+ # Best-effort; ignore failures silently here
355
+ end
356
+
357
+ # Provide current book path for modes/components that need persistence context
358
+ def current_book_path
359
+ @state.get(%i[reader book_path])
360
+ end
361
+
362
+ def set_message(text, duration = 2)
363
+ notifier = @dependencies.resolve(:notification_service)
364
+ notifier.set_message(@state, text, duration)
365
+ rescue StandardError
366
+ # Fallback to direct dispatch if service not available
367
+ @state.dispatch(Shoko::Application::Actions::UpdateMessageAction.new(text))
368
+ end
369
+
370
+ attr_reader :current_mode
371
+
372
+ private
373
+
374
+ def sidebar_visible?
375
+ @state.get(%i[reader sidebar_visible])
376
+ end
377
+
378
+ def show_annotations_overlay
379
+ overlay = Shoko::Adapters::Output::Ui::Components::AnnotationsOverlayComponent.new(@state)
380
+ @state.dispatch(Shoko::Application::Actions::UpdateAnnotationsOverlayAction.new(overlay))
381
+ set_message('Annotations overlay open (↑/↓ navigate, Enter open, e edit, d delete)', 3)
382
+ rescue StandardError
383
+ cleanup_annotations_overlay_fallback
384
+ end
385
+
386
+ def close_annotations_overlay
387
+ overlay = Application::Selectors::ReaderSelectors.annotations_overlay(@state)
388
+ return unless overlay
389
+
390
+ overlay.hide if overlay.respond_to?(:hide)
391
+ @state.dispatch(Shoko::Application::Actions::ClearAnnotationsOverlayAction.new)
392
+ rescue StandardError
393
+ cleanup_annotations_overlay_fallback
394
+ end
395
+
396
+ def show_annotation_editor_overlay(text:, range:, chapter_index:, annotation: nil)
397
+ message = 'Annotation editor unavailable'
398
+ overlay = Shoko::Adapters::Output::Ui::Components::AnnotationEditorOverlayComponent.new(
399
+ selected_text: text,
400
+ range: range,
401
+ chapter_index: chapter_index,
402
+ annotation: annotation
403
+ )
404
+ @state.dispatch(Shoko::Application::Actions::UpdateAnnotationEditorOverlayAction.new(overlay))
405
+ if activate_annotation_editor_overlay_session
406
+ message = 'Annotation editor active (Ctrl+S save, Esc cancel)'
407
+ else
408
+ cleanup_annotation_editor_overlay_fallback
409
+ end
410
+ rescue StandardError => e
411
+ cleanup_annotation_editor_overlay_fallback
412
+ log_dependency_error(:show_annotation_editor_overlay, e)
413
+ ensure
414
+ set_message(message, 3)
415
+ end
416
+
417
+ def close_annotation_editor_overlay
418
+ overlay = Application::Selectors::ReaderSelectors.annotation_editor_overlay(@state)
419
+ return unless overlay
420
+
421
+ overlay.hide if overlay.respond_to?(:hide)
422
+ @state.dispatch(Shoko::Application::Actions::ClearAnnotationEditorOverlayAction.new)
423
+ deactivate_annotation_editor_overlay_session
424
+ rescue StandardError
425
+ cleanup_annotation_editor_overlay_fallback
426
+ end
427
+
428
+ def update_sidebar_selection(delta)
429
+ return unless sidebar_visible?
430
+
431
+ tab = @state.get(%i[reader sidebar_active_tab])
432
+ key, action_key, max = case tab
433
+ when :toc
434
+ doc = safe_resolve(:document)
435
+ entries = toc_entries_for(doc)
436
+ raw_collapsed = @state.get(%i[reader sidebar_toc_collapsed])
437
+ collapsed = toc_collapsed_for(entries, raw_collapsed)
438
+ indices = navigable_toc_entry_indices(entries, collapsed)
439
+ first_index = indices.first
440
+ last_index = indices.last
441
+ cur = (@state.get(%i[reader sidebar_toc_selected]) || first_index || 0).to_i
442
+ cur = ensure_visible_toc_selection(entries, collapsed, cur)
443
+ target = if delta.positive?
444
+ indices.find { |idx| idx > cur } || last_index || cur
445
+ elsif delta.negative?
446
+ indices.reverse.find { |idx| idx < cur } || first_index || cur
447
+ else
448
+ cur
449
+ end
450
+ updates = { toc_selected: target }
451
+ updates[:toc_collapsed] = collapsed if raw_collapsed != collapsed
452
+ @state.dispatch(Shoko::Application::Actions::UpdateSidebarAction.new(updates))
453
+ return
454
+ when :annotations
455
+ cur = @state.get(%i[reader sidebar_annotations_selected]) || 0
456
+ max = (@state.get(%i[reader annotations]) || []).length - 1
457
+ [:sidebar_annotations_selected, :annotations_selected, max]
458
+ when :bookmarks
459
+ cur = @state.get(%i[reader sidebar_bookmarks_selected]) || 0
460
+ max = (@state.get(%i[reader bookmarks]) || []).length - 1
461
+ [:sidebar_bookmarks_selected, :bookmarks_selected, max]
462
+ else
463
+ [nil, nil, nil]
464
+ end
465
+ return unless key && action_key
466
+
467
+ current = @state.get([:reader, key]) || 0
468
+ max0 = [max, 0].max
469
+ new_val = (current + delta).clamp(0, max0)
470
+ @state.dispatch(Shoko::Application::Actions::UpdateSidebarAction.new(action_key => new_val))
471
+ end
472
+
473
+ def toc_entries_for(doc)
474
+ entries = doc.respond_to?(:toc_entries) ? Array(doc.toc_entries) : []
475
+ return entries unless entries.empty?
476
+
477
+ chapters = doc.respond_to?(:chapters) ? Array(doc.chapters) : []
478
+ chapters.each_with_index.map do |chapter, idx|
479
+ title = chapter.respond_to?(:title) ? chapter.title.to_s : ''
480
+ title = "Chapter #{idx + 1}" if title.strip.empty?
481
+ Core::Models::TOCEntry.new(
482
+ title: title,
483
+ href: nil,
484
+ level: 0,
485
+ chapter_index: idx,
486
+ navigable: true
487
+ )
488
+ end
489
+ end
490
+
491
+ def toc_collapsed_for(entries, raw = nil)
492
+ raw = @state.get(%i[reader sidebar_toc_collapsed]) if raw.nil?
493
+ entries = Array(entries)
494
+ return [] if entries.empty?
495
+ return default_toc_collapsed(entries) if raw.nil?
496
+
497
+ normalize_toc_collapsed(entries, raw)
498
+ end
499
+
500
+ def toggle_toc_collapsed(collapsed, index)
501
+ list = Array(collapsed).dup
502
+ if list.include?(index)
503
+ list.delete(index)
504
+ else
505
+ list << index
506
+ end
507
+ list
508
+ end
509
+
510
+ def ensure_visible_toc_selection(entries, collapsed, current)
511
+ visible = toc_visible_indices(entries, collapsed)
512
+ return current if visible.include?(current)
513
+ return visible.first || 0 if visible.empty?
514
+
515
+ current_level = entries[current]&.level
516
+ if current_level
517
+ visible_set = visible.each_with_object({}) { |idx, memo| memo[idx] = true }
518
+ (current - 1).downto(0) do |idx|
519
+ next unless visible_set[idx]
520
+ return idx if entries[idx].level < current_level
521
+ end
522
+ end
523
+
524
+ visible.reverse.find { |idx| idx < current } || visible.first
525
+ end
526
+
527
+ def toc_visible_indices(entries, collapsed)
528
+ entries = Array(entries)
529
+ return [] if entries.empty?
530
+
531
+ collapsed_set = Array(collapsed).each_with_object({}) { |idx, memo| memo[idx] = true }
532
+ visible = []
533
+ skip_levels = []
534
+
535
+ entries.each_with_index do |entry, idx|
536
+ level = entry.level
537
+ skip_levels.pop while skip_levels.any? && level <= skip_levels.last
538
+ next if skip_levels.any?
539
+
540
+ visible << idx
541
+ next unless collapsed_set[idx]
542
+ next unless toc_entry_has_children?(entries, idx)
543
+
544
+ skip_levels << level
545
+ end
546
+
547
+ visible
548
+ end
549
+
550
+ def default_toc_collapsed(entries)
551
+ entries.each_index.select { |idx| toc_entry_has_children?(entries, idx) }
552
+ end
553
+
554
+ def normalize_toc_collapsed(entries, raw)
555
+ max_index = entries.length - 1
556
+ Array(raw).map(&:to_i).uniq.select do |idx|
557
+ idx.between?(0, max_index) && toc_entry_has_children?(entries, idx)
558
+ end
559
+ end
560
+
561
+ def toc_entry_has_children?(entries, index)
562
+ next_entry = entries[index + 1]
563
+ next_entry && next_entry.level > entries[index].level
564
+ end
565
+
566
+ def navigable_toc_entry_indices(entries, collapsed)
567
+ visible = toc_visible_indices(entries, collapsed)
568
+ indices = visible.select { |idx| entries[idx]&.chapter_index }
569
+ return indices unless indices.empty?
570
+
571
+ visible
572
+ end
573
+
574
+ def toc_index_for_chapter(entries, chapter_index)
575
+ Array(entries).find_index { |entry| entry&.chapter_index == chapter_index } || 0
576
+ end
577
+
578
+ def safe_resolve(name)
579
+ @dependencies.resolve(name)
580
+ rescue StandardError
581
+ nil
582
+ end
583
+
584
+ def handle_create_annotation_action(action_data)
585
+ selection_range = if action_data.is_a?(Hash)
586
+ action_data[:data][:selection_range]
587
+ else
588
+ @state.get(%i[
589
+ reader selection
590
+ ])
591
+ end
592
+ # Extract selected text from the controller that manages it
593
+ selected_text = extract_selected_text_from_selection(selection_range)
594
+ close_annotations_overlay
595
+ show_annotation_editor_overlay(text: selected_text,
596
+ range: selection_range,
597
+ chapter_index: @state.get(%i[reader current_chapter]))
598
+ end
599
+
600
+ def handle_copy_to_clipboard_action(_action_data)
601
+ clipboard_service = @dependencies.resolve(:clipboard_service)
602
+ # Get selected text from current selection
603
+ selection = @state.get(%i[reader selection])
604
+ selected_text = extract_selected_text_from_selection(selection)
605
+
606
+ if clipboard_service.available? && selected_text && !selected_text.strip.empty?
607
+ success = clipboard_service.copy_with_feedback(selected_text) do |msg|
608
+ set_message(msg)
609
+ end
610
+ set_message(' Failed to copy to clipboard') unless success
611
+ else
612
+ set_message(' Copy to clipboard not available')
613
+ end
614
+ switch_mode(:read)
615
+ end
616
+
617
+ # Extract selected text from selection range using SelectionService
618
+ def extract_selected_text_from_selection(selection_range)
619
+ selection_service = @dependencies.resolve(:selection_service)
620
+ if selection_service.respond_to?(:extract_from_state)
621
+ selection_service.extract_from_state(@state, selection_range)
622
+ else
623
+ rendered_lines = Shoko::Application::Selectors::ReaderSelectors.rendered_lines(@state)
624
+ selection_service.extract_text(selection_range, rendered_lines)
625
+ end
626
+ end
627
+
628
+ def open_annotation_from_overlay(annotation)
629
+ with_normalized_annotation(annotation) do |normalized|
630
+ state_controller = @dependencies.resolve(:state_controller)
631
+ state_controller.jump_to_annotation(normalized) if state_controller.respond_to?(:jump_to_annotation)
632
+ close_annotations_overlay
633
+ end
634
+ rescue StandardError
635
+ close_annotations_overlay
636
+ end
637
+
638
+ def edit_annotation_from_overlay(annotation)
639
+ with_normalized_annotation(annotation) do |normalized|
640
+ close_annotations_overlay
641
+ show_annotation_editor_overlay(text: normalized[:text],
642
+ range: normalized[:range],
643
+ chapter_index: normalized[:chapter_index],
644
+ annotation: normalized)
645
+ end
646
+ end
647
+
648
+ def delete_annotation_from_overlay(annotation)
649
+ with_normalized_annotation(annotation) do |normalized|
650
+ state_controller = @dependencies.resolve(:state_controller)
651
+ new_index = if state_controller.respond_to?(:delete_annotation_by_id)
652
+ state_controller.delete_annotation_by_id(normalized)
653
+ end
654
+
655
+ overlay = Application::Selectors::ReaderSelectors.annotations_overlay(@state)
656
+ overlay.selected_index = new_index if overlay.respond_to?(:selected_index=) && !new_index.nil?
657
+
658
+ annotations = @state.get(%i[reader annotations]) || []
659
+ close_annotations_overlay if annotations.empty?
660
+ set_message('Annotation deleted', 2)
661
+ end
662
+ rescue StandardError
663
+ close_annotations_overlay
664
+ end
665
+
666
+ def cleanup_annotations_overlay_fallback
667
+ @state.dispatch(Shoko::Application::Actions::ClearAnnotationsOverlayAction.new)
668
+ rescue StandardError
669
+ nil
670
+ end
671
+
672
+ def normalize_annotation(annotation)
673
+ return nil unless annotation.is_a?(Hash)
674
+
675
+ annotation.transform_keys do |key|
676
+ key.is_a?(String) ? key.to_sym : key
677
+ end
678
+ end
679
+
680
+ def with_normalized_annotation(annotation)
681
+ normalized = normalize_annotation(annotation)
682
+ return unless normalized
683
+
684
+ yield normalized
685
+ end
686
+
687
+ def cleanup_annotation_editor_overlay_fallback
688
+ @state.dispatch(Shoko::Application::Actions::ClearAnnotationEditorOverlayAction.new)
689
+ deactivate_annotation_editor_overlay_session
690
+ rescue StandardError
691
+ nil
692
+ end
693
+
694
+ def handle_annotation_editor_overlay_event(result)
695
+ overlay = Application::Selectors::ReaderSelectors.annotation_editor_overlay(@state)
696
+ return unless overlay
697
+
698
+ case result[:type]
699
+ when :save
700
+ save_annotation_from_overlay(result[:note], overlay)
701
+ when :cancel
702
+ cancel_annotation_editor_overlay
703
+ end
704
+ end
705
+
706
+ def save_annotation_from_overlay(note, overlay)
707
+ svc = @dependencies.resolve(:annotation_service)
708
+ path = current_book_path
709
+ unless svc && path
710
+ cancel_annotation_editor_overlay
711
+ return
712
+ end
713
+
714
+ begin
715
+ if overlay.annotation_id
716
+ svc.update(path, overlay.annotation_id, note)
717
+ set_message('Annotation updated', 2)
718
+ else
719
+ svc.add(path, overlay.selected_text, note, overlay.selection_range, overlay.chapter_index, nil)
720
+ set_message('Annotation saved!', 2)
721
+ end
722
+ refresh_annotations
723
+ rescue StandardError => e
724
+ set_message("Save failed: #{e.message}", 3)
725
+ ensure
726
+ close_annotation_editor_overlay
727
+ @state.dispatch(Shoko::Application::Actions::ClearSelectionAction.new)
728
+ end
729
+ end
730
+
731
+ def cancel_annotation_editor_overlay
732
+ close_annotation_editor_overlay
733
+ set_message('Annotation cancelled', 2)
734
+ @state.dispatch(Shoko::Application::Actions::ClearSelectionAction.new)
735
+ end
736
+
737
+ def activate_annotation_editor_overlay_session
738
+ reader_controller = resolve_required(:reader_controller)
739
+ input_controller = resolve_required(:input_controller)
740
+ reader_controller.activate_annotation_editor_overlay_session
741
+ input_controller.enter_modal_mode(:annotation_editor)
742
+ true
743
+ rescue MissingDependencyError => e
744
+ log_dependency_error(:activate_annotation_editor_overlay_session, e)
745
+ false
746
+ end
747
+
748
+ def deactivate_annotation_editor_overlay_session
749
+ input_controller = resolve_optional(:input_controller)
750
+ input_controller&.exit_modal_mode(:annotation_editor)
751
+ reader_controller = resolve_optional(:reader_controller)
752
+ reader_controller&.deactivate_annotation_editor_overlay_session
753
+ end
754
+
755
+ def resolve_required(key)
756
+ service = @dependencies.resolve(key)
757
+ raise MissingDependencyError, "Dependency :#{key} not registered" unless service
758
+
759
+ service
760
+ rescue MissingDependencyError
761
+ raise
762
+ rescue StandardError => e
763
+ raise MissingDependencyError, "Dependency :#{key} failed to resolve: #{e.message}"
764
+ end
765
+
766
+ def resolve_optional(key)
767
+ @dependencies.resolve(key)
768
+ rescue StandardError
769
+ nil
770
+ end
771
+
772
+ def log_dependency_error(context, error)
773
+ logger = resolve_optional(:logger)
774
+ return unless logger.respond_to?(:error)
775
+
776
+ logger.error('Annotation editor activation failed', context: context, error: error.message)
777
+ rescue StandardError
778
+ nil
779
+ end
780
+ end
781
+ end
782
+ end