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,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_service'
4
+ require_relative '../models/selection_anchor'
5
+
6
+ module Shoko
7
+ module Core
8
+ module Services
9
+ # Domain service for coordinate system management with dependency injection.
10
+ # Migrated from legacy Services::CoordinateService to follow DI pattern.
11
+ class CoordinateService < BaseService
12
+ # Convert mouse coordinates (0-based) to terminal coordinates (1-based)
13
+ def mouse_to_terminal(mouse_x, mouse_y)
14
+ {
15
+ x: mouse_x + 1,
16
+ y: mouse_y + 1,
17
+ }
18
+ end
19
+
20
+ # Convert terminal coordinates (1-based) to mouse coordinates (0-based)
21
+ def terminal_to_mouse(terminal_x, terminal_y)
22
+ {
23
+ x: [terminal_x - 1, 0].max,
24
+ y: [terminal_y - 1, 0].max,
25
+ }
26
+ end
27
+
28
+ # Normalize selection range ensuring start <= end. Accepts either
29
+ # geometry-based anchors or legacy screen coordinate hashes.
30
+ #
31
+ # @param selection_range [Hash]
32
+ # @param rendered_lines [Hash]
33
+ # @return [Hash,nil]
34
+ def normalize_selection_range(selection_range, rendered_lines = nil)
35
+ return nil unless selection_range
36
+
37
+ start_anchor = normalize_anchor(selection_range[:start], rendered_lines)
38
+ end_anchor = normalize_anchor(selection_range[:end], rendered_lines)
39
+ return nil unless start_anchor && end_anchor
40
+
41
+ start_anchor, end_anchor = end_anchor, start_anchor if (start_anchor <=> end_anchor)&.positive?
42
+
43
+ {
44
+ start: start_anchor.to_h,
45
+ end: end_anchor.to_h,
46
+ }
47
+ end
48
+
49
+ # Validate coordinate bounds
50
+ def validate_coordinates?(col, row, max_col, max_row)
51
+ col.between?(1, max_col) && row.between?(1, max_row)
52
+ end
53
+
54
+ # Calculate distance between two points
55
+ def calculate_distance(x_start, y_start, x_end, y_end)
56
+ Math.sqrt(((x_end - x_start)**2) + ((y_end - y_start)**2))
57
+ end
58
+
59
+ # Calculate optimal popup position near selection end
60
+ def calculate_popup_position(selection_end, popup_width, popup_height)
61
+ terminal_height, terminal_width = @dependencies.resolve(:terminal_service).size
62
+
63
+ # Start with position below selection end
64
+ end_y = selection_end[:y]
65
+ popup_x = selection_end[:x]
66
+ popup_y = end_y + 1
67
+
68
+ # Adjust if popup would go off right edge
69
+ popup_x = [terminal_width - popup_width, 1].max if popup_x + popup_width > terminal_width
70
+
71
+ # Adjust if popup would go off bottom edge
72
+ if popup_y + popup_height > terminal_height
73
+ # Try to position above selection instead
74
+ popup_y = [end_y - popup_height, 1].max
75
+ end
76
+
77
+ {
78
+ x: popup_x,
79
+ y: popup_y,
80
+ }
81
+ end
82
+
83
+ # Check if coordinates are within bounds
84
+ def within_bounds?(col, row, bounds)
85
+ bx = bounds.x
86
+ by = bounds.y
87
+ bw = bounds.width
88
+ bh = bounds.height
89
+ col >= bx && col < (bx + bw) && row >= by && row < (by + bh)
90
+ end
91
+
92
+ # Convert line-relative coordinates to absolute terminal coordinates
93
+ def line_to_terminal(line_col, line_start_col, terminal_row)
94
+ {
95
+ x: line_start_col + line_col,
96
+ y: terminal_row,
97
+ }
98
+ end
99
+
100
+ # Normalize position hash to consistent format
101
+ def normalize_position(pos)
102
+ return nil unless pos
103
+
104
+ {
105
+ x: pos[:x] || pos['x'],
106
+ y: pos[:y] || pos['y'],
107
+ }
108
+ end
109
+
110
+ # Determine the column bounds (start..end) for a given click/selection position
111
+ # by inspecting rendered geometry. Maintained for compatibility with
112
+ # consumers that still reason about legacy coordinate ranges.
113
+ def column_bounds_for(click_pos, rendered_lines)
114
+ pos = normalize_position(click_pos)
115
+ return nil unless pos
116
+
117
+ anchor_geometry = locate_geometry(rendered_lines, pos[:x], pos[:y])
118
+ return nil unless anchor_geometry
119
+
120
+ start_col = anchor_geometry.column_origin
121
+ {
122
+ start: start_col,
123
+ end: start_col + anchor_geometry.visible_width,
124
+ }
125
+ end
126
+
127
+ def column_overlaps?(line_start, line_end, bounds)
128
+ return false unless bounds
129
+
130
+ !(line_end < bounds[:start] || line_start > bounds[:end])
131
+ end
132
+
133
+ # Build an anchor from screen coordinates (0-based) using rendered geometry.
134
+ def anchor_from_point(point, rendered_lines, bias: :nearest)
135
+ pos = normalize_position(point)
136
+ return nil unless pos
137
+
138
+ geometry = locate_geometry(rendered_lines, pos[:x], pos[:y])
139
+ return nil unless geometry
140
+
141
+ cell_index = cell_index_for_geometry(geometry, pos[:x], bias)
142
+
143
+ Shoko::Core::Models::SelectionAnchor.new(
144
+ page_id: geometry.page_id,
145
+ column_id: geometry.column_id,
146
+ geometry_key: geometry.key,
147
+ line_offset: geometry.line_offset,
148
+ cell_index: cell_index,
149
+ row: geometry.row,
150
+ column_origin: geometry.column_origin
151
+ )
152
+ end
153
+
154
+ # Convenience helper for highlight logic: find the rendered line geometry
155
+ # that covers the provided terminal row.
156
+ def geometry_for_row(rendered_lines, row)
157
+ index = geometry_index_by_row(rendered_lines)
158
+ candidates = index[row]
159
+ candidates && candidates.first
160
+ end
161
+
162
+ protected
163
+
164
+ def required_dependencies
165
+ [] # No dependencies required for coordinate operations
166
+ end
167
+
168
+ private
169
+
170
+ def normalize_anchor(anchor, rendered_lines)
171
+ return nil unless anchor
172
+
173
+ selection_anchor = Shoko::Core::Models::SelectionAnchor.from(anchor)
174
+ return selection_anchor if selection_anchor&.geometry_key
175
+
176
+ return nil unless rendered_lines
177
+
178
+ anchor_from_point(anchor, rendered_lines)
179
+ end
180
+
181
+ def locate_geometry(rendered_lines, mouse_x, mouse_y)
182
+ row = mouse_y.to_i + 1
183
+ col = mouse_x.to_i + 1
184
+
185
+ candidates = geometry_index_by_row(rendered_lines)[row] || []
186
+ return nil if candidates.empty?
187
+
188
+ candidates.each do |geometry|
189
+ line_start = geometry.column_origin
190
+ line_end = line_start + geometry.visible_width
191
+
192
+ if col < line_start
193
+ return geometry if geometry.visible_width.zero?
194
+
195
+ next
196
+ end
197
+
198
+ return geometry if col <= line_end || geometry.visible_width.zero?
199
+ end
200
+
201
+ # No direct hit; fall back to the last geometry on the row for trailing whitespace selection.
202
+ candidates.last
203
+ end
204
+
205
+ def cell_index_for_geometry(geometry, mouse_x, bias)
206
+ cells = geometry.cells
207
+ return 0 if cells.empty?
208
+
209
+ target_col = mouse_x.to_i + 1
210
+ relative = target_col - geometry.column_origin
211
+ relative = 0 if relative.negative?
212
+
213
+ cells.each_with_index do |cell, index|
214
+ cell_start = cell.screen_x
215
+ cell_end = cell_start + cell.display_width
216
+
217
+ if relative < cell_start
218
+ return clamp_cell_index(index, cells.length, bias)
219
+ elsif relative < cell_end
220
+ return bias == :trailing ? [index + 1, cells.length].min : index
221
+ end
222
+ end
223
+
224
+ return cells.length if bias == :trailing
225
+
226
+ [cells.length - 1, 0].max
227
+ end
228
+
229
+ def clamp_cell_index(index, cell_count, bias)
230
+ case bias
231
+ when :trailing
232
+ [[index, cell_count].min, 0].max
233
+ when :leading
234
+ [[index, cell_count - 1].min, 0].max
235
+ else
236
+ index
237
+ end
238
+ end
239
+
240
+ def geometry_index_by_row(rendered_lines)
241
+ return {} unless rendered_lines
242
+
243
+ key = rendered_lines.object_id
244
+ if @geometry_index_key != key
245
+ @geometry_index_key = key
246
+ @geometry_index_by_row = build_geometry_index(rendered_lines)
247
+ end
248
+ @geometry_index_by_row || {}
249
+ end
250
+
251
+ def build_geometry_index(rendered_lines)
252
+ index = Hash.new { |h, k| h[k] = [] }
253
+ rendered_lines.each_value do |line_info|
254
+ geometry = line_info[:geometry]
255
+ next unless geometry
256
+
257
+ index[geometry.row] << geometry
258
+ end
259
+ index.each_value { |rows| rows.sort_by!(&:column_origin) }
260
+ index
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_service'
4
+
5
+ module Shoko
6
+ module Core
7
+ module Services
8
+ # Domain service for layout calculations with dependency injection.
9
+ # Migrated from legacy Services::LayoutService to follow DI pattern.
10
+ class LayoutService < BaseService
11
+ # Shared layout constants so pagination and rendering stay in sync
12
+ SPLIT_LEFT_MARGIN = 2
13
+ SPLIT_RIGHT_MARGIN = 2
14
+ SPLIT_COLUMN_GAP = 4
15
+ SPLIT_MIN_USABLE_WIDTH = 40
16
+ MIN_COLUMN_WIDTH = 20
17
+ CONTENT_TOP_PADDING = 2
18
+ CONTENT_BOTTOM_PADDING = 1
19
+ CONTENT_VERTICAL_PADDING = CONTENT_TOP_PADDING + CONTENT_BOTTOM_PADDING
20
+
21
+ # Calculate layout metrics for given dimensions and view mode
22
+ def calculate_metrics(width, height, view_mode)
23
+ col_width = view_mode == :split ? split_column_width(width) : single_column_width(width)
24
+ content_height = content_area_height(height)
25
+ [col_width, content_height]
26
+ end
27
+
28
+ # Adjust height for line spacing
29
+ def adjust_for_line_spacing(height, line_spacing)
30
+ return 1 if height <= 0
31
+
32
+ spacing = begin
33
+ line_spacing&.to_sym
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ return [(height + 1) / 2, 1].max if spacing == :relaxed
39
+
40
+ multiplier = resolve_multiplier(spacing)
41
+ adjusted = (height * multiplier).floor
42
+ adjusted = height if multiplier >= 1.0 && adjusted < height
43
+ [adjusted, 1].max
44
+ end
45
+
46
+ # Calculate center start row for content
47
+ def calculate_center_start_row(content_height, lines_count, line_spacing)
48
+ actual_lines = line_spacing == :relaxed ? [(lines_count * 2) - 1, 0].max : lines_count
49
+ padding = [(content_height - actual_lines) / 2, 0].max
50
+ [3 + padding, 3].max
51
+ end
52
+
53
+ # Calculate optimal column width for text content
54
+ def calculate_optimal_column_width(available_width, _content_length)
55
+ # Use golden ratio for optimal reading width
56
+ optimal = (available_width * 0.618).to_i
57
+ optimal.clamp(40, 80) # Keep within readable bounds
58
+ end
59
+
60
+ # Calculate padding for centered content
61
+ def calculate_centered_padding(container_width, content_width)
62
+ [(container_width - content_width) / 2, 0].max
63
+ end
64
+
65
+ def content_area_height(height)
66
+ [height - CONTENT_VERTICAL_PADDING, 1].max
67
+ end
68
+
69
+ def split_column_width(width)
70
+ usable_width = [width - SPLIT_LEFT_MARGIN - SPLIT_RIGHT_MARGIN, SPLIT_MIN_USABLE_WIDTH].max
71
+ [(usable_width - SPLIT_COLUMN_GAP) / 2, MIN_COLUMN_WIDTH].max
72
+ end
73
+
74
+ def single_column_width(width)
75
+ (width * Core::Models::ReaderSettings::SINGLE_VIEW_WIDTH_PERCENT).to_i.clamp(30, 120)
76
+ end
77
+
78
+ def resolve_multiplier(line_spacing)
79
+ key = begin
80
+ line_spacing&.to_sym
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ Shoko::Core::Models::ReaderSettings::LINE_SPACING_MULTIPLIERS.fetch(key, 1.0)
85
+ end
86
+
87
+ protected
88
+
89
+ def required_dependencies
90
+ [] # No dependencies required for layout calculations
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'context_helpers'
4
+
5
+ module Shoko
6
+ module Core
7
+ module Services
8
+ module Navigation
9
+ # Applies absolute-mode changes and computes offsets from layout data.
10
+ class AbsoluteChangeApplier
11
+ CHANGE_PATHS = {
12
+ current_chapter: %i[reader current_chapter],
13
+ current_page: %i[reader current_page],
14
+ single_page: %i[reader single_page],
15
+ left_page: %i[reader left_page],
16
+ right_page: %i[reader right_page],
17
+ }.freeze
18
+
19
+ def initialize(state_updater:, absolute_layout:, image_snapper:, advance_callback:)
20
+ @state_updater = state_updater
21
+ @absolute_layout = absolute_layout
22
+ @image_snapper = image_snapper
23
+ @advance_callback = advance_callback
24
+ end
25
+
26
+ def apply(changes)
27
+ return if changes.nil? || changes.empty?
28
+
29
+ layout_state = @absolute_layout.build
30
+ return if handle_advance(changes[:advance_chapter], layout_state)
31
+
32
+ updates = build_updates(changes)
33
+ updates = apply_align_to_last(updates, changes, layout_state)
34
+ updates = @image_snapper.snap(updates, layout_state) if @image_snapper
35
+ @state_updater.apply(updates)
36
+ end
37
+
38
+ private
39
+
40
+ def handle_advance(advance, layout_state)
41
+ return false unless advance
42
+
43
+ current_chapter = ContextHelpers.current_chapter(layout_state.snapshot)
44
+ case advance
45
+ when :next
46
+ @advance_callback.call(current_chapter + 1)
47
+ true
48
+ when :prev
49
+ previous = current_chapter - 1
50
+ return true if previous.negative?
51
+
52
+ offset = @absolute_layout.max_offset_for(layout_state.snapshot, previous, layout_state.stride)
53
+ updates = { %i[reader current_chapter] => previous }
54
+ apply_offset(updates, layout_state, offset)
55
+ @state_updater.apply(updates)
56
+ true
57
+ else
58
+ false
59
+ end
60
+ end
61
+
62
+ def build_updates(changes)
63
+ updates = {}
64
+ CHANGE_PATHS.each do |key, path|
65
+ updates[path] = changes[key] if changes.key?(key)
66
+ end
67
+ updates
68
+ end
69
+
70
+ def apply_align_to_last(updates, changes, layout_state)
71
+ return updates unless changes[:align_to_last]
72
+
73
+ total = ContextHelpers.total_chapters(layout_state.snapshot)
74
+ total = 1 if total.to_i <= 0
75
+ last_chapter = total - 1
76
+ offset = @absolute_layout.max_offset_for(layout_state.snapshot, last_chapter, layout_state.stride)
77
+ updates[%i[reader current_chapter]] = last_chapter
78
+ apply_offset(updates, layout_state, offset)
79
+ updates
80
+ end
81
+
82
+ def apply_offset(updates, layout_state, offset)
83
+ updates[%i[reader current_page]] = offset
84
+ if layout_state.view_mode == :split
85
+ updates[%i[reader left_page]] = offset
86
+ updates[%i[reader right_page]] = offset + layout_state.stride
87
+ else
88
+ updates[%i[reader single_page]] = offset
89
+ end
90
+ updates
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'context_helpers'
4
+
5
+ module Shoko
6
+ module Core
7
+ module Services
8
+ module Navigation
9
+ # Computes absolute-layout metrics and enriches navigation contexts.
10
+ class AbsoluteLayout
11
+ # Snapshot of layout-derived values for absolute navigation.
12
+ LayoutState = Struct.new(:snapshot, :view_mode, :metrics, :stride, keyword_init: true)
13
+
14
+ def initialize(state_store:, layout_service:)
15
+ @state_store = state_store
16
+ @layout_service = layout_service
17
+ end
18
+
19
+ def build
20
+ snapshot = ContextHelpers.safe_snapshot(@state_store)
21
+ view_mode = ContextHelpers.current_view_mode(snapshot)
22
+ metrics = {
23
+ single: lines_for(snapshot, :single),
24
+ split: lines_for(snapshot, :split),
25
+ }
26
+ stride = view_mode == :split ? metrics[:split] : metrics[:single]
27
+ stride = metrics[:single] if stride.to_i <= 0
28
+ stride = 1 if stride.to_i <= 0
29
+ LayoutState.new(snapshot: snapshot, view_mode: view_mode, metrics: metrics, stride: stride)
30
+ end
31
+
32
+ def populate_context(ctx)
33
+ return ctx unless ctx.mode == :absolute
34
+
35
+ layout_state = build
36
+ ctx.lines_per_page = layout_state.metrics[:single]
37
+ ctx.column_lines_per_page = layout_state.metrics[:split]
38
+ ctx.max_page_in_chapter = page_count(layout_state.snapshot, ctx.current_chapter)
39
+ ctx.max_offset_in_chapter = max_offset_for(layout_state.snapshot, ctx.current_chapter, layout_state.stride)
40
+ ctx
41
+ end
42
+
43
+ def page_count(snapshot, chapter_index)
44
+ return 0 if chapter_index.nil?
45
+
46
+ snapshot.dig(:reader, :page_map)&.[](chapter_index) || 0
47
+ end
48
+
49
+ def max_offset_for(snapshot, chapter_index, stride)
50
+ return 0 if chapter_index.nil? || stride.to_i <= 0
51
+
52
+ pages = page_count(snapshot, chapter_index).to_i
53
+ return 0 if pages <= 1
54
+
55
+ (pages - 1) * stride
56
+ end
57
+
58
+ def column_width(snapshot, view_mode)
59
+ return fallback_width(snapshot) unless @layout_service
60
+
61
+ width = fallback_width(snapshot)
62
+ height = fallback_height(snapshot)
63
+ col_width, = @layout_service.calculate_metrics(width, height, view_mode)
64
+ col_width = width if col_width.to_i <= 0
65
+ col_width
66
+ rescue StandardError
67
+ fallback_width(snapshot)
68
+ end
69
+
70
+ private
71
+
72
+ def lines_for(snapshot, view_mode)
73
+ return fallback_lines(view_mode) unless @layout_service
74
+
75
+ width = fallback_width(snapshot)
76
+ height = fallback_height(snapshot)
77
+ _, content_height = @layout_service.calculate_metrics(width, height, view_mode)
78
+ line_spacing = snapshot.dig(:config, :line_spacing) || Shoko::Core::Models::ReaderSettings::DEFAULT_LINE_SPACING
79
+ lines = @layout_service.adjust_for_line_spacing(content_height, line_spacing)
80
+ lines = 1 if lines.to_i <= 0
81
+ lines
82
+ rescue StandardError
83
+ 1
84
+ end
85
+
86
+ def fallback_width(snapshot)
87
+ snapshot.dig(:ui, :terminal_width) || 80
88
+ end
89
+
90
+ def fallback_height(snapshot)
91
+ snapshot.dig(:ui, :terminal_height) || 24
92
+ end
93
+
94
+ def fallback_lines(view_mode)
95
+ view_mode == :split ? 2 : 1
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end