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,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_component'
4
+ require_relative 'render_style'
5
+ require_relative 'ui/overlay_layout'
6
+ require_relative 'annotation_editor_overlay/footer_renderer'
7
+ require_relative 'annotation_editor_overlay/geometry'
8
+ require_relative 'annotation_editor_overlay/note_renderer'
9
+ require_relative '../../terminal/text_metrics.rb'
10
+ require_relative '../../../input/key_definitions.rb'
11
+ require_relative '../../terminal/terminal_sanitizer.rb'
12
+
13
+ module Shoko
14
+ module Adapters::Output::Ui::Components
15
+ # Overlay for creating/editing annotations without leaving the reader view.
16
+ class AnnotationEditorOverlayComponent < BaseComponent
17
+ include Adapters::Output::Ui::Constants::UI
18
+
19
+ SAVE_KEYS = ["\x13"].freeze # Ctrl+S
20
+ BACKSPACE_KEYS = ["\x08", "\x7F", '\b'].freeze
21
+
22
+ attr_reader :visible, :selected_text, :note, :chapter_index, :annotation_id
23
+
24
+ def initialize(selected_text:, range:, chapter_index:, annotation: nil)
25
+ super()
26
+ @selected_text = (selected_text || '').dup
27
+ @range = range
28
+ @chapter_index = chapter_index
29
+ @annotation_id = annotation.is_a?(Hash) ? (annotation[:id] || annotation['id']) : nil
30
+ note_source = annotation.is_a?(Hash) ? (annotation[:note] || annotation['note']) : nil
31
+ @note = (note_source || '').dup
32
+ @cursor_pos = @note.length
33
+ @visible = true
34
+ @button_regions = {}
35
+ @overlay_sizing = UI::OverlaySizing.new(
36
+ width_ratio: 0.7,
37
+ width_padding: 10,
38
+ min_width: 60,
39
+ height_ratio: 0.6,
40
+ height_padding: 6,
41
+ min_height: 16
42
+ )
43
+ end
44
+
45
+ def visible?
46
+ @visible
47
+ end
48
+
49
+ def hide
50
+ @visible = false
51
+ end
52
+
53
+ def selection_range
54
+ @range
55
+ end
56
+
57
+ def render(surface, bounds)
58
+ do_render(surface, bounds)
59
+ end
60
+
61
+ def do_render(surface, bounds)
62
+ return unless @visible
63
+
64
+ layout = overlay_layout(bounds)
65
+
66
+ render_panel(surface, bounds, layout)
67
+
68
+ geometry = AnnotationEditorOverlay::Geometry.new(layout)
69
+ render_header(surface, bounds, geometry)
70
+ render_selection_summary(surface, bounds, geometry)
71
+ render_field_label(surface, bounds, geometry)
72
+ note_renderer = AnnotationEditorOverlay::NoteRenderer.new(
73
+ background: ANNOTATION_PANEL_BG,
74
+ text_color: theme_primary,
75
+ cursor_color: theme_accent,
76
+ geometry: geometry,
77
+ placeholder_text: 'Write your annotation...',
78
+ placeholder_color: COLOR_TEXT_DIM
79
+ )
80
+ note_renderer.render(surface, bounds, note: @note, cursor_pos: @cursor_pos)
81
+
82
+ footer_renderer = AnnotationEditorOverlay::FooterRenderer.new(
83
+ background: ANNOTATION_PANEL_BG,
84
+ text_fg: theme_primary,
85
+ key_fg: COLOR_TEXT_DIM
86
+ )
87
+ @button_regions = footer_renderer.render(surface, bounds, geometry)
88
+ end
89
+
90
+ # Handles key input, returning an event hash when an action should be taken.
91
+ def handle_key(key)
92
+ return { type: :cancel } if cancel_key?(key)
93
+ return handle_save if save_key?(key)
94
+
95
+ case key
96
+ when *BACKSPACE_KEYS
97
+ handle_backspace
98
+ when "\r", "\n"
99
+ insert_newline
100
+ else
101
+ insert_character(key)
102
+ end
103
+ nil
104
+ end
105
+
106
+ def calculate_width(total_width)
107
+ @overlay_sizing.width_for(total_width)
108
+ end
109
+
110
+ def calculate_height(total_height)
111
+ @overlay_sizing.height_for(total_height)
112
+ end
113
+
114
+ def handle_backspace
115
+ return if @cursor_pos.zero?
116
+
117
+ @note.slice!(@cursor_pos - 1)
118
+ @cursor_pos -= 1
119
+ end
120
+
121
+ def handle_enter
122
+ insert_newline
123
+ end
124
+
125
+ def handle_character(key)
126
+ insert_character(key)
127
+ end
128
+
129
+ def insert_newline
130
+ insert_text("\n")
131
+ end
132
+
133
+ def insert_character(key)
134
+ return unless printable?(key)
135
+
136
+ insert_text(key)
137
+ end
138
+
139
+ def insert_text(str)
140
+ @note.insert(@cursor_pos, str)
141
+ @cursor_pos += str.length
142
+ end
143
+
144
+ def printable?(key)
145
+ Shoko::Adapters::Output::Terminal::TerminalSanitizer.printable_char?(key.to_s)
146
+ end
147
+
148
+ def save_key?(key)
149
+ SAVE_KEYS.include?(key)
150
+ end
151
+
152
+ def cancel_key?(key)
153
+ Adapters::Input::KeyDefinitions::ACTIONS[:cancel].include?(key)
154
+ end
155
+
156
+ def handle_save
157
+ { type: :save, note: @note }
158
+ end
159
+
160
+ def handle_click(col, row)
161
+ return nil unless @visible && @button_regions
162
+
163
+ @button_regions.each do |key, region|
164
+ next unless row == region[:row]
165
+ next unless col.between?(region[:col], region[:col] + region[:width] - 1)
166
+
167
+ return handle_save if key == :save
168
+ return { type: :cancel } if key == :cancel
169
+ end
170
+
171
+ nil
172
+ end
173
+
174
+ private
175
+
176
+ def render_panel(surface, bounds, layout)
177
+ layout.fill_background(surface, bounds, background: ANNOTATION_PANEL_BG)
178
+ end
179
+
180
+ def render_header(surface, bounds, geometry)
181
+ reset = Terminal::ANSI::RESET
182
+ title = 'Annotation'
183
+ surface.write(bounds, geometry.header_row, geometry.content_x,
184
+ "#{ANNOTATION_PANEL_BG}#{ANNOTATION_HEADER_FG}#{title}#{reset}")
185
+ end
186
+
187
+ def render_selection_summary(surface, bounds, geometry)
188
+ reset = Terminal::ANSI::RESET
189
+ summary = selection_summary_text(geometry)
190
+ return if summary.empty?
191
+
192
+ surface.write(bounds, geometry.subheader_row, geometry.content_x,
193
+ "#{ANNOTATION_PANEL_BG}#{summary}#{reset}")
194
+ end
195
+
196
+ def render_field_label(surface, bounds, geometry)
197
+ label = 'Note'
198
+ surface.write(bounds, geometry.label_row, geometry.content_x,
199
+ "#{ANNOTATION_PANEL_BG}#{COLOR_TEXT_DIM}#{label}#{Terminal::ANSI::RESET}")
200
+ end
201
+
202
+ def selection_summary_text(geometry)
203
+ sanitized = Shoko::Adapters::Output::Terminal::TerminalSanitizer.sanitize(@selected_text.to_s,
204
+ preserve_newlines: false,
205
+ preserve_tabs: false)
206
+ condensed = sanitized.gsub(/\s+/, ' ').strip
207
+ return "#{COLOR_TEXT_DIM}Write your note below" if condensed.empty?
208
+
209
+ label = 'Selected: '
210
+ max = geometry.content_width - Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(label)
211
+ snippet = Shoko::Adapters::Output::Terminal::TextMetrics.truncate_to(condensed, [max, 1].max)
212
+ "#{COLOR_TEXT_DIM}#{label}#{theme_primary}#{snippet}"
213
+ end
214
+
215
+ def theme_accent
216
+ Shoko::Adapters::Output::Ui::Components::RenderStyle.color(:accent)
217
+ rescue StandardError
218
+ COLOR_TEXT_ACCENT
219
+ end
220
+
221
+ def theme_primary
222
+ Shoko::Adapters::Output::Ui::Components::RenderStyle.color(:primary)
223
+ rescue StandardError
224
+ COLOR_TEXT_PRIMARY
225
+ end
226
+
227
+ def overlay_layout(bounds)
228
+ width = calculate_width(bounds.width)
229
+ height = calculate_height(bounds.height)
230
+ UI::OverlayLayout.centered(bounds, width: width, height: height)
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../ui/text_utils'
4
+ require_relative '../ui/list_helpers'
5
+ require_relative '../../../terminal/text_metrics.rb'
6
+ require_relative '../../../terminal/terminal.rb'
7
+
8
+ module Shoko
9
+ module Adapters::Output::Ui::Components
10
+ # Namespace for annotations overlay helpers.
11
+ module AnnotationsOverlay
12
+ # Renders the annotations list and header within the overlay.
13
+ class ListRenderer
14
+ include Adapters::Output::Ui::Constants::UI
15
+
16
+ # Rendering inputs for the annotations overlay list.
17
+ RenderContext = Struct.new(:surface, :bounds, :layout, :entries, :selected_index, keyword_init: true)
18
+
19
+ ColumnWidths = Struct.new(:idx, :snippet, :note, :date, keyword_init: true)
20
+
21
+ def render(context)
22
+ draw_title(context)
23
+ draw_entries(context)
24
+ end
25
+
26
+ private
27
+
28
+ def draw_title(context)
29
+ surface = context.surface
30
+ bounds = context.bounds
31
+ layout = context.layout
32
+ count = context.entries.length
33
+ origin_x = layout.origin_x
34
+ reset = Terminal::ANSI::RESET
35
+ title = "#{COLOR_TEXT_ACCENT}📝 Annotations (#{count})#{reset}"
36
+ title_row = layout.origin_y + 1
37
+ title_col = origin_x + 2
38
+ surface.write(bounds, title_row, title_col, title)
39
+
40
+ info_plain = '[Enter] Open • [e] Edit • [d] Delete • [Esc] Close'
41
+ info_col = origin_x + [layout.width - Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(info_plain) - 2, 2].max
42
+ surface.write(bounds, title_row, info_col, "#{COLOR_TEXT_DIM}#{info_plain}#{reset}")
43
+ end
44
+
45
+ def draw_entries(context)
46
+ layout = context.layout
47
+ entries = context.entries
48
+ list_top = layout.origin_y + 3
49
+ list_height = [layout.height - 5, 1].max
50
+ inner_width = layout.width - 4
51
+
52
+ if entries.empty?
53
+ render_empty(context)
54
+ return
55
+ end
56
+
57
+ columns = build_columns(inner_width)
58
+ render_header(context, columns, list_top)
59
+ render_rows(context, columns, list_top, list_height)
60
+ end
61
+
62
+ def render_empty(context)
63
+ surface = context.surface
64
+ bounds = context.bounds
65
+ layout = context.layout
66
+ message = "#{COLOR_TEXT_DIM}No annotations yet#{Terminal::ANSI::RESET}"
67
+ row = layout.origin_y + (layout.height / 2)
68
+ col = layout.origin_x + [(layout.width - Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(message)) / 2,
69
+ 2].max
70
+ surface.write(bounds, row, col, message)
71
+ end
72
+
73
+ def build_columns(inner_width)
74
+ idx_width = 4
75
+ date_width = [12, inner_width / 5].max
76
+ remaining = inner_width - idx_width - date_width - 2
77
+ remaining = 12 if remaining < 12
78
+ snippet_width = [(remaining * 0.6).floor, 8].max
79
+ note_width = [remaining - snippet_width, 6].max
80
+ ColumnWidths.new(idx: idx_width, snippet: snippet_width, note: note_width, date: date_width)
81
+ end
82
+
83
+ def render_header(context, columns, list_top)
84
+ surface = context.surface
85
+ bounds = context.bounds
86
+ layout = context.layout
87
+ header = [
88
+ ' ',
89
+ UI::TextUtils.pad_right('#', columns.idx),
90
+ ' ',
91
+ UI::TextUtils.pad_right('Snippet', columns.snippet),
92
+ ' ',
93
+ UI::TextUtils.pad_right('Note', columns.note),
94
+ ' ',
95
+ UI::TextUtils.pad_right('Saved', columns.date),
96
+ ].join
97
+ surface.write(bounds, list_top - 1, layout.origin_x + 2,
98
+ "#{COLOR_TEXT_DIM}#{header}#{Terminal::ANSI::RESET}")
99
+ end
100
+
101
+ def render_rows(context, columns, list_top, list_height)
102
+ surface = context.surface
103
+ bounds = context.bounds
104
+ layout = context.layout
105
+ selected_index = context.selected_index
106
+ entries = context.entries
107
+ start_index, visible = UI::ListHelpers.slice_visible(entries, list_height, selected_index)
108
+ list_col = layout.origin_x + 2
109
+
110
+ visible.each_with_index do |annotation, offset|
111
+ entry_index = start_index + offset
112
+ is_selected = entry_index == selected_index
113
+ line_color = is_selected ? SELECTION_HIGHLIGHT : COLOR_TEXT_PRIMARY
114
+ pointer = is_selected ? '▸' : ' '
115
+
116
+ line = build_line(annotation, entry_index, columns, pointer)
117
+ surface.write(bounds, list_top + offset, list_col, "#{line_color}#{line}#{Terminal::ANSI::RESET}")
118
+ end
119
+ end
120
+
121
+ def build_line(annotation, entry_index, columns, pointer)
122
+ snippet = format_cell(annotation[:text], columns.snippet)
123
+ note = format_cell(annotation[:note], columns.note)
124
+ saved = format_cell(saved_text(annotation), columns.date)
125
+ idx_text = UI::TextUtils.pad_right((entry_index + 1).to_s, columns.idx)
126
+
127
+ [pointer, ' ', idx_text, ' ', snippet, ' ', note, ' ', saved].join
128
+ end
129
+
130
+ def format_cell(value, width)
131
+ text = value.to_s.tr("\n", ' ')
132
+ UI::TextUtils.pad_right(UI::TextUtils.truncate_text(text, width), width)
133
+ end
134
+
135
+ def saved_text(annotation)
136
+ saved_at = annotation[:updated_at] || annotation[:created_at]
137
+ saved_at ? saved_at.to_s.split('T').first : '-'
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_component'
4
+ require_relative 'ui/box_drawer'
5
+ require_relative 'ui/overlay_layout'
6
+ require_relative 'annotations_overlay/list_renderer'
7
+
8
+ module Shoko
9
+ module Adapters::Output::Ui::Components
10
+ # Centered overlay listing annotations above the reading surface.
11
+ class AnnotationsOverlayComponent < BaseComponent
12
+ include Adapters::Output::Ui::Constants::UI
13
+ include UI::BoxDrawer
14
+
15
+ def initialize(state)
16
+ super()
17
+ @state = state
18
+ @visible = true
19
+ @selected_index = (@state.get(%i[reader sidebar_annotations_selected]) || 0).to_i
20
+ @overlay_sizing = UI::OverlaySizing.new(
21
+ width_ratio: 0.6,
22
+ width_padding: 8,
23
+ min_width: 48,
24
+ height_ratio: 0.5,
25
+ height_padding: 6,
26
+ min_height: 12
27
+ )
28
+ end
29
+
30
+ def visible?
31
+ @visible
32
+ end
33
+
34
+ def hide
35
+ @visible = false
36
+ end
37
+
38
+ def selected_index=(index)
39
+ @selected_index = index.to_i
40
+ end
41
+
42
+ def handle_key(key)
43
+ entries = annotations
44
+ return { type: :close } if close_when_empty?(entries, key)
45
+
46
+ navigation_action(key) || selection_action(key) || close_action(key)
47
+ end
48
+
49
+ def current_annotation
50
+ entries = annotations
51
+ entries.empty? ? nil : entries[@selected_index]
52
+ end
53
+
54
+ def render(surface, bounds)
55
+ do_render(surface, bounds)
56
+ end
57
+
58
+ def do_render(surface, bounds)
59
+ return unless @visible
60
+
61
+ entries = annotations
62
+ layout = overlay_layout(bounds)
63
+
64
+ layout.fill_background(surface, bounds, background: POPUP_BG_DEFAULT)
65
+ draw_box(surface, bounds, layout.origin_y, layout.origin_x, layout.height, layout.width, label: 'Annotations')
66
+ render_context = AnnotationsOverlay::ListRenderer::RenderContext.new(
67
+ surface: surface,
68
+ bounds: bounds,
69
+ layout: layout,
70
+ entries: entries,
71
+ selected_index: @selected_index
72
+ )
73
+ list_renderer.render(render_context)
74
+ draw_footer(surface, bounds, layout)
75
+ end
76
+
77
+ private
78
+
79
+ def annotations
80
+ @annotations = (@state.get(%i[reader annotations]) || []).map { |ann| symbolize_keys(ann) }
81
+ clamp_selection!
82
+ @annotations
83
+ end
84
+
85
+ def clamp_selection!
86
+ count = @annotations.length
87
+ @selected_index = if count.zero?
88
+ 0
89
+ else
90
+ @selected_index.clamp(0, count - 1)
91
+ end
92
+ end
93
+
94
+ def move_selection(delta)
95
+ entries = annotations
96
+ return if entries.empty?
97
+
98
+ new_index = (@selected_index + delta).clamp(0, entries.length - 1)
99
+ return if new_index == @selected_index
100
+
101
+ @selected_index = new_index
102
+ { type: :selection_change, index: @selected_index }
103
+ end
104
+
105
+ def draw_footer(surface, bounds, layout)
106
+ hint = "#{COLOR_TEXT_DIM}Use ↑/↓ to navigate • Enter to open#{Terminal::ANSI::RESET}"
107
+ surface.write(bounds, layout.origin_y + layout.height - 2, layout.origin_x + 2, hint)
108
+ end
109
+
110
+ def calculate_width(total_width)
111
+ @overlay_sizing.width_for(total_width)
112
+ end
113
+
114
+ def calculate_height(total_height)
115
+ @overlay_sizing.height_for(total_height)
116
+ end
117
+
118
+ def up_key?(key)
119
+ Adapters::Input::KeyDefinitions::NAVIGATION[:up].include?(key)
120
+ end
121
+
122
+ def down_key?(key)
123
+ Adapters::Input::KeyDefinitions::NAVIGATION[:down].include?(key)
124
+ end
125
+
126
+ def confirm_key?(key)
127
+ Adapters::Input::KeyDefinitions::ACTIONS[:confirm].include?(key)
128
+ end
129
+
130
+ def cancel_key?(key)
131
+ Adapters::Input::KeyDefinitions::ACTIONS[:cancel].include?(key)
132
+ end
133
+
134
+ def edit_key?(key)
135
+ %w[e E].include?(key)
136
+ end
137
+
138
+ def delete_key?(key)
139
+ key == 'd'
140
+ end
141
+
142
+ def close_when_empty?(entries, key)
143
+ entries.empty? && cancel_key?(key)
144
+ end
145
+
146
+ def navigation_action(key)
147
+ return move_selection(-1) if up_key?(key)
148
+
149
+ move_selection(1) if down_key?(key)
150
+ end
151
+
152
+ def selection_action(key)
153
+ annotation = current_annotation
154
+ return nil unless annotation
155
+
156
+ return { type: :open, annotation: annotation } if confirm_key?(key)
157
+ return { type: :edit, annotation: annotation } if edit_key?(key)
158
+
159
+ { type: :delete, annotation: annotation } if delete_key?(key)
160
+ end
161
+
162
+ def close_action(key)
163
+ { type: :close } if cancel_key?(key)
164
+ end
165
+
166
+ def overlay_layout(bounds)
167
+ width = calculate_width(bounds.width)
168
+ height = calculate_height(bounds.height)
169
+ UI::OverlayLayout.centered(bounds, width: width, height: height)
170
+ end
171
+
172
+ def list_renderer
173
+ @list_renderer ||= AnnotationsOverlay::ListRenderer.new
174
+ end
175
+
176
+ def symbolize_keys(annotation)
177
+ return annotation unless annotation.is_a?(Hash)
178
+
179
+ annotation.transform_keys do |key|
180
+ key.is_a?(String) ? key.to_sym : key
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'component_interface'
4
+
5
+ module Shoko
6
+ module Adapters::Output::Ui::Components
7
+ # Base implementation for all UI components following ComponentInterface
8
+ class BaseComponent
9
+ include ComponentInterface
10
+
11
+ attr_reader :dependencies
12
+
13
+ def initialize(dependencies = nil)
14
+ @dependencies = dependencies
15
+ @initialized = false
16
+ @needs_update = true
17
+ end
18
+
19
+ # Render this component into the given surface within bounds
20
+ # @param surface [Surface] terminal surface wrapper
21
+ # @param bounds [Rect] local bounds for this component
22
+ def render(surface, bounds)
23
+ ensure_mounted
24
+
25
+ # Always render for now to debug display issues
26
+ do_render(surface, bounds)
27
+ mark_updated
28
+ end
29
+
30
+ # Override this method in subclasses for actual rendering logic
31
+ def do_render(surface, bounds)
32
+ # to be implemented by subclasses
33
+ end
34
+
35
+ # Handle input key for this component
36
+ # Return :handled or :pass_through
37
+ def handle_input(_key)
38
+ :pass_through
39
+ end
40
+
41
+ # Component height calculation contract
42
+ # @param available_height [Integer] Total height available from parent
43
+ # @return [Integer, :flexible, :fill] Height requirement:
44
+ # - Integer: Fixed height in rows
45
+ # - :flexible: Use as much space as needed, up to available
46
+ # - :fill: Take all remaining space after fixed components
47
+ def preferred_height(_available_height)
48
+ :flexible
49
+ end
50
+
51
+ # Component lifecycle methods
52
+
53
+ # Called once when component is first initialized with a parent
54
+ def mount
55
+ ensure_mounted
56
+ end
57
+
58
+ # Called when component is removed from the component tree
59
+ def unmount
60
+ ensure_unmounted
61
+ end
62
+
63
+ # Mark component as needing a re-render
64
+ def invalidate
65
+ @needs_update = true
66
+ end
67
+
68
+ # Check if component needs to be re-rendered
69
+ def needs_update?
70
+ @needs_update
71
+ end
72
+
73
+ # Mark component as updated (called automatically after render)
74
+ def mark_updated
75
+ @needs_update = false
76
+ end
77
+
78
+ # Override in subclasses for mount logic
79
+ def on_mount
80
+ # no-op by default
81
+ end
82
+
83
+ # Override in subclasses for cleanup logic
84
+ def on_unmount
85
+ # no-op by default
86
+ end
87
+
88
+ # Observer pattern support for state changes
89
+ def state_changed(_path, _old_value, _new_value)
90
+ invalidate
91
+ end
92
+
93
+ private
94
+
95
+ def ensure_mounted
96
+ return if @initialized
97
+
98
+ on_mount
99
+ @initialized = true
100
+ end
101
+
102
+ def ensure_unmounted
103
+ return unless @initialized
104
+
105
+ on_unmount
106
+ @initialized = false
107
+ end
108
+ end
109
+ end
110
+ end