diff --git a/configurator/README.md b/configurator/README.md index 09d4a3e5..c9561122 100644 --- a/configurator/README.md +++ b/configurator/README.md @@ -25,6 +25,7 @@ The window loads the current config, lets you tweak values across the tabbed sec - **Reload** – re-read `config.toml` from disk. - **Defaults** – drop in the built-in defaults without saving. - **Save** – validate inputs (including numeric ranges and color arrays) and write the TOML file. An existing file is backed up with a timestamp. +- **Search** – filter tabs, sections, saved sessions, boards, render profiles, presets, and keybindings as you type. Press `Ctrl+F` to focus search and `Escape` to clear it. - Launch from the main overlay with the default `F11` keybinding (configurable inside the app). ## UI Coverage diff --git a/configurator/src/app/entry.rs b/configurator/src/app/entry.rs index 881dd2a9..e70c1df4 100644 --- a/configurator/src/app/entry.rs +++ b/configurator/src/app/entry.rs @@ -23,6 +23,7 @@ pub fn run() -> iced::Result { ConfiguratorApp::view, ) .title("Wayscriber Configurator (Iced)") + .subscription(ConfiguratorApp::subscription) .theme(iced::Theme::Dark) .settings(settings) .window(window) diff --git a/configurator/src/app/mod.rs b/configurator/src/app/mod.rs index 26ae2eec..762edbe2 100644 --- a/configurator/src/app/mod.rs +++ b/configurator/src/app/mod.rs @@ -1,8 +1,11 @@ mod daemon_setup; mod entry; mod io; +pub(crate) mod scroll; +mod search; mod session_catalog; mod state; +mod subscription; mod update; mod view; diff --git a/configurator/src/app/scroll.rs b/configurator/src/app/scroll.rs new file mode 100644 index 00000000..5d3f9f36 --- /dev/null +++ b/configurator/src/app/scroll.rs @@ -0,0 +1,196 @@ +use iced::Task; +use iced::keyboard::{self, Key, key}; +use iced::widget::operation::{self, AbsoluteOffset}; + +use crate::messages::Message; + +pub(crate) const CONTENT_SCROLL_ID: &str = "configurator-content-scroll"; + +const LINE_SCROLL_DELTA_Y: f32 = 64.0; +const PAGE_SCROLL_DELTA_Y: f32 = 360.0; +const EDGE_SCROLL_DELTA_Y: f32 = 1_000_000.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ContentScrollAction { + Top, + Bottom, + LineUp, + LineDown, + PageUp, + PageDown, +} + +impl ContentScrollAction { + pub(crate) fn can_scroll_when_captured(self) -> bool { + matches!(self, ContentScrollAction::Top | ContentScrollAction::Bottom) + } + + pub(crate) fn task(self) -> Task { + match self { + ContentScrollAction::Top => scroll_by_y(-EDGE_SCROLL_DELTA_Y), + ContentScrollAction::Bottom => scroll_by_y(EDGE_SCROLL_DELTA_Y), + ContentScrollAction::LineUp => scroll_by_y(-LINE_SCROLL_DELTA_Y), + ContentScrollAction::LineDown => scroll_by_y(LINE_SCROLL_DELTA_Y), + ContentScrollAction::PageUp => scroll_by_y(-PAGE_SCROLL_DELTA_Y), + ContentScrollAction::PageDown => scroll_by_y(PAGE_SCROLL_DELTA_Y), + } + } +} + +pub(crate) fn content_scroll_action_for_event( + event: &keyboard::Event, +) -> Option { + let keyboard::Event::KeyPressed { + key, + physical_key, + modifiers, + .. + } = event + else { + return None; + }; + + if !modifiers.is_empty() { + return None; + } + + content_scroll_action_for_key(key.as_ref()) + .or_else(|| content_scroll_action_for_physical_key(*physical_key)) +} + +fn content_scroll_action_for_key(key: Key<&str>) -> Option { + match key { + Key::Named(key::Named::Home) => Some(ContentScrollAction::Top), + Key::Named(key::Named::End) => Some(ContentScrollAction::Bottom), + Key::Named(key::Named::ArrowUp) => Some(ContentScrollAction::LineUp), + Key::Named(key::Named::ArrowDown) => Some(ContentScrollAction::LineDown), + Key::Named(key::Named::PageUp) => Some(ContentScrollAction::PageUp), + Key::Named(key::Named::PageDown) => Some(ContentScrollAction::PageDown), + _ => None, + } +} + +fn content_scroll_action_for_physical_key( + physical_key: key::Physical, +) -> Option { + match physical_key { + key::Physical::Code(key::Code::Home) => Some(ContentScrollAction::Top), + key::Physical::Code(key::Code::End) => Some(ContentScrollAction::Bottom), + key::Physical::Code(key::Code::ArrowUp) => Some(ContentScrollAction::LineUp), + key::Physical::Code(key::Code::ArrowDown) => Some(ContentScrollAction::LineDown), + key::Physical::Code(key::Code::PageUp) => Some(ContentScrollAction::PageUp), + key::Physical::Code(key::Code::PageDown) => Some(ContentScrollAction::PageDown), + _ => None, + } +} + +fn scroll_by_y(y: f32) -> Task { + operation::scroll_by(CONTENT_SCROLL_ID, AbsoluteOffset { x: 0.0, y }) +} + +#[cfg(test)] +mod tests { + use super::*; + use iced::keyboard::{Location, Modifiers, key}; + + fn key_press(key: Key) -> keyboard::Event { + keyboard::Event::KeyPressed { + key: key.clone(), + modified_key: key, + physical_key: key::Physical::Unidentified(key::NativeCode::Unidentified), + location: Location::Standard, + modifiers: Modifiers::empty(), + text: None, + repeat: false, + } + } + + fn physical_key_press(physical_key: key::Physical) -> keyboard::Event { + keyboard::Event::KeyPressed { + key: Key::Unidentified, + modified_key: Key::Unidentified, + physical_key, + location: Location::Standard, + modifiers: Modifiers::empty(), + text: None, + repeat: false, + } + } + + #[test] + fn navigation_keys_map_to_content_scroll_actions() { + let cases = [ + (Key::Named(key::Named::Home), ContentScrollAction::Top), + (Key::Named(key::Named::End), ContentScrollAction::Bottom), + (Key::Named(key::Named::ArrowUp), ContentScrollAction::LineUp), + ( + Key::Named(key::Named::ArrowDown), + ContentScrollAction::LineDown, + ), + (Key::Named(key::Named::PageUp), ContentScrollAction::PageUp), + ( + Key::Named(key::Named::PageDown), + ContentScrollAction::PageDown, + ), + ]; + + for (key, expected) in cases { + assert_eq!( + content_scroll_action_for_event(&key_press(key)), + Some(expected) + ); + } + } + + #[test] + fn modified_navigation_keys_do_not_scroll_content() { + let event = keyboard::Event::KeyPressed { + key: Key::Named(key::Named::Home), + modified_key: Key::Named(key::Named::Home), + physical_key: key::Physical::Code(key::Code::Home), + location: Location::Standard, + modifiers: Modifiers::CTRL, + text: None, + repeat: false, + }; + + assert_eq!(content_scroll_action_for_event(&event), None); + } + + #[test] + fn physical_navigation_keys_map_to_content_scroll_actions() { + let cases = [ + ( + key::Physical::Code(key::Code::Home), + ContentScrollAction::Top, + ), + ( + key::Physical::Code(key::Code::End), + ContentScrollAction::Bottom, + ), + ( + key::Physical::Code(key::Code::ArrowUp), + ContentScrollAction::LineUp, + ), + ( + key::Physical::Code(key::Code::ArrowDown), + ContentScrollAction::LineDown, + ), + ( + key::Physical::Code(key::Code::PageUp), + ContentScrollAction::PageUp, + ), + ( + key::Physical::Code(key::Code::PageDown), + ContentScrollAction::PageDown, + ), + ]; + + for (physical_key, expected) in cases { + assert_eq!( + content_scroll_action_for_event(&physical_key_press(physical_key)), + Some(expected) + ); + } + } +} diff --git a/configurator/src/app/search/mod.rs b/configurator/src/app/search/mod.rs new file mode 100644 index 00000000..13f50614 --- /dev/null +++ b/configurator/src/app/search/mod.rs @@ -0,0 +1,155 @@ +mod summary; +mod terms; +#[cfg(test)] +mod tests; +mod types; + +use iced::keyboard::{self, Key, key}; +use iced::{Task, event}; + +use crate::messages::Message; +use crate::models::{SearchQuery, TabId}; + +use super::scroll; +use super::state::ConfiguratorApp; + +pub(crate) use types::{AppSearchSummary, SearchArea, TabSearchSummary}; + +pub(crate) const SEARCH_INPUT_ID: &str = "configurator-search-input"; + +impl ConfiguratorApp { + pub(crate) fn search_summary(&self) -> AppSearchSummary { + summary::build_search_summary(self) + } + + pub(crate) fn align_active_tabs_for_search(&mut self) { + let search = self.search_summary(); + if !search.is_active() { + return; + } + + if let Some(tab) = search.active_tab_or_first(self.active_tab) { + self.active_tab = tab; + } + + match self.active_tab { + TabId::Ui => self.align_active_ui_tab_for_search(&search), + TabId::Keybindings => self.align_active_keybindings_tab_for_search(&search), + _ => {} + } + } + + fn align_active_ui_tab_for_search(&mut self, search: &AppSearchSummary) { + let Some(tab) = search.tab(TabId::Ui) else { + return; + }; + if tab.ui_tab_visible(self.active_ui_tab) { + return; + } + if let Some(first) = tab.ui_tabs().first().copied() { + self.active_ui_tab = first; + } + } + + fn align_active_keybindings_tab_for_search(&mut self, search: &AppSearchSummary) { + let Some(tab) = search.tab(TabId::Keybindings) else { + return; + }; + if tab.keybindings_tab_visible(self.active_keybindings_tab) { + return; + } + if let Some(first) = tab.keybinding_tabs().first().copied() { + self.active_keybindings_tab = first; + } + } + + pub(super) fn handle_search_changed(&mut self, value: String) -> Task { + self.search_input_focus_hint = true; + self.search_query = SearchQuery::new(value); + self.align_active_tabs_for_search(); + Task::none() + } + + pub(super) fn handle_search_cleared(&mut self) -> Task { + self.search_query = SearchQuery::default(); + Task::none() + } + + pub(super) fn handle_search_focus_requested(&mut self) -> Task { + self.search_input_focus_hint = true; + iced::widget::operation::focus(SEARCH_INPUT_ID) + } + + pub(super) fn handle_startup_search_focus_config_fallback(&mut self) -> Task { + if !self.startup_search_focus_pending { + return Task::none(); + } + + self.startup_search_focus_pending = false; + self.handle_search_focus_requested() + } + + pub(super) fn handle_search_focus_observed(&mut self, is_focused: bool) -> Task { + self.search_input_focus_hint = is_focused; + Task::none() + } + + pub(super) fn handle_pointer_pressed(&mut self) -> Task { + self.cancel_startup_search_focus(); + self.observe_search_focus() + } + + pub(super) fn handle_keyboard_event( + &mut self, + event: keyboard::Event, + status: event::Status, + ) -> Task { + let keyboard::Event::KeyPressed { key, modifiers, .. } = &event else { + return Task::none(); + }; + + match key.as_ref() { + Key::Character("f") | Key::Character("F") if modifiers.command() => { + self.handle_search_focus_requested() + } + Key::Named(key::Named::Escape) if self.search_query.has_raw_input() => { + let should_refocus_search = self.search_input_focus_hint; + self.search_query = SearchQuery::default(); + if should_refocus_search { + self.handle_search_focus_requested() + } else { + Task::none() + } + } + Key::Named(key::Named::Tab) => { + self.cancel_startup_search_focus(); + self.observe_search_focus() + } + _ => content_scroll_action_for_status(&event, status, self.search_input_focus_hint) + .map_or_else(Task::none, scroll::ContentScrollAction::task), + } + } + + fn cancel_startup_search_focus(&mut self) { + self.startup_search_focus_pending = false; + } + + fn observe_search_focus(&self) -> Task { + iced::widget::operation::is_focused(SEARCH_INPUT_ID).map(Message::SearchFocusObserved) + } +} + +fn content_scroll_action_for_status( + event: &keyboard::Event, + status: event::Status, + allow_captured_edges: bool, +) -> Option { + let action = scroll::content_scroll_action_for_event(event)?; + if status == event::Status::Ignored + || (allow_captured_edges && action.can_scroll_when_captured()) + { + Some(action) + } else { + None + } +} diff --git a/configurator/src/app/search/summary.rs b/configurator/src/app/search/summary.rs new file mode 100644 index 00000000..98b66fdc --- /dev/null +++ b/configurator/src/app/search/summary.rs @@ -0,0 +1,390 @@ +use crate::app::state::ConfiguratorApp; +use crate::models::{KeybindingsTabId, SearchQuery, TabId, UiTabId}; + +use super::terms::*; +use super::types::{AppSearchSummary, SearchArea, TabSearchSummary}; + +pub(super) fn build_search_summary(app: &ConfiguratorApp) -> AppSearchSummary { + if !app.search_query.is_active() { + return AppSearchSummary::inactive(app.search_query.clone()); + } + + let tabs = TabId::ALL + .iter() + .filter_map(|tab| tab_summary(app, *tab)) + .collect(); + + AppSearchSummary { + query: app.search_query.clone(), + tabs, + } +} + +fn tab_summary(app: &ConfiguratorApp, tab: TabId) -> Option { + let query = &app.search_query; + let direct_title_match = query.matches_text(tab.title()); + let alias_match = tab_aliases(tab) + .iter() + .any(|alias| query.matches_text(alias)); + let mut summary = TabSearchSummary::new(tab, direct_title_match, alias_match); + + if !summary.show_all() { + match tab { + TabId::Drawing => drawing_matches(query, &mut summary), + TabId::Presets => preset_matches(app, query, &mut summary), + TabId::Arrow => add_area_if(query, &mut summary, tab, SearchArea::Arrow, ARROW_TERMS), + TabId::History => history_matches(query, &mut summary), + TabId::Performance => performance_matches(query, &mut summary), + TabId::Ui => ui_matches(query, &mut summary), + TabId::Boards => board_matches(app, query, &mut summary), + TabId::RenderProfiles => render_profile_matches(app, query, &mut summary), + TabId::Capture => capture_matches(query, &mut summary), + TabId::Daemon => daemon_matches(query, &mut summary), + TabId::Session => session_matches(app, query, &mut summary), + TabId::Keybindings => keybinding_matches(app, query, &mut summary), + #[cfg(feature = "tablet-input")] + TabId::Tablet => { + add_area_if(query, &mut summary, tab, SearchArea::Tablet, TABLET_TERMS) + } + } + } + + summary.has_content().then_some(summary) +} + +fn drawing_matches(query: &SearchQuery, summary: &mut TabSearchSummary) { + add_area_if( + query, + summary, + TabId::Drawing, + SearchArea::DrawingColor, + DRAWING_COLOR_TERMS, + ); + add_area_if( + query, + summary, + TabId::Drawing, + SearchArea::DrawingDefaults, + DRAWING_DEFAULT_TERMS, + ); + add_area_if( + query, + summary, + TabId::Drawing, + SearchArea::DrawingDragTools, + DRAWING_DRAG_TERMS, + ); + add_area_if( + query, + summary, + TabId::Drawing, + SearchArea::DrawingFont, + DRAWING_FONT_TERMS, + ); +} + +fn preset_matches(app: &ConfiguratorApp, query: &SearchQuery, summary: &mut TabSearchSummary) { + add_area_if( + query, + summary, + TabId::Presets, + SearchArea::PresetControls, + PRESET_CONTROL_TERMS, + ); + for slot_index in 1..=app.draft.presets.slot_count { + let Some(slot) = app.draft.presets.slot(slot_index) else { + continue; + }; + let mut text = format!("preset slot {slot_index} settings enabled"); + if slot.enabled { + text.push_str(&format!( + " label tool color named color rgb color fill enabled text background show status bar arrow head at end {} {} {} {} {} {} {} {} {} {} {} {} {:?} {:?} {:?}", + slot.name, + slot.tool.label(), + slot.color.summary(), + slot.color.rgb.join(" "), + slot.color.name, + slot.size, + slot.marker_opacity, + slot.font_size, + slot.eraser_kind.label(), + slot.eraser_mode.label(), + slot.arrow_length, + slot.arrow_angle, + slot.fill_enabled, + slot.text_background_enabled, + slot.arrow_head_at_end, + )); + } else { + text.push_str(" slot disabled enable to configure"); + } + if query.matches_text(&text) { + summary.add_preset_slot(slot_index); + } + } +} + +fn history_matches(query: &SearchQuery, summary: &mut TabSearchSummary) { + add_area_if( + query, + summary, + TabId::History, + SearchArea::HistoryMain, + HISTORY_MAIN_TERMS, + ); + add_area_if( + query, + summary, + TabId::History, + SearchArea::HistoryCustom, + HISTORY_CUSTOM_TERMS, + ); +} + +fn performance_matches(query: &SearchQuery, summary: &mut TabSearchSummary) { + add_area_if( + query, + summary, + TabId::Performance, + SearchArea::PerformanceRendering, + PERFORMANCE_RENDERING_TERMS, + ); + add_area_if( + query, + summary, + TabId::Performance, + SearchArea::PerformanceAnimations, + PERFORMANCE_ANIMATION_TERMS, + ); +} + +fn ui_matches(query: &SearchQuery, summary: &mut TabSearchSummary) { + add_area_if( + query, + summary, + TabId::Ui, + SearchArea::UiGeneral, + UI_GENERAL_TERMS, + ); + for tab in UiTabId::ALL { + let identity_parts = std::iter::once(tab.title()) + .chain(ui_tab_aliases(tab).iter().copied()) + .collect::>(); + let field_terms = ui_tab_terms(tab); + let full_parts = identity_parts + .iter() + .copied() + .chain(field_terms.iter().copied()) + .collect::>(); + if query.matches_parts_scoped_to_tab(TabId::Ui, identity_parts.iter().copied()) + || query.matches_parts(full_parts.iter().copied()) + || (query.matches_any_raw_text(field_terms) + && query.matches_parts_scoped_to_tab(TabId::Ui, full_parts.iter().copied())) + { + summary.add_ui_tab(tab); + } + } +} + +fn board_matches(app: &ConfiguratorApp, query: &SearchQuery, summary: &mut TabSearchSummary) { + add_area_if( + query, + summary, + TabId::Boards, + SearchArea::BoardsGeneral, + BOARD_GENERAL_TERMS, + ); + for (index, item) in app.draft.boards.items.iter().enumerate() { + let text = format!( + "board {} board id display name background background color override default pen color pen color auto-adjust pen auto adjust pen persist pinned duplicate remove up down collapse expand {} {} {} background pen persist pinned auto adjust", + index + 1, + item.id, + item.name, + item.background_kind.label(), + ); + if query.matches_text(&text) { + summary.add_board_index(index); + } + } +} + +fn render_profile_matches( + app: &ConfiguratorApp, + query: &SearchQuery, + summary: &mut TabSearchSummary, +) { + add_area_if( + query, + summary, + TabId::RenderProfiles, + SearchArea::RenderProfilesGeneral, + RENDER_PROFILE_GENERAL_TERMS, + ); + for (index, profile) in app.draft.render_profiles.profiles.iter().enumerate() { + let text = format!( + "render profile {} {} {} id name duplicate delete add mapping", + index + 1, + profile.id, + profile.name + ); + if query.matches_text(&text) { + summary.add_render_profile_index(index); + } + for (mapping_index, mapping) in profile.mappings.iter().enumerate() { + let mapping_text = format!( + "mapping {} color mapping from to {} {} remove pick", + mapping_index + 1, + mapping.from, + mapping.to, + ); + if query.matches_text(&mapping_text) { + summary.add_render_profile_mapping_index(index, mapping_index); + } + } + } +} + +fn capture_matches(query: &SearchQuery, summary: &mut TabSearchSummary) { + add_area_if( + query, + summary, + TabId::Capture, + SearchArea::CaptureFiles, + CAPTURE_FILE_TERMS, + ); + if query.matches_parts(CAPTURE_PDF_TERMS.iter().copied()) + || (query.matches_any_raw_text(CAPTURE_PDF_IDENTITY_TERMS) + && query.matches_parts_scoped_to_tab(TabId::Capture, CAPTURE_PDF_TERMS.iter().copied())) + { + summary.add_area(SearchArea::CapturePdf); + } +} + +fn daemon_matches(query: &SearchQuery, summary: &mut TabSearchSummary) { + add_area_if( + query, + summary, + TabId::Daemon, + SearchArea::DaemonStatus, + DAEMON_STATUS_TERMS, + ); + add_area_if( + query, + summary, + TabId::Daemon, + SearchArea::DaemonService, + DAEMON_SERVICE_TERMS, + ); + add_area_if( + query, + summary, + TabId::Daemon, + SearchArea::DaemonShortcut, + DAEMON_SHORTCUT_TERMS, + ); + add_area_if( + query, + summary, + TabId::Daemon, + SearchArea::DaemonLightControls, + DAEMON_LIGHT_TERMS, + ); +} + +fn session_matches(app: &ConfiguratorApp, query: &SearchQuery, summary: &mut TabSearchSummary) { + add_area_if( + query, + summary, + TabId::Session, + SearchArea::SessionPersistence, + SESSION_PERSISTENCE_TERMS, + ); + let catalog_area_matched = query.matches_parts(SESSION_CATALOG_TERMS.iter().copied()); + if catalog_area_matched { + summary.add_area(SearchArea::SessionCatalog); + summary.show_all_session_catalog_items(); + } + add_area_if( + query, + summary, + TabId::Session, + SearchArea::SessionCatalog, + SESSION_CATALOG_TERMS, + ); + for item in &app.session_catalog.items { + let text = format!( + "session saved recent catalog {} {} {} {:?}", + item.display_name, item.path_label, item.created_label, item.path, + ); + if query.matches_text(&text) { + summary.add_area(SearchArea::SessionCatalog); + summary.add_session_item_id(&item.id); + } + } +} + +fn keybinding_matches(app: &ConfiguratorApp, query: &SearchQuery, summary: &mut TabSearchSummary) { + for tab in KeybindingsTabId::ALL { + if query.matches_parts_scoped_to_tab(TabId::Keybindings, [tab.title()]) { + summary.add_direct_keybinding_tab(tab); + } + } + for entry in &app.draft.keybindings.entries { + let default_value = app + .defaults + .keybindings + .value_for(entry.field) + .unwrap_or(""); + let text = format!( + "keybindings keybinding shortcut hotkey keyboard shortcut list {} {} {} {}", + entry.field.tab().title(), + entry.field.label(), + entry.field.field_key(), + entry.value, + ); + if query.matches_text(&text) || query.matches_text(default_value) { + summary.add_keybinding_field(entry.field); + } + } +} + +fn add_area_if( + query: &SearchQuery, + summary: &mut TabSearchSummary, + tab: TabId, + area: SearchArea, + terms: &[&str], +) { + if query.matches_parts_scoped_to_tab(tab, terms.iter().copied()) { + summary.add_area(area); + } +} + +trait ScopedSearchQuery { + fn matches_parts_scoped_to_tab<'a>( + &self, + tab: TabId, + parts: impl IntoIterator, + ) -> bool; + fn matches_any_raw_text(&self, values: &[&str]) -> bool; +} + +impl ScopedSearchQuery for SearchQuery { + fn matches_parts_scoped_to_tab<'a>( + &self, + tab: TabId, + parts: impl IntoIterator, + ) -> bool { + let parts = parts.into_iter().collect::>(); + self.matches_parts(std::iter::once(tab.title()).chain(parts.iter().copied())) + || tab_scope_aliases(tab).iter().any(|alias| { + self.matches_parts(std::iter::once(*alias).chain(parts.iter().copied())) + }) + } + + fn matches_any_raw_text(&self, values: &[&str]) -> bool { + values + .iter() + .any(|value| SearchQuery::new(*value).matches_text(self.raw())) + } +} diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs new file mode 100644 index 00000000..5cd56376 --- /dev/null +++ b/configurator/src/app/search/terms.rs @@ -0,0 +1,375 @@ +use crate::models::{TabId, UiTabId}; + +pub(super) fn tab_aliases(tab: TabId) -> &'static [&'static str] { + match tab { + TabId::Drawing => &["draw"], + TabId::Presets => &["preset", "slot", "tool preset"], + TabId::Arrow => &["arrowhead"], + TabId::History => &["undo history"], + TabId::Performance => &["render performance"], + TabId::Ui => &["interface"], + TabId::Boards => &["board", "page", "overlay", "whiteboard", "blackboard"], + TabId::RenderProfiles => &["render profile", "theme", "color mapping", "color profile"], + TabId::Capture => &["capture", "screenshot", "export", "clipboard"], + TabId::Daemon => &[ + "daemon", + "background", + "background mode", + "service", + "autostart", + ], + TabId::Session => &["persistence"], + TabId::Keybindings => &["shortcut", "hotkey", "keyboard"], + #[cfg(feature = "tablet-input")] + TabId::Tablet => &["stylus", "pressure"], + } +} + +pub(super) fn tab_scope_aliases(tab: TabId) -> &'static [&'static str] { + match tab { + TabId::Drawing => &["draw"], + TabId::Presets => &["preset", "slot", "tool preset"], + TabId::Arrow => &["arrowhead"], + TabId::History => &["undo history"], + TabId::Performance => &["render performance"], + TabId::Ui => &["interface"], + TabId::Boards => &["board", "page", "overlay", "whiteboard", "blackboard"], + TabId::RenderProfiles => &["render profile", "theme", "color profile"], + TabId::Capture => &["screenshot"], + TabId::Daemon => &["daemon"], + TabId::Session => &[], + TabId::Keybindings => &["shortcut", "hotkey", "keyboard"], + #[cfg(feature = "tablet-input")] + TabId::Tablet => &["stylus", "pressure"], + } +} + +pub(super) fn ui_tab_aliases(tab: UiTabId) -> &'static [&'static str] { + match tab { + UiTabId::Toolbar => &["tools", "palette", "drawer"], + UiTabId::StatusBar => &["status", "badge", "indicator"], + UiTabId::HelpOverlay => &["help", "quick help", "hints"], + UiTabId::ClickHighlight => &["click", "cursor", "highlight"], + UiTabId::PresenterMode => &["present", "presentation", "focus"], + } +} + +pub(super) fn ui_tab_terms(tab: UiTabId) -> &'static [&'static str] { + match tab { + UiTabId::Toolbar => UI_TOOLBAR_TERMS, + UiTabId::StatusBar => UI_STATUS_BAR_TERMS, + UiTabId::HelpOverlay => UI_HELP_OVERLAY_TERMS, + UiTabId::ClickHighlight => UI_CLICK_HIGHLIGHT_TERMS, + UiTabId::PresenterMode => UI_PRESENTER_MODE_TERMS, + } +} + +pub(super) const DRAWING_COLOR_TERMS: &[&str] = &[ + "color", + "pen color", + "named color", + "rgb color", + "custom color name", + "rgb", +]; +pub(super) const DRAWING_DEFAULT_TERMS: &[&str] = &[ + "drawing", + "pen", + "thickness", + "thickness px", + "font size pt", + "eraser", + "eraser size px", + "eraser mode", + "polygon", + "polygon sides", + "shape", + "marker", + "marker opacity", + "fill", + "start shapes filled", + "enable text background", + "hit test", + "hit-test tolerance px", + "hit-test threshold", + "undo stack", + "undo stack limit", +]; +pub(super) const DRAWING_DRAG_TERMS: &[&str] = &["drag", "mouse", "button", "shift", "ctrl", "tab"]; +pub(super) const DRAWING_FONT_TERMS: &[&str] = &[ + "font", + "font family", + "font weight", + "custom or numeric weight", + "font style", + "custom style", + "text", + "family", + "weight", + "style", + "size", +]; +pub(super) const PRESET_CONTROL_TERMS: &[&str] = &["preset", "visible slots", "slot count"]; +pub(super) const ARROW_TERMS: &[&str] = &[ + "arrow", + "arrowhead", + "arrow length px", + "arrow angle deg", + "place arrowhead at end of line", + "length", + "angle", +]; +pub(super) const HISTORY_MAIN_TERMS: &[&str] = &[ + "history", + "undo all delay ms", + "redo all delay ms", + "undo", + "redo", + "delay", +]; +pub(super) const HISTORY_CUSTOM_TERMS: &[&str] = &[ + "custom", + "enable custom undo redo section", + "custom section", + "custom undo delay ms", + "custom redo delay ms", + "custom undo steps", + "custom redo steps", + "undo", + "redo", + "steps", + "delay", +]; +pub(super) const PERFORMANCE_RENDERING_TERMS: &[&str] = &[ + "rendering", + "buffer", + "buffer count", + "buffer count 2 4", + "vsync", + "enable vsync", + "fps", + "max fps", + "max fps vsync off", +]; +pub(super) const PERFORMANCE_ANIMATION_TERMS: &[&str] = &["animation", "ui animation fps"]; +pub(super) const UI_GENERAL_TERMS: &[&str] = &[ + "general ui", + "preferred output", + "gnome fallback", + "use fullscreen xdg fallback", + "keep open on xdg focus loss", + "focus loss", + "enable context menu", + "show capabilities warning toast", + "capabilities warning", + "command palette toast", +]; +pub(super) const UI_TOOLBAR_TERMS: &[&str] = &[ + "toolbar", + "layout mode", + "pin top toolbar", + "pin side toolbar", + "use icon-only buttons", + "show extended colors", + "show presets", + "show actions basic", + "show zoom actions", + "show advanced actions", + "show pages section", + "show boards section", + "show step undo redo", + "always show text controls", + "show settings section", + "show delay sliders", + "show marker opacity controls", + "show tool preview bubble", + "show preset action toasts", + "force inline toolbars", + "mode overrides", + "edit mode", + "placement offsets", + "top offset x", + "top offset y", + "side offset x", + "side offset y", +]; +pub(super) const UI_STATUS_BAR_TERMS: &[&str] = &[ + "status bar", + "show status bar", + "show board label", + "show page counter", + "show overlay badge with status bar", + "show frozen badge", + "status bar position", + "status bar style", + "background rgba", + "text rgba", + "font size", + "padding", + "dot radius", +]; +pub(super) const UI_HELP_OVERLAY_TERMS: &[&str] = &[ + "help overlay", + "help overlay style", + "filter sections by enabled features", + "background rgba", + "border rgba", + "text rgba", + "font family", + "font size", + "line height", + "padding", + "border width", +]; +pub(super) const UI_CLICK_HIGHLIGHT_TERMS: &[&str] = &[ + "click highlight", + "enable click highlight", + "show ring while highlight tool is active", + "link highlight color to current pen", + "force on when entering light mode", + "radius", + "click highlight radius", + "outline thickness", + "duration ms", + "fill rgba", + "outline rgba", +]; +pub(super) const UI_PRESENTER_MODE_TERMS: &[&str] = &[ + "presenter mode", + "hide status bar", + "hide toolbars", + "hide tool preview", + "close help overlay on entry", + "force click highlights on", + "tool behavior", + "show enter exit toast", +]; +pub(super) const BOARD_GENERAL_TERMS: &[&str] = &[ + "boards", + "max boards", + "default board", + "auto-create missing boards", + "show board badge", + "persist runtime customizations", + "add board", + "auto create", + "badge", +]; +pub(super) const RENDER_PROFILE_GENERAL_TERMS: &[&str] = &[ + "render profiles", + "startup profile", + "canvas export", + "canvas export profile", + "named export profile", + "add profile", + "preview canvas", + "preview ui", +]; +pub(super) const CAPTURE_FILE_TERMS: &[&str] = &[ + "capture", + "screenshot", + "enable capture shortcuts", + "save directory", + "filename", + "filename template", + "capture filename template", + "clipboard", + "copy to clipboard", + "always exit overlay after capture", + "format", +]; +pub(super) const CAPTURE_PDF_TERMS: &[&str] = &[ + "pdf", + "export", + "filename template", + "pdf filename template", + "all boards pdf filename template", + "label", + "show pdf page labels", + "page", + "orientation", + "fit", + "font", + "transparent page background", + "custom width", + "custom height", + "content source padding", + "label position", + "label content", + "label template", + "label font family", + "label font size", + "label margin", + "label horizontal padding", + "label vertical padding", + "label text rgba", + "label solid background", + "label background rgba", +]; +pub(super) const CAPTURE_PDF_IDENTITY_TERMS: &[&str] = &["pdf", "export", "pdf export"]; +pub(super) const DAEMON_STATUS_TERMS: &[&str] = + &["status", "running", "installed", "background mode"]; +pub(super) const DAEMON_SERVICE_TERMS: &[&str] = &[ + "service", + "background mode service", + "install", + "start", + "stop", + "background", +]; +pub(super) const DAEMON_SHORTCUT_TERMS: &[&str] = + &["shortcut", "toggle", "keyboard", "super", "ctrl"]; +pub(super) const DAEMON_LIGHT_TERMS: &[&str] = &["light", "passthrough", "freeze", "background"]; +pub(super) const SESSION_PERSISTENCE_TERMS: &[&str] = &[ + "session", + "persistence", + "persist transparent mode drawings", + "persist whiteboard mode drawings", + "persist blackboard mode drawings", + "persist undo redo history", + "restore tool state on startup", + "enable autosave", + "per-output persistence", + "storage mode", + "custom directory", + "compression", + "autosave idle ms", + "autosave interval ms", + "autosave failure backoff ms", + "max shapes per frame", + "max persisted undo depth", + "max file size mb", + "auto-compress threshold kb", + "backup retention count", + "save", + "autosave", + "restore", + "storage", + "compression", + "backup", +]; +pub(super) const SESSION_CATALOG_TERMS: &[&str] = &[ + "saved sessions", + "catalog", + "recent", + "rename", + "duplicate", + "move", + "clear", +]; +#[cfg(feature = "tablet-input")] +pub(super) const TABLET_TERMS: &[&str] = &[ + "tablet", + "stylus", + "enable tablet input", + "enable pressure-to-thickness", + "auto-switch to eraser", + "min thickness", + "max thickness", + "pressure variation threshold", + "pressure thickness scale step", + "pressure thickness edit mode", + "pressure thickness entry mode", + "pressure", + "eraser", +]; diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs new file mode 100644 index 00000000..ac4efbe2 --- /dev/null +++ b/configurator/src/app/search/tests.rs @@ -0,0 +1,794 @@ +use super::*; +use iced::event::Status; +use iced::keyboard::{self, Key, Location, Modifiers, key}; +use std::path::PathBuf; + +use crate::models::session::SessionArtifactSummary; +use crate::models::{KeybindingsTabId, SearchQuery, SessionCatalogItem, TabId, UiTabId}; + +#[test] +fn active_search_tab_click_corrects_keybindings_nested_tab() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("pdf"); + app.active_tab = TabId::Keybindings; + app.active_keybindings_tab = KeybindingsTabId::General; + + app.align_active_tabs_for_search(); + + assert_eq!(app.active_keybindings_tab, KeybindingsTabId::CaptureView); +} + +#[test] +fn active_search_tab_click_corrects_ui_nested_tab() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("presenter"); + app.active_tab = TabId::Ui; + app.active_ui_tab = UiTabId::Toolbar; + + app.align_active_tabs_for_search(); + + assert_eq!(app.active_ui_tab, UiTabId::PresenterMode); +} + +#[test] +fn direct_tab_title_match_exposes_concrete_tab_content() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("keybindings"); + + let summary = app.search_summary(); + let tab = summary.tab(TabId::Keybindings).expect("keybindings match"); + + assert!(tab.show_all()); + assert_eq!(summary.total_matches(), 1); +} + +#[test] +fn alias_match_exposes_keybindings_without_empty_tab() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("shortcut"); + + let summary = app.search_summary(); + let tab = summary.tab(TabId::Keybindings).expect("shortcut alias"); + + assert!(tab.show_all()); +} + +#[test] +fn pdf_matches_capture_and_capture_view_keybindings() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("pdf"); + + let summary = app.search_summary(); + let capture = summary.tab(TabId::Capture).expect("capture match"); + let keybindings = summary.tab(TabId::Keybindings).expect("keybindings match"); + + assert!(capture.area_matches(SearchArea::CapturePdf)); + assert!(keybindings.keybindings_tab_visible(KeybindingsTabId::CaptureView)); +} + +#[test] +fn direct_nested_keybinding_title_shows_that_tabs_rows() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("capture view"); + + let summary = app.search_summary(); + let keybindings = summary.tab(TabId::Keybindings).expect("keybindings match"); + + assert!(keybindings.keybindings_tab_visible(KeybindingsTabId::CaptureView)); + assert!(keybindings.keybinding_tab_title_visible(KeybindingsTabId::CaptureView)); +} + +#[test] +fn parent_scoped_keybinding_title_shows_nested_tab() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("keybindings capture view"); + + let summary = app.search_summary(); + let keybindings = summary.tab(TabId::Keybindings).expect("keybindings match"); + + assert!(!keybindings.show_all()); + assert!(keybindings.keybindings_tab_visible(KeybindingsTabId::CaptureView)); + assert!(keybindings.keybinding_tab_title_visible(KeybindingsTabId::CaptureView)); +} + +#[test] +fn field_level_terms_do_not_force_whole_tab_visible() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("font"); + + let summary = app.search_summary(); + let drawing = summary.tab(TabId::Drawing).expect("drawing match"); + + assert!(!drawing.show_all()); + assert!(drawing.area_matches(SearchArea::DrawingFont)); +} + +#[test] +fn exact_drawing_default_labels_match_defaults_section() { + for query in ["font size pt", "eraser size px", "enable text background"] { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let drawing = summary.tab(TabId::Drawing).expect("drawing match"); + + assert!( + drawing.area_matches(SearchArea::DrawingDefaults), + "query should show Drawing Defaults: {query}", + ); + } +} + +#[test] +fn exact_drawing_color_and_font_labels_match_their_sections() { + let cases = [ + ("pen color", SearchArea::DrawingColor), + ("custom or numeric weight", SearchArea::DrawingFont), + ]; + + for (query, expected_area) in cases { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let drawing = summary.tab(TabId::Drawing).expect("drawing match"); + + assert!( + drawing.area_matches(expected_area), + "query should show Drawing section: {query}", + ); + } +} + +#[test] +fn exact_performance_rendering_labels_match_rendering_section() { + for query in ["buffer count", "enable vsync", "max fps"] { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let performance = summary.tab(TabId::Performance).expect("performance match"); + + assert!( + performance.area_matches(SearchArea::PerformanceRendering), + "query should show Performance Rendering: {query}", + ); + } +} + +#[test] +fn exact_static_section_labels_match_their_sections() { + let cases = [ + ("undo all delay", TabId::History, SearchArea::HistoryMain), + ("redo all delay", TabId::History, SearchArea::HistoryMain), + ( + "enable custom undo redo section", + TabId::History, + SearchArea::HistoryCustom, + ), + ( + "named export profile", + TabId::RenderProfiles, + SearchArea::RenderProfilesGeneral, + ), + ( + "per-output persistence", + TabId::Session, + SearchArea::SessionPersistence, + ), + ( + "max shapes per frame", + TabId::Session, + SearchArea::SessionPersistence, + ), + ( + "auto-compress threshold kb", + TabId::Session, + SearchArea::SessionPersistence, + ), + ( + "persist transparent mode drawings", + TabId::Session, + SearchArea::SessionPersistence, + ), + ( + "max file size mb", + TabId::Session, + SearchArea::SessionPersistence, + ), + ("show board badge", TabId::Boards, SearchArea::BoardsGeneral), + ( + "persist runtime customizations", + TabId::Boards, + SearchArea::BoardsGeneral, + ), + ( + "place arrowhead at end of line", + TabId::Arrow, + SearchArea::Arrow, + ), + ]; + + for (query, expected_tab, expected_area) in cases { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let tab = summary.tab(expected_tab).expect("tab match"); + + assert!( + tab.area_matches(expected_area), + "query should show expected section: {query}", + ); + } +} + +#[cfg(feature = "tablet-input")] +#[test] +fn exact_tablet_labels_match_tablet_section() { + for query in [ + "enable pressure-to-thickness", + "min thickness", + "pressure thickness scale step", + ] { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let tablet = summary.tab(TabId::Tablet).expect("tablet match"); + + assert!( + tablet.area_matches(SearchArea::Tablet), + "query should show Tablet section: {query}", + ); + } +} + +#[test] +fn ui_nested_alias_matches_concrete_nested_tab() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("toolbar"); + + let summary = app.search_summary(); + let ui = summary.tab(TabId::Ui).expect("ui match"); + + assert!(!ui.show_all()); + assert!(ui.ui_tabs().contains(&UiTabId::Toolbar)); +} + +#[test] +fn parent_scoped_ui_queries_match_concrete_nested_tabs() { + let cases = [ + ("ui toolbar", UiTabId::Toolbar), + ("ui layout mode", UiTabId::Toolbar), + ("interface presenter", UiTabId::PresenterMode), + ("interface status bar position", UiTabId::StatusBar), + ]; + + for (query, expected_tab) in cases { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let ui = summary.tab(TabId::Ui).expect("ui match"); + + assert!(!ui.show_all(), "query should not show all UI tabs: {query}"); + assert_eq!( + ui.ui_tabs(), + &[expected_tab], + "query should show concrete nested UI tab: {query}", + ); + } +} + +#[test] +fn ui_nested_visible_control_labels_match_concrete_nested_tabs() { + let cases = [ + ("layout mode", UiTabId::Toolbar), + ("status bar position", UiTabId::StatusBar), + ("click highlight radius", UiTabId::ClickHighlight), + ]; + + for (query, expected_tab) in cases { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let ui = summary.tab(TabId::Ui).expect("ui match"); + + assert!( + !ui.show_all(), + "query should not force all UI tabs: {query}" + ); + assert_eq!( + ui.ui_tabs(), + &[expected_tab], + "query should show concrete nested UI tab: {query}", + ); + } +} + +#[test] +fn dynamic_matches_preserve_original_indices() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.draft.boards.items[1].name = "Meeting board".to_string(); + app.search_query = SearchQuery::new("meeting"); + + let summary = app.search_summary(); + let boards = summary.tab(TabId::Boards).expect("board match"); + + assert_eq!(boards.board_indices(), &[1]); +} + +#[test] +fn scoped_tab_section_queries_match_target_sections() { + let cases = [ + ( + "capture pdf", + TabId::Capture, + SearchArea::CapturePdf, + &[SearchArea::CaptureFiles][..], + ), + ( + "background mode service", + TabId::Daemon, + SearchArea::DaemonService, + &[ + SearchArea::DaemonStatus, + SearchArea::DaemonShortcut, + SearchArea::DaemonLightControls, + ][..], + ), + ( + "daemon service", + TabId::Daemon, + SearchArea::DaemonService, + &[ + SearchArea::DaemonStatus, + SearchArea::DaemonShortcut, + SearchArea::DaemonLightControls, + ][..], + ), + ( + "daemon status", + TabId::Daemon, + SearchArea::DaemonStatus, + &[ + SearchArea::DaemonService, + SearchArea::DaemonShortcut, + SearchArea::DaemonLightControls, + ][..], + ), + ( + "screenshot pdf", + TabId::Capture, + SearchArea::CapturePdf, + &[SearchArea::CaptureFiles][..], + ), + ]; + + for (query, expected_tab, expected_area, hidden_areas) in cases { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let tab = summary.tab(expected_tab).expect("tab match"); + + assert!( + !tab.show_all(), + "scoped query should not show the whole tab: {query}", + ); + assert!( + tab.area_matches(expected_area), + "query should show scoped area: {query}", + ); + for hidden_area in hidden_areas { + assert!( + !tab.area_matches(*hidden_area), + "query should not show unrelated section {hidden_area:?}: {query}", + ); + } + } +} + +#[test] +fn inactive_token_search_preserves_raw_input_text() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("/"); + + let summary = app.search_summary(); + + assert!(!summary.is_active()); + assert!(summary.has_raw_input()); + assert_eq!(summary.raw_query(), "/"); + assert_eq!(summary.total_matches(), 0); +} + +#[test] +fn escape_clears_inactive_raw_search_text() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("/"); + + let _ = app.handle_keyboard_event( + keyboard::Event::KeyPressed { + key: Key::Named(key::Named::Escape), + modified_key: Key::Named(key::Named::Escape), + physical_key: key::Physical::Code(key::Code::Escape), + location: Location::Standard, + modifiers: Modifiers::empty(), + text: None, + repeat: false, + }, + Status::Captured, + ); + + assert_eq!(app.search_query.raw(), ""); +} + +#[test] +fn escape_refocus_hint_is_cleared_after_pointer_press() { + let (mut app, _task) = ConfiguratorApp::new_app(); + let _ = app.handle_search_changed("preset".to_string()); + assert!(app.search_input_focus_hint); + + let _ = app.handle_pointer_pressed(); + assert!(app.search_input_focus_hint); + + let _ = app.handle_search_focus_observed(false); + assert!(!app.search_input_focus_hint); + + let _ = app.handle_keyboard_event( + keyboard::Event::KeyPressed { + key: Key::Named(key::Named::Escape), + modified_key: Key::Named(key::Named::Escape), + physical_key: key::Physical::Code(key::Code::Escape), + location: Location::Standard, + modifiers: Modifiers::empty(), + text: None, + repeat: false, + }, + Status::Captured, + ); + + assert_eq!(app.search_query.raw(), ""); + assert!(!app.search_input_focus_hint); +} + +#[test] +fn startup_config_fallback_consumes_startup_focus_pending_state() { + let (mut app, _task) = ConfiguratorApp::new_app(); + + let _ = app.handle_startup_search_focus_config_fallback(); + + assert!(app.search_input_focus_hint); + assert!(!app.startup_search_focus_pending); +} + +#[test] +fn pointer_press_cancels_pending_startup_focus() { + let (mut app, _task) = ConfiguratorApp::new_app(); + + let _ = app.handle_pointer_pressed(); + + assert!(!app.startup_search_focus_pending); +} + +#[test] +fn startup_config_fallback_does_not_focus_after_pointer_press() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_input_focus_hint = false; + + let _ = app.handle_pointer_pressed(); + let _ = app.handle_startup_search_focus_config_fallback(); + + assert!(!app.search_input_focus_hint); + assert!(!app.startup_search_focus_pending); +} + +#[test] +fn tab_key_cancels_pending_startup_focus() { + let (mut app, _task) = ConfiguratorApp::new_app(); + + let _ = app.handle_keyboard_event( + keyboard::Event::KeyPressed { + key: Key::Named(key::Named::Tab), + modified_key: Key::Named(key::Named::Tab), + physical_key: key::Physical::Code(key::Code::Tab), + location: Location::Standard, + modifiers: Modifiers::empty(), + text: None, + repeat: false, + }, + Status::Captured, + ); + + assert!(!app.startup_search_focus_pending); +} + +#[test] +fn observed_search_focus_allows_captured_home_end() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_input_focus_hint = false; + + let _ = app.handle_search_focus_observed(true); + + assert!(app.search_input_focus_hint); + for key in [key::Named::Home, key::Named::End] { + let event = keyboard::Event::KeyPressed { + key: Key::Named(key), + modified_key: Key::Named(key), + physical_key: key::Physical::Unidentified(key::NativeCode::Unidentified), + location: Location::Standard, + modifiers: Modifiers::empty(), + text: None, + repeat: false, + }; + + assert!( + content_scroll_action_for_status(&event, Status::Captured, app.search_input_focus_hint) + .is_some() + ); + } +} + +#[test] +fn captured_home_end_scroll_only_when_search_focus_hint_is_active() { + for key in [key::Named::Home, key::Named::End] { + let event = keyboard::Event::KeyPressed { + key: Key::Named(key), + modified_key: Key::Named(key), + physical_key: key::Physical::Unidentified(key::NativeCode::Unidentified), + location: Location::Standard, + modifiers: Modifiers::empty(), + text: None, + repeat: false, + }; + + assert_eq!( + content_scroll_action_for_status(&event, Status::Captured, false), + None + ); + assert!(content_scroll_action_for_status(&event, Status::Captured, true).is_some()); + assert!(content_scroll_action_for_status(&event, Status::Ignored, false).is_some()); + } +} + +#[test] +fn board_item_static_labels_match_board_rows() { + for query in ["display name", "board id", "override default pen color"] { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let boards = summary.tab(TabId::Boards).expect("board match"); + let expected = (0..app.draft.boards.items.len()).collect::>(); + + assert_eq!( + boards.board_indices(), + expected.as_slice(), + "query should show board rows by static row label: {query}", + ); + } +} + +#[test] +fn render_profile_matches_preserve_original_indices() { + let (mut app, _task) = ConfiguratorApp::new_app(); + let profile = app.draft.render_profiles.new_profile(); + app.draft.render_profiles.profiles.push(profile); + app.draft.render_profiles.profiles[0].name = "Night colors".to_string(); + app.search_query = SearchQuery::new("night"); + + let summary = app.search_summary(); + let render = summary + .tab(TabId::RenderProfiles) + .expect("render profile match"); + + assert_eq!(render.render_profile_indices(), &[0]); +} + +#[test] +fn render_profile_label_match_keeps_profile_controls_visible() { + let (mut app, _task) = ConfiguratorApp::new_app(); + let profile = app.draft.render_profiles.new_profile(); + app.draft.render_profiles.profiles.push(profile); + app.search_query = SearchQuery::new("render profile 1"); + + let summary = app.search_summary(); + let render = summary + .tab(TabId::RenderProfiles) + .expect("render profile match"); + + assert_eq!(render.render_profile_indices(), &[0]); + assert!(render.render_profile_controls_visible(0)); + assert!(render.render_profile_mapping_indices().is_empty()); +} + +#[test] +fn session_catalog_action_match_keeps_catalog_items_visible() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.session_catalog.replace_items(vec![catalog_item("s-1")]); + app.search_query = SearchQuery::new("rename"); + + let summary = app.search_summary(); + let session = summary.tab(TabId::Session).expect("session match"); + + assert!(session.area_matches(SearchArea::SessionCatalog)); + assert!(session.session_item_visible("s-1")); +} + +#[test] +fn preset_slot_search_indexes_visible_slot_body_labels() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.draft.presets.slot_mut(1).expect("slot 1").enabled = true; + app.search_query = SearchQuery::new("arrow head at end"); + + let summary = app.search_summary(); + let presets = summary.tab(TabId::Presets).expect("preset match"); + + assert_eq!(presets.preset_slots(), &[1]); +} + +#[test] +fn render_profile_mapping_match_preserves_mapping_identity() { + let (mut app, _task) = ConfiguratorApp::new_app(); + let mut profile = app.draft.render_profiles.new_profile(); + profile.mappings[0].from = "#123456".to_string(); + app.draft.render_profiles.profiles.push(profile); + app.search_query = SearchQuery::new("#123456"); + + let summary = app.search_summary(); + let render = summary + .tab(TabId::RenderProfiles) + .expect("render profile match"); + + assert_eq!(render.render_profile_indices(), &[0]); + assert!(!render.render_profile_controls_visible(0)); + assert_eq!(render.render_profile_mapping_indices(), &[(0, 0)]); +} + +#[test] +fn exact_pdf_field_labels_match_pdf_section() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("show pdf page labels"); + + let summary = app.search_summary(); + let capture = summary.tab(TabId::Capture).expect("capture match"); + + assert!(capture.area_matches(SearchArea::CapturePdf)); +} + +#[test] +fn exact_pdf_background_field_label_matches_pdf_section() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("transparent page background"); + + let summary = app.search_summary(); + let capture = summary.tab(TabId::Capture).expect("capture match"); + + assert!(capture.area_matches(SearchArea::CapturePdf)); +} + +#[test] +fn exact_general_ui_field_labels_match_general_ui_section() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("focus loss"); + + let summary = app.search_summary(); + let ui = summary.tab(TabId::Ui).expect("ui match"); + + assert!(ui.area_matches(SearchArea::UiGeneral)); +} + +#[test] +fn exact_capture_file_labels_match_capture_file_section() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("enable capture shortcuts"); + + let summary = app.search_summary(); + let capture = summary.tab(TabId::Capture).expect("capture match"); + + assert!(capture.area_matches(SearchArea::CaptureFiles)); +} + +#[test] +fn exact_capture_filename_labels_match_capture_sections() { + let cases = [ + ( + "capture filename template", + SearchArea::CaptureFiles, + SearchArea::CapturePdf, + ), + ( + "pdf filename template", + SearchArea::CapturePdf, + SearchArea::CaptureFiles, + ), + ( + "capture pdf filename template", + SearchArea::CapturePdf, + SearchArea::CaptureFiles, + ), + ( + "capture show pdf page labels", + SearchArea::CapturePdf, + SearchArea::CaptureFiles, + ), + ]; + + for (query, expected_area, hidden_area) in cases { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let capture = summary.tab(TabId::Capture).expect("capture match"); + + assert!( + capture.area_matches(expected_area), + "query should show matching capture area: {query}", + ); + assert!( + !capture.area_matches(hidden_area), + "query should hide unrelated capture area: {query}", + ); + } +} + +#[test] +fn disabled_preset_slots_do_not_match_hidden_body_controls() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.draft.presets.slot_mut(1).expect("slot 1").enabled = false; + app.draft.presets.slot_mut(2).expect("slot 2").enabled = true; + app.search_query = SearchQuery::new("arrow head at end"); + + let summary = app.search_summary(); + let presets = summary.tab(TabId::Presets).expect("preset match"); + + assert!(!presets.preset_slots().contains(&1)); + assert!(presets.preset_slots().contains(&2)); +} + +#[test] +fn daemon_area_terms_match_individual_sections() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("light"); + + let summary = app.search_summary(); + let daemon = summary.tab(TabId::Daemon).expect("daemon match"); + + assert!(daemon.area_matches(SearchArea::DaemonLightControls)); + assert!(!daemon.area_matches(SearchArea::DaemonShortcut)); +} + +#[test] +fn exact_keybinding_input_label_matches_keybinding_rows() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("shortcut list"); + + let summary = app.search_summary(); + let keybindings = summary.tab(TabId::Keybindings).expect("keybindings match"); + + assert!(!keybindings.show_all()); + assert!(!keybindings.keybinding_tabs().is_empty()); +} + +fn catalog_item(id: &str) -> SessionCatalogItem { + SessionCatalogItem { + id: id.to_string(), + display_name: "Lecture".to_string(), + path: PathBuf::from("/tmp/lecture.wayscriber-session"), + path_label: "/tmp/lecture.wayscriber-session".to_string(), + canonical_path_label: None, + created_label: "now".to_string(), + last_opened_label: "Never".to_string(), + last_saved_label: "Never".to_string(), + artifacts: SessionArtifactSummary { + primary_exists: false, + backup_exists: false, + recovery_exists: false, + clear_marker_exists: false, + lock_exists: false, + non_lock_size_bytes: 0, + }, + } +} diff --git a/configurator/src/app/search/types.rs b/configurator/src/app/search/types.rs new file mode 100644 index 00000000..89cdbeb8 --- /dev/null +++ b/configurator/src/app/search/types.rs @@ -0,0 +1,282 @@ +use crate::models::{KeybindingField, KeybindingsTabId, SearchQuery, TabId, UiTabId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SearchArea { + DrawingColor, + DrawingDefaults, + DrawingDragTools, + DrawingFont, + PresetControls, + Arrow, + HistoryMain, + HistoryCustom, + PerformanceRendering, + PerformanceAnimations, + UiGeneral, + BoardsGeneral, + RenderProfilesGeneral, + CaptureFiles, + CapturePdf, + DaemonStatus, + DaemonService, + DaemonShortcut, + DaemonLightControls, + SessionPersistence, + SessionCatalog, + #[cfg(feature = "tablet-input")] + Tablet, +} + +#[derive(Debug, Clone)] +pub(crate) struct AppSearchSummary { + pub(super) query: SearchQuery, + pub(super) tabs: Vec, +} + +impl AppSearchSummary { + pub(super) fn inactive(query: SearchQuery) -> Self { + Self { + query, + tabs: Vec::new(), + } + } + + pub(crate) fn is_active(&self) -> bool { + self.query.is_active() + } + + pub(crate) fn raw_query(&self) -> &str { + self.query.raw() + } + + pub(crate) fn has_raw_input(&self) -> bool { + self.query.has_raw_input() + } + + pub(crate) fn total_matches(&self) -> usize { + if !self.is_active() { + return 0; + } + self.tabs.iter().map(TabSearchSummary::match_count).sum() + } + + pub(crate) fn tabs(&self) -> &[TabSearchSummary] { + &self.tabs + } + + pub(crate) fn tab(&self, tab: TabId) -> Option<&TabSearchSummary> { + self.tabs.iter().find(|summary| summary.tab == tab) + } + + pub(crate) fn tab_is_visible(&self, tab: TabId) -> bool { + !self.is_active() || self.tab(tab).is_some() + } + + pub(crate) fn active_tab_or_first(&self, preferred: TabId) -> Option { + if !self.is_active() || self.tab_is_visible(preferred) { + return Some(preferred); + } + self.tabs.first().map(|summary| summary.tab) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct TabSearchSummary { + tab: TabId, + direct_title_match: bool, + alias_match: bool, + areas: Vec, + ui_tabs: Vec, + keybinding_tabs: Vec, + direct_keybinding_tabs: Vec, + board_indices: Vec, + preset_slots: Vec, + render_profile_indices: Vec, + render_profile_control_indices: Vec, + render_profile_mapping_indices: Vec<(usize, usize)>, + session_catalog_all_items: bool, + session_item_ids: Vec, + keybinding_fields: Vec, +} + +impl TabSearchSummary { + pub(super) fn new(tab: TabId, direct_title_match: bool, alias_match: bool) -> Self { + Self { + tab, + direct_title_match, + alias_match, + areas: Vec::new(), + ui_tabs: Vec::new(), + keybinding_tabs: Vec::new(), + direct_keybinding_tabs: Vec::new(), + board_indices: Vec::new(), + preset_slots: Vec::new(), + render_profile_indices: Vec::new(), + render_profile_control_indices: Vec::new(), + render_profile_mapping_indices: Vec::new(), + session_catalog_all_items: false, + session_item_ids: Vec::new(), + keybinding_fields: Vec::new(), + } + } + + pub(crate) fn tab(&self) -> TabId { + self.tab + } + + pub(crate) fn show_all(&self) -> bool { + self.direct_title_match || self.alias_match + } + + pub(crate) fn area_matches(&self, area: SearchArea) -> bool { + self.show_all() || self.areas.contains(&area) + } + + pub(crate) fn ui_tab_visible(&self, tab: UiTabId) -> bool { + self.show_all() || self.ui_tabs.contains(&tab) + } + + pub(crate) fn keybindings_tab_visible(&self, tab: KeybindingsTabId) -> bool { + self.show_all() || self.keybinding_tabs.contains(&tab) + } + + pub(crate) fn ui_tabs(&self) -> &[UiTabId] { + &self.ui_tabs + } + + pub(crate) fn keybinding_tabs(&self) -> &[KeybindingsTabId] { + &self.keybinding_tabs + } + + pub(crate) fn keybinding_tab_title_visible(&self, tab: KeybindingsTabId) -> bool { + self.direct_keybinding_tabs.contains(&tab) + } + + pub(crate) fn board_indices(&self) -> &[usize] { + &self.board_indices + } + + pub(crate) fn preset_slots(&self) -> &[usize] { + &self.preset_slots + } + + pub(crate) fn render_profile_indices(&self) -> &[usize] { + &self.render_profile_indices + } + + pub(crate) fn render_profile_controls_visible(&self, index: usize) -> bool { + self.show_all() || self.render_profile_control_indices.contains(&index) + } + + pub(crate) fn render_profile_mapping_indices(&self) -> &[(usize, usize)] { + &self.render_profile_mapping_indices + } + + pub(crate) fn session_item_visible(&self, id: &str) -> bool { + self.show_all() + || self.session_catalog_all_items + || self.session_item_ids.iter().any(|item_id| item_id == id) + } + + pub(crate) fn keybinding_field_visible(&self, field: KeybindingField) -> bool { + self.show_all() || self.keybinding_fields.contains(&field) + } + + pub(super) fn has_content(&self) -> bool { + self.show_all() + || !self.areas.is_empty() + || !self.ui_tabs.is_empty() + || !self.keybinding_tabs.is_empty() + || !self.direct_keybinding_tabs.is_empty() + || !self.board_indices.is_empty() + || !self.preset_slots.is_empty() + || !self.render_profile_indices.is_empty() + || !self.render_profile_control_indices.is_empty() + || !self.render_profile_mapping_indices.is_empty() + || self.session_catalog_all_items + || !self.session_item_ids.is_empty() + || !self.keybinding_fields.is_empty() + } + + fn match_count(&self) -> usize { + let count = self.areas.len() + + self.ui_tabs.len() + + self.keybinding_tabs.len() + + self.direct_keybinding_tabs.len() + + self.board_indices.len() + + self.preset_slots.len() + + self.render_profile_indices.len() + + self.render_profile_mapping_indices.len() + + usize::from(self.session_catalog_all_items) + + self.session_item_ids.len() + + self.keybinding_fields.len(); + if self.show_all() && count == 0 { + 1 + } else { + count.max(1) + } + } + + pub(super) fn add_area(&mut self, area: SearchArea) { + push_unique(&mut self.areas, area); + } + + pub(super) fn add_ui_tab(&mut self, tab: UiTabId) { + push_unique(&mut self.ui_tabs, tab); + } + + pub(super) fn add_direct_keybinding_tab(&mut self, tab: KeybindingsTabId) { + self.add_keybinding_tab(tab); + push_unique(&mut self.direct_keybinding_tabs, tab); + } + + pub(super) fn add_board_index(&mut self, index: usize) { + push_unique(&mut self.board_indices, index); + } + + pub(super) fn add_preset_slot(&mut self, slot: usize) { + push_unique(&mut self.preset_slots, slot); + } + + pub(super) fn add_render_profile_index(&mut self, index: usize) { + push_unique(&mut self.render_profile_indices, index); + push_unique(&mut self.render_profile_control_indices, index); + } + + pub(super) fn add_render_profile_mapping_index( + &mut self, + profile_index: usize, + mapping_index: usize, + ) { + push_unique(&mut self.render_profile_indices, profile_index); + push_unique( + &mut self.render_profile_mapping_indices, + (profile_index, mapping_index), + ); + } + + pub(super) fn show_all_session_catalog_items(&mut self) { + self.session_catalog_all_items = true; + } + + pub(super) fn add_session_item_id(&mut self, id: &str) { + if !self.session_item_ids.iter().any(|item_id| item_id == id) { + self.session_item_ids.push(id.to_string()); + } + } + + pub(super) fn add_keybinding_field(&mut self, field: KeybindingField) { + push_unique(&mut self.keybinding_fields, field); + self.add_keybinding_tab(field.tab()); + } + + fn add_keybinding_tab(&mut self, tab: KeybindingsTabId) { + push_unique(&mut self.keybinding_tabs, tab); + } +} + +fn push_unique(values: &mut Vec, value: T) { + if !values.contains(&value) { + values.push(value); + } +} diff --git a/configurator/src/app/state.rs b/configurator/src/app/state.rs index 2605c41a..ceadd75c 100644 --- a/configurator/src/app/state.rs +++ b/configurator/src/app/state.rs @@ -7,7 +7,7 @@ use wayscriber::config::{Config, PRESET_SLOTS_MAX}; use crate::messages::Message; use crate::models::{ ColorPickerId, ConfigDraft, DaemonRuntimeStatus, DesktopEnvironment, DragMouseButton, - KeybindingsTabId, SessionCatalogState, TabId, ToolbarLayoutModeOption, UiTabId, + KeybindingsTabId, SearchQuery, SessionCatalogState, TabId, ToolbarLayoutModeOption, UiTabId, }; use super::daemon_setup::load_daemon_runtime_status; @@ -47,6 +47,9 @@ pub(crate) struct ConfiguratorApp { pub(crate) daemon_latest_status_request_id: u64, pub(crate) daemon_preserve_feedback_status_request_id: Option, pub(crate) session_catalog: SessionCatalogState, + pub(crate) search_query: SearchQuery, + pub(crate) search_input_focus_hint: bool, + pub(crate) startup_search_focus_pending: bool, } #[derive(Debug, Clone)] @@ -122,6 +125,9 @@ impl ConfiguratorApp { daemon_latest_status_request_id: 1, daemon_preserve_feedback_status_request_id: None, session_catalog: SessionCatalogState::loading(), + search_query: SearchQuery::default(), + search_input_focus_hint: true, + startup_search_focus_pending: true, }; app.sync_all_color_picker_hex(); @@ -181,6 +187,14 @@ mod tests { assert!(app.is_dirty); } + #[test] + fn new_app_starts_with_search_focus_hint() { + let (app, _cmd) = ConfiguratorApp::new_app(); + + assert!(app.search_input_focus_hint); + assert!(app.startup_search_focus_pending); + } + #[test] fn config_changed_on_disk_detects_newer_file() { let (mut app, _cmd) = ConfiguratorApp::new_app(); diff --git a/configurator/src/app/subscription.rs b/configurator/src/app/subscription.rs new file mode 100644 index 00000000..270f8539 --- /dev/null +++ b/configurator/src/app/subscription.rs @@ -0,0 +1,62 @@ +use iced::{Event, Subscription, event, mouse, touch}; + +use crate::messages::Message; + +use super::state::ConfiguratorApp; + +impl ConfiguratorApp { + pub(crate) fn subscription(&self) -> Subscription { + event::listen_with(|event, status, _window| message_for_runtime_event(event, status)) + } +} + +fn message_for_runtime_event(event: Event, status: event::Status) -> Option { + match event { + Event::Keyboard(keyboard_event) => Some(Message::KeyboardEvent(keyboard_event, status)), + Event::Mouse(mouse::Event::ButtonPressed(_)) + | Event::Touch(touch::Event::FingerPressed { .. }) => Some(Message::PointerPressed), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use iced::{Point, touch, window}; + + #[test] + fn window_focus_does_not_request_startup_search_focus() { + let message = message_for_runtime_event( + Event::Window(window::Event::Focused), + event::Status::Ignored, + ); + + assert!(message.is_none()); + } + + #[test] + fn touch_press_clears_search_focus_hint_like_mouse_press() { + let message = message_for_runtime_event( + Event::Touch(touch::Event::FingerPressed { + id: touch::Finger(1), + position: Point::ORIGIN, + }), + event::Status::Ignored, + ); + + assert!(matches!(message, Some(Message::PointerPressed))); + } + + #[test] + fn touch_move_does_not_clear_search_focus_hint() { + let message = message_for_runtime_event( + Event::Touch(touch::Event::FingerMoved { + id: touch::Finger(1), + position: Point::ORIGIN, + }), + event::Status::Ignored, + ); + + assert!(message.is_none()); + } +} diff --git a/configurator/src/app/update/config.rs b/configurator/src/app/update/config.rs index 95099f0b..0baec934 100644 --- a/configurator/src/app/update/config.rs +++ b/configurator/src/app/update/config.rs @@ -39,7 +39,7 @@ impl ConfiguratorApp { } } - Task::none() + self.handle_startup_search_focus_config_fallback() } pub(super) fn handle_reload_requested(&mut self) -> Task { @@ -198,6 +198,21 @@ mod tests { )); } + #[test] + fn handle_config_loaded_uses_startup_search_focus_fallback_once() { + let (mut app, _cmd) = ConfiguratorApp::new_app(); + + let _ = app.handle_config_loaded(Ok(Arc::new(Config::default()))); + + assert!(app.search_input_focus_hint); + assert!(!app.startup_search_focus_pending); + + app.search_input_focus_hint = false; + let _ = app.handle_config_loaded(Ok(Arc::new(Config::default()))); + + assert!(!app.search_input_focus_hint); + } + #[test] fn handle_config_loaded_error_updates_status() { let (mut app, _cmd) = ConfiguratorApp::new_app(); diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index 5670eb81..b9cb78f3 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -70,6 +70,14 @@ impl ConfiguratorApp { Message::SessionCatalogActionCompleted(result) => { self.handle_session_catalog_action_completed(result) } + Message::SearchChanged(value) => self.handle_search_changed(value), + Message::SearchCleared => self.handle_search_cleared(), + Message::SearchFocusRequested => self.handle_search_focus_requested(), + Message::SearchFocusObserved(is_focused) => { + self.handle_search_focus_observed(is_focused) + } + Message::KeyboardEvent(event, status) => self.handle_keyboard_event(event, status), + Message::PointerPressed => self.handle_pointer_pressed(), Message::TabSelected(tab) => self.handle_tab_selected(tab), Message::UiTabSelected(tab) => self.handle_ui_tab_selected(tab), Message::KeybindingsTabSelected(tab) => self.handle_keybindings_tab_selected(tab), diff --git a/configurator/src/app/update/tabs.rs b/configurator/src/app/update/tabs.rs index b8e497b7..c14245af 100644 --- a/configurator/src/app/update/tabs.rs +++ b/configurator/src/app/update/tabs.rs @@ -8,11 +8,13 @@ use super::super::state::ConfiguratorApp; impl ConfiguratorApp { pub(super) fn handle_tab_selected(&mut self, tab: TabId) -> Task { self.active_tab = tab; + self.align_active_tabs_for_search(); Task::none() } pub(super) fn handle_ui_tab_selected(&mut self, tab: UiTabId) -> Task { self.active_ui_tab = tab; + self.align_active_tabs_for_search(); Task::none() } @@ -21,6 +23,7 @@ impl ConfiguratorApp { tab: KeybindingsTabId, ) -> Task { self.active_keybindings_tab = tab; + self.align_active_tabs_for_search(); Task::none() } diff --git a/configurator/src/app/view/arrow.rs b/configurator/src/app/view/arrow.rs index 9d58b7bc..63313362 100644 --- a/configurator/src/app/view/arrow.rs +++ b/configurator/src/app/view/arrow.rs @@ -1,14 +1,16 @@ use iced::Element; use iced::widget::{column, row, scrollable, text}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::messages::Message; use crate::models::{TextField, ToggleField}; +use super::super::search::TabSearchSummary; use super::super::state::ConfiguratorApp; use super::widgets::{labeled_input_with_feedback, toggle_row, validate_f64_range}; impl ConfiguratorApp { - pub(super) fn arrow_tab(&self) -> Element<'_, Message> { + pub(super) fn arrow_tab(&self, _search: Option<&TabSearchSummary>) -> Element<'_, Message> { scrollable( column![ text("Arrow Settings").size(20), @@ -40,6 +42,7 @@ impl ConfiguratorApp { ] .spacing(12), ) + .id(CONTENT_SCROLL_ID) .into() } } diff --git a/configurator/src/app/view/boards/item.rs b/configurator/src/app/view/boards/item.rs index 0a43f97c..cdf95ebc 100644 --- a/configurator/src/app/view/boards/item.rs +++ b/configurator/src/app/view/boards/item.rs @@ -11,11 +11,16 @@ use super::super::super::state::ConfiguratorApp; use super::super::widgets::{ColorPickerUi, color_triplet_picker}; impl ConfiguratorApp { - pub(super) fn board_item_section(&self, index: usize) -> Element<'_, Message> { + pub(super) fn board_item_section_for_search( + &self, + index: usize, + force_expanded: bool, + ) -> Element<'_, Message> { let Some(item) = self.draft.boards.items.get(index) else { return text("Missing board").size(12).into(); }; - let is_collapsed = self.boards_collapsed.get(index).copied().unwrap_or(false); + let is_collapsed = + !force_expanded && self.boards_collapsed.get(index).copied().unwrap_or(false); let title = if item.name.trim().is_empty() { format!("Board {}", index + 1) diff --git a/configurator/src/app/view/boards/mod.rs b/configurator/src/app/view/boards/mod.rs index a7e3ba96..f424df57 100644 --- a/configurator/src/app/view/boards/mod.rs +++ b/configurator/src/app/view/boards/mod.rs @@ -4,18 +4,23 @@ use crate::app::view::theme; use iced::widget::{button, column, pick_list, row, scrollable, text}; use iced::{Element, Length}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::messages::Message; use crate::models::{TextField, ToggleField}; +use super::super::search::{SearchArea, TabSearchSummary}; use super::super::state::ConfiguratorApp; use super::widgets::{ labeled_control, labeled_input_with_feedback, toggle_row, validate_usize_min, }; impl ConfiguratorApp { - pub(super) fn boards_tab(&self) -> Element<'_, Message> { + pub(super) fn boards_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { let defaults = &self.defaults.boards; let boards = &self.draft.boards; + let show_all = search.is_none_or(TabSearchSummary::show_all); + let show_general = + search.is_none_or(|search| search.area_matches(SearchArea::BoardsGeneral)); let max_count = labeled_input_with_feedback( "Max boards", @@ -74,18 +79,19 @@ impl ConfiguratorApp { let add_button = button("Add board").on_press(Message::BoardsAddItem); - let mut column = column![ - text("Boards").size(20), - max_count, - auto_create, - show_badge, - persist_customizations, - default_board_control, - row![add_button].spacing(8), - ] - .spacing(12); - - if self.base_config.boards.is_none() { + let mut column = column![text("Boards").size(20)].spacing(12); + + if show_general || show_all { + column = column + .push(max_count) + .push(auto_create) + .push(show_badge) + .push(persist_customizations) + .push(default_board_control) + .push(row![add_button].spacing(8)); + } + + if (show_general || show_all) && self.base_config.boards.is_none() { column = column.push( text("Legacy [board] settings detected. Saving will write [boards].") .size(12) @@ -93,10 +99,19 @@ impl ConfiguratorApp { ); } - for index in 0..boards.items.len() { - column = column.push(self.board_item_section(index)); + let indices: Vec = if show_all { + (0..boards.items.len()).collect() + } else { + search + .map(TabSearchSummary::board_indices) + .unwrap_or_default() + .to_vec() + }; + + for index in indices { + column = column.push(self.board_item_section_for_search(index, !show_all)); } - scrollable(column).into() + scrollable(column).id(CONTENT_SCROLL_ID).into() } } diff --git a/configurator/src/app/view/capture.rs b/configurator/src/app/view/capture.rs index 1a794d08..2463350f 100644 --- a/configurator/src/app/view/capture.rs +++ b/configurator/src/app/view/capture.rs @@ -1,6 +1,7 @@ use iced::widget::{column, pick_list, scrollable, text}; use iced::{Element, Length}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::messages::Message; use crate::models::{ ColorPickerId, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, @@ -8,6 +9,7 @@ use crate::models::{ ToggleField, }; +use super::super::search::{SearchArea, TabSearchSummary}; use super::super::state::ConfiguratorApp; use super::widgets::{ ColorPickerUi, color_quad_picker, labeled_control, labeled_input, labeled_input_with_feedback, @@ -15,60 +17,67 @@ use super::widgets::{ }; impl ConfiguratorApp { - pub(super) fn capture_tab(&self) -> Element<'_, Message> { - scrollable( - column![ - text("Capture Settings").size(20), - toggle_row( + pub(super) fn capture_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { + let show_files = search.is_none_or(|search| search.area_matches(SearchArea::CaptureFiles)); + let show_pdf = search.is_none_or(|search| search.area_matches(SearchArea::CapturePdf)); + let mut content = column![text("Capture Settings").size(20)].spacing(12); + + if show_files { + content = content + .push(toggle_row( "Enable capture shortcuts", self.draft.capture_enabled, self.defaults.capture_enabled, ToggleField::CaptureEnabled, - ), - labeled_input( + )) + .push(labeled_input( "Save directory", &self.draft.capture_save_directory, &self.defaults.capture_save_directory, TextField::CaptureSaveDirectory, - ), - labeled_input( + )) + .push(labeled_input( "Filename template", &self.draft.capture_filename_template, &self.defaults.capture_filename_template, TextField::CaptureFilename, - ), - labeled_input( + )) + .push(labeled_input( "Format (png, jpg, ...)", &self.draft.capture_format, &self.defaults.capture_format, TextField::CaptureFormat, - ), - toggle_row( + )) + .push(toggle_row( "Copy to clipboard", self.draft.capture_copy_to_clipboard, self.defaults.capture_copy_to_clipboard, ToggleField::CaptureCopyToClipboard, - ), - toggle_row( + )) + .push(toggle_row( "Always exit overlay after capture", self.draft.capture_exit_after, self.defaults.capture_exit_after, ToggleField::CaptureExitAfter, - ), - text("PDF Export").size(20), - labeled_input( + )); + } + + if show_pdf { + content = content + .push(text("PDF Export").size(20)) + .push(labeled_input( "PDF filename template (blank = capture template)", &self.draft.export_pdf_filename_template, &self.defaults.export_pdf_filename_template, TextField::ExportPdfFilenameTemplate, - ), - labeled_input( + )) + .push(labeled_input( "All boards PDF filename template", &self.draft.export_pdf_all_boards_filename_template, &self.defaults.export_pdf_all_boards_filename_template, TextField::ExportPdfAllBoardsFilenameTemplate, - ), - labeled_control( + )) + .push(labeled_control( "Page size", pick_list( PdfPageSizeOption::list(), @@ -79,8 +88,8 @@ impl ConfiguratorApp { .into(), self.defaults.export_pdf_page_size.label().to_string(), self.draft.export_pdf_page_size != self.defaults.export_pdf_page_size, - ), - labeled_control( + )) + .push(labeled_control( "Orientation", pick_list( PdfOrientationOption::list(), @@ -91,8 +100,8 @@ impl ConfiguratorApp { .into(), self.defaults.export_pdf_orientation.label().to_string(), self.draft.export_pdf_orientation != self.defaults.export_pdf_orientation, - ), - labeled_control( + )) + .push(labeled_control( "Fit", pick_list( PdfFitModeOption::list(), @@ -103,8 +112,8 @@ impl ConfiguratorApp { .into(), self.defaults.export_pdf_fit.label().to_string(), self.draft.export_pdf_fit != self.defaults.export_pdf_fit, - ), - labeled_control( + )) + .push(labeled_control( "Transparent page background", pick_list( PdfTransparentBackgroundOption::list(), @@ -119,38 +128,38 @@ impl ConfiguratorApp { .to_string(), self.draft.export_pdf_transparent_background != self.defaults.export_pdf_transparent_background, - ), - labeled_input_with_feedback( + )) + .push(labeled_input_with_feedback( "Custom width (PDF points)", &self.draft.export_pdf_custom_width, &self.defaults.export_pdf_custom_width, TextField::ExportPdfCustomWidth, Some("Range: 1-14400"), validate_f64_range(&self.draft.export_pdf_custom_width, 1.0, 14400.0), - ), - labeled_input_with_feedback( + )) + .push(labeled_input_with_feedback( "Custom height (PDF points)", &self.draft.export_pdf_custom_height, &self.defaults.export_pdf_custom_height, TextField::ExportPdfCustomHeight, Some("Range: 1-14400"), validate_f64_range(&self.draft.export_pdf_custom_height, 1.0, 14400.0), - ), - labeled_input_with_feedback( + )) + .push(labeled_input_with_feedback( "Content source padding", &self.draft.export_pdf_content_source_padding, &self.defaults.export_pdf_content_source_padding, TextField::ExportPdfContentSourcePadding, Some("Range: 0-4096"), - validate_f64_range(&self.draft.export_pdf_content_source_padding, 0.0, 4096.0,), - ), - toggle_row( + validate_f64_range(&self.draft.export_pdf_content_source_padding, 0.0, 4096.0), + )) + .push(toggle_row( "Show PDF page labels", self.draft.export_pdf_labels_enabled, self.defaults.export_pdf_labels_enabled, ToggleField::ExportPdfLabelsEnabled, - ), - labeled_control( + )) + .push(labeled_control( "Label position", pick_list( PdfLabelPositionOption::list(), @@ -161,8 +170,8 @@ impl ConfiguratorApp { .into(), self.defaults.export_pdf_label_position.label().to_string(), self.draft.export_pdf_label_position != self.defaults.export_pdf_label_position, - ), - labeled_control( + )) + .push(labeled_control( "Label content", pick_list( PdfLabelContentModeOption::list(), @@ -173,52 +182,52 @@ impl ConfiguratorApp { .into(), self.defaults.export_pdf_label_content.label().to_string(), self.draft.export_pdf_label_content != self.defaults.export_pdf_label_content, - ), - labeled_input( + )) + .push(labeled_input( "Label template", &self.draft.export_pdf_label_template, &self.defaults.export_pdf_label_template, TextField::ExportPdfLabelTemplate, - ), - labeled_input( + )) + .push(labeled_input( "Label font family", &self.draft.export_pdf_label_font_family, &self.defaults.export_pdf_label_font_family, TextField::ExportPdfLabelFontFamily, - ), - labeled_input_with_feedback( + )) + .push(labeled_input_with_feedback( "Label font size", &self.draft.export_pdf_label_font_size, &self.defaults.export_pdf_label_font_size, TextField::ExportPdfLabelFontSize, Some("Range: 1-72"), validate_f64_range(&self.draft.export_pdf_label_font_size, 1.0, 72.0), - ), - labeled_input_with_feedback( + )) + .push(labeled_input_with_feedback( "Label margin", &self.draft.export_pdf_label_margin, &self.defaults.export_pdf_label_margin, TextField::ExportPdfLabelMargin, Some("Range: 0-240"), validate_f64_range(&self.draft.export_pdf_label_margin, 0.0, 240.0), - ), - labeled_input_with_feedback( + )) + .push(labeled_input_with_feedback( "Label horizontal padding", &self.draft.export_pdf_label_padding_x, &self.defaults.export_pdf_label_padding_x, TextField::ExportPdfLabelPaddingX, Some("Range: 0-120"), validate_f64_range(&self.draft.export_pdf_label_padding_x, 0.0, 120.0), - ), - labeled_input_with_feedback( + )) + .push(labeled_input_with_feedback( "Label vertical padding", &self.draft.export_pdf_label_padding_y, &self.defaults.export_pdf_label_padding_y, TextField::ExportPdfLabelPaddingY, Some("Range: 0-120"), validate_f64_range(&self.draft.export_pdf_label_padding_y, 0.0, 120.0), - ), - color_quad_picker( + )) + .push(color_quad_picker( "Label text RGBA (0-1)", ColorPickerUi { id: ColorPickerId::ExportPdfLabelText, @@ -235,14 +244,14 @@ impl ConfiguratorApp { &self.draft.export_pdf_label_text_color, &self.defaults.export_pdf_label_text_color, QuadField::ExportPdfLabelText, - ), - toggle_row( + )) + .push(toggle_row( "Label solid background", self.draft.export_pdf_label_background_enabled, self.defaults.export_pdf_label_background_enabled, ToggleField::ExportPdfLabelBackgroundEnabled, - ), - color_quad_picker( + )) + .push(color_quad_picker( "Label background RGBA (0-1)", ColorPickerUi { id: ColorPickerId::ExportPdfLabelBackground, @@ -260,10 +269,9 @@ impl ConfiguratorApp { &self.draft.export_pdf_label_background_color, &self.defaults.export_pdf_label_background_color, QuadField::ExportPdfLabelBackground, - ) - ] - .spacing(12), - ) - .into() + )); + } + + scrollable(content).id(CONTENT_SCROLL_ID).into() } } diff --git a/configurator/src/app/view/daemon.rs b/configurator/src/app/view/daemon.rs index 2b95aa2c..609eb79e 100644 --- a/configurator/src/app/view/daemon.rs +++ b/configurator/src/app/view/daemon.rs @@ -2,19 +2,39 @@ use crate::app::view::theme; use iced::Element; use iced::widget::{button, column, row, rule, scrollable, text, text_input}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::messages::Message; use crate::models::{DaemonAction, LightShortcutApplyCapability, ShortcutApplyCapability}; +use super::super::search::{SearchArea, TabSearchSummary}; use super::super::state::ConfiguratorApp; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DaemonSection { + Install, + Shortcut, + LightControls, + Start, + TechnicalDetails, +} + impl ConfiguratorApp { - pub(super) fn daemon_tab(&self) -> Element<'_, Message> { + pub(super) fn daemon_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { let busy = self.daemon_busy; let status_loading = self.daemon_status.is_none(); let service_installed = self .daemon_status .as_ref() .is_some_and(|status| status.service_installed); + let show_all = search.is_none_or(TabSearchSummary::show_all); + let show_status = + show_all || search.is_some_and(|search| search.area_matches(SearchArea::DaemonStatus)); + let show_service = + show_all || search.is_some_and(|search| search.area_matches(SearchArea::DaemonService)); + let show_shortcut = show_all + || search.is_some_and(|search| search.area_matches(SearchArea::DaemonShortcut)); + let show_light = show_all + || search.is_some_and(|search| search.area_matches(SearchArea::DaemonLightControls)); let mut content = column![].spacing(16); @@ -23,8 +43,9 @@ impl ConfiguratorApp { "Run wayscriber in the background and toggle it with a keyboard shortcut.", )); - // ── Overall status summary ── - content = content.push(self.daemon_overall_status(busy)); + if show_status { + content = content.push(self.daemon_overall_status(busy)); + } // ── Feedback banner ── if let Some(feedback) = self.daemon_feedback.as_deref() { @@ -48,32 +69,26 @@ impl ConfiguratorApp { .size(14) .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), ); - content = content.push(self.daemon_technical_details(busy)); - return scrollable(content).into(); + if show_status || show_service { + content = content.push(self.daemon_technical_details(busy)); + } + return scrollable(content).id(CONTENT_SCROLL_ID).into(); } - content = content.push(rule::horizontal(1)); - - // ── Step 1: Install the service ── - content = content.push(self.daemon_step_install(busy)); - content = content.push(rule::horizontal(1)); - - // ── Step 2: Set your shortcut ── - content = content.push(self.daemon_step_shortcut(busy, service_installed)); - content = content.push(rule::horizontal(1)); - - // ── Light passthrough controls ── - content = content.push(self.daemon_step_light_controls(busy, service_installed)); - content = content.push(rule::horizontal(1)); - - // ── Step 3: Start the service ── - content = content.push(self.daemon_step_start(busy, service_installed)); - content = content.push(rule::horizontal(1)); - - // ── Technical details (bottom) ── - content = content.push(self.daemon_technical_details(busy)); + for section in daemon_sections(show_status, show_service, show_shortcut, show_light) { + let body = match section { + DaemonSection::Install => self.daemon_step_install(busy), + DaemonSection::Shortcut => self.daemon_step_shortcut(busy, service_installed), + DaemonSection::LightControls => { + self.daemon_step_light_controls(busy, service_installed) + } + DaemonSection::Start => self.daemon_step_start(busy, service_installed), + DaemonSection::TechnicalDetails => self.daemon_technical_details(busy), + }; + content = content.push(rule::horizontal(1)).push(body); + } - scrollable(content).into() + scrollable(content).id(CONTENT_SCROLL_ID).into() } fn daemon_overall_status(&self, busy: bool) -> Element<'_, Message> { @@ -450,3 +465,47 @@ impl ConfiguratorApp { details.into() } } + +fn daemon_sections( + show_status: bool, + show_service: bool, + show_shortcut: bool, + show_light: bool, +) -> Vec { + let mut sections = Vec::new(); + if show_service { + sections.push(DaemonSection::Install); + } + if show_shortcut { + sections.push(DaemonSection::Shortcut); + } + if show_light { + sections.push(DaemonSection::LightControls); + } + if show_service { + sections.push(DaemonSection::Start); + } + if show_status || show_service { + sections.push(DaemonSection::TechnicalDetails); + } + sections +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn daemon_sections_keep_default_setup_order() { + assert_eq!( + daemon_sections(true, true, true, true), + vec![ + DaemonSection::Install, + DaemonSection::Shortcut, + DaemonSection::LightControls, + DaemonSection::Start, + DaemonSection::TechnicalDetails, + ], + ); + } +} diff --git a/configurator/src/app/view/drawing/mod.rs b/configurator/src/app/view/drawing/mod.rs index 33fb2c63..3555511d 100644 --- a/configurator/src/app/view/drawing/mod.rs +++ b/configurator/src/app/view/drawing/mod.rs @@ -4,6 +4,7 @@ mod font; use iced::widget::{button, column, pick_list, row, scrollable, text}; use iced::{Element, Length}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::messages::Message; use crate::models::{ DragColorOption, DragMouseButton, DragToolField, DragToolOption, EraserModeOption, TextField, @@ -13,6 +14,7 @@ use wayscriber::config::DragButtonConfig; use self::color::drawing_color_block; use self::font::font_controls; +use super::super::search::{SearchArea, TabSearchSummary}; use super::super::state::ConfiguratorApp; use super::theme; use super::widgets::{ @@ -21,124 +23,149 @@ use super::widgets::{ }; impl ConfiguratorApp { - pub(super) fn drawing_tab(&self) -> Element<'_, Message> { + pub(super) fn drawing_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { + let show_color = search.is_none_or(|search| search.area_matches(SearchArea::DrawingColor)); + let show_defaults = + search.is_none_or(|search| search.area_matches(SearchArea::DrawingDefaults)); + let show_drag = + search.is_none_or(|search| search.area_matches(SearchArea::DrawingDragTools)); + let show_font = search.is_none_or(|search| search.area_matches(SearchArea::DrawingFont)); let eraser_mode_pick = pick_list( EraserModeOption::list(), Some(self.draft.drawing_default_eraser_mode), Message::EraserModeChanged, ); - let column = column![ - text("Drawing Defaults").size(20), - drawing_color_block(self), - row![ - labeled_input_with_feedback( - "Thickness (px)", - &self.draft.drawing_default_thickness, - &self.defaults.drawing_default_thickness, - TextField::DrawingThickness, - Some("Range: 1-50 px"), - validate_f64_range(&self.draft.drawing_default_thickness, 1.0, 50.0), - ), - labeled_input_with_feedback( - "Font size (pt)", - &self.draft.drawing_default_font_size, - &self.defaults.drawing_default_font_size, - TextField::DrawingFontSize, - Some("Range: 8-72 pt"), - validate_f64_range(&self.draft.drawing_default_font_size, 8.0, 72.0), - ), - labeled_input_with_feedback( - "Polygon sides", - &self.draft.drawing_polygon_sides, - &self.defaults.drawing_polygon_sides, - TextField::DrawingPolygonSides, - Some("Range: 3-12"), - validate_usize_range(&self.draft.drawing_polygon_sides, 3, 12), + let mut column = column![text("Drawing Defaults").size(20)].spacing(12); + + if show_color { + column = column.push(drawing_color_block(self)); + } + + if show_defaults { + column = column + .push( + row![ + labeled_input_with_feedback( + "Thickness (px)", + &self.draft.drawing_default_thickness, + &self.defaults.drawing_default_thickness, + TextField::DrawingThickness, + Some("Range: 1-50 px"), + validate_f64_range(&self.draft.drawing_default_thickness, 1.0, 50.0), + ), + labeled_input_with_feedback( + "Font size (pt)", + &self.draft.drawing_default_font_size, + &self.defaults.drawing_default_font_size, + TextField::DrawingFontSize, + Some("Range: 8-72 pt"), + validate_f64_range(&self.draft.drawing_default_font_size, 8.0, 72.0), + ), + labeled_input_with_feedback( + "Polygon sides", + &self.draft.drawing_polygon_sides, + &self.defaults.drawing_polygon_sides, + TextField::DrawingPolygonSides, + Some("Range: 3-12"), + validate_usize_range(&self.draft.drawing_polygon_sides, 3, 12), + ) + ] + .spacing(12), ) - ] - .spacing(12), - row![ - labeled_input_with_feedback( - "Eraser size (px)", - &self.draft.drawing_default_eraser_size, - &self.defaults.drawing_default_eraser_size, - TextField::DrawingEraserSize, - Some("Range: 1-50 px"), - validate_f64_range(&self.draft.drawing_default_eraser_size, 1.0, 50.0), - ), - labeled_control( - "Eraser mode", - eraser_mode_pick.width(Length::Fill).into(), - self.defaults - .drawing_default_eraser_mode - .label() - .to_string(), - self.draft.drawing_default_eraser_mode - != self.defaults.drawing_default_eraser_mode, + .push( + row![ + labeled_input_with_feedback( + "Eraser size (px)", + &self.draft.drawing_default_eraser_size, + &self.defaults.drawing_default_eraser_size, + TextField::DrawingEraserSize, + Some("Range: 1-50 px"), + validate_f64_range(&self.draft.drawing_default_eraser_size, 1.0, 50.0), + ), + labeled_control( + "Eraser mode", + eraser_mode_pick.width(Length::Fill).into(), + self.defaults + .drawing_default_eraser_mode + .label() + .to_string(), + self.draft.drawing_default_eraser_mode + != self.defaults.drawing_default_eraser_mode, + ) + ] + .spacing(12), ) - ] - .spacing(12), - self.drag_mapping_block(), - row![ - labeled_input_with_feedback( - "Marker opacity (0.05-0.9)", - &self.draft.drawing_marker_opacity, - &self.defaults.drawing_marker_opacity, - TextField::DrawingMarkerOpacity, - None, - validate_f64_range(&self.draft.drawing_marker_opacity, 0.05, 0.9), - ), - labeled_input_with_feedback( - "Undo stack limit", - &self.draft.drawing_undo_stack_limit, - &self.defaults.drawing_undo_stack_limit, - TextField::DrawingUndoStackLimit, - Some("Range: 10-1000"), - validate_usize_range(&self.draft.drawing_undo_stack_limit, 10, 1000), + .push( + row![ + labeled_input_with_feedback( + "Marker opacity (0.05-0.9)", + &self.draft.drawing_marker_opacity, + &self.defaults.drawing_marker_opacity, + TextField::DrawingMarkerOpacity, + None, + validate_f64_range(&self.draft.drawing_marker_opacity, 0.05, 0.9), + ), + labeled_input_with_feedback( + "Undo stack limit", + &self.draft.drawing_undo_stack_limit, + &self.defaults.drawing_undo_stack_limit, + TextField::DrawingUndoStackLimit, + Some("Range: 10-1000"), + validate_usize_range(&self.draft.drawing_undo_stack_limit, 10, 1000), + ) + ] + .spacing(12), ) - ] - .spacing(12), - row![ - labeled_input_with_feedback( - "Hit-test tolerance (px)", - &self.draft.drawing_hit_test_tolerance, - &self.defaults.drawing_hit_test_tolerance, - TextField::DrawingHitTestTolerance, - Some("Range: 1-20 px"), - validate_f64_range(&self.draft.drawing_hit_test_tolerance, 1.0, 20.0), - ), - labeled_input_with_feedback( - "Hit-test threshold", - &self.draft.drawing_hit_test_linear_threshold, - &self.defaults.drawing_hit_test_linear_threshold, - TextField::DrawingHitTestThreshold, - Some("Minimum: 1"), - validate_usize_min(&self.draft.drawing_hit_test_linear_threshold, 1), + .push( + row![ + labeled_input_with_feedback( + "Hit-test tolerance (px)", + &self.draft.drawing_hit_test_tolerance, + &self.defaults.drawing_hit_test_tolerance, + TextField::DrawingHitTestTolerance, + Some("Range: 1-20 px"), + validate_f64_range(&self.draft.drawing_hit_test_tolerance, 1.0, 20.0), + ), + labeled_input_with_feedback( + "Hit-test threshold", + &self.draft.drawing_hit_test_linear_threshold, + &self.defaults.drawing_hit_test_linear_threshold, + TextField::DrawingHitTestThreshold, + Some("Minimum: 1"), + validate_usize_min(&self.draft.drawing_hit_test_linear_threshold, 1), + ) + ] + .spacing(12), ) - ] - .spacing(12), - font_controls(self), - toggle_row( - "Enable text background", - self.draft.drawing_text_background_enabled, - self.defaults.drawing_text_background_enabled, - ToggleField::DrawingTextBackground, - ), - toggle_row( - "Start shapes filled", - self.draft.drawing_default_fill_enabled, - self.defaults.drawing_default_fill_enabled, - ToggleField::DrawingFillEnabled, - ) - ] - .spacing(12) - .width(Length::Fill); + .push(toggle_row( + "Enable text background", + self.draft.drawing_text_background_enabled, + self.defaults.drawing_text_background_enabled, + ToggleField::DrawingTextBackground, + )) + .push(toggle_row( + "Start shapes filled", + self.draft.drawing_default_fill_enabled, + self.defaults.drawing_default_fill_enabled, + ToggleField::DrawingFillEnabled, + )); + } - scrollable(column).into() + if show_drag { + column = column.push(self.drag_mapping_block(search)); + } + + if show_font { + column = column.push(font_controls(self)); + } + + let column = column.width(Length::Fill); + + scrollable(column).id(CONTENT_SCROLL_ID).into() } - fn drag_mapping_block(&self) -> Element<'_, Message> { + fn drag_mapping_block(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { let section_button = |mouse_button: DragMouseButton| { button(mouse_button.label()) .style(if self.active_drawing_drag_button == Some(mouse_button) { @@ -160,7 +187,7 @@ impl ConfiguratorApp { ] .spacing(8); - if let Some(mouse_button) = self.active_drawing_drag_button { + for mouse_button in self.visible_drag_mapping_buttons(search) { let (current, defaults) = match mouse_button { DragMouseButton::Left => ( &self.draft.drawing_drag_tools.left, @@ -181,6 +208,23 @@ impl ConfiguratorApp { column.into() } + + fn visible_drag_mapping_buttons( + &self, + search: Option<&TabSearchSummary>, + ) -> Vec { + if search.is_some_and(|search| { + !search.show_all() && search.area_matches(SearchArea::DrawingDragTools) + }) { + return vec![ + DragMouseButton::Left, + DragMouseButton::Right, + DragMouseButton::Middle, + ]; + } + + self.active_drawing_drag_button.into_iter().collect() + } } fn drag_button_controls<'a>( @@ -270,3 +314,29 @@ fn drag_color_for_field(config: &DragButtonConfig, field: DragToolField) -> Drag DragToolField::TabDrag => DragColorOption::from_color(config.tab_drag_color.as_ref()), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{SearchQuery, TabId}; + + #[test] + fn drag_tool_search_expands_all_drag_button_mappings() { + let (mut app, _task) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("shift"); + + let search = app.search_summary(); + let drawing = search.tab(TabId::Drawing).expect("drawing match"); + + assert_eq!(app.active_drawing_drag_button, None); + assert_eq!( + app.visible_drag_mapping_buttons(Some(drawing)), + vec![ + DragMouseButton::Left, + DragMouseButton::Right, + DragMouseButton::Middle, + ], + ); + assert_eq!(app.active_drawing_drag_button, None); + } +} diff --git a/configurator/src/app/view/history.rs b/configurator/src/app/view/history.rs index 6b0de14d..e963588a 100644 --- a/configurator/src/app/view/history.rs +++ b/configurator/src/app/view/history.rs @@ -2,9 +2,11 @@ use crate::app::view::theme; use iced::Element; use iced::widget::{column, container, row, scrollable, text}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::messages::Message; use crate::models::{TextField, ToggleField}; +use super::super::search::{SearchArea, TabSearchSummary}; use super::super::state::ConfiguratorApp; use super::widgets::{ labeled_input_state, labeled_input_with_feedback, toggle_row, validate_u64_range, @@ -12,7 +14,11 @@ use super::widgets::{ }; impl ConfiguratorApp { - pub(super) fn history_tab(&self) -> Element<'_, Message> { + pub(super) fn history_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { + let show_main = search.is_none_or(|search| search.area_matches(SearchArea::HistoryMain)); + let show_custom = + search.is_none_or(|search| search.area_matches(SearchArea::HistoryCustom)); + let show_custom_toggle = custom_toggle_visible(show_main, show_custom); let custom_enabled = self.draft.history_custom_section_enabled; let custom_section = container( column![ @@ -81,9 +87,9 @@ impl ConfiguratorApp { .padding(12) .style(theme::Container::Box); - scrollable( - column![ - text("History").size(20), + let mut content = column![text("History").size(20)].spacing(12); + if show_main { + content = content.push( row![ labeled_input_with_feedback( "Undo all delay (ms)", @@ -103,16 +109,34 @@ impl ConfiguratorApp { ) ] .spacing(12), - toggle_row( - "Enable custom undo/redo section", - self.draft.history_custom_section_enabled, - self.defaults.history_custom_section_enabled, - ToggleField::HistoryCustomSectionEnabled, - ), - custom_section, - ] - .spacing(12), - ) - .into() + ); + } + if show_custom_toggle { + content = content.push(toggle_row( + "Enable custom undo/redo section", + self.draft.history_custom_section_enabled, + self.defaults.history_custom_section_enabled, + ToggleField::HistoryCustomSectionEnabled, + )); + } + if show_custom { + content = content.push(custom_section); + } + + scrollable(content).id(CONTENT_SCROLL_ID).into() + } +} + +fn custom_toggle_visible(show_main: bool, show_custom: bool) -> bool { + show_main || show_custom +} + +#[cfg(test)] +mod tests { + use super::custom_toggle_visible; + + #[test] + fn custom_search_results_keep_custom_toggle_visible() { + assert!(custom_toggle_visible(false, true)); } } diff --git a/configurator/src/app/view/keybindings.rs b/configurator/src/app/view/keybindings.rs index 3342ec9b..1173b0a1 100644 --- a/configurator/src/app/view/keybindings.rs +++ b/configurator/src/app/view/keybindings.rs @@ -3,21 +3,28 @@ use iced::alignment::Horizontal; use iced::widget::{Column, Row, button, column, container, row, scrollable, text, text_input}; use iced::{Element, Length}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::messages::Message; use crate::models::KeybindingsTabId; +use super::super::search::TabSearchSummary; use super::super::state::ConfiguratorApp; use super::widgets::{LABEL_COLUMN_WIDTH, default_value_text}; impl ConfiguratorApp { - pub(super) fn keybindings_tab(&self) -> Element<'_, Message> { - let tab_bar = KeybindingsTabId::ALL.iter().fold( + pub(super) fn keybindings_tab( + &self, + search: Option<&TabSearchSummary>, + ) -> Element<'_, Message> { + let tabs = visible_keybinding_tabs(search); + let active_tab = active_keybinding_tab(search, self.active_keybindings_tab); + let tab_bar = tabs.iter().fold( Row::new().spacing(8).align_y(iced::Alignment::Center), |row, tab| { let label = tab.title(); let button = button(label) .padding([6, 12]) - .style(if *tab == self.active_keybindings_tab { + .style(if Some(*tab) == active_tab { theme::Button::Primary } else { theme::Button::Secondary @@ -29,15 +36,18 @@ impl ConfiguratorApp { let mut column = Column::new() .spacing(8) - .push(text("Keybindings (comma-separated)").size(20)) - .push(tab_bar); + .push(text("Keybindings (comma-separated)").size(20)); + + if !tabs.is_empty() { + column = column.push(tab_bar); + } for entry in self .draft .keybindings .entries .iter() - .filter(|entry| entry.field.tab() == self.active_keybindings_tab) + .filter(|entry| keybinding_row_visible(search, active_tab, entry.field)) { let default_value = self .defaults @@ -68,6 +78,42 @@ impl ConfiguratorApp { ); } - scrollable(column).into() + scrollable(column).id(CONTENT_SCROLL_ID).into() + } +} + +fn visible_keybinding_tabs(search: Option<&TabSearchSummary>) -> Vec { + match search { + Some(summary) if !summary.show_all() => summary.keybinding_tabs().to_vec(), + _ => KeybindingsTabId::ALL.to_vec(), + } +} + +fn active_keybinding_tab( + search: Option<&TabSearchSummary>, + preferred: KeybindingsTabId, +) -> Option { + match search { + Some(summary) if summary.show_all() || summary.keybindings_tab_visible(preferred) => { + Some(preferred) + } + Some(summary) => summary.keybinding_tabs().first().copied(), + None => Some(preferred), + } +} + +fn keybinding_row_visible( + search: Option<&TabSearchSummary>, + active_tab: Option, + field: crate::models::KeybindingField, +) -> bool { + let Some(search) = search else { + return active_tab == Some(field.tab()); + }; + if search.show_all() { + return true; } + active_tab == Some(field.tab()) + && (search.keybinding_field_visible(field) + || search.keybinding_tab_title_visible(field.tab())) } diff --git a/configurator/src/app/view/mod.rs b/configurator/src/app/view/mod.rs index 983de97d..a90704d6 100644 --- a/configurator/src/app/view/mod.rs +++ b/configurator/src/app/view/mod.rs @@ -15,19 +15,23 @@ pub(crate) mod theme; mod ui; mod widgets; -use iced::widget::{Column, Row, Space, button, column, container, row, rule, text}; +use iced::widget::{Column, Row, Space, button, column, container, row, rule, text, text_input}; use iced::{Element, Length}; use crate::messages::Message; use crate::models::TabId; use self::widgets::{default_label_color, feedback_text}; +use super::search::{AppSearchSummary, SEARCH_INPUT_ID, TabSearchSummary}; use super::state::{ConfiguratorApp, StatusMessage}; +const SEARCH_INPUT_WIDTH: f32 = 324.0; + impl ConfiguratorApp { pub(crate) fn view(&self) -> Element<'_, Message> { - let header = self.header_view(); - let content = self.tab_view(); + let search = self.search_summary(); + let header = self.header_view(&search); + let content = self.tab_view(&search); let footer = self.footer_view(); column![header, content, footer] @@ -36,9 +40,9 @@ impl ConfiguratorApp { .into() } - fn header_view(&self) -> Element<'_, Message> { + fn header_view(&self, search: &AppSearchSummary) -> Element<'_, Message> { let reload_button = button("Reload") - .style(theme::Button::Subtle) + .style(theme::Button::Secondary) .on_press(Message::ReloadRequested); let defaults_button = if self.defaults_reset_pending { @@ -55,12 +59,37 @@ impl ConfiguratorApp { .style(theme::Button::Primary) .on_press(Message::SaveRequested); + let search_input = text_input("Search settings", search.raw_query()) + .id(SEARCH_INPUT_ID) + .on_input(Message::SearchChanged) + .padding(8) + .width(Length::Fixed(SEARCH_INPUT_WIDTH)); + let mut toolbar = Row::new() .spacing(12) .align_y(iced::Alignment::Center) .push(reload_button) .push(defaults_button) - .push(save_button); + .push(save_button) + .push(search_input); + + if search.has_raw_input() { + if search.is_active() { + toolbar = + toolbar.push(text(format!("{} matches", search.total_matches())).size(14)); + } + toolbar = toolbar.push( + button("Clear") + .style(theme::Button::Subtle) + .on_press(Message::SearchCleared), + ); + } else { + toolbar = toolbar.push( + button("Find") + .style(theme::Button::Subtle) + .on_press(Message::SearchFocusRequested), + ); + } if self.defaults_reset_pending { toolbar = toolbar.push( @@ -81,13 +110,14 @@ impl ConfiguratorApp { ) } else { toolbar.push( - text("All changes saved") + text("No unsaved changes") .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.8, 0.6))), ) }; let toolbar = container(toolbar) .padding([6, 8]) + .width(Length::Fill) .style(theme::Container::ActionBar); let banner: Element<'_, Message> = match &self.status { @@ -120,14 +150,16 @@ impl ConfiguratorApp { column![toolbar, banner].spacing(8).into() } - fn tab_view(&self) -> Element<'_, Message> { - let tab_bar = TabId::ALL.iter().fold( + fn tab_view(&self, search: &AppSearchSummary) -> Element<'_, Message> { + let tabs = visible_tabs(search); + let active_tab = search.active_tab_or_first(self.active_tab); + let tab_bar = tabs.iter().fold( Row::new().spacing(8).align_y(iced::Alignment::Center), |row, tab| { let label = tab.title(); let button = button(label) .padding([6, 12]) - .style(if *tab == self.active_tab { + .style(if Some(*tab) == active_tab { theme::Button::TabActive } else { theme::Button::TabInactive @@ -137,21 +169,24 @@ impl ConfiguratorApp { }, ); - let content: Element<'_, Message> = match self.active_tab { - TabId::Drawing => self.drawing_tab(), - TabId::Presets => self.presets_tab(), - TabId::Arrow => self.arrow_tab(), - TabId::History => self.history_tab(), - TabId::Performance => self.performance_tab(), - TabId::Ui => self.ui_tab(), - TabId::Boards => self.boards_tab(), - TabId::RenderProfiles => self.render_profiles_tab(), - TabId::Capture => self.capture_tab(), - TabId::Daemon => self.daemon_tab(), - TabId::Session => self.session_tab(), - TabId::Keybindings => self.keybindings_tab(), + let content: Element<'_, Message> = match active_tab { + Some(TabId::Drawing) => self.drawing_tab(search.tab(TabId::Drawing)), + Some(TabId::Presets) => self.presets_tab(search.tab(TabId::Presets)), + Some(TabId::Arrow) => self.arrow_tab(search.tab(TabId::Arrow)), + Some(TabId::History) => self.history_tab(search.tab(TabId::History)), + Some(TabId::Performance) => self.performance_tab(search.tab(TabId::Performance)), + Some(TabId::Ui) => self.ui_tab(search.tab(TabId::Ui)), + Some(TabId::Boards) => self.boards_tab(search.tab(TabId::Boards)), + Some(TabId::RenderProfiles) => { + self.render_profiles_tab(search.tab(TabId::RenderProfiles)) + } + Some(TabId::Capture) => self.capture_tab(search.tab(TabId::Capture)), + Some(TabId::Daemon) => self.daemon_tab(search.tab(TabId::Daemon)), + Some(TabId::Session) => self.session_tab(search.tab(TabId::Session)), + Some(TabId::Keybindings) => self.keybindings_tab(search.tab(TabId::Keybindings)), #[cfg(feature = "tablet-input")] - TabId::Tablet => self.tablet_tab(), + Some(TabId::Tablet) => self.tablet_tab(search.tab(TabId::Tablet)), + None => empty_search_view(), }; let legend = self.defaults_legend(); @@ -195,3 +230,24 @@ impl ConfiguratorApp { column![legend, hint].spacing(4).into() } } + +fn visible_tabs(search: &AppSearchSummary) -> Vec { + if search.is_active() { + search.tabs().iter().map(TabSearchSummary::tab).collect() + } else { + TabId::ALL.to_vec() + } +} + +fn empty_search_view<'a>() -> Element<'a, Message> { + container( + column![ + text("No settings match this search.").size(20), + text("Try a field label, tab name, shortcut, or session path.").size(14), + ] + .spacing(8), + ) + .padding(16) + .style(theme::Container::Box) + .into() +} diff --git a/configurator/src/app/view/performance.rs b/configurator/src/app/view/performance.rs index 7b3d2857..610c76c8 100644 --- a/configurator/src/app/view/performance.rs +++ b/configurator/src/app/view/performance.rs @@ -1,9 +1,11 @@ use iced::widget::{column, pick_list, row, scrollable, text}; use iced::{Element, Length}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::messages::Message; use crate::models::{TextField, ToggleField}; +use super::super::search::{SearchArea, TabSearchSummary}; use super::super::state::ConfiguratorApp; use super::widgets::{ BUFFER_PICKER_WIDTH, labeled_control, labeled_input_with_feedback, toggle_row, @@ -11,7 +13,10 @@ use super::widgets::{ }; impl ConfiguratorApp { - pub(super) fn performance_tab(&self) -> Element<'_, Message> { + pub(super) fn performance_tab( + &self, + search: Option<&TabSearchSummary>, + ) -> Element<'_, Message> { let buffer_pick = pick_list( vec![2u32, 3, 4], Some(self.draft.performance_buffer_count), @@ -25,45 +30,53 @@ impl ConfiguratorApp { .align_y(iced::Alignment::Center) .into(); - scrollable( - column![ - text("Performance").size(20), - text("Rendering").size(16), - labeled_control( + let show_rendering = + search.is_none_or(|search| search.area_matches(SearchArea::PerformanceRendering)); + let show_animation = + search.is_none_or(|search| search.area_matches(SearchArea::PerformanceAnimations)); + let mut content = column![text("Performance").size(20)].spacing(12); + + if show_rendering { + content = content + .push(text("Rendering").size(16)) + .push(labeled_control( "Buffer count (2-4)", buffer_control, self.defaults.performance_buffer_count.to_string(), self.draft.performance_buffer_count != self.defaults.performance_buffer_count, - ), - toggle_row( + )) + .push(toggle_row( "Enable VSync", self.draft.performance_enable_vsync, self.defaults.performance_enable_vsync, ToggleField::PerformanceVsync, - ), - text("Synchronizes rendering with display refresh. Prevents tearing but adds slight input latency.").size(12), - labeled_input_with_feedback( + )) + .push(text("Synchronizes rendering with display refresh. Prevents tearing but adds slight input latency.").size(12)) + .push(labeled_input_with_feedback( "Max FPS (VSync off)", &self.draft.performance_max_fps_no_vsync, &self.defaults.performance_max_fps_no_vsync, TextField::PerformanceMaxFpsNoVsync, Some("0 = unlimited, or match your monitor (60/120/144/240)"), validate_u32_range(&self.draft.performance_max_fps_no_vsync, 0, 1000), - ), - text("Caps frame rate when VSync is disabled. Prevents CPU spinning at 500+ FPS. Set to your monitor's refresh rate for best results, or 0 for unlimited (requires strong CPU).").size(12), - text("Animations").size(16), - labeled_input_with_feedback( + )) + .push(text("Caps frame rate when VSync is disabled. Prevents CPU spinning at 500+ FPS. Set to your monitor's refresh rate for best results, or 0 for unlimited (requires strong CPU).").size(12)); + } + + if show_animation { + content = content + .push(text("Animations").size(16)) + .push(labeled_input_with_feedback( "UI Animation FPS", &self.draft.performance_ui_animation_fps, &self.defaults.performance_ui_animation_fps, TextField::PerformanceUiAnimationFps, Some("0 = unlimited, recommended: 30-60"), validate_u32_range(&self.draft.performance_ui_animation_fps, 0, 1000), - ), - text("Controls how often UI animations tick (fade effects, toasts, click highlights). Higher values = smoother animations but more CPU usage. Does not affect input responsiveness.").size(12), - ] - .spacing(12), - ) - .into() + )) + .push(text("Controls how often UI animations tick (fade effects, toasts, click highlights). Higher values = smoother animations but more CPU usage. Does not affect input responsiveness.").size(12)); + } + + scrollable(content).id(CONTENT_SCROLL_ID).into() } } diff --git a/configurator/src/app/view/presets/mod.rs b/configurator/src/app/view/presets/mod.rs index eb6056f9..3077d045 100644 --- a/configurator/src/app/view/presets/mod.rs +++ b/configurator/src/app/view/presets/mod.rs @@ -2,15 +2,20 @@ use iced::widget::{Column, pick_list, scrollable, text}; use iced::{Element, Length}; use wayscriber::config::{PRESET_SLOTS_MAX, PRESET_SLOTS_MIN}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::messages::Message; +use super::super::search::{SearchArea, TabSearchSummary}; use super::super::state::ConfiguratorApp; use super::widgets::{SMALL_PICKER_WIDTH, labeled_control}; mod slot; impl ConfiguratorApp { - pub(super) fn presets_tab(&self) -> Element<'_, Message> { + pub(super) fn presets_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { + let show_all = search.is_none_or(TabSearchSummary::show_all); + let show_controls = + search.is_none_or(|search| search.area_matches(SearchArea::PresetControls)); let slot_counts: Vec = (PRESET_SLOTS_MIN..=PRESET_SLOTS_MAX).collect(); let slot_picker = pick_list( slot_counts, @@ -28,18 +33,30 @@ impl ConfiguratorApp { let mut column = Column::new() .spacing(12) - .push(text("Preset Slots").size(20)) - .push(slot_count_control); + .push(text("Preset Slots").size(20)); + + if show_controls || show_all { + column = column.push(slot_count_control); + } let slot_limit = self .draft .presets .slot_count .clamp(PRESET_SLOTS_MIN, PRESET_SLOTS_MAX); - for slot_index in 1..=slot_limit { - column = column.push(self.preset_slot_section(slot_index)); + let slots: Vec = if show_all { + (1..=slot_limit).collect() + } else { + search + .map(TabSearchSummary::preset_slots) + .unwrap_or_default() + .to_vec() + }; + + for slot_index in slots { + column = column.push(self.preset_slot_section_for_search(slot_index, !show_all)); } - scrollable(column).into() + scrollable(column).id(CONTENT_SCROLL_ID).into() } } diff --git a/configurator/src/app/view/presets/slot/mod.rs b/configurator/src/app/view/presets/slot/mod.rs index 05901ba1..bb1033c9 100644 --- a/configurator/src/app/view/presets/slot/mod.rs +++ b/configurator/src/app/view/presets/slot/mod.rs @@ -11,7 +11,11 @@ use crate::messages::Message; use crate::app::state::ConfiguratorApp; impl ConfiguratorApp { - pub(super) fn preset_slot_section(&self, slot_index: usize) -> Element<'_, Message> { + pub(super) fn preset_slot_section_for_search( + &self, + slot_index: usize, + force_expanded: bool, + ) -> Element<'_, Message> { let Some(slot) = self.draft.presets.slot(slot_index) else { return Space::new() .width(Length::Shrink) @@ -40,7 +44,8 @@ impl ConfiguratorApp { .preset_collapsed .get(slot_index.saturating_sub(1)) .copied() - .unwrap_or(false); + .unwrap_or(false) + && !force_expanded; if is_collapsed { return container(section) .padding(12) diff --git a/configurator/src/app/view/render_profiles.rs b/configurator/src/app/view/render_profiles.rs index b3add7b3..8aa01eb2 100644 --- a/configurator/src/app/view/render_profiles.rs +++ b/configurator/src/app/view/render_profiles.rs @@ -3,6 +3,7 @@ use iced::widget::{ }; use iced::{Alignment, Element, Length}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::app::view::theme; use crate::messages::Message; use crate::models::color::rgb_to_hsv; @@ -12,12 +13,19 @@ use crate::models::{ }; use wayscriber::render_profiles::parse_hex_rgb; +use super::super::search::{SearchArea, TabSearchSummary}; use super::super::state::ConfiguratorApp; use super::widgets::{color_preview_badge, labeled_control, picker_panel}; impl ConfiguratorApp { - pub(super) fn render_profiles_tab(&self) -> Element<'_, Message> { + pub(super) fn render_profiles_tab( + &self, + search: Option<&TabSearchSummary>, + ) -> Element<'_, Message> { let profiles = &self.draft.render_profiles; + let show_all = search.is_none_or(TabSearchSummary::show_all); + let show_general = + search.is_none_or(|search| search.area_matches(SearchArea::RenderProfilesGeneral)); let profile_ids = profiles.profile_ids(); let active_selection = RenderProfileSelectionOption::from_active(&profiles.active, &profile_ids); @@ -39,34 +47,37 @@ impl ConfiguratorApp { ) .width(Length::Fill); - let mut content = column![ - text("Render Profiles").size(20), - row![ - checkbox(profiles.apply_to_canvas) - .label("Preview canvas") - .on_toggle(Message::RenderProfileApplyCanvasChanged), - checkbox(profiles.apply_to_ui) - .label("Preview UI") - .on_toggle(Message::RenderProfileApplyUiChanged), - ] - .spacing(16) - .align_y(Alignment::Center), - labeled_control( - "Startup profile", - active_picker.into(), - self.defaults.render_profiles.active.clone(), - profiles.active != self.defaults.render_profiles.active, - ), - labeled_control( - "Canvas export profile", - export_picker.into(), - self.defaults.render_profiles.export.label().to_string(), - profiles.export != self.defaults.render_profiles.export, - ), - ] - .spacing(12); + let mut content = column![text("Render Profiles").size(20)].spacing(12); - if profiles.export == RenderProfileExportOption::Profile { + if show_general || show_all { + content = content + .push( + row![ + checkbox(profiles.apply_to_canvas) + .label("Preview canvas") + .on_toggle(Message::RenderProfileApplyCanvasChanged), + checkbox(profiles.apply_to_ui) + .label("Preview UI") + .on_toggle(Message::RenderProfileApplyUiChanged), + ] + .spacing(16) + .align_y(Alignment::Center), + ) + .push(labeled_control( + "Startup profile", + active_picker.into(), + self.defaults.render_profiles.active.clone(), + profiles.active != self.defaults.render_profiles.active, + )) + .push(labeled_control( + "Canvas export profile", + export_picker.into(), + self.defaults.render_profiles.export.label().to_string(), + profiles.export != self.defaults.render_profiles.export, + )); + } + + if (show_general || show_all) && profiles.export == RenderProfileExportOption::Profile { let picker = pick_list( profile_ids, export_selection, @@ -81,17 +92,35 @@ impl ConfiguratorApp { )); } - content = content.push(button("Add profile").on_press(Message::RenderProfileAdd)); + if show_general || show_all { + content = content.push(button("Add profile").on_press(Message::RenderProfileAdd)); + } + + let indices: Vec = if show_all { + (0..profiles.profiles.len()).collect() + } else { + search + .map(TabSearchSummary::render_profile_indices) + .unwrap_or_default() + .to_vec() + }; - for index in 0..profiles.profiles.len() { - content = content.push(self.render_profile_section(index)); + for index in indices { + content = content.push(self.render_profile_section(index, search)); } - scrollable(content).into() + scrollable(content).id(CONTENT_SCROLL_ID).into() } - fn render_profile_section(&self, profile_index: usize) -> Element<'_, Message> { + fn render_profile_section( + &self, + profile_index: usize, + search: Option<&TabSearchSummary>, + ) -> Element<'_, Message> { let profile = &self.draft.render_profiles.profiles[profile_index]; + let show_all = search.is_none_or(TabSearchSummary::show_all); + let show_profile_controls = + search.is_none_or(|search| search.render_profile_controls_visible(profile_index)); let header = row![ text(if profile.name.trim().is_empty() { "Profile" @@ -108,13 +137,25 @@ impl ConfiguratorApp { .align_y(Alignment::Center); let mut mappings = column![].spacing(8); - for mapping_index in 0..profile.mappings.len() { + let mapping_indices: Vec = if show_all || show_profile_controls { + (0..profile.mappings.len()).collect() + } else { + search + .map(TabSearchSummary::render_profile_mapping_indices) + .unwrap_or_default() + .iter() + .filter_map(|(matched_profile, mapping)| { + (*matched_profile == profile_index).then_some(*mapping) + }) + .collect() + }; + for mapping_index in mapping_indices { mappings = mappings.push(self.render_profile_mapping_row(profile_index, mapping_index)); } - container( - column![ - header, + let mut section = column![header].spacing(10); + if show_profile_controls { + section = section.push( row![ text_input("id", &profile.id) .on_input(move |value| Message::RenderProfileTextChanged( @@ -132,14 +173,19 @@ impl ConfiguratorApp { .width(Length::FillPortion(2)), ] .spacing(8), - mappings, + ); + } + section = section.push(mappings); + if show_profile_controls { + section = section.push( button("Add mapping").on_press(Message::RenderProfileMappingAdd(profile_index)), - ] - .spacing(10), - ) - .padding(12) - .style(theme::Container::Box) - .into() + ); + } + + container(section) + .padding(12) + .style(theme::Container::Box) + .into() } fn render_profile_mapping_row( diff --git a/configurator/src/app/view/session.rs b/configurator/src/app/view/session.rs index fffa27df..cf353050 100644 --- a/configurator/src/app/view/session.rs +++ b/configurator/src/app/view/session.rs @@ -1,6 +1,7 @@ use iced::widget::{button, column, container, pick_list, row, rule, scrollable, text, text_input}; use iced::{Element, Length}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::app::session_catalog::{ session_artifact_status_label, session_clear_cached_status_blocker, session_duplicate_cached_status_blocker, session_move_cached_status_blocker, @@ -11,6 +12,7 @@ use crate::models::{ SessionCatalogItem, SessionCompressionOption, SessionStorageModeOption, TextField, ToggleField, }; +use super::super::search::{SearchArea, TabSearchSummary}; use super::super::state::ConfiguratorApp; use super::widgets::{ labeled_control, labeled_input, labeled_input_with_feedback, toggle_row, validate_u64_min, @@ -18,7 +20,12 @@ use super::widgets::{ }; impl ConfiguratorApp { - pub(super) fn session_tab(&self) -> Element<'_, Message> { + pub(super) fn session_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { + let show_all = search.is_none_or(TabSearchSummary::show_all); + let show_persistence = + search.is_none_or(|search| search.area_matches(SearchArea::SessionPersistence)); + let show_catalog = + search.is_none_or(|search| search.area_matches(SearchArea::SessionCatalog)); let storage_pick = pick_list( SessionStorageModeOption::list(), Some(self.draft.session_storage_mode), @@ -30,142 +37,147 @@ impl ConfiguratorApp { Message::SessionCompressionChanged, ); - let mut column = column![ - text("Session Persistence").size(20), - toggle_row( - "Persist transparent mode drawings", - self.draft.session_persist_transparent, - self.defaults.session_persist_transparent, - ToggleField::SessionPersistTransparent, - ), - toggle_row( - "Persist whiteboard mode drawings", - self.draft.session_persist_whiteboard, - self.defaults.session_persist_whiteboard, - ToggleField::SessionPersistWhiteboard, - ), - toggle_row( - "Persist blackboard mode drawings", - self.draft.session_persist_blackboard, - self.defaults.session_persist_blackboard, - ToggleField::SessionPersistBlackboard, - ), - toggle_row( - "Persist undo/redo history", - self.draft.session_persist_history, - self.defaults.session_persist_history, - ToggleField::SessionPersistHistory, - ), - toggle_row( - "Restore tool state on startup", - self.draft.session_restore_tool_state, - self.defaults.session_restore_tool_state, - ToggleField::SessionRestoreToolState, - ), - toggle_row( - "Enable autosave", - self.draft.session_autosave_enabled, - self.defaults.session_autosave_enabled, - ToggleField::SessionAutosaveEnabled, - ), - toggle_row( - "Per-output persistence", - self.draft.session_per_output, - self.defaults.session_per_output, - ToggleField::SessionPerOutput, - ), - labeled_control( - "Storage mode", - storage_pick.width(Length::Fill).into(), - self.defaults.session_storage_mode.label().to_string(), - self.draft.session_storage_mode != self.defaults.session_storage_mode, - ), - ] - .spacing(12); + let mut column = column![text("Session Persistence").size(20)].spacing(12); - if self.draft.session_storage_mode == SessionStorageModeOption::Custom { - column = column.push(labeled_input( - "Custom directory", - &self.draft.session_custom_directory, - &self.defaults.session_custom_directory, - TextField::SessionCustomDirectory, - )); - } + if show_persistence || show_all { + column = column + .push(toggle_row( + "Persist transparent mode drawings", + self.draft.session_persist_transparent, + self.defaults.session_persist_transparent, + ToggleField::SessionPersistTransparent, + )) + .push(toggle_row( + "Persist whiteboard mode drawings", + self.draft.session_persist_whiteboard, + self.defaults.session_persist_whiteboard, + ToggleField::SessionPersistWhiteboard, + )) + .push(toggle_row( + "Persist blackboard mode drawings", + self.draft.session_persist_blackboard, + self.defaults.session_persist_blackboard, + ToggleField::SessionPersistBlackboard, + )) + .push(toggle_row( + "Persist undo/redo history", + self.draft.session_persist_history, + self.defaults.session_persist_history, + ToggleField::SessionPersistHistory, + )) + .push(toggle_row( + "Restore tool state on startup", + self.draft.session_restore_tool_state, + self.defaults.session_restore_tool_state, + ToggleField::SessionRestoreToolState, + )) + .push(toggle_row( + "Enable autosave", + self.draft.session_autosave_enabled, + self.defaults.session_autosave_enabled, + ToggleField::SessionAutosaveEnabled, + )) + .push(toggle_row( + "Per-output persistence", + self.draft.session_per_output, + self.defaults.session_per_output, + ToggleField::SessionPerOutput, + )) + .push(labeled_control( + "Storage mode", + storage_pick.width(Length::Fill).into(), + self.defaults.session_storage_mode.label().to_string(), + self.draft.session_storage_mode != self.defaults.session_storage_mode, + )); + + if self.draft.session_storage_mode == SessionStorageModeOption::Custom { + column = column.push(labeled_input( + "Custom directory", + &self.draft.session_custom_directory, + &self.defaults.session_custom_directory, + TextField::SessionCustomDirectory, + )); + } - column = column - .push(labeled_control( + column = column.push(labeled_control( "Compression", compression_pick.width(Length::Fill).into(), self.defaults.session_compression.label().to_string(), self.draft.session_compression != self.defaults.session_compression, - )) - .push(labeled_input_with_feedback( + )); + + column = column.push(labeled_input_with_feedback( "Autosave idle (ms)", &self.draft.session_autosave_idle_ms, &self.defaults.session_autosave_idle_ms, TextField::SessionAutosaveIdleMs, Some("Minimum: 1000 ms"), validate_u64_min(&self.draft.session_autosave_idle_ms, 1000), - )) - .push(labeled_input_with_feedback( + )); + column = column.push(labeled_input_with_feedback( "Autosave interval (ms)", &self.draft.session_autosave_interval_ms, &self.defaults.session_autosave_interval_ms, TextField::SessionAutosaveIntervalMs, Some("Minimum: 1000 ms"), validate_u64_min(&self.draft.session_autosave_interval_ms, 1000), - )) - .push(labeled_input_with_feedback( + )); + column = column.push(labeled_input_with_feedback( "Autosave failure backoff (ms)", &self.draft.session_autosave_failure_backoff_ms, &self.defaults.session_autosave_failure_backoff_ms, TextField::SessionAutosaveFailureBackoffMs, Some("Minimum: 1000 ms"), validate_u64_min(&self.draft.session_autosave_failure_backoff_ms, 1000), - )) - .push(labeled_input_with_feedback( + )); + column = column.push(labeled_input_with_feedback( "Max shapes per frame", &self.draft.session_max_shapes_per_frame, &self.defaults.session_max_shapes_per_frame, TextField::SessionMaxShapesPerFrame, Some("Minimum: 1"), validate_usize_min(&self.draft.session_max_shapes_per_frame, 1), - )) - .push(labeled_input( + )); + column = column.push(labeled_input( "Max persisted undo depth (blank = runtime limit)", &self.draft.session_max_persisted_undo_depth, &self.defaults.session_max_persisted_undo_depth, TextField::SessionMaxPersistedUndoDepth, - )) - .push(labeled_input_with_feedback( + )); + column = column.push(labeled_input_with_feedback( "Max file size (MB)", &self.draft.session_max_file_size_mb, &self.defaults.session_max_file_size_mb, TextField::SessionMaxFileSizeMb, Some("Range: 1-1024 MB"), validate_u64_range(&self.draft.session_max_file_size_mb, 1, 1024), - )) - .push(labeled_input_with_feedback( + )); + column = column.push(labeled_input_with_feedback( "Auto-compress threshold (KB)", &self.draft.session_auto_compress_threshold_kb, &self.defaults.session_auto_compress_threshold_kb, TextField::SessionAutoCompressThresholdKb, Some("Minimum: 1 KB"), validate_u64_min(&self.draft.session_auto_compress_threshold_kb, 1), - )) - .push(labeled_input( + )); + column = column.push(labeled_input( "Backup retention count", &self.draft.session_backup_retention, &self.defaults.session_backup_retention, TextField::SessionBackupRetention, - )) - .push(rule::horizontal(1)) - .push(self.session_catalog_section()); + )); + } + + if show_catalog || show_all { + column = column + .push(rule::horizontal(1)) + .push(self.session_catalog_section(search)); + } - scrollable(column).into() + scrollable(column).id(CONTENT_SCROLL_ID).into() } - fn session_catalog_section(&self) -> Element<'_, Message> { + fn session_catalog_section(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { let busy = self.session_catalog.busy || self.session_catalog.is_loading; let mut refresh = button("Refresh").style(theme::Button::Secondary); if !busy { @@ -199,7 +211,13 @@ impl ConfiguratorApp { .into(); } - for item in &self.session_catalog.items { + let visible_items = self + .session_catalog + .items + .iter() + .filter(|item| search.is_none_or(|search| search.session_item_visible(&item.id))); + + for item in visible_items { section = section.push(self.session_catalog_item(item)); } diff --git a/configurator/src/app/view/tablet.rs b/configurator/src/app/view/tablet.rs index 9790bdaf..3f87d134 100644 --- a/configurator/src/app/view/tablet.rs +++ b/configurator/src/app/view/tablet.rs @@ -1,18 +1,20 @@ use iced::widget::{column, pick_list, row, scrollable, text}; use iced::{Element, Length}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::messages::Message; use crate::models::{ PressureThicknessEditModeOption, PressureThicknessEntryModeOption, TextField, ToggleField, }; +use super::super::search::TabSearchSummary; use super::super::state::ConfiguratorApp; use super::widgets::{ labeled_control, labeled_input_with_feedback, toggle_row, validate_f64_range, }; impl ConfiguratorApp { - pub(super) fn tablet_tab(&self) -> Element<'_, Message> { + pub(super) fn tablet_tab(&self, _search: Option<&TabSearchSummary>) -> Element<'_, Message> { let edit_mode = pick_list( PressureThicknessEditModeOption::list(), Some(self.draft.tablet_pressure_thickness_edit_mode), @@ -102,6 +104,7 @@ impl ConfiguratorApp { ] .spacing(12), ) + .id(CONTENT_SCROLL_ID) .into() } } diff --git a/configurator/src/app/view/ui/click_highlight.rs b/configurator/src/app/view/ui/click_highlight.rs index f370ae54..e7ecfd48 100644 --- a/configurator/src/app/view/ui/click_highlight.rs +++ b/configurator/src/app/view/ui/click_highlight.rs @@ -1,6 +1,7 @@ use iced::Element; use iced::widget::{column, row, scrollable, text}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::app::state::ConfiguratorApp; use crate::messages::Message; use crate::models::{ColorPickerId, QuadField, TextField, ToggleField}; @@ -104,6 +105,6 @@ impl ConfiguratorApp { ] .spacing(12); - scrollable(column).into() + scrollable(column).id(CONTENT_SCROLL_ID).into() } } diff --git a/configurator/src/app/view/ui/help_overlay.rs b/configurator/src/app/view/ui/help_overlay.rs index 4f4872e8..c138458b 100644 --- a/configurator/src/app/view/ui/help_overlay.rs +++ b/configurator/src/app/view/ui/help_overlay.rs @@ -1,6 +1,7 @@ use iced::Element; use iced::widget::{column, row, scrollable, text}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::app::state::ConfiguratorApp; use crate::messages::Message; use crate::models::{ColorPickerId, QuadField, TextField, ToggleField}; @@ -105,6 +106,6 @@ impl ConfiguratorApp { ] .spacing(12); - scrollable(column).into() + scrollable(column).id(CONTENT_SCROLL_ID).into() } } diff --git a/configurator/src/app/view/ui/mod.rs b/configurator/src/app/view/ui/mod.rs index 9f988e54..cafabd18 100644 --- a/configurator/src/app/view/ui/mod.rs +++ b/configurator/src/app/view/ui/mod.rs @@ -12,17 +12,21 @@ use crate::app::state::ConfiguratorApp; use crate::messages::Message; use crate::models::{TextField, ToggleField, UiTabId}; +use super::super::search::{SearchArea, TabSearchSummary}; use super::widgets::{labeled_input, toggle_row}; impl ConfiguratorApp { - pub(super) fn ui_tab(&self) -> Element<'_, Message> { - let tab_bar = UiTabId::ALL.iter().fold( + pub(super) fn ui_tab(&self, search: Option<&TabSearchSummary>) -> Element<'_, Message> { + let show_general = search.is_none_or(|search| search.area_matches(SearchArea::UiGeneral)); + let tabs = visible_ui_tabs(search); + let active_tab = active_ui_tab(search, self.active_ui_tab); + let tab_bar = tabs.iter().fold( Row::new().spacing(8).align_y(iced::Alignment::Center), |row, tab| { let label = tab.title(); let button = button(label) .padding([4, 10]) - .style(if *tab == self.active_ui_tab { + .style(if Some(*tab) == active_tab { theme::Button::Primary } else { theme::Button::Secondary @@ -32,12 +36,13 @@ impl ConfiguratorApp { }, ); - let content = match self.active_ui_tab { - UiTabId::Toolbar => self.ui_toolbar_tab(), - UiTabId::StatusBar => self.ui_status_bar_tab(), - UiTabId::HelpOverlay => self.ui_help_overlay_tab(), - UiTabId::ClickHighlight => self.ui_click_highlight_tab(), - UiTabId::PresenterMode => self.ui_presenter_mode_tab(), + let content = match active_tab { + Some(UiTabId::Toolbar) => Some(self.ui_toolbar_tab()), + Some(UiTabId::StatusBar) => Some(self.ui_status_bar_tab()), + Some(UiTabId::HelpOverlay) => Some(self.ui_help_overlay_tab()), + Some(UiTabId::ClickHighlight) => Some(self.ui_click_highlight_tab()), + Some(UiTabId::PresenterMode) => Some(self.ui_presenter_mode_tab()), + None => None, }; let general = column![ @@ -84,8 +89,31 @@ impl ConfiguratorApp { ] .spacing(12); - column![text("UI Settings").size(20), general, tab_bar, content] - .spacing(12) - .into() + let mut page = column![text("UI Settings").size(20)].spacing(12); + if show_general { + page = page.push(general); + } + if !tabs.is_empty() { + page = page.push(tab_bar); + } + if let Some(content) = content { + page = page.push(content); + } + page.into() + } +} + +fn visible_ui_tabs(search: Option<&TabSearchSummary>) -> Vec { + match search { + Some(summary) if !summary.show_all() => summary.ui_tabs().to_vec(), + _ => UiTabId::ALL.to_vec(), + } +} + +fn active_ui_tab(search: Option<&TabSearchSummary>, preferred: UiTabId) -> Option { + match search { + Some(summary) if summary.show_all() || summary.ui_tab_visible(preferred) => Some(preferred), + Some(summary) => summary.ui_tabs().first().copied(), + None => Some(preferred), } } diff --git a/configurator/src/app/view/ui/presenter_mode.rs b/configurator/src/app/view/ui/presenter_mode.rs index e59f322d..9767f818 100644 --- a/configurator/src/app/view/ui/presenter_mode.rs +++ b/configurator/src/app/view/ui/presenter_mode.rs @@ -1,6 +1,7 @@ use iced::widget::{column, pick_list, scrollable, text}; use iced::{Element, Length}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::app::state::ConfiguratorApp; use crate::messages::Message; use crate::models::{PresenterToolBehaviorOption, ToggleField}; @@ -65,6 +66,6 @@ impl ConfiguratorApp { ] .spacing(12); - scrollable(column).into() + scrollable(column).id(CONTENT_SCROLL_ID).into() } } diff --git a/configurator/src/app/view/ui/status_bar.rs b/configurator/src/app/view/ui/status_bar.rs index 14d5c973..9d8b027b 100644 --- a/configurator/src/app/view/ui/status_bar.rs +++ b/configurator/src/app/view/ui/status_bar.rs @@ -1,6 +1,7 @@ use iced::widget::{column, pick_list, row, scrollable, text}; use iced::{Element, Length}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::app::state::ConfiguratorApp; use crate::messages::Message; use crate::models::{ColorPickerId, QuadField, StatusPositionOption, TextField, ToggleField}; @@ -116,6 +117,6 @@ impl ConfiguratorApp { ] .spacing(12); - scrollable(column).into() + scrollable(column).id(CONTENT_SCROLL_ID).into() } } diff --git a/configurator/src/app/view/ui/toolbar.rs b/configurator/src/app/view/ui/toolbar.rs index 4782ca09..fa73b9aa 100644 --- a/configurator/src/app/view/ui/toolbar.rs +++ b/configurator/src/app/view/ui/toolbar.rs @@ -1,6 +1,7 @@ use iced::widget::{column, pick_list, row, scrollable, text}; use iced::{Element, Length}; +use crate::app::scroll::CONTENT_SCROLL_ID; use crate::app::state::ConfiguratorApp; use crate::messages::Message; use crate::models::{TextField, ToggleField, ToolbarLayoutModeOption, ToolbarOverrideField}; @@ -212,6 +213,6 @@ impl ConfiguratorApp { ] .spacing(12); - scrollable(column).into() + scrollable(column).id(CONTENT_SCROLL_ID).into() } } diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index f0772163..17fde4f2 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -46,6 +46,12 @@ pub enum Message { SessionCatalogClearConfirmed(String), SessionCatalogClearCanceled, SessionCatalogActionCompleted(Result), + SearchChanged(String), + SearchCleared, + SearchFocusRequested, + SearchFocusObserved(bool), + KeyboardEvent(iced::keyboard::Event, iced::event::Status), + PointerPressed, TabSelected(TabId), UiTabSelected(UiTabId), KeybindingsTabSelected(KeybindingsTabId), diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index 79fb6b9b..9ed73021 100644 --- a/configurator/src/models/mod.rs +++ b/configurator/src/models/mod.rs @@ -5,6 +5,7 @@ pub mod daemon; pub mod error; pub mod fields; pub mod keybindings; +pub(crate) mod search; pub mod session; pub mod tab; pub mod util; @@ -32,5 +33,6 @@ pub use fields::{ #[cfg(feature = "tablet-input")] pub use fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; pub use keybindings::KeybindingField; +pub(crate) use search::SearchQuery; pub use session::{SessionCatalogActionResult, SessionCatalogItem, SessionCatalogState}; pub use tab::{KeybindingsTabId, TabId, UiTabId}; diff --git a/configurator/src/models/search.rs b/configurator/src/models/search.rs new file mode 100644 index 00000000..6a77c14b --- /dev/null +++ b/configurator/src/models/search.rs @@ -0,0 +1,103 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SearchQuery { + raw: String, + tokens: Vec, +} + +impl SearchQuery { + pub(crate) fn new(raw: impl Into) -> Self { + let raw = raw.into(); + let tokens = raw + .split_whitespace() + .map(normalize) + .filter(|token| !token.is_empty()) + .collect(); + Self { raw, tokens } + } + + pub(crate) fn raw(&self) -> &str { + &self.raw + } + + pub(crate) fn has_raw_input(&self) -> bool { + !self.raw.is_empty() + } + + pub(crate) fn is_active(&self) -> bool { + !self.tokens.is_empty() + } + + pub(crate) fn matches_text(&self, value: &str) -> bool { + self.matches_parts([value]) + } + + pub(crate) fn matches_parts<'a>(&self, parts: impl IntoIterator) -> bool { + if !self.is_active() { + return true; + } + let haystack = parts + .into_iter() + .map(normalize) + .collect::>() + .join(" "); + self.tokens.iter().all(|token| haystack.contains(token)) + } +} + +impl Default for SearchQuery { + fn default() -> Self { + Self::new("") + } +} + +fn normalize(value: &str) -> String { + value + .chars() + .flat_map(char::to_lowercase) + .map(|ch| { + if ch.is_alphanumeric() || ch == '#' { + ch + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::SearchQuery; + + #[test] + fn inactive_query_matches_everything() { + assert!(SearchQuery::new(" ").matches_text("Boards")); + } + + #[test] + fn raw_input_state_is_separate_from_active_tokens() { + let punctuation = SearchQuery::new("/"); + assert!(punctuation.has_raw_input()); + assert!(!punctuation.is_active()); + + let empty = SearchQuery::new(""); + assert!(!empty.has_raw_input()); + assert!(!empty.is_active()); + } + + #[test] + fn matches_all_tokens_across_parts() { + let query = SearchQuery::new("pdf label"); + assert!(query.matches_parts(["Export PDF", "Show page labels"])); + assert!(!query.matches_parts(["Export PDF", "Filename template"])); + } + + #[test] + fn punctuation_does_not_block_matching() { + let query = SearchQuery::new("render profiles"); + assert!(query.matches_text("Render Profiles")); + assert!(SearchQuery::new("ctrl f").matches_text("Ctrl+F")); + } +}