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.
- checksums.yaml +7 -0
- data/.bundle/config +4 -0
- data/.bundle/config.bak +3 -0
- data/.rspec_status +42 -0
- data/.rubocop.yml +124 -0
- data/Gemfile +19 -0
- data/LICENSE +21 -0
- data/README.md +82 -0
- data/Rakefile +29 -0
- data/bin/start +15 -0
- data/lib/shoko/adapters/book_sources/document_service.rb +201 -0
- data/lib/shoko/adapters/book_sources/download_service.rb +95 -0
- data/lib/shoko/adapters/book_sources/epub/epub_resource_loader.rb +137 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/html_processor.rb +151 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/metadata_extractor.rb +53 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/entry_reader.rb +77 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/metadata_extractor.rb +67 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_context.rb +86 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_index.rb +75 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_document_scanner.rb +47 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_extractor.rb +46 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_label_resolver.rb +83 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_list_item.rb +55 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_result.rb +8 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_selector.rb +100 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_source_locator.rb +93 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_traversal.rb +103 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf/navigation_walker.rb +56 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/opf_processor.rb +102 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/xhtml_content_parser.rb +661 -0
- data/lib/shoko/adapters/book_sources/epub/parsers/xml_text_normalizer.rb +41 -0
- data/lib/shoko/adapters/book_sources/epub_document.rb +253 -0
- data/lib/shoko/adapters/book_sources/epub_finder/directory_scanner.rb +134 -0
- data/lib/shoko/adapters/book_sources/epub_finder/scanner_context.rb +28 -0
- data/lib/shoko/adapters/book_sources/epub_finder.rb +161 -0
- data/lib/shoko/adapters/book_sources/epub_importer.rb +268 -0
- data/lib/shoko/adapters/book_sources/gutendex_client.rb +150 -0
- data/lib/shoko/adapters/book_sources/library_scanner.rb +93 -0
- data/lib/shoko/adapters/book_sources/source_fingerprint.rb +57 -0
- data/lib/shoko/adapters/input/annotations/mouse_handler.rb +84 -0
- data/lib/shoko/adapters/input/command_bridge.rb +148 -0
- data/lib/shoko/adapters/input/command_factory.rb +255 -0
- data/lib/shoko/adapters/input/commands.rb +60 -0
- data/lib/shoko/adapters/input/dispatcher.rb +69 -0
- data/lib/shoko/adapters/input/input_controller.rb +250 -0
- data/lib/shoko/adapters/input/key_definitions.rb +108 -0
- data/lib/shoko/adapters/input/validators/file_path_validator.rb +81 -0
- data/lib/shoko/adapters/input/validators/terminal_size_validator.rb +76 -0
- data/lib/shoko/adapters/monitoring/logger.rb +150 -0
- data/lib/shoko/adapters/monitoring/perf_tracer.rb +183 -0
- data/lib/shoko/adapters/monitoring/performance_monitor.rb +110 -0
- data/lib/shoko/adapters/output/clipboard/clipboard_service.rb +125 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/image_builder.rb +149 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/text_wrapper.rb +149 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler/tokenizer.rb +91 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/line_assembler.rb +144 -0
- data/lib/shoko/adapters/output/formatting/formatting_service/plain_lines_builder.rb +54 -0
- data/lib/shoko/adapters/output/formatting/formatting_service.rb +247 -0
- data/lib/shoko/adapters/output/formatting/wrapping_service.rb +228 -0
- data/lib/shoko/adapters/output/instrumentation_service.rb +52 -0
- data/lib/shoko/adapters/output/kitty/image_transcoder.rb +71 -0
- data/lib/shoko/adapters/output/kitty/kitty_graphics.rb +114 -0
- data/lib/shoko/adapters/output/kitty/kitty_image_renderer.rb +239 -0
- data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders.rb +139 -0
- data/lib/shoko/adapters/output/kitty/kitty_unicode_placeholders_diacritic_codepoints.txt +26 -0
- data/lib/shoko/adapters/output/notification_service.rb +58 -0
- data/lib/shoko/adapters/output/render_registry.rb +45 -0
- data/lib/shoko/adapters/output/rendering/models/line_geometry.rb +60 -0
- data/lib/shoko/adapters/output/rendering/models/page_rendering_context.rb +22 -0
- data/lib/shoko/adapters/output/rendering/models/render_params.rb +28 -0
- data/lib/shoko/adapters/output/rendering/models/rendering_context.rb +58 -0
- data/lib/shoko/adapters/output/terminal/buffer.rb +275 -0
- data/lib/shoko/adapters/output/terminal/constants/terminal_defaults.rb +11 -0
- data/lib/shoko/adapters/output/terminal/input/decoder.rb +347 -0
- data/lib/shoko/adapters/output/terminal/input.rb +161 -0
- data/lib/shoko/adapters/output/terminal/output.rb +105 -0
- data/lib/shoko/adapters/output/terminal/terminal.rb +167 -0
- data/lib/shoko/adapters/output/terminal/terminal_sanitizer.rb +243 -0
- data/lib/shoko/adapters/output/terminal/terminal_service.rb +138 -0
- data/lib/shoko/adapters/output/terminal/text_metrics.rb +273 -0
- data/lib/shoko/adapters/output/ui/builders/page_setup_builder.rb +47 -0
- data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/footer_renderer.rb +80 -0
- data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/geometry.rb +61 -0
- data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay/note_renderer.rb +86 -0
- data/lib/shoko/adapters/output/ui/components/annotation_editor_overlay_component.rb +234 -0
- data/lib/shoko/adapters/output/ui/components/annotations_overlay/list_renderer.rb +142 -0
- data/lib/shoko/adapters/output/ui/components/annotations_overlay_component.rb +185 -0
- data/lib/shoko/adapters/output/ui/components/base_component.rb +110 -0
- data/lib/shoko/adapters/output/ui/components/component_interface.rb +80 -0
- data/lib/shoko/adapters/output/ui/components/content_component.rb +61 -0
- data/lib/shoko/adapters/output/ui/components/enhanced_popup_menu.rb +191 -0
- data/lib/shoko/adapters/output/ui/components/footer_component.rb +120 -0
- data/lib/shoko/adapters/output/ui/components/header_component.rb +46 -0
- data/lib/shoko/adapters/output/ui/components/layouts/horizontal.rb +63 -0
- data/lib/shoko/adapters/output/ui/components/layouts/vertical.rb +73 -0
- data/lib/shoko/adapters/output/ui/components/main_menu_component.rb +103 -0
- data/lib/shoko/adapters/output/ui/components/reading/base_view_renderer.rb +199 -0
- data/lib/shoko/adapters/output/ui/components/reading/config_helpers.rb +42 -0
- data/lib/shoko/adapters/output/ui/components/reading/help_renderer.rb +62 -0
- data/lib/shoko/adapters/output/ui/components/reading/inline_segment_highlighter.rb +144 -0
- data/lib/shoko/adapters/output/ui/components/reading/kitty_image_line_renderer.rb +262 -0
- data/lib/shoko/adapters/output/ui/components/reading/line_content_composer.rb +114 -0
- data/lib/shoko/adapters/output/ui/components/reading/line_drawer.rb +87 -0
- data/lib/shoko/adapters/output/ui/components/reading/line_geometry_builder.rb +41 -0
- data/lib/shoko/adapters/output/ui/components/reading/rendered_lines_recorder.rb +64 -0
- data/lib/shoko/adapters/output/ui/components/reading/single_view_renderer.rb +156 -0
- data/lib/shoko/adapters/output/ui/components/reading/split_view_renderer.rb +221 -0
- data/lib/shoko/adapters/output/ui/components/reading/view_renderer_factory.rb +20 -0
- data/lib/shoko/adapters/output/ui/components/reading/wrapped_lines_fetcher.rb +139 -0
- data/lib/shoko/adapters/output/ui/components/rect.rb +15 -0
- data/lib/shoko/adapters/output/ui/components/render_style.rb +84 -0
- data/lib/shoko/adapters/output/ui/components/screen_component.rb +24 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotation_detail_screen_component.rb +175 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotation_edit_screen_component.rb +221 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotation_editor_screen_component.rb +205 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotation_rendering_helpers.rb +190 -0
- data/lib/shoko/adapters/output/ui/components/screens/annotations_screen_component.rb +266 -0
- data/lib/shoko/adapters/output/ui/components/screens/base_screen_component.rb +49 -0
- data/lib/shoko/adapters/output/ui/components/screens/browse_screen_component.rb +319 -0
- data/lib/shoko/adapters/output/ui/components/screens/download_books_screen_component.rb +340 -0
- data/lib/shoko/adapters/output/ui/components/screens/library_screen_component.rb +205 -0
- data/lib/shoko/adapters/output/ui/components/screens/loading_overlay_component.rb +49 -0
- data/lib/shoko/adapters/output/ui/components/screens/menu_screen_component.rb +107 -0
- data/lib/shoko/adapters/output/ui/components/screens/settings_screen_component.rb +238 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/annotations_tab_renderer.rb +159 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/bookmarks_tab_renderer.rb +139 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/tab_header_component.rb +157 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_renderer.rb +111 -0
- data/lib/shoko/adapters/output/ui/components/sidebar/toc_tab_support.rb +1606 -0
- data/lib/shoko/adapters/output/ui/components/sidebar_panel_component.rb +217 -0
- data/lib/shoko/adapters/output/ui/components/surface.rb +88 -0
- data/lib/shoko/adapters/output/ui/components/tooltip_overlay_component.rb +224 -0
- data/lib/shoko/adapters/output/ui/components/ui/box_drawer.rb +32 -0
- data/lib/shoko/adapters/output/ui/components/ui/list_helpers.rb +33 -0
- data/lib/shoko/adapters/output/ui/components/ui/overlay_layout.rb +79 -0
- data/lib/shoko/adapters/output/ui/components/ui/text_utils.rb +46 -0
- data/lib/shoko/adapters/output/ui/constants/highlighting.rb +21 -0
- data/lib/shoko/adapters/output/ui/constants/messages.rb +12 -0
- data/lib/shoko/adapters/output/ui/constants/themes.rb +79 -0
- data/lib/shoko/adapters/output/ui/constants/ui_constants.rb +85 -0
- data/lib/shoko/adapters/output/ui/rendering/frame_coordinator.rb +42 -0
- data/lib/shoko/adapters/output/ui/rendering/reader_render_coordinator.rb +169 -0
- data/lib/shoko/adapters/output/ui/rendering/render_pipeline.rb +55 -0
- data/lib/shoko/adapters/storage/atomic_file_writer.rb +43 -0
- data/lib/shoko/adapters/storage/background_worker.rb +66 -0
- data/lib/shoko/adapters/storage/book_cache_pipeline.rb +653 -0
- data/lib/shoko/adapters/storage/cache/epub/memory_cache.rb +99 -0
- data/lib/shoko/adapters/storage/cache/epub/persistence.rb +131 -0
- data/lib/shoko/adapters/storage/cache/epub/serializer/deserialize.rb +225 -0
- data/lib/shoko/adapters/storage/cache/epub/serializer/helpers.rb +63 -0
- data/lib/shoko/adapters/storage/cache/epub/serializer/serialize.rb +83 -0
- data/lib/shoko/adapters/storage/cache/epub/serializer.rb +5 -0
- data/lib/shoko/adapters/storage/cache/epub/source_reference.rb +58 -0
- data/lib/shoko/adapters/storage/cache_paths.rb +21 -0
- data/lib/shoko/adapters/storage/cache_pointer_manager.rb +60 -0
- data/lib/shoko/adapters/storage/config_paths.rb +30 -0
- data/lib/shoko/adapters/storage/epub_cache.rb +195 -0
- data/lib/shoko/adapters/storage/file_writer_service.rb +47 -0
- data/lib/shoko/adapters/storage/json_cache_store/chapters.rb +141 -0
- data/lib/shoko/adapters/storage/json_cache_store/layouts.rb +67 -0
- data/lib/shoko/adapters/storage/json_cache_store/manifest.rb +42 -0
- data/lib/shoko/adapters/storage/json_cache_store/payload_helpers.rb +113 -0
- data/lib/shoko/adapters/storage/json_cache_store/resources.rb +84 -0
- data/lib/shoko/adapters/storage/json_cache_store.rb +167 -0
- data/lib/shoko/adapters/storage/lazy_file_string.rb +65 -0
- data/lib/shoko/adapters/storage/pagination_cache.rb +127 -0
- data/lib/shoko/adapters/storage/recent_files.rb +78 -0
- data/lib/shoko/adapters/storage/repositories/annotation_repository.rb +182 -0
- data/lib/shoko/adapters/storage/repositories/base_repository.rb +81 -0
- data/lib/shoko/adapters/storage/repositories/bookmark_repository.rb +132 -0
- data/lib/shoko/adapters/storage/repositories/cached_library_repository.rb +129 -0
- data/lib/shoko/adapters/storage/repositories/config_repository.rb +262 -0
- data/lib/shoko/adapters/storage/repositories/progress_repository.rb +166 -0
- data/lib/shoko/adapters/storage/repositories/storage/annotation_file_store.rb +128 -0
- data/lib/shoko/adapters/storage/repositories/storage/bookmark_file_store.rb +109 -0
- data/lib/shoko/adapters/storage/repositories/storage/file_store_utils.rb +20 -0
- data/lib/shoko/adapters/storage/repositories/storage/progress_file_store.rb +59 -0
- data/lib/shoko/application/annotation_editor_overlay_session.rb +138 -0
- data/lib/shoko/application/cli.rb +134 -0
- data/lib/shoko/application/controllers/menu/input_controller.rb +189 -0
- data/lib/shoko/application/controllers/menu/state_controller.rb +642 -0
- data/lib/shoko/application/controllers/menu_controller.rb +469 -0
- data/lib/shoko/application/controllers/mouseable_reader.rb +377 -0
- data/lib/shoko/application/controllers/reader_controller.rb +449 -0
- data/lib/shoko/application/controllers/state_controller.rb +410 -0
- data/lib/shoko/application/controllers/ui_controller.rb +782 -0
- data/lib/shoko/application/dependency_container.rb +301 -0
- data/lib/shoko/application/infrastructure/event_bus.rb +80 -0
- data/lib/shoko/application/infrastructure/observer_state_store.rb +136 -0
- data/lib/shoko/application/infrastructure/state_store.rb +413 -0
- data/lib/shoko/application/main_menu/menu_progress_presenter.rb +83 -0
- data/lib/shoko/application/pending_jump_handler.rb +122 -0
- data/lib/shoko/application/reader_lifecycle.rb +65 -0
- data/lib/shoko/application/reader_startup_orchestrator.rb +113 -0
- data/lib/shoko/application/selectors/config_selectors.rb +62 -0
- data/lib/shoko/application/selectors/menu_selectors.rb +62 -0
- data/lib/shoko/application/selectors/reader_selectors.rb +186 -0
- data/lib/shoko/application/state/actions/base_action.rb +24 -0
- data/lib/shoko/application/state/actions/quit_to_menu_action.rb +16 -0
- data/lib/shoko/application/state/actions/switch_reader_mode_action.rb +22 -0
- data/lib/shoko/application/state/actions/toggle_view_mode_action.rb +31 -0
- data/lib/shoko/application/state/actions/update_annotation_editor_overlay_action.rb +27 -0
- data/lib/shoko/application/state/actions/update_annotations_action.rb +20 -0
- data/lib/shoko/application/state/actions/update_annotations_overlay_action.rb +27 -0
- data/lib/shoko/application/state/actions/update_bookmarks_action.rb +20 -0
- data/lib/shoko/application/state/actions/update_chapter_action.rb +24 -0
- data/lib/shoko/application/state/actions/update_config_action.rb +22 -0
- data/lib/shoko/application/state/actions/update_field_helpers.rb +26 -0
- data/lib/shoko/application/state/actions/update_menu_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_message_action.rb +35 -0
- data/lib/shoko/application/state/actions/update_page_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_pagination_state_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_popup_menu_action.rb +27 -0
- data/lib/shoko/application/state/actions/update_reader_meta_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_reader_mode_action.rb +20 -0
- data/lib/shoko/application/state/actions/update_rendered_lines_action.rb +40 -0
- data/lib/shoko/application/state/actions/update_selection_action.rb +27 -0
- data/lib/shoko/application/state/actions/update_selections_action.rb +21 -0
- data/lib/shoko/application/state/actions/update_sidebar_action.rb +34 -0
- data/lib/shoko/application/state/actions/update_ui_loading_action.rb +23 -0
- data/lib/shoko/application/ui/reader_view_model_builder.rb +74 -0
- data/lib/shoko/application/ui/view_models/reader_view_model.rb +177 -0
- data/lib/shoko/application/unified_application.rb +48 -0
- data/lib/shoko/application/use_cases/catalog_service.rb +117 -0
- data/lib/shoko/application/use_cases/commands/annotation_editor_commands.rb +105 -0
- data/lib/shoko/application/use_cases/commands/application_commands.rb +208 -0
- data/lib/shoko/application/use_cases/commands/base_command.rb +166 -0
- data/lib/shoko/application/use_cases/commands/bookmark_commands.rb +114 -0
- data/lib/shoko/application/use_cases/commands/conditional_navigation_commands.rb +57 -0
- data/lib/shoko/application/use_cases/commands/menu_commands.rb +170 -0
- data/lib/shoko/application/use_cases/commands/navigation_commands.rb +183 -0
- data/lib/shoko/application/use_cases/commands/reader_commands.rb +46 -0
- data/lib/shoko/application/use_cases/commands/sidebar_commands.rb +55 -0
- data/lib/shoko/application/use_cases/settings_service.rb +123 -0
- data/lib/shoko/core/events/annotation_events.rb +94 -0
- data/lib/shoko/core/events/base_domain_event.rb +169 -0
- data/lib/shoko/core/events/bookmark_events.rb +41 -0
- data/lib/shoko/core/events/domain_event_bus.rb +163 -0
- data/lib/shoko/core/events/progress_events.rb +108 -0
- data/lib/shoko/core/models/bookmark.rb +36 -0
- data/lib/shoko/core/models/bookmark_data.rb +10 -0
- data/lib/shoko/core/models/chapter.rb +25 -0
- data/lib/shoko/core/models/content_block.rb +44 -0
- data/lib/shoko/core/models/reader_settings.rb +20 -0
- data/lib/shoko/core/models/selection_anchor.rb +73 -0
- data/lib/shoko/core/models/toc_entry.rb +14 -0
- data/lib/shoko/core/ports/annotation_repository.rb +0 -0
- data/lib/shoko/core/ports/book_repository.rb +0 -0
- data/lib/shoko/core/ports/book_source.rb +0 -0
- data/lib/shoko/core/ports/bookmark_repository.rb +0 -0
- data/lib/shoko/core/ports/cache.rb +0 -0
- data/lib/shoko/core/ports/input_handler.rb +0 -0
- data/lib/shoko/core/ports/renderer.rb +0 -0
- data/lib/shoko/core/ports/storage.rb +0 -0
- data/lib/shoko/core/services/annotation_service.rb +102 -0
- data/lib/shoko/core/services/base_service.rb +60 -0
- data/lib/shoko/core/services/bookmark_service.rb +267 -0
- data/lib/shoko/core/services/coordinate_service.rb +265 -0
- data/lib/shoko/core/services/layout_service.rb +95 -0
- data/lib/shoko/core/services/navigation/absolute_change_applier.rb +96 -0
- data/lib/shoko/core/services/navigation/absolute_layout.rb +101 -0
- data/lib/shoko/core/services/navigation/absolute_strategy.rb +179 -0
- data/lib/shoko/core/services/navigation/context_builder.rb +52 -0
- data/lib/shoko/core/services/navigation/context_helpers.rb +63 -0
- data/lib/shoko/core/services/navigation/dynamic_change_applier.rb +50 -0
- data/lib/shoko/core/services/navigation/dynamic_strategy.rb +51 -0
- data/lib/shoko/core/services/navigation/image_offset_snapper.rb +150 -0
- data/lib/shoko/core/services/navigation/nav_context.rb +27 -0
- data/lib/shoko/core/services/navigation/state_updater.rb +29 -0
- data/lib/shoko/core/services/navigation/strategy_factory.rb +20 -0
- data/lib/shoko/core/services/navigation_service.rb +150 -0
- data/lib/shoko/core/services/page_calculator_service.rb +242 -0
- data/lib/shoko/core/services/pagination/internal/absolute_page_map_builder.rb +28 -0
- data/lib/shoko/core/services/pagination/internal/chapter_cache.rb +60 -0
- data/lib/shoko/core/services/pagination/internal/dynamic_page_map_builder.rb +157 -0
- data/lib/shoko/core/services/pagination/internal/layout_metrics_calculator.rb +73 -0
- data/lib/shoko/core/services/pagination/internal/page_hydrator.rb +145 -0
- data/lib/shoko/core/services/pagination/internal/pagination_workflow.rb +152 -0
- data/lib/shoko/core/services/pagination/page_info_calculator.rb +247 -0
- data/lib/shoko/core/services/pagination/pagination_cache_preloader.rb +173 -0
- data/lib/shoko/core/services/pagination/pagination_coordinator.rb +202 -0
- data/lib/shoko/core/services/pagination/pagination_orchestrator.rb +291 -0
- data/lib/shoko/core/services/pagination.rb +10 -0
- data/lib/shoko/core/services/progress_helper.rb +22 -0
- data/lib/shoko/core/services/selection_service.rb +126 -0
- data/lib/shoko/core/validator.rb +76 -0
- data/lib/shoko/shared/errors.rb +97 -0
- data/lib/shoko/shared/version.rb +5 -0
- data/lib/shoko/test_support/terminal_double.rb +175 -0
- data/lib/shoko/test_support/test_mode.rb +78 -0
- data/lib/shoko.rb +279 -0
- data/lib/zip.rb +732 -0
- data/zip.rb +5 -0
- metadata +370 -0
|
@@ -0,0 +1,1606 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
require_relative '../../../terminal/text_metrics.rb'
|
|
6
|
+
require_relative '../../../../../core/models/toc_entry.rb'
|
|
7
|
+
|
|
8
|
+
module Shoko
|
|
9
|
+
module Adapters::Output::Ui::Components
|
|
10
|
+
module Sidebar
|
|
11
|
+
SCROLLBAR_WIDTH = 1
|
|
12
|
+
RIGHT_MARGIN = 2
|
|
13
|
+
|
|
14
|
+
# Orchestrates rendering of all components
|
|
15
|
+
class ComponentOrchestrator
|
|
16
|
+
def initialize(context)
|
|
17
|
+
@context = context
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def render
|
|
21
|
+
return EmptyStateRenderer.new(@context).render if @context.entries.empty?
|
|
22
|
+
|
|
23
|
+
HeaderRenderer.new(@context).render
|
|
24
|
+
FilterInputRenderer.new(@context).render if @context.filter_active?
|
|
25
|
+
EntriesListRenderer.new(@context).render
|
|
26
|
+
ScrollbarRenderer.new(@context).render
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
EntriesCache = Struct.new(:full, :visible, :visible_indices, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
# Encapsulates all rendering context and state
|
|
33
|
+
class RenderContext
|
|
34
|
+
include Adapters::Output::Ui::Constants::UI
|
|
35
|
+
|
|
36
|
+
attr_reader :surface, :bounds, :state, :document, :wrap_cache
|
|
37
|
+
|
|
38
|
+
def initialize(surface, bounds, state, document, wrap_cache: nil, entries_cache: nil)
|
|
39
|
+
@surface = surface
|
|
40
|
+
@bounds = bounds
|
|
41
|
+
@state = state
|
|
42
|
+
@document = document
|
|
43
|
+
@wrap_cache = wrap_cache || {}
|
|
44
|
+
@entries_cache = entries_cache
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def entries
|
|
48
|
+
return @entries if @entries
|
|
49
|
+
return cached_entries if @entries_cache
|
|
50
|
+
|
|
51
|
+
@entries = EntriesCalculator.new(self).calculate
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def selected_index
|
|
55
|
+
@selected_index ||= SelectedIndexCalculator.new(entries).calculate
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def filter_active?
|
|
59
|
+
state.get(%i[reader sidebar_toc_filter_active])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def filter_text
|
|
63
|
+
state.get(%i[reader sidebar_toc_filter]) || ''
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def collapsed_indices
|
|
67
|
+
raw = state.get(%i[reader sidebar_toc_collapsed])
|
|
68
|
+
Array(raw).map(&:to_i)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def collapsed_set
|
|
72
|
+
@collapsed_set ||= Set.new(collapsed_indices)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def collapse_enabled?
|
|
76
|
+
!filter_active?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def metrics
|
|
80
|
+
@metrics ||= calculate_metrics
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def write(row, col, text)
|
|
84
|
+
surface.write(bounds, row, col, text)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def scroll_metrics
|
|
88
|
+
@scroll_metrics ||= EntriesScrollMetrics.new(self)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def entries_layout
|
|
92
|
+
@entries_layout ||= EntriesListLayout.new(self)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def cached_entries
|
|
98
|
+
@entries = EntriesCollection.new(
|
|
99
|
+
full: @entries_cache.full,
|
|
100
|
+
visible: @entries_cache.visible,
|
|
101
|
+
visible_indices: @entries_cache.visible_indices,
|
|
102
|
+
selected_full_index: selected_full_index_for(@entries_cache.full)
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def selected_full_index_for(entries)
|
|
107
|
+
raw_index = state.get(%i[reader sidebar_toc_selected]) || 0
|
|
108
|
+
max_index = [entries.length - 1, 0].max
|
|
109
|
+
raw_index.to_i.clamp(0, max_index)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def calculate_metrics
|
|
113
|
+
Metrics.new(
|
|
114
|
+
x: 1,
|
|
115
|
+
y: 1,
|
|
116
|
+
width: bounds.width,
|
|
117
|
+
height: bounds.height
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Calculates the selected index in visible list
|
|
123
|
+
class SelectedIndexCalculator
|
|
124
|
+
def initialize(entries)
|
|
125
|
+
@entries = entries
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def calculate
|
|
129
|
+
selected_entry = full_entries[selected_full_index]
|
|
130
|
+
find_visible_index(selected_entry)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def full_entries
|
|
136
|
+
@entries.full
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def visible_entries
|
|
140
|
+
@entries.visible
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def selected_full_index
|
|
144
|
+
@entries.selected_full_index
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def find_visible_index(selected_entry)
|
|
148
|
+
return 0 unless selected_entry
|
|
149
|
+
|
|
150
|
+
visible_entries.index(selected_entry) || 0
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Calculates entries collection with filtering
|
|
155
|
+
class EntriesCalculator
|
|
156
|
+
def initialize(context)
|
|
157
|
+
@context = context
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def calculate
|
|
161
|
+
full_entries = DocumentEntriesExtractor.new(@context.document).extract
|
|
162
|
+
filtered = apply_filter(full_entries)
|
|
163
|
+
index_map = build_index_map(full_entries)
|
|
164
|
+
visible = apply_collapse(filtered, full_entries, index_map)
|
|
165
|
+
visible_indices = visible.map { |entry| index_map[entry.object_id] }.compact
|
|
166
|
+
|
|
167
|
+
EntriesCollection.new(
|
|
168
|
+
full: full_entries,
|
|
169
|
+
visible: visible,
|
|
170
|
+
visible_indices: visible_indices,
|
|
171
|
+
selected_full_index: calculate_selected_index(full_entries)
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def apply_filter(entries)
|
|
178
|
+
return entries unless @context.filter_active?
|
|
179
|
+
|
|
180
|
+
EntryFilter.new(entries, @context.filter_text).filter
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def apply_collapse(entries, full_entries, index_map)
|
|
184
|
+
return entries unless @context.collapse_enabled?
|
|
185
|
+
|
|
186
|
+
collapsed = @context.collapsed_set
|
|
187
|
+
return entries if collapsed.empty?
|
|
188
|
+
|
|
189
|
+
CollapsedEntriesFilter.new(entries, full_entries, index_map, collapsed).filter
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def build_index_map(entries)
|
|
193
|
+
entries.each_with_index.to_h { |entry, idx| [entry.object_id, idx] }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def calculate_selected_index(entries)
|
|
197
|
+
raw_index = @context.state.get(%i[reader sidebar_toc_selected]) || 0
|
|
198
|
+
max_index = [entries.length - 1, 0].max
|
|
199
|
+
raw_index.to_i.clamp(0, max_index)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Extracts entries from document
|
|
204
|
+
class DocumentEntriesExtractor
|
|
205
|
+
def initialize(document)
|
|
206
|
+
@document = NullDocument.wrap(document)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def extract
|
|
210
|
+
toc_entries = @document.toc_entries
|
|
211
|
+
return toc_entries unless toc_entries.empty?
|
|
212
|
+
|
|
213
|
+
create_fallback_entries
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
def create_fallback_entries
|
|
219
|
+
chapters = @document.chapters
|
|
220
|
+
FallbackEntriesBuilder.build(chapters)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Null object pattern for missing documents
|
|
225
|
+
class NullDocument
|
|
226
|
+
EMPTY_ARRAY = [].freeze
|
|
227
|
+
EMPTY_HASH = {}.freeze
|
|
228
|
+
|
|
229
|
+
def self.wrap(document)
|
|
230
|
+
return document if document
|
|
231
|
+
|
|
232
|
+
new
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def toc_entries
|
|
236
|
+
EMPTY_ARRAY
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def chapters
|
|
240
|
+
EMPTY_ARRAY
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def metadata
|
|
244
|
+
EMPTY_HASH
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def title
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Builds fallback entries from chapters
|
|
253
|
+
module FallbackEntriesBuilder
|
|
254
|
+
def self.build(chapters)
|
|
255
|
+
chapters.each_with_index.map do |chapter, idx|
|
|
256
|
+
create_entry(chapter, idx)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def self.create_entry(chapter, index)
|
|
261
|
+
Core::Models::TOCEntry.new(
|
|
262
|
+
title: chapter.title || "Chapter #{index + 1}",
|
|
263
|
+
href: nil,
|
|
264
|
+
level: 1,
|
|
265
|
+
chapter_index: index,
|
|
266
|
+
navigable: true
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Metrics for layout calculations
|
|
272
|
+
Metrics = Struct.new(:x, :y, :width, :height, keyword_init: true)
|
|
273
|
+
|
|
274
|
+
# Collection of entries with selection state
|
|
275
|
+
class EntriesCollection
|
|
276
|
+
attr_reader :full, :visible, :visible_indices, :selected_full_index
|
|
277
|
+
|
|
278
|
+
def initialize(full:, visible:, visible_indices:, selected_full_index:)
|
|
279
|
+
@full = full
|
|
280
|
+
@visible = visible
|
|
281
|
+
@visible_indices = visible_indices
|
|
282
|
+
@selected_full_index = selected_full_index
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def empty?
|
|
286
|
+
visible.empty?
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def count
|
|
290
|
+
full.length
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Resolves document from dependencies
|
|
295
|
+
class DocumentResolver
|
|
296
|
+
def initialize(dependencies)
|
|
297
|
+
@dependencies = NullDependencies.wrap(dependencies)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def resolve
|
|
301
|
+
@dependencies.resolve(:document)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Null object for dependencies
|
|
306
|
+
class NullDependencies
|
|
307
|
+
def self.wrap(dependencies)
|
|
308
|
+
return dependencies if dependencies
|
|
309
|
+
|
|
310
|
+
new
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def resolve(_key)
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Filters TOC entries based on search term
|
|
319
|
+
class EntryFilter
|
|
320
|
+
def initialize(entries, filter_text)
|
|
321
|
+
@entries = entries
|
|
322
|
+
@filter_text = filter_text.to_s.strip
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def filter
|
|
326
|
+
return @entries if @filter_text.empty?
|
|
327
|
+
|
|
328
|
+
matching_indices = MatchingIndicesFinder.new(@entries, @filter_text).find
|
|
329
|
+
return [] if matching_indices.empty?
|
|
330
|
+
|
|
331
|
+
select_matching_entries(matching_indices)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
private
|
|
335
|
+
|
|
336
|
+
def select_matching_entries(matching_indices)
|
|
337
|
+
@entries.select.with_index { |_, idx| matching_indices.include?(idx) }
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Removes descendants of collapsed entries from the visible list
|
|
342
|
+
class CollapsedEntriesFilter
|
|
343
|
+
def initialize(entries, full_entries, index_map, collapsed)
|
|
344
|
+
@entries = entries
|
|
345
|
+
@full_entries = full_entries
|
|
346
|
+
@index_map = index_map
|
|
347
|
+
@collapsed = collapsed
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def filter
|
|
351
|
+
visible = []
|
|
352
|
+
skip_levels = []
|
|
353
|
+
|
|
354
|
+
@entries.each do |entry|
|
|
355
|
+
level = entry.level
|
|
356
|
+
skip_levels.pop while skip_levels.any? && level <= skip_levels.last
|
|
357
|
+
next if skip_levels.any?
|
|
358
|
+
|
|
359
|
+
visible << entry
|
|
360
|
+
full_index = @index_map[entry.object_id]
|
|
361
|
+
next unless full_index
|
|
362
|
+
next unless @collapsed.include?(full_index)
|
|
363
|
+
next unless EntryHierarchy.children?(@full_entries, full_index)
|
|
364
|
+
|
|
365
|
+
skip_levels << level
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
visible
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Finds indices of matching entries and their ancestors
|
|
373
|
+
class MatchingIndicesFinder
|
|
374
|
+
def initialize(entries, filter_text)
|
|
375
|
+
@entries = entries
|
|
376
|
+
@filter_text = filter_text.downcase
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def find
|
|
380
|
+
required = Set.new
|
|
381
|
+
find_matches(required)
|
|
382
|
+
required
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
private
|
|
386
|
+
|
|
387
|
+
def find_matches(required)
|
|
388
|
+
@entries.each_with_index do |entry, idx|
|
|
389
|
+
next unless entry_matches?(entry)
|
|
390
|
+
|
|
391
|
+
required << idx
|
|
392
|
+
add_ancestor_indices(idx, required)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def entry_matches?(entry)
|
|
397
|
+
entry.title.to_s.downcase.include?(@filter_text)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def add_ancestor_indices(start_idx, required)
|
|
401
|
+
ancestor_finder = AncestorFinder.new(@entries, start_idx)
|
|
402
|
+
ancestor_finder.find_all.each { |idx| required << idx }
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Finds ancestor entries in tree structure
|
|
407
|
+
class AncestorFinder
|
|
408
|
+
def initialize(entries, start_idx)
|
|
409
|
+
@entries = entries
|
|
410
|
+
@start_idx = start_idx
|
|
411
|
+
@start_level = entries[start_idx].level
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def find_all
|
|
415
|
+
ancestors = []
|
|
416
|
+
tracker = LevelTracker.new(@start_level)
|
|
417
|
+
|
|
418
|
+
scan_backwards do |idx|
|
|
419
|
+
break if tracker.finished?
|
|
420
|
+
|
|
421
|
+
process_ancestor(idx, tracker, ancestors)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
ancestors
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
private
|
|
428
|
+
|
|
429
|
+
def scan_backwards(&)
|
|
430
|
+
(@start_idx - 1).downto(0, &)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def process_ancestor(idx, tracker, ancestors)
|
|
434
|
+
ancestor_level = @entries[idx].level
|
|
435
|
+
return unless tracker.ancestor?(ancestor_level)
|
|
436
|
+
|
|
437
|
+
ancestors << idx
|
|
438
|
+
tracker.descend_to(ancestor_level)
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Tracks level traversal for ancestor finding
|
|
443
|
+
class LevelTracker
|
|
444
|
+
def initialize(start_level)
|
|
445
|
+
@current_level = start_level
|
|
446
|
+
@target_level = start_level - 1
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def finished?
|
|
450
|
+
@target_level.negative?
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def ancestor?(level)
|
|
454
|
+
level < @current_level
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def descend_to(level)
|
|
458
|
+
@current_level = level
|
|
459
|
+
@target_level = level - 1
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Renders empty state message
|
|
464
|
+
class EmptyStateRenderer
|
|
465
|
+
include Adapters::Output::Ui::Constants::UI
|
|
466
|
+
|
|
467
|
+
MESSAGES = [
|
|
468
|
+
'No chapters found',
|
|
469
|
+
'',
|
|
470
|
+
'Content may still be loading',
|
|
471
|
+
].freeze
|
|
472
|
+
|
|
473
|
+
def initialize(context)
|
|
474
|
+
@context = context
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def render
|
|
478
|
+
MESSAGES.each_with_index do |message, index|
|
|
479
|
+
write_centered_message(message, index)
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
private
|
|
484
|
+
|
|
485
|
+
def write_centered_message(message, offset)
|
|
486
|
+
x_pos = calculate_x_position(message)
|
|
487
|
+
y_pos = start_y + offset
|
|
488
|
+
styled_text = "#{COLOR_TEXT_DIM}#{message}#{Terminal::ANSI::RESET}"
|
|
489
|
+
|
|
490
|
+
@context.write(y_pos, x_pos, styled_text)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def calculate_x_position(message)
|
|
494
|
+
msg_width = Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(message)
|
|
495
|
+
[(@context.metrics.width - msg_width) / 2, 2].max
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def start_y
|
|
499
|
+
((@context.metrics.height - MESSAGES.length) / 2) + 1
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Renders header with title and entry count
|
|
504
|
+
class HeaderRenderer
|
|
505
|
+
include Adapters::Output::Ui::Constants::UI
|
|
506
|
+
|
|
507
|
+
def initialize(context)
|
|
508
|
+
@context = context
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def render
|
|
512
|
+
writer = HeaderWriter.new(@context)
|
|
513
|
+
writer.write_title(title_content)
|
|
514
|
+
writer.write_subtitle(subtitle_content) if should_show_subtitle?
|
|
515
|
+
writer.write_divider
|
|
516
|
+
|
|
517
|
+
@context.metrics.y + 2
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
private
|
|
521
|
+
|
|
522
|
+
def title_content
|
|
523
|
+
TitleExtractor.new(@context.document).extract
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def subtitle_content
|
|
527
|
+
SubtitleFormatter.new(@context.entries.count).format
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def should_show_subtitle?
|
|
531
|
+
subtitle_width = Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(subtitle_content.plain)
|
|
532
|
+
@context.metrics.width > subtitle_width + 2
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Extracts and formats title from document
|
|
537
|
+
class TitleExtractor
|
|
538
|
+
DEFAULT_TITLE = 'CONTENTS'
|
|
539
|
+
|
|
540
|
+
def initialize(document)
|
|
541
|
+
@document = NullDocument.wrap(document)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def extract
|
|
545
|
+
title = extract_title_text
|
|
546
|
+
return default_content if title.empty?
|
|
547
|
+
|
|
548
|
+
TitleContent.new(title.strip.upcase)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
private
|
|
552
|
+
|
|
553
|
+
def default_content
|
|
554
|
+
@default_content ||= TitleContent.new(DEFAULT_TITLE)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def extract_title_text
|
|
558
|
+
metadata_title = @document.metadata.fetch(:title, nil)
|
|
559
|
+
metadata_title || @document.title || ''
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Represents styled title content
|
|
564
|
+
class TitleContent
|
|
565
|
+
include Adapters::Output::Ui::Constants::UI
|
|
566
|
+
|
|
567
|
+
attr_reader :plain
|
|
568
|
+
|
|
569
|
+
def initialize(plain_text)
|
|
570
|
+
@plain = plain_text
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def styled
|
|
574
|
+
"#{Terminal::ANSI::BOLD}#{COLOR_TEXT_ACCENT}#{@plain}#{Terminal::ANSI::RESET}"
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def width
|
|
578
|
+
Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(@plain)
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Formats subtitle with entry count
|
|
583
|
+
class SubtitleFormatter
|
|
584
|
+
def initialize(count)
|
|
585
|
+
@count = count
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def format
|
|
589
|
+
SubtitleContent.new("#{@count} entries")
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Represents styled subtitle content
|
|
594
|
+
class SubtitleContent
|
|
595
|
+
include Adapters::Output::Ui::Constants::UI
|
|
596
|
+
|
|
597
|
+
attr_reader :plain
|
|
598
|
+
|
|
599
|
+
def initialize(plain_text)
|
|
600
|
+
@plain = plain_text
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def styled
|
|
604
|
+
"#{COLOR_TEXT_DIM}#{@plain}#{Terminal::ANSI::RESET}"
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def width
|
|
608
|
+
Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(@plain)
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Writes header components to surface
|
|
613
|
+
class HeaderWriter
|
|
614
|
+
include Adapters::Output::Ui::Constants::UI
|
|
615
|
+
|
|
616
|
+
def initialize(context)
|
|
617
|
+
@context = context
|
|
618
|
+
@metrics = context.metrics
|
|
619
|
+
@last_title_width = 0
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def write_title(title_content)
|
|
623
|
+
@context.write(y_pos, x_pos + 1, title_content.styled)
|
|
624
|
+
@last_title_width = title_content.width
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def write_subtitle(subtitle_content)
|
|
628
|
+
col = calculate_subtitle_column(subtitle_content)
|
|
629
|
+
@context.write(y_pos, col, subtitle_content.styled)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def write_divider
|
|
633
|
+
width = [@metrics.width - 2, 0].max
|
|
634
|
+
divider = "#{COLOR_TEXT_DIM}#{'─' * width}#{Terminal::ANSI::RESET}"
|
|
635
|
+
@context.write(y_pos + 1, x_pos + 1, divider)
|
|
636
|
+
write_right_junction
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
private
|
|
640
|
+
|
|
641
|
+
def calculate_subtitle_column(subtitle_content)
|
|
642
|
+
min_col = x_pos + 1 + @last_title_width + 2
|
|
643
|
+
right_col = x_pos + @metrics.width - subtitle_content.width - 1
|
|
644
|
+
[right_col, min_col].max
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def y_pos
|
|
648
|
+
@metrics.y
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def x_pos
|
|
652
|
+
@metrics.x
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def write_right_junction
|
|
656
|
+
junction_col = x_pos + @metrics.width - 1
|
|
657
|
+
return if junction_col < x_pos
|
|
658
|
+
|
|
659
|
+
glyph = "#{COLOR_TEXT_DIM}┤#{Terminal::ANSI::RESET}"
|
|
660
|
+
@context.write(y_pos + 1, junction_col, glyph)
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Renders filter input field
|
|
665
|
+
class FilterInputRenderer
|
|
666
|
+
include Adapters::Output::Ui::Constants::UI
|
|
667
|
+
|
|
668
|
+
def initialize(context)
|
|
669
|
+
@context = context
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def render
|
|
673
|
+
write_input_line
|
|
674
|
+
write_help_text
|
|
675
|
+
start_y + 2
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
private
|
|
679
|
+
|
|
680
|
+
def write_input_line
|
|
681
|
+
prompt = "#{COLOR_TEXT_ACCENT}SEARCH ▸#{Terminal::ANSI::RESET} "
|
|
682
|
+
@context.write(start_y, x_pos, "#{prompt}#{styled_input_text}")
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def write_help_text
|
|
686
|
+
help = "#{COLOR_TEXT_DIM}ESC cancel#{Terminal::ANSI::RESET}"
|
|
687
|
+
@context.write(start_y + 1, x_pos, help)
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def styled_input_text
|
|
691
|
+
base = "#{COLOR_TEXT_PRIMARY}#{@context.filter_text}#{Terminal::ANSI::RESET}"
|
|
692
|
+
cursor = @context.filter_active? ? "#{Terminal::ANSI::REVERSE} #{Terminal::ANSI::RESET}" : ''
|
|
693
|
+
base + cursor
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def start_y
|
|
697
|
+
@context.metrics.y + 2
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def x_pos
|
|
701
|
+
@context.metrics.x + 1
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Calculates layout information for TOC entries
|
|
706
|
+
class EntriesListLayout
|
|
707
|
+
attr_reader :content_start_y, :available_height, :max_width
|
|
708
|
+
|
|
709
|
+
def initialize(context)
|
|
710
|
+
@context = context
|
|
711
|
+
@content_start_y = compute_content_start_y
|
|
712
|
+
@available_height = compute_available_height
|
|
713
|
+
@max_width = compute_max_width
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def visible_items
|
|
717
|
+
return [] if @context.entries.empty? || @available_height <= 0
|
|
718
|
+
|
|
719
|
+
viewport = create_viewport_config
|
|
720
|
+
VisibleItemsCalculator.new(
|
|
721
|
+
@context.entries.visible,
|
|
722
|
+
@context.entries.visible_indices,
|
|
723
|
+
@context.selected_index,
|
|
724
|
+
viewport,
|
|
725
|
+
full_entries: @context.entries.full,
|
|
726
|
+
collapsed_set: @context.collapsed_set,
|
|
727
|
+
filter_active: @context.filter_active?,
|
|
728
|
+
wrap_cache: @context.wrap_cache,
|
|
729
|
+
line_index: line_index
|
|
730
|
+
).calculate
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def item_at(row)
|
|
734
|
+
visible_items.find do |item|
|
|
735
|
+
row >= item.screen_y && row < (item.screen_y + item.visible_height)
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def total_height
|
|
740
|
+
line_index.total_height
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def line_index
|
|
744
|
+
@line_index ||= LineIndex.new(@context.entries.visible, @max_width, @context.wrap_cache)
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
private
|
|
748
|
+
|
|
749
|
+
def create_viewport_config
|
|
750
|
+
ViewportConfig.new(
|
|
751
|
+
start_y: @content_start_y,
|
|
752
|
+
height: @available_height,
|
|
753
|
+
max_width: @max_width
|
|
754
|
+
)
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def compute_content_start_y
|
|
758
|
+
base = @context.metrics.y + 2
|
|
759
|
+
base += 2 if @context.filter_active?
|
|
760
|
+
base
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def compute_available_height
|
|
764
|
+
metrics = @context.metrics
|
|
765
|
+
total = metrics.height - (@content_start_y - metrics.y)
|
|
766
|
+
[total, 0].max
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def compute_max_width
|
|
770
|
+
[@context.metrics.width - 2 - SCROLLBAR_WIDTH - RIGHT_MARGIN, 0].max
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# Computes scroll metrics for TOC entries within the content viewport
|
|
775
|
+
class EntriesScrollMetrics
|
|
776
|
+
attr_reader :track_start_y, :track_height, :thumb_start_y, :thumb_height, :total_items,
|
|
777
|
+
:total_height, :viewport_height, :viewport_start, :max_start,
|
|
778
|
+
:scrollbar_start_col, :scrollbar_end_col, :visible_indices,
|
|
779
|
+
:selected_full_index, :selected_visible_index, :navigable_indices
|
|
780
|
+
|
|
781
|
+
def initialize(context)
|
|
782
|
+
@context = context
|
|
783
|
+
@layout = context.entries_layout
|
|
784
|
+
@visible_entries = context.entries.visible
|
|
785
|
+
@visible_indices = context.entries.visible_indices
|
|
786
|
+
@total_items = @visible_entries.length
|
|
787
|
+
@total_height = @layout.total_height
|
|
788
|
+
@viewport_height = @layout.available_height
|
|
789
|
+
@scrollbar_end_col = context.metrics.width
|
|
790
|
+
@scrollbar_start_col = [@scrollbar_end_col - SCROLLBAR_WIDTH + 1, 1].max
|
|
791
|
+
@track_start_y = @layout.content_start_y
|
|
792
|
+
@track_height = @layout.available_height
|
|
793
|
+
@max_start = [@total_height - @viewport_height, 0].max
|
|
794
|
+
@selected_visible_index = context.selected_index
|
|
795
|
+
@selected_full_index = context.entries.selected_full_index
|
|
796
|
+
@viewport_start = calculate_viewport_start
|
|
797
|
+
@thumb_height = calculate_thumb_height
|
|
798
|
+
@thumb_start_y = calculate_thumb_start
|
|
799
|
+
@navigable_indices = build_navigable_indices
|
|
800
|
+
@nav_positions = build_nav_positions
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
def scrollable?
|
|
804
|
+
@track_height.positive? && @total_height.positive?
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def absolute_scrollbar_start_col
|
|
808
|
+
@context.bounds.x + @scrollbar_start_col - 1
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
def absolute_scrollbar_end_col
|
|
812
|
+
@context.bounds.x + @scrollbar_end_col - 1
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
def absolute_track_start_y
|
|
816
|
+
@context.bounds.y + @track_start_y - 1
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def absolute_track_end_y
|
|
820
|
+
absolute_track_start_y + @track_height - 1
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
def absolute_thumb_start_y
|
|
824
|
+
@context.bounds.y + @thumb_start_y - 1
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
def hit_scrollbar?(abs_col, abs_row)
|
|
828
|
+
return false unless scrollable?
|
|
829
|
+
|
|
830
|
+
abs_col.between?(absolute_scrollbar_start_col, absolute_scrollbar_end_col) &&
|
|
831
|
+
abs_row.between?(absolute_track_start_y, absolute_track_end_y)
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
def row_in_track?(abs_row)
|
|
835
|
+
return false unless scrollable?
|
|
836
|
+
|
|
837
|
+
abs_row.between?(absolute_track_start_y, absolute_track_end_y)
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def hit_thumb?(abs_col, abs_row)
|
|
841
|
+
return false unless hit_scrollbar?(abs_col, abs_row)
|
|
842
|
+
return false unless @thumb_height.positive?
|
|
843
|
+
|
|
844
|
+
abs_row.between?(absolute_thumb_start_y, absolute_thumb_start_y + @thumb_height - 1)
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def full_index_for_abs_row(abs_row)
|
|
848
|
+
full_index_for_row(abs_row - @context.bounds.y + 1)
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def full_index_for_row(local_row)
|
|
852
|
+
return nil unless scrollable?
|
|
853
|
+
return nil if @visible_indices.empty?
|
|
854
|
+
return @visible_indices.first if @max_start <= 0 || @track_height <= 1
|
|
855
|
+
|
|
856
|
+
clamped = [local_row - @track_start_y, 0].max
|
|
857
|
+
clamped = [clamped, @track_height - 1].min
|
|
858
|
+
ratio = clamped.to_f / (@track_height - 1)
|
|
859
|
+
viewport_start = (ratio * @max_start).round
|
|
860
|
+
target_line = viewport_start + (@viewport_height / 2.0)
|
|
861
|
+
target_index = @layout.line_index.entry_index_for_line(target_line) || 0
|
|
862
|
+
@visible_indices[target_index]
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
def nav_position_for(full_index)
|
|
866
|
+
@nav_positions[full_index]
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
private
|
|
870
|
+
|
|
871
|
+
def calculate_viewport_start
|
|
872
|
+
return 0 if @total_height <= @viewport_height || @viewport_height <= 0
|
|
873
|
+
|
|
874
|
+
selected_index = @selected_visible_index.to_i.clamp(0, @visible_entries.length - 1)
|
|
875
|
+
selected_offset = @layout.line_index.offset_for(selected_index)
|
|
876
|
+
selected_height = @layout.line_index.height_for(selected_index)
|
|
877
|
+
selected_center = selected_offset + (selected_height / 2.0)
|
|
878
|
+
|
|
879
|
+
raw = selected_center - (@viewport_height / 2.0)
|
|
880
|
+
raw = [raw, 0].max
|
|
881
|
+
[raw.round, @max_start].min
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
def calculate_thumb_height
|
|
885
|
+
return 0 unless scrollable?
|
|
886
|
+
return @track_height if @max_start <= 0
|
|
887
|
+
height = (@viewport_height.to_f / @total_height) * @track_height
|
|
888
|
+
[height.round, 1].max
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def calculate_thumb_start
|
|
892
|
+
return @track_start_y unless scrollable?
|
|
893
|
+
return @track_start_y if @max_start <= 0 || @track_height <= @thumb_height
|
|
894
|
+
|
|
895
|
+
offset = ((@viewport_start.to_f / @max_start) * (@track_height - @thumb_height)).round
|
|
896
|
+
@track_start_y + offset
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def build_navigable_indices
|
|
900
|
+
navigable = []
|
|
901
|
+
@visible_entries.each_with_index do |entry, idx|
|
|
902
|
+
navigable << @visible_indices[idx] if entry&.chapter_index
|
|
903
|
+
end
|
|
904
|
+
navigable.empty? ? @visible_indices.dup : navigable
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def build_nav_positions
|
|
908
|
+
positions = {}
|
|
909
|
+
@navigable_indices.each_with_index { |idx, pos| positions[idx] = pos }
|
|
910
|
+
positions
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# Renders list of TOC entries
|
|
916
|
+
class EntriesListRenderer
|
|
917
|
+
def initialize(context)
|
|
918
|
+
@context = context
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
def render
|
|
922
|
+
@context.entries_layout.visible_items.each { |item| render_entry_item(item) }
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
private
|
|
926
|
+
|
|
927
|
+
def render_entry_item(item)
|
|
928
|
+
EntryRenderer.new(@context, item).render
|
|
929
|
+
end
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# Renders a scrollbar at the right edge of the TOC content area
|
|
933
|
+
class ScrollbarRenderer
|
|
934
|
+
include Adapters::Output::Ui::Constants::UI
|
|
935
|
+
|
|
936
|
+
TRACK_CHAR = '░'
|
|
937
|
+
THUMB_CHAR = '█'
|
|
938
|
+
|
|
939
|
+
def initialize(context)
|
|
940
|
+
@context = context
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
def render
|
|
944
|
+
metrics = @context.scroll_metrics
|
|
945
|
+
return unless metrics.scrollable?
|
|
946
|
+
|
|
947
|
+
draw_track(metrics)
|
|
948
|
+
draw_thumb(metrics)
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
private
|
|
952
|
+
|
|
953
|
+
def draw_track(metrics)
|
|
954
|
+
track_end = metrics.track_start_y + metrics.track_height - 1
|
|
955
|
+
line = "#{COLOR_TEXT_DIM}#{TRACK_CHAR * SCROLLBAR_WIDTH}#{Terminal::ANSI::RESET}"
|
|
956
|
+
metrics.track_start_y.upto(track_end) do |row|
|
|
957
|
+
@context.write(row, metrics.scrollbar_start_col, line)
|
|
958
|
+
end
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
def draw_thumb(metrics)
|
|
962
|
+
return unless metrics.thumb_height.positive?
|
|
963
|
+
|
|
964
|
+
thumb_end = metrics.thumb_start_y + metrics.thumb_height - 1
|
|
965
|
+
line = "#{COLOR_TEXT_ACCENT}#{THUMB_CHAR * SCROLLBAR_WIDTH}#{Terminal::ANSI::RESET}"
|
|
966
|
+
metrics.thumb_start_y.upto(thumb_end) do |row|
|
|
967
|
+
@context.write(row, metrics.scrollbar_start_col, line)
|
|
968
|
+
end
|
|
969
|
+
end
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
# Configuration for viewport
|
|
973
|
+
ViewportConfig = Struct.new(:start_y, :height, :max_width, keyword_init: true)
|
|
974
|
+
|
|
975
|
+
# Calculates wrapped lines and widths for entries
|
|
976
|
+
class EntryLayoutHelper
|
|
977
|
+
def self.wrap_lines(entry, max_width, wrap_cache)
|
|
978
|
+
width = available_width(entry, max_width)
|
|
979
|
+
return [''] if width <= 0
|
|
980
|
+
|
|
981
|
+
cache = wrap_cache
|
|
982
|
+
key = [entry.object_id, width]
|
|
983
|
+
if cache
|
|
984
|
+
cache[key] ||= Shoko::Adapters::Output::Terminal::TextMetrics.wrap_plain_text(formatted_title(entry), width)
|
|
985
|
+
else
|
|
986
|
+
Shoko::Adapters::Output::Terminal::TextMetrics.wrap_plain_text(formatted_title(entry), width)
|
|
987
|
+
end
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
def self.line_count(entry, max_width, wrap_cache)
|
|
991
|
+
wrap_lines(entry, max_width, wrap_cache).length
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
def self.available_width(entry, max_width)
|
|
995
|
+
width = max_width - width_without_title(entry)
|
|
996
|
+
[width, 0].max
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
def self.width_without_title(entry)
|
|
1000
|
+
level = entry.level.to_i
|
|
1001
|
+
level = 0 if level.negative?
|
|
1002
|
+
(level * 2) + 2
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
def self.formatted_title(entry)
|
|
1006
|
+
EntryTitleFormatter.format(entry)
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
private_class_method :available_width, :width_without_title, :formatted_title
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
# Precomputes line offsets for variable-height entries
|
|
1013
|
+
class LineIndex
|
|
1014
|
+
attr_reader :total_height
|
|
1015
|
+
|
|
1016
|
+
def initialize(entries, max_width, wrap_cache)
|
|
1017
|
+
@offsets = []
|
|
1018
|
+
@heights = []
|
|
1019
|
+
total = 0
|
|
1020
|
+
|
|
1021
|
+
entries.each do |entry|
|
|
1022
|
+
@offsets << total
|
|
1023
|
+
height = EntryLayoutHelper.line_count(entry, max_width, wrap_cache)
|
|
1024
|
+
@heights << height
|
|
1025
|
+
total += height
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
@total_height = total
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
def height_for(index)
|
|
1032
|
+
@heights[index] || 0
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
def offset_for(index)
|
|
1036
|
+
@offsets[index] || 0
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
def entry_index_for_line(line)
|
|
1040
|
+
return nil if @offsets.empty?
|
|
1041
|
+
return 0 if @total_height <= 0
|
|
1042
|
+
|
|
1043
|
+
line = line.to_i
|
|
1044
|
+
line = 0 if line.negative?
|
|
1045
|
+
line = @total_height - 1 if line >= @total_height
|
|
1046
|
+
|
|
1047
|
+
low = 0
|
|
1048
|
+
high = @offsets.length - 1
|
|
1049
|
+
while low <= high
|
|
1050
|
+
mid = (low + high) / 2
|
|
1051
|
+
if @offsets[mid] <= line
|
|
1052
|
+
return mid if mid == @offsets.length - 1 || @offsets[mid + 1] > line
|
|
1053
|
+
|
|
1054
|
+
low = mid + 1
|
|
1055
|
+
else
|
|
1056
|
+
high = mid - 1
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
0
|
|
1061
|
+
end
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
# Calculates which entries are visible in viewport
|
|
1065
|
+
class VisibleItemsCalculator
|
|
1066
|
+
def initialize(entries, visible_indices, selected_index, viewport, full_entries:,
|
|
1067
|
+
collapsed_set:, filter_active:, wrap_cache:, line_index:)
|
|
1068
|
+
@entries = entries
|
|
1069
|
+
@visible_indices = visible_indices
|
|
1070
|
+
@selected_index = selected_index
|
|
1071
|
+
@viewport = viewport
|
|
1072
|
+
@full_entries = full_entries
|
|
1073
|
+
@collapsed_set = collapsed_set
|
|
1074
|
+
@filter_active = filter_active
|
|
1075
|
+
@wrap_cache = wrap_cache
|
|
1076
|
+
@line_index = line_index
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
def calculate
|
|
1080
|
+
return [] if @entries.empty? || @viewport.height <= 0
|
|
1081
|
+
return [] if @line_index.total_height <= 0
|
|
1082
|
+
|
|
1083
|
+
viewport_start = viewport_start_line
|
|
1084
|
+
start_index = @line_index.entry_index_for_line(viewport_start) || 0
|
|
1085
|
+
start_offset = viewport_start - @line_index.offset_for(start_index)
|
|
1086
|
+
items = []
|
|
1087
|
+
remaining = @viewport.height
|
|
1088
|
+
screen_y = @viewport.start_y
|
|
1089
|
+
idx = start_index
|
|
1090
|
+
offset = start_offset
|
|
1091
|
+
|
|
1092
|
+
while idx < @entries.length && remaining.positive?
|
|
1093
|
+
entry = @entries[idx]
|
|
1094
|
+
full_index = @visible_indices[idx]
|
|
1095
|
+
config = ItemConfig.new(
|
|
1096
|
+
item_entries: @entries,
|
|
1097
|
+
entry: entry,
|
|
1098
|
+
index: idx,
|
|
1099
|
+
full_index: full_index,
|
|
1100
|
+
selected_index: @selected_index,
|
|
1101
|
+
max_width: @viewport.max_width,
|
|
1102
|
+
full_entries: @full_entries,
|
|
1103
|
+
collapsed_set: @collapsed_set,
|
|
1104
|
+
filter_active: @filter_active,
|
|
1105
|
+
wrap_cache: @wrap_cache
|
|
1106
|
+
)
|
|
1107
|
+
item = VisibleEntryItem.new(config)
|
|
1108
|
+
height = item.height
|
|
1109
|
+
visible_height = [height - offset, remaining].min
|
|
1110
|
+
items << item.with_screen_position(screen_y, offset, visible_height)
|
|
1111
|
+
screen_y += visible_height
|
|
1112
|
+
remaining -= visible_height
|
|
1113
|
+
offset = 0
|
|
1114
|
+
idx += 1
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
items
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
private
|
|
1121
|
+
|
|
1122
|
+
def viewport_start_line
|
|
1123
|
+
total_height = @line_index.total_height
|
|
1124
|
+
return 0 if total_height <= @viewport.height || @viewport.height <= 0
|
|
1125
|
+
|
|
1126
|
+
selected_index = @selected_index.to_i.clamp(0, @entries.length - 1)
|
|
1127
|
+
selected_offset = @line_index.offset_for(selected_index)
|
|
1128
|
+
selected_height = @line_index.height_for(selected_index)
|
|
1129
|
+
selected_center = selected_offset + (selected_height / 2.0)
|
|
1130
|
+
|
|
1131
|
+
raw_start = selected_center - (@viewport.height / 2.0)
|
|
1132
|
+
raw_start = [raw_start, 0].max
|
|
1133
|
+
max_start = [total_height - @viewport.height, 0].max
|
|
1134
|
+
[raw_start.round, max_start].min
|
|
1135
|
+
end
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
# Configuration for creating visible entry items
|
|
1139
|
+
ItemConfig = Struct.new(
|
|
1140
|
+
:item_entries, :entry, :index, :full_index, :selected_index, :max_width,
|
|
1141
|
+
:full_entries, :collapsed_set, :filter_active, :wrap_cache,
|
|
1142
|
+
keyword_init: true
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
# Represents a single entry item with rendering info
|
|
1146
|
+
class VisibleEntryItem
|
|
1147
|
+
attr_reader :entry, :index, :full_index, :max_width
|
|
1148
|
+
|
|
1149
|
+
def initialize(config)
|
|
1150
|
+
@config = config
|
|
1151
|
+
@entry = config.entry
|
|
1152
|
+
@index = config.index
|
|
1153
|
+
@full_index = config.full_index
|
|
1154
|
+
@max_width = config.max_width
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
def with_screen_position(screen_y, start_offset, visible_height)
|
|
1158
|
+
PositionedEntryItem.new(self, screen_y, start_offset, visible_height)
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
def selected?
|
|
1162
|
+
index == @config.selected_index
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
def height
|
|
1166
|
+
wrapped_lines.length
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
def wrapped_lines
|
|
1170
|
+
@wrapped_lines ||= EntryLayoutHelper.wrap_lines(@entry, @max_width, @config.wrap_cache)
|
|
1171
|
+
end
|
|
1172
|
+
|
|
1173
|
+
def components
|
|
1174
|
+
@components ||= EntryComponents.new(
|
|
1175
|
+
@config.item_entries,
|
|
1176
|
+
@entry,
|
|
1177
|
+
@index,
|
|
1178
|
+
full_entries: @config.full_entries,
|
|
1179
|
+
full_index: @config.full_index,
|
|
1180
|
+
collapsed_set: @config.collapsed_set,
|
|
1181
|
+
filter_active: @config.filter_active
|
|
1182
|
+
)
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
# Item with screen position
|
|
1188
|
+
class PositionedEntryItem
|
|
1189
|
+
attr_reader :screen_y, :start_offset, :visible_height
|
|
1190
|
+
|
|
1191
|
+
def initialize(item, screen_y, start_offset, visible_height)
|
|
1192
|
+
@item = item
|
|
1193
|
+
@screen_y = screen_y
|
|
1194
|
+
@start_offset = start_offset
|
|
1195
|
+
@visible_height = visible_height
|
|
1196
|
+
end
|
|
1197
|
+
|
|
1198
|
+
def entry
|
|
1199
|
+
@item.entry
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
def index
|
|
1203
|
+
@item.index
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1206
|
+
def full_index
|
|
1207
|
+
@item.full_index
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
def max_width
|
|
1211
|
+
@item.max_width
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
def selected?
|
|
1215
|
+
@item.selected?
|
|
1216
|
+
end
|
|
1217
|
+
|
|
1218
|
+
def height
|
|
1219
|
+
@visible_height
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
def wrapped_lines
|
|
1223
|
+
@item.wrapped_lines
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
def components
|
|
1227
|
+
@item.components
|
|
1228
|
+
end
|
|
1229
|
+
end
|
|
1230
|
+
|
|
1231
|
+
# Renders a single TOC entry
|
|
1232
|
+
class EntryRenderer
|
|
1233
|
+
include Adapters::Output::Ui::Constants::UI
|
|
1234
|
+
|
|
1235
|
+
def initialize(context, item)
|
|
1236
|
+
@context = context
|
|
1237
|
+
@item = item
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
def render
|
|
1241
|
+
render_lines
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
private
|
|
1245
|
+
|
|
1246
|
+
def render_lines
|
|
1247
|
+
formatter = EntryFormatter.new(@item)
|
|
1248
|
+
lines = formatter.lines
|
|
1249
|
+
start = @item.start_offset
|
|
1250
|
+
visible = @item.visible_height
|
|
1251
|
+
lines_to_render = lines.slice(start, visible) || []
|
|
1252
|
+
|
|
1253
|
+
lines_to_render.each_with_index do |line, offset|
|
|
1254
|
+
y_pos = @item.screen_y + offset
|
|
1255
|
+
write_gutter(y_pos)
|
|
1256
|
+
write_content(y_pos, line)
|
|
1257
|
+
end
|
|
1258
|
+
end
|
|
1259
|
+
|
|
1260
|
+
def write_gutter(y_pos)
|
|
1261
|
+
gutter = gutter_symbol + Terminal::ANSI::RESET
|
|
1262
|
+
@context.write(y_pos, @context.metrics.x, gutter)
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
def gutter_symbol
|
|
1266
|
+
@item.selected? ? "#{COLOR_TEXT_ACCENT}│" : "#{COLOR_TEXT_DIM}│"
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
def write_content(y_pos, line)
|
|
1270
|
+
@context.write(y_pos, @context.metrics.x + 2, line)
|
|
1271
|
+
end
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
# Formats entry text with tree structure
|
|
1275
|
+
class EntryFormatter
|
|
1276
|
+
include Adapters::Output::Ui::Constants::UI
|
|
1277
|
+
|
|
1278
|
+
def initialize(item)
|
|
1279
|
+
@item = item
|
|
1280
|
+
@components = item.components
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
def lines
|
|
1284
|
+
builder = EntryLineBuilder.new(@components, @item.wrapped_lines)
|
|
1285
|
+
@item.selected? ? builder.build_selected : builder.build
|
|
1286
|
+
end
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
# Builds multi-line entry strings
|
|
1290
|
+
class EntryLineBuilder
|
|
1291
|
+
include Adapters::Output::Ui::Constants::UI
|
|
1292
|
+
|
|
1293
|
+
def initialize(components, wrapped_lines)
|
|
1294
|
+
@components = components
|
|
1295
|
+
@entry = components.entry
|
|
1296
|
+
@wrapped_lines = wrapped_lines
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
def build
|
|
1300
|
+
build_lines { |line, idx| format_line(line, idx) }
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
def build_selected
|
|
1304
|
+
build_lines { |line, idx| format_selected_line(line, idx) }
|
|
1305
|
+
end
|
|
1306
|
+
|
|
1307
|
+
private
|
|
1308
|
+
|
|
1309
|
+
def build_lines
|
|
1310
|
+
@wrapped_lines.map.with_index do |line, idx|
|
|
1311
|
+
yield(line, idx)
|
|
1312
|
+
end
|
|
1313
|
+
end
|
|
1314
|
+
|
|
1315
|
+
def format_line(line, idx)
|
|
1316
|
+
idx.zero? ? format_first_line(line) : format_continuation_line(line)
|
|
1317
|
+
end
|
|
1318
|
+
|
|
1319
|
+
def format_selected_line(line, idx)
|
|
1320
|
+
plain = idx.zero? ? plain_first_line(line) : plain_continuation_line(line)
|
|
1321
|
+
"#{Terminal::ANSI::BG_GREY}#{Terminal::ANSI::WHITE}#{plain}#{Terminal::ANSI::RESET}"
|
|
1322
|
+
end
|
|
1323
|
+
|
|
1324
|
+
def format_first_line(line)
|
|
1325
|
+
parts = []
|
|
1326
|
+
prefix = @components.prefix
|
|
1327
|
+
parts << colorize(prefix, COLOR_TEXT_DIM) unless prefix.empty?
|
|
1328
|
+
|
|
1329
|
+
if @components.icon_present?
|
|
1330
|
+
parts << colorize(@components.icon, EntryStyler.icon_color(@entry))
|
|
1331
|
+
parts << ' '
|
|
1332
|
+
end
|
|
1333
|
+
|
|
1334
|
+
parts << colorize(line, EntryStyler.title_color(@entry))
|
|
1335
|
+
parts.join
|
|
1336
|
+
end
|
|
1337
|
+
|
|
1338
|
+
def format_continuation_line(line)
|
|
1339
|
+
prefix = @components.continuation_prefix
|
|
1340
|
+
styled_prefix = prefix.empty? ? '' : colorize(prefix, COLOR_TEXT_DIM)
|
|
1341
|
+
"#{styled_prefix}#{colorize(line, EntryStyler.title_color(@entry))}"
|
|
1342
|
+
end
|
|
1343
|
+
|
|
1344
|
+
def plain_first_line(line)
|
|
1345
|
+
spacer = @components.icon_present? ? ' ' : ''
|
|
1346
|
+
"#{@components.prefix}#{@components.icon}#{spacer}#{line}"
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
def plain_continuation_line(line)
|
|
1350
|
+
"#{@components.continuation_prefix}#{line}"
|
|
1351
|
+
end
|
|
1352
|
+
|
|
1353
|
+
def colorize(text, color)
|
|
1354
|
+
return text if text.empty? || color.nil?
|
|
1355
|
+
|
|
1356
|
+
"#{color}#{text}#{Terminal::ANSI::RESET}"
|
|
1357
|
+
end
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
# Calculates components of an entry (prefix, icon, title)
|
|
1361
|
+
class EntryComponents
|
|
1362
|
+
attr_reader :prefix, :icon, :title, :entry, :continuation_prefix
|
|
1363
|
+
|
|
1364
|
+
def initialize(item_entries, entry, index, full_entries:, full_index:, collapsed_set:, filter_active:)
|
|
1365
|
+
@entry = entry
|
|
1366
|
+
@prefix = TreeFormatter.prefix(item_entries, index, entry.level)
|
|
1367
|
+
@icon = IconSelector.select(
|
|
1368
|
+
full_entries,
|
|
1369
|
+
entry,
|
|
1370
|
+
full_index,
|
|
1371
|
+
collapsed_set: collapsed_set,
|
|
1372
|
+
filter_active: filter_active
|
|
1373
|
+
)
|
|
1374
|
+
@title = EntryTitleFormatter.format(entry)
|
|
1375
|
+
@continuation_prefix = IndentCalculator.new(
|
|
1376
|
+
item_entries,
|
|
1377
|
+
index,
|
|
1378
|
+
entry.level,
|
|
1379
|
+
icon_present: icon_present?
|
|
1380
|
+
).build
|
|
1381
|
+
end
|
|
1382
|
+
|
|
1383
|
+
def icon_present?
|
|
1384
|
+
!@icon.empty?
|
|
1385
|
+
end
|
|
1386
|
+
|
|
1387
|
+
def width_without_title
|
|
1388
|
+
prefix_width + icon_width + spacer_width
|
|
1389
|
+
end
|
|
1390
|
+
|
|
1391
|
+
private
|
|
1392
|
+
|
|
1393
|
+
def spacer_width
|
|
1394
|
+
icon_present? ? 1 : 0
|
|
1395
|
+
end
|
|
1396
|
+
|
|
1397
|
+
def prefix_width
|
|
1398
|
+
Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(@prefix)
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
def icon_width
|
|
1402
|
+
Shoko::Adapters::Output::Terminal::TextMetrics.visible_length(@icon)
|
|
1403
|
+
end
|
|
1404
|
+
end
|
|
1405
|
+
|
|
1406
|
+
# Formats entry titles
|
|
1407
|
+
module EntryTitleFormatter
|
|
1408
|
+
def self.format(entry)
|
|
1409
|
+
text = entry.title || 'Untitled'
|
|
1410
|
+
entry.level.zero? ? text.upcase : text
|
|
1411
|
+
end
|
|
1412
|
+
end
|
|
1413
|
+
|
|
1414
|
+
# Formats tree structure prefix for entries
|
|
1415
|
+
class TreeFormatter
|
|
1416
|
+
def self.prefix(item_entries, index, level)
|
|
1417
|
+
return '' if level <= 0
|
|
1418
|
+
|
|
1419
|
+
PrefixBuilder.new(item_entries, index, level).build
|
|
1420
|
+
end
|
|
1421
|
+
|
|
1422
|
+
def self.continuation_prefix(item_entries, index, level)
|
|
1423
|
+
return '' if level <= 0
|
|
1424
|
+
|
|
1425
|
+
ContinuationPrefixBuilder.new(item_entries, index, level).build
|
|
1426
|
+
end
|
|
1427
|
+
end
|
|
1428
|
+
|
|
1429
|
+
# Calculates indentation for wrapped lines
|
|
1430
|
+
class IndentCalculator
|
|
1431
|
+
def initialize(item_entries, index, level, icon_present:)
|
|
1432
|
+
@item_entries = item_entries
|
|
1433
|
+
@index = index
|
|
1434
|
+
@level = level
|
|
1435
|
+
@icon_present = icon_present
|
|
1436
|
+
end
|
|
1437
|
+
|
|
1438
|
+
def build
|
|
1439
|
+
prefix = TreeFormatter.continuation_prefix(@item_entries, @index, @level)
|
|
1440
|
+
prefix + (@icon_present ? ' ' : '')
|
|
1441
|
+
end
|
|
1442
|
+
end
|
|
1443
|
+
|
|
1444
|
+
# Builds continuation prefix from segments
|
|
1445
|
+
class ContinuationPrefixBuilder
|
|
1446
|
+
def initialize(item_entries, index, level)
|
|
1447
|
+
@item_entries = item_entries
|
|
1448
|
+
@index = index
|
|
1449
|
+
@level = level
|
|
1450
|
+
end
|
|
1451
|
+
|
|
1452
|
+
def build
|
|
1453
|
+
(1..@level).map { |depth| segment_for_depth(depth) }.join
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1456
|
+
private
|
|
1457
|
+
|
|
1458
|
+
def segment_for_depth(depth)
|
|
1459
|
+
TreeAnalyzer.ancestor_continues?(@item_entries, @index, depth) ? '│ ' : ' '
|
|
1460
|
+
end
|
|
1461
|
+
end
|
|
1462
|
+
|
|
1463
|
+
# Builds tree prefix from segments
|
|
1464
|
+
class PrefixBuilder
|
|
1465
|
+
def initialize(item_entries, index, level)
|
|
1466
|
+
@item_entries = item_entries
|
|
1467
|
+
@index = index
|
|
1468
|
+
@level = level
|
|
1469
|
+
end
|
|
1470
|
+
|
|
1471
|
+
def build
|
|
1472
|
+
(1..@level).map { |depth| segment_for_depth(depth) }.join
|
|
1473
|
+
end
|
|
1474
|
+
|
|
1475
|
+
private
|
|
1476
|
+
|
|
1477
|
+
def segment_for_depth(depth)
|
|
1478
|
+
TreeSegment.new(@item_entries, @index, depth, @level).format
|
|
1479
|
+
end
|
|
1480
|
+
end
|
|
1481
|
+
|
|
1482
|
+
# Represents a single tree segment
|
|
1483
|
+
class TreeSegment
|
|
1484
|
+
def initialize(item_entries, index, depth, current_level)
|
|
1485
|
+
@item_entries = item_entries
|
|
1486
|
+
@index = index
|
|
1487
|
+
@depth = depth
|
|
1488
|
+
@current_level = current_level
|
|
1489
|
+
end
|
|
1490
|
+
|
|
1491
|
+
def format
|
|
1492
|
+
at_current_level? ? branch_segment : continuation_segment
|
|
1493
|
+
end
|
|
1494
|
+
|
|
1495
|
+
private
|
|
1496
|
+
|
|
1497
|
+
def at_current_level?
|
|
1498
|
+
@depth == @current_level
|
|
1499
|
+
end
|
|
1500
|
+
|
|
1501
|
+
def branch_segment
|
|
1502
|
+
TreeAnalyzer.last_child?(@item_entries, @index) ? '└─' : '├─'
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
def continuation_segment
|
|
1506
|
+
TreeAnalyzer.ancestor_continues?(@item_entries, @index, @depth) ? '│ ' : ' '
|
|
1507
|
+
end
|
|
1508
|
+
end
|
|
1509
|
+
|
|
1510
|
+
# Analyzes tree structure relationships
|
|
1511
|
+
class TreeAnalyzer
|
|
1512
|
+
def self.last_child?(item_entries, index)
|
|
1513
|
+
analyzer = SiblingAnalyzer.new(item_entries, index)
|
|
1514
|
+
analyzer.last_child?
|
|
1515
|
+
end
|
|
1516
|
+
|
|
1517
|
+
def self.ancestor_continues?(item_entries, index, depth)
|
|
1518
|
+
analyzer = AncestorContinuationAnalyzer.new(item_entries, index, depth)
|
|
1519
|
+
analyzer.continues?
|
|
1520
|
+
end
|
|
1521
|
+
end
|
|
1522
|
+
|
|
1523
|
+
# Analyzes sibling relationships
|
|
1524
|
+
class SiblingAnalyzer
|
|
1525
|
+
def initialize(item_entries, index)
|
|
1526
|
+
@item_entries = item_entries
|
|
1527
|
+
@index = index
|
|
1528
|
+
@current_level = item_entries[index].level
|
|
1529
|
+
end
|
|
1530
|
+
|
|
1531
|
+
def last_child?
|
|
1532
|
+
(@index + 1).upto(@item_entries.length - 1) do |next_index|
|
|
1533
|
+
next_level = @item_entries[next_index].level
|
|
1534
|
+
return false if next_level == @current_level
|
|
1535
|
+
return true if next_level < @current_level
|
|
1536
|
+
end
|
|
1537
|
+
|
|
1538
|
+
true
|
|
1539
|
+
end
|
|
1540
|
+
end
|
|
1541
|
+
|
|
1542
|
+
# Analyzes ancestor continuation
|
|
1543
|
+
class AncestorContinuationAnalyzer
|
|
1544
|
+
def initialize(item_entries, index, depth)
|
|
1545
|
+
@item_entries = item_entries
|
|
1546
|
+
@index = index
|
|
1547
|
+
@depth = depth
|
|
1548
|
+
end
|
|
1549
|
+
|
|
1550
|
+
def continues?
|
|
1551
|
+
(@index + 1).upto(@item_entries.length - 1) do |next_index|
|
|
1552
|
+
next_level = @item_entries[next_index].level
|
|
1553
|
+
return true if next_level == @depth
|
|
1554
|
+
return false if next_level < @depth
|
|
1555
|
+
end
|
|
1556
|
+
|
|
1557
|
+
false
|
|
1558
|
+
end
|
|
1559
|
+
end
|
|
1560
|
+
|
|
1561
|
+
# Provides hierarchy helpers for TOC entries
|
|
1562
|
+
class EntryHierarchy
|
|
1563
|
+
def self.children?(entries, index)
|
|
1564
|
+
next_entry = entries[index + 1]
|
|
1565
|
+
return false unless next_entry
|
|
1566
|
+
|
|
1567
|
+
next_entry.level > entries[index].level
|
|
1568
|
+
end
|
|
1569
|
+
end
|
|
1570
|
+
|
|
1571
|
+
# Selects appropriate icon for entry
|
|
1572
|
+
class IconSelector
|
|
1573
|
+
def self.select(full_entries, _entry, full_index, collapsed_set:, filter_active:)
|
|
1574
|
+
return ' ' unless EntryHierarchy.children?(full_entries, full_index)
|
|
1575
|
+
|
|
1576
|
+
collapsed = !filter_active && collapsed_set.include?(full_index)
|
|
1577
|
+
collapsed ? '▶' : '▼'
|
|
1578
|
+
end
|
|
1579
|
+
end
|
|
1580
|
+
|
|
1581
|
+
# Provides styling colors for entries
|
|
1582
|
+
class EntryStyler
|
|
1583
|
+
include Adapters::Output::Ui::Constants::UI
|
|
1584
|
+
|
|
1585
|
+
def self.icon_color(entry)
|
|
1586
|
+
ICON_COLORS[entry.level] || COLOR_TEXT_DIM
|
|
1587
|
+
end
|
|
1588
|
+
|
|
1589
|
+
def self.title_color(entry)
|
|
1590
|
+
TITLE_COLORS[entry.level] || COLOR_TEXT_SECONDARY
|
|
1591
|
+
end
|
|
1592
|
+
|
|
1593
|
+
ICON_COLORS = {
|
|
1594
|
+
0 => COLOR_TEXT_ACCENT,
|
|
1595
|
+
1 => COLOR_TEXT_SECONDARY,
|
|
1596
|
+
}.freeze
|
|
1597
|
+
|
|
1598
|
+
TITLE_COLORS = {
|
|
1599
|
+
0 => "#{Terminal::ANSI::BOLD}#{COLOR_TEXT_PRIMARY}",
|
|
1600
|
+
1 => COLOR_TEXT_PRIMARY,
|
|
1601
|
+
}.freeze
|
|
1602
|
+
end
|
|
1603
|
+
|
|
1604
|
+
end
|
|
1605
|
+
end
|
|
1606
|
+
end
|