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,413 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ begin
5
+ require 'json'
6
+ rescue NameError => e
7
+ raise unless e.name == :Fragment
8
+
9
+ # Ensure JSON::Fragment exists for compatibility with JSON parsing in some environments.
10
+ module JSON
11
+ Fragment = Object unless const_defined?(:Fragment)
12
+ end
13
+ require 'json'
14
+ end
15
+ require_relative '../../adapters/storage/atomic_file_writer.rb'
16
+ require_relative '../../adapters/storage/config_paths.rb'
17
+ require_relative '../../adapters/output/kitty/kitty_graphics'
18
+
19
+ module Shoko
20
+ module Application::Infrastructure
21
+ # Immutable state store with event-driven updates.
22
+ # Single source of truth for application state with validation.
23
+ class StateStore
24
+ attr_reader :event_bus
25
+
26
+ SYMBOL_KEYS = %i[view_mode line_spacing page_numbering_mode theme].freeze
27
+ LINE_SPACING_ALIASES = {
28
+ tight: :compact,
29
+ wide: :relaxed,
30
+ }.freeze
31
+ private_constant :SYMBOL_KEYS, :LINE_SPACING_ALIASES
32
+
33
+ # Configuration file management (used by persistence + specs).
34
+ def self.config_dir
35
+ Shoko::Adapters::Storage::ConfigPaths.config_root
36
+ end
37
+
38
+ def self.config_file
39
+ File.join(config_dir, 'config.json')
40
+ end
41
+
42
+ def initialize(event_bus = EventBus.new)
43
+ @event_bus = event_bus
44
+ @state = build_initial_state
45
+ @mutex = Mutex.new
46
+ end
47
+
48
+ # Cheap, read-only reference to the current state (no deep copy).
49
+ # Callers must not mutate the returned object.
50
+ def peek
51
+ @mutex.synchronize { @state }
52
+ end
53
+
54
+ # Get current state snapshot (immutable)
55
+ #
56
+ # @return [Hash] Current state
57
+ def current_state
58
+ @mutex.synchronize { deep_dup(@state, true) }
59
+ end
60
+
61
+ # Get value at specific path
62
+ #
63
+ # @param path [Array<Symbol>] Path to value
64
+ # @return [Object] Value at path
65
+ def get(path)
66
+ @mutex.synchronize do
67
+ path.reduce(@state) { |state, key| state&.dig(key) }
68
+ end
69
+ end
70
+
71
+ # Update state and emit events
72
+ #
73
+ # @param updates [Hash] Hash of path => value updates
74
+ def update(updates)
75
+ @mutex.synchronize do
76
+ old_state = @state
77
+ new_state = apply_updates(old_state, updates)
78
+
79
+ return if old_state == new_state
80
+
81
+ @state = new_state
82
+ emit_change_events(old_state, new_state, updates)
83
+ end
84
+ end
85
+
86
+ # Update single path
87
+ #
88
+ # @param path [Array<Symbol>] Path to update
89
+ # @param value [Object] New value
90
+ def set(path, value)
91
+ update({ path => value })
92
+ end
93
+
94
+ # Reset to initial state
95
+ def reset!
96
+ @mutex.synchronize do
97
+ old_state = @state
98
+ @state = build_initial_state
99
+ @event_bus.emit_event(:state_reset, { old_state: old_state, new_state: @state })
100
+ end
101
+ end
102
+
103
+ # Validate state transition (override in subclasses)
104
+ #
105
+ # @param old_state [Hash] Previous state
106
+ # @param new_state [Hash] Proposed new state
107
+ # @param updates [Hash] Applied updates
108
+ # @return [Boolean] Whether transition is valid
109
+ def valid_transition?(_old_state, _new_state, _updates)
110
+ true # Base implementation allows all transitions
111
+ end
112
+
113
+ # Convenience methods for compatibility with legacy callers
114
+ def terminal_size_changed?(width, height)
115
+ last_width = get(%i[reader last_width])
116
+ last_height = get(%i[reader last_height])
117
+ width != last_width || height != last_height
118
+ end
119
+
120
+ def update_terminal_size(width, height)
121
+ update({
122
+ %i[reader last_width] => width,
123
+ %i[reader last_height] => height,
124
+ %i[ui terminal_width] => width,
125
+ %i[ui terminal_height] => height,
126
+ })
127
+ end
128
+
129
+ def apply_terminal_dimensions(width, height)
130
+ return unless width && height
131
+
132
+ update_terminal_size(width, height)
133
+ end
134
+
135
+ # State snapshot for persistence
136
+ def reader_snapshot
137
+ {
138
+ current_chapter: get(%i[reader current_chapter]),
139
+ page_offset: get(%i[reader single_page]),
140
+ mode: get(%i[reader mode]).to_s,
141
+ timestamp: Time.now.iso8601,
142
+ }
143
+ end
144
+
145
+ # Restore reader state from snapshot
146
+ def restore_reader_from(snapshot)
147
+ update({
148
+ %i[reader current_chapter] => snapshot['current_chapter'] || 0,
149
+ %i[reader single_page] => snapshot['page_offset'] || 0,
150
+ %i[reader left_page] => snapshot['page_offset'] || 0,
151
+ %i[reader mode] => (snapshot['mode'] || 'read').to_sym,
152
+ })
153
+ end
154
+
155
+ # Configuration persistence methods
156
+ def save_config
157
+ ensure_config_dir
158
+ write_config_file
159
+ rescue StandardError
160
+ # Ignore save errors
161
+ end
162
+
163
+ def config_to_h
164
+ get([:config])
165
+ end
166
+
167
+ # Dispatch Application::Actions to update state explicitly
168
+ def dispatch(action)
169
+ return unless action.respond_to?(:apply)
170
+
171
+ action.apply(self)
172
+ end
173
+
174
+ private
175
+
176
+ def build_initial_state
177
+ {
178
+ reader: {
179
+ # Position state
180
+ current_chapter: 0,
181
+ left_page: 0,
182
+ right_page: 0,
183
+ single_page: 0,
184
+ current_page_index: 0,
185
+
186
+ # Mode and UI state
187
+ mode: :read,
188
+ selection: nil,
189
+ message: nil,
190
+ running: true,
191
+
192
+ # Lists and selections
193
+ bookmarks: [],
194
+ annotations: [],
195
+
196
+ # Pagination state
197
+ page_map: [],
198
+ total_pages: 0,
199
+ pages_per_chapter: [],
200
+
201
+ # Terminal sizing
202
+ last_width: 0,
203
+ last_height: 0,
204
+ page_offset: 0,
205
+
206
+ # Dynamic pagination
207
+ dynamic_page_map: nil,
208
+ dynamic_total_pages: 0,
209
+ dynamic_chapter_starts: [],
210
+ last_dynamic_width: 0,
211
+ last_dynamic_height: 0,
212
+
213
+ # UI state
214
+ rendered_lines: {},
215
+ popup_menu: nil,
216
+ annotations_overlay: nil,
217
+ annotation_editor_overlay: nil,
218
+
219
+ # Sidebar state
220
+ sidebar_visible: false,
221
+ sidebar_active_tab: :toc,
222
+ sidebar_toc_selected: 0,
223
+ sidebar_annotations_selected: 0,
224
+ sidebar_bookmarks_selected: 0,
225
+ sidebar_toc_filter: nil,
226
+ sidebar_toc_filter_active: false,
227
+ sidebar_toc_collapsed: nil,
228
+ },
229
+
230
+ menu: {
231
+ selected: 0,
232
+ mode: :menu,
233
+ browse_selected: 0,
234
+ settings_selected: 1,
235
+ search_query: '',
236
+ search_cursor: 0,
237
+ search_active: false,
238
+ download_query: '',
239
+ download_cursor: 0,
240
+ download_selected: 0,
241
+ download_results: [],
242
+ download_count: 0,
243
+ download_next: nil,
244
+ download_prev: nil,
245
+ download_status: :idle,
246
+ download_message: '',
247
+ download_progress: 0.0,
248
+ },
249
+
250
+ config: {
251
+ view_mode: :split,
252
+ line_spacing: :compact,
253
+ page_numbering_mode: :dynamic,
254
+ theme: :dark,
255
+ show_page_numbers: true,
256
+ highlight_quotes: true,
257
+ highlight_keywords: false,
258
+ prefetch_pages: 20,
259
+ kitty_images: Shoko::Adapters::Output::Kitty::KittyGraphics.supported?,
260
+ },
261
+
262
+ ui: {
263
+ terminal_width: 80,
264
+ terminal_height: 24,
265
+ },
266
+ }
267
+ end
268
+
269
+ def apply_updates(state, updates)
270
+ # Copy only the branches we need to touch instead of duplicating the entire tree.
271
+ clones = {}.compare_by_identity
272
+ new_root = duplicate_node(state, clones)
273
+
274
+ updates.each do |path, value|
275
+ validate_update(path, value)
276
+ keys = Array(path)
277
+ target = new_root
278
+ keys[0...-1].each do |key|
279
+ existing = target[key]
280
+ duplicated = duplicate_node(existing, clones)
281
+ duplicated = {} if duplicated.nil?
282
+ target[key] = duplicated
283
+ target = duplicated
284
+ end
285
+ target[keys.last] = value
286
+ end
287
+
288
+ new_root
289
+ end
290
+
291
+ def validate_update(path, value)
292
+ # Add validation logic here
293
+ path_array = Array(path)
294
+
295
+ case path_array
296
+ when %i[reader current_chapter]
297
+ raise ArgumentError, 'current_chapter must be non-negative' if value.negative?
298
+ when %i[config view_mode]
299
+ raise ArgumentError, 'invalid view_mode' unless %i[single split].include?(value)
300
+ when %i[config kitty_images]
301
+ raise ArgumentError, 'kitty_images must be boolean' unless [true, false].include?(value)
302
+ when %i[ui terminal_width], %i[ui terminal_height]
303
+ raise ArgumentError, 'terminal dimensions must be positive' if value <= 0
304
+ end
305
+ end
306
+
307
+ def set_nested(hash, path, value)
308
+ *keys, last_key = path
309
+
310
+ if keys.empty?
311
+ hash[last_key] = value
312
+ else
313
+ # Create mutable path to target
314
+ target = hash
315
+ keys.each do |key|
316
+ target[key] = {} unless target.key?(key)
317
+ target = target[key]
318
+ end
319
+ target[last_key] = value
320
+ end
321
+ end
322
+
323
+ def duplicate_node(node, clones)
324
+ return node unless node.is_a?(Hash)
325
+ return clones[node] if clones.key?(node)
326
+
327
+ duped = node.dup
328
+ clones[node] = duped
329
+ duped
330
+ rescue StandardError
331
+ node
332
+ end
333
+
334
+ def deep_dup(obj, freeze_result = false)
335
+ case obj
336
+ when Hash
337
+ result = obj.transform_values { |v| deep_dup(v, freeze_result) }
338
+ freeze_result ? result.freeze : result
339
+ when Array
340
+ result = obj.map { |v| deep_dup(v, freeze_result) }
341
+ freeze_result ? result.freeze : result
342
+ else
343
+ begin
344
+ obj.dup
345
+ rescue StandardError
346
+ obj
347
+ end
348
+ end
349
+ end
350
+
351
+ def emit_change_events(old_state, new_state, updates)
352
+ updates.each do |path, new_value|
353
+ arr_path = Array(path)
354
+ old_value = get_nested_value(old_state, arr_path)
355
+ next if old_value == new_value
356
+
357
+ @event_bus.emit_event(:state_changed, {
358
+ path: arr_path,
359
+ old_value: old_value,
360
+ new_value: new_value,
361
+ full_state: new_state,
362
+ })
363
+ end
364
+ end
365
+
366
+ def get_nested_value(hash, path)
367
+ path.reduce(hash) { |h, key| h&.dig(key) }
368
+ end
369
+
370
+ def ensure_config_dir
371
+ FileUtils.mkdir_p(self.class.config_dir)
372
+ rescue StandardError
373
+ nil
374
+ end
375
+
376
+ def write_config_file
377
+ payload = JSON.pretty_generate(config_to_h)
378
+ Shoko::Adapters::Storage::AtomicFileWriter.write(self.class.config_file, payload)
379
+ rescue StandardError
380
+ nil
381
+ end
382
+
383
+ # Load config from file on initialization
384
+ def load_config_from_file
385
+ config_file = self.class.config_file
386
+ return unless File.exist?(config_file)
387
+
388
+ data = parse_config_file(config_file)
389
+ apply_config_data(data) if data
390
+ rescue StandardError
391
+ # Use defaults on error
392
+ end
393
+
394
+ def parse_config_file(path)
395
+ JSON.parse(File.read(path), symbolize_names: true)
396
+ rescue StandardError
397
+ nil
398
+ end
399
+
400
+ def apply_config_data(data)
401
+ config_updates = {}
402
+ data.each do |key, value|
403
+ next unless get([:config]).key?(key)
404
+
405
+ value = value.to_sym if SYMBOL_KEYS.include?(key)
406
+ value = LINE_SPACING_ALIASES.fetch(value, value) if key == :line_spacing
407
+ config_updates[[:config, key]] = value
408
+ end
409
+ update(config_updates) unless config_updates.empty?
410
+ end
411
+ end
412
+ end
413
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Application
5
+ module MainMenu
6
+ # Encapsulates loading-state updates while a book is preprocessed.
7
+ class MenuProgressPresenter
8
+ MIN_PROGRESS_DELTA = 0.01
9
+
10
+ def initialize(state)
11
+ @state = state
12
+ @last_message = nil
13
+ @last_progress = nil
14
+ end
15
+
16
+ def show(path:, index:, mode:)
17
+ @last_message = 'Preparing book...'
18
+ @last_progress = 0.0
19
+ dispatch(
20
+ loading_active: true,
21
+ loading_path: path,
22
+ loading_progress: 0.0,
23
+ loading_index: index,
24
+ loading_mode: mode,
25
+ loading_message: @last_message
26
+ )
27
+ end
28
+
29
+ def update(done:, total:)
30
+ progress = Shoko::Core::Services::ProgressHelper.ratio(done, total)
31
+ update_status(progress: progress)
32
+ end
33
+
34
+ def update_message(message)
35
+ update_status(message: message)
36
+ end
37
+
38
+ def set_progress(progress)
39
+ update_status(progress: progress)
40
+ end
41
+
42
+ def update_status(message: nil, progress: nil)
43
+ updates = {}
44
+
45
+ if message && message != @last_message
46
+ updates[:loading_message] = message
47
+ @last_message = message
48
+ end
49
+
50
+ unless progress.nil?
51
+ normalized = progress.to_f.clamp(0.0, 1.0)
52
+ if @last_progress.nil? || (normalized - @last_progress).abs >= MIN_PROGRESS_DELTA
53
+ updates[:loading_progress] = normalized
54
+ @last_progress = normalized
55
+ end
56
+ end
57
+
58
+ dispatch(updates) unless updates.empty?
59
+ !updates.empty?
60
+ end
61
+
62
+ def clear
63
+ @last_message = nil
64
+ @last_progress = nil
65
+ dispatch(
66
+ loading_active: false,
67
+ loading_path: nil,
68
+ loading_progress: nil,
69
+ loading_index: nil,
70
+ loading_mode: nil,
71
+ loading_message: nil
72
+ )
73
+ end
74
+
75
+ private
76
+
77
+ def dispatch(payload)
78
+ @state.dispatch(Shoko::Application::Actions::UpdateMenuAction.new(payload))
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../application/selectors/reader_selectors'
4
+
5
+ module Shoko
6
+ module Application
7
+ # Applies a pending jump payload captured in state before reader starts.
8
+ class PendingJumpHandler
9
+ def initialize(state, dependencies, ui_controller)
10
+ @state = state
11
+ @dependencies = dependencies
12
+ @ui_controller = ui_controller
13
+ end
14
+
15
+ def apply
16
+ payload = state.get(%i[reader pending_jump])
17
+ return unless payload
18
+
19
+ apply_chapter_jump(payload)
20
+ apply_selection(payload)
21
+ open_annotation_editor(payload)
22
+ ensure
23
+ clear_pending_jump
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :state, :dependencies, :ui_controller
29
+
30
+ def apply_chapter_jump(payload)
31
+ chapter_index = payload[:chapter_index] || payload['chapter_index']
32
+ return unless chapter_index
33
+
34
+ navigation = resolve_optional(:navigation_service)
35
+ navigation&.jump_to_chapter(chapter_index)
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ def apply_selection(payload)
41
+ range = payload[:selection_range] || payload['selection_range']
42
+ return unless range
43
+
44
+ normalized = normalize_selection(range)
45
+ return unless normalized
46
+
47
+ state.dispatch(Shoko::Application::Actions::UpdateSelectionAction.new(normalized))
48
+ end
49
+
50
+ def open_annotation_editor(payload)
51
+ return unless edit_requested?(payload)
52
+
53
+ annotation = normalized_annotation(payload)
54
+ return unless annotation
55
+
56
+ ui_controller.open_annotation_editor_overlay(
57
+ text: annotation[:text],
58
+ range: annotation[:range],
59
+ chapter_index: annotation[:chapter_index],
60
+ annotation: annotation
61
+ )
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
66
+ def normalize_selection(range)
67
+ service = resolve_optional(:selection_service)
68
+ if service.respond_to?(:normalize_range)
69
+ normalized = service.normalize_range(state, range)
70
+ return normalized if normalized
71
+ end
72
+
73
+ coord = resolve_optional(:coordinate_service)
74
+ return range unless coord
75
+
76
+ rendered = Shoko::Application::Selectors::ReaderSelectors.rendered_lines(state)
77
+ coord.normalize_selection_range(range, rendered)
78
+ rescue StandardError
79
+ nil
80
+ end
81
+
82
+ def clear_pending_jump
83
+ state.dispatch(Shoko::Application::Actions::UpdateSelectionsAction.new(pending_jump: nil))
84
+ end
85
+
86
+ def resolve_optional(key)
87
+ return nil unless dependencies.respond_to?(:resolve)
88
+
89
+ dependencies.resolve(key)
90
+ rescue StandardError
91
+ nil
92
+ end
93
+
94
+ def truthy?(value)
95
+ return value unless value.is_a?(String)
96
+
97
+ !%w[false 0 no].include?(value.downcase)
98
+ end
99
+
100
+ def edit_requested?(payload)
101
+ truthy?(payload[:edit] || payload['edit'])
102
+ end
103
+
104
+ def normalized_annotation(payload)
105
+ raw = payload[:annotation] || payload['annotation']
106
+ return unless raw
107
+
108
+ {
109
+ id: value_from(raw, :id),
110
+ text: value_from(raw, :text),
111
+ note: value_from(raw, :note),
112
+ chapter_index: value_from(raw, :chapter_index),
113
+ range: value_from(raw, :range),
114
+ }
115
+ end
116
+
117
+ def value_from(hash, key)
118
+ hash[key] || hash[key.to_s]
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Application
5
+ # Manages reader startup and shutdown concerns (terminal setup, background worker).
6
+ class ReaderLifecycle
7
+ def initialize(controller, dependencies:, terminal_service:)
8
+ @controller = controller
9
+ @dependencies = dependencies
10
+ @terminal_service = terminal_service
11
+ @background_worker = nil
12
+ end
13
+
14
+ def ensure_background_worker(name: 'reader-background')
15
+ @background_worker ||= resolve_existing(:background_worker)
16
+ return @background_worker if @background_worker
17
+
18
+ factory = resolve_optional(:background_worker_factory)
19
+ return nil unless factory.respond_to?(:call)
20
+
21
+ @background_worker = factory.call(name: name)
22
+ @dependencies.register(:background_worker, @background_worker) if @background_worker
23
+ @background_worker
24
+ rescue StandardError
25
+ nil
26
+ end
27
+
28
+ attr_reader :background_worker
29
+
30
+ def run
31
+ ensure_background_worker
32
+ @terminal_service.setup
33
+ @controller.mark_metrics_start!
34
+ Shoko::Application::ReaderStartupOrchestrator.new(@dependencies).start(@controller)
35
+ @controller.main_loop
36
+ ensure
37
+ shutdown_background_worker
38
+ @terminal_service.cleanup
39
+ end
40
+
41
+ def shutdown_background_worker
42
+ @background_worker&.shutdown
43
+ ensure
44
+ @background_worker = nil
45
+ @dependencies.register(:background_worker, nil)
46
+ end
47
+
48
+ private
49
+
50
+ def resolve_existing(name)
51
+ return nil unless @dependencies.respond_to?(:registered?) && @dependencies.registered?(name)
52
+
53
+ @dependencies.resolve(name)
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ def resolve_optional(name)
59
+ @dependencies.resolve(name)
60
+ rescue StandardError
61
+ nil
62
+ end
63
+ end
64
+ end
65
+ end