Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions configurator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions configurator/src/app/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions configurator/src/app/mod.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
196 changes: 196 additions & 0 deletions configurator/src/app/scroll.rs
Original file line number Diff line number Diff line change
@@ -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<Message> {
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<ContentScrollAction> {
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<ContentScrollAction> {
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<ContentScrollAction> {
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<Message> {
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)
);
}
}
}
155 changes: 155 additions & 0 deletions configurator/src/app/search/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Message> {
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<Message> {
self.search_query = SearchQuery::default();
Task::none()
}

pub(super) fn handle_search_focus_requested(&mut self) -> Task<Message> {
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<Message> {
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<Message> {
self.search_input_focus_hint = is_focused;
Task::none()
}

pub(super) fn handle_pointer_pressed(&mut self) -> Task<Message> {
self.cancel_startup_search_focus();
self.observe_search_focus()
}

pub(super) fn handle_keyboard_event(
&mut self,
event: keyboard::Event,
status: event::Status,
) -> Task<Message> {
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<Message> {
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<scroll::ContentScrollAction> {
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
}
}
Loading
Loading