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,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../core/services/base_service.rb'
4
+
5
+ module Shoko
6
+ module Adapters::Output::Terminal
7
+ # Terminal interaction service for mouse and rendering coordination
8
+ class TerminalService < BaseService
9
+ # Maintain a global session depth so nested setup/cleanup calls
10
+ # (e.g., menu -> reader) don't flicker or drop to shell.
11
+ class << self
12
+ attr_accessor :session_depth
13
+ end
14
+ @session_depth = 0
15
+
16
+ def enable_mouse
17
+ Terminal.enable_mouse
18
+ end
19
+
20
+ def disable_mouse
21
+ Terminal.disable_mouse
22
+ end
23
+
24
+ def read_input_with_mouse(timeout: nil)
25
+ Terminal.read_input_with_mouse(timeout: timeout)
26
+ end
27
+
28
+ def read_key
29
+ Terminal.read_key
30
+ end
31
+
32
+ def size
33
+ Terminal.size
34
+ end
35
+
36
+ def end_frame
37
+ Terminal.end_frame
38
+ end
39
+
40
+ def setup
41
+ previous_depth = TerminalService.session_depth || 0
42
+ depth = previous_depth + 1
43
+ TerminalService.session_depth = depth
44
+ logger&.debug('terminal.setup', depth: depth)
45
+ return if previous_depth.positive?
46
+
47
+ Terminal.setup
48
+ rescue StandardError => e
49
+ TerminalService.session_depth = previous_depth
50
+ logger&.error('terminal.setup_failed', error: e.message)
51
+ raise
52
+ end
53
+
54
+ def cleanup(force: false)
55
+ return force_cleanup! if force
56
+
57
+ depth = decrement_session_depth
58
+ logger&.debug('terminal.cleanup', depth: depth)
59
+ return if depth.positive?
60
+
61
+ perform_terminal_cleanup
62
+ rescue StandardError => e
63
+ logger&.error('terminal.cleanup_failed', error: e.message)
64
+ raise
65
+ end
66
+
67
+ def force_cleanup
68
+ cleanup(force: true)
69
+ end
70
+
71
+ def start_frame(width: nil, height: nil)
72
+ Terminal.start_frame(width: width, height: height)
73
+ end
74
+
75
+ def read_key_blocking(timeout: nil)
76
+ Terminal.read_key_blocking(timeout: timeout)
77
+ end
78
+
79
+ # Read one blocking key, then drain a few non-blocking extras.
80
+ # Returns an array of keys, or [] if nothing was read.
81
+ #
82
+ # @param limit [Integer] maximum total keys to return
83
+ # @return [Array<String>]
84
+ def read_keys_blocking(limit: 10, timeout: nil)
85
+ first = read_key_blocking(timeout: timeout)
86
+ return [] unless first
87
+
88
+ keys = [first]
89
+ while (extra = read_key)
90
+ keys << extra
91
+ break if keys.size >= limit
92
+ end
93
+ keys
94
+ end
95
+
96
+ # Create a surface for component rendering
97
+ def create_surface
98
+ Shoko::Adapters::Output::Ui::Components::Surface.new(Terminal)
99
+ end
100
+
101
+ protected
102
+
103
+ def required_dependencies
104
+ [] # No dependencies required
105
+ end
106
+
107
+ private
108
+
109
+ def logger
110
+ @logger ||= begin
111
+ resolve(:logger)
112
+ rescue StandardError
113
+ nil
114
+ end
115
+ end
116
+
117
+ def force_cleanup!
118
+ depth = TerminalService.session_depth || 0
119
+ logger&.warn('terminal.cleanup.force', depth: depth)
120
+ TerminalService.session_depth = 0
121
+ perform_terminal_cleanup
122
+ end
123
+
124
+ def decrement_session_depth
125
+ depth = TerminalService.session_depth
126
+ return 0 unless depth
127
+
128
+ new_depth = depth.positive? ? depth - 1 : 0
129
+ TerminalService.session_depth = new_depth
130
+ new_depth
131
+ end
132
+
133
+ def perform_terminal_cleanup
134
+ Terminal.cleanup
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters
5
+ module Output
6
+ module Terminal
7
+ # Utility helpers for measuring and truncating strings (with ANSI support)
8
+ # while respecting grapheme clusters and terminal cell widths.
9
+ module TextMetrics
10
+ begin
11
+ require 'reline'
12
+ require 'reline/unicode'
13
+ DISPLAY_WIDTH = ->(str) { Reline::Unicode.calculate_width(str) }
14
+ rescue LoadError, NameError
15
+ DISPLAY_WIDTH = lambda do |str|
16
+ str.to_s.scan(/\X/).sum { |cluster| cluster.length }
17
+ end
18
+ end
19
+ TAB_SIZE = 4
20
+ CSI_REGEX = %r{\e\[[0-?]*[ -/]*[@-~]}
21
+ ANSI_REGEX = CSI_REGEX
22
+ TOKEN_REGEX = /#{CSI_REGEX}|\X/m
23
+
24
+ module_function
25
+
26
+ def visible_length(text)
27
+ cell_data_for(strip_ansi(text.to_s)).sum { |cell| cell[:display_width] }
28
+ end
29
+
30
+ def cell_data_for(text)
31
+ expanded = expand_tabs(text.to_s)
32
+ cells = []
33
+ char_index = 0
34
+ screen_x = 0
35
+
36
+ expanded.each_grapheme_cluster do |cluster|
37
+ grapheme_length = cluster.length
38
+ display_width = display_width_for(cluster)
39
+
40
+ cells << {
41
+ cluster: cluster,
42
+ char_start: char_index,
43
+ char_end: char_index + grapheme_length,
44
+ display_width: display_width,
45
+ screen_x: screen_x,
46
+ }
47
+
48
+ char_index += grapheme_length
49
+ screen_x += display_width
50
+ end
51
+
52
+ cells
53
+ end
54
+
55
+ def strip_ansi(text)
56
+ text.to_s.gsub(ANSI_REGEX, '')
57
+ end
58
+
59
+ def display_width_for(cluster)
60
+ return TAB_SIZE if cluster == "\t"
61
+ return 0 if cluster == "\u00AD"
62
+
63
+ width = DISPLAY_WIDTH.call(cluster)
64
+ width = 1 if width <= 0 && !cluster.empty?
65
+ width
66
+ rescue StandardError
67
+ cluster.length
68
+ end
69
+
70
+ def expand_tabs(text, tab_size: TAB_SIZE)
71
+ column = 0
72
+ buffer = +''
73
+ text.to_s.each_grapheme_cluster do |cluster|
74
+ if cluster == "\t"
75
+ spaces = tab_size - (column % tab_size)
76
+ buffer << (' ' * spaces)
77
+ column += spaces
78
+ else
79
+ buffer << cluster
80
+ column += display_width_for(cluster)
81
+ end
82
+ end
83
+ buffer
84
+ end
85
+
86
+ def wrap_plain_text(line, width)
87
+ normalized = expand_tabs(line.to_s)
88
+ return [''] if normalized.empty?
89
+ return [normalized] if width.to_i <= 0
90
+
91
+ wrapped = []
92
+ current_line = +''
93
+ current_width = 0
94
+
95
+ width_i = width.to_i
96
+
97
+ normalized.split(/\s+/).each do |word|
98
+ next if word.nil? || word.empty?
99
+
100
+ word_width = visible_length(word)
101
+
102
+ if current_width.zero?
103
+ current_line.replace(word)
104
+ current_width = word_width
105
+ elsif current_width + 1 + word_width <= width_i
106
+ current_line << ' ' unless current_line.empty?
107
+ current_line << word
108
+ current_width += 1 + word_width
109
+ else
110
+ wrapped << current_line.dup unless current_line.empty?
111
+ current_line.replace(word)
112
+ current_width = word_width
113
+ end
114
+ end
115
+
116
+ wrapped << current_line.dup unless current_line.empty?
117
+ wrapped = [''] if wrapped.empty?
118
+ wrapped
119
+ end
120
+
121
+ def truncate_to(text, width, start_column: 0)
122
+ max_width = width.to_i
123
+ return '' if max_width <= 0
124
+
125
+ str = text.to_s
126
+ return '' if str.empty?
127
+
128
+ # Fast-path: preserve original when it already fits and contains no tab/newline.
129
+ if !(str.include?("\t") || str.include?("\n") || str.include?("\r")) && (max_width >= visible_length(str))
130
+ return str
131
+ end
132
+
133
+ buffer = +''
134
+ current_width = 0
135
+ column = start_column.to_i
136
+
137
+ str.scan(TOKEN_REGEX).each do |token|
138
+ if token.start_with?("\e[")
139
+ buffer << token
140
+ next
141
+ end
142
+
143
+ next if token == "\e"
144
+
145
+ remaining = max_width - current_width
146
+ break if remaining <= 0
147
+
148
+ case token
149
+ when "\t"
150
+ spaces = TAB_SIZE - (column % TAB_SIZE)
151
+ take = [spaces, remaining].min
152
+ buffer << (' ' * take)
153
+ current_width += take
154
+ column += take
155
+ when "\n", "\r"
156
+ # Never allow newlines to affect terminal layout; treat as a space.
157
+ break if remaining < 1
158
+
159
+ buffer << ' '
160
+ current_width += 1
161
+ column += 1
162
+ else
163
+ token_width = display_width_for(token)
164
+ break if token_width > remaining
165
+
166
+ buffer << token
167
+ current_width += token_width
168
+ column += token_width
169
+ end
170
+ end
171
+
172
+ buffer
173
+ end
174
+
175
+ def pad_right(text, width, start_column: 0, pad: ' ')
176
+ w = width.to_i
177
+ return '' if w <= 0
178
+
179
+ clipped = truncate_to(text.to_s, w, start_column: start_column)
180
+ pad_len = w - visible_length(clipped)
181
+ pad_len.positive? ? (clipped + (pad.to_s * pad_len)) : clipped
182
+ end
183
+
184
+ def pad_left(text, width, start_column: 0, pad: ' ')
185
+ w = width.to_i
186
+ return '' if w <= 0
187
+
188
+ clipped = truncate_to(text.to_s, w, start_column: start_column)
189
+ pad_len = w - visible_length(clipped)
190
+ pad_len.positive? ? ((pad.to_s * pad_len) + clipped) : clipped
191
+ end
192
+
193
+ def pad_center(text, width, start_column: 0, pad: ' ')
194
+ w = width.to_i
195
+ return '' if w <= 0
196
+
197
+ clipped = truncate_to(text.to_s, w, start_column: start_column)
198
+ pad_len = w - visible_length(clipped)
199
+ return clipped unless pad_len.positive?
200
+
201
+ left = pad_len / 2
202
+ right = pad_len - left
203
+ (pad.to_s * left) + clipped + (pad.to_s * right)
204
+ end
205
+
206
+ # Wraps text by terminal cell width without splitting grapheme clusters.
207
+ # Preserves newlines and expands tabs relative to the provided start column.
208
+ #
209
+ # This is intended for UI text entry/display helpers (notes, dialogs),
210
+ # not for paragraph-aware ebook formatting.
211
+ def wrap_cells(text, width, start_column: 0)
212
+ w = width.to_i
213
+ return [''] if w <= 0
214
+
215
+ lines = []
216
+ line = +''
217
+ line_width = 0
218
+ column = start_column.to_i
219
+
220
+ text.to_s.each_grapheme_cluster do |cluster|
221
+ if cluster == "\n"
222
+ lines << line.dup
223
+ line.clear
224
+ line_width = 0
225
+ column = start_column.to_i
226
+ next
227
+ end
228
+
229
+ cluster = ' ' if cluster == "\r"
230
+
231
+ if cluster == "\t"
232
+ spaces = TAB_SIZE - (column % TAB_SIZE)
233
+ spaces.times do
234
+ if line_width >= w
235
+ lines << line.dup
236
+ line.clear
237
+ line_width = 0
238
+ column = start_column.to_i
239
+ end
240
+ line << ' '
241
+ line_width += 1
242
+ column += 1
243
+ end
244
+ next
245
+ end
246
+
247
+ cw = display_width_for(cluster)
248
+ next if cw <= 0
249
+ next if cw > w
250
+
251
+ if line_width.positive? && (line_width + cw > w)
252
+ lines << line.dup
253
+ line.clear
254
+ line_width = 0
255
+ column = start_column.to_i
256
+ end
257
+
258
+ break if cw > (w - line_width)
259
+
260
+ line << cluster
261
+ line_width += cw
262
+ column += cw
263
+ end
264
+
265
+ lines << line.dup
266
+ lines = [''] if lines.empty?
267
+ lines
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Output::Ui::Builders
5
+ # Builder for page setup configuration
6
+ class PageSetupBuilder
7
+ attr_reader :setup
8
+
9
+ def initialize
10
+ @setup = PageSetup.new
11
+ end
12
+
13
+ def with_lines(lines)
14
+ @setup.lines = lines
15
+ self
16
+ end
17
+
18
+ def with_wrapped(wrapped)
19
+ @setup.wrapped = wrapped
20
+ self
21
+ end
22
+
23
+ def with_dimensions(col_width, content_height, displayable_lines)
24
+ @setup.col_width = col_width
25
+ @setup.content_height = content_height
26
+ @setup.displayable_lines = displayable_lines
27
+ self
28
+ end
29
+
30
+ def with_position(col_start)
31
+ @setup.col_start = col_start
32
+ self
33
+ end
34
+
35
+ def build
36
+ @setup
37
+ end
38
+ end
39
+
40
+ # Data structure representing page setup parameters
41
+ PageSetup = Struct.new(
42
+ :lines, :wrapped, :col_width, :col_start,
43
+ :content_height, :displayable_lines,
44
+ keyword_init: true
45
+ )
46
+ end
47
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../terminal/terminal.rb'
4
+ require_relative '../../../terminal/text_metrics.rb'
5
+
6
+ module Shoko
7
+ module Adapters::Output::Ui::Components
8
+ # Namespace for annotation editor overlay helpers.
9
+ module AnnotationEditorOverlay
10
+ # Renders the footer buttons for the annotation editor overlay.
11
+ class FooterRenderer
12
+ SegmentSpec = Struct.new(:row, :col, :key, :text, :width, keyword_init: true)
13
+
14
+ def initialize(background:, text_fg:, key_fg:)
15
+ @background = background
16
+ @text_fg = text_fg
17
+ @key_fg = key_fg
18
+ end
19
+
20
+ def render(surface, bounds, geometry)
21
+ button_row = geometry.buttons_row
22
+ fill_footer(surface, bounds, geometry, button_row)
23
+ save_spec, cancel_spec = segment_specs(geometry, button_row)
24
+
25
+ draw_segment(surface, bounds, save_spec)
26
+ draw_segment(surface, bounds, cancel_spec)
27
+
28
+ abs_row = geometry.button_row_abs(bounds)
29
+ {
30
+ save: region_for(bounds, abs_row, save_spec),
31
+ cancel: region_for(bounds, abs_row, cancel_spec),
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def fill_footer(surface, bounds, geometry, button_row)
38
+ footer_bg = "#{@background}#{' ' * geometry.content_width}#{Terminal::ANSI::RESET}"
39
+ surface.write(bounds, button_row, geometry.content_x, footer_bg)
40
+ end
41
+
42
+ def segment_specs(geometry, button_row)
43
+ save_spec = build_segment_spec(geometry, button_row, key: 'Ctrl+S', text: 'Save', align: :left)
44
+ cancel_spec = build_segment_spec(geometry, button_row, key: 'Esc', text: 'Cancel', align: :right)
45
+
46
+ min_cancel_col = save_spec.col + save_spec.width + 2
47
+ max_cancel_col = geometry.content_x + geometry.content_width - cancel_spec.width
48
+ cancel_spec.col = [min_cancel_col, max_cancel_col].min if cancel_spec.col < min_cancel_col
49
+
50
+ [save_spec, cancel_spec]
51
+ end
52
+
53
+ def build_segment_spec(geometry, row, key:, text:, align:)
54
+ label = "#{key} #{text}"
55
+ width = Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(label)
56
+
57
+ col = if align == :right
58
+ geometry.content_x + geometry.content_width - width
59
+ else
60
+ geometry.content_x
61
+ end
62
+ col = [col, geometry.content_x].max
63
+
64
+ SegmentSpec.new(row: row, col: col, key: key, text: text, width: width)
65
+ end
66
+
67
+ def draw_segment(surface, bounds, spec)
68
+ reset = Terminal::ANSI::RESET
69
+ label = "#{@background}#{@key_fg}#{spec.key}#{reset}" \
70
+ "#{@background}#{@text_fg} #{spec.text}#{reset}"
71
+ surface.write(bounds, spec.row, spec.col, label)
72
+ end
73
+
74
+ def region_for(bounds, abs_row, spec)
75
+ { row: abs_row, col: bounds.x + spec.col - 1, width: spec.width }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Output::Ui::Components
5
+ # Namespace for annotation editor overlay helpers.
6
+ module AnnotationEditorOverlay
7
+ # Geometry helper for the annotation editor overlay content region.
8
+ class Geometry
9
+ attr_reader :layout
10
+
11
+ def initialize(layout)
12
+ @layout = layout
13
+ end
14
+
15
+ def content_x
16
+ layout.inner_x + 2
17
+ end
18
+
19
+ def content_width
20
+ [layout.inner_width - 4, 1].max
21
+ end
22
+
23
+ def header_row
24
+ layout.inner_y + 1
25
+ end
26
+
27
+ def subheader_row
28
+ header_row + 1
29
+ end
30
+
31
+ def label_row
32
+ subheader_row + 2
33
+ end
34
+
35
+ def note_top
36
+ label_row + 1
37
+ end
38
+
39
+ def note_rows
40
+ [buttons_row - note_top, 1].max
41
+ end
42
+
43
+ def text_x
44
+ content_x
45
+ end
46
+
47
+ def text_width
48
+ content_width
49
+ end
50
+
51
+ def buttons_row
52
+ layout.inner_y + layout.inner_height - 2
53
+ end
54
+
55
+ def button_row_abs(bounds)
56
+ bounds.y + buttons_row - 1
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../ui/text_utils'
4
+ require_relative '../../../terminal/text_metrics.rb'
5
+ require_relative '../../../terminal/terminal.rb'
6
+
7
+ module Shoko
8
+ module Adapters::Output::Ui::Components
9
+ # Namespace for annotation editor overlay helpers.
10
+ module AnnotationEditorOverlay
11
+ # Renders note contents and cursor inside the annotation editor overlay.
12
+ class NoteRenderer
13
+ def initialize(background:, text_color:, cursor_color:, geometry:, placeholder_text: nil,
14
+ placeholder_color: nil)
15
+ @background = background
16
+ @text_color = text_color
17
+ @cursor_color = cursor_color
18
+ @geometry = geometry
19
+ @placeholder_text = placeholder_text
20
+ @placeholder_color = placeholder_color
21
+ end
22
+
23
+ def render(surface, bounds, note:, cursor_pos:)
24
+ note_text = note.to_s
25
+ wrapped_note = wrap_lines(note_text, @geometry.text_width)
26
+ cursor_lines = wrap_lines(note_text[0...cursor_pos], @geometry.text_width)
27
+ cursor_line_index = [cursor_lines.length - 1, 0].max
28
+
29
+ visible_start, visible_lines = visible_window(wrapped_note, cursor_line_index, @geometry.note_rows)
30
+ render_lines(surface, bounds, visible_lines)
31
+ render_placeholder(surface, bounds) if note_text.strip.empty?
32
+ cursor_row, cursor_col = cursor_position(cursor_lines, cursor_line_index, visible_start)
33
+ render_cursor(surface, bounds, cursor_row, cursor_col)
34
+ end
35
+
36
+ private
37
+
38
+ def wrap_lines(text, width)
39
+ lines = UI::TextUtils.wrap_text(text.to_s, width)
40
+ lines.empty? ? [''] : lines
41
+ end
42
+
43
+ def visible_window(lines, cursor_line_index, note_rows)
44
+ max_start = [lines.length - note_rows, 0].max
45
+ visible_start = [cursor_line_index - note_rows + 1, 0].max
46
+ visible_start = [visible_start, max_start].min
47
+ visible_lines = lines[visible_start, note_rows] || []
48
+ visible_lines += Array.new(note_rows - visible_lines.length, '')
49
+ [visible_start, visible_lines]
50
+ end
51
+
52
+ def render_lines(surface, bounds, lines)
53
+ lines.each_with_index do |line, idx|
54
+ row = @geometry.note_top + idx
55
+ padded = UI::TextUtils.pad_right(line, @geometry.text_width)
56
+ surface.write(bounds, row, @geometry.text_x,
57
+ "#{@background}#{@text_color}#{padded}#{Terminal::ANSI::RESET}")
58
+ end
59
+ end
60
+
61
+ def render_placeholder(surface, bounds)
62
+ return unless @placeholder_text && @placeholder_color
63
+
64
+ truncated = Shoko::Adapters::Output::Terminal::TextMetrics.truncate_to(@placeholder_text, @geometry.text_width)
65
+ padded = UI::TextUtils.pad_right(truncated, @geometry.text_width)
66
+ surface.write(bounds, @geometry.note_top, @geometry.text_x,
67
+ "#{@background}#{@placeholder_color}#{padded}#{Terminal::ANSI::RESET}")
68
+ end
69
+
70
+ def cursor_position(cursor_lines, cursor_line_index, visible_start)
71
+ cursor_display_row = (cursor_line_index - visible_start).clamp(0, @geometry.note_rows - 1)
72
+ cursor_row = @geometry.note_top + cursor_display_row
73
+ cursor_line = cursor_lines.last || ''
74
+ cursor_col = @geometry.text_x + [Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(cursor_line),
75
+ @geometry.text_width - 1].min
76
+ [cursor_row, cursor_col]
77
+ end
78
+
79
+ def render_cursor(surface, bounds, cursor_row, cursor_col)
80
+ surface.write(bounds, cursor_row, cursor_col,
81
+ "#{@background}#{@cursor_color}_#{Terminal::ANSI::RESET}")
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end