Skip to content

Commit 06b15e5

Browse files
authored
Merge pull request #244 from devmobasa/feat/configurator-search
Add searchable settings navigation to configurator
2 parents 64cadd4 + 9fe568f commit 06b15e5

38 files changed

Lines changed: 3270 additions & 439 deletions

configurator/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ The window loads the current config, lets you tweak values across the tabbed sec
2525
- **Reload** – re-read `config.toml` from disk.
2626
- **Defaults** – drop in the built-in defaults without saving.
2727
- **Save** – validate inputs (including numeric ranges and color arrays) and write the TOML file. An existing file is backed up with a timestamp.
28+
- **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.
2829
- Launch from the main overlay with the default `F11` keybinding (configurable inside the app).
2930

3031
## UI Coverage

configurator/src/app/entry.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub fn run() -> iced::Result {
2323
ConfiguratorApp::view,
2424
)
2525
.title("Wayscriber Configurator (Iced)")
26+
.subscription(ConfiguratorApp::subscription)
2627
.theme(iced::Theme::Dark)
2728
.settings(settings)
2829
.window(window)

configurator/src/app/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
mod daemon_setup;
22
mod entry;
33
mod io;
4+
pub(crate) mod scroll;
5+
mod search;
46
mod session_catalog;
57
mod state;
8+
mod subscription;
69
mod update;
710
mod view;
811

configurator/src/app/scroll.rs

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
use iced::Task;
2+
use iced::keyboard::{self, Key, key};
3+
use iced::widget::operation::{self, AbsoluteOffset};
4+
5+
use crate::messages::Message;
6+
7+
pub(crate) const CONTENT_SCROLL_ID: &str = "configurator-content-scroll";
8+
9+
const LINE_SCROLL_DELTA_Y: f32 = 64.0;
10+
const PAGE_SCROLL_DELTA_Y: f32 = 360.0;
11+
const EDGE_SCROLL_DELTA_Y: f32 = 1_000_000.0;
12+
13+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14+
pub(crate) enum ContentScrollAction {
15+
Top,
16+
Bottom,
17+
LineUp,
18+
LineDown,
19+
PageUp,
20+
PageDown,
21+
}
22+
23+
impl ContentScrollAction {
24+
pub(crate) fn can_scroll_when_captured(self) -> bool {
25+
matches!(self, ContentScrollAction::Top | ContentScrollAction::Bottom)
26+
}
27+
28+
pub(crate) fn task(self) -> Task<Message> {
29+
match self {
30+
ContentScrollAction::Top => scroll_by_y(-EDGE_SCROLL_DELTA_Y),
31+
ContentScrollAction::Bottom => scroll_by_y(EDGE_SCROLL_DELTA_Y),
32+
ContentScrollAction::LineUp => scroll_by_y(-LINE_SCROLL_DELTA_Y),
33+
ContentScrollAction::LineDown => scroll_by_y(LINE_SCROLL_DELTA_Y),
34+
ContentScrollAction::PageUp => scroll_by_y(-PAGE_SCROLL_DELTA_Y),
35+
ContentScrollAction::PageDown => scroll_by_y(PAGE_SCROLL_DELTA_Y),
36+
}
37+
}
38+
}
39+
40+
pub(crate) fn content_scroll_action_for_event(
41+
event: &keyboard::Event,
42+
) -> Option<ContentScrollAction> {
43+
let keyboard::Event::KeyPressed {
44+
key,
45+
physical_key,
46+
modifiers,
47+
..
48+
} = event
49+
else {
50+
return None;
51+
};
52+
53+
if !modifiers.is_empty() {
54+
return None;
55+
}
56+
57+
content_scroll_action_for_key(key.as_ref())
58+
.or_else(|| content_scroll_action_for_physical_key(*physical_key))
59+
}
60+
61+
fn content_scroll_action_for_key(key: Key<&str>) -> Option<ContentScrollAction> {
62+
match key {
63+
Key::Named(key::Named::Home) => Some(ContentScrollAction::Top),
64+
Key::Named(key::Named::End) => Some(ContentScrollAction::Bottom),
65+
Key::Named(key::Named::ArrowUp) => Some(ContentScrollAction::LineUp),
66+
Key::Named(key::Named::ArrowDown) => Some(ContentScrollAction::LineDown),
67+
Key::Named(key::Named::PageUp) => Some(ContentScrollAction::PageUp),
68+
Key::Named(key::Named::PageDown) => Some(ContentScrollAction::PageDown),
69+
_ => None,
70+
}
71+
}
72+
73+
fn content_scroll_action_for_physical_key(
74+
physical_key: key::Physical,
75+
) -> Option<ContentScrollAction> {
76+
match physical_key {
77+
key::Physical::Code(key::Code::Home) => Some(ContentScrollAction::Top),
78+
key::Physical::Code(key::Code::End) => Some(ContentScrollAction::Bottom),
79+
key::Physical::Code(key::Code::ArrowUp) => Some(ContentScrollAction::LineUp),
80+
key::Physical::Code(key::Code::ArrowDown) => Some(ContentScrollAction::LineDown),
81+
key::Physical::Code(key::Code::PageUp) => Some(ContentScrollAction::PageUp),
82+
key::Physical::Code(key::Code::PageDown) => Some(ContentScrollAction::PageDown),
83+
_ => None,
84+
}
85+
}
86+
87+
fn scroll_by_y(y: f32) -> Task<Message> {
88+
operation::scroll_by(CONTENT_SCROLL_ID, AbsoluteOffset { x: 0.0, y })
89+
}
90+
91+
#[cfg(test)]
92+
mod tests {
93+
use super::*;
94+
use iced::keyboard::{Location, Modifiers, key};
95+
96+
fn key_press(key: Key) -> keyboard::Event {
97+
keyboard::Event::KeyPressed {
98+
key: key.clone(),
99+
modified_key: key,
100+
physical_key: key::Physical::Unidentified(key::NativeCode::Unidentified),
101+
location: Location::Standard,
102+
modifiers: Modifiers::empty(),
103+
text: None,
104+
repeat: false,
105+
}
106+
}
107+
108+
fn physical_key_press(physical_key: key::Physical) -> keyboard::Event {
109+
keyboard::Event::KeyPressed {
110+
key: Key::Unidentified,
111+
modified_key: Key::Unidentified,
112+
physical_key,
113+
location: Location::Standard,
114+
modifiers: Modifiers::empty(),
115+
text: None,
116+
repeat: false,
117+
}
118+
}
119+
120+
#[test]
121+
fn navigation_keys_map_to_content_scroll_actions() {
122+
let cases = [
123+
(Key::Named(key::Named::Home), ContentScrollAction::Top),
124+
(Key::Named(key::Named::End), ContentScrollAction::Bottom),
125+
(Key::Named(key::Named::ArrowUp), ContentScrollAction::LineUp),
126+
(
127+
Key::Named(key::Named::ArrowDown),
128+
ContentScrollAction::LineDown,
129+
),
130+
(Key::Named(key::Named::PageUp), ContentScrollAction::PageUp),
131+
(
132+
Key::Named(key::Named::PageDown),
133+
ContentScrollAction::PageDown,
134+
),
135+
];
136+
137+
for (key, expected) in cases {
138+
assert_eq!(
139+
content_scroll_action_for_event(&key_press(key)),
140+
Some(expected)
141+
);
142+
}
143+
}
144+
145+
#[test]
146+
fn modified_navigation_keys_do_not_scroll_content() {
147+
let event = keyboard::Event::KeyPressed {
148+
key: Key::Named(key::Named::Home),
149+
modified_key: Key::Named(key::Named::Home),
150+
physical_key: key::Physical::Code(key::Code::Home),
151+
location: Location::Standard,
152+
modifiers: Modifiers::CTRL,
153+
text: None,
154+
repeat: false,
155+
};
156+
157+
assert_eq!(content_scroll_action_for_event(&event), None);
158+
}
159+
160+
#[test]
161+
fn physical_navigation_keys_map_to_content_scroll_actions() {
162+
let cases = [
163+
(
164+
key::Physical::Code(key::Code::Home),
165+
ContentScrollAction::Top,
166+
),
167+
(
168+
key::Physical::Code(key::Code::End),
169+
ContentScrollAction::Bottom,
170+
),
171+
(
172+
key::Physical::Code(key::Code::ArrowUp),
173+
ContentScrollAction::LineUp,
174+
),
175+
(
176+
key::Physical::Code(key::Code::ArrowDown),
177+
ContentScrollAction::LineDown,
178+
),
179+
(
180+
key::Physical::Code(key::Code::PageUp),
181+
ContentScrollAction::PageUp,
182+
),
183+
(
184+
key::Physical::Code(key::Code::PageDown),
185+
ContentScrollAction::PageDown,
186+
),
187+
];
188+
189+
for (physical_key, expected) in cases {
190+
assert_eq!(
191+
content_scroll_action_for_event(&physical_key_press(physical_key)),
192+
Some(expected)
193+
);
194+
}
195+
}
196+
}

configurator/src/app/search/mod.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
mod summary;
2+
mod terms;
3+
#[cfg(test)]
4+
mod tests;
5+
mod types;
6+
7+
use iced::keyboard::{self, Key, key};
8+
use iced::{Task, event};
9+
10+
use crate::messages::Message;
11+
use crate::models::{SearchQuery, TabId};
12+
13+
use super::scroll;
14+
use super::state::ConfiguratorApp;
15+
16+
pub(crate) use types::{AppSearchSummary, SearchArea, TabSearchSummary};
17+
18+
pub(crate) const SEARCH_INPUT_ID: &str = "configurator-search-input";
19+
20+
impl ConfiguratorApp {
21+
pub(crate) fn search_summary(&self) -> AppSearchSummary {
22+
summary::build_search_summary(self)
23+
}
24+
25+
pub(crate) fn align_active_tabs_for_search(&mut self) {
26+
let search = self.search_summary();
27+
if !search.is_active() {
28+
return;
29+
}
30+
31+
if let Some(tab) = search.active_tab_or_first(self.active_tab) {
32+
self.active_tab = tab;
33+
}
34+
35+
match self.active_tab {
36+
TabId::Ui => self.align_active_ui_tab_for_search(&search),
37+
TabId::Keybindings => self.align_active_keybindings_tab_for_search(&search),
38+
_ => {}
39+
}
40+
}
41+
42+
fn align_active_ui_tab_for_search(&mut self, search: &AppSearchSummary) {
43+
let Some(tab) = search.tab(TabId::Ui) else {
44+
return;
45+
};
46+
if tab.ui_tab_visible(self.active_ui_tab) {
47+
return;
48+
}
49+
if let Some(first) = tab.ui_tabs().first().copied() {
50+
self.active_ui_tab = first;
51+
}
52+
}
53+
54+
fn align_active_keybindings_tab_for_search(&mut self, search: &AppSearchSummary) {
55+
let Some(tab) = search.tab(TabId::Keybindings) else {
56+
return;
57+
};
58+
if tab.keybindings_tab_visible(self.active_keybindings_tab) {
59+
return;
60+
}
61+
if let Some(first) = tab.keybinding_tabs().first().copied() {
62+
self.active_keybindings_tab = first;
63+
}
64+
}
65+
66+
pub(super) fn handle_search_changed(&mut self, value: String) -> Task<Message> {
67+
self.search_input_focus_hint = true;
68+
self.search_query = SearchQuery::new(value);
69+
self.align_active_tabs_for_search();
70+
Task::none()
71+
}
72+
73+
pub(super) fn handle_search_cleared(&mut self) -> Task<Message> {
74+
self.search_query = SearchQuery::default();
75+
Task::none()
76+
}
77+
78+
pub(super) fn handle_search_focus_requested(&mut self) -> Task<Message> {
79+
self.search_input_focus_hint = true;
80+
iced::widget::operation::focus(SEARCH_INPUT_ID)
81+
}
82+
83+
pub(super) fn handle_startup_search_focus_config_fallback(&mut self) -> Task<Message> {
84+
if !self.startup_search_focus_pending {
85+
return Task::none();
86+
}
87+
88+
self.startup_search_focus_pending = false;
89+
self.handle_search_focus_requested()
90+
}
91+
92+
pub(super) fn handle_search_focus_observed(&mut self, is_focused: bool) -> Task<Message> {
93+
self.search_input_focus_hint = is_focused;
94+
Task::none()
95+
}
96+
97+
pub(super) fn handle_pointer_pressed(&mut self) -> Task<Message> {
98+
self.cancel_startup_search_focus();
99+
self.observe_search_focus()
100+
}
101+
102+
pub(super) fn handle_keyboard_event(
103+
&mut self,
104+
event: keyboard::Event,
105+
status: event::Status,
106+
) -> Task<Message> {
107+
let keyboard::Event::KeyPressed { key, modifiers, .. } = &event else {
108+
return Task::none();
109+
};
110+
111+
match key.as_ref() {
112+
Key::Character("f") | Key::Character("F") if modifiers.command() => {
113+
self.handle_search_focus_requested()
114+
}
115+
Key::Named(key::Named::Escape) if self.search_query.has_raw_input() => {
116+
let should_refocus_search = self.search_input_focus_hint;
117+
self.search_query = SearchQuery::default();
118+
if should_refocus_search {
119+
self.handle_search_focus_requested()
120+
} else {
121+
Task::none()
122+
}
123+
}
124+
Key::Named(key::Named::Tab) => {
125+
self.cancel_startup_search_focus();
126+
self.observe_search_focus()
127+
}
128+
_ => content_scroll_action_for_status(&event, status, self.search_input_focus_hint)
129+
.map_or_else(Task::none, scroll::ContentScrollAction::task),
130+
}
131+
}
132+
133+
fn cancel_startup_search_focus(&mut self) {
134+
self.startup_search_focus_pending = false;
135+
}
136+
137+
fn observe_search_focus(&self) -> Task<Message> {
138+
iced::widget::operation::is_focused(SEARCH_INPUT_ID).map(Message::SearchFocusObserved)
139+
}
140+
}
141+
142+
fn content_scroll_action_for_status(
143+
event: &keyboard::Event,
144+
status: event::Status,
145+
allow_captured_edges: bool,
146+
) -> Option<scroll::ContentScrollAction> {
147+
let action = scroll::content_scroll_action_for_event(event)?;
148+
if status == event::Status::Ignored
149+
|| (allow_captured_edges && action.can_scroll_when_captured())
150+
{
151+
Some(action)
152+
} else {
153+
None
154+
}
155+
}

0 commit comments

Comments
 (0)