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,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zip'
4
+ require 'rexml/document'
5
+
6
+ require_relative '../../shared/errors.rb'
7
+ require_relative 'epub/parsers/html_processor.rb'
8
+ require_relative 'epub/parsers/opf_processor.rb'
9
+ require_relative '../output/terminal/terminal_sanitizer.rb'
10
+ require_relative 'epub/parsers/xml_text_normalizer.rb'
11
+ require_relative '../../core/models/chapter.rb'
12
+ require_relative '../../core/models/toc_entry.rb'
13
+ require_relative '../monitoring/perf_tracer.rb'
14
+
15
+ module Shoko
16
+ module Adapters::BookSources
17
+ # Imports an EPUB archive into an in-memory representation that can be
18
+ # serialized using {EpubCache}. Responsible for extracting metadata,
19
+ # chapters, and table-of-contents entries in a consistent schema.
20
+ #
21
+ # Binary resources (images, stylesheets, etc.) are intentionally not extracted
22
+ # by default since the reader currently renders image placeholders and does
23
+ # not consume the raw bytes. Optional consumers (e.g. Kitty image rendering)
24
+ # should load resources on-demand.
25
+ class EpubImporter
26
+ DEFAULT_LANGUAGE = 'en_US'
27
+ CONTAINER_PATH = 'META-INF/container.xml'
28
+
29
+ def initialize(formatting_service: nil, extract_resources: false, progress_reporter: nil)
30
+ @formatting_service = formatting_service
31
+ @extract_resources = !extract_resources.nil?
32
+ @progress_reporter = progress_reporter
33
+ end
34
+
35
+ def import(epub_path)
36
+ @epub_path = File.expand_path(epub_path)
37
+ raise Shoko::FileNotFoundError, epub_path unless File.file?(@epub_path)
38
+
39
+ report('Opening EPUB archive...', progress: 0.0)
40
+ Zip::File.open(@epub_path) do |zip|
41
+ report('Reading container.xml...', progress: 0.0)
42
+ container_xml = read_container(zip)
43
+ report('Locating OPF package...', progress: 0.0)
44
+ opf_path = locate_opf_path(zip, container_xml)
45
+ report('Parsing OPF metadata...', progress: 0.0)
46
+ processor = Adapters::BookSources::Epub::Parsers::OPFProcessor.new(opf_path, zip: zip)
47
+
48
+ metadata = processor.extract_metadata
49
+ report('Building manifest...', progress: 0.0)
50
+ manifest = processor.build_manifest_map
51
+ report('Reading navigation data...', progress: 0.0)
52
+ chapter_titles = processor.extract_chapter_titles(manifest)
53
+
54
+ chapters_data = build_chapters(zip, opf_path, processor, manifest, chapter_titles)
55
+ chapters = chapters_data[:chapters]
56
+ chapter_hrefs = chapters_data[:hrefs]
57
+ spine = chapters_data[:spine]
58
+
59
+ report('Building table of contents...', progress: 0.0)
60
+ toc_entries = build_toc_entries(chapters, processor.toc_entries, chapter_hrefs, opf_path)
61
+ report('Extracting resources...', progress: 0.0) if @extract_resources
62
+ resources = @extract_resources ? extract_resources(zip, opf_path, manifest) : {}
63
+
64
+ Adapters::Storage::EpubCache::BookData.new(
65
+ title: metadata[:title] || fallback_title(@epub_path),
66
+ language: metadata[:language] || DEFAULT_LANGUAGE,
67
+ authors: Array(metadata[:authors]).map(&:to_s),
68
+ chapters: chapters,
69
+ toc_entries: toc_entries,
70
+ opf_path: opf_path,
71
+ spine: spine,
72
+ chapter_hrefs: chapter_hrefs,
73
+ resources: resources,
74
+ metadata: metadata,
75
+ container_path: CONTAINER_PATH,
76
+ container_xml: container_xml
77
+ )
78
+ end
79
+ rescue Zip::Error, REXML::ParseException => e
80
+ raise Shoko::EPUBParseError.new(e.message, epub_path)
81
+ end
82
+
83
+ private
84
+
85
+ def read_container(zip)
86
+ Adapters::Monitoring::PerfTracer.measure('epub.read_container') do
87
+ normalize_text(zip.read(CONTAINER_PATH))
88
+ end
89
+ rescue Zip::Error
90
+ raise Shoko::EPUBParseError.new('Missing META-INF/container.xml', @epub_path)
91
+ end
92
+
93
+ def locate_opf_path(zip, container_xml)
94
+ Adapters::Monitoring::PerfTracer.measure('epub.locate_opf') do
95
+ doc = REXML::Document.new(container_xml)
96
+ elems = doc.elements
97
+ rootfile = elems['//rootfile'] || elems['//container:rootfile']
98
+ candidate = rootfile&.attributes&.[]('full-path')
99
+ return candidate if candidate && zip.find_entry(candidate)
100
+ end
101
+
102
+ if (match = container_xml.to_s.match(/full-path=["']([^"']+)["']/i))
103
+ candidate = match[1]
104
+ return candidate if zip.find_entry(candidate)
105
+ end
106
+
107
+ raise Shoko::EPUBParseError.new('Unable to locate OPF file', @epub_path)
108
+ rescue REXML::ParseException => e
109
+ raise Shoko::EPUBParseError.new("Invalid container.xml: #{e.message}", @epub_path)
110
+ end
111
+
112
+ def build_chapters(zip, opf_path, processor, manifest, chapter_titles)
113
+ chapters = []
114
+ hrefs = []
115
+ spine = []
116
+
117
+ items = []
118
+ processor.process_spine(manifest, chapter_titles) { |item| items << item }
119
+ total = items.length
120
+
121
+ if total.positive?
122
+ report("Extracting HTML (0/#{total})...", progress: 0.0)
123
+ else
124
+ report('Extracting HTML...', progress: 0.0)
125
+ end
126
+
127
+ items.each_with_index do |item, index|
128
+ report(
129
+ "Extracting HTML (#{index + 1}/#{total})...",
130
+ progress: ratio(index + 1, total)
131
+ )
132
+ raw = read_text_entry(zip, item.file_path)
133
+ resolved_href = resolve_href(opf_path, item.href)
134
+ chapter = Core::Models::Chapter.new(
135
+ number: item.number.to_s,
136
+ title: extract_chapter_title(raw, item.number, item.title),
137
+ lines: nil,
138
+ metadata: { source_path: item.file_path, href: resolved_href },
139
+ blocks: nil,
140
+ raw_content: raw
141
+ )
142
+
143
+ chapters << chapter
144
+ hrefs << resolved_href
145
+ spine << item.file_path
146
+ end
147
+
148
+ { chapters:, hrefs:, spine: }
149
+ end
150
+
151
+ def build_toc_entries(chapters, toc_entries, chapter_hrefs, opf_path)
152
+ href_to_index = {}
153
+ chapter_hrefs.each_with_index do |href, idx|
154
+ href_to_index[href] = idx if href
155
+ end
156
+
157
+ Array(toc_entries).map do |entry|
158
+ title = entry[:title]
159
+ href = entry[:href]
160
+ level = entry[:level].to_i
161
+
162
+ target = resolve_toc_target(opf_path, entry)
163
+ chapter_index = href_to_index[target]
164
+ if chapter_index && (chapter = chapters[chapter_index]) && chapter.title.to_s.strip.empty?
165
+ chapter.title = title
166
+ end
167
+
168
+ Core::Models::TOCEntry.new(
169
+ title: title,
170
+ href: href,
171
+ level: level,
172
+ chapter_index: chapter_index,
173
+ navigable: !chapter_index.nil?
174
+ )
175
+ end
176
+ end
177
+
178
+ def resolve_toc_target(opf_path, entry)
179
+ return nil unless entry
180
+
181
+ return entry[:target].to_s if entry.is_a?(Hash) && entry[:target]
182
+
183
+ href = entry.is_a?(Hash) ? entry[:href] : nil
184
+ return nil unless href
185
+
186
+ core = href.to_s.split('#', 2).first.to_s
187
+ return nil if core.empty?
188
+
189
+ source_path = entry.is_a?(Hash) ? entry[:source_path] : nil
190
+ base_path = (source_path || opf_path).to_s
191
+ base_dir = File.dirname(base_path)
192
+ File.expand_path(File.join('/', base_dir, core), '/').sub(%r{^/}, '')
193
+ end
194
+
195
+ def extract_resources(zip, opf_path, manifest)
196
+ resources = {}
197
+
198
+ manifest.each_value do |href|
199
+ rel = href.to_s
200
+ next if rel.empty?
201
+
202
+ path = resolve_href(opf_path, rel)
203
+ next unless zip.find_entry(path)
204
+
205
+ resources[path] = read_binary_entry(zip, path)
206
+ end
207
+ resources
208
+ end
209
+
210
+ def read_text_entry(zip, path)
211
+ Adapters::Monitoring::PerfTracer.measure('epub.read_text_entry') do
212
+ content = zip.read(path)
213
+ normalize_text(content)
214
+ end
215
+ end
216
+
217
+ def read_binary_entry(zip, path)
218
+ Adapters::Monitoring::PerfTracer.measure('epub.read_binary_entry') do
219
+ data = zip.read(path)
220
+ data.force_encoding(Encoding::BINARY)
221
+ end
222
+ end
223
+
224
+ def normalize_text(content)
225
+ Adapters::BookSources::Epub::Parsers::XmlTextNormalizer.normalize(content)
226
+ end
227
+
228
+ def resolve_href(opf_path, href)
229
+ return nil unless href
230
+
231
+ base = File.dirname(opf_path)
232
+ root = File.expand_path(File.join('/', base, href), '/')
233
+ root.sub(%r{^/}, '')
234
+ end
235
+
236
+ def extract_chapter_title(raw_content, number, hinted_title)
237
+ hinted = hinted_title.to_s.strip
238
+ return hinted unless hinted.empty?
239
+
240
+ Adapters::BookSources::Epub::Parsers::HTMLProcessor.extract_title(raw_content) || "Chapter #{number}"
241
+ end
242
+
243
+ def fallback_title(path)
244
+ raw = File.basename(path, File.extname(path)).tr('_', ' ')
245
+ Shoko::Adapters::Output::Terminal::TerminalSanitizer.sanitize(raw, preserve_newlines: false, preserve_tabs: false)
246
+ end
247
+
248
+ def report(message, progress: nil)
249
+ reporter = @progress_reporter
250
+ return unless reporter
251
+ return if message.nil? || message.to_s.strip.empty?
252
+
253
+ if reporter.respond_to?(:call)
254
+ reporter.call(message: message, progress: progress)
255
+ elsif reporter.respond_to?(:update_status)
256
+ reporter.update_status(message: message, progress: progress)
257
+ end
258
+ rescue StandardError
259
+ nil
260
+ end
261
+
262
+ def ratio(done, total)
263
+ denom = [total.to_f, 1.0].max
264
+ done.to_f / denom
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module Shoko
8
+ module Adapters::BookSources
9
+ # Thin HTTP client for the Gutendex API.
10
+ class GutendexClient
11
+ class Error < StandardError; end
12
+
13
+ API_ROOT = 'https://gutendex.com/books'
14
+
15
+ def initialize(logger: nil, open_timeout: 5, read_timeout: 15)
16
+ @logger = logger
17
+ @open_timeout = open_timeout
18
+ @read_timeout = read_timeout
19
+ end
20
+
21
+ def search(query:, page_url: nil)
22
+ uri = page_url ? normalize_uri(page_url, base: API_ROOT) : build_query_uri(query)
23
+ request_json(uri)
24
+ end
25
+
26
+ def download(url, dest_path, &)
27
+ uri = normalize_uri(url, base: API_ROOT)
28
+ request_download(uri, dest_path, &)
29
+ end
30
+
31
+ private
32
+
33
+ def build_query_uri(query)
34
+ uri = URI.parse(API_ROOT)
35
+ q = query.to_s.strip
36
+ uri.query = URI.encode_www_form(search: q) unless q.empty?
37
+ uri
38
+ end
39
+
40
+ def request_json(uri, limit = 2)
41
+ response = request(uri)
42
+ return parse_json(response) if response.is_a?(Net::HTTPSuccess)
43
+
44
+ if response.is_a?(Net::HTTPRedirection) && limit.positive?
45
+ redirect = resolve_redirect_uri(uri, response['location'])
46
+ return request_json(redirect, limit - 1)
47
+ end
48
+
49
+ raise Error, "Request failed (#{response.code})"
50
+ end
51
+
52
+ def parse_json(response)
53
+ JSON.parse(response.body)
54
+ rescue JSON::ParserError => e
55
+ raise Error, "Invalid JSON response: #{e.message}"
56
+ end
57
+
58
+ def request_download(uri, dest_path, limit = 2, &on_progress)
59
+ response = request(uri) do |http|
60
+ http.request(Net::HTTP::Get.new(uri)) do |resp|
61
+ if resp.is_a?(Net::HTTPSuccess)
62
+ stream_response(resp, dest_path, &on_progress)
63
+ else
64
+ resp
65
+ end
66
+ end
67
+ end
68
+
69
+ if response.is_a?(Net::HTTPRedirection) && limit.positive?
70
+ redirect = resolve_redirect_uri(uri, response['location'])
71
+ return request_download(redirect, dest_path, limit - 1, &on_progress)
72
+ end
73
+
74
+ return response if response.is_a?(Net::HTTPSuccess)
75
+
76
+ raise Error, "Download failed (#{response.code})"
77
+ end
78
+
79
+ def stream_response(response, dest_path)
80
+ return response unless response.is_a?(Net::HTTPSuccess)
81
+
82
+ total = response['Content-Length'].to_i
83
+ downloaded = 0
84
+ File.open(dest_path, 'wb') do |file|
85
+ response.read_body do |chunk|
86
+ file.write(chunk)
87
+ downloaded += chunk.bytesize
88
+ yield(downloaded, total) if block_given?
89
+ end
90
+ end
91
+ response
92
+ end
93
+
94
+ def request(uri, &)
95
+ uri = normalize_uri(uri, base: API_ROOT)
96
+ raise Error, "Invalid URL: #{uri}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
97
+
98
+ http = Net::HTTP.new(uri.host, uri.port)
99
+ http.use_ssl = uri.scheme == 'https'
100
+ http.open_timeout = @open_timeout
101
+ http.read_timeout = @read_timeout
102
+ if block_given?
103
+ http.start(&)
104
+ else
105
+ http.get(uri.request_uri)
106
+ end
107
+ rescue StandardError => e
108
+ @logger&.error('Gutendex request failed', error: e.message, url: uri.to_s)
109
+ raise Error, e.message
110
+ end
111
+
112
+ def normalize_uri(input, base: nil)
113
+ uri = input.is_a?(URI) ? input : URI.parse(input.to_s)
114
+ return uri if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
115
+
116
+ if base
117
+ base_uri = base.is_a?(URI) ? base : URI.parse(base.to_s)
118
+ begin
119
+ joined = URI.join(base_uri.to_s, uri.to_s)
120
+ return joined if joined.is_a?(URI::HTTP) || joined.is_a?(URI::HTTPS)
121
+ rescue URI::Error
122
+ # fall through
123
+ end
124
+ end
125
+
126
+ if uri.scheme.nil? && uri.host.nil?
127
+ candidate = uri.to_s
128
+ if candidate.start_with?('//')
129
+ candidate = "https:#{candidate}"
130
+ elsif /\A[a-z0-9.-]+\.[a-z]{2,}/i.match?(candidate)
131
+ candidate = "https://#{candidate}"
132
+ end
133
+
134
+ begin
135
+ parsed = URI.parse(candidate)
136
+ return parsed if parsed.is_a?(URI::HTTP) || parsed.is_a?(URI::HTTPS)
137
+ rescue URI::Error
138
+ # fall through
139
+ end
140
+ end
141
+
142
+ uri
143
+ end
144
+
145
+ def resolve_redirect_uri(base_uri, location)
146
+ normalize_uri(location, base: base_uri)
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'epub_finder'
4
+
5
+ module Shoko
6
+ module Adapters::BookSources
7
+ # Handles EPUB library scanning operations (filesystem/OS concerns)
8
+ class LibraryScanner
9
+ attr_accessor :scan_status, :scan_message, :epubs
10
+
11
+ def initialize
12
+ @epubs = []
13
+ @filtered_epubs = []
14
+ @scan_status = :idle
15
+ @scan_message = ''
16
+ @scan_thread = nil
17
+ @scan_results_queue = Queue.new
18
+ end
19
+
20
+ def load_cached
21
+ @epubs = EPUBFinder.scan_system(force_refresh: false) || []
22
+ @filtered_epubs = @epubs
23
+ @scan_status = @epubs.empty? ? :idle : :done
24
+ @scan_message = "Loaded #{@epubs.length} books from cache" if @scan_status == :done
25
+ rescue StandardError => e
26
+ @scan_status = :error
27
+ @scan_message = "Cache load failed: #{e.message}"
28
+ @epubs = []
29
+ @filtered_epubs = []
30
+ end
31
+
32
+ def start_scan(force: false)
33
+ return if @scan_thread&.alive?
34
+
35
+ initialize_scan
36
+ @scan_thread = create_scan_thread(force)
37
+ end
38
+
39
+ private
40
+
41
+ def initialize_scan
42
+ @scan_status = :scanning
43
+ @scan_message = 'Scanning for EPUB files...'
44
+ @epubs = []
45
+ @filtered_epubs = []
46
+ end
47
+
48
+ def create_scan_thread(force)
49
+ Thread.new do
50
+ perform_scan_operation(force)
51
+ rescue StandardError => e
52
+ handle_scan_error(e)
53
+ end
54
+ end
55
+
56
+ def perform_scan_operation(force)
57
+ epubs = EPUBFinder.scan_system(force_refresh: force) || []
58
+ sorted_epubs = epubs.sort_by { |e| (e['name'] || '').downcase }
59
+
60
+ @scan_results_queue.push(
61
+ status: :done,
62
+ epubs: sorted_epubs,
63
+ message: "Found #{sorted_epubs.length} books"
64
+ )
65
+ end
66
+
67
+ def handle_scan_error(error)
68
+ @scan_results_queue.push(
69
+ status: :error,
70
+ epubs: [],
71
+ message: "Scan failed: #{error.message[0..50]}"
72
+ )
73
+ end
74
+
75
+ public
76
+
77
+ def process_results
78
+ return if @scan_results_queue.empty?
79
+
80
+ result = @scan_results_queue.pop
81
+ @scan_status = result[:status]
82
+ @scan_message = result[:message]
83
+ result[:epubs]
84
+ end
85
+
86
+ def cleanup
87
+ @scan_thread&.kill
88
+ rescue StandardError
89
+ nil
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Shoko
6
+ module Adapters::BookSources
7
+ # Computes a fast, stable fingerprint for a source EPUB file without
8
+ # reading the full file into memory. Intended to validate that a cache
9
+ # entry still corresponds to a local file when avoiding full SHA-256
10
+ # hashing on warm opens.
11
+ module SourceFingerprint
12
+ module_function
13
+
14
+ VERSION = 1
15
+ DEFAULT_CHUNK_BYTES = 64 * 1024
16
+
17
+ def compute(path, chunk_bytes: DEFAULT_CHUNK_BYTES)
18
+ return nil if path.nil? || path.to_s.empty?
19
+ return nil unless File.file?(path)
20
+
21
+ size_bytes = File.size(path)
22
+ chunk = normalize_chunk_bytes(chunk_bytes)
23
+
24
+ head = ''.b
25
+ tail = ''.b
26
+
27
+ File.open(path, 'rb') do |io|
28
+ head = io.read([size_bytes, chunk].min) || ''.b
29
+
30
+ if size_bytes > chunk
31
+ io.seek(-[chunk, size_bytes].min, ::IO::SEEK_END)
32
+ tail = io.read([chunk, size_bytes].min) || ''.b
33
+ end
34
+ end
35
+
36
+ buffer = "v#{VERSION}\0#{size_bytes}\0"
37
+ buffer << head
38
+ buffer << "\0"
39
+ buffer << tail
40
+
41
+ Digest::SHA256.hexdigest(buffer)
42
+ rescue StandardError
43
+ nil
44
+ end
45
+
46
+ def normalize_chunk_bytes(value)
47
+ bytes = Integer(value)
48
+ return DEFAULT_CHUNK_BYTES if bytes <= 0
49
+
50
+ bytes
51
+ rescue StandardError
52
+ DEFAULT_CHUNK_BYTES
53
+ end
54
+ private_class_method :normalize_chunk_bytes
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Input::Annotations
5
+ # Handles mouse events for text selection in the reader
6
+ class MouseHandler
7
+ attr_reader :selection_start, :selection_end, :selecting
8
+
9
+ def initialize
10
+ reset
11
+ end
12
+
13
+ # Parse ANSI mouse event
14
+ def parse_mouse_event(input)
15
+ return nil unless input =~ /\e\[<(\d+);(\d+);(\d+)([Mm])/
16
+
17
+ {
18
+ button: ::Regexp.last_match(1).to_i,
19
+ x: ::Regexp.last_match(2).to_i - 1, # Convert to 0-based
20
+ y: ::Regexp.last_match(3).to_i - 1,
21
+ released: ::Regexp.last_match(4) == 'm',
22
+ }
23
+ end
24
+
25
+ # Handle mouse event and update selection state
26
+ def handle_event(event)
27
+ return nil unless event
28
+
29
+ btn = event[:button]
30
+ rel = event[:released]
31
+ col = event[:x]
32
+ row = event[:y]
33
+
34
+ if btn.zero? && !rel # Left button pressed
35
+ start_selection(col, row)
36
+ elsif btn == 32 && @selecting # Mouse dragged
37
+ update_selection(col, row)
38
+ elsif rel && @selecting # Button released
39
+ finish_selection
40
+ end
41
+ end
42
+
43
+ # Get normalized selection range
44
+ def selection_range
45
+ return nil unless @selection_start && @selection_end
46
+
47
+ start_pos = @selection_start
48
+ end_pos = @selection_end
49
+
50
+ # Ensure start comes before end
51
+ sy = start_pos[:y]
52
+ ey = end_pos[:y]
53
+ start_pos, end_pos = end_pos, start_pos if sy > ey || (sy == ey && start_pos[:x] > end_pos[:x])
54
+
55
+ { start: start_pos, end: end_pos }
56
+ end
57
+
58
+ def reset
59
+ @selecting = false
60
+ @selection_start = nil
61
+ @selection_end = nil
62
+ end
63
+
64
+ private
65
+
66
+ def start_selection(col, row)
67
+ @selecting = true
68
+ @selection_start = { x: col, y: row }
69
+ @selection_end = { x: col, y: row }
70
+ { type: :selection_start }
71
+ end
72
+
73
+ def update_selection(col, row)
74
+ @selection_end = { x: col, y: row }
75
+ { type: :selection_drag }
76
+ end
77
+
78
+ def finish_selection
79
+ @selecting = false
80
+ { type: :selection_end }
81
+ end
82
+ end
83
+ end
84
+ end