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,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_component'
4
+ require_relative '../../constants/ui_constants'
5
+ require_relative '../../../terminal/terminal_sanitizer.rb'
6
+ require_relative '../ui/text_utils'
7
+ require_relative '../ui/list_helpers'
8
+
9
+ module Shoko
10
+ module Adapters::Output::Ui::Components
11
+ module Screens
12
+ # Browse screen component that renders the book browsing interface
13
+ class BrowseScreenComponent < BaseComponent
14
+ include Adapters::Output::Ui::Constants::UI
15
+ include UI::TextUtils
16
+
17
+ BookItemCtx = Struct.new(:row, :book, :selected, :layout, keyword_init: true)
18
+
19
+ def initialize(catalog_service, state)
20
+ super()
21
+ @catalog = catalog_service
22
+ @state = state
23
+ @filtered_epubs = []
24
+
25
+ # Observe state changes for search and selection
26
+ @state.add_observer(self, %i[menu browse_selected], %i[menu search_query],
27
+ %i[menu search_active])
28
+ end
29
+
30
+ def state_changed(path, _old_value, _new_value)
31
+ case path
32
+ when %i[menu search_query]
33
+ filter_books
34
+ end
35
+ end
36
+
37
+ def filtered_epubs=(books)
38
+ @filtered_epubs = books || []
39
+ end
40
+
41
+ def selected
42
+ Shoko::Application::Selectors::MenuSelectors.browse_selected(@state)
43
+ end
44
+
45
+ def navigate(key)
46
+ return unless @filtered_epubs.any?
47
+
48
+ current = Shoko::Application::Selectors::MenuSelectors.browse_selected(@state)
49
+ max_index = @filtered_epubs.length - 1
50
+
51
+ new_selected = case key
52
+ when :up then [current - 1, 0].max
53
+ when :down then [current + 1, max_index].min
54
+ else current
55
+ end
56
+
57
+ @state.dispatch(Shoko::Application::Actions::UpdateMenuAction.new(browse_selected: new_selected))
58
+ end
59
+
60
+ def selected_book
61
+ browse_selected = Shoko::Application::Selectors::MenuSelectors.browse_selected(@state)
62
+ @filtered_epubs[browse_selected]
63
+ end
64
+
65
+ # Expose filtered list count for navigation logic integration
66
+ def filtered_count
67
+ (@filtered_epubs || []).length
68
+ end
69
+
70
+ # Expose random access by index (read-only)
71
+ def book_at(index)
72
+ (@filtered_epubs || [])[index]
73
+ end
74
+
75
+ def do_render(surface, bounds)
76
+ @filtered_epubs ||= []
77
+ layout = layout_metrics(bounds)
78
+
79
+ render_search(surface, bounds, layout)
80
+ render_status(surface, bounds, layout)
81
+
82
+ if @filtered_epubs.nil? || @filtered_epubs.empty?
83
+ render_empty_state(surface, bounds, layout)
84
+ else
85
+ render_books_list(surface, bounds, layout)
86
+ end
87
+ end
88
+
89
+ def preferred_height(_available_height)
90
+ :fill
91
+ end
92
+
93
+ private
94
+
95
+ def filter_books
96
+ query = Shoko::Application::Selectors::MenuSelectors.search_query(@state)
97
+ books = @catalog.entries || []
98
+ return @filtered_epubs = books if query.nil? || query.empty?
99
+
100
+ q = query.downcase
101
+ @filtered_epubs = books.select do |book|
102
+ name = book['name']&.downcase
103
+ author = book['author']&.downcase
104
+ name&.include?(q) || author&.include?(q)
105
+ end
106
+ end
107
+
108
+ def render_status(surface, bounds, layout)
109
+ total = @filtered_epubs&.length.to_i
110
+ status = @catalog.scan_status
111
+ message = Shoko::Adapters::Output::Terminal::TerminalSanitizer.sanitize(@catalog.scan_message.to_s,
112
+ preserve_newlines: false,
113
+ preserve_tabs: false)
114
+ status_row = layout[:status_row]
115
+ indent = layout[:indent]
116
+
117
+ count_text = "#{COLOR_TEXT_DIM}Found #{total} #{total == 1 ? 'book' : 'books'}#{Terminal::ANSI::RESET}"
118
+ surface.write(bounds, status_row, indent, count_text)
119
+
120
+ return unless status
121
+
122
+ status_text = case status
123
+ when :scanning then "#{COLOR_TEXT_WARNING}⟳ #{message}#{Terminal::ANSI::RESET}"
124
+ when :error then "#{COLOR_TEXT_ERROR}✗ #{message}#{Terminal::ANSI::RESET}"
125
+ when :done then "#{COLOR_TEXT_SUCCESS}✓ #{message}#{Terminal::ANSI::RESET}"
126
+ else ''
127
+ end
128
+ return if status_text.empty?
129
+
130
+ offset = Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(count_text)
131
+ surface.write(bounds, status_row, indent + offset + 2, status_text)
132
+ end
133
+
134
+ def render_empty_state(surface, bounds, layout)
135
+ status = @catalog.scan_status
136
+ empty_text = if status == :scanning
137
+ "#{COLOR_TEXT_WARNING}⟳ Scanning for books...#{Terminal::ANSI::RESET}"
138
+ else
139
+ "#{COLOR_TEXT_DIM}No matching books#{Terminal::ANSI::RESET}"
140
+ end
141
+ row = (bounds.height / 2).clamp(layout[:list_start_row], bounds.bottom - 2)
142
+ surface.write(bounds, row, layout[:indent], empty_text)
143
+ end
144
+
145
+ def render_books_list(surface, bounds, layout)
146
+ list_start_row = layout[:list_start_row]
147
+ list_height = bounds.height - list_start_row - 2
148
+ return if list_height <= 0
149
+
150
+ selected = Shoko::Application::Selectors::MenuSelectors.browse_selected(@state)
151
+ start_index, visible_books = UI::ListHelpers.slice_visible(@filtered_epubs, list_height, selected)
152
+
153
+ draw_list_header(surface, bounds, layout, layout[:header_row])
154
+ current_row = list_start_row
155
+
156
+ loading_path = @state.get(%i[menu loading_path])
157
+ loading_active = @state.get(%i[menu loading_active])
158
+ loading_progress = (@state.get(%i[menu loading_progress]) || 0.0).to_f
159
+ loading_message = @state.get(%i[menu loading_message])
160
+
161
+ visible_books.each_with_index do |book, index|
162
+ is_selected = (start_index + index) == selected
163
+ ctx = BookItemCtx.new(row: current_row, book: book, selected: is_selected, layout: layout)
164
+ render_book_item(surface, bounds, ctx)
165
+
166
+ progress_row = current_row + 1
167
+ if loading_active && loading_path == book['path'] && progress_row <= bounds.bottom
168
+ rows_used = draw_inline_progress(surface, bounds, layout, progress_row, loading_progress, loading_message)
169
+ current_row += 1 + rows_used
170
+ else
171
+ current_row += 1
172
+ end
173
+ end
174
+ end
175
+
176
+ def render_book_item(surface, bounds, ctx)
177
+ book = ctx.book
178
+ path = book['path']
179
+ meta = @catalog.metadata_for(path)
180
+
181
+ title = (meta[:title] || book['name'] || 'Unknown').to_s
182
+ size_mb = format_size(book['size'] || @catalog.size_for(path))
183
+
184
+ # Compute column widths
185
+ layout = ctx.layout
186
+ cols = layout[:columns]
187
+ gap = ' ' * layout[:gap]
188
+ title_width = cols[:title]
189
+ size_width = cols[:size]
190
+
191
+ title_col = pad_right(truncate_text(title, title_width), title_width)
192
+ size_col = pad_left(size_mb, size_width)
193
+
194
+ line = [title_col, size_col].join(gap)
195
+ row = ctx.row
196
+ indent = layout[:indent]
197
+
198
+ content = if ctx.selected
199
+ Terminal::ANSI::BOLD + COLOR_TEXT_ACCENT + line + Terminal::ANSI::RESET
200
+ else
201
+ COLOR_TEXT_PRIMARY + line + Terminal::ANSI::RESET
202
+ end
203
+ surface.write(bounds, row, indent, content)
204
+ end
205
+
206
+ def draw_list_header(surface, bounds, layout, row)
207
+ return if row < 5
208
+
209
+ indent = layout[:indent]
210
+ content_width = layout[:content_width]
211
+ cols = layout[:columns]
212
+ gap = ' ' * layout[:gap]
213
+ headers = [
214
+ pad_right('Title', cols[:title]),
215
+ pad_left('Size', cols[:size]),
216
+ ].join(gap)
217
+
218
+ header_style = Terminal::ANSI::BOLD + Terminal::ANSI::LIGHT_GREY
219
+ padded_headers = pad_right(headers, content_width)
220
+ surface.write(bounds, row, indent, header_style + padded_headers + Terminal::ANSI::RESET)
221
+ # Divider line
222
+ divider = ('─' * [content_width, 1].max)
223
+ surface.write(bounds, row + 1, indent, COLOR_TEXT_DIM + divider + Terminal::ANSI::RESET)
224
+ end
225
+
226
+ def format_size(bytes)
227
+ mb = (bytes.to_f / (1024 * 1024)).round(1)
228
+ format('%.1f MB', mb)
229
+ end
230
+
231
+ def draw_inline_progress(surface, bounds, layout, row, progress, message)
232
+ return 0 if row > bounds.bottom
233
+
234
+ rows_used = 0
235
+ indent = layout[:indent]
236
+ content_width = layout[:content_width]
237
+ message_text = message.to_s.strip
238
+
239
+ unless message_text.empty?
240
+ truncated = Shoko::Adapters::Output::Terminal::TextMetrics.truncate_to(message_text, content_width)
241
+ surface.write(bounds, row, indent, "#{COLOR_TEXT_DIM}#{truncated}#{Terminal::ANSI::RESET}")
242
+ rows_used += 1
243
+ row += 1
244
+ return rows_used if row > bounds.bottom
245
+ end
246
+
247
+ bar_col = layout[:indent]
248
+ usable = [layout[:content_width], 10].max
249
+ filled = (usable * progress.to_f.clamp(0.0, 1.0)).round
250
+ accent = Terminal::ANSI::BRIGHT_GREEN
251
+ dim = Terminal::ANSI::DIM
252
+ reset = Terminal::ANSI::RESET
253
+ track = accent + ('━' * filled) + reset
254
+ track << (dim + ('━' * (usable - filled)) + reset) if filled < usable
255
+ surface.write(bounds, row, bar_col, track)
256
+ rows_used + 1
257
+ end
258
+
259
+ def render_search(surface, bounds, layout)
260
+ row = layout[:search_row]
261
+ indent = layout[:indent]
262
+
263
+ surface.write(bounds, row, indent, "#{COLOR_TEXT_DIM}Search#{Terminal::ANSI::RESET}")
264
+
265
+ search_query = Shoko::Application::Selectors::MenuSelectors.search_query(@state)
266
+ search_display = search_query.dup
267
+ cursor_pos = Shoko::Application::Selectors::MenuSelectors.search_cursor(@state)
268
+ cursor_pos = cursor_pos.to_i.clamp(0, search_display.length)
269
+ search_display.insert(cursor_pos, '_')
270
+ field_text = pad_right(search_display, layout[:content_width])
271
+
272
+ surface.write(bounds, row + 1, indent,
273
+ "#{SELECTION_HIGHLIGHT}#{field_text}#{Terminal::ANSI::RESET}")
274
+ end
275
+
276
+ def layout_metrics(bounds)
277
+ height = bounds.height
278
+ width = bounds.width
279
+ row_base = height / 6
280
+
281
+ base_width = [width - 8, 72].min
282
+ column_spec = column_layout(base_width)
283
+ content_width = column_spec[:content_width]
284
+ indent = ((width - content_width) / 2).floor
285
+ indent = indent.clamp(2, width / 3)
286
+
287
+ {
288
+ indent: indent,
289
+ content_width: content_width,
290
+ columns: column_spec[:columns],
291
+ gap: column_spec[:gap],
292
+ search_row: [row_base, 2].max,
293
+ status_row: [row_base + 2, 4].max,
294
+ header_row: [row_base + 4, 6].max,
295
+ list_start_row: [row_base + 6, 8].max,
296
+ }
297
+ end
298
+
299
+ def column_layout(content_width)
300
+ gap = 4
301
+ size_w = 8
302
+ title_w = [content_width - size_w - gap, 24].max
303
+ content_width = title_w + size_w + gap
304
+
305
+ {
306
+ content_width: content_width,
307
+ columns: {
308
+ title: title_w,
309
+ size: size_w,
310
+ },
311
+ gap: gap,
312
+ }
313
+ end
314
+
315
+ # truncate_text provided by UI::TextUtils
316
+ end
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_component'
4
+ require_relative '../../constants/ui_constants'
5
+ require_relative '../../../terminal/text_metrics.rb'
6
+ require_relative '../../../terminal/terminal_sanitizer.rb'
7
+ require_relative '../ui/text_utils'
8
+ require_relative '../ui/list_helpers'
9
+
10
+ module Shoko
11
+ module Adapters::Output::Ui::Components
12
+ module Screens
13
+ # Centralized download screen for Gutendex search + download flow.
14
+ class DownloadBooksScreenComponent < BaseComponent
15
+ include Adapters::Output::Ui::Constants::UI
16
+ include UI::TextUtils
17
+
18
+ BookItemCtx = Struct.new(:row, :book, :selected, :layout, keyword_init: true)
19
+
20
+ def initialize(state)
21
+ super()
22
+ @state = state
23
+ end
24
+
25
+ def do_render(surface, bounds)
26
+ layout = layout_metrics(bounds)
27
+
28
+ render_header(surface, bounds, layout)
29
+ render_search(surface, bounds, layout)
30
+ render_status(surface, bounds, layout)
31
+ render_results(surface, bounds, layout)
32
+ render_footer(surface, bounds, layout)
33
+ end
34
+
35
+ def preferred_height(_available_height)
36
+ :fill
37
+ end
38
+
39
+ private
40
+
41
+ def results
42
+ Array(@state.get(%i[menu download_results]))
43
+ end
44
+
45
+ def selected_index
46
+ (@state.get(%i[menu download_selected]) || 0).to_i
47
+ end
48
+
49
+ def download_status
50
+ (@state.get(%i[menu download_status]) || :idle).to_sym
51
+ end
52
+
53
+ def download_message
54
+ @state.get(%i[menu download_message]).to_s
55
+ end
56
+
57
+ def download_count
58
+ (@state.get(%i[menu download_count]) || 0).to_i
59
+ end
60
+
61
+ def download_progress
62
+ (@state.get(%i[menu download_progress]) || 0.0).to_f
63
+ end
64
+
65
+ def search_query
66
+ @state.get(%i[menu download_query]) || ''
67
+ end
68
+
69
+ def search_cursor
70
+ cursor = @state.get(%i[menu download_cursor])
71
+ cursor ? cursor.to_i : search_query.length
72
+ end
73
+
74
+ def search_active?
75
+ @state.get(%i[menu mode]) == :download_search
76
+ end
77
+
78
+ def render_header(surface, bounds, layout)
79
+ title_plain = 'Download Books'
80
+ reset = Terminal::ANSI::RESET
81
+ surface.write(bounds, layout[:header_row], layout[:indent],
82
+ "#{COLOR_TEXT_ACCENT}#{title_plain}#{reset}")
83
+ end
84
+
85
+ def render_search(surface, bounds, layout)
86
+ row = layout[:search_row]
87
+ indent = layout[:indent]
88
+ reset = Terminal::ANSI::RESET
89
+
90
+ surface.write(bounds, row, indent, "#{COLOR_TEXT_DIM}Search Gutendex#{reset}")
91
+
92
+ query = search_query.dup
93
+ cursor = search_cursor.clamp(0, query.length)
94
+ query.insert(cursor, '_')
95
+ field_text = pad_right(query, layout[:content_width])
96
+
97
+ style = search_active? ? SELECTION_HIGHLIGHT : COLOR_TEXT_DIM
98
+ surface.write(bounds, row + 1, indent, "#{style}#{field_text}#{reset}")
99
+ end
100
+
101
+ def render_status(surface, bounds, layout)
102
+ row = layout[:status_row]
103
+ indent = layout[:indent]
104
+ reset = Terminal::ANSI::RESET
105
+
106
+ shown = results.length
107
+ total = download_count
108
+ count_text = if total.positive? && total != shown
109
+ "#{COLOR_TEXT_DIM}Showing #{shown} of #{total}#{reset}"
110
+ else
111
+ "#{COLOR_TEXT_DIM}Found #{shown} #{shown == 1 ? 'book' : 'books'}#{reset}"
112
+ end
113
+ surface.write(bounds, row, indent, count_text)
114
+
115
+ status_text, color = status_label
116
+ return if status_text.empty?
117
+
118
+ offset = Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(count_text)
119
+ surface.write(bounds, row, indent + offset + 2, "#{color}#{status_text}#{reset}")
120
+
121
+ render_progress(surface, bounds, layout) if download_progress.positive?
122
+ end
123
+
124
+ def render_progress(surface, bounds, layout)
125
+ row = layout[:progress_row]
126
+ return if row > bounds.bottom
127
+
128
+ indent = layout[:indent]
129
+ content_width = layout[:content_width]
130
+ usable = [content_width, 10].max
131
+ filled = (usable * download_progress.clamp(0.0, 1.0)).round
132
+
133
+ accent = Terminal::ANSI::BRIGHT_GREEN
134
+ dim = Terminal::ANSI::DIM
135
+ reset = Terminal::ANSI::RESET
136
+ track = accent + ('=' * filled) + reset
137
+ track << (dim + ('-' * (usable - filled)) + reset) if filled < usable
138
+ surface.write(bounds, row, indent, track)
139
+ end
140
+
141
+ def render_results(surface, bounds, layout)
142
+ items = results
143
+ if items.empty?
144
+ render_empty_state(surface, bounds, layout)
145
+ else
146
+ render_results_list(surface, bounds, layout, items)
147
+ end
148
+ end
149
+
150
+ def render_empty_state(surface, bounds, layout)
151
+ row = (bounds.height / 2).clamp(layout[:list_start_row], bounds.bottom - 2)
152
+ message = empty_state_message
153
+ surface.write(bounds, row, layout[:indent], message)
154
+ end
155
+
156
+ def empty_state_message
157
+ reset = Terminal::ANSI::RESET
158
+ case download_status
159
+ when :searching
160
+ "#{COLOR_TEXT_WARNING}Searching Gutendex...#{reset}"
161
+ when :error
162
+ "#{COLOR_TEXT_ERROR}#{safe_text(download_message)}#{reset}"
163
+ else
164
+ if search_query.strip.empty?
165
+ "#{COLOR_TEXT_DIM}Type to search and press Enter#{reset}"
166
+ else
167
+ "#{COLOR_TEXT_DIM}No results for your search#{reset}"
168
+ end
169
+ end
170
+ end
171
+
172
+ def render_results_list(surface, bounds, layout, items)
173
+ list_start_row = layout[:list_start_row]
174
+ list_height = bounds.height - list_start_row - 3
175
+ return if list_height <= 0
176
+
177
+ selected = selected_index
178
+ start_index, visible = UI::ListHelpers.slice_visible(items, list_height, selected)
179
+
180
+ draw_list_header(surface, bounds, layout, layout[:header_row_list])
181
+
182
+ current_row = list_start_row
183
+ visible.each_with_index do |book, index|
184
+ is_selected = (start_index + index) == selected
185
+ ctx = BookItemCtx.new(row: current_row, book: book, selected: is_selected, layout: layout)
186
+ render_book_item(surface, bounds, ctx)
187
+ current_row += 1
188
+ break if current_row > bounds.bottom
189
+ end
190
+ end
191
+
192
+ def render_book_item(surface, bounds, ctx)
193
+ book = ctx.book
194
+ layout = ctx.layout
195
+ cols = layout[:columns]
196
+ gap = ' ' * layout[:gap]
197
+
198
+ title = safe_text(value_for(book, :title, 'title', 'Untitled'))
199
+ authors = Array(value_for(book, :authors, 'authors', [])).join(', ')
200
+ authors = safe_text(authors)
201
+ languages = Array(value_for(book, :languages, 'languages', [])).map(&:to_s).join(',')
202
+ languages = safe_text(languages)
203
+ downloads = value_for(book, :download_count, 'download_count', 0).to_i
204
+
205
+ title_col = pad_right(truncate_text(title, cols[:title]), cols[:title])
206
+ author_col = pad_right(truncate_text(authors, cols[:author]), cols[:author])
207
+ lang_col = pad_right(truncate_text(languages, cols[:lang]), cols[:lang])
208
+ dl_col = pad_left(downloads.to_s, cols[:downloads])
209
+
210
+ line = [title_col, author_col, lang_col, dl_col].join(gap)
211
+
212
+ content = if ctx.selected
213
+ Terminal::ANSI::BOLD + COLOR_TEXT_ACCENT + line + Terminal::ANSI::RESET
214
+ else
215
+ COLOR_TEXT_PRIMARY + line + Terminal::ANSI::RESET
216
+ end
217
+ surface.write(bounds, ctx.row, layout[:indent], content)
218
+ end
219
+
220
+ def draw_list_header(surface, bounds, layout, row)
221
+ return if row < 5
222
+
223
+ indent = layout[:indent]
224
+ content_width = layout[:content_width]
225
+ cols = layout[:columns]
226
+ gap = ' ' * layout[:gap]
227
+
228
+ headers = [
229
+ pad_right('Title', cols[:title]),
230
+ pad_right('Author', cols[:author]),
231
+ pad_right('Lang', cols[:lang]),
232
+ pad_left('DLs', cols[:downloads]),
233
+ ].join(gap)
234
+
235
+ header_style = Terminal::ANSI::BOLD + Terminal::ANSI::LIGHT_GREY
236
+ padded = pad_right(headers, content_width)
237
+ surface.write(bounds, row, indent, header_style + padded + Terminal::ANSI::RESET)
238
+ divider = ('-' * [content_width, 1].max)
239
+ surface.write(bounds, row + 1, indent, COLOR_TEXT_DIM + divider + Terminal::ANSI::RESET)
240
+ end
241
+
242
+ def render_footer(surface, bounds, layout)
243
+ row = layout[:footer_row]
244
+ return if row > bounds.bottom
245
+
246
+ reset = Terminal::ANSI::RESET
247
+ hint = if search_active?
248
+ '[Enter] Search [/ or ESC] Back'
249
+ else
250
+ '[Enter] Download [/] Search [N/P] Page [ESC] Back'
251
+ end
252
+ clipped = Shoko::Adapters::Output::Terminal::TextMetrics.truncate_to(hint, layout[:content_width])
253
+ surface.write(bounds, row, layout[:indent], "#{COLOR_TEXT_DIM}#{clipped}#{reset}")
254
+ end
255
+
256
+ def status_label
257
+ msg = safe_text(download_message)
258
+ case download_status
259
+ when :searching
260
+ [msg.empty? ? 'Searching...' : msg, COLOR_TEXT_WARNING]
261
+ when :downloading
262
+ [msg.empty? ? 'Downloading...' : msg, COLOR_TEXT_WARNING]
263
+ when :error
264
+ [msg.empty? ? 'Request failed' : msg, COLOR_TEXT_ERROR]
265
+ when :done
266
+ [msg, COLOR_TEXT_SUCCESS]
267
+ else
268
+ ['', COLOR_TEXT_DIM]
269
+ end
270
+ end
271
+
272
+ def value_for(book, key_sym, key_str, default)
273
+ return book[key_sym] if book.respond_to?(:key?) && book.key?(key_sym)
274
+ return book[key_str] if book.respond_to?(:key?) && book.key?(key_str)
275
+
276
+ default
277
+ end
278
+
279
+ def safe_text(text)
280
+ Shoko::Adapters::Output::Terminal::TerminalSanitizer.sanitize(text.to_s, preserve_newlines: false,
281
+ preserve_tabs: false)
282
+ end
283
+
284
+ def layout_metrics(bounds)
285
+ height = bounds.height
286
+ width = bounds.width
287
+ row_base = height / 6
288
+
289
+ base_width = [width - 8, 86].min
290
+ column_spec = column_layout(base_width)
291
+ content_width = column_spec[:content_width]
292
+ indent = ((width - content_width) / 2).floor
293
+ indent = indent.clamp(2, width / 3)
294
+
295
+ header_row = [row_base - 2, 1].max
296
+ search_row = [row_base, header_row + 2].max
297
+ status_row = search_row + 2
298
+ progress_row = status_row + 1
299
+ header_row_list = status_row + 2
300
+ list_start_row = header_row_list + 2
301
+ footer_row = [height - 2, list_start_row + 2].max
302
+
303
+ {
304
+ indent: indent,
305
+ content_width: content_width,
306
+ columns: column_spec[:columns],
307
+ gap: column_spec[:gap],
308
+ header_row: header_row,
309
+ search_row: search_row,
310
+ status_row: status_row,
311
+ progress_row: progress_row,
312
+ header_row_list: header_row_list,
313
+ list_start_row: list_start_row,
314
+ footer_row: footer_row,
315
+ }
316
+ end
317
+
318
+ def column_layout(content_width)
319
+ gap = 3
320
+ downloads_w = 6
321
+ lang_w = 6
322
+ author_w = 18
323
+ title_w = [content_width - (downloads_w + lang_w + author_w + (gap * 3)), 16].max
324
+ content_width = title_w + author_w + lang_w + downloads_w + (gap * 3)
325
+
326
+ {
327
+ content_width: content_width,
328
+ columns: {
329
+ title: title_w,
330
+ author: author_w,
331
+ lang: lang_w,
332
+ downloads: downloads_w,
333
+ },
334
+ gap: gap,
335
+ }
336
+ end
337
+ end
338
+ end
339
+ end
340
+ end