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,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_service'
4
+ require_relative 'navigation/context_builder'
5
+ require_relative 'navigation/absolute_change_applier'
6
+ require_relative 'navigation/absolute_layout'
7
+ require_relative 'navigation/dynamic_change_applier'
8
+ require_relative 'navigation/dynamic_strategy'
9
+ require_relative 'navigation/image_offset_snapper'
10
+ require_relative 'navigation/state_updater'
11
+ require_relative 'navigation/absolute_strategy'
12
+
13
+ module Shoko
14
+ module Core
15
+ module Services
16
+ # Pure business logic for book navigation.
17
+ # Replaces the coupled NavigationService with clean domain logic.
18
+ class NavigationService < BaseService
19
+ # Navigate to next page
20
+ def next_page
21
+ ctx = build_nav_context
22
+ dynamic_route_exec(ctx,
23
+ -> { @dynamic_applier.apply(Navigation::DynamicStrategy.next_page(ctx)) },
24
+ -> { @absolute_applier.apply(Navigation::AbsoluteStrategy.next_page(ctx)) })
25
+ end
26
+
27
+ # Navigate to previous page
28
+ def prev_page
29
+ ctx = build_nav_context
30
+ dynamic_route_exec(ctx,
31
+ -> { @dynamic_applier.apply(Navigation::DynamicStrategy.prev_page(ctx)) },
32
+ -> { @absolute_applier.apply(Navigation::AbsoluteStrategy.prev_page(ctx)) })
33
+ end
34
+
35
+ # Navigate to specific chapter
36
+ #
37
+ # @param chapter_index [Integer] Zero-based chapter index
38
+ def jump_to_chapter(chapter_index)
39
+ validate_chapter_index(chapter_index)
40
+ ctx = build_nav_context
41
+ dynamic_route_exec(ctx,
42
+ lambda do
43
+ page_index = @page_calculator.find_page_index(chapter_index, 0)
44
+ page_index = 0 if page_index.nil? || page_index.negative?
45
+ @state_updater.apply({ %i[reader current_chapter] => chapter_index,
46
+ %i[reader current_page_index] => page_index })
47
+ end,
48
+ lambda {
49
+ @absolute_applier.apply(Navigation::AbsoluteStrategy.jump_to_chapter(ctx, chapter_index))
50
+ })
51
+ end
52
+
53
+ # Navigate to beginning of book
54
+ def go_to_start
55
+ ctx = build_nav_context
56
+ dynamic_route_exec(ctx,
57
+ -> { @dynamic_applier.apply(Navigation::DynamicStrategy.go_to_start(ctx)) },
58
+ -> { @absolute_applier.apply(Navigation::AbsoluteStrategy.go_to_start(ctx)) })
59
+ end
60
+
61
+ # Navigate to end of book
62
+ def go_to_end
63
+ ctx = build_nav_context
64
+ dynamic_route_exec(ctx,
65
+ -> { @dynamic_applier.apply(Navigation::DynamicStrategy.go_to_end(ctx)) },
66
+ -> { @absolute_applier.apply(Navigation::AbsoluteStrategy.go_to_end(ctx)) })
67
+ end
68
+
69
+ # Scroll within current page/view
70
+ #
71
+ # @param direction [Symbol] :up or :down
72
+ # @param lines [Integer] Number of lines to scroll
73
+ def scroll(direction, lines = 1)
74
+ ctx = build_nav_context
75
+ if ctx.mode == :dynamic
76
+ # No-op for dynamic; scrolling is page-based via next/prev
77
+ return
78
+ end
79
+
80
+ changes = Navigation::AbsoluteStrategy.scroll(ctx, direction, lines)
81
+ @absolute_applier.apply(changes)
82
+ end
83
+
84
+ protected
85
+
86
+ def required_dependencies
87
+ [:state_store]
88
+ end
89
+
90
+ def setup_service_dependencies
91
+ @state_store = resolve(:state_store)
92
+ @page_calculator = resolve(:page_calculator) if registered?(:page_calculator)
93
+ @layout_service = resolve(:layout_service) if registered?(:layout_service)
94
+
95
+ @state_updater = Navigation::StateUpdater.new(@state_store)
96
+ @context_builder = Navigation::ContextBuilder.new(@state_store, @page_calculator)
97
+ @absolute_layout = Navigation::AbsoluteLayout.new(state_store: @state_store, layout_service: @layout_service)
98
+
99
+ formatting_service = resolve(:formatting_service) if registered?(:formatting_service)
100
+ document = resolve(:document) if registered?(:document)
101
+ @image_snapper = Navigation::ImageOffsetSnapper.new(
102
+ state_store: @state_store,
103
+ layout_service: @layout_service,
104
+ formatting_service: formatting_service,
105
+ document: document
106
+ )
107
+
108
+ @dynamic_applier = Navigation::DynamicChangeApplier.new(
109
+ state_store: @state_store,
110
+ page_calculator: @page_calculator,
111
+ state_updater: @state_updater
112
+ )
113
+ @absolute_applier = Navigation::AbsoluteChangeApplier.new(
114
+ state_updater: @state_updater,
115
+ absolute_layout: @absolute_layout,
116
+ image_snapper: @image_snapper,
117
+ advance_callback: method(:jump_to_chapter)
118
+ )
119
+ end
120
+
121
+ private
122
+
123
+ def build_nav_context
124
+ ctx = @context_builder.build
125
+ @absolute_layout.populate_context(ctx)
126
+ ctx
127
+ end
128
+
129
+ def dynamic_route_exec(ctx, dyn_proc, abs_proc)
130
+ if ctx.mode == :dynamic && @page_calculator
131
+ dyn_proc.call
132
+ else
133
+ abs_proc.call
134
+ end
135
+ end
136
+
137
+ def validate_chapter_index(index)
138
+ raise ArgumentError, 'Chapter index must be non-negative' if index.negative?
139
+
140
+ current_state = @state_store.current_state
141
+ total_chapters = current_state.dig(:reader, :total_chapters) || 0
142
+
143
+ return unless index >= total_chapters
144
+
145
+ raise ArgumentError, "Chapter index #{index} exceeds total chapters #{total_chapters}"
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_service'
4
+ require_relative 'pagination/internal/absolute_page_map_builder'
5
+ require_relative 'pagination/internal/dynamic_page_map_builder'
6
+ require_relative 'pagination/internal/page_hydrator'
7
+ require_relative 'pagination/internal/pagination_workflow'
8
+ require_relative 'pagination/internal/layout_metrics_calculator'
9
+ require_relative '../../adapters/output/terminal/text_metrics.rb'
10
+ require_relative '../../adapters/output/kitty/kitty_graphics.rb'
11
+
12
+ module Shoko
13
+ module Core
14
+ module Services
15
+ # Enhanced service for page calculations with full PageManager functionality.
16
+ # Migrated from legacy Services::PageManager with dependency injection.
17
+ class PageCalculatorService < BaseService
18
+ attr_reader :pages_data
19
+
20
+ def initialize(dependencies)
21
+ super
22
+ @text_wrapper = DefaultTextWrapper.new
23
+ @pages_data = []
24
+ @chapter_page_index = {}
25
+ @layout_service = begin
26
+ resolve(:layout_service)
27
+ rescue StandardError
28
+ nil
29
+ end
30
+ @metrics_calculator = Pagination::Internal::LayoutMetricsCalculator.new(@state_store,
31
+ layout_service: @layout_service)
32
+ @pagination_cache = begin
33
+ resolve(:pagination_cache)
34
+ rescue StandardError
35
+ nil
36
+ end
37
+ @instrumentation = begin
38
+ resolve(:instrumentation_service)
39
+ rescue StandardError
40
+ nil
41
+ end
42
+ @pagination_workflow = Pagination::Internal::PaginationWorkflow.new(
43
+ metrics_calculator: @metrics_calculator,
44
+ dependencies: @dependencies,
45
+ pagination_cache: @pagination_cache
46
+ )
47
+ @page_hydrator = Pagination::Internal::PageHydrator.new(
48
+ state_store: @state_store,
49
+ dependencies: @dependencies,
50
+ text_wrapper: @text_wrapper,
51
+ metrics_calculator: @metrics_calculator
52
+ )
53
+ end
54
+
55
+ # Build complete page map (PageManager compatibility)
56
+ def build_page_map(terminal_width, terminal_height, doc, config, &)
57
+ return unless Shoko::Application::Selectors::ConfigSelectors.page_numbering_mode(config) == :dynamic
58
+
59
+ result = @pagination_workflow.build_dynamic(doc: doc,
60
+ width: terminal_width,
61
+ height: terminal_height,
62
+ config: config,
63
+ &)
64
+ @doc_ref = doc
65
+ @pages_data = result.pages
66
+ rebuild_page_index!
67
+ @pages_data
68
+ end
69
+
70
+ # Get page data by index (PageManager compatibility)
71
+ def get_page(page_index)
72
+ return nil if @pages_data.empty?
73
+ return @pages_data.first if page_index.negative?
74
+ return @pages_data.last if page_index >= @pages_data.size
75
+
76
+ page = @pages_data[page_index]
77
+ return page if formatted_lines?(page[:lines])
78
+
79
+ hydrated = measure_with_instrumentation('page_map.hydrate') do
80
+ doc = resolve_document_reference
81
+ @page_hydrator.hydrate(page, doc, prefer_formatting: true)
82
+ rescue StandardError
83
+ page
84
+ end
85
+
86
+ @pages_data[page_index] = hydrated if hydrated
87
+ hydrated
88
+ end
89
+
90
+ # Find page index for chapter and line offset (PageManager compatibility)
91
+ def find_page_index(chapter_index, line_offset)
92
+ pages = @chapter_page_index[chapter_index]
93
+ return 0 unless pages && !pages.empty?
94
+
95
+ match = pages.bsearch { |page| line_offset <= page[:end_line].to_i }
96
+ return match[:global_index] if match && match[:global_index]
97
+
98
+ pages.last[:global_index] || 0
99
+ end
100
+
101
+ # Total pages built in map (PageManager compatibility)
102
+ def total_pages
103
+ @pages_data.size
104
+ end
105
+
106
+ # Build absolute mode page map (per-chapter pages) with progress callback.
107
+ # Returns an array of pages per chapter.
108
+ # @yield [done, total] optional progress callback
109
+ def build_absolute_page_map(terminal_width, terminal_height, doc, state)
110
+ # Compute layout metrics based on current config
111
+ col_width, content_height = @metrics_calculator.layout(terminal_width, terminal_height, state)
112
+ lines_per_page = @metrics_calculator.lines_per_page_for(content_height, state)
113
+ wrapper = begin
114
+ @dependencies&.resolve(:wrapping_service)
115
+ rescue StandardError
116
+ nil
117
+ end
118
+
119
+ Pagination::Internal::AbsolutePageMapBuilder.build(doc, col_width, lines_per_page, wrapper) do |done, total|
120
+ yield(done, total) if block_given?
121
+ end
122
+ end
123
+
124
+ # --- Unified orchestration helpers ---
125
+ # Build dynamic (lazy) page map and sync total to state. Accepts optional progress callback.
126
+ def build_dynamic_map!(width, height, doc, state, &)
127
+ build_page_map(width, height, doc, state, &)
128
+ rebuild_page_index!
129
+ state.dispatch(Shoko::Application::Actions::UpdatePaginationStateAction.new(
130
+ total_pages: total_pages
131
+ ))
132
+ end
133
+
134
+ # Build absolute page map and sync map/total/last dims to state. Accepts optional progress callback.
135
+ def build_absolute_map!(width, height, doc, state, &)
136
+ map = build_absolute_page_map(width, height, doc, state, &)
137
+ state.dispatch(Shoko::Application::Actions::UpdatePaginationStateAction.new(
138
+ page_map: map,
139
+ total_pages: map.sum,
140
+ last_width: width,
141
+ last_height: height
142
+ ))
143
+ map
144
+ end
145
+
146
+ # Apply precise pending progress (dynamic mode) if present in state
147
+ def apply_pending_precise_restore!(state)
148
+ pending = state.get(%i[reader pending_progress])
149
+ return unless pending && pending[:line_offset]
150
+
151
+ ch = pending[:chapter_index] || state.get(%i[reader current_chapter])
152
+ idx = find_page_index(ch, pending[:line_offset].to_i)
153
+ state.dispatch(Shoko::Application::Actions::UpdatePageAction.new(current_page_index: idx)) if idx && idx >= 0
154
+ state.dispatch(Shoko::Application::Actions::UpdateSelectionsAction.new(pending_progress: nil))
155
+ rescue StandardError
156
+ # no-op on failure
157
+ end
158
+
159
+ def resolve_document_reference
160
+ return @doc_ref if @doc_ref
161
+
162
+ @dependencies&.resolve(:document)
163
+ rescue StandardError
164
+ nil
165
+ end
166
+
167
+ def formatted_lines?(lines)
168
+ first = Array(lines).find { |ln| !ln.nil? }
169
+ first.respond_to?(:segments) && first.respond_to?(:text)
170
+ end
171
+
172
+ def rebuild_page_index!
173
+ @chapter_page_index = Hash.new { |h, k| h[k] = [] }
174
+ @pages_data.each_with_index do |page, idx|
175
+ ch = page[:chapter_index] || 0
176
+ entry = page.merge(global_index: idx)
177
+ @chapter_page_index[ch] << entry
178
+ end
179
+ @chapter_page_index.each_value { |arr| arr.sort_by! { |p| p[:end_line].to_i } }
180
+ end
181
+
182
+ # Hydrate from cached pagination without recomputation
183
+ def hydrate_from_cache(pages, state: nil, width: nil, height: nil)
184
+ return nil unless pages.is_a?(Array)
185
+
186
+ @pages_data = pages
187
+ rebuild_page_index!
188
+ total = @pages_data.size
189
+ state&.dispatch(Shoko::Application::Actions::UpdatePaginationStateAction.new(
190
+ total_pages: total,
191
+ last_width: width,
192
+ last_height: height
193
+ ))
194
+ total
195
+ end
196
+
197
+ protected
198
+
199
+ def required_dependencies
200
+ [:state_store]
201
+ end
202
+
203
+ def setup_service_dependencies
204
+ @state_store = resolve(:state_store) if @dependencies
205
+ end
206
+
207
+ private
208
+
209
+ def measure_with_instrumentation(metric, &)
210
+ if @instrumentation
211
+ @instrumentation.time(metric, &)
212
+ else
213
+ yield
214
+ end
215
+ end
216
+ end
217
+
218
+ # Default text wrapping implementation
219
+ class DefaultTextWrapper
220
+ def wrap_chapter_lines(lines, column_width)
221
+ return [] if lines.empty? || column_width <= 0
222
+
223
+ wrapped = []
224
+ lines.each do |line|
225
+ next if line.nil?
226
+
227
+ if line.strip.empty?
228
+ wrapped << ''
229
+ else
230
+ segments = Shoko::Adapters::Output::Terminal::TextMetrics.wrap_plain_text(line, column_width)
231
+ wrapped.concat(segments)
232
+ end
233
+ end
234
+ wrapped
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
240
+ # NOTE: Former helper that prepopulated lines for cached pages has been
241
+ # removed to avoid blocking first paint. Lines are populated lazily in
242
+ # #get_page when needed.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../pagination'
4
+ module Shoko::Core::Services::Pagination::Internal
5
+ # Small helper to compute absolute page maps per chapter.
6
+ # Encapsulates the per-chapter wrapping + page counting loop.
7
+ class AbsolutePageMapBuilder
8
+ def self.build(doc, col_width, lines_per_page, wrapper = nil)
9
+ total = doc.chapter_count
10
+ page_map = []
11
+ total.times do |i|
12
+ chapter = doc.get_chapter(i)
13
+ lines = chapter&.lines || []
14
+
15
+ wrapped = if wrapper
16
+ wrapper.wrap_lines(lines, i, col_width)
17
+ else
18
+ Shoko::Core::Services::DefaultTextWrapper.new.wrap_chapter_lines(lines, col_width)
19
+ end
20
+
21
+ pages = (wrapped.size.to_f / [lines_per_page, 1].max).ceil
22
+ page_map << pages
23
+ yield(i + 1, total) if block_given?
24
+ end
25
+ page_map
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../pagination'
4
+ require_relative '../../../../adapters/output/terminal/text_metrics.rb'
5
+
6
+ module Shoko::Core::Services::Pagination::Internal
7
+ # Caches wrapped lines for chapters to avoid recomputation
8
+ # Internal helper used by WrappingService; not DI-registered.
9
+ class ChapterCache
10
+ def initialize
11
+ @wrapped_cache = {}
12
+ @cache_key_memo = {}
13
+ end
14
+
15
+ # Get wrapped lines for chapter and width
16
+ # @param chapter_index [Integer]
17
+ # @param lines [Array<String>]
18
+ # @param width [Integer]
19
+ # @return [Array<String>]
20
+ def get_wrapped_lines(chapter_index, lines, width)
21
+ cache_key = generate_cache_key(chapter_index, width)
22
+ cached = @wrapped_cache[cache_key]
23
+ memo_id = @cache_key_memo[cache_key]
24
+ return cached if cached && memo_id == lines.object_id
25
+
26
+ wrapped = wrap_lines_internal(lines, width)
27
+ @wrapped_cache[cache_key] = wrapped
28
+ @cache_key_memo[cache_key] = lines.object_id
29
+ wrapped
30
+ end
31
+
32
+ # Clear cached entries for given width
33
+ def clear_cache_for_width(width)
34
+ @wrapped_cache.delete_if { |key, _| key.end_with?("_#{width}") }
35
+ end
36
+
37
+ private
38
+
39
+ def generate_cache_key(chapter_index, width)
40
+ "#{chapter_index}_#{width}"
41
+ end
42
+
43
+ def wrap_lines_internal(lines, width)
44
+ return [] if lines.nil? || width < 1
45
+
46
+ wrapped = []
47
+ lines.each do |line|
48
+ next if line.nil?
49
+
50
+ if line.strip.empty?
51
+ wrapped << ''
52
+ else
53
+ segments = Shoko::Adapters::Output::Terminal::TextMetrics.wrap_plain_text(line, width)
54
+ wrapped.concat(segments)
55
+ end
56
+ end
57
+ wrapped
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../pagination'
4
+ require_relative '../../../../adapters/output/terminal/text_metrics.rb'
5
+
6
+ module Shoko::Core::Services::Pagination::Internal
7
+ # Builds dynamic pagination page data for all chapters.
8
+ # Produces the same page hashes used by PageCalculatorService.
9
+ # Not DI-registered; used internally by the facade service.
10
+ class DynamicPageMapBuilder
11
+ def self.build(doc, col_width, lines_per_page, wrapper: nil, formatter: nil, config: nil)
12
+ pages_data = []
13
+ total = doc.chapter_count
14
+
15
+ total.times do |chapter_idx|
16
+ chapter = doc.get_chapter(chapter_idx)
17
+ next unless chapter
18
+
19
+ wrapped = wrapped_lines(doc, chapter, chapter_idx, col_width, lines_per_page,
20
+ wrapper, formatter, config)
21
+
22
+ pages = paginate_lines(wrapped, lines_per_page)
23
+ page_count = [pages.length, 1].max
24
+ pages.each_with_index do |page, page_idx|
25
+ pages_data << {
26
+ chapter_index: chapter_idx,
27
+ page_in_chapter: page_idx,
28
+ total_pages_in_chapter: page_count,
29
+ start_line: page[:start_line],
30
+ end_line: page[:end_line],
31
+ lines: page[:lines],
32
+ }
33
+ end
34
+
35
+ yield(chapter_idx + 1, total) if block_given?
36
+ end
37
+
38
+ pages_data
39
+ end
40
+
41
+ class << self
42
+ private
43
+
44
+ def paginate_lines(lines, lines_per_page)
45
+ per_page = [lines_per_page.to_i, 1].max
46
+ list = Array(lines)
47
+ return [{ start_line: 0, end_line: -1, lines: [] }] if list.empty?
48
+
49
+ pages = []
50
+ index = 0
51
+
52
+ while index < list.length
53
+ start_line = index
54
+ page_lines = []
55
+
56
+ while page_lines.length < per_page && index < list.length
57
+ group_len = image_group_length(list, index)
58
+ remaining = per_page - page_lines.length
59
+
60
+ break if group_len && group_len > remaining && !page_lines.empty?
61
+
62
+ if group_len
63
+ take = [group_len, remaining].min
64
+ page_lines.concat(list[index, take])
65
+ index += take
66
+ else
67
+ page_lines << list[index]
68
+ index += 1
69
+ end
70
+ end
71
+
72
+ pages << {
73
+ start_line: start_line,
74
+ end_line: start_line + page_lines.length - 1,
75
+ lines: page_lines,
76
+ }
77
+ end
78
+
79
+ pages
80
+ rescue StandardError
81
+ [{ start_line: 0, end_line: [list.length - 1, -1].max, lines: list }]
82
+ end
83
+
84
+ def image_group_length(lines, start_index)
85
+ meta = metadata_for(lines[start_index])
86
+ return nil unless meta
87
+ return nil unless meta[:image_render].is_a?(Hash) || meta['image_render'].is_a?(Hash)
88
+
89
+ render_line = meta.key?(:image_render_line) ? meta[:image_render_line] : meta['image_render_line']
90
+ return nil unless render_line == true
91
+
92
+ image = meta[:image] || meta['image'] || {}
93
+ src = image[:src] || image['src']
94
+ return nil if src.to_s.empty?
95
+
96
+ index = start_index
97
+ while index < lines.length
98
+ cur = metadata_for(lines[index])
99
+ break unless cur
100
+
101
+ block_type = cur[:block_type] || cur['block_type']
102
+ break unless block_type == :image || block_type.to_s == 'image'
103
+
104
+ cur_image = cur[:image] || cur['image'] || {}
105
+ cur_src = cur_image[:src] || cur_image['src']
106
+ break unless cur_src.to_s == src.to_s
107
+
108
+ index += 1
109
+ end
110
+
111
+ index - start_index
112
+ rescue StandardError
113
+ nil
114
+ end
115
+
116
+ def metadata_for(line)
117
+ return nil unless line.respond_to?(:metadata)
118
+
119
+ meta = line.metadata
120
+ meta.is_a?(Hash) ? meta : nil
121
+ rescue StandardError
122
+ nil
123
+ end
124
+
125
+ def wrapped_lines(doc, chapter, chapter_idx, width, lines_per_page, wrapper, formatter, config)
126
+ return [] if width <= 0 || chapter.nil?
127
+
128
+ if formatter
129
+ lines = formatter.wrap_all(doc, chapter_idx, width, config: config, lines_per_page: lines_per_page)
130
+ return lines if lines && !lines.empty?
131
+ end
132
+
133
+ if wrapper
134
+ lines = wrapper.wrap_lines(chapter.lines || [], chapter_idx, width)
135
+ return lines if lines && !lines.empty?
136
+ end
137
+
138
+ wrap_plain_lines(chapter.lines || [], width)
139
+ end
140
+
141
+ def wrap_plain_lines(lines, width)
142
+ return [] if lines.empty? || width <= 0
143
+
144
+ lines.each_with_object([]) do |line, acc|
145
+ next if line.nil?
146
+
147
+ if line.strip.empty?
148
+ acc << ''
149
+ else
150
+ segments = Shoko::Adapters::Output::Terminal::TextMetrics.wrap_plain_text(line, width)
151
+ acc.concat(segments)
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end