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,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_component'
4
+ require_relative 'surface'
5
+ require_relative 'rect'
6
+ require_relative 'sidebar/tab_header_component'
7
+ require_relative 'sidebar/toc_tab_renderer'
8
+ require_relative 'sidebar/annotations_tab_renderer'
9
+ require_relative 'sidebar/bookmarks_tab_renderer'
10
+ require_relative 'ui/text_utils'
11
+
12
+ module Shoko
13
+ module Adapters::Output::Ui::Components
14
+ # Collapsible sidebar panel with tabbed interface for TOC, Annotations, and Bookmarks
15
+ class SidebarPanelComponent < BaseComponent
16
+ include Adapters::Output::Ui::Constants::UI
17
+
18
+ TABS = %i[toc annotations bookmarks].freeze
19
+ TAB_TITLES = { toc: 'Contents', annotations: 'Annotations', bookmarks: 'Bookmarks' }.freeze
20
+ TAB_KEYS = { toc: 'T', annotations: 'A', bookmarks: 'B' }.freeze
21
+ HELP_TEXTS = {
22
+ toc: '↑↓ Navigate • ⏎ Jump • / Filter',
23
+ annotations: '↑↓ Navigate • ⏎ Jump • e Edit • d Delete',
24
+ bookmarks: '↑↓ Navigate • ⏎ Jump • d Delete',
25
+ }.freeze
26
+ DEFAULT_WIDTH_PERCENT = 30
27
+ MIN_WIDTH = 24
28
+ HEADER_HEIGHT = 2
29
+ TAB_HEIGHT = 3
30
+ HELP_HEIGHT = 1
31
+
32
+ def initialize(state, dependencies)
33
+ super() # Call BaseComponent constructor
34
+ @state = state
35
+ @dependencies = dependencies
36
+ @tab_header = Sidebar::TabHeaderComponent.new(state)
37
+ @toc_renderer = Sidebar::TocTabRenderer.new(state, dependencies)
38
+ @annotations_renderer = Sidebar::AnnotationsTabRenderer.new(state)
39
+ @bookmarks_renderer = Sidebar::BookmarksTabRenderer.new(state, dependencies)
40
+
41
+ # Observe sidebar state changes
42
+ state.add_observer(self,
43
+ %i[reader sidebar_visible],
44
+ %i[reader sidebar_active_tab],
45
+ %i[reader sidebar_toc_selected],
46
+ %i[reader sidebar_toc_collapsed],
47
+ %i[reader sidebar_annotations_selected],
48
+ %i[reader sidebar_bookmarks_selected])
49
+ end
50
+
51
+ def preferred_width(total_width)
52
+ state = @state
53
+ return :hidden unless state.get(%i[reader sidebar_visible])
54
+
55
+ # Calculate width as percentage of total, with minimum
56
+ preferred = (total_width * DEFAULT_WIDTH_PERCENT / 100.0).round
57
+ [preferred, MIN_WIDTH].max
58
+ end
59
+
60
+ def do_render(surface, bounds)
61
+ state = @state
62
+ bw = bounds.width
63
+ bh = bounds.height
64
+ return unless state.get(%i[reader sidebar_visible]) && bw >= MIN_WIDTH
65
+
66
+ # Cache frequently-used bounds values
67
+ bx = bounds.x
68
+ by = bounds.y
69
+ # bw, bh already cached above
70
+
71
+ # Draw modern border
72
+ draw_border(surface, bounds)
73
+
74
+ content_bounds = content_bounds_for(bounds)
75
+ return unless content_bounds
76
+
77
+ # Render minimal header with title only
78
+ header_bounds = Rect.new(x: bx, y: by, width: bw,
79
+ height: HEADER_HEIGHT)
80
+ render_header(surface, header_bounds)
81
+
82
+ # Render active tab content
83
+ render_active_tab(surface, content_bounds)
84
+
85
+ # Render help text
86
+ help_bounds = Rect.new(
87
+ x: bx,
88
+ y: content_bounds.y + content_bounds.height,
89
+ width: bw,
90
+ height: HELP_HEIGHT
91
+ )
92
+ render_help(surface, help_bounds)
93
+
94
+ # Render tab navigation at bottom
95
+ tab_bounds = Rect.new(
96
+ x: bx,
97
+ y: by + bh - TAB_HEIGHT,
98
+ width: bw,
99
+ height: TAB_HEIGHT
100
+ )
101
+ @tab_header.render(surface, tab_bounds)
102
+ end
103
+
104
+ def sidebar_bounds_for(total_width, total_height)
105
+ return nil unless @state.get(%i[reader sidebar_visible])
106
+
107
+ width = preferred_width(total_width)
108
+ return nil unless width.is_a?(Integer) && width.positive?
109
+
110
+ width = [width, total_width].min
111
+ Rect.new(x: 1, y: 1, width: width, height: total_height)
112
+ end
113
+
114
+ def tab_for_point(col, row, sidebar_bounds)
115
+ return nil unless sidebar_bounds
116
+
117
+ tab_bounds = tab_bounds_for(sidebar_bounds)
118
+ @tab_header.tab_for_point(tab_bounds, col, row)
119
+ end
120
+
121
+ def toc_entry_at(col, row, sidebar_bounds)
122
+ return nil unless sidebar_bounds
123
+
124
+ active_tab = Shoko::Application::Selectors::ReaderSelectors.sidebar_active_tab(@state)
125
+ return nil unless active_tab == :toc
126
+
127
+ content_bounds = content_bounds_for(sidebar_bounds)
128
+ return nil unless content_bounds
129
+
130
+ @toc_renderer.entry_at(content_bounds, col, row)
131
+ end
132
+
133
+ def toc_scroll_metrics(sidebar_bounds)
134
+ return nil unless sidebar_bounds
135
+
136
+ active_tab = Shoko::Application::Selectors::ReaderSelectors.sidebar_active_tab(@state)
137
+ return nil unless active_tab == :toc
138
+
139
+ content_bounds = content_bounds_for(sidebar_bounds)
140
+ return nil unless content_bounds
141
+
142
+ @toc_renderer.scroll_metrics(content_bounds)
143
+ end
144
+
145
+ private
146
+
147
+ def draw_border(surface, bounds)
148
+ # Draw modern vertical border on the right edge
149
+ h = bounds.height
150
+ w = bounds.width
151
+ reset = Terminal::ANSI::RESET
152
+ dim = COLOR_TEXT_DIM
153
+ (1..h).each do |y|
154
+ surface.write(bounds, y, w, "#{dim}│#{reset}")
155
+ end
156
+ end
157
+
158
+ def render_header(surface, bounds)
159
+ state = @state
160
+
161
+ # Simple clean title
162
+ active_tab = Shoko::Application::Selectors::ReaderSelectors.sidebar_active_tab(state)
163
+ title = TAB_TITLES[active_tab] || 'Sidebar'
164
+ reset = Terminal::ANSI::RESET
165
+ surface.write(bounds, 1, 2, "#{SELECTION_HIGHLIGHT}#{title}#{reset}")
166
+
167
+ # Close indicator
168
+ w = bounds.width
169
+ key = TAB_KEYS[active_tab] || 'T'
170
+ close_text = "#{COLOR_TEXT_DIM}[#{key}]#{reset}"
171
+ surface.write(bounds, 1, w - 5, close_text)
172
+ end
173
+
174
+ def render_help(surface, bounds)
175
+ state = @state
176
+
177
+ active_tab = Shoko::Application::Selectors::ReaderSelectors.sidebar_active_tab(state)
178
+ reset = Terminal::ANSI::RESET
179
+ width = bounds.width
180
+ hint = HELP_TEXTS[active_tab]
181
+ return unless hint
182
+
183
+ max_hint_width = [width - 4, 1].max
184
+ clipped_hint = UI::TextUtils.truncate_text(hint, max_hint_width)
185
+ surface.write(bounds, 1, 2, "#{COLOR_TEXT_DIM}#{clipped_hint}#{reset}")
186
+ end
187
+
188
+ def render_active_tab(surface, bounds)
189
+ active_tab = Shoko::Application::Selectors::ReaderSelectors.sidebar_active_tab(@state)
190
+ renderer = { toc: @toc_renderer, annotations: @annotations_renderer,
191
+ bookmarks: @bookmarks_renderer }[active_tab]
192
+ renderer&.render(surface, bounds)
193
+ end
194
+
195
+ def content_bounds_for(bounds)
196
+ content_height = bounds.height - HEADER_HEIGHT - TAB_HEIGHT - HELP_HEIGHT
197
+ return nil if content_height <= 0
198
+
199
+ Rect.new(
200
+ x: bounds.x,
201
+ y: bounds.y + HEADER_HEIGHT,
202
+ width: bounds.width,
203
+ height: content_height
204
+ )
205
+ end
206
+
207
+ def tab_bounds_for(sidebar_bounds)
208
+ Rect.new(
209
+ x: sidebar_bounds.x,
210
+ y: sidebar_bounds.y + sidebar_bounds.height - TAB_HEIGHT,
211
+ width: sidebar_bounds.width,
212
+ height: TAB_HEIGHT
213
+ )
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../terminal/terminal.rb'
4
+ require_relative '../../terminal/text_metrics.rb'
5
+
6
+ module Shoko
7
+ module Adapters::Output::Ui::Components
8
+ # Terminal wrapper that applies bounds and basic clipping
9
+ class Surface
10
+ def initialize(output = Terminal)
11
+ @output = output
12
+ @style_stack = []
13
+ end
14
+
15
+ # Write text at local (row, col) relative to bounds
16
+ # Applies basic clipping to the provided bounds
17
+ def write(bounds, row, col, text)
18
+ b_height = bounds.height
19
+ b_width = bounds.width
20
+ return if b_height <= 0 || b_width <= 0
21
+
22
+ b_y = bounds.y
23
+ b_x = bounds.x
24
+ b_right = bounds.right
25
+ b_bottom = bounds.bottom
26
+
27
+ abs_row = b_y + row - 1
28
+ abs_col = b_x + col - 1
29
+
30
+ return if abs_row < b_y || abs_row > b_bottom
31
+ return if abs_col < b_x || abs_col > b_right
32
+
33
+ max_width = b_right - abs_col + 1
34
+ clipped = Shoko::Adapters::Output::Terminal::TextMetrics.truncate_to(
35
+ text.to_s,
36
+ max_width,
37
+ start_column: [abs_col - 1, 0].max
38
+ )
39
+ clipped = apply_dim(clipped) if dimmed?
40
+ return if clipped.nil? || clipped.empty?
41
+
42
+ @output.write(abs_row, abs_col, clipped)
43
+ end
44
+
45
+ # Write using absolute terminal coordinates while still clipping to bounds.
46
+ #
47
+ # This is intended for overlay components that operate in absolute
48
+ # coordinates (e.g., mouse hit regions, selection geometry).
49
+ def write_abs(bounds, abs_row, abs_col, text)
50
+ local_row = abs_row.to_i - bounds.y + 1
51
+ local_col = abs_col.to_i - bounds.x + 1
52
+ write(bounds, local_row, local_col, text)
53
+ end
54
+
55
+ # Convenience to fill an area with a character
56
+ def fill(bounds, char)
57
+ w = bounds.width
58
+ h = bounds.height
59
+ line = char.to_s * w
60
+ (0...h).each do |r|
61
+ write(bounds, r + 1, 1, line)
62
+ end
63
+ end
64
+
65
+ def with_dimmed
66
+ @style_stack << :dim
67
+ yield
68
+ ensure
69
+ @style_stack.pop
70
+ end
71
+
72
+ private
73
+
74
+ def dimmed?
75
+ @style_stack.include?(:dim)
76
+ end
77
+
78
+ def apply_dim(text)
79
+ dim = Terminal::ANSI::DIM
80
+ reset = Terminal::ANSI::RESET
81
+ return text if text.empty?
82
+
83
+ transformed = text.gsub(reset, "#{reset}#{dim}")
84
+ "#{dim}#{transformed}#{reset}"
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_component'
4
+ require_relative '../../terminal/text_metrics.rb'
5
+ require_relative '../../../../core/models/selection_anchor.rb'
6
+ module Shoko
7
+ module Adapters::Output::Ui::Components
8
+ # Unified overlay component that handles all tooltip/popup rendering
9
+ # including text selection highlighting, popup menus, and annotations.
10
+ #
11
+ # This component consolidates the scattered rendering logic and provides
12
+ # consistent coordinate handling for the fragile tooltip system.
13
+ class TooltipOverlayComponent < BaseComponent
14
+ include Adapters::Output::Ui::Constants::UI
15
+
16
+ def initialize(controller, coordinate_service:)
17
+ super()
18
+ @controller = controller
19
+ @coordinate_service = coordinate_service
20
+ @last_selection_segments = []
21
+ @geometry_cache_key = nil
22
+ @geometry_cache = nil
23
+ end
24
+
25
+ # Render all overlay elements: highlights, popups, tooltips
26
+ def do_render(surface, bounds)
27
+ # Render in specific order to ensure proper layering
28
+ clear_previous_selection_artifacts(surface, bounds)
29
+ render_saved_annotations(surface, bounds)
30
+ render_active_selection(surface, bounds)
31
+ render_popup_menu(surface, bounds)
32
+ render_annotations_overlay(surface, bounds)
33
+ render_annotation_editor_overlay(surface, bounds)
34
+ render_toast_notification(surface, bounds)
35
+ end
36
+
37
+ private
38
+
39
+ def render_saved_annotations(surface, bounds)
40
+ state = @controller.state
41
+ anns = state.get(%i[reader annotations])
42
+ return unless anns
43
+
44
+ current_ch = state.get(%i[reader current_chapter])
45
+ chapter_annotations = anns.select { |annotation| annotation['chapter_index'] == current_ch }
46
+ chapter_annotations.each do |annotation|
47
+ render_text_highlight(surface, bounds, annotation['range'], HIGHLIGHT_BG_SAVED)
48
+ end
49
+ end
50
+
51
+ def render_active_selection(surface, bounds)
52
+ # Render current selection highlight
53
+ selection_range = @controller.state.get(%i[reader selection])
54
+
55
+ unless selection_range
56
+ # No active selection; keep any previously rendered segments for one clear pass
57
+ @pending_clear = true if @last_selection_segments.any?
58
+ return
59
+ end
60
+
61
+ # Reset tracking for this frame
62
+ @last_selection_segments.clear
63
+ render_text_highlight(surface, bounds, selection_range, HIGHLIGHT_BG_ACTIVE)
64
+ end
65
+
66
+ def render_popup_menu(surface, bounds)
67
+ popup_menu = @controller.state.get(%i[reader popup_menu])
68
+ return unless popup_menu&.visible
69
+
70
+ # Unified component rendering path
71
+ popup_menu.render(surface, bounds)
72
+ end
73
+
74
+ def render_annotations_overlay(surface, bounds)
75
+ overlay = @controller.state.get(%i[reader annotations_overlay])
76
+ return unless overlay.respond_to?(:visible?) && overlay.visible?
77
+
78
+ overlay.render(surface, bounds)
79
+ end
80
+
81
+ def render_annotation_editor_overlay(surface, bounds)
82
+ overlay = @controller.state.get(%i[reader annotation_editor_overlay])
83
+ return unless overlay.respond_to?(:visible?) && overlay.visible?
84
+
85
+ overlay.render(surface, bounds)
86
+ end
87
+
88
+ def render_toast_notification(surface, bounds)
89
+ message = Application::Selectors::ReaderSelectors.message(@controller.state)
90
+ message = message.to_s
91
+ return if message.empty?
92
+
93
+ ui = Adapters::Output::Ui::Constants::UI
94
+ width = bounds.width
95
+ max_width = [width - 2, 1].max
96
+ label_max = [max_width - 1, 1].max
97
+ label = " #{message} "
98
+ label = Shoko::Adapters::Output::Terminal::TextMetrics.truncate_to(label, label_max)
99
+ content = "|#{label}"
100
+ col = [width - Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(content) + 1, 1].max
101
+
102
+ toast = "#{Terminal::ANSI::RESET}#{ui::TOAST_ACCENT}|#{ui::TOAST_FG}#{label}#{Terminal::ANSI::RESET}"
103
+ surface.write(bounds, 1, col, toast)
104
+ end
105
+
106
+ def render_text_highlight(surface, bounds, range, color)
107
+ rendered_lines = Application::Selectors::ReaderSelectors.rendered_lines(@controller.state)
108
+ return if rendered_lines.empty?
109
+
110
+ normalized_range = @coordinate_service.normalize_selection_range(range, rendered_lines)
111
+ return unless normalized_range
112
+
113
+ start_anchor = Shoko::Core::Models::SelectionAnchor.from(normalized_range[:start])
114
+ end_anchor = Shoko::Core::Models::SelectionAnchor.from(normalized_range[:end])
115
+ return unless start_anchor && end_anchor
116
+
117
+ cache = geometry_cache_for(rendered_lines)
118
+ ordered = cache[:ordered]
119
+ index_by_key = cache[:index_by_key]
120
+ return if ordered.empty?
121
+
122
+ start_idx = index_by_key[start_anchor.geometry_key]
123
+ end_idx = index_by_key[end_anchor.geometry_key]
124
+ return unless start_idx && end_idx
125
+
126
+ ordered[start_idx..end_idx].each do |geometry|
127
+ start_cell = geometry.key == start_anchor.geometry_key ? start_anchor.cell_index : 0
128
+ end_cell = geometry.key == end_anchor.geometry_key ? end_anchor.cell_index : geometry.cells.length
129
+ render_geometry_highlight(surface, bounds, geometry, start_cell, end_cell, color)
130
+ end
131
+ end
132
+
133
+ def render_geometry_highlight(surface, bounds, geometry, start_cell, end_cell, color)
134
+ return if end_cell <= start_cell
135
+
136
+ start_char = char_index_for_cell(geometry, start_cell)
137
+ end_char = char_index_for_cell(geometry, end_cell)
138
+ return if end_char <= start_char
139
+
140
+ segment_text = geometry.plain_text[start_char...end_char]
141
+ return if segment_text.nil? || segment_text.empty?
142
+
143
+ highlight = "#{color}#{COLOR_TEXT_PRIMARY}#{segment_text}#{Terminal::ANSI::RESET}"
144
+ start_col = screen_column_for_cell(geometry, start_cell)
145
+ surface.write_abs(bounds, geometry.row, start_col, highlight)
146
+ record_selection_segment(geometry.row, start_col, segment_text)
147
+ end
148
+
149
+ def screen_column_for_cell(geometry, cell_index)
150
+ if cell_index <= 0
151
+ geometry.column_origin
152
+ elsif cell_index >= geometry.cells.length
153
+ geometry.column_origin + geometry.visible_width
154
+ else
155
+ geometry.column_origin + geometry.cells[cell_index].screen_x
156
+ end
157
+ end
158
+
159
+ def char_index_for_cell(geometry, cell_index)
160
+ cells = geometry.cells
161
+ return 0 if cells.empty?
162
+
163
+ if cell_index <= 0
164
+ 0
165
+ elsif cell_index >= cells.length
166
+ geometry.plain_text.length
167
+ else
168
+ cells[cell_index].char_start
169
+ end
170
+ end
171
+
172
+ def geometry_cache_for(rendered_lines)
173
+ cache_key = rendered_lines.object_id
174
+ return @geometry_cache if @geometry_cache_key == cache_key && @geometry_cache
175
+
176
+ geometry_by_key = {}
177
+ rendered_lines.each do |key, info|
178
+ geometry = info[:geometry]
179
+ next unless geometry
180
+
181
+ geometry_by_key[key] = geometry
182
+ end
183
+ ordered = order_geometry(geometry_by_key.values)
184
+ index_by_key = {}
185
+ ordered.each_with_index { |geo, idx| index_by_key[geo.key] = idx }
186
+
187
+ @geometry_cache_key = cache_key
188
+ @geometry_cache = { ordered: ordered, index_by_key: index_by_key }
189
+ end
190
+
191
+ def order_geometry(geometries)
192
+ geometries.sort_by do |geo|
193
+ [geo.page_id || 0, geo.line_offset || 0, geo.column_id || 0, geo.row || 0, geo.column_origin || 0]
194
+ end
195
+ end
196
+
197
+ def record_selection_segment(row, col, text)
198
+ @last_selection_segments << {
199
+ row: row,
200
+ col: col,
201
+ text: text,
202
+ }
203
+ end
204
+
205
+ # If selection was present on previous frame but not this one, explicitly repaint
206
+ # the previously highlighted character cells to clear any lingering background color
207
+ def clear_previous_selection_artifacts(surface, bounds)
208
+ return unless @pending_clear && @last_selection_segments.any?
209
+
210
+ @last_selection_segments.each do |seg|
211
+ safe_text = seg[:text] || ''
212
+ reset = Terminal::ANSI::RESET
213
+ repaint = "#{reset}#{COLOR_TEXT_PRIMARY}#{safe_text}#{reset}"
214
+ surface.write_abs(bounds, seg[:row], seg[:col], repaint)
215
+ end
216
+
217
+ @last_selection_segments.clear
218
+ @pending_clear = false
219
+ end
220
+
221
+ # Column bounds and overlap checks are now handled by CoordinateService
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../terminal/text_metrics.rb'
4
+
5
+ module Shoko
6
+ module Adapters::Output::Ui::Components
7
+ module UI
8
+ # Helper for drawing bordered boxes with optional labels.
9
+ module BoxDrawer
10
+ def draw_box(surface, bounds, row, col, height, width, label: nil)
11
+ # Top border
12
+ hline = '─' * (width - 2)
13
+ surface.write(bounds, row, col, "╭#{hline}╮")
14
+ if label && width > 4
15
+ label_text = "[ #{label} ]"
16
+ available = width - 3
17
+ clipped = Shoko::Adapters::Output::Terminal::TextMetrics.truncate_to(label_text, available, start_column: bounds.x + col)
18
+ surface.write(bounds, row, col + 2, clipped) unless clipped.empty?
19
+ end
20
+ # Sides
21
+ (1...(height - 1)).each do |index|
22
+ y_pos = row + index
23
+ surface.write(bounds, y_pos, col, '│')
24
+ surface.write(bounds, y_pos, col + width - 1, '│')
25
+ end
26
+ # Bottom
27
+ surface.write(bounds, row + height - 1, col, "╰#{hline}╯")
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shoko
4
+ module Adapters::Output::Ui::Components
5
+ module UI
6
+ # Shared helpers for list-based components to keep pagination logic consistent.
7
+ module ListHelpers
8
+ module_function
9
+
10
+ def visible_window(total_items, per_page, selected)
11
+ return [0, 0] if total_items <= 0 || per_page <= 0
12
+
13
+ clamped_selected = selected.clamp(0, total_items - 1)
14
+ start_index = if clamped_selected < per_page
15
+ 0
16
+ else
17
+ clamped_selected - per_page + 1
18
+ end
19
+ max_start = [total_items - per_page, 0].max
20
+ start_index = [start_index, max_start].min
21
+ end_index = [start_index + per_page - 1, total_items - 1].min
22
+ [start_index, end_index]
23
+ end
24
+
25
+ def slice_visible(items, per_page, selected)
26
+ total = items.length
27
+ start_index, end_index = visible_window(total, per_page, selected)
28
+ [start_index, items[start_index..end_index] || []]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../terminal/terminal.rb'
4
+
5
+ module Shoko
6
+ module Adapters::Output::Ui::Components
7
+ module UI
8
+ # Calculates overlay dimensions based on viewport bounds.
9
+ class OverlaySizing
10
+ def initialize(width_ratio:, width_padding:, min_width:, height_ratio:, height_padding:, min_height:)
11
+ @width_ratio = width_ratio
12
+ @width_padding = width_padding
13
+ @min_width = min_width
14
+ @height_ratio = height_ratio
15
+ @height_padding = height_padding
16
+ @min_height = min_height
17
+ end
18
+
19
+ def width_for(total_width)
20
+ clamp_dimension(total_width, ratio: @width_ratio, padding: @width_padding, min: @min_width)
21
+ end
22
+
23
+ def height_for(total_height)
24
+ clamp_dimension(total_height, ratio: @height_ratio, padding: @height_padding, min: @min_height)
25
+ end
26
+
27
+ private
28
+
29
+ def clamp_dimension(total, ratio:, padding:, min:)
30
+ base = [(total * ratio).floor, total - padding].min
31
+ upper = total - padding
32
+ lower = [min, upper].min
33
+ base.clamp(lower, upper)
34
+ end
35
+ end
36
+
37
+ # Provides centered overlay placement and frame geometry helpers.
38
+ class OverlayLayout
39
+ attr_reader :origin_x, :origin_y, :width, :height
40
+
41
+ def initialize(origin_x:, origin_y:, width:, height:)
42
+ @origin_x = origin_x
43
+ @origin_y = origin_y
44
+ @width = width
45
+ @height = height
46
+ end
47
+
48
+ def self.centered(bounds, width:, height:)
49
+ origin_x = [(bounds.width - width) / 2, 1].max + 1
50
+ origin_y = [(bounds.height - height) / 2, 1].max + 1
51
+ new(origin_x: origin_x, origin_y: origin_y, width: width, height: height)
52
+ end
53
+
54
+ def inner_x
55
+ origin_x + 1
56
+ end
57
+
58
+ def inner_y
59
+ origin_y + 1
60
+ end
61
+
62
+ def inner_width
63
+ width - 2
64
+ end
65
+
66
+ def inner_height
67
+ height - 2
68
+ end
69
+
70
+ def fill_background(surface, bounds, background:)
71
+ reset = Terminal::ANSI::RESET
72
+ height.times do |offset|
73
+ surface.write(bounds, origin_y + offset, origin_x, "#{background}#{' ' * width}#{reset}")
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end