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,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_component'
4
+ require_relative '../ui/list_helpers'
5
+ require_relative '../ui/text_utils'
6
+
7
+ module Shoko
8
+ module Adapters::Output::Ui::Components
9
+ module Sidebar
10
+ # Annotations tab renderer for sidebar
11
+ class AnnotationsTabRenderer < BaseComponent
12
+ include Adapters::Output::Ui::Constants::UI
13
+
14
+ ItemCtx = Struct.new(:annotation, :index, :selected_index, :y, keyword_init: true)
15
+
16
+ def initialize(state)
17
+ super()
18
+ @state = state
19
+ end
20
+
21
+ BoundsMetrics = Struct.new(:x, :y, :width, :height, keyword_init: true)
22
+
23
+ def do_render(surface, bounds)
24
+ metrics = metrics_for(bounds)
25
+ annotations = @state.get(%i[reader annotations]) || []
26
+ selected_index = @state.get(%i[reader sidebar_annotations_selected]) || 0
27
+
28
+ return render_empty_message(surface, bounds, metrics) if annotations.empty?
29
+
30
+ render_annotations_list(surface, bounds, metrics, annotations, selected_index)
31
+ end
32
+
33
+ private
34
+
35
+ def metrics_for(bounds)
36
+ BoundsMetrics.new(x: 1, y: 1, width: bounds.width, height: bounds.height)
37
+ end
38
+
39
+ def render_empty_message(surface, bounds, metrics)
40
+ reset = Terminal::ANSI::RESET
41
+ bw = metrics.width
42
+ bh = metrics.height
43
+ messages = [
44
+ 'No annotations yet',
45
+ '',
46
+ 'Select text while reading',
47
+ 'to create annotations',
48
+ ]
49
+
50
+ start_y = ((bh - messages.length) / 2) + 1
51
+ messages.each_with_index do |message, i|
52
+ msg_width = Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(message)
53
+ x = [(bw - msg_width) / 2, 2].max
54
+ y = start_y + i
55
+ surface.write(bounds, y, x, "#{COLOR_TEXT_DIM}#{message}#{reset}")
56
+ end
57
+ end
58
+
59
+ def render_annotations_list(surface, bounds, metrics, annotations, selected_index)
60
+ # Each annotation takes 3 lines: text excerpt, note (if any), location
61
+ item_height = 3
62
+ bh = metrics.height
63
+ by = metrics.y
64
+ visible_items = [bh / item_height, 1].max
65
+ window_start, window_items = UI::ListHelpers.slice_visible(annotations, visible_items, selected_index)
66
+ current_y = by
67
+
68
+ window_items.each_with_index do |annotation, offset|
69
+ idx = window_start + offset
70
+ break if current_y + item_height > by + bh
71
+
72
+ ctx = ItemCtx.new(annotation: annotation, index: idx, selected_index: selected_index, y: current_y)
73
+ render_annotation_item(surface, bounds, metrics, ctx)
74
+ current_y += item_height
75
+ end
76
+ end
77
+
78
+ def render_annotation_item(surface, bounds, metrics, ctx)
79
+ is_selected = (ctx.index == ctx.selected_index)
80
+ bx = metrics.x
81
+ bw = metrics.width
82
+ max_width = bw - 4
83
+
84
+ # Color indicator based on highlight color
85
+ ann = ctx.annotation
86
+ color_indicator = get_color_indicator(ann['color'])
87
+
88
+ # Text excerpt (first line)
89
+ text = ann['text'] || ''
90
+ excerpt = text.tr("\n", ' ').strip
91
+ excerpt = UI::TextUtils.truncate_text(excerpt, [max_width - 6, 1].max)
92
+
93
+ reset = Terminal::ANSI::RESET
94
+ if is_selected
95
+ prefix = "#{COLOR_TEXT_ACCENT}#{SELECTION_POINTER}#{reset}"
96
+ note_style = COLOR_TEXT_PRIMARY
97
+ location_style = COLOR_TEXT_SECONDARY
98
+ else
99
+ prefix = ' '
100
+ note_style = COLOR_TEXT_DIM
101
+ location_style = COLOR_TEXT_DIM
102
+ end
103
+ text_line = "#{prefix}#{color_indicator}#{excerpt}#{reset}"
104
+ row = ctx.y
105
+ col1 = bx + 1
106
+ surface.write(bounds, row, col1, text_line)
107
+
108
+ # Note (second line, if exists)
109
+ note = ann['note']
110
+ if note && !note.strip.empty?
111
+ note_text = note.tr("\n", ' ').strip
112
+ note_text = UI::TextUtils.truncate_text(note_text, [max_width - 5, 1].max)
113
+
114
+ note_line = " #{Terminal::ANSI::ITALIC}#{note_style}✎ #{note_text}#{reset}"
115
+ surface.write(bounds, row + 1, col1, note_line)
116
+ end
117
+
118
+ # Location (third line)
119
+ location = format_location(ann)
120
+ location = UI::TextUtils.truncate_text(location, [max_width, 1].max)
121
+ location_line = " #{location_style}#{location}#{reset}"
122
+ surface.write(bounds, row + 2, col1, location_line)
123
+ end
124
+
125
+ def get_color_indicator(color)
126
+ reset = Terminal::ANSI::RESET
127
+ case color&.downcase
128
+ when 'yellow', 'highlight'
129
+ "#{COLOR_TEXT_WARNING}●#{reset} "
130
+ when 'red'
131
+ "#{COLOR_TEXT_ERROR}●#{reset} "
132
+ when 'green'
133
+ "#{COLOR_TEXT_SUCCESS}●#{reset} "
134
+ when 'blue'
135
+ "#{COLOR_TEXT_ACCENT}●#{reset} "
136
+ else
137
+ "#{COLOR_TEXT_PRIMARY}●#{reset} "
138
+ end
139
+ end
140
+
141
+ def format_location(annotation)
142
+ ch_idx = annotation['chapter_index'] || 0
143
+ chapter_title = annotation['chapter_title'] || "Ch. #{ch_idx + 1}"
144
+
145
+ # Try to calculate percentage if we have position info
146
+ percentage = ''
147
+ start_pos = annotation['start_position']
148
+ ch_len = annotation['chapter_length']
149
+ if start_pos && ch_len
150
+ pct = (start_pos.to_f / ch_len * 100).round
151
+ percentage = " · #{pct}%"
152
+ end
153
+
154
+ "#{chapter_title}#{percentage}"
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_component'
4
+ require_relative '../ui/list_helpers'
5
+ require_relative '../ui/text_utils'
6
+
7
+ module Shoko
8
+ module Adapters::Output::Ui::Components
9
+ module Sidebar
10
+ # Bookmarks tab renderer for sidebar
11
+ class BookmarksTabRenderer < BaseComponent
12
+ include Adapters::Output::Ui::Constants::UI
13
+
14
+ ItemCtx = Struct.new(:bookmark, :doc, :index, :selected_index, :y, keyword_init: true)
15
+
16
+ def initialize(state, dependencies)
17
+ super()
18
+ @state = state
19
+ @dependencies = dependencies
20
+ end
21
+
22
+ BoundsMetrics = Struct.new(:x, :y, :width, :height, keyword_init: true)
23
+
24
+ def do_render(surface, bounds)
25
+ metrics = metrics_for(bounds)
26
+ bookmarks = @state.get(%i[reader bookmarks]) || []
27
+ doc = resolve_document
28
+ selected_index = @state.get(%i[reader sidebar_bookmarks_selected]) || 0
29
+
30
+ return render_empty_message(surface, bounds, metrics) if bookmarks.empty?
31
+
32
+ render_bookmarks_list(surface, bounds, metrics, bookmarks, doc, selected_index)
33
+ end
34
+
35
+ private
36
+
37
+ def metrics_for(bounds)
38
+ BoundsMetrics.new(x: 1, y: 1, width: bounds.width, height: bounds.height)
39
+ end
40
+
41
+ def render_empty_message(surface, bounds, metrics)
42
+ reset = Terminal::ANSI::RESET
43
+ bw = metrics.width
44
+ bh = metrics.height
45
+ messages = [
46
+ 'No bookmarks yet',
47
+ '',
48
+ 'Press "b" while reading',
49
+ 'to add a bookmark',
50
+ ]
51
+
52
+ start_y = ((bh - messages.length) / 2) + 1
53
+ messages.each_with_index do |message, i|
54
+ msg_width = Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(message)
55
+ x = [(bw - msg_width) / 2, 2].max
56
+ y = start_y + i
57
+ surface.write(bounds, y, x, "#{COLOR_TEXT_DIM}#{message}#{reset}")
58
+ end
59
+ end
60
+
61
+ def render_bookmarks_list(surface, bounds, metrics, bookmarks, doc, selected_index)
62
+ # Each bookmark takes 2 lines: title/chapter + snippet
63
+ item_height = 2
64
+ bh = metrics.height
65
+ by = metrics.y
66
+ visible_items = [bh / item_height, 1].max
67
+ visible_start, window_items = UI::ListHelpers.slice_visible(bookmarks, visible_items, selected_index)
68
+ current_y = by
69
+ end_y = by + bh
70
+
71
+ window_items.each_with_index do |bookmark, offset|
72
+ idx = visible_start + offset
73
+ break if current_y + item_height > end_y
74
+
75
+ ctx = ItemCtx.new(bookmark: bookmark, doc: doc, index: idx, selected_index: selected_index, y: current_y)
76
+ render_bookmark_item(surface, bounds, metrics, ctx)
77
+ current_y += item_height
78
+ end
79
+ end
80
+
81
+ def render_bookmark_item(surface, bounds, metrics, ctx)
82
+ reset = Terminal::ANSI::RESET
83
+ bx = metrics.x
84
+ bw = metrics.width
85
+ row = ctx.y
86
+ col = bx + 1
87
+ bm = ctx.bookmark
88
+ is_selected = (ctx.index == ctx.selected_index)
89
+ max_width = bw - 4
90
+
91
+ # Get chapter info
92
+ ch_index = bm.chapter_index
93
+ chapter = ctx.doc&.get_chapter(ch_index)
94
+ chapter_title = chapter&.title || "Chapter #{ch_index + 1}"
95
+
96
+ # First line: Chapter title with bookmark indicator
97
+ if is_selected
98
+ prefix = "#{COLOR_TEXT_ACCENT}#{SELECTION_POINTER}#{reset}"
99
+ title_style = SELECTION_HIGHLIGHT
100
+ snippet_style = COLOR_TEXT_SECONDARY
101
+ else
102
+ prefix = ' '
103
+ title_style = COLOR_TEXT_PRIMARY
104
+ snippet_style = COLOR_TEXT_DIM
105
+ end
106
+ chapter_text = chapter_title.to_s
107
+ chapter_text = UI::TextUtils.truncate_text(chapter_text, [max_width - 6, 1].max)
108
+
109
+ # Modern bookmark icon
110
+ bookmark_icon = "#{COLOR_TEXT_WARNING}◆#{reset}"
111
+ title_line = "#{prefix}#{bookmark_icon} #{title_style}#{chapter_text}#{reset}"
112
+ surface.write(bounds, row, col, title_line)
113
+
114
+ # Second line: Text snippet and position
115
+ snippet = bm.text_snippet || ''
116
+ snippet = UI::TextUtils.truncate_text(snippet, [max_width - 11, 1].max)
117
+
118
+ # Add position indicator if available
119
+ position_text = ''
120
+ if bm.respond_to?(:position_percentage)
121
+ pct = bm.position_percentage
122
+ position_text = " (#{pct}%)" if pct
123
+ end
124
+
125
+ snippet_line = " #{snippet_style}\"#{snippet}\"#{position_text}#{reset}"
126
+ surface.write(bounds, row + 1, col, snippet_line)
127
+ end
128
+
129
+ def resolve_document
130
+ return @dependencies.resolve(:document) if @dependencies.respond_to?(:resolve)
131
+
132
+ nil
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_component'
4
+ require_relative '../../../terminal/text_metrics.rb'
5
+
6
+ module Shoko
7
+ module Adapters::Output::Ui::Components
8
+ module Sidebar
9
+ # Modern bottom tab navigation for sidebar
10
+ class TabHeaderComponent < BaseComponent
11
+ include Adapters::Output::Ui::Constants::UI
12
+
13
+ RenderTarget = Struct.new(:surface, :bounds, keyword_init: true) do
14
+ def write(row, col, text)
15
+ surface.write(bounds, row, col, text)
16
+ end
17
+ end
18
+ private_constant :RenderTarget
19
+
20
+ TABS = %i[toc annotations bookmarks].freeze
21
+ TAB_INFO = {
22
+ toc: { label: 'Contents', icon: '◉', key: 'T' },
23
+ annotations: { label: 'Notes', icon: '◈', key: 'A' },
24
+ bookmarks: { label: 'Bookmarks', icon: '◆', key: 'B' },
25
+ }.freeze
26
+
27
+ def initialize(state)
28
+ super() # Call BaseComponent constructor with no services
29
+ @state = state
30
+ end
31
+
32
+ def do_render(surface, bounds)
33
+ target = RenderTarget.new(surface: surface, bounds: bounds)
34
+ draw_separator(target)
35
+ render_tab_navigation(target)
36
+ end
37
+
38
+ def tab_for_point(bounds, col, row)
39
+ return nil unless bounds
40
+
41
+ local_row = row.to_i - bounds.y + 1
42
+ local_col = col.to_i - bounds.x + 1
43
+ return nil unless local_row.between?(2, 3)
44
+ return nil unless local_col.between?(1, bounds.width)
45
+
46
+ tab_width = (bounds.width - 2) / TABS.length
47
+ return nil if tab_width <= 0
48
+
49
+ offset = local_col - 2
50
+ return nil if offset.negative?
51
+
52
+ max_tabs_width = tab_width * TABS.length
53
+ return nil if offset >= max_tabs_width
54
+
55
+ TABS[offset / tab_width]
56
+ end
57
+
58
+ # Internal context for rendering a single tab button.
59
+ TabButtonCtx = Struct.new(
60
+ :tab,
61
+ :x,
62
+ :width,
63
+ :active,
64
+ :icon,
65
+ :label,
66
+ :key,
67
+ :row_top,
68
+ :row_bottom,
69
+ keyword_init: true
70
+ )
71
+ private_constant :TabButtonCtx
72
+
73
+ private
74
+
75
+ def draw_separator(target)
76
+ width = target.bounds.width
77
+ line_width = [width - 1, 0].max
78
+ return if line_width.zero?
79
+
80
+ reset = Terminal::ANSI::RESET
81
+ target.write(1, 1, "#{COLOR_TEXT_DIM}#{'─' * line_width}#{reset}")
82
+ junction_col = width
83
+ target.write(1, junction_col, "#{COLOR_TEXT_DIM}┤#{reset}") if junction_col.positive?
84
+ end
85
+
86
+ def render_tab_navigation(target)
87
+ tab_width = (target.bounds.width - 2) / TABS.length
88
+ row_top = 2
89
+ row_bottom = 3
90
+
91
+ active_tab = Shoko::Application::Selectors::ReaderSelectors.sidebar_active_tab(@state)
92
+ TABS.each_with_index do |tab, index|
93
+ x_pos = 2 + (index * tab_width)
94
+ active = (active_tab == tab)
95
+ ctx = build_tab_button_ctx(tab, x_pos, tab_width, active, row_top: row_top, row_bottom: row_bottom)
96
+ render_tab_button(target, ctx)
97
+ end
98
+ end
99
+
100
+ def build_tab_button_ctx(tab, x_pos, width, active, row_top:, row_bottom:)
101
+ info = TAB_INFO.fetch(tab)
102
+ TabButtonCtx.new(
103
+ tab: tab,
104
+ x: x_pos,
105
+ width: width,
106
+ active: active,
107
+ icon: info[:icon],
108
+ label: info[:label],
109
+ key: info[:key],
110
+ row_top: row_top,
111
+ row_bottom: row_bottom
112
+ )
113
+ end
114
+
115
+ def render_tab_button(target, ctx)
116
+ ctx.active ? render_active(target, ctx) : render_inactive(target, ctx)
117
+ end
118
+
119
+ def render_active(target, ctx)
120
+ reset = Terminal::ANSI::RESET
121
+ icon_text = "#{COLOR_TEXT_ACCENT}#{ctx.icon}#{reset}"
122
+ label_text = "#{COLOR_TEXT_PRIMARY}#{ctx.label}#{reset}"
123
+
124
+ content = "#{ctx.icon} #{ctx.label}"
125
+ content_len = Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(content)
126
+ padding = [(ctx.width - content_len) / 2, 0].max
127
+ padded_col = ctx.x + padding
128
+
129
+ target.write(ctx.row_top, padded_col, icon_text)
130
+ target.write(ctx.row_top, padded_col + 2, label_text)
131
+ render_active_indicator(target, ctx, content_len, reset)
132
+ end
133
+
134
+ def render_active_indicator(target, ctx, content_len, reset)
135
+ indicator_width = [content_len, ctx.width - 2].min
136
+ return if indicator_width <= 0
137
+
138
+ start = ctx.x + [(ctx.width - indicator_width) / 2, 0].max
139
+ line = "#{COLOR_TEXT_ACCENT}#{'▬' * indicator_width}#{reset}"
140
+ target.write(ctx.row_bottom, start, line)
141
+ end
142
+
143
+ def render_inactive(target, ctx)
144
+ reset = Terminal::ANSI::RESET
145
+ icon_text = "#{COLOR_TEXT_DIM}#{ctx.icon}#{reset}"
146
+ key_hint = "#{COLOR_TEXT_DIM}[#{ctx.key}]#{reset}"
147
+
148
+ icon_padding = [(ctx.width - 1) / 2, 0].max
149
+ target.write(ctx.row_top, ctx.x + icon_padding, icon_text)
150
+
151
+ key_padding = [(ctx.width - 3) / 2, 0].max
152
+ target.write(ctx.row_bottom, ctx.x + key_padding, key_hint)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_component'
4
+ require_relative 'toc_tab_support'
5
+
6
+ module Shoko
7
+ module Adapters::Output::Ui::Components
8
+ module Sidebar
9
+ # TOC tab renderer for sidebar
10
+ class TocTabRenderer < BaseComponent
11
+ include Adapters::Output::Ui::Constants::UI
12
+
13
+ NullSurface = Struct.new(:_noop) do
14
+ def write(*_args); end
15
+ end
16
+ private_constant :NullSurface
17
+
18
+ def initialize(state, dependencies = nil)
19
+ super()
20
+ @state = state
21
+ @dependencies = dependencies
22
+ @wrap_cache = {}
23
+ @cache_document_id = nil
24
+ @entries_cache_key = nil
25
+ @entries_cache = nil
26
+ end
27
+
28
+ def do_render(surface, bounds)
29
+ doc = document
30
+ refresh_wrap_cache(doc)
31
+ entries_cache = entries_cache_for(@state, doc, bounds)
32
+ context = RenderContext.new(surface, bounds, @state, doc, wrap_cache: @wrap_cache,
33
+ entries_cache: entries_cache)
34
+ @last_bounds_signature = bounds_signature(bounds)
35
+ @last_scroll_metrics = context.scroll_metrics
36
+ ComponentOrchestrator.new(context).render
37
+ end
38
+
39
+ def entry_at(bounds, col, row)
40
+ return nil unless bounds
41
+
42
+ local_row = row.to_i - bounds.y + 1
43
+ local_col = col.to_i - bounds.x + 1
44
+ return nil unless local_row.between?(1, bounds.height)
45
+ return nil unless local_col.between?(1, bounds.width)
46
+
47
+ doc = document
48
+ refresh_wrap_cache(doc)
49
+ entries_cache = entries_cache_for(@state, doc, bounds)
50
+ context = RenderContext.new(NullSurface.new, bounds, @state, doc, wrap_cache: @wrap_cache,
51
+ entries_cache: entries_cache)
52
+ context.entries_layout.item_at(local_row)
53
+ end
54
+
55
+ def scroll_metrics(bounds)
56
+ return nil unless bounds
57
+
58
+ signature = bounds_signature(bounds)
59
+ if @last_scroll_metrics && @last_bounds_signature == signature
60
+ return @last_scroll_metrics
61
+ end
62
+
63
+ doc = document
64
+ refresh_wrap_cache(doc)
65
+ entries_cache = entries_cache_for(@state, doc, bounds)
66
+ context = RenderContext.new(NullSurface.new, bounds, @state, doc, wrap_cache: @wrap_cache,
67
+ entries_cache: entries_cache)
68
+ @last_bounds_signature = signature
69
+ @last_scroll_metrics = context.scroll_metrics
70
+ end
71
+
72
+ private
73
+
74
+ def document
75
+ @document ||= DocumentResolver.new(@dependencies).resolve
76
+ end
77
+
78
+ def bounds_signature(bounds)
79
+ [bounds.x, bounds.y, bounds.width, bounds.height]
80
+ end
81
+
82
+ def refresh_wrap_cache(doc)
83
+ doc_id = doc&.object_id
84
+ return if doc_id == @cache_document_id
85
+
86
+ @cache_document_id = doc_id
87
+ @wrap_cache.clear
88
+ end
89
+
90
+ def entries_cache_for(state, doc, bounds)
91
+ key = entries_cache_key(state, doc)
92
+ return @entries_cache if key == @entries_cache_key && @entries_cache
93
+
94
+ context = RenderContext.new(NullSurface.new, bounds, state, doc, wrap_cache: @wrap_cache)
95
+ entries = EntriesCalculator.new(context).calculate
96
+ @entries_cache = EntriesCache.new(full: entries.full, visible: entries.visible,
97
+ visible_indices: entries.visible_indices)
98
+ @entries_cache_key = key
99
+ @entries_cache
100
+ end
101
+
102
+ def entries_cache_key(state, doc)
103
+ filter_active = state.get(%i[reader sidebar_toc_filter_active])
104
+ filter_text = state.get(%i[reader sidebar_toc_filter]) || ''
105
+ collapsed = Array(state.get(%i[reader sidebar_toc_collapsed])).map(&:to_i).sort
106
+ [doc&.object_id, filter_active ? 1 : 0, filter_text.to_s, collapsed]
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end