diff --git a/configurator/src/app/component.rs b/configurator/src/app/component.rs index 7c538d80..5fe886f9 100644 --- a/configurator/src/app/component.rs +++ b/configurator/src/app/component.rs @@ -8,25 +8,26 @@ //! state is written: shell chrome directly, page rows through the bindings //! the page builders registered ([`super::pages`]). +mod effects; +mod shell; +mod view; + use relm4::prelude::*; use relm4::{adw, gtk}; -use adw::prelude::*; - use crate::messages::{CommandMessage, Message}; use crate::models::{StartupRequest, TabId}; -use super::effects::Effect; -use super::pages::{self, Binding}; -use super::search::AppSearchSummary; -use super::state::{ConfiguratorApp, StatusMessage}; -use super::{daemon_setup, io, session_catalog}; +use super::pages::Binding; +use super::state::ConfiguratorApp; /// GApplication id. A valid dotted id is required by GLib; the window still /// advertises this as its Wayland app-id, so compositor rules and the /// `.desktop` `StartupWMClass` must match it. const APP_ID: &str = "org.wayscriber.Configurator"; +use effects::spawn_effect; + pub(crate) fn run(startup: StartupRequest) { // Every launch is its own window, as it was under Iced: the overlay and // the tray spawn `wayscriber-configurator --open ` and @@ -93,278 +94,7 @@ impl Component for ConfiguratorApp { spawn_effect(effect, &sender); } - // ---- Sidebar ---------------------------------------------------- - let search_entry = gtk::SearchEntry::builder() - .placeholder_text("Search settings") - .build(); - { - let sender = sender.clone(); - search_entry.connect_search_changed(move |entry| { - sender.input(Message::SearchChanged(entry.text().to_string())); - }); - } - { - let sender = sender.clone(); - search_entry.connect_stop_search(move |_| { - sender.input(Message::SearchCleared); - }); - } - - let sidebar = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::Single) - .css_classes(["navigation-sidebar"]) - .build(); - let mut sidebar_rows = Vec::new(); - for tab in TabId::ALL { - let row = gtk::ListBoxRow::builder() - .child( - >k::Label::builder() - .label(tab.title()) - .halign(gtk::Align::Start) - .margin_top(8) - .margin_bottom(8) - .margin_start(6) - .margin_end(6) - .build(), - ) - .build(); - sidebar.append(&row); - sidebar_rows.push((tab, row)); - } - { - let sender = sender.clone(); - let rows = sidebar_rows.clone(); - sidebar.connect_row_selected(move |_, selected| { - let Some(selected) = selected else { - return; - }; - if let Some((tab, _)) = rows.iter().find(|(_, row)| row == selected) { - sender.input(Message::TabSelected(*tab)); - } - }); - } - - let sidebar_scroll = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Never) - .vexpand(true) - .child(&sidebar) - .build(); - let sidebar_box = gtk::Box::new(gtk::Orientation::Vertical, 6); - sidebar_box.set_margin_top(6); - sidebar_box.set_margin_start(6); - sidebar_box.set_margin_end(6); - sidebar_box.append(&search_entry); - sidebar_box.append(&sidebar_scroll); - let sidebar_page = adw::NavigationPage::builder() - .title("Wayscriber") - .child(&sidebar_box) - .build(); - - // ---- Header actions --------------------------------------------- - let window_title = adw::WindowTitle::new("Wayscriber Configurator", ""); - let reload_button = gtk::Button::with_label("Reload"); - { - let sender = sender.clone(); - reload_button.connect_clicked(move |_| sender.input(Message::ReloadRequested)); - } - // Asking for the reset and answering for it are different messages, - // so they are different controls: while the confirmation stands, the - // button that asks steps aside for the pair that answers. All three - // exist from the start and visibility picks between them, which is - // what keeps a repeat of the same press from ever applying defaults. - let defaults_button = gtk::Button::with_label("Defaults"); - { - let sender = sender.clone(); - defaults_button.connect_clicked(move |_| { - sender.input(Message::ResetToDefaultsRequested); - }); - } - let defaults_confirm_button = gtk::Button::builder() - .label("Confirm Defaults") - .visible(false) - .css_classes(["destructive-action"]) - .build(); - { - let sender = sender.clone(); - defaults_confirm_button.connect_clicked(move |_| { - sender.input(Message::ResetToDefaultsConfirmed); - }); - } - let defaults_cancel_button = gtk::Button::builder() - .label("Cancel") - .visible(false) - .css_classes(["flat"]) - .build(); - { - let sender = sender.clone(); - defaults_cancel_button.connect_clicked(move |_| { - sender.input(Message::ResetToDefaultsCanceled); - }); - } - let defaults_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); - defaults_box.append(&defaults_button); - defaults_box.append(&defaults_confirm_button); - defaults_box.append(&defaults_cancel_button); - - let save_button = gtk::Button::with_label("Save"); - save_button.add_css_class("suggested-action"); - { - let sender = sender.clone(); - save_button.connect_clicked(move |_| sender.input(Message::SaveRequested)); - } - - let header = adw::HeaderBar::builder() - .title_widget(&window_title) - .build(); - header.pack_start(&reload_button); - header.pack_start(&defaults_box); - header.pack_end(&save_button); - - // ---- Status + migration strip ----------------------------------- - let status_label = gtk::Label::builder() - .wrap(true) - .xalign(0.0) - .selectable(true) - .margin_top(6) - .margin_bottom(6) - .margin_start(12) - .margin_end(12) - .build(); - let status_revealer = gtk::Revealer::builder().child(&status_label).build(); - - let migration_label = gtk::Label::builder().wrap(true).xalign(0.0).build(); - let migration_apply = gtk::Button::with_label("Apply Update"); - migration_apply.add_css_class("suggested-action"); - { - let sender = sender.clone(); - migration_apply.connect_clicked(move |_| { - sender.input(Message::MigrationApplyRequested); - }); - } - let migration_dismiss = gtk::Button::with_label("Dismiss"); - { - let sender = sender.clone(); - migration_dismiss.connect_clicked(move |_| { - sender.input(Message::MigrationDismissed); - }); - } - let migration_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 6); - migration_buttons.append(&migration_apply); - migration_buttons.append(&migration_dismiss); - let migration_box = gtk::Box::new(gtk::Orientation::Vertical, 6); - migration_box.add_css_class("card"); - migration_box.set_margin_start(12); - migration_box.set_margin_end(12); - migration_box.set_margin_top(6); - migration_label.set_margin_top(8); - migration_label.set_margin_start(8); - migration_label.set_margin_end(8); - migration_box.append(&migration_label); - migration_buttons.set_margin_start(8); - migration_buttons.set_margin_bottom(8); - migration_box.append(&migration_buttons); - let migration_revealer = gtk::Revealer::builder().child(&migration_box).build(); - - // ---- Pages ------------------------------------------------------- - let stack = gtk::Stack::builder() - .transition_type(gtk::StackTransitionType::Crossfade) - .vexpand(true) - .build(); - let mut bindings: Vec = Vec::new(); - for (tab, built) in pages::build_all(&sender) { - stack.add_named(&built.widget, Some(pages::stack_name(tab))); - bindings.extend(built.bindings); - } - - // Sidebar visibility follows the search summary. - { - let rows = sidebar_rows.clone(); - bindings.push(Box::new( - move |_app: &ConfiguratorApp, summary: &AppSearchSummary| { - for (tab, row) in &rows { - let visible = !summary.is_active() || summary.tab(*tab).is_some(); - if row.is_visible() != visible { - row.set_visible(visible); - } - } - }, - )); - } - - let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0); - content_box.append(&status_revealer); - content_box.append(&migration_revealer); - content_box.append(&stack); - - let toolbar_view = adw::ToolbarView::new(); - toolbar_view.add_top_bar(&header); - toolbar_view.set_content(Some(&content_box)); - let content_page = adw::NavigationPage::builder() - .title("Settings") - .child(&toolbar_view) - .build(); - - let split = adw::NavigationSplitView::builder() - .sidebar(&sidebar_page) - .content(&content_page) - .build(); - root.set_content(Some(&split)); - - // Ctrl+F focuses search from anywhere in the window. - { - let sender = sender.clone(); - let controller = gtk::EventControllerKey::new(); - controller.connect_key_pressed(move |_, key, _, modifiers| { - if modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK) - && matches!(key, gtk::gdk::Key::f | gtk::gdk::Key::F) - { - sender.input(Message::SearchFocusRequested); - return gtk::glib::Propagation::Stop; - } - // Tab is the user moving focus deliberately; a still-pending - // startup search focus must not steal it back later. - if matches!(key, gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab) { - sender.input(Message::StartupInteractionObserved); - } - gtk::glib::Propagation::Proceed - }); - root.add_controller(controller); - } - - // Any click or tap is the same signal: the user is interacting, so - // the deferred startup search focus (which fires when the initial - // config load lands) must stand down instead of yanking focus. - { - let sender = sender.clone(); - let click = gtk::GestureClick::new(); - click.set_button(0); - click.set_propagation_phase(gtk::PropagationPhase::Capture); - click.connect_pressed(move |_, _, _, _| { - sender.input(Message::StartupInteractionObserved); - }); - root.add_controller(click); - } - - let widgets = AppWidgets { - window_title, - status_label, - status_revealer, - migration_revealer, - migration_label, - migration_seen: String::new(), - save_button, - defaults_button, - defaults_confirm_button, - defaults_cancel_button, - reload_button, - sidebar_rows, - sidebar, - stack, - search_entry, - seen_focus_serial: 0, - bindings, - }; - + let widgets = shell::build(&root, &sender); ComponentParts { model, widgets } } @@ -386,181 +116,6 @@ impl Component for ConfiguratorApp { } fn update_view(&self, widgets: &mut Self::Widgets, _sender: ComponentSender) { - // Header chrome. - let subtitle = if self.is_dirty { "Unsaved changes" } else { "" }; - if widgets.window_title.subtitle() != subtitle { - widgets.window_title.set_subtitle(subtitle); - } - // A color field the parser rejects is an edit that never reached the - // draft, so Save is not offered while one is on screen: pressing it - // would write the last value that parsed and lose the text being typed. - let save_enabled = self.is_dirty - && !self.is_saving - && !self.is_loading - && self.invalid_color_hex_count() == 0; - if widgets.save_button.is_sensitive() != save_enabled { - widgets.save_button.set_sensitive(save_enabled); - } - let busy = self.is_loading || self.is_saving; - if widgets.reload_button.is_sensitive() == busy { - widgets.reload_button.set_sensitive(!busy); - } - // The armed confirmation is the model's, so which of the two Defaults - // affordances is on screen follows it: asking is offered until the - // question stands, answering only while it does. - let defaults_armed = self.defaults_reset_pending; - let defaults_arming = defaults_armed && !widgets.defaults_confirm_button.get_visible(); - set_visible(&widgets.defaults_button, !defaults_armed); - set_visible(&widgets.defaults_confirm_button, defaults_armed); - set_visible(&widgets.defaults_cancel_button, defaults_armed); - if defaults_arming { - // The Defaults button just stepped aside. Keep keyboard users in - // the revealed flow instead of leaving focus on a hidden widget. - widgets.defaults_confirm_button.grab_focus(); - } - - // Status strip. - let (status_text, status_class) = match &self.status { - StatusMessage::Idle => ("", None), - StatusMessage::Info(text) => (text.as_str(), None), - StatusMessage::Success(text) => (text.as_str(), Some("success")), - StatusMessage::Warning(text) => (text.as_str(), Some("warning")), - StatusMessage::Error(text) => (text.as_str(), Some("error")), - StatusMessage::Confirmation(prompt) => (prompt.message(), Some("warning")), - }; - if widgets.status_label.text() != status_text { - widgets.status_label.set_text(status_text); - for class in ["success", "warning", "error"] { - widgets.status_label.remove_css_class(class); - } - if let Some(class) = status_class { - widgets.status_label.add_css_class(class); - } - } - widgets - .status_revealer - .set_reveal_child(!status_text.is_empty()); - - // Migration offer. - let migration_text = self - .pending_migration() - .map(super::update::migration_offer_text) - .unwrap_or_default(); - if widgets.migration_seen != migration_text { - widgets.migration_label.set_text(&migration_text); - widgets.migration_seen = migration_text.clone(); - } - widgets - .migration_revealer - .set_reveal_child(!migration_text.is_empty()); - - // Navigation: model decides, widgets follow. - let stack_name = pages::stack_name(self.active_tab); - if widgets.stack.visible_child_name().as_deref() != Some(stack_name) { - widgets.stack.set_visible_child_name(stack_name); - } - let selected = widgets - .sidebar_rows - .iter() - .find(|(tab, _)| *tab == self.active_tab) - .map(|(_, row)| row.clone()); - if let Some(row) = selected - && widgets.sidebar.selected_row().as_ref() != Some(&row) - { - widgets.sidebar.select_row(Some(&row)); - } - - // Search text + one-shot focus grabs. - let query = self.search_query.raw(); - if widgets.search_entry.text() != query { - widgets.search_entry.set_text(query); - } - if widgets.seen_focus_serial != self.search_focus_serial { - widgets.seen_focus_serial = self.search_focus_serial; - widgets.search_entry.grab_focus(); - } - - // Page rows. - let summary = self.search_summary(); - // `&mut`: a binding may own the state its section needs between - // refreshes, which is what the dynamic lists keep their built rows in. - for binding in &mut widgets.bindings { - binding(self, &summary); - } - } -} - -/// Writes the widget's own visibility flag, never `is_visible`: a widget -/// inside a hidden parent reports invisible while its own flag still says -/// otherwise, and skipping the write there would leak the stale state the -/// moment the parent comes back. -fn set_visible(widget: &impl IsA, visible: bool) { - if widget.get_visible() != visible { - widget.set_visible(visible); - } -} - -/// Runs one effect as a Relm4 command; its result re-enters the component -/// as an ordinary message through `update_cmd`. -fn spawn_effect(effect: Effect, sender: &ComponentSender) { - match effect { - Effect::LoadConfig => sender.oneshot_command(async { - CommandMessage::ConfigLoaded(io::load_config_from_disk().await) - }), - Effect::SaveConfig { document, config } => sender.oneshot_command(async move { - CommandMessage::ConfigSaved(io::save_config_to_disk(document, *config).await) - }), - Effect::LoadDaemonStatus { request_id } => sender.oneshot_command(async move { - CommandMessage::DaemonStatusLoaded( - request_id, - daemon_setup::load_daemon_runtime_status().await, - ) - }), - Effect::PerformDaemonAction { - action, - shortcut_input, - } => sender.oneshot_command(async move { - CommandMessage::DaemonActionCompleted( - daemon_setup::perform_daemon_action(action, shortcut_input).await, - ) - }), - Effect::LoadSessionCatalog => sender.oneshot_command(async { - CommandMessage::SessionCatalogLoaded(session_catalog::load_session_catalog().await) - }), - Effect::ForgetSessionEntry { id } => sender.oneshot_command(async move { - CommandMessage::SessionCatalogActionCompleted( - session_catalog::forget_session_catalog_entry(id).await, - ) - }), - Effect::RenameSessionEntry { id, display_name } => sender.oneshot_command(async move { - CommandMessage::SessionCatalogActionCompleted( - session_catalog::rename_session_catalog_entry(id, display_name).await, - ) - }), - Effect::DuplicateSessionEntry { id, target } => sender.oneshot_command(async move { - CommandMessage::SessionCatalogActionCompleted( - session_catalog::duplicate_session_catalog_entry(id, target).await, - ) - }), - Effect::MoveSessionEntry { id, target } => sender.oneshot_command(async move { - CommandMessage::SessionCatalogActionCompleted( - session_catalog::move_session_catalog_entry(id, target).await, - ) - }), - Effect::RevealSessionEntry { id } => sender.oneshot_command(async move { - CommandMessage::SessionCatalogActionCompleted( - session_catalog::reveal_session_catalog_entry(id).await, - ) - }), - Effect::ClearSessionToolState { id } => sender.oneshot_command(async move { - CommandMessage::SessionCatalogActionCompleted( - session_catalog::clear_session_catalog_tool_state_entry(id).await, - ) - }), - Effect::ClearSessionEntry { id } => sender.oneshot_command(async move { - CommandMessage::SessionCatalogActionCompleted( - session_catalog::clear_session_catalog_entry(id).await, - ) - }), + view::refresh(self, widgets); } } diff --git a/configurator/src/app/component/effects.rs b/configurator/src/app/component/effects.rs new file mode 100644 index 00000000..6786204a --- /dev/null +++ b/configurator/src/app/component/effects.rs @@ -0,0 +1,72 @@ +use relm4::ComponentSender; + +use crate::messages::CommandMessage; + +use super::super::effects::Effect; +use super::super::state::ConfiguratorApp; +use super::super::{daemon_setup, io, session_catalog}; + +/// Runs one effect as a Relm4 command; its result re-enters the component +/// as an ordinary message through `update_cmd`. +pub(super) fn spawn_effect(effect: Effect, sender: &ComponentSender) { + match effect { + Effect::LoadConfig => sender.oneshot_command(async { + CommandMessage::ConfigLoaded(io::load_config_from_disk().await) + }), + Effect::SaveConfig { document, config } => sender.oneshot_command(async move { + CommandMessage::ConfigSaved(io::save_config_to_disk(document, *config).await) + }), + Effect::LoadDaemonStatus { request_id } => sender.oneshot_command(async move { + CommandMessage::DaemonStatusLoaded( + request_id, + daemon_setup::load_daemon_runtime_status().await, + ) + }), + Effect::PerformDaemonAction { + action, + shortcut_input, + } => sender.oneshot_command(async move { + CommandMessage::DaemonActionCompleted( + daemon_setup::perform_daemon_action(action, shortcut_input).await, + ) + }), + Effect::LoadSessionCatalog => sender.oneshot_command(async { + CommandMessage::SessionCatalogLoaded(session_catalog::load_session_catalog().await) + }), + Effect::ForgetSessionEntry { id } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::forget_session_catalog_entry(id).await, + ) + }), + Effect::RenameSessionEntry { id, display_name } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::rename_session_catalog_entry(id, display_name).await, + ) + }), + Effect::DuplicateSessionEntry { id, target } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::duplicate_session_catalog_entry(id, target).await, + ) + }), + Effect::MoveSessionEntry { id, target } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::move_session_catalog_entry(id, target).await, + ) + }), + Effect::RevealSessionEntry { id } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::reveal_session_catalog_entry(id).await, + ) + }), + Effect::ClearSessionToolState { id } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::clear_session_catalog_tool_state_entry(id).await, + ) + }), + Effect::ClearSessionEntry { id } => sender.oneshot_command(async move { + CommandMessage::SessionCatalogActionCompleted( + session_catalog::clear_session_catalog_entry(id).await, + ) + }), + } +} diff --git a/configurator/src/app/component/shell.rs b/configurator/src/app/component/shell.rs new file mode 100644 index 00000000..9986bce1 --- /dev/null +++ b/configurator/src/app/component/shell.rs @@ -0,0 +1,298 @@ +use relm4::{ComponentSender, adw, gtk}; + +use adw::prelude::*; + +use crate::messages::Message; +use crate::models::TabId; + +use super::super::pages::{self, Binding}; +use super::super::search::AppSearchSummary; +use super::super::state::ConfiguratorApp; +use super::AppWidgets; + +pub(super) fn build( + root: &adw::ApplicationWindow, + sender: &ComponentSender, +) -> AppWidgets { + // ---- Sidebar ---------------------------------------------------- + let search_entry = gtk::SearchEntry::builder() + .placeholder_text("Search settings") + .build(); + { + let sender = sender.clone(); + search_entry.connect_search_changed(move |entry| { + sender.input(Message::SearchChanged(entry.text().to_string())); + }); + } + { + let sender = sender.clone(); + search_entry.connect_stop_search(move |_| { + sender.input(Message::SearchCleared); + }); + } + + let sidebar = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::Single) + .css_classes(["navigation-sidebar"]) + .build(); + let mut sidebar_rows = Vec::new(); + for tab in TabId::ALL { + let row = gtk::ListBoxRow::builder() + .child( + >k::Label::builder() + .label(tab.title()) + .halign(gtk::Align::Start) + .margin_top(8) + .margin_bottom(8) + .margin_start(6) + .margin_end(6) + .build(), + ) + .build(); + sidebar.append(&row); + sidebar_rows.push((tab, row)); + } + { + let sender = sender.clone(); + let rows = sidebar_rows.clone(); + sidebar.connect_row_selected(move |_, selected| { + let Some(selected) = selected else { + return; + }; + if let Some((tab, _)) = rows.iter().find(|(_, row)| row == selected) { + sender.input(Message::TabSelected(*tab)); + } + }); + } + + let sidebar_scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .child(&sidebar) + .build(); + let sidebar_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + sidebar_box.set_margin_top(6); + sidebar_box.set_margin_start(6); + sidebar_box.set_margin_end(6); + sidebar_box.append(&search_entry); + sidebar_box.append(&sidebar_scroll); + let sidebar_page = adw::NavigationPage::builder() + .title("Wayscriber") + .child(&sidebar_box) + .build(); + + // ---- Header actions --------------------------------------------- + let window_title = adw::WindowTitle::new("Wayscriber Configurator", ""); + let reload_button = gtk::Button::with_label("Reload"); + { + let sender = sender.clone(); + reload_button.connect_clicked(move |_| sender.input(Message::ReloadRequested)); + } + // Asking for the reset and answering for it are different messages, + // so they are different controls: while the confirmation stands, the + // button that asks steps aside for the pair that answers. All three + // exist from the start and visibility picks between them, which is + // what keeps a repeat of the same press from ever applying defaults. + let defaults_button = gtk::Button::with_label("Defaults"); + { + let sender = sender.clone(); + defaults_button.connect_clicked(move |_| { + sender.input(Message::ResetToDefaultsRequested); + }); + } + let defaults_confirm_button = gtk::Button::builder() + .label("Confirm Defaults") + .visible(false) + .css_classes(["destructive-action"]) + .build(); + { + let sender = sender.clone(); + defaults_confirm_button.connect_clicked(move |_| { + sender.input(Message::ResetToDefaultsConfirmed); + }); + } + let defaults_cancel_button = gtk::Button::builder() + .label("Cancel") + .visible(false) + .css_classes(["flat"]) + .build(); + { + let sender = sender.clone(); + defaults_cancel_button.connect_clicked(move |_| { + sender.input(Message::ResetToDefaultsCanceled); + }); + } + let defaults_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); + defaults_box.append(&defaults_button); + defaults_box.append(&defaults_confirm_button); + defaults_box.append(&defaults_cancel_button); + + let save_button = gtk::Button::with_label("Save"); + save_button.add_css_class("suggested-action"); + { + let sender = sender.clone(); + save_button.connect_clicked(move |_| sender.input(Message::SaveRequested)); + } + + let header = adw::HeaderBar::builder() + .title_widget(&window_title) + .build(); + header.pack_start(&reload_button); + header.pack_start(&defaults_box); + header.pack_end(&save_button); + + // ---- Status + migration strip ----------------------------------- + let status_label = gtk::Label::builder() + .wrap(true) + .xalign(0.0) + .selectable(true) + .margin_top(6) + .margin_bottom(6) + .margin_start(12) + .margin_end(12) + .build(); + let status_revealer = gtk::Revealer::builder().child(&status_label).build(); + + let migration_label = gtk::Label::builder().wrap(true).xalign(0.0).build(); + let migration_apply = gtk::Button::with_label("Apply Update"); + migration_apply.add_css_class("suggested-action"); + { + let sender = sender.clone(); + migration_apply.connect_clicked(move |_| { + sender.input(Message::MigrationApplyRequested); + }); + } + let migration_dismiss = gtk::Button::with_label("Dismiss"); + { + let sender = sender.clone(); + migration_dismiss.connect_clicked(move |_| { + sender.input(Message::MigrationDismissed); + }); + } + let migration_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 6); + migration_buttons.append(&migration_apply); + migration_buttons.append(&migration_dismiss); + let migration_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + migration_box.add_css_class("card"); + migration_box.set_margin_start(12); + migration_box.set_margin_end(12); + migration_box.set_margin_top(6); + migration_label.set_margin_top(8); + migration_label.set_margin_start(8); + migration_label.set_margin_end(8); + migration_box.append(&migration_label); + migration_buttons.set_margin_start(8); + migration_buttons.set_margin_bottom(8); + migration_box.append(&migration_buttons); + let migration_revealer = gtk::Revealer::builder().child(&migration_box).build(); + + // ---- Pages ------------------------------------------------------- + let stack = gtk::Stack::builder() + .transition_type(gtk::StackTransitionType::Crossfade) + .vexpand(true) + .build(); + let mut bindings: Vec = Vec::new(); + for (tab, built) in pages::build_all(sender) { + stack.add_named(&built.widget, Some(pages::stack_name(tab))); + bindings.extend(built.bindings); + } + + // Sidebar visibility follows the search summary. + { + let rows = sidebar_rows.clone(); + bindings.push(Box::new( + move |_app: &ConfiguratorApp, summary: &AppSearchSummary| { + for (tab, row) in &rows { + let visible = !summary.is_active() || summary.tab(*tab).is_some(); + if row.is_visible() != visible { + row.set_visible(visible); + } + } + }, + )); + } + + let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + content_box.append(&status_revealer); + content_box.append(&migration_revealer); + content_box.append(&stack); + + let toolbar_view = adw::ToolbarView::new(); + toolbar_view.add_top_bar(&header); + toolbar_view.set_content(Some(&content_box)); + let content_page = adw::NavigationPage::builder() + .title("Settings") + .child(&toolbar_view) + .build(); + + let split = adw::NavigationSplitView::builder() + .sidebar(&sidebar_page) + .content(&content_page) + .build(); + root.set_content(Some(&split)); + + // Ctrl+F focuses search from anywhere in the window. + { + let sender = sender.clone(); + let controller = gtk::EventControllerKey::new(); + // Run before focused-widget keybindings so Escape always disarms the + // model-owned confirmation. The handler still returns Proceed, which + // lets widgets such as SearchEntry perform their own Escape behavior. + controller.set_propagation_phase(gtk::PropagationPhase::Capture); + controller.connect_key_pressed(move |_, key, _, modifiers| { + if modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK) + && matches!(key, gtk::gdk::Key::f | gtk::gdk::Key::F) + { + sender.input(Message::SearchFocusRequested); + return gtk::glib::Propagation::Stop; + } + if key == gtk::gdk::Key::Escape { + // The model owns which destructive question is current. + // Propagate as well so a widget with its own Escape + // behavior does not lose it when no confirmation exists. + sender.input(Message::ActiveConfirmationCanceled); + } + // Tab is the user moving focus deliberately; a still-pending + // startup search focus must not steal it back later. + if matches!(key, gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab) { + sender.input(Message::StartupInteractionObserved); + } + gtk::glib::Propagation::Proceed + }); + root.add_controller(controller); + } + + // Any click or tap is the same signal: the user is interacting, so + // the deferred startup search focus (which fires when the initial + // config load lands) must stand down instead of yanking focus. + { + let sender = sender.clone(); + let click = gtk::GestureClick::new(); + click.set_button(0); + click.set_propagation_phase(gtk::PropagationPhase::Capture); + click.connect_pressed(move |_, _, _, _| { + sender.input(Message::StartupInteractionObserved); + }); + root.add_controller(click); + } + + AppWidgets { + window_title, + status_label, + status_revealer, + migration_revealer, + migration_label, + migration_seen: String::new(), + save_button, + defaults_button, + defaults_confirm_button, + defaults_cancel_button, + reload_button, + sidebar_rows, + sidebar, + stack, + search_entry, + seen_focus_serial: 0, + bindings, + } +} diff --git a/configurator/src/app/component/view.rs b/configurator/src/app/component/view.rs new file mode 100644 index 00000000..4bf3e4fa --- /dev/null +++ b/configurator/src/app/component/view.rs @@ -0,0 +1,128 @@ +use relm4::{adw, gtk}; + +use adw::prelude::*; + +use super::super::pages; +use super::super::state::{ConfiguratorApp, StatusMessage}; +use super::AppWidgets; + +pub(super) fn refresh(app: &ConfiguratorApp, widgets: &mut AppWidgets) { + // Header chrome. + let subtitle = if app.is_dirty { "Unsaved changes" } else { "" }; + if widgets.window_title.subtitle() != subtitle { + widgets.window_title.set_subtitle(subtitle); + } + // A color field the parser rejects is an edit that never reached the + // draft, so Save is not offered while one is on screen: pressing it + // would write the last value that parsed and lose the text being typed. + let save_enabled = + app.is_dirty && !app.is_saving && !app.is_loading && app.invalid_color_hex_count() == 0; + if widgets.save_button.is_sensitive() != save_enabled { + widgets.save_button.set_sensitive(save_enabled); + } + let busy = app.is_loading || app.is_saving; + if widgets.reload_button.is_sensitive() == busy { + widgets.reload_button.set_sensitive(!busy); + } + // The armed confirmation is the model's, so which of the two Defaults + // affordances is on screen follows it: asking is offered until the + // question stands, answering only while it does. + let defaults_armed = app.defaults_reset_pending(); + let defaults_was_armed = widgets.defaults_confirm_button.get_visible(); + let defaults_arming = defaults_armed && !defaults_was_armed; + let defaults_return_focus = !defaults_armed + && defaults_was_armed + && (widgets.defaults_confirm_button.has_focus() + || widgets.defaults_cancel_button.has_focus()); + set_visible(&widgets.defaults_button, !defaults_armed); + set_visible(&widgets.defaults_confirm_button, defaults_armed); + set_visible(&widgets.defaults_cancel_button, defaults_armed); + if defaults_arming { + // The Defaults button just stepped aside. Keep keyboard users in + // the revealed flow instead of leaving focus on a hidden widget. + widgets.defaults_confirm_button.grab_focus(); + } else if defaults_return_focus { + // Cancel and Confirm both remove the answer controls. Return the + // keyboard user to the action that owns this header location. + widgets.defaults_button.grab_focus(); + } + + // Status strip. + let (status_text, status_class) = match &app.status { + StatusMessage::Idle => ("", None), + StatusMessage::Info(text) => (text.as_str(), None), + StatusMessage::Success(text) => (text.as_str(), Some("success")), + StatusMessage::Warning(text) => (text.as_str(), Some("warning")), + StatusMessage::Error(text) => (text.as_str(), Some("error")), + StatusMessage::Confirmation(prompt) => (prompt.message(), Some("warning")), + }; + if widgets.status_label.text() != status_text { + widgets.status_label.set_text(status_text); + for class in ["success", "warning", "error"] { + widgets.status_label.remove_css_class(class); + } + if let Some(class) = status_class { + widgets.status_label.add_css_class(class); + } + } + widgets + .status_revealer + .set_reveal_child(!status_text.is_empty()); + + // Migration offer. + let migration_text = app + .pending_migration() + .map(super::super::update::migration_offer_text) + .unwrap_or_default(); + if widgets.migration_seen != migration_text { + widgets.migration_label.set_text(&migration_text); + widgets.migration_seen = migration_text.clone(); + } + widgets + .migration_revealer + .set_reveal_child(!migration_text.is_empty()); + + // Navigation: model decides, widgets follow. + let stack_name = pages::stack_name(app.active_tab); + if widgets.stack.visible_child_name().as_deref() != Some(stack_name) { + widgets.stack.set_visible_child_name(stack_name); + } + let selected = widgets + .sidebar_rows + .iter() + .find(|(tab, _)| *tab == app.active_tab) + .map(|(_, row)| row.clone()); + if let Some(row) = selected + && widgets.sidebar.selected_row().as_ref() != Some(&row) + { + widgets.sidebar.select_row(Some(&row)); + } + + // Search text + one-shot focus grabs. + let query = app.search_query.raw(); + if widgets.search_entry.text() != query { + widgets.search_entry.set_text(query); + } + if widgets.seen_focus_serial != app.search_focus_serial { + widgets.seen_focus_serial = app.search_focus_serial; + widgets.search_entry.grab_focus(); + } + + // Page rows. + let summary = app.search_summary(); + // `&mut`: a binding may own the state its section needs between + // refreshes, which is what the dynamic lists keep their built rows in. + for binding in &mut widgets.bindings { + binding(app, &summary); + } +} + +/// Writes the widget's own visibility flag, never `is_visible`: a widget +/// inside a hidden parent reports invisible while its own flag still says +/// otherwise, and skipping the write there would leak the stale state the +/// moment the parent comes back. +fn set_visible(widget: &impl IsA, visible: bool) { + if widget.get_visible() != visible { + widget.set_visible(visible); + } +} diff --git a/configurator/src/app/pages/boards.rs b/configurator/src/app/pages/boards.rs index b15648be..560dbb91 100644 --- a/configurator/src/app/pages/boards.rs +++ b/configurator/src/app/pages/boards.rs @@ -22,23 +22,27 @@ //! path is lossy in that direction, so an echo would quantize a board's float //! components to whatever the 8-bit hex said. +mod color; +mod header; +mod rows; +mod section; +#[cfg(test)] +mod tests; + use relm4::prelude::*; use relm4::{adw, gtk}; -use adw::prelude::*; -use gtk::glib::SignalHandlerId; - use crate::messages::Message; -use crate::models::color::parse_triplet_values; use crate::models::{ - BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorPickerId, - ColorTripletInput, TabId, TextField, ToggleField, + BoardBackgroundOption, ColorPickerId, ColorTripletInput, TabId, TextField, ToggleField, }; +use adw::prelude::*; use super::super::search::{AppSearchSummary, SearchArea, TabSearchSummary}; use super::super::state::ConfiguratorApp; -use super::color_rows::{dialog_hex, mark_hex_error, set_swatch_blocked}; -use super::{BuiltPage, PageBuilder, set_text_blocked}; +use super::{BuiltPage, PageBuilder}; +use rows::{is_collapsed, note_label, picker_hex, sync_combo, validate_min}; +use section::{BoundBoardSection, rebuild_sections}; /// `GTK_INVALID_LIST_POSITION`: what a `GtkSingleSelection` reads as "nothing /// is selected", which is how the Iced pick list rendered an unknown default. @@ -275,622 +279,3 @@ struct ColorValues<'a> { hex: &'a str, color: &'a ColorTripletInput, } - -// ---- Sections ---------------------------------------------------------- - -/// One section's refresh: built beside its row, so it owns that row's typed -/// widget handles and the signal handler ids guarding each write. No -/// positional lookup, and no widget the refresh can silently miss. -type BoardRowRefresh = Box)>; - -struct BoundBoardSection { - layout: SectionLayout, - refresh: BoardRowRefresh, -} - -/// One board's section: the list box, and the closure that writes the values -/// the layout deliberately left out. -struct BoardSection { - section: gtk::ListBox, - refresh: BoardRowRefresh, -} - -fn rebuild_sections( - container: >k::Box, - layouts: &[SectionLayout], - sender: &ComponentSender, -) -> Vec { - // Draining the container, not walking it for a control: the sections that - // replace these carry their own refresh closures. - while let Some(child) = container.first_child() { - container.remove(&child); - } - - let mut sections = Vec::with_capacity(layouts.len()); - for (index, layout) in layouts.iter().enumerate() { - let built = build_section(index, *layout, sender); - container.append(&built.section); - sections.push(BoundBoardSection { - layout: *layout, - refresh: built.refresh, - }); - } - sections -} - -fn build_section( - index: usize, - layout: SectionLayout, - sender: &ComponentSender, -) -> BoardSection { - let section = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) - .visible(layout.visible) - .build(); - - let header = build_header_row(index, layout.expanded, sender); - section.append(&header.row); - - let id = build_text_row("Board id", index, BoardItemTextField::Id, sender); - id.row.set_visible(layout.expanded); - section.append(&id.row); - - let name = build_text_row("Display name", index, BoardItemTextField::Name, sender); - name.row.set_visible(layout.expanded); - section.append(&name.row); - - let kind_row = build_kind_row(index, layout.background_kind, sender); - kind_row.set_visible(layout.expanded); - section.append(&kind_row); - - let background = build_color_row( - "Background color (0-1)", - ColorPickerId::BoardBackground(index), - index, - Message::BoardsBackgroundColorChanged, - sender, - ); - // The Iced view swapped this for a note explaining why the color is - // inert; a hidden row says the same thing without the clutter. - background - .row - .set_visible(layout.expanded && layout.background_kind == BoardBackgroundOption::Color); - section.append(&background.row); - - let pen_enabled = adw::SwitchRow::builder() - .title("Override default pen color") - .active(layout.pen_enabled) - .visible(layout.expanded) - .build(); - { - let sender = sender.clone(); - pen_enabled.connect_active_notify(move |row| { - sender.input(Message::BoardsDefaultPenEnabledChanged( - index, - row.is_active(), - )); - }); - } - section.append(&pen_enabled); - - let pen = build_color_row( - "Pen color (0-1)", - ColorPickerId::BoardPen(index), - index, - Message::BoardsDefaultPenColorChanged, - sender, - ); - pen.row.set_visible(layout.expanded && layout.pen_enabled); - section.append(&pen.row); - - for (title, field, active) in [ - ( - "Auto-adjust pen", - BoardItemToggleField::AutoAdjustPen, - layout.auto_adjust, - ), - ("Persist", BoardItemToggleField::Persist, layout.persist), - ( - "Configured default pinned", - BoardItemToggleField::Pinned, - layout.pinned, - ), - ] { - section.append(&build_toggle_row( - title, - index, - field, - active, - layout.expanded, - sender, - )); - } - - let refresh: BoardRowRefresh = Box::new(move |values| { - header.set_labels(index, values.id, values.name); - set_text_blocked(&id.row, &id.handler, values.id); - set_text_blocked(&name.row, &name.handler, values.name); - background.refresh(&values.background); - pen.refresh(&values.pen); - }); - - BoardSection { section, refresh } -} - -// ---- Header row -------------------------------------------------------- - -/// The header's two labels, which restate the id and name rows below them. -struct HeaderRow { - row: gtk::ListBoxRow, - title: gtk::Label, - id: gtk::Label, -} - -impl HeaderRow { - fn set_labels(&self, index: usize, id: &str, name: &str) { - let title = if name.trim().is_empty() { - format!("Board {}", index + 1) - } else { - name.trim().to_string() - }; - set_label(&self.title, &title); - - let id_label = if id.trim().is_empty() { - "id: ".to_string() - } else { - format!("id: {}", id.trim()) - }; - set_label(&self.id, &id_label); - } -} - -fn build_header_row( - index: usize, - expanded: bool, - sender: &ComponentSender, -) -> HeaderRow { - let labels = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(2) - .hexpand(true) - .build(); - let title = gtk::Label::builder() - .xalign(0.0) - .css_classes(["heading"]) - .build(); - let id = gtk::Label::builder() - .xalign(0.0) - .css_classes(["dim-label", "caption"]) - .build(); - labels.append(&title); - labels.append(&id); - - let content = row_content_box(gtk::Orientation::Horizontal); - content.append(&labels); - for (label, message) in [ - ( - if expanded { "Collapse" } else { "Expand" }, - Message::BoardsCollapseToggled(index), - ), - ("Up", Message::BoardsMoveItemUp(index)), - ("Down", Message::BoardsMoveItemDown(index)), - ("Duplicate", Message::BoardsDuplicateItem(index)), - ("Remove", Message::BoardsRemoveItem(index)), - ] { - let button = gtk::Button::builder() - .label(label) - .valign(gtk::Align::Center) - .build(); - connect_button(&button, message, sender); - content.append(&button); - } - - HeaderRow { - row: plain_row(&content), - title, - id, - } -} - -// ---- Color row --------------------------------------------------------- - -/// The Iced view's triplet picker: a hex field, a popup picker, and the three -/// raw components. The native color dialog stands in for the popup; both feed -/// the same `ColorPickerHexChanged` path the popup's hex field used. -struct ColorRow { - row: gtk::ListBoxRow, - hex: gtk::Entry, - hex_handler: SignalHandlerId, - swatch: gtk::ColorDialogButton, - swatch_handler: SignalHandlerId, - components: [ComponentEntry; 3], -} - -struct ComponentEntry { - entry: gtk::Entry, - handler: SignalHandlerId, -} - -const COMPONENT_PLACEHOLDERS: [&str; 3] = ["R", "G", "B"]; - -impl ColorRow { - fn refresh(&self, values: &ColorValues<'_>) { - set_text_blocked(&self.hex, &self.hex_handler, values.hex); - // The same predicate the save gate counts with, so a field styled - // clean can never be one the save refuses. - mark_hex_error(&self.hex, values.hex); - - for (component, value) in self.components.iter().zip(values.color.components.iter()) { - set_text_blocked(&component.entry, &component.handler, value); - } - - let rgb = parse_triplet_values(&values.color.components); - let rgba = gtk::gdk::RGBA::new(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32, 1.0); - set_swatch_blocked(&self.swatch, &self.swatch_handler, &rgba); - } -} - -fn build_color_row( - title: &str, - id: ColorPickerId, - index: usize, - to_component: fn(usize, usize, String) -> Message, - sender: &ComponentSender, -) -> ColorRow { - let controls = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(6) - .build(); - - let hex = gtk::Entry::builder() - .placeholder_text("#RRGGBB") - .width_chars(9) - .max_width_chars(9) - .build(); - let hex_handler = { - let sender = sender.clone(); - hex.connect_changed(move |entry| { - sender.input(Message::ColorPickerHexChanged(id, entry.text().to_string())); - }) - }; - controls.append(&hex); - - let swatch = - gtk::ColorDialogButton::new(Some(gtk::ColorDialog::builder().with_alpha(false).build())); - swatch.set_valign(gtk::Align::Center); - let swatch_handler = { - let sender = sender.clone(); - swatch.connect_rgba_notify(move |button| { - sender.input(Message::ColorPickerHexChanged( - id, - dialog_hex(&button.rgba()), - )); - }) - }; - controls.append(&swatch); - - let components = std::array::from_fn(|component| { - let entry = gtk::Entry::builder() - .placeholder_text(COMPONENT_PLACEHOLDERS.get(component).copied().unwrap_or("")) - .width_chars(6) - .max_width_chars(6) - .build(); - let handler = { - let sender = sender.clone(); - entry.connect_changed(move |entry| { - sender.input(to_component(index, component, entry.text().to_string())); - }) - }; - controls.append(&entry); - ComponentEntry { entry, handler } - }); - - let content = row_content_box(gtk::Orientation::Vertical); - content.append( - >k::Label::builder() - .label(title) - .xalign(0.0) - .css_classes(["dim-label", "caption"]) - .build(), - ); - content.append(&controls); - - ColorRow { - row: plain_row(&content), - hex, - hex_handler, - swatch, - swatch_handler, - components, - } -} - -// ---- Small shared plumbing -------------------------------------------- - -/// An entry row whose text the model owns, kept with the handler a refresh -/// has to block before writing it. -struct TextRow { - row: adw::EntryRow, - handler: SignalHandlerId, -} - -fn build_text_row( - title: &str, - index: usize, - field: BoardItemTextField, - sender: &ComponentSender, -) -> TextRow { - let row = adw::EntryRow::builder().title(title).build(); - let handler = { - let sender = sender.clone(); - row.connect_changed(move |row| { - sender.input(Message::BoardsItemTextChanged( - index, - field, - row.text().to_string(), - )); - }) - }; - TextRow { row, handler } -} - -fn build_kind_row( - index: usize, - selected: BoardBackgroundOption, - sender: &ComponentSender, -) -> adw::ComboRow { - let options = BoardBackgroundOption::list(); - let labels: Vec = options - .iter() - .map(|option| option.label().to_string()) - .collect(); - let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); - let row = adw::ComboRow::builder() - .title("Background") - .model(>k::StringList::new(&label_refs)) - .build(); - - // The selection is part of the rebuild fingerprint, so it is set before - // the handler exists and never written again behind the user's back. - if let Some(position) = options.iter().position(|option| *option == selected) { - row.set_selected(position as u32); - } - { - let sender = sender.clone(); - row.connect_selected_notify(move |row| { - if let Some(option) = options.get(row.selected() as usize) { - sender.input(Message::BoardsBackgroundKindChanged(index, *option)); - } - }); - } - row -} - -fn build_toggle_row( - title: &str, - index: usize, - field: BoardItemToggleField, - active: bool, - expanded: bool, - sender: &ComponentSender, -) -> adw::SwitchRow { - let row = adw::SwitchRow::builder() - .title(title) - .active(active) - .visible(expanded) - .build(); - let sender = sender.clone(); - row.connect_active_notify(move |row| { - sender.input(Message::BoardsItemToggleChanged( - index, - field, - row.is_active(), - )); - }); - row -} - -fn connect_button( - button: >k::Button, - message: Message, - sender: &ComponentSender, -) { - let sender = sender.clone(); - button.connect_clicked(move |_| sender.input(message.clone())); -} - -fn set_label(label: >k::Label, value: &str) { - if label.text() != value { - label.set_text(value); - } -} - -/// Rewrites a combo's model only when the choices themselves changed, and -/// writes both model and selection with the change handler blocked: replacing -/// a model resets the selection to the first row, which would otherwise be -/// reported as if the user had picked it. -/// -/// `shown` is what the combo currently offers, owned by the binding that -/// calls this — the entries themselves, not a rendering of them. -fn sync_combo( - row: &adw::ComboRow, - handler: &SignalHandlerId, - shown: &mut Vec, - entries: &[String], - selected: Option, -) { - let rebuild = shown.as_slice() != entries; - let target = selected.map_or(NO_SELECTION, |index| index as u32); - if !rebuild && row.selected() == target { - return; - } - - row.block_signal(handler); - if rebuild { - let refs: Vec<&str> = entries.iter().map(String::as_str).collect(); - row.set_model(Some(>k::StringList::new(&refs))); - shown.clear(); - shown.extend_from_slice(entries); - } - if row.selected() != target { - row.set_selected(target); - } - row.unblock_signal(handler); -} - -fn row_content_box(orientation: gtk::Orientation) -> gtk::Box { - gtk::Box::builder() - .orientation(orientation) - .spacing(6) - .margin_top(8) - .margin_bottom(8) - .margin_start(12) - .margin_end(12) - .build() -} - -fn plain_row(content: &impl IsA) -> gtk::ListBoxRow { - gtk::ListBoxRow::builder() - .child(content) - .activatable(false) - .selectable(false) - .build() -} - -fn note_label(text: &str) -> gtk::Label { - gtk::Label::builder() - .label(text) - .wrap(true) - .xalign(0.0) - .css_classes(["dim-label", "caption"]) - .build() -} - -fn picker_hex(app: &ConfiguratorApp, id: ColorPickerId) -> &str { - app.color_picker_hex.get(&id).map_or("", String::as_str) -} - -fn is_collapsed(app: &ConfiguratorApp, index: usize) -> bool { - app.boards_collapsed.get(index).copied().unwrap_or(false) -} - -/// Error text for a count field with a lower bound, worded as the Iced view -/// worded it. -fn validate_min(value: &str, min: usize) -> Option { - match value.trim().parse::() { - Ok(parsed) if parsed >= min => None, - Ok(_) => Some(format!("Minimum: {min}")), - Err(_) => Some("Expected a whole number".to_string()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::SearchQuery; - - /// Two boards, so a fingerprint covers more than one section. - fn app_with_two_boards() -> ConfiguratorApp { - let (mut app, _effects) = ConfiguratorApp::new_app(); - while app.draft.boards.items.len() < 2 { - let Some(item) = app.draft.boards.items.first().cloned() else { - break; - }; - app.draft.boards.items.push(item); - app.boards_collapsed.push(false); - } - app - } - - /// The law the binding rests on: one layout per board, so the sections it - /// builds and the values it hands them are indexed by the same thing. - #[test] - fn there_is_one_layout_per_board() { - let app = app_with_two_boards(); - let layouts = section_layouts(&app, &app.search_summary()); - - assert_eq!(layouts.len(), app.draft.boards.items.len()); - } - - /// The caret guarantee, stated as the layout law it rests on: no keystroke - /// in a board's text may rebuild the row it lands in. - #[test] - fn typing_into_a_board_field_leaves_the_layout_alone() { - let mut app = app_with_two_boards(); - let summary = app.search_summary(); - let before = section_layouts(&app, &summary); - - let Some(item) = app.draft.boards.items.first_mut() else { - return; - }; - item.name = "Half typ".to_string(); - item.id = "half-typ".to_string(); - item.background_color.set_component(0, "0.1".to_string()); - app.color_picker_hex - .insert(ColorPickerId::BoardBackground(0), "#1A00".to_string()); - - let summary = app.search_summary(); - assert_eq!(before, section_layouts(&app, &summary)); - // The values a refresh writes in place carry the edit instead. - assert_eq!(app.draft.boards.items[0].id, "half-typ"); - assert_eq!( - app.color_picker_hex - .get(&ColorPickerId::BoardBackground(0)) - .map(String::as_str), - Some("#1A00") - ); - } - - #[test] - fn toggling_a_board_switch_changes_the_layout() { - let mut app = app_with_two_boards(); - let summary = app.search_summary(); - let before = section_layouts(&app, &summary); - - let Some(item) = app.draft.boards.items.first_mut() else { - return; - }; - item.pinned = !item.pinned; - - let summary = app.search_summary(); - assert_ne!(before, section_layouts(&app, &summary)); - } - - #[test] - fn collapsing_a_board_changes_the_layout() { - let mut app = app_with_two_boards(); - let summary = app.search_summary(); - let before = section_layouts(&app, &summary); - - let Some(collapsed) = app.boards_collapsed.first_mut() else { - return; - }; - *collapsed = true; - - let summary = app.search_summary(); - assert_ne!(before, section_layouts(&app, &summary)); - } - - /// Search visibility belongs to the layout too: a rebuild is what applies - /// it now that nothing refreshes a section in place. - #[test] - fn a_search_that_hides_a_board_changes_the_layout() { - let mut app = app_with_two_boards(); - let Some(item) = app.draft.boards.items.first_mut() else { - return; - }; - item.name = "zqxwvu".to_string(); - let summary = app.search_summary(); - let before = section_layouts(&app, &summary); - - app.search_query = SearchQuery::new("zqxwvu"); - let summary = app.search_summary(); - let layouts = section_layouts(&app, &summary); - - assert_ne!(before, layouts); - let visible: Vec = layouts.iter().map(|layout| layout.visible).collect(); - assert_eq!(visible.first(), Some(&true)); - assert!(visible.iter().skip(1).all(|visible| !visible)); - } -} diff --git a/configurator/src/app/pages/boards/color.rs b/configurator/src/app/pages/boards/color.rs new file mode 100644 index 00000000..4d684b11 --- /dev/null +++ b/configurator/src/app/pages/boards/color.rs @@ -0,0 +1,125 @@ +use relm4::{ComponentSender, gtk}; + +use gtk::glib::SignalHandlerId; +use gtk::prelude::*; + +use crate::messages::Message; +use crate::models::ColorPickerId; +use crate::models::color::parse_triplet_values; + +use super::super::super::state::ConfiguratorApp; +use super::super::color_rows::{dialog_hex, mark_hex_error, set_swatch_blocked}; +use super::super::set_text_blocked; +use super::ColorValues; +use super::rows::{plain_row, row_content_box}; + +/// The Iced view's triplet picker: a hex field, a popup picker, and the three +/// raw components. The native color dialog stands in for the popup; both feed +/// the same `ColorPickerHexChanged` path the popup's hex field used. +pub(super) struct ColorRow { + pub(super) row: gtk::ListBoxRow, + hex: gtk::Entry, + hex_handler: SignalHandlerId, + swatch: gtk::ColorDialogButton, + swatch_handler: SignalHandlerId, + components: [ComponentEntry; 3], +} + +struct ComponentEntry { + entry: gtk::Entry, + handler: SignalHandlerId, +} + +const COMPONENT_PLACEHOLDERS: [&str; 3] = ["R", "G", "B"]; + +impl ColorRow { + pub(super) fn refresh(&self, values: &ColorValues<'_>) { + set_text_blocked(&self.hex, &self.hex_handler, values.hex); + // The same predicate the save gate counts with, so a field styled + // clean can never be one the save refuses. + mark_hex_error(&self.hex, values.hex); + + for (component, value) in self.components.iter().zip(values.color.components.iter()) { + set_text_blocked(&component.entry, &component.handler, value); + } + + let rgb = parse_triplet_values(&values.color.components); + let rgba = gtk::gdk::RGBA::new(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32, 1.0); + set_swatch_blocked(&self.swatch, &self.swatch_handler, &rgba); + } +} + +pub(super) fn build_color_row( + title: &str, + id: ColorPickerId, + index: usize, + to_component: fn(usize, usize, String) -> Message, + sender: &ComponentSender, +) -> ColorRow { + let controls = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .build(); + + let hex = gtk::Entry::builder() + .placeholder_text("#RRGGBB") + .width_chars(9) + .max_width_chars(9) + .build(); + let hex_handler = { + let sender = sender.clone(); + hex.connect_changed(move |entry| { + sender.input(Message::ColorPickerHexChanged(id, entry.text().to_string())); + }) + }; + controls.append(&hex); + + let swatch = + gtk::ColorDialogButton::new(Some(gtk::ColorDialog::builder().with_alpha(false).build())); + swatch.set_valign(gtk::Align::Center); + let swatch_handler = { + let sender = sender.clone(); + swatch.connect_rgba_notify(move |button| { + sender.input(Message::ColorPickerHexChanged( + id, + dialog_hex(&button.rgba()), + )); + }) + }; + controls.append(&swatch); + + let components = std::array::from_fn(|component| { + let entry = gtk::Entry::builder() + .placeholder_text(COMPONENT_PLACEHOLDERS.get(component).copied().unwrap_or("")) + .width_chars(6) + .max_width_chars(6) + .build(); + let handler = { + let sender = sender.clone(); + entry.connect_changed(move |entry| { + sender.input(to_component(index, component, entry.text().to_string())); + }) + }; + controls.append(&entry); + ComponentEntry { entry, handler } + }); + + let content = row_content_box(gtk::Orientation::Vertical); + content.append( + >k::Label::builder() + .label(title) + .xalign(0.0) + .css_classes(["dim-label", "caption"]) + .build(), + ); + content.append(&controls); + + ColorRow { + row: plain_row(&content), + hex, + hex_handler, + swatch, + swatch_handler, + components, + } +} diff --git a/configurator/src/app/pages/boards/header.rs b/configurator/src/app/pages/boards/header.rs new file mode 100644 index 00000000..6def6a28 --- /dev/null +++ b/configurator/src/app/pages/boards/header.rs @@ -0,0 +1,81 @@ +use relm4::{ComponentSender, gtk}; + +use gtk::prelude::*; + +use crate::messages::Message; + +use super::super::super::state::ConfiguratorApp; +use super::rows::{connect_button, plain_row, row_content_box, set_label}; + +/// The header's two labels, which restate the id and name rows below them. +pub(super) struct HeaderRow { + pub(super) row: gtk::ListBoxRow, + title: gtk::Label, + id: gtk::Label, +} + +impl HeaderRow { + pub(super) fn set_labels(&self, index: usize, id: &str, name: &str) { + let title = if name.trim().is_empty() { + format!("Board {}", index + 1) + } else { + name.trim().to_string() + }; + set_label(&self.title, &title); + + let id_label = if id.trim().is_empty() { + "id: ".to_string() + } else { + format!("id: {}", id.trim()) + }; + set_label(&self.id, &id_label); + } +} + +pub(super) fn build_header_row( + index: usize, + expanded: bool, + sender: &ComponentSender, +) -> HeaderRow { + let labels = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(2) + .hexpand(true) + .build(); + let title = gtk::Label::builder() + .xalign(0.0) + .css_classes(["heading"]) + .build(); + let id = gtk::Label::builder() + .xalign(0.0) + .css_classes(["dim-label", "caption"]) + .build(); + labels.append(&title); + labels.append(&id); + + let content = row_content_box(gtk::Orientation::Horizontal); + content.append(&labels); + for (label, message) in [ + ( + if expanded { "Collapse" } else { "Expand" }, + Message::BoardsCollapseToggled(index), + ), + ("Up", Message::BoardsMoveItemUp(index)), + ("Down", Message::BoardsMoveItemDown(index)), + ("Duplicate", Message::BoardsDuplicateItem(index)), + ("Remove", Message::BoardsRemoveItem(index)), + ] { + let button = gtk::Button::builder() + .label(label) + .valign(gtk::Align::Center) + .build(); + connect_button(&button, message, sender); + content.append(&button); + } + + HeaderRow { + row: plain_row(&content), + title, + id, + } +} diff --git a/configurator/src/app/pages/boards/rows.rs b/configurator/src/app/pages/boards/rows.rs new file mode 100644 index 00000000..c54c9c19 --- /dev/null +++ b/configurator/src/app/pages/boards/rows.rs @@ -0,0 +1,189 @@ +use relm4::{ComponentSender, adw, gtk}; + +use adw::prelude::*; +use gtk::glib::SignalHandlerId; + +use crate::messages::Message; +use crate::models::{ + BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorPickerId, +}; + +use super::super::super::state::ConfiguratorApp; +use super::NO_SELECTION; + +/// An entry row whose text the model owns, kept with the handler a refresh +/// has to block before writing it. +pub(super) struct TextRow { + pub(super) row: adw::EntryRow, + pub(super) handler: SignalHandlerId, +} + +pub(super) fn build_text_row( + title: &str, + index: usize, + field: BoardItemTextField, + sender: &ComponentSender, +) -> TextRow { + let row = adw::EntryRow::builder().title(title).build(); + let handler = { + let sender = sender.clone(); + row.connect_changed(move |row| { + sender.input(Message::BoardsItemTextChanged( + index, + field, + row.text().to_string(), + )); + }) + }; + TextRow { row, handler } +} + +pub(super) fn build_kind_row( + index: usize, + selected: BoardBackgroundOption, + sender: &ComponentSender, +) -> adw::ComboRow { + let options = BoardBackgroundOption::list(); + let labels: Vec = options + .iter() + .map(|option| option.label().to_string()) + .collect(); + let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let row = adw::ComboRow::builder() + .title("Background") + .model(>k::StringList::new(&label_refs)) + .build(); + + // The selection is part of the rebuild fingerprint, so it is set before + // the handler exists and never written again behind the user's back. + if let Some(position) = options.iter().position(|option| *option == selected) { + row.set_selected(position as u32); + } + { + let sender = sender.clone(); + row.connect_selected_notify(move |row| { + if let Some(option) = options.get(row.selected() as usize) { + sender.input(Message::BoardsBackgroundKindChanged(index, *option)); + } + }); + } + row +} + +pub(super) fn build_toggle_row( + title: &str, + index: usize, + field: BoardItemToggleField, + active: bool, + expanded: bool, + sender: &ComponentSender, +) -> adw::SwitchRow { + let row = adw::SwitchRow::builder() + .title(title) + .active(active) + .visible(expanded) + .build(); + let sender = sender.clone(); + row.connect_active_notify(move |row| { + sender.input(Message::BoardsItemToggleChanged( + index, + field, + row.is_active(), + )); + }); + row +} + +pub(super) fn connect_button( + button: >k::Button, + message: Message, + sender: &ComponentSender, +) { + let sender = sender.clone(); + button.connect_clicked(move |_| sender.input(message.clone())); +} + +pub(super) fn set_label(label: >k::Label, value: &str) { + if label.text() != value { + label.set_text(value); + } +} + +/// Rewrites a combo's model only when the choices themselves changed, and +/// writes both model and selection with the change handler blocked: replacing +/// a model resets the selection to the first row, which would otherwise be +/// reported as if the user had picked it. +/// +/// `shown` is what the combo currently offers, owned by the binding that +/// calls this — the entries themselves, not a rendering of them. +pub(super) fn sync_combo( + row: &adw::ComboRow, + handler: &SignalHandlerId, + shown: &mut Vec, + entries: &[String], + selected: Option, +) { + let rebuild = shown.as_slice() != entries; + let target = selected.map_or(NO_SELECTION, |index| index as u32); + if !rebuild && row.selected() == target { + return; + } + + row.block_signal(handler); + if rebuild { + let refs: Vec<&str> = entries.iter().map(String::as_str).collect(); + row.set_model(Some(>k::StringList::new(&refs))); + shown.clear(); + shown.extend_from_slice(entries); + } + if row.selected() != target { + row.set_selected(target); + } + row.unblock_signal(handler); +} + +pub(super) fn row_content_box(orientation: gtk::Orientation) -> gtk::Box { + gtk::Box::builder() + .orientation(orientation) + .spacing(6) + .margin_top(8) + .margin_bottom(8) + .margin_start(12) + .margin_end(12) + .build() +} + +pub(super) fn plain_row(content: &impl IsA) -> gtk::ListBoxRow { + gtk::ListBoxRow::builder() + .child(content) + .activatable(false) + .selectable(false) + .build() +} + +pub(super) fn note_label(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .wrap(true) + .xalign(0.0) + .css_classes(["dim-label", "caption"]) + .build() +} + +pub(super) fn picker_hex(app: &ConfiguratorApp, id: ColorPickerId) -> &str { + app.color_picker_hex.get(&id).map_or("", String::as_str) +} + +pub(super) fn is_collapsed(app: &ConfiguratorApp, index: usize) -> bool { + app.boards_collapsed.get(index).copied().unwrap_or(false) +} + +/// Error text for a count field with a lower bound, worded as the Iced view +/// worded it. +pub(super) fn validate_min(value: &str, min: usize) -> Option { + match value.trim().parse::() { + Ok(parsed) if parsed >= min => None, + Ok(_) => Some(format!("Minimum: {min}")), + Err(_) => Some("Expected a whole number".to_string()), + } +} diff --git a/configurator/src/app/pages/boards/section.rs b/configurator/src/app/pages/boards/section.rs new file mode 100644 index 00000000..1fb8ad23 --- /dev/null +++ b/configurator/src/app/pages/boards/section.rs @@ -0,0 +1,155 @@ +use relm4::{ComponentSender, adw, gtk}; + +use adw::prelude::*; + +use crate::messages::Message; +use crate::models::{ + BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorPickerId, +}; + +use super::super::super::state::ConfiguratorApp; +use super::super::set_text_blocked; +use super::color::build_color_row; +use super::header::build_header_row; +use super::rows::{build_kind_row, build_text_row, build_toggle_row}; +use super::{BoardValues, SectionLayout}; + +/// One section's refresh: built beside its row, so it owns that row's typed +/// widget handles and the signal handler ids guarding each write. No +/// positional lookup, and no widget the refresh can silently miss. +pub(super) type BoardRowRefresh = Box)>; + +pub(super) struct BoundBoardSection { + pub(super) layout: SectionLayout, + pub(super) refresh: BoardRowRefresh, +} + +/// One board's section: the list box, and the closure that writes the values +/// the layout deliberately left out. +struct BoardSection { + section: gtk::ListBox, + refresh: BoardRowRefresh, +} + +pub(super) fn rebuild_sections( + container: >k::Box, + layouts: &[SectionLayout], + sender: &ComponentSender, +) -> Vec { + // Draining the container, not walking it for a control: the sections that + // replace these carry their own refresh closures. + while let Some(child) = container.first_child() { + container.remove(&child); + } + + let mut sections = Vec::with_capacity(layouts.len()); + for (index, layout) in layouts.iter().enumerate() { + let built = build_section(index, *layout, sender); + container.append(&built.section); + sections.push(BoundBoardSection { + layout: *layout, + refresh: built.refresh, + }); + } + sections +} + +fn build_section( + index: usize, + layout: SectionLayout, + sender: &ComponentSender, +) -> BoardSection { + let section = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .visible(layout.visible) + .build(); + + let header = build_header_row(index, layout.expanded, sender); + section.append(&header.row); + + let id = build_text_row("Board id", index, BoardItemTextField::Id, sender); + id.row.set_visible(layout.expanded); + section.append(&id.row); + + let name = build_text_row("Display name", index, BoardItemTextField::Name, sender); + name.row.set_visible(layout.expanded); + section.append(&name.row); + + let kind_row = build_kind_row(index, layout.background_kind, sender); + kind_row.set_visible(layout.expanded); + section.append(&kind_row); + + let background = build_color_row( + "Background color (0-1)", + ColorPickerId::BoardBackground(index), + index, + Message::BoardsBackgroundColorChanged, + sender, + ); + // The Iced view swapped this for a note explaining why the color is + // inert; a hidden row says the same thing without the clutter. + background + .row + .set_visible(layout.expanded && layout.background_kind == BoardBackgroundOption::Color); + section.append(&background.row); + + let pen_enabled = adw::SwitchRow::builder() + .title("Override default pen color") + .active(layout.pen_enabled) + .visible(layout.expanded) + .build(); + { + let sender = sender.clone(); + pen_enabled.connect_active_notify(move |row| { + sender.input(Message::BoardsDefaultPenEnabledChanged( + index, + row.is_active(), + )); + }); + } + section.append(&pen_enabled); + + let pen = build_color_row( + "Pen color (0-1)", + ColorPickerId::BoardPen(index), + index, + Message::BoardsDefaultPenColorChanged, + sender, + ); + pen.row.set_visible(layout.expanded && layout.pen_enabled); + section.append(&pen.row); + + for (title, field, active) in [ + ( + "Auto-adjust pen", + BoardItemToggleField::AutoAdjustPen, + layout.auto_adjust, + ), + ("Persist", BoardItemToggleField::Persist, layout.persist), + ( + "Configured default pinned", + BoardItemToggleField::Pinned, + layout.pinned, + ), + ] { + section.append(&build_toggle_row( + title, + index, + field, + active, + layout.expanded, + sender, + )); + } + + let refresh: BoardRowRefresh = Box::new(move |values| { + header.set_labels(index, values.id, values.name); + set_text_blocked(&id.row, &id.handler, values.id); + set_text_blocked(&name.row, &name.handler, values.name); + background.refresh(&values.background); + pen.refresh(&values.pen); + }); + + BoardSection { section, refresh } +} diff --git a/configurator/src/app/pages/boards/tests.rs b/configurator/src/app/pages/boards/tests.rs new file mode 100644 index 00000000..33b88f25 --- /dev/null +++ b/configurator/src/app/pages/boards/tests.rs @@ -0,0 +1,106 @@ +use super::*; +use crate::models::SearchQuery; + +/// Two boards, so a fingerprint covers more than one section. +fn app_with_two_boards() -> ConfiguratorApp { + let (mut app, _effects) = ConfiguratorApp::new_app(); + while app.draft.boards.items.len() < 2 { + let Some(item) = app.draft.boards.items.first().cloned() else { + break; + }; + app.draft.boards.items.push(item); + app.boards_collapsed.push(false); + } + app +} + +/// The law the binding rests on: one layout per board, so the sections it +/// builds and the values it hands them are indexed by the same thing. +#[test] +fn there_is_one_layout_per_board() { + let app = app_with_two_boards(); + let layouts = section_layouts(&app, &app.search_summary()); + + assert_eq!(layouts.len(), app.draft.boards.items.len()); +} + +/// The caret guarantee, stated as the layout law it rests on: no keystroke +/// in a board's text may rebuild the row it lands in. +#[test] +fn typing_into_a_board_field_leaves_the_layout_alone() { + let mut app = app_with_two_boards(); + let summary = app.search_summary(); + let before = section_layouts(&app, &summary); + + let Some(item) = app.draft.boards.items.first_mut() else { + return; + }; + item.name = "Half typ".to_string(); + item.id = "half-typ".to_string(); + item.background_color.set_component(0, "0.1".to_string()); + app.color_picker_hex + .insert(ColorPickerId::BoardBackground(0), "#1A00".to_string()); + + let summary = app.search_summary(); + assert_eq!(before, section_layouts(&app, &summary)); + // The values a refresh writes in place carry the edit instead. + assert_eq!(app.draft.boards.items[0].id, "half-typ"); + assert_eq!( + app.color_picker_hex + .get(&ColorPickerId::BoardBackground(0)) + .map(String::as_str), + Some("#1A00") + ); +} + +#[test] +fn toggling_a_board_switch_changes_the_layout() { + let mut app = app_with_two_boards(); + let summary = app.search_summary(); + let before = section_layouts(&app, &summary); + + let Some(item) = app.draft.boards.items.first_mut() else { + return; + }; + item.pinned = !item.pinned; + + let summary = app.search_summary(); + assert_ne!(before, section_layouts(&app, &summary)); +} + +#[test] +fn collapsing_a_board_changes_the_layout() { + let mut app = app_with_two_boards(); + let summary = app.search_summary(); + let before = section_layouts(&app, &summary); + + let Some(collapsed) = app.boards_collapsed.first_mut() else { + return; + }; + *collapsed = true; + + let summary = app.search_summary(); + assert_ne!(before, section_layouts(&app, &summary)); +} + +/// Search visibility belongs to the layout too: a rebuild is what applies +/// it now that nothing refreshes a section in place. +#[test] +fn a_search_that_hides_a_board_changes_the_layout() { + let mut app = app_with_two_boards(); + let Some(item) = app.draft.boards.items.first_mut() else { + return; + }; + item.name = "zqxwvu".to_string(); + let summary = app.search_summary(); + let before = section_layouts(&app, &summary); + + app.search_query = SearchQuery::new("zqxwvu"); + let summary = app.search_summary(); + let layouts = section_layouts(&app, &summary); + + assert_ne!(before, layouts); + let visible: Vec = layouts.iter().map(|layout| layout.visible).collect(); + assert_eq!(visible.first(), Some(&true)); + assert!(visible.iter().skip(1).all(|visible| !visible)); +} diff --git a/configurator/src/app/pages/daemon.rs b/configurator/src/app/pages/daemon.rs index 09ed832c..70c03183 100644 --- a/configurator/src/app/pages/daemon.rs +++ b/configurator/src/app/pages/daemon.rs @@ -7,33 +7,37 @@ //! `DaemonRuntimeStatus` fields the Iced view read, and every button sends //! the `DaemonAction` that view sent. +mod groups; +mod status; +#[cfg(test)] +mod tests; +mod widgets; + +use relm4::adw; use relm4::prelude::*; -use relm4::{adw, gtk}; use adw::prelude::*; -use crate::messages::Message; -use crate::models::{ - DaemonAction, DaemonRuntimeStatus, LightShortcutApplyCapability, ShortcutApplyCapability, TabId, -}; +use crate::models::TabId; use super::super::search::{AppSearchSummary, SearchArea}; use super::super::state::ConfiguratorApp; -use super::{Binding, BuiltPage, set_text_blocked}; +use super::{Binding, BuiltPage}; +use widgets::set_visible; pub(super) fn build(sender: &ComponentSender) -> BuiltPage { let page = adw::PreferencesPage::new(); let mut bindings: Vec = Vec::new(); - page.add(&overview_group(sender, &mut bindings)); + page.add(&groups::overview_group(sender, &mut bindings)); for section in DaemonSection::ALL { let group = match section { - DaemonSection::Install => install_group(sender, &mut bindings), - DaemonSection::Shortcut => shortcut_group(sender, &mut bindings), - DaemonSection::LightControls => light_controls_group(sender, &mut bindings), - DaemonSection::Start => start_group(sender, &mut bindings), - DaemonSection::TechnicalDetails => details_group(sender, &mut bindings), + DaemonSection::Install => groups::install_group(sender, &mut bindings), + DaemonSection::Shortcut => groups::shortcut_group(sender, &mut bindings), + DaemonSection::LightControls => groups::light_controls_group(sender, &mut bindings), + DaemonSection::Start => groups::start_group(sender, &mut bindings), + DaemonSection::TechnicalDetails => groups::details_group(sender, &mut bindings), }; page.add(&group); bindings.push(Box::new(move |app, summary| { @@ -106,791 +110,3 @@ fn shown_areas(summary: &AppSearchSummary) -> ShownAreas { light: matches(SearchArea::DaemonLightControls), } } - -// ---- Groups ------------------------------------------------------------ - -fn overview_group( - sender: &ComponentSender, - bindings: &mut Vec, -) -> adw::PreferencesGroup { - let group = adw::PreferencesGroup::builder() - .title("Background Mode") - .description("Run wayscriber in the background and toggle it with a keyboard shortcut.") - .build(); - - let body = column_box(); - let status_row = row_box(); - let status_label = body_label(""); - status_label.add_css_class("heading"); - let refresh = action_button("Refresh", DaemonAction::RefreshStatus, sender); - status_row.append(&status_label); - status_row.append(&refresh); - body.append(&status_row); - - let feedback_label = body_label(""); - body.append(&feedback_label); - let busy_label = caption_label("Working..."); - body.append(&busy_label); - let loading_label = hint_label("Checking your system and background service status..."); - body.append(&loading_label); - group.add(&body); - - bindings.push(Box::new(move |app, summary| { - let (text, tone) = overall_status(app.daemon_status.as_ref()); - set_label(&status_label, text); - apply_tone(&status_label, tone); - set_visible(&status_row, shown_areas(summary).status); - set_sensitive(&refresh, !app.daemon_busy); - - match app.daemon_feedback.as_deref() { - Some(feedback) => { - set_label(&feedback_label, feedback); - apply_tone(&feedback_label, feedback_tone(feedback)); - set_visible(&feedback_label, true); - } - None => set_visible(&feedback_label, false), - } - - set_visible(&busy_label, app.daemon_busy); - set_visible(&loading_label, app.daemon_status.is_none()); - })); - - group -} - -fn install_group( - sender: &ComponentSender, - bindings: &mut Vec, -) -> adw::PreferencesGroup { - let group = adw::PreferencesGroup::builder() - .title("Step 1 \u{2014} Install the service") - .description("Install wayscriber as a background service.") - .build(); - - let row = row_box(); - let state_label = body_label(""); - let install = action_button( - install_button_label(false), - DaemonAction::InstallOrUpdateService, - sender, - ); - row.append(&state_label); - row.append(&install); - group.add(&row); - - bindings.push(Box::new(move |app, _summary| { - let installed = service_installed(app); - let (text, tone) = install_status(installed); - set_label(&state_label, text); - apply_tone(&state_label, tone); - set_button_label(&install, install_button_label(installed)); - set_sensitive(&install, !app.daemon_busy); - })); - - group -} - -fn shortcut_group( - sender: &ComponentSender, - bindings: &mut Vec, -) -> adw::PreferencesGroup { - let group = adw::PreferencesGroup::builder() - .title("Step 2 \u{2014} Set your shortcut") - .build(); - - let locked_label = hint_label("Install the background service first, then set your shortcut."); - group.add(&locked_label); - - let body = column_box(); - body.append(&body_label( - "Choose a keyboard shortcut to toggle drawing on/off.", - )); - body.append(&hint_label( - "The shortcut takes effect after the background service is installed and running.", - )); - let configured_label = hint_label(""); - body.append(&configured_label); - let manual_label = warning_label( - "Automatic shortcut setup is unavailable here. Add a manual keybind for `wayscriber --daemon-toggle`.", - ); - body.append(&manual_label); - - let entry = gtk::Entry::builder().hexpand(true).build(); - let entry_handler = { - let sender = sender.clone(); - entry.connect_changed(move |entry| { - sender.input(Message::DaemonShortcutInputChanged( - entry.text().to_string(), - )); - }) - }; - body.append(&entry); - - let apply = action_button("Apply Shortcut", DaemonAction::ApplyShortcut, sender); - apply.add_css_class("suggested-action"); - body.append(&apply); - group.add(&body); - - bindings.push(Box::new(move |app, _summary| { - let installed = service_installed(app); - set_visible(&locked_label, !installed); - set_visible(&body, installed); - - let capability = app - .daemon_status - .as_ref() - .map(|status| status.shortcut_apply_capability); - let placeholder = shortcut_placeholder(capability); - if entry.placeholder_text().as_deref() != Some(placeholder) { - entry.set_placeholder_text(Some(placeholder)); - } - set_text_blocked(&entry, &entry_handler, &app.daemon_shortcut_input); - - match app - .daemon_status - .as_ref() - .and_then(|status| status.configured_shortcut.as_deref()) - { - Some(configured) => { - set_label( - &configured_label, - &format!("Current shortcut: {configured}"), - ); - set_visible(&configured_label, true); - } - None => set_visible(&configured_label, false), - } - - let manual = capability == Some(ShortcutApplyCapability::Manual); - set_visible(&manual_label, manual); - set_sensitive(&apply, !app.daemon_busy && !manual); - })); - - group -} - -fn light_controls_group( - sender: &ComponentSender, - bindings: &mut Vec, -) -> adw::PreferencesGroup { - let group = adw::PreferencesGroup::builder() - .title("Light passthrough controls") - .description("Install global controls for light passthrough and quick drawing.") - .build(); - - let path_label = hint_label(""); - group.add(&path_label); - - let native_body = column_box(); - let service_warning = warning_label( - "Install the background service first so these bindings have a daemon to control.", - ); - native_body.append(&service_warning); - let native_row = row_box(); - let state_label = body_label(""); - let install = action_button( - "Install Hyprland Light Controls", - DaemonAction::ApplyLightControls, - sender, - ); - install.add_css_class("suggested-action"); - native_row.append(&state_label); - native_row.append(&install); - native_body.append(&native_row); - group.add(&native_body); - - let manual_label = warning_label( - "Automatic light controls setup is unavailable here. Add compositor bindings for `wayscriber --light-toggle` and `wayscriber --light-draw-toggle`.", - ); - group.add(&manual_label); - - bindings.push(Box::new(move |app, _summary| { - let status = app.daemon_status.as_ref(); - match status.and_then(|status| status.light_controls_config_path.as_deref()) { - Some(path) => { - set_label(&path_label, &format!("Hyprland include: {path}")); - set_visible(&path_label, true); - } - None => set_visible(&path_label, false), - } - - let capability = status - .map(|status| status.light_shortcut_apply_capability) - .unwrap_or(LightShortcutApplyCapability::Manual); - let native = capability == LightShortcutApplyCapability::HyprlandNative; - set_visible(&native_body, native); - set_visible(&manual_label, !native); - - let installed = service_installed(app); - set_visible(&service_warning, !installed); - let (text, tone) = - light_controls_status(status.is_some_and(|status| status.light_controls_configured)); - set_label(&state_label, text); - apply_tone(&state_label, tone); - set_sensitive(&install, !app.daemon_busy && installed); - })); - - group -} - -fn start_group( - sender: &ComponentSender, - bindings: &mut Vec, -) -> adw::PreferencesGroup { - let group = adw::PreferencesGroup::builder() - .title("Step 3 \u{2014} Start the service") - .build(); - - let locked_label = hint_label("Install the background service first."); - group.add(&locked_label); - - let body = column_box(); - body.append(&body_label("Enable and start the background service.")); - let state_label = body_label(""); - body.append(&state_label); - - let running_row = row_box(); - let restart = action_button("Restart", DaemonAction::RestartService, sender); - let stop = action_button( - "Stop & Disable", - DaemonAction::StopAndDisableService, - sender, - ); - running_row.append(&restart); - running_row.append(&stop); - body.append(&running_row); - - let start = action_button("Start", DaemonAction::EnableAndStartService, sender); - start.add_css_class("suggested-action"); - body.append(&start); - group.add(&body); - - bindings.push(Box::new(move |app, _summary| { - let installed = service_installed(app); - set_visible(&locked_label, !installed); - set_visible(&body, installed); - - let status = app.daemon_status.as_ref(); - let running = status.is_some_and(|status| status.service_active); - let enabled = status.is_some_and(|status| status.service_enabled); - let (text, tone) = service_status(running, enabled); - set_label(&state_label, text); - apply_tone(&state_label, tone); - - set_visible(&running_row, running); - set_visible(&start, !running); - for button in [&restart, &stop, &start] { - set_sensitive(button, !app.daemon_busy); - } - })); - - group -} - -fn details_group( - sender: &ComponentSender, - bindings: &mut Vec, -) -> adw::PreferencesGroup { - let group = adw::PreferencesGroup::builder().title("Details").build(); - - let body = column_box(); - let detecting_label = hint_label("Detecting environment..."); - let desktop_label = hint_label(""); - let backend_label = hint_label(""); - let shortcut_capability_label = hint_label(""); - let light_capability_label = hint_label(""); - let service_file_label = hint_label(""); - let light_controls_label = hint_label(""); - let missing_tools_label = warning_label(""); - for label in [ - &detecting_label, - &desktop_label, - &backend_label, - &shortcut_capability_label, - &light_capability_label, - &service_file_label, - &light_controls_label, - &missing_tools_label, - ] { - body.append(label); - } - - let refresh = action_button("Refresh", DaemonAction::RefreshStatus, sender); - body.append(&refresh); - group.add(&body); - - bindings.push(Box::new(move |app, _summary| { - set_sensitive(&refresh, !app.daemon_busy); - let Some(status) = app.daemon_status.as_ref() else { - set_visible(&detecting_label, true); - for label in [ - &desktop_label, - &backend_label, - &shortcut_capability_label, - &light_capability_label, - &service_file_label, - &light_controls_label, - &missing_tools_label, - ] { - set_visible(label, false); - } - return; - }; - - set_visible(&detecting_label, false); - for (label, text) in [ - ( - &desktop_label, - format!("Desktop: {}", status.desktop.label()), - ), - ( - &backend_label, - status.shortcut_backend.friendly_label().to_string(), - ), - ( - &shortcut_capability_label, - status - .shortcut_apply_capability - .friendly_label() - .to_string(), - ), - ( - &light_capability_label, - status - .light_shortcut_apply_capability - .friendly_label() - .to_string(), - ), - ] { - set_label(label, &text); - set_visible(label, true); - } - - for (label, text) in [ - ( - &service_file_label, - status - .service_unit_path - .as_deref() - .map(|path| format!("Service file: {path}")), - ), - (&light_controls_label, light_controls_details(status)), - (&missing_tools_label, missing_tools(status)), - ] { - match text { - Some(text) => { - set_label(label, &text); - set_visible(label, true); - } - None => set_visible(label, false), - } - } - })); - - group -} - -// ---- Status wording ---------------------------------------------------- - -/// Emphasis for a status line, mapped to the Adwaita state classes that -/// stand in for the Iced view's literal colors. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Tone { - Neutral, - Positive, - Caution, - Negative, -} - -impl Tone { - const CLASSES: [&'static str; 4] = ["dim-label", "success", "warning", "error"]; - - fn css_class(self) -> &'static str { - match self { - Self::Neutral => "dim-label", - Self::Positive => "success", - Self::Caution => "warning", - Self::Negative => "error", - } - } -} - -fn overall_status(status: Option<&DaemonRuntimeStatus>) -> (&'static str, Tone) { - match status { - None => ("Status: Detecting...", Tone::Neutral), - Some(status) if status.service_active => ("Status: Running", Tone::Positive), - Some(status) if status.service_installed => { - ("Status: Installed, not running", Tone::Caution) - } - Some(_) => ("Status: Not installed", Tone::Neutral), - } -} - -fn feedback_tone(feedback: &str) -> Tone { - let feedback = feedback.to_ascii_lowercase(); - if feedback.contains("failed") || feedback.contains("error") { - Tone::Negative - } else { - Tone::Positive - } -} - -fn install_status(installed: bool) -> (&'static str, Tone) { - if installed { - ("Installed \u{2713}", Tone::Positive) - } else { - ("Not installed", Tone::Neutral) - } -} - -fn install_button_label(installed: bool) -> &'static str { - if installed { - "Update Service" - } else { - "Install Service" - } -} - -fn light_controls_status(configured: bool) -> (&'static str, Tone) { - if configured { - ("Configured \u{2713}", Tone::Positive) - } else { - ("Not configured", Tone::Neutral) - } -} - -fn service_status(running: bool, enabled: bool) -> (&'static str, Tone) { - match (running, enabled) { - (true, true) => ("Running \u{2713}", Tone::Positive), - (true, false) => ("Running (not enabled)", Tone::Caution), - (false, true) => ("Enabled, not running", Tone::Caution), - (false, false) => ("Stopped and disabled", Tone::Neutral), - } -} - -fn shortcut_placeholder(capability: Option) -> &'static str { - match capability { - Some(ShortcutApplyCapability::GnomeCustomShortcut) => "e.g. Super+G or g", - Some(ShortcutApplyCapability::PortalServiceDropIn) => "e.g. Ctrl+Shift+G or g", - _ => "e.g. Ctrl+Shift+G", - } -} - -fn light_controls_details(status: &DaemonRuntimeStatus) -> Option { - let path = status.light_controls_config_path.as_deref()?; - Some(if status.light_controls_configured { - format!("Light controls: configured at {path}") - } else { - format!("Light controls include: {path}") - }) -} - -/// The tool availability line, shown only when something is missing. -fn missing_tools(status: &DaemonRuntimeStatus) -> Option { - let mut missing = Vec::new(); - if !status.systemctl_available { - missing.push("systemctl"); - } - if !status.gsettings_available { - missing.push("gsettings"); - } - (!missing.is_empty()).then(|| format!("Missing tools: {}", missing.join(", "))) -} - -fn service_installed(app: &ConfiguratorApp) -> bool { - app.daemon_status - .as_ref() - .is_some_and(|status| status.service_installed) -} - -// ---- Widget helpers ---------------------------------------------------- - -fn column_box() -> gtk::Box { - gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .build() -} - -fn row_box() -> gtk::Box { - gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(12) - .build() -} - -fn body_label(text: &str) -> gtk::Label { - gtk::Label::builder() - .label(text) - .xalign(0.0) - .wrap(true) - .halign(gtk::Align::Start) - .valign(gtk::Align::Center) - .build() -} - -fn caption_label(text: &str) -> gtk::Label { - let label = body_label(text); - label.add_css_class("caption"); - label -} - -fn hint_label(text: &str) -> gtk::Label { - let label = caption_label(text); - label.add_css_class("dim-label"); - label -} - -fn warning_label(text: &str) -> gtk::Label { - let label = caption_label(text); - label.add_css_class("warning"); - label -} - -fn action_button( - label: &str, - action: DaemonAction, - sender: &ComponentSender, -) -> gtk::Button { - let button = gtk::Button::builder() - .label(label) - .halign(gtk::Align::Start) - .valign(gtk::Align::Center) - .build(); - let sender = sender.clone(); - button.connect_clicked(move |_| sender.input(Message::DaemonActionRequested(action))); - button -} - -fn set_label(label: >k::Label, text: &str) { - if label.label() != text { - label.set_label(text); - } -} - -fn set_button_label(button: >k::Button, text: &str) { - if button.label().as_deref() != Some(text) { - button.set_label(text); - } -} - -/// Writes the widget's own visibility flag, never `is_visible`: a child of a -/// hidden group reports invisible while its own flag still says otherwise, -/// and skipping the write there would leak the stale state the moment the -/// group comes back. -fn set_visible(widget: &impl IsA, visible: bool) { - if widget.get_visible() != visible { - widget.set_visible(visible); - } -} - -fn set_sensitive(widget: &impl IsA, sensitive: bool) { - if widget.is_sensitive() != sensitive { - widget.set_sensitive(sensitive); - } -} - -fn apply_tone(widget: &impl IsA, tone: Tone) { - let wanted = tone.css_class(); - for class in Tone::CLASSES { - let has_class = widget.has_css_class(class); - if class == wanted { - if !has_class { - widget.add_css_class(class); - } - } else if has_class { - widget.remove_css_class(class); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::{DesktopEnvironment, ShortcutBackend}; - - fn shown_all() -> ShownAreas { - ShownAreas { - status: true, - service: true, - shortcut: true, - light: true, - } - } - - fn visible_sections(shown: ShownAreas) -> Vec { - DaemonSection::ALL - .into_iter() - .filter(|section| daemon_section_visible(*section, shown)) - .collect() - } - - fn test_status() -> DaemonRuntimeStatus { - DaemonRuntimeStatus { - desktop: DesktopEnvironment::Hyprland, - shortcut_backend: ShortcutBackend::Manual, - shortcut_apply_capability: ShortcutApplyCapability::Manual, - light_shortcut_apply_capability: LightShortcutApplyCapability::HyprlandNative, - systemctl_available: true, - gsettings_available: true, - service_installed: false, - service_enabled: false, - service_active: false, - service_unit_path: None, - configured_shortcut: None, - light_controls_configured: false, - light_controls_config_path: None, - } - } - - #[test] - fn daemon_sections_keep_default_setup_order() { - assert_eq!( - visible_sections(shown_all()), - vec![ - DaemonSection::Install, - DaemonSection::Shortcut, - DaemonSection::LightControls, - DaemonSection::Start, - DaemonSection::TechnicalDetails, - ], - ); - } - - #[test] - fn service_area_keeps_install_start_and_details() { - assert_eq!( - visible_sections(ShownAreas { - status: false, - service: true, - shortcut: false, - light: false, - }), - vec![ - DaemonSection::Install, - DaemonSection::Start, - DaemonSection::TechnicalDetails, - ], - ); - } - - #[test] - fn status_area_alone_keeps_only_details() { - assert_eq!( - visible_sections(ShownAreas { - status: true, - service: false, - shortcut: false, - light: false, - }), - vec![DaemonSection::TechnicalDetails], - ); - } - - #[test] - fn shortcut_area_alone_keeps_only_the_shortcut_step() { - assert_eq!( - visible_sections(ShownAreas { - status: false, - service: false, - shortcut: true, - light: false, - }), - vec![DaemonSection::Shortcut], - ); - } - - #[test] - fn overall_status_follows_service_state() { - assert_eq!(overall_status(None).0, "Status: Detecting..."); - - let mut status = test_status(); - assert_eq!(overall_status(Some(&status)).0, "Status: Not installed"); - - status.service_installed = true; - assert_eq!( - overall_status(Some(&status)), - ("Status: Installed, not running", Tone::Caution) - ); - - status.service_active = true; - assert_eq!( - overall_status(Some(&status)), - ("Status: Running", Tone::Positive) - ); - } - - #[test] - fn feedback_tone_flags_failures() { - assert_eq!( - feedback_tone("Background setup action failed"), - Tone::Negative - ); - assert_eq!(feedback_tone("gsettings ERROR: nope"), Tone::Negative); - assert_eq!( - feedback_tone("Background mode status loaded."), - Tone::Positive - ); - } - - #[test] - fn service_status_separates_running_from_enabled() { - assert_eq!(service_status(true, true).0, "Running \u{2713}"); - assert_eq!(service_status(true, false).0, "Running (not enabled)"); - assert_eq!(service_status(false, true).0, "Enabled, not running"); - assert_eq!(service_status(false, false).0, "Stopped and disabled"); - } - - #[test] - fn shortcut_placeholder_follows_apply_capability() { - assert_eq!( - shortcut_placeholder(Some(ShortcutApplyCapability::GnomeCustomShortcut)), - "e.g. Super+G or g" - ); - assert_eq!( - shortcut_placeholder(Some(ShortcutApplyCapability::PortalServiceDropIn)), - "e.g. Ctrl+Shift+G or g" - ); - assert_eq!( - shortcut_placeholder(Some(ShortcutApplyCapability::Manual)), - "e.g. Ctrl+Shift+G" - ); - assert_eq!(shortcut_placeholder(None), "e.g. Ctrl+Shift+G"); - } - - #[test] - fn missing_tools_only_reports_absent_ones() { - let mut status = test_status(); - assert_eq!(missing_tools(&status), None); - - status.gsettings_available = false; - assert_eq!( - missing_tools(&status).as_deref(), - Some("Missing tools: gsettings") - ); - - status.systemctl_available = false; - assert_eq!( - missing_tools(&status).as_deref(), - Some("Missing tools: systemctl, gsettings") - ); - } - - #[test] - fn light_controls_details_reflect_configured_state() { - let mut status = test_status(); - assert_eq!(light_controls_details(&status), None); - - status.light_controls_config_path = Some("/tmp/wayscriber.conf".to_string()); - assert_eq!( - light_controls_details(&status).as_deref(), - Some("Light controls include: /tmp/wayscriber.conf") - ); - - status.light_controls_configured = true; - assert_eq!( - light_controls_details(&status).as_deref(), - Some("Light controls: configured at /tmp/wayscriber.conf") - ); - } -} diff --git a/configurator/src/app/pages/daemon/groups.rs b/configurator/src/app/pages/daemon/groups.rs new file mode 100644 index 00000000..177c809c --- /dev/null +++ b/configurator/src/app/pages/daemon/groups.rs @@ -0,0 +1,400 @@ +use relm4::{ComponentSender, adw, gtk}; + +use adw::prelude::*; + +use crate::messages::Message; +use crate::models::{DaemonAction, LightShortcutApplyCapability, ShortcutApplyCapability}; + +use super::super::super::state::ConfiguratorApp; +use super::super::{Binding, set_text_blocked}; +use super::shown_areas; +use super::status::{ + apply_tone, feedback_tone, install_button_label, install_status, light_controls_details, + light_controls_status, missing_tools, overall_status, service_installed, service_status, + shortcut_placeholder, +}; +use super::widgets::{ + action_button, body_label, caption_label, column_box, hint_label, row_box, set_button_label, + set_label, set_sensitive, set_visible, warning_label, +}; + +pub(super) fn overview_group( + sender: &ComponentSender, + bindings: &mut Vec, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Background Mode") + .description("Run wayscriber in the background and toggle it with a keyboard shortcut.") + .build(); + + let body = column_box(); + let status_row = row_box(); + let status_label = body_label(""); + status_label.add_css_class("heading"); + let refresh = action_button("Refresh", DaemonAction::RefreshStatus, sender); + status_row.append(&status_label); + status_row.append(&refresh); + body.append(&status_row); + + let feedback_label = body_label(""); + body.append(&feedback_label); + let busy_label = caption_label("Working..."); + body.append(&busy_label); + let loading_label = hint_label("Checking your system and background service status..."); + body.append(&loading_label); + group.add(&body); + + bindings.push(Box::new(move |app, summary| { + let (text, tone) = overall_status(app.daemon_status.as_ref()); + set_label(&status_label, text); + apply_tone(&status_label, tone); + set_visible(&status_row, shown_areas(summary).status); + set_sensitive(&refresh, !app.daemon_busy); + + match app.daemon_feedback.as_deref() { + Some(feedback) => { + set_label(&feedback_label, feedback); + apply_tone(&feedback_label, feedback_tone(feedback)); + set_visible(&feedback_label, true); + } + None => set_visible(&feedback_label, false), + } + + set_visible(&busy_label, app.daemon_busy); + set_visible(&loading_label, app.daemon_status.is_none()); + })); + + group +} + +pub(super) fn install_group( + sender: &ComponentSender, + bindings: &mut Vec, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Step 1 \u{2014} Install the service") + .description("Install wayscriber as a background service.") + .build(); + + let row = row_box(); + let state_label = body_label(""); + let install = action_button( + install_button_label(false), + DaemonAction::InstallOrUpdateService, + sender, + ); + row.append(&state_label); + row.append(&install); + group.add(&row); + + bindings.push(Box::new(move |app, _summary| { + let installed = service_installed(app); + let (text, tone) = install_status(installed); + set_label(&state_label, text); + apply_tone(&state_label, tone); + set_button_label(&install, install_button_label(installed)); + set_sensitive(&install, !app.daemon_busy); + })); + + group +} + +pub(super) fn shortcut_group( + sender: &ComponentSender, + bindings: &mut Vec, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Step 2 \u{2014} Set your shortcut") + .build(); + + let locked_label = hint_label("Install the background service first, then set your shortcut."); + group.add(&locked_label); + + let body = column_box(); + body.append(&body_label( + "Choose a keyboard shortcut to toggle drawing on/off.", + )); + body.append(&hint_label( + "The shortcut takes effect after the background service is installed and running.", + )); + let configured_label = hint_label(""); + body.append(&configured_label); + let manual_label = warning_label( + "Automatic shortcut setup is unavailable here. Add a manual keybind for `wayscriber --daemon-toggle`.", + ); + body.append(&manual_label); + + let entry = gtk::Entry::builder().hexpand(true).build(); + let entry_handler = { + let sender = sender.clone(); + entry.connect_changed(move |entry| { + sender.input(Message::DaemonShortcutInputChanged( + entry.text().to_string(), + )); + }) + }; + body.append(&entry); + + let apply = action_button("Apply Shortcut", DaemonAction::ApplyShortcut, sender); + apply.add_css_class("suggested-action"); + body.append(&apply); + group.add(&body); + + bindings.push(Box::new(move |app, _summary| { + let installed = service_installed(app); + set_visible(&locked_label, !installed); + set_visible(&body, installed); + + let capability = app + .daemon_status + .as_ref() + .map(|status| status.shortcut_apply_capability); + let placeholder = shortcut_placeholder(capability); + if entry.placeholder_text().as_deref() != Some(placeholder) { + entry.set_placeholder_text(Some(placeholder)); + } + set_text_blocked(&entry, &entry_handler, &app.daemon_shortcut_input); + + match app + .daemon_status + .as_ref() + .and_then(|status| status.configured_shortcut.as_deref()) + { + Some(configured) => { + set_label( + &configured_label, + &format!("Current shortcut: {configured}"), + ); + set_visible(&configured_label, true); + } + None => set_visible(&configured_label, false), + } + + let manual = capability == Some(ShortcutApplyCapability::Manual); + set_visible(&manual_label, manual); + set_sensitive(&apply, !app.daemon_busy && !manual); + })); + + group +} + +pub(super) fn light_controls_group( + sender: &ComponentSender, + bindings: &mut Vec, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Light passthrough controls") + .description("Install global controls for light passthrough and quick drawing.") + .build(); + + let path_label = hint_label(""); + group.add(&path_label); + + let native_body = column_box(); + let service_warning = warning_label( + "Install the background service first so these bindings have a daemon to control.", + ); + native_body.append(&service_warning); + let native_row = row_box(); + let state_label = body_label(""); + let install = action_button( + "Install Hyprland Light Controls", + DaemonAction::ApplyLightControls, + sender, + ); + install.add_css_class("suggested-action"); + native_row.append(&state_label); + native_row.append(&install); + native_body.append(&native_row); + group.add(&native_body); + + let manual_label = warning_label( + "Automatic light controls setup is unavailable here. Add compositor bindings for `wayscriber --light-toggle` and `wayscriber --light-draw-toggle`.", + ); + group.add(&manual_label); + + bindings.push(Box::new(move |app, _summary| { + let status = app.daemon_status.as_ref(); + match status.and_then(|status| status.light_controls_config_path.as_deref()) { + Some(path) => { + set_label(&path_label, &format!("Hyprland include: {path}")); + set_visible(&path_label, true); + } + None => set_visible(&path_label, false), + } + + let capability = status + .map(|status| status.light_shortcut_apply_capability) + .unwrap_or(LightShortcutApplyCapability::Manual); + let native = capability == LightShortcutApplyCapability::HyprlandNative; + set_visible(&native_body, native); + set_visible(&manual_label, !native); + + let installed = service_installed(app); + set_visible(&service_warning, !installed); + let (text, tone) = + light_controls_status(status.is_some_and(|status| status.light_controls_configured)); + set_label(&state_label, text); + apply_tone(&state_label, tone); + set_sensitive(&install, !app.daemon_busy && installed); + })); + + group +} + +pub(super) fn start_group( + sender: &ComponentSender, + bindings: &mut Vec, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Step 3 \u{2014} Start the service") + .build(); + + let locked_label = hint_label("Install the background service first."); + group.add(&locked_label); + + let body = column_box(); + body.append(&body_label("Enable and start the background service.")); + let state_label = body_label(""); + body.append(&state_label); + + let running_row = row_box(); + let restart = action_button("Restart", DaemonAction::RestartService, sender); + let stop = action_button( + "Stop & Disable", + DaemonAction::StopAndDisableService, + sender, + ); + running_row.append(&restart); + running_row.append(&stop); + body.append(&running_row); + + let start = action_button("Start", DaemonAction::EnableAndStartService, sender); + start.add_css_class("suggested-action"); + body.append(&start); + group.add(&body); + + bindings.push(Box::new(move |app, _summary| { + let installed = service_installed(app); + set_visible(&locked_label, !installed); + set_visible(&body, installed); + + let status = app.daemon_status.as_ref(); + let running = status.is_some_and(|status| status.service_active); + let enabled = status.is_some_and(|status| status.service_enabled); + let (text, tone) = service_status(running, enabled); + set_label(&state_label, text); + apply_tone(&state_label, tone); + + set_visible(&running_row, running); + set_visible(&start, !running); + for button in [&restart, &stop, &start] { + set_sensitive(button, !app.daemon_busy); + } + })); + + group +} + +pub(super) fn details_group( + sender: &ComponentSender, + bindings: &mut Vec, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder().title("Details").build(); + + let body = column_box(); + let detecting_label = hint_label("Detecting environment..."); + let desktop_label = hint_label(""); + let backend_label = hint_label(""); + let shortcut_capability_label = hint_label(""); + let light_capability_label = hint_label(""); + let service_file_label = hint_label(""); + let light_controls_label = hint_label(""); + let missing_tools_label = warning_label(""); + for label in [ + &detecting_label, + &desktop_label, + &backend_label, + &shortcut_capability_label, + &light_capability_label, + &service_file_label, + &light_controls_label, + &missing_tools_label, + ] { + body.append(label); + } + + let refresh = action_button("Refresh", DaemonAction::RefreshStatus, sender); + body.append(&refresh); + group.add(&body); + + bindings.push(Box::new(move |app, _summary| { + set_sensitive(&refresh, !app.daemon_busy); + let Some(status) = app.daemon_status.as_ref() else { + set_visible(&detecting_label, true); + for label in [ + &desktop_label, + &backend_label, + &shortcut_capability_label, + &light_capability_label, + &service_file_label, + &light_controls_label, + &missing_tools_label, + ] { + set_visible(label, false); + } + return; + }; + + set_visible(&detecting_label, false); + for (label, text) in [ + ( + &desktop_label, + format!("Desktop: {}", status.desktop.label()), + ), + ( + &backend_label, + status.shortcut_backend.friendly_label().to_string(), + ), + ( + &shortcut_capability_label, + status + .shortcut_apply_capability + .friendly_label() + .to_string(), + ), + ( + &light_capability_label, + status + .light_shortcut_apply_capability + .friendly_label() + .to_string(), + ), + ] { + set_label(label, &text); + set_visible(label, true); + } + + for (label, text) in [ + ( + &service_file_label, + status + .service_unit_path + .as_deref() + .map(|path| format!("Service file: {path}")), + ), + (&light_controls_label, light_controls_details(status)), + (&missing_tools_label, missing_tools(status)), + ] { + match text { + Some(text) => { + set_label(label, &text); + set_visible(label, true); + } + None => set_visible(label, false), + } + } + })); + + group +} diff --git a/configurator/src/app/pages/daemon/status.rs b/configurator/src/app/pages/daemon/status.rs new file mode 100644 index 00000000..0a960098 --- /dev/null +++ b/configurator/src/app/pages/daemon/status.rs @@ -0,0 +1,132 @@ +use relm4::gtk; + +use gtk::prelude::*; + +use crate::models::{DaemonRuntimeStatus, ShortcutApplyCapability}; + +use super::super::super::state::ConfiguratorApp; + +/// Emphasis for a status line, mapped to the Adwaita state classes that +/// stand in for the Iced view's literal colors. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Tone { + Neutral, + Positive, + Caution, + Negative, +} + +impl Tone { + pub(super) const CLASSES: [&'static str; 4] = ["dim-label", "success", "warning", "error"]; + + pub(super) fn css_class(self) -> &'static str { + match self { + Self::Neutral => "dim-label", + Self::Positive => "success", + Self::Caution => "warning", + Self::Negative => "error", + } + } +} + +pub(super) fn overall_status(status: Option<&DaemonRuntimeStatus>) -> (&'static str, Tone) { + match status { + None => ("Status: Detecting...", Tone::Neutral), + Some(status) if status.service_active => ("Status: Running", Tone::Positive), + Some(status) if status.service_installed => { + ("Status: Installed, not running", Tone::Caution) + } + Some(_) => ("Status: Not installed", Tone::Neutral), + } +} + +pub(super) fn feedback_tone(feedback: &str) -> Tone { + let feedback = feedback.to_ascii_lowercase(); + if feedback.contains("failed") || feedback.contains("error") { + Tone::Negative + } else { + Tone::Positive + } +} + +pub(super) fn install_status(installed: bool) -> (&'static str, Tone) { + if installed { + ("Installed \u{2713}", Tone::Positive) + } else { + ("Not installed", Tone::Neutral) + } +} + +pub(super) fn install_button_label(installed: bool) -> &'static str { + if installed { + "Update Service" + } else { + "Install Service" + } +} + +pub(super) fn light_controls_status(configured: bool) -> (&'static str, Tone) { + if configured { + ("Configured \u{2713}", Tone::Positive) + } else { + ("Not configured", Tone::Neutral) + } +} + +pub(super) fn service_status(running: bool, enabled: bool) -> (&'static str, Tone) { + match (running, enabled) { + (true, true) => ("Running \u{2713}", Tone::Positive), + (true, false) => ("Running (not enabled)", Tone::Caution), + (false, true) => ("Enabled, not running", Tone::Caution), + (false, false) => ("Stopped and disabled", Tone::Neutral), + } +} + +pub(super) fn shortcut_placeholder(capability: Option) -> &'static str { + match capability { + Some(ShortcutApplyCapability::GnomeCustomShortcut) => "e.g. Super+G or g", + Some(ShortcutApplyCapability::PortalServiceDropIn) => "e.g. Ctrl+Shift+G or g", + _ => "e.g. Ctrl+Shift+G", + } +} + +pub(super) fn light_controls_details(status: &DaemonRuntimeStatus) -> Option { + let path = status.light_controls_config_path.as_deref()?; + Some(if status.light_controls_configured { + format!("Light controls: configured at {path}") + } else { + format!("Light controls include: {path}") + }) +} + +/// The tool availability line, shown only when something is missing. +pub(super) fn missing_tools(status: &DaemonRuntimeStatus) -> Option { + let mut missing = Vec::new(); + if !status.systemctl_available { + missing.push("systemctl"); + } + if !status.gsettings_available { + missing.push("gsettings"); + } + (!missing.is_empty()).then(|| format!("Missing tools: {}", missing.join(", "))) +} + +pub(super) fn service_installed(app: &ConfiguratorApp) -> bool { + app.daemon_status + .as_ref() + .is_some_and(|status| status.service_installed) +} + +pub(super) fn apply_tone(widget: &impl IsA, tone: Tone) { + let wanted = tone.css_class(); + for class in Tone::CLASSES { + let has_class = widget.has_css_class(class); + if class == wanted { + if !has_class { + widget.add_css_class(class); + } + } else if has_class { + widget.remove_css_class(class); + } + } +} diff --git a/configurator/src/app/pages/daemon/tests.rs b/configurator/src/app/pages/daemon/tests.rs new file mode 100644 index 00000000..cb9b9121 --- /dev/null +++ b/configurator/src/app/pages/daemon/tests.rs @@ -0,0 +1,195 @@ +use super::*; +use crate::models::{ + DaemonRuntimeStatus, DesktopEnvironment, LightShortcutApplyCapability, ShortcutApplyCapability, + ShortcutBackend, +}; + +use super::status::{ + Tone, feedback_tone, light_controls_details, missing_tools, overall_status, service_status, + shortcut_placeholder, +}; + +fn shown_all() -> ShownAreas { + ShownAreas { + status: true, + service: true, + shortcut: true, + light: true, + } +} + +fn visible_sections(shown: ShownAreas) -> Vec { + DaemonSection::ALL + .into_iter() + .filter(|section| daemon_section_visible(*section, shown)) + .collect() +} + +fn test_status() -> DaemonRuntimeStatus { + DaemonRuntimeStatus { + desktop: DesktopEnvironment::Hyprland, + shortcut_backend: ShortcutBackend::Manual, + shortcut_apply_capability: ShortcutApplyCapability::Manual, + light_shortcut_apply_capability: LightShortcutApplyCapability::HyprlandNative, + systemctl_available: true, + gsettings_available: true, + service_installed: false, + service_enabled: false, + service_active: false, + service_unit_path: None, + configured_shortcut: None, + light_controls_configured: false, + light_controls_config_path: None, + } +} + +#[test] +fn daemon_sections_keep_default_setup_order() { + assert_eq!( + visible_sections(shown_all()), + vec![ + DaemonSection::Install, + DaemonSection::Shortcut, + DaemonSection::LightControls, + DaemonSection::Start, + DaemonSection::TechnicalDetails, + ], + ); +} + +#[test] +fn service_area_keeps_install_start_and_details() { + assert_eq!( + visible_sections(ShownAreas { + status: false, + service: true, + shortcut: false, + light: false, + }), + vec![ + DaemonSection::Install, + DaemonSection::Start, + DaemonSection::TechnicalDetails, + ], + ); +} + +#[test] +fn status_area_alone_keeps_only_details() { + assert_eq!( + visible_sections(ShownAreas { + status: true, + service: false, + shortcut: false, + light: false, + }), + vec![DaemonSection::TechnicalDetails], + ); +} + +#[test] +fn shortcut_area_alone_keeps_only_the_shortcut_step() { + assert_eq!( + visible_sections(ShownAreas { + status: false, + service: false, + shortcut: true, + light: false, + }), + vec![DaemonSection::Shortcut], + ); +} + +#[test] +fn overall_status_follows_service_state() { + assert_eq!(overall_status(None).0, "Status: Detecting..."); + + let mut status = test_status(); + assert_eq!(overall_status(Some(&status)).0, "Status: Not installed"); + + status.service_installed = true; + assert_eq!( + overall_status(Some(&status)), + ("Status: Installed, not running", Tone::Caution) + ); + + status.service_active = true; + assert_eq!( + overall_status(Some(&status)), + ("Status: Running", Tone::Positive) + ); +} + +#[test] +fn feedback_tone_flags_failures() { + assert_eq!( + feedback_tone("Background setup action failed"), + Tone::Negative + ); + assert_eq!(feedback_tone("gsettings ERROR: nope"), Tone::Negative); + assert_eq!( + feedback_tone("Background mode status loaded."), + Tone::Positive + ); +} + +#[test] +fn service_status_separates_running_from_enabled() { + assert_eq!(service_status(true, true).0, "Running \u{2713}"); + assert_eq!(service_status(true, false).0, "Running (not enabled)"); + assert_eq!(service_status(false, true).0, "Enabled, not running"); + assert_eq!(service_status(false, false).0, "Stopped and disabled"); +} + +#[test] +fn shortcut_placeholder_follows_apply_capability() { + assert_eq!( + shortcut_placeholder(Some(ShortcutApplyCapability::GnomeCustomShortcut)), + "e.g. Super+G or g" + ); + assert_eq!( + shortcut_placeholder(Some(ShortcutApplyCapability::PortalServiceDropIn)), + "e.g. Ctrl+Shift+G or g" + ); + assert_eq!( + shortcut_placeholder(Some(ShortcutApplyCapability::Manual)), + "e.g. Ctrl+Shift+G" + ); + assert_eq!(shortcut_placeholder(None), "e.g. Ctrl+Shift+G"); +} + +#[test] +fn missing_tools_only_reports_absent_ones() { + let mut status = test_status(); + assert_eq!(missing_tools(&status), None); + + status.gsettings_available = false; + assert_eq!( + missing_tools(&status).as_deref(), + Some("Missing tools: gsettings") + ); + + status.systemctl_available = false; + assert_eq!( + missing_tools(&status).as_deref(), + Some("Missing tools: systemctl, gsettings") + ); +} + +#[test] +fn light_controls_details_reflect_configured_state() { + let mut status = test_status(); + assert_eq!(light_controls_details(&status), None); + + status.light_controls_config_path = Some("/tmp/wayscriber.conf".to_string()); + assert_eq!( + light_controls_details(&status).as_deref(), + Some("Light controls include: /tmp/wayscriber.conf") + ); + + status.light_controls_configured = true; + assert_eq!( + light_controls_details(&status).as_deref(), + Some("Light controls: configured at /tmp/wayscriber.conf") + ); +} diff --git a/configurator/src/app/pages/daemon/widgets.rs b/configurator/src/app/pages/daemon/widgets.rs new file mode 100644 index 00000000..5f4b583d --- /dev/null +++ b/configurator/src/app/pages/daemon/widgets.rs @@ -0,0 +1,93 @@ +use relm4::{ComponentSender, gtk}; + +use gtk::prelude::*; + +use crate::messages::Message; +use crate::models::DaemonAction; + +use super::super::super::state::ConfiguratorApp; + +pub(super) fn column_box() -> gtk::Box { + gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build() +} + +pub(super) fn row_box() -> gtk::Box { + gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .build() +} + +pub(super) fn body_label(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .xalign(0.0) + .wrap(true) + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .build() +} + +pub(super) fn caption_label(text: &str) -> gtk::Label { + let label = body_label(text); + label.add_css_class("caption"); + label +} + +pub(super) fn hint_label(text: &str) -> gtk::Label { + let label = caption_label(text); + label.add_css_class("dim-label"); + label +} + +pub(super) fn warning_label(text: &str) -> gtk::Label { + let label = caption_label(text); + label.add_css_class("warning"); + label +} + +pub(super) fn action_button( + label: &str, + action: DaemonAction, + sender: &ComponentSender, +) -> gtk::Button { + let button = gtk::Button::builder() + .label(label) + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .build(); + let sender = sender.clone(); + button.connect_clicked(move |_| sender.input(Message::DaemonActionRequested(action))); + button +} + +pub(super) fn set_label(label: >k::Label, text: &str) { + if label.label() != text { + label.set_label(text); + } +} + +pub(super) fn set_button_label(button: >k::Button, text: &str) { + if button.label().as_deref() != Some(text) { + button.set_label(text); + } +} + +/// Writes the widget's own visibility flag, never `is_visible`: a child of a +/// hidden group reports invisible while its own flag still says otherwise, +/// and skipping the write there would leak the stale state the moment the +/// group comes back. +pub(super) fn set_visible(widget: &impl IsA, visible: bool) { + if widget.get_visible() != visible { + widget.set_visible(visible); + } +} + +pub(super) fn set_sensitive(widget: &impl IsA, sensitive: bool) { + if widget.is_sensitive() != sensitive { + widget.set_sensitive(sensitive); + } +} diff --git a/configurator/src/app/pages/drawing.rs b/configurator/src/app/pages/drawing.rs index 526eacff..cdada9c6 100644 --- a/configurator/src/app/pages/drawing.rs +++ b/configurator/src/app/pages/drawing.rs @@ -9,28 +9,27 @@ //! count changes and refreshed in place otherwise, which keeps the row the //! user is typing in alive. +mod default_color; +mod defaults; +mod drag_mapping; +mod font; +mod quick_colors; + use relm4::prelude::*; use relm4::{adw, gtk}; use adw::prelude::*; use gtk::glib; -use wayscriber::config::{DragButtonConfig, QUICK_COLOR_RENDER_LIMIT, QuickColorSlot}; use wayscriber::draw::Color; use crate::messages::Message; -use crate::models::color::ColorInput; use crate::models::util::format_float; -use crate::models::{ - ColorMode, ColorPickerId, DragColorOption, DragMouseButton, DragToolField, DragToolOption, - EraserModeOption, FontStyleOption, FontWeightOption, NamedColorOption, TabId, TextField, - ToggleField, -}; +use crate::models::{ColorMode, DragMouseButton, DragToolField, NamedColorOption, TabId}; -use super::super::search::{AppSearchSummary, SearchArea}; use super::super::state::ConfiguratorApp; -use super::color_rows::{ResolvedColor, color_row, dialog_hex, mark_hex_error, set_swatch_blocked}; -use super::{BuiltPage, PageBuilder, set_selected_blocked, set_text_blocked}; +use super::color_rows::ResolvedColor; +use super::{BuiltPage, PageBuilder, set_text_blocked}; /// Mouse buttons that carry a drag mapping section, in the order the old /// view listed them. @@ -54,655 +53,15 @@ const COLOR_MODES: [ColorMode; 2] = [ColorMode::Named, ColorMode::Rgb]; pub(super) fn build(sender: &ComponentSender) -> BuiltPage { let mut page = PageBuilder::new(sender, TabId::Drawing); - build_default_color(&mut page); - build_quick_colors(&mut page); - build_defaults(&mut page); - build_drag_mapping(&mut page); - build_font(&mut page); + default_color::build(&mut page); + quick_colors::build(&mut page); + defaults::build(&mut page); + drag_mapping::build(&mut page); + font::build(&mut page); page.finish() } -// --------------------------------------------------------------------------- -// Default color -// --------------------------------------------------------------------------- - -fn build_default_color(page: &mut PageBuilder) { - page.group_in_area("Default color", SearchArea::DrawingColor) - .combo_row( - "Color mode", - "A palette name or hex string, or explicit RGB components.", - COLOR_MODES.to_vec(), - vec!["Named color".to_string(), "RGB color".to_string()], - |app| app.draft.drawing_color.mode, - Message::ColorModeChanged, - ); - - let named = conditional_section(page, |app| app.draft.drawing_color.mode == ColorMode::Named); - section_combo_row( - page, - &named, - "Named color", - NamedColorOption::list(), - named_color_labels(), - |app| app.draft.drawing_color.selected_named, - Message::NamedColorSelected, - ); - section_entry_row( - page, - &named, - "Custom color name", - |app| app.draft.drawing_color.name.clone(), - |value| Message::TextChanged(TextField::DrawingColorName, value), - |app| color_name_error(&app.draft.drawing_color, "Unknown color name"), - ); - - // Only in RGB mode: in Named mode the save serializes the named value, - // so a visible RGB edit here would be silently discarded. - page.group_in_area_when("RGB color", SearchArea::DrawingColor, |app| { - app.draft.drawing_color.mode == ColorMode::Rgb - }); - color_row(page, "Custom color", ColorPickerId::DrawingColor, |app| { - resolved(app.draft.drawing_color.preview_color()) - }); -} - -/// The error the old view showed under a custom color name that resolves to -/// nothing, `None` while the field is empty or usable. -fn color_name_error(color: &ColorInput, message: &str) -> Option { - let unresolved = color.preview_color().is_none() && !color.name.trim().is_empty(); - unresolved.then(|| message.to_string()) -} - -fn named_color_labels() -> Vec { - NamedColorOption::list() - .iter() - .map(|option| option.label().to_string()) - .collect() -} - -fn resolved(color: Option) -> ResolvedColor { - color.map(|color| (color.r, color.g, color.b, color.a)) -} - -// --------------------------------------------------------------------------- -// Quick colors -// --------------------------------------------------------------------------- - -fn build_quick_colors(page: &mut PageBuilder) { - page.group_in_area("Quick colors", SearchArea::DrawingColor); - - // An activatable ActionRow rather than AdwButtonRow: ButtonRow needs - // libadwaita 1.6 and the crate's feature floor is 1.4 (Ubuntu 24.04). - let add = adw::ActionRow::builder() - .title("Add color") - .activatable(true) - .build(); - add.add_prefix(>k::Image::from_icon_name("list-add-symbolic")); - { - let sender = page.sender(); - add.connect_activated(move |_| sender.input(Message::QuickColorAdded)); - } - page.custom(&add); - - let warning = gtk::Label::builder() - .label(format!( - "Only the first {QUICK_COLOR_RENDER_LIMIT} quick colors are shown in toolbar and radial menus." - )) - .wrap(true) - .xalign(0.0) - .margin_top(6) - .css_classes(["warning"]) - .build(); - page.custom(&warning); - page.bind(move |app, _summary| { - let over_limit = app.draft.drawing_quick_colors.entries.len() > QUICK_COLOR_RENDER_LIMIT; - set_visible_if_changed(&warning, over_limit); - }); - - let list = boxed_list(); - list.set_margin_top(6); - page.custom(&list); - let sender = page.sender(); - // Each built row owns the layout that produced it and its typed refresh. - let mut rows: Vec = Vec::new(); - page.bind(move |app, _summary| { - let layouts = quick_color_layouts(app); - if !rows - .iter() - .map(|row| row.layout) - .eq(layouts.iter().copied()) - { - rows = rebuild_quick_colors(&list, &layouts, &sender); - } - for ((index, entry), row) in app - .draft - .drawing_quick_colors - .entries - .iter() - .enumerate() - .zip(rows.iter()) - { - let values = QuickColorValues { - label: &entry.label, - name: &entry.color.name, - hex: picker_hex(app, ColorPickerId::QuickColor(index)), - named: entry.color.selected_named, - preview: resolved(entry.color.preview_color()), - summary: entry.color.summary(), - }; - (row.refresh)(&values); - } - }); -} - -/// Everything a quick color row shows before any text is written into it. -/// -/// This is the whole rebuild trigger: the binding compares the list of these -/// it built against the list the model asks for now, so a control added here -/// rebuilds on its own value. Everything the user types — and the named-color -/// choice, which a typed name moves on its own — stays out, because rebuilding -/// a row takes the caret with it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct QuickColorLayout { - mode: ColorMode, - can_move_up: bool, - can_move_down: bool, - can_remove: bool, -} - -fn quick_color_layouts(app: &ConfiguratorApp) -> Vec { - let count = app.draft.drawing_quick_colors.entries.len(); - app.draft - .drawing_quick_colors - .entries - .iter() - .enumerate() - .map(|(index, entry)| QuickColorLayout { - mode: entry.color.mode, - can_move_up: index > 0, - can_move_down: index + 1 < count, - // The draft refuses to drop below the eight slots the shortcuts - // bind. - can_remove: count > QuickColorSlot::ALL.len(), - }) - .collect() -} - -/// One quick color row's values: everything the layout deliberately left out. -struct QuickColorValues<'a> { - label: &'a str, - name: &'a str, - hex: &'a str, - named: NamedColorOption, - /// The color the entry resolves to, `None` when it resolves to nothing — - /// which is also what makes the name field an error. - preview: ResolvedColor, - summary: String, -} - -/// One row's refresh: built beside its row, so it owns that row's typed widget -/// handles and the signal handler ids guarding each write. -type QuickColorRowRefresh = Box)>; - -struct BoundQuickColorRow { - layout: QuickColorLayout, - refresh: QuickColorRowRefresh, -} - -fn rebuild_quick_colors( - list: >k::ListBox, - layouts: &[QuickColorLayout], - sender: &ComponentSender, -) -> Vec { - // Draining the list, not walking it for a control: the rows that replace - // these carry their own refresh closures. - while let Some(child) = list.first_child() { - list.remove(&child); - } - - let mut rows = Vec::with_capacity(layouts.len()); - for (index, layout) in layouts.iter().enumerate() { - let built = build_quick_color_row(index, *layout, sender); - list.append(&built.row); - rows.push(BoundQuickColorRow { - layout: *layout, - refresh: built.refresh, - }); - } - rows -} - -/// One quick color entry, built with everything its layout decides already in -/// place, and paired with the closure that writes the rest. -/// -/// The closure owns the row's widgets and the ids of their handlers, so it -/// writes them blocked: the hex path reports a programmatic write as a user -/// pick, and that pick rewrites the entry's components and wipes the status -/// line the user was reading. -struct QuickColorRow { - row: adw::ExpanderRow, - refresh: QuickColorRowRefresh, -} - -fn build_quick_color_row( - index: usize, - layout: QuickColorLayout, - sender: &ComponentSender, -) -> QuickColorRow { - let row = adw::ExpanderRow::builder() - .title(format!("Color {}", index + 1)) - .build(); - - let up = icon_button("go-up-symbolic", "Move up"); - up.set_sensitive(layout.can_move_up); - { - let sender = sender.clone(); - up.connect_clicked(move |_| sender.input(Message::QuickColorMoved(index, -1))); - } - let down = icon_button("go-down-symbolic", "Move down"); - down.set_sensitive(layout.can_move_down); - { - let sender = sender.clone(); - down.connect_clicked(move |_| sender.input(Message::QuickColorMoved(index, 1))); - } - let remove = icon_button("user-trash-symbolic", "Remove"); - remove.set_sensitive(layout.can_remove); - { - let sender = sender.clone(); - remove.connect_clicked(move |_| sender.input(Message::QuickColorRemoved(index))); - } - row.add_suffix(&up); - row.add_suffix(&down); - row.add_suffix(&remove); - - let label = adw::EntryRow::builder().title("Label").build(); - let label_handler = { - let sender = sender.clone(); - label.connect_changed(move |row| { - sender.input(Message::TextChanged( - TextField::QuickColorLabel(index), - row.text().to_string(), - )); - }) - }; - row.add_row(&label); - - // The mode is part of the fingerprint, so it is selected before the - // handler exists and never written again behind the user's back. - let mode = combo_row_widget( - "Color mode", - &["Named or hex".to_string(), "RGB".to_string()], - ); - select_if_changed(&mode, &COLOR_MODES, layout.mode); - connect_combo(&mode, sender.clone(), COLOR_MODES.to_vec(), move |value| { - Message::QuickColorModeChanged(index, value) - }); - row.add_row(&mode); - - let named_options = NamedColorOption::list(); - let named = combo_row_widget("Named color", &named_color_labels()); - named.set_visible(layout.mode == ColorMode::Named); - let named_handler = { - let sender = sender.clone(); - let options = named_options.clone(); - named.connect_selected_notify(move |row| { - if let Some(option) = options.get(row.selected() as usize) { - sender.input(Message::QuickNamedColorSelected(index, *option)); - } - }) - }; - row.add_row(&named); - - let name = adw::EntryRow::builder() - .title("Color name or #RRGGBB[AA]") - .build(); - name.set_visible(layout.mode == ColorMode::Named); - let name_handler = { - let sender = sender.clone(); - name.connect_changed(move |row| { - sender.input(Message::TextChanged( - TextField::QuickColorName(index), - row.text().to_string(), - )); - }) - }; - row.add_row(&name); - - let picker = ColorPickerId::QuickColor(index); - let hex = adw::EntryRow::builder().title("Custom color").build(); - hex.set_visible(layout.mode == ColorMode::Rgb); - let hex_handler = { - let sender = sender.clone(); - hex.connect_changed(move |row| { - sender.input(Message::ColorPickerHexChanged( - picker, - row.text().to_string(), - )); - }) - }; - let swatch = color_dialog_button(); - let swatch_handler = { - let sender = sender.clone(); - swatch.connect_rgba_notify(move |button| { - sender.input(Message::ColorPickerHexChanged( - picker, - dialog_hex(&button.rgba()), - )); - }) - }; - hex.add_suffix(&swatch); - row.add_row(&hex); - - let expander = row.clone(); - let refresh: QuickColorRowRefresh = Box::new(move |values| { - let subtitle = format!("{} / {}", values.label.trim(), values.summary); - if expander.subtitle() != subtitle { - expander.set_subtitle(&subtitle); - } - - set_text_blocked(&label, &label_handler, values.label); - set_text_blocked(&name, &name_handler, values.name); - set_error_if_changed(&name, name_error(values)); - set_text_blocked(&hex, &hex_handler, values.hex); - // The same predicate the save gate counts with, so a field styled - // clean can never be one the save refuses. - mark_hex_error(&hex, values.hex); - - if let Some(position) = named_options - .iter() - .position(|option| *option == values.named) - { - set_selected_blocked(&named, &named_handler, position as u32); - } - - if let Some((r, g, b, a)) = values.preview { - let rgba = gtk::gdk::RGBA::new(r as f32, g as f32, b as f32, a as f32); - set_swatch_blocked(&swatch, &swatch_handler, &rgba); - } - }); - - QuickColorRow { row, refresh } -} - -/// The error the old view showed under a quick color name that resolves to -/// nothing, read off the values the row was handed. -fn name_error(values: &QuickColorValues<'_>) -> Option { - let unresolved = values.preview.is_none() && !values.name.trim().is_empty(); - unresolved.then(|| "Use a known color name, #RRGGBB, or #RRGGBBAA for alpha".to_string()) -} - -fn picker_hex(app: &ConfiguratorApp, id: ColorPickerId) -> &str { - app.color_picker_hex.get(&id).map_or("", String::as_str) -} - -// --------------------------------------------------------------------------- -// Drawing defaults -// --------------------------------------------------------------------------- - -fn build_defaults(page: &mut PageBuilder) { - page.group_in_area("Drawing defaults", SearchArea::DrawingDefaults) - .entry_row_validated( - "Thickness (px)", - |app| app.draft.drawing_default_thickness.clone(), - |value| Message::TextChanged(TextField::DrawingThickness, value), - |app| validate_f64_range(&app.draft.drawing_default_thickness, 1.0, 50.0), - ) - .entry_row_validated( - "Font size (pt)", - |app| app.draft.drawing_default_font_size.clone(), - |value| Message::TextChanged(TextField::DrawingFontSize, value), - |app| validate_f64_range(&app.draft.drawing_default_font_size, 8.0, 72.0), - ) - .entry_row_validated( - "Polygon sides", - |app| app.draft.drawing_polygon_sides.clone(), - |value| Message::TextChanged(TextField::DrawingPolygonSides, value), - |app| validate_usize_range(&app.draft.drawing_polygon_sides, 3, 12), - ) - .entry_row_validated( - "Eraser size (px)", - |app| app.draft.drawing_default_eraser_size.clone(), - |value| Message::TextChanged(TextField::DrawingEraserSize, value), - |app| validate_f64_range(&app.draft.drawing_default_eraser_size, 1.0, 50.0), - ) - .combo_row( - "Eraser mode", - "", - EraserModeOption::list(), - EraserModeOption::list() - .iter() - .map(|option| option.label().to_string()) - .collect(), - |app| app.draft.drawing_default_eraser_mode, - Message::EraserModeChanged, - ) - .entry_row_validated( - "Marker opacity (0.05-0.9)", - |app| app.draft.drawing_marker_opacity.clone(), - |value| Message::TextChanged(TextField::DrawingMarkerOpacity, value), - |app| validate_f64_range(&app.draft.drawing_marker_opacity, 0.05, 0.9), - ) - .entry_row_validated( - "Undo stack limit", - |app| app.draft.drawing_undo_stack_limit.clone(), - |value| Message::TextChanged(TextField::DrawingUndoStackLimit, value), - |app| validate_usize_range(&app.draft.drawing_undo_stack_limit, 10, 1000), - ) - .entry_row_validated( - "Hit-test tolerance (px)", - |app| app.draft.drawing_hit_test_tolerance.clone(), - |value| Message::TextChanged(TextField::DrawingHitTestTolerance, value), - |app| validate_f64_range(&app.draft.drawing_hit_test_tolerance, 1.0, 20.0), - ) - .entry_row_validated( - "Hit-test threshold", - |app| app.draft.drawing_hit_test_linear_threshold.clone(), - |value| Message::TextChanged(TextField::DrawingHitTestThreshold, value), - |app| validate_usize_min(&app.draft.drawing_hit_test_linear_threshold, 1), - ) - .switch_row( - "Enable text background", - "", - |app| app.draft.drawing_text_background_enabled, - |value| Message::ToggleChanged(ToggleField::DrawingTextBackground, value), - ) - .switch_row( - "Start shapes filled", - "", - |app| app.draft.drawing_default_fill_enabled, - |value| Message::ToggleChanged(ToggleField::DrawingFillEnabled, value), - ); -} - -// --------------------------------------------------------------------------- -// Drag tool mapping -// --------------------------------------------------------------------------- - -fn build_drag_mapping(page: &mut PageBuilder) { - page.group_in_area("Drag tool mapping", SearchArea::DrawingDragTools); - - let switcher = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .halign(gtk::Align::Start) - .css_classes(["linked"]) - .build(); - for button in DRAG_BUTTONS { - let toggle = gtk::Button::with_label(button.label()); - { - let sender = page.sender(); - toggle.connect_clicked(move |_| { - sender.input(Message::DrawingDragMappingSectionToggled(button)); - }); - } - switcher.append(&toggle); - page.bind(move |app, _summary| { - let open = app.active_drawing_drag_button == Some(button); - if toggle.has_css_class("suggested-action") != open { - if open { - toggle.add_css_class("suggested-action"); - } else { - toggle.remove_css_class("suggested-action"); - } - } - }); - } - page.custom(&switcher); - - for button in DRAG_BUTTONS { - build_drag_button_section(page, button); - } -} - -fn build_drag_button_section(page: &mut PageBuilder, button: DragMouseButton) { - let section = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(6) - .margin_top(12) - .build(); - section.append( - >k::Label::builder() - .label(button.label()) - .xalign(0.0) - .css_classes(["heading"]) - .build(), - ); - let list = boxed_list(); - section.append(&list); - page.custom(§ion); - page.bind(move |app, summary| { - set_visible_if_changed(§ion, drag_section_visible(app, summary, button)); - }); - - let tools = DragToolOption::list_for_button(button); - let tool_labels: Vec = tools - .iter() - .map(|option| option.label().to_string()) - .collect(); - let colors = DragColorOption::list(); - let color_labels: Vec = colors - .iter() - .map(|option| option.label().to_string()) - .collect(); - - for field in DRAG_FIELDS { - section_combo_row( - page, - &list, - field.label(), - tools.clone(), - tool_labels.clone(), - move |app| drag_tool_for_field(drag_button_config(app, button), field), - move |option| Message::DrawingMouseDragToolChanged(button, field, option), - ); - section_combo_row( - page, - &list, - &format!("{} color", field.label()), - colors.clone(), - color_labels.clone(), - move |app| drag_color_for_field(drag_button_config(app, button), field), - move |option| Message::DrawingMouseDragColorChanged(button, field, option), - ); - } -} - -/// Mirrors the old view's `visible_drag_mapping_buttons`: a search that -/// matched the drag area on its own opens every button, otherwise only the -/// section the user picked is open. -fn drag_section_visible( - app: &ConfiguratorApp, - summary: &AppSearchSummary, - button: DragMouseButton, -) -> bool { - let matched_by_search = summary.tab(TabId::Drawing).is_some_and(|search| { - !search.show_all() && search.area_matches(SearchArea::DrawingDragTools) - }); - matched_by_search || app.active_drawing_drag_button == Some(button) -} - -fn drag_button_config(app: &ConfiguratorApp, button: DragMouseButton) -> &DragButtonConfig { - match button { - DragMouseButton::Left => &app.draft.drawing_drag_tools.left, - DragMouseButton::Right => &app.draft.drawing_drag_tools.right, - DragMouseButton::Middle => &app.draft.drawing_drag_tools.middle, - } -} - -fn drag_tool_for_field(config: &DragButtonConfig, field: DragToolField) -> DragToolOption { - match field { - DragToolField::Drag => DragToolOption::from_drag_tool(config.drag_tool), - DragToolField::ShiftDrag => DragToolOption::from_drag_tool(config.shift_drag_tool), - DragToolField::CtrlDrag => DragToolOption::from_drag_tool(config.ctrl_drag_tool), - DragToolField::CtrlShiftDrag => DragToolOption::from_drag_tool(config.ctrl_shift_drag_tool), - DragToolField::TabDrag => DragToolOption::from_drag_tool(config.tab_drag_tool), - } -} - -fn drag_color_for_field(config: &DragButtonConfig, field: DragToolField) -> DragColorOption { - match field { - DragToolField::Drag => DragColorOption::from_color(config.drag_color.as_ref()), - DragToolField::ShiftDrag => DragColorOption::from_color(config.shift_drag_color.as_ref()), - DragToolField::CtrlDrag => DragColorOption::from_color(config.ctrl_drag_color.as_ref()), - DragToolField::CtrlShiftDrag => { - DragColorOption::from_color(config.ctrl_shift_drag_color.as_ref()) - } - DragToolField::TabDrag => DragColorOption::from_color(config.tab_drag_color.as_ref()), - } -} - -// --------------------------------------------------------------------------- -// Font -// --------------------------------------------------------------------------- - -fn build_font(page: &mut PageBuilder) { - page.group_in_area("Font", SearchArea::DrawingFont) - .entry_row( - "Font family", - |app| app.draft.drawing_font_family.clone(), - |value| Message::TextChanged(TextField::DrawingFontFamily, value), - ) - .combo_row( - "Font weight", - "", - FontWeightOption::list(), - FontWeightOption::list() - .iter() - .map(|option| option.label().to_string()) - .collect(), - |app| app.draft.drawing_font_weight_option, - Message::FontWeightOptionSelected, - ) - .entry_row( - "Custom or numeric weight", - |app| app.draft.drawing_font_weight.clone(), - |value| Message::TextChanged(TextField::DrawingFontWeight, value), - ) - .combo_row( - "Font style", - "", - FontStyleOption::list(), - FontStyleOption::list() - .iter() - .map(|option| option.label().to_string()) - .collect(), - |app| app.draft.drawing_font_style_option, - Message::FontStyleOptionSelected, - ); - - let custom_style = conditional_section(page, |app| { - app.draft.drawing_font_style_option == FontStyleOption::Custom - }); - section_entry_row( - page, - &custom_style, - "Custom style", - |app| app.draft.drawing_font_style.clone(), - |value| Message::TextChanged(TextField::DrawingFontStyle, value), - |_app| None, - ); -} - // --------------------------------------------------------------------------- // Sections and rows // --------------------------------------------------------------------------- @@ -906,106 +265,20 @@ fn validate_usize_min(value: &str, min: usize) -> Option { } } +fn named_color_labels() -> Vec { + NamedColorOption::list() + .iter() + .map(|option| option.label().to_string()) + .collect() +} + +fn resolved(color: Option) -> ResolvedColor { + color.map(|color| (color.r, color.g, color.b, color.a)) +} + #[cfg(test)] mod tests { use super::*; - use crate::models::SearchQuery; - - /// The law the binding rests on: one layout per quick color, so the rows - /// it builds and the values it hands them are indexed by the same thing. - #[test] - fn there_is_one_layout_per_quick_color() { - let (app, _effects) = ConfiguratorApp::new_app(); - let layouts = quick_color_layouts(&app); - - assert_eq!(layouts.len(), app.draft.drawing_quick_colors.entries.len()); - } - - /// The caret guarantee, stated as the layout law it rests on: typing a - /// color name moves the named-color choice with it, so neither may be - /// part of what rebuilds the row. - #[test] - fn typing_a_quick_color_name_leaves_the_layout_alone() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let before = quick_color_layouts(&app); - - app.draft - .set_text(TextField::QuickColorName(0), "gree".to_string()); - - assert_eq!(before, quick_color_layouts(&app)); - // The choice the model moved rides the row's values instead, where a - // blocked write can put it in the combo without a rebuild. - assert_eq!( - app.draft.drawing_quick_colors.entries[0] - .color - .selected_named, - NamedColorOption::Custom - ); - } - - #[test] - fn switching_a_quick_color_to_rgb_changes_the_layout() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let before = quick_color_layouts(&app); - - let Some(entry) = app.draft.drawing_quick_colors.get_mut(0) else { - return; - }; - entry.color.mode = ColorMode::Rgb; - - assert_ne!(before, quick_color_layouts(&app)); - } - - #[test] - fn a_quick_color_name_that_resolves_to_nothing_is_an_error() { - let values = QuickColorValues { - label: "Red", - name: "red", - hex: "#FF0000", - named: NamedColorOption::Red, - preview: Some((1.0, 0.0, 0.0, 1.0)), - summary: "Red".to_string(), - }; - assert_eq!(name_error(&values), None); - - let unresolved = QuickColorValues { - preview: None, - name: "nope", - summary: values.summary.clone(), - ..values - }; - assert!(name_error(&unresolved).is_some()); - - let empty = QuickColorValues { - preview: None, - name: " ", - summary: values.summary.clone(), - ..values - }; - assert_eq!(name_error(&empty), None); - } - - #[test] - fn drag_sections_all_open_when_search_matches_the_drag_area() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.search_query = SearchQuery::new("shift"); - let summary = app.search_summary(); - - assert_eq!(app.active_drawing_drag_button, None); - for button in DRAG_BUTTONS { - assert!(drag_section_visible(&app, &summary, button)); - } - } - - #[test] - fn drag_sections_follow_the_open_button_without_a_search() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.active_drawing_drag_button = Some(DragMouseButton::Right); - let summary = app.search_summary(); - - assert!(drag_section_visible(&app, &summary, DragMouseButton::Right)); - assert!(!drag_section_visible(&app, &summary, DragMouseButton::Left)); - } #[test] fn range_validators_report_the_old_view_messages() { diff --git a/configurator/src/app/pages/drawing/default_color.rs b/configurator/src/app/pages/drawing/default_color.rs new file mode 100644 index 00000000..f6643489 --- /dev/null +++ b/configurator/src/app/pages/drawing/default_color.rs @@ -0,0 +1,58 @@ +use crate::messages::Message; +use crate::models::color::ColorInput; +use crate::models::{ColorMode, ColorPickerId, NamedColorOption, TextField}; + +use super::super::super::search::SearchArea; +use super::super::PageBuilder; +use super::super::color_rows::color_row; +use super::{ + COLOR_MODES, conditional_section, named_color_labels, resolved, section_combo_row, + section_entry_row, +}; + +pub(super) fn build(page: &mut PageBuilder) { + page.group_in_area("Default color", SearchArea::DrawingColor) + .combo_row( + "Color mode", + "A palette name or hex string, or explicit RGB components.", + COLOR_MODES.to_vec(), + vec!["Named color".to_string(), "RGB color".to_string()], + |app| app.draft.drawing_color.mode, + Message::ColorModeChanged, + ); + + let named = conditional_section(page, |app| app.draft.drawing_color.mode == ColorMode::Named); + section_combo_row( + page, + &named, + "Named color", + NamedColorOption::list(), + named_color_labels(), + |app| app.draft.drawing_color.selected_named, + Message::NamedColorSelected, + ); + section_entry_row( + page, + &named, + "Custom color name", + |app| app.draft.drawing_color.name.clone(), + |value| Message::TextChanged(TextField::DrawingColorName, value), + |app| color_name_error(&app.draft.drawing_color, "Unknown color name"), + ); + + // Only in RGB mode: in Named mode the save serializes the named value, + // so a visible RGB edit here would be silently discarded. + page.group_in_area_when("RGB color", SearchArea::DrawingColor, |app| { + app.draft.drawing_color.mode == ColorMode::Rgb + }); + color_row(page, "Custom color", ColorPickerId::DrawingColor, |app| { + resolved(app.draft.drawing_color.preview_color()) + }); +} + +/// The error the old view showed under a custom color name that resolves to +/// nothing, `None` while the field is empty or usable. +fn color_name_error(color: &ColorInput, message: &str) -> Option { + let unresolved = color.preview_color().is_none() && !color.name.trim().is_empty(); + unresolved.then(|| message.to_string()) +} diff --git a/configurator/src/app/pages/drawing/defaults.rs b/configurator/src/app/pages/drawing/defaults.rs new file mode 100644 index 00000000..46a5ccde --- /dev/null +++ b/configurator/src/app/pages/drawing/defaults.rs @@ -0,0 +1,81 @@ +use crate::messages::Message; +use crate::models::{EraserModeOption, TextField, ToggleField}; + +use super::super::super::search::SearchArea; +use super::super::PageBuilder; +use super::{validate_f64_range, validate_usize_min, validate_usize_range}; + +pub(super) fn build(page: &mut PageBuilder) { + page.group_in_area("Drawing defaults", SearchArea::DrawingDefaults) + .entry_row_validated( + "Thickness (px)", + |app| app.draft.drawing_default_thickness.clone(), + |value| Message::TextChanged(TextField::DrawingThickness, value), + |app| validate_f64_range(&app.draft.drawing_default_thickness, 1.0, 50.0), + ) + .entry_row_validated( + "Font size (pt)", + |app| app.draft.drawing_default_font_size.clone(), + |value| Message::TextChanged(TextField::DrawingFontSize, value), + |app| validate_f64_range(&app.draft.drawing_default_font_size, 8.0, 72.0), + ) + .entry_row_validated( + "Polygon sides", + |app| app.draft.drawing_polygon_sides.clone(), + |value| Message::TextChanged(TextField::DrawingPolygonSides, value), + |app| validate_usize_range(&app.draft.drawing_polygon_sides, 3, 12), + ) + .entry_row_validated( + "Eraser size (px)", + |app| app.draft.drawing_default_eraser_size.clone(), + |value| Message::TextChanged(TextField::DrawingEraserSize, value), + |app| validate_f64_range(&app.draft.drawing_default_eraser_size, 1.0, 50.0), + ) + .combo_row( + "Eraser mode", + "", + EraserModeOption::list(), + EraserModeOption::list() + .iter() + .map(|option| option.label().to_string()) + .collect(), + |app| app.draft.drawing_default_eraser_mode, + Message::EraserModeChanged, + ) + .entry_row_validated( + "Marker opacity (0.05-0.9)", + |app| app.draft.drawing_marker_opacity.clone(), + |value| Message::TextChanged(TextField::DrawingMarkerOpacity, value), + |app| validate_f64_range(&app.draft.drawing_marker_opacity, 0.05, 0.9), + ) + .entry_row_validated( + "Undo stack limit", + |app| app.draft.drawing_undo_stack_limit.clone(), + |value| Message::TextChanged(TextField::DrawingUndoStackLimit, value), + |app| validate_usize_range(&app.draft.drawing_undo_stack_limit, 10, 1000), + ) + .entry_row_validated( + "Hit-test tolerance (px)", + |app| app.draft.drawing_hit_test_tolerance.clone(), + |value| Message::TextChanged(TextField::DrawingHitTestTolerance, value), + |app| validate_f64_range(&app.draft.drawing_hit_test_tolerance, 1.0, 20.0), + ) + .entry_row_validated( + "Hit-test threshold", + |app| app.draft.drawing_hit_test_linear_threshold.clone(), + |value| Message::TextChanged(TextField::DrawingHitTestThreshold, value), + |app| validate_usize_min(&app.draft.drawing_hit_test_linear_threshold, 1), + ) + .switch_row( + "Enable text background", + "", + |app| app.draft.drawing_text_background_enabled, + |value| Message::ToggleChanged(ToggleField::DrawingTextBackground, value), + ) + .switch_row( + "Start shapes filled", + "", + |app| app.draft.drawing_default_fill_enabled, + |value| Message::ToggleChanged(ToggleField::DrawingFillEnabled, value), + ); +} diff --git a/configurator/src/app/pages/drawing/drag_mapping.rs b/configurator/src/app/pages/drawing/drag_mapping.rs new file mode 100644 index 00000000..12d19ac3 --- /dev/null +++ b/configurator/src/app/pages/drawing/drag_mapping.rs @@ -0,0 +1,172 @@ +use relm4::gtk; + +use gtk::prelude::*; + +use wayscriber::config::DragButtonConfig; + +use crate::messages::Message; +use crate::models::{DragColorOption, DragMouseButton, DragToolField, DragToolOption, TabId}; + +use super::super::super::search::{AppSearchSummary, SearchArea}; +use super::super::super::state::ConfiguratorApp; +use super::super::PageBuilder; +use super::{DRAG_BUTTONS, DRAG_FIELDS, boxed_list, section_combo_row, set_visible_if_changed}; + +pub(super) fn build(page: &mut PageBuilder) { + page.group_in_area("Drag tool mapping", SearchArea::DrawingDragTools); + + let switcher = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .halign(gtk::Align::Start) + .css_classes(["linked"]) + .build(); + for button in DRAG_BUTTONS { + let toggle = gtk::Button::with_label(button.label()); + { + let sender = page.sender(); + toggle.connect_clicked(move |_| { + sender.input(Message::DrawingDragMappingSectionToggled(button)); + }); + } + switcher.append(&toggle); + page.bind(move |app, _summary| { + let open = app.active_drawing_drag_button == Some(button); + if toggle.has_css_class("suggested-action") != open { + if open { + toggle.add_css_class("suggested-action"); + } else { + toggle.remove_css_class("suggested-action"); + } + } + }); + } + page.custom(&switcher); + + for button in DRAG_BUTTONS { + build_drag_button_section(page, button); + } +} + +fn build_drag_button_section(page: &mut PageBuilder, button: DragMouseButton) { + let section = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .margin_top(12) + .build(); + section.append( + >k::Label::builder() + .label(button.label()) + .xalign(0.0) + .css_classes(["heading"]) + .build(), + ); + let list = boxed_list(); + section.append(&list); + page.custom(§ion); + page.bind(move |app, summary| { + set_visible_if_changed(§ion, drag_section_visible(app, summary, button)); + }); + + let tools = DragToolOption::list_for_button(button); + let tool_labels: Vec = tools + .iter() + .map(|option| option.label().to_string()) + .collect(); + let colors = DragColorOption::list(); + let color_labels: Vec = colors + .iter() + .map(|option| option.label().to_string()) + .collect(); + + for field in DRAG_FIELDS { + section_combo_row( + page, + &list, + field.label(), + tools.clone(), + tool_labels.clone(), + move |app| drag_tool_for_field(drag_button_config(app, button), field), + move |option| Message::DrawingMouseDragToolChanged(button, field, option), + ); + section_combo_row( + page, + &list, + &format!("{} color", field.label()), + colors.clone(), + color_labels.clone(), + move |app| drag_color_for_field(drag_button_config(app, button), field), + move |option| Message::DrawingMouseDragColorChanged(button, field, option), + ); + } +} + +/// Mirrors the old view's `visible_drag_mapping_buttons`: a search that +/// matched the drag area on its own opens every button, otherwise only the +/// section the user picked is open. +fn drag_section_visible( + app: &ConfiguratorApp, + summary: &AppSearchSummary, + button: DragMouseButton, +) -> bool { + let matched_by_search = summary.tab(TabId::Drawing).is_some_and(|search| { + !search.show_all() && search.area_matches(SearchArea::DrawingDragTools) + }); + matched_by_search || app.active_drawing_drag_button == Some(button) +} + +fn drag_button_config(app: &ConfiguratorApp, button: DragMouseButton) -> &DragButtonConfig { + match button { + DragMouseButton::Left => &app.draft.drawing_drag_tools.left, + DragMouseButton::Right => &app.draft.drawing_drag_tools.right, + DragMouseButton::Middle => &app.draft.drawing_drag_tools.middle, + } +} + +fn drag_tool_for_field(config: &DragButtonConfig, field: DragToolField) -> DragToolOption { + match field { + DragToolField::Drag => DragToolOption::from_drag_tool(config.drag_tool), + DragToolField::ShiftDrag => DragToolOption::from_drag_tool(config.shift_drag_tool), + DragToolField::CtrlDrag => DragToolOption::from_drag_tool(config.ctrl_drag_tool), + DragToolField::CtrlShiftDrag => DragToolOption::from_drag_tool(config.ctrl_shift_drag_tool), + DragToolField::TabDrag => DragToolOption::from_drag_tool(config.tab_drag_tool), + } +} + +fn drag_color_for_field(config: &DragButtonConfig, field: DragToolField) -> DragColorOption { + match field { + DragToolField::Drag => DragColorOption::from_color(config.drag_color.as_ref()), + DragToolField::ShiftDrag => DragColorOption::from_color(config.shift_drag_color.as_ref()), + DragToolField::CtrlDrag => DragColorOption::from_color(config.ctrl_drag_color.as_ref()), + DragToolField::CtrlShiftDrag => { + DragColorOption::from_color(config.ctrl_shift_drag_color.as_ref()) + } + DragToolField::TabDrag => DragColorOption::from_color(config.tab_drag_color.as_ref()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::SearchQuery; + #[test] + fn drag_sections_all_open_when_search_matches_the_drag_area() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("shift"); + let summary = app.search_summary(); + + assert_eq!(app.active_drawing_drag_button, None); + for button in DRAG_BUTTONS { + assert!(drag_section_visible(&app, &summary, button)); + } + } + + #[test] + fn drag_sections_follow_the_open_button_without_a_search() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.active_drawing_drag_button = Some(DragMouseButton::Right); + let summary = app.search_summary(); + + assert!(drag_section_visible(&app, &summary, DragMouseButton::Right)); + assert!(!drag_section_visible(&app, &summary, DragMouseButton::Left)); + } +} diff --git a/configurator/src/app/pages/drawing/font.rs b/configurator/src/app/pages/drawing/font.rs new file mode 100644 index 00000000..62a9eb2c --- /dev/null +++ b/configurator/src/app/pages/drawing/font.rs @@ -0,0 +1,54 @@ +use crate::messages::Message; +use crate::models::{FontStyleOption, FontWeightOption, TextField}; + +use super::super::super::search::SearchArea; +use super::super::PageBuilder; +use super::{conditional_section, section_entry_row}; + +pub(super) fn build(page: &mut PageBuilder) { + page.group_in_area("Font", SearchArea::DrawingFont) + .entry_row( + "Font family", + |app| app.draft.drawing_font_family.clone(), + |value| Message::TextChanged(TextField::DrawingFontFamily, value), + ) + .combo_row( + "Font weight", + "", + FontWeightOption::list(), + FontWeightOption::list() + .iter() + .map(|option| option.label().to_string()) + .collect(), + |app| app.draft.drawing_font_weight_option, + Message::FontWeightOptionSelected, + ) + .entry_row( + "Custom or numeric weight", + |app| app.draft.drawing_font_weight.clone(), + |value| Message::TextChanged(TextField::DrawingFontWeight, value), + ) + .combo_row( + "Font style", + "", + FontStyleOption::list(), + FontStyleOption::list() + .iter() + .map(|option| option.label().to_string()) + .collect(), + |app| app.draft.drawing_font_style_option, + Message::FontStyleOptionSelected, + ); + + let custom_style = conditional_section(page, |app| { + app.draft.drawing_font_style_option == FontStyleOption::Custom + }); + section_entry_row( + page, + &custom_style, + "Custom style", + |app| app.draft.drawing_font_style.clone(), + |value| Message::TextChanged(TextField::DrawingFontStyle, value), + |_app| None, + ); +} diff --git a/configurator/src/app/pages/drawing/quick_colors.rs b/configurator/src/app/pages/drawing/quick_colors.rs new file mode 100644 index 00000000..cdd422f9 --- /dev/null +++ b/configurator/src/app/pages/drawing/quick_colors.rs @@ -0,0 +1,402 @@ +use relm4::{ComponentSender, adw, gtk}; + +use adw::prelude::*; + +use wayscriber::config::{QUICK_COLOR_RENDER_LIMIT, QuickColorSlot}; + +use crate::messages::Message; +use crate::models::{ColorMode, ColorPickerId, NamedColorOption, TextField}; + +use super::super::super::search::SearchArea; +use super::super::super::state::ConfiguratorApp; +use super::super::color_rows::{ResolvedColor, dialog_hex, mark_hex_error, set_swatch_blocked}; +use super::super::{PageBuilder, set_selected_blocked, set_text_blocked}; +use super::{ + COLOR_MODES, boxed_list, color_dialog_button, combo_row_widget, connect_combo, icon_button, + named_color_labels, resolved, select_if_changed, set_error_if_changed, set_visible_if_changed, +}; + +pub(super) fn build(page: &mut PageBuilder) { + page.group_in_area("Quick colors", SearchArea::DrawingColor); + + // An activatable ActionRow rather than AdwButtonRow: ButtonRow needs + // libadwaita 1.6 and the crate's feature floor is 1.4 (Ubuntu 24.04). + let add = adw::ActionRow::builder() + .title("Add color") + .activatable(true) + .build(); + add.add_prefix(>k::Image::from_icon_name("list-add-symbolic")); + { + let sender = page.sender(); + add.connect_activated(move |_| sender.input(Message::QuickColorAdded)); + } + page.custom(&add); + + let warning = gtk::Label::builder() + .label(format!( + "Only the first {QUICK_COLOR_RENDER_LIMIT} quick colors are shown in toolbar and radial menus." + )) + .wrap(true) + .xalign(0.0) + .margin_top(6) + .css_classes(["warning"]) + .build(); + page.custom(&warning); + page.bind(move |app, _summary| { + let over_limit = app.draft.drawing_quick_colors.entries.len() > QUICK_COLOR_RENDER_LIMIT; + set_visible_if_changed(&warning, over_limit); + }); + + let list = boxed_list(); + list.set_margin_top(6); + page.custom(&list); + let sender = page.sender(); + // Each built row owns the layout that produced it and its typed refresh. + let mut rows: Vec = Vec::new(); + page.bind(move |app, _summary| { + let layouts = quick_color_layouts(app); + if !rows + .iter() + .map(|row| row.layout) + .eq(layouts.iter().copied()) + { + rows = rebuild_quick_colors(&list, &layouts, &sender); + } + for ((index, entry), row) in app + .draft + .drawing_quick_colors + .entries + .iter() + .enumerate() + .zip(rows.iter()) + { + let values = QuickColorValues { + label: &entry.label, + name: &entry.color.name, + hex: picker_hex(app, ColorPickerId::QuickColor(index)), + named: entry.color.selected_named, + preview: resolved(entry.color.preview_color()), + summary: entry.color.summary(), + }; + (row.refresh)(&values); + } + }); +} + +/// Everything a quick color row shows before any text is written into it. +/// +/// This is the whole rebuild trigger: the binding compares the list of these +/// it built against the list the model asks for now, so a control added here +/// rebuilds on its own value. Everything the user types — and the named-color +/// choice, which a typed name moves on its own — stays out, because rebuilding +/// a row takes the caret with it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct QuickColorLayout { + mode: ColorMode, + can_move_up: bool, + can_move_down: bool, + can_remove: bool, +} + +fn quick_color_layouts(app: &ConfiguratorApp) -> Vec { + let count = app.draft.drawing_quick_colors.entries.len(); + app.draft + .drawing_quick_colors + .entries + .iter() + .enumerate() + .map(|(index, entry)| QuickColorLayout { + mode: entry.color.mode, + can_move_up: index > 0, + can_move_down: index + 1 < count, + // The draft refuses to drop below the eight slots the shortcuts + // bind. + can_remove: count > QuickColorSlot::ALL.len(), + }) + .collect() +} + +/// One quick color row's values: everything the layout deliberately left out. +struct QuickColorValues<'a> { + label: &'a str, + name: &'a str, + hex: &'a str, + named: NamedColorOption, + /// The color the entry resolves to, `None` when it resolves to nothing — + /// which is also what makes the name field an error. + preview: ResolvedColor, + summary: String, +} + +/// One row's refresh: built beside its row, so it owns that row's typed widget +/// handles and the signal handler ids guarding each write. +type QuickColorRowRefresh = Box)>; + +struct BoundQuickColorRow { + layout: QuickColorLayout, + refresh: QuickColorRowRefresh, +} + +fn rebuild_quick_colors( + list: >k::ListBox, + layouts: &[QuickColorLayout], + sender: &ComponentSender, +) -> Vec { + // Draining the list, not walking it for a control: the rows that replace + // these carry their own refresh closures. + while let Some(child) = list.first_child() { + list.remove(&child); + } + + let mut rows = Vec::with_capacity(layouts.len()); + for (index, layout) in layouts.iter().enumerate() { + let built = build_quick_color_row(index, *layout, sender); + list.append(&built.row); + rows.push(BoundQuickColorRow { + layout: *layout, + refresh: built.refresh, + }); + } + rows +} + +/// One quick color entry, built with everything its layout decides already in +/// place, and paired with the closure that writes the rest. +/// +/// The closure owns the row's widgets and the ids of their handlers, so it +/// writes them blocked: the hex path reports a programmatic write as a user +/// pick, and that pick rewrites the entry's components and wipes the status +/// line the user was reading. +struct QuickColorRow { + row: adw::ExpanderRow, + refresh: QuickColorRowRefresh, +} + +fn build_quick_color_row( + index: usize, + layout: QuickColorLayout, + sender: &ComponentSender, +) -> QuickColorRow { + let row = adw::ExpanderRow::builder() + .title(format!("Color {}", index + 1)) + .build(); + + let up = icon_button("go-up-symbolic", "Move up"); + up.set_sensitive(layout.can_move_up); + { + let sender = sender.clone(); + up.connect_clicked(move |_| sender.input(Message::QuickColorMoved(index, -1))); + } + let down = icon_button("go-down-symbolic", "Move down"); + down.set_sensitive(layout.can_move_down); + { + let sender = sender.clone(); + down.connect_clicked(move |_| sender.input(Message::QuickColorMoved(index, 1))); + } + let remove = icon_button("user-trash-symbolic", "Remove"); + remove.set_sensitive(layout.can_remove); + { + let sender = sender.clone(); + remove.connect_clicked(move |_| sender.input(Message::QuickColorRemoved(index))); + } + row.add_suffix(&up); + row.add_suffix(&down); + row.add_suffix(&remove); + + let label = adw::EntryRow::builder().title("Label").build(); + let label_handler = { + let sender = sender.clone(); + label.connect_changed(move |row| { + sender.input(Message::TextChanged( + TextField::QuickColorLabel(index), + row.text().to_string(), + )); + }) + }; + row.add_row(&label); + + // The mode is part of the fingerprint, so it is selected before the + // handler exists and never written again behind the user's back. + let mode = combo_row_widget( + "Color mode", + &["Named or hex".to_string(), "RGB".to_string()], + ); + select_if_changed(&mode, &COLOR_MODES, layout.mode); + connect_combo(&mode, sender.clone(), COLOR_MODES.to_vec(), move |value| { + Message::QuickColorModeChanged(index, value) + }); + row.add_row(&mode); + + let named_options = NamedColorOption::list(); + let named = combo_row_widget("Named color", &named_color_labels()); + named.set_visible(layout.mode == ColorMode::Named); + let named_handler = { + let sender = sender.clone(); + let options = named_options.clone(); + named.connect_selected_notify(move |row| { + if let Some(option) = options.get(row.selected() as usize) { + sender.input(Message::QuickNamedColorSelected(index, *option)); + } + }) + }; + row.add_row(&named); + + let name = adw::EntryRow::builder() + .title("Color name or #RRGGBB[AA]") + .build(); + name.set_visible(layout.mode == ColorMode::Named); + let name_handler = { + let sender = sender.clone(); + name.connect_changed(move |row| { + sender.input(Message::TextChanged( + TextField::QuickColorName(index), + row.text().to_string(), + )); + }) + }; + row.add_row(&name); + + let picker = ColorPickerId::QuickColor(index); + let hex = adw::EntryRow::builder().title("Custom color").build(); + hex.set_visible(layout.mode == ColorMode::Rgb); + let hex_handler = { + let sender = sender.clone(); + hex.connect_changed(move |row| { + sender.input(Message::ColorPickerHexChanged( + picker, + row.text().to_string(), + )); + }) + }; + let swatch = color_dialog_button(); + let swatch_handler = { + let sender = sender.clone(); + swatch.connect_rgba_notify(move |button| { + sender.input(Message::ColorPickerHexChanged( + picker, + dialog_hex(&button.rgba()), + )); + }) + }; + hex.add_suffix(&swatch); + row.add_row(&hex); + + let expander = row.clone(); + let refresh: QuickColorRowRefresh = Box::new(move |values| { + let subtitle = format!("{} / {}", values.label.trim(), values.summary); + if expander.subtitle() != subtitle { + expander.set_subtitle(&subtitle); + } + + set_text_blocked(&label, &label_handler, values.label); + set_text_blocked(&name, &name_handler, values.name); + set_error_if_changed(&name, name_error(values)); + set_text_blocked(&hex, &hex_handler, values.hex); + // The same predicate the save gate counts with, so a field styled + // clean can never be one the save refuses. + mark_hex_error(&hex, values.hex); + + if let Some(position) = named_options + .iter() + .position(|option| *option == values.named) + { + set_selected_blocked(&named, &named_handler, position as u32); + } + + if let Some((r, g, b, a)) = values.preview { + let rgba = gtk::gdk::RGBA::new(r as f32, g as f32, b as f32, a as f32); + set_swatch_blocked(&swatch, &swatch_handler, &rgba); + } + }); + + QuickColorRow { row, refresh } +} + +/// The error the old view showed under a quick color name that resolves to +/// nothing, read off the values the row was handed. +fn name_error(values: &QuickColorValues<'_>) -> Option { + let unresolved = values.preview.is_none() && !values.name.trim().is_empty(); + unresolved.then(|| "Use a known color name, #RRGGBB, or #RRGGBBAA for alpha".to_string()) +} + +fn picker_hex(app: &ConfiguratorApp, id: ColorPickerId) -> &str { + app.color_picker_hex.get(&id).map_or("", String::as_str) +} + +#[cfg(test)] +mod tests { + use super::*; + /// The law the binding rests on: one layout per quick color, so the rows + /// it builds and the values it hands them are indexed by the same thing. + #[test] + fn there_is_one_layout_per_quick_color() { + let (app, _effects) = ConfiguratorApp::new_app(); + let layouts = quick_color_layouts(&app); + + assert_eq!(layouts.len(), app.draft.drawing_quick_colors.entries.len()); + } + + /// The caret guarantee, stated as the layout law it rests on: typing a + /// color name moves the named-color choice with it, so neither may be + /// part of what rebuilds the row. + #[test] + fn typing_a_quick_color_name_leaves_the_layout_alone() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let before = quick_color_layouts(&app); + + app.draft + .set_text(TextField::QuickColorName(0), "gree".to_string()); + + assert_eq!(before, quick_color_layouts(&app)); + // The choice the model moved rides the row's values instead, where a + // blocked write can put it in the combo without a rebuild. + assert_eq!( + app.draft.drawing_quick_colors.entries[0] + .color + .selected_named, + NamedColorOption::Custom + ); + } + + #[test] + fn switching_a_quick_color_to_rgb_changes_the_layout() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let before = quick_color_layouts(&app); + + let Some(entry) = app.draft.drawing_quick_colors.get_mut(0) else { + return; + }; + entry.color.mode = ColorMode::Rgb; + + assert_ne!(before, quick_color_layouts(&app)); + } + + #[test] + fn a_quick_color_name_that_resolves_to_nothing_is_an_error() { + let values = QuickColorValues { + label: "Red", + name: "red", + hex: "#FF0000", + named: NamedColorOption::Red, + preview: Some((1.0, 0.0, 0.0, 1.0)), + summary: "Red".to_string(), + }; + assert_eq!(name_error(&values), None); + + let unresolved = QuickColorValues { + preview: None, + name: "nope", + summary: values.summary.clone(), + ..values + }; + assert!(name_error(&unresolved).is_some()); + + let empty = QuickColorValues { + preview: None, + name: " ", + summary: values.summary.clone(), + ..values + }; + assert_eq!(name_error(&empty), None); + } +} diff --git a/configurator/src/app/pages/presets.rs b/configurator/src/app/pages/presets.rs index 015b4efa..ee16366b 100644 --- a/configurator/src/app/pages/presets.rs +++ b/configurator/src/app/pages/presets.rs @@ -9,23 +9,26 @@ //! `enabled` flag, its expansion mirrors `preset_collapsed`, and every row //! inside sends the same indexed [`Message`] the Iced slot view sent. +mod color; +mod rows; + +use relm4::adw; use relm4::prelude::*; -use relm4::{adw, gtk}; use adw::prelude::*; use wayscriber::config::{PRESET_SLOTS_MAX, PRESET_SLOTS_MIN}; -use gtk::glib::SignalHandlerId; - use crate::messages::Message; use crate::models::{ - ColorMode, NamedColorOption, OverrideOption, PresetEraserKindOption, PresetEraserModeOption, - PresetTextField, PresetToggleField, TabId, ToolOption, + PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, TabId, + ToolOption, }; use super::super::search::{AppSearchSummary, SearchArea}; use super::super::state::ConfiguratorApp; -use super::{BuiltPage, PageBuilder, set_selected_blocked, set_text_blocked, validate_u32_range}; +use super::{BuiltPage, PageBuilder}; +use color::build_color_rows; +use rows::{SlotBuilder, labels_of, slot_button}; pub(super) fn build(sender: &ComponentSender) -> BuiltPage { let mut page = PageBuilder::new(sender, TabId::Presets); @@ -289,300 +292,3 @@ fn build_slot(page: &mut PageBuilder, slot: usize) { }, ); } - -/// The slot's color block: a mode chooser with a live preview, then the -/// rows the chosen mode edits. -fn build_color_rows(rows: &mut SlotBuilder<'_>, slot: usize) { - let mode_row = rows.combo_row( - "Color mode", - vec![ColorMode::Named, ColorMode::Rgb], - vec!["Named".to_string(), "RGB".to_string()], - move |app| app.draft.presets.slot(slot).map(|draft| draft.color.mode), - Message::PresetColorModeChanged, - ); - let swatch = color_swatch(); - mode_row.add_suffix(&swatch); - // The color the swatch is currently drawing, owned by the binding that - // draws it: a new draw function is what redraws the widget, so it is only - // installed when the color actually moved. - let mut shown: Option<[f64; 3]> = None; - rows.page.bind(move |app, _search| { - let color = app - .draft - .presets - .slot(slot) - .and_then(|draft| draft.color.preview_color()) - .map(|color| [color.r, color.g, color.b]); - if shown == color { - return; - } - shown = color; - match color { - Some(rgb) => swatch.set_draw_func(move |_, context, width, height| { - context.set_source_rgb(rgb[0], rgb[1], rgb[2]); - context.rectangle(0.0, 0.0, f64::from(width), f64::from(height)); - // A failed fill only leaves the swatch blank. - let _ = context.fill(); - }), - // A slot whose color resolves to nothing draws nothing. - None => swatch.set_draw_func(|_, _, _, _| {}), - } - }); - - let named = NamedColorOption::list(); - let named_labels = labels_of(&named, NamedColorOption::label); - let named_row = rows.combo_row( - "Named color", - named, - named_labels, - move |app| { - app.draft - .presets - .slot(slot) - .map(|draft| draft.color.selected_named) - }, - Message::PresetNamedColorSelected, - ); - rows.visible_when(&named_row, move |app| { - app.draft - .presets - .slot(slot) - .is_some_and(|draft| draft.color.mode == ColorMode::Named) - }); - - let custom_name = rows.entry_row_validated( - "Custom color name", - move |app| { - app.draft - .presets - .slot(slot) - .map(|draft| draft.color.name.clone()) - }, - move |slot, value| Message::PresetTextChanged(slot, PresetTextField::ColorName, value), - move |app| { - let color = &app.draft.presets.slot(slot)?.color; - if color.mode != ColorMode::Named { - return None; - } - (color.preview_color().is_none() && !color.name.trim().is_empty()) - .then(|| "Unknown color name.".to_string()) - }, - ); - rows.visible_when(&custom_name, move |app| { - app.draft.presets.slot(slot).is_some_and(|draft| { - draft.color.mode == ColorMode::Named && draft.color.selected_named_is_custom() - }) - }); - - for (component, title) in [ - (0usize, "Red (0-255)"), - (1, "Green (0-255)"), - (2, "Blue (0-255)"), - ] { - let row = rows.entry_row_validated( - title, - move |app| { - app.draft - .presets - .slot(slot) - .and_then(|draft| draft.color.rgb.get(component).cloned()) - }, - move |slot, value| Message::PresetColorComponentChanged(slot, component, value), - move |app| { - let color = &app.draft.presets.slot(slot)?.color; - if color.mode != ColorMode::Rgb { - return None; - } - validate_u32_range(color.rgb.get(component)?, 0, 255) - }, - ); - rows.visible_when(&row, move |app| { - app.draft - .presets - .slot(slot) - .is_some_and(|draft| draft.color.mode == ColorMode::Rgb) - }); - } -} - -/// Row builder for one slot. -/// -/// Mirrors the [`PageBuilder`] row helpers, but adds rows to the slot's -/// expander and reads the slot out of the model, so a slot the draft does -/// not hold simply leaves its rows alone. -struct SlotBuilder<'a> { - page: &'a mut PageBuilder, - expander: adw::ExpanderRow, - slot: usize, -} - -impl SlotBuilder<'_> { - /// A free-text row sending `to_message(slot, text)` on change. - fn entry_row( - &mut self, - title: &str, - get: impl Fn(&ConfiguratorApp) -> Option + 'static, - to_message: impl Fn(usize, String) -> Message + 'static, - ) -> adw::EntryRow { - self.entry_row_validated(title, get, to_message, |_app| None) - } - - /// A free-text row with live validation: a non-`None` result marks the - /// row `.error` and shows the text as its tooltip. - fn entry_row_validated( - &mut self, - title: &str, - get: impl Fn(&ConfiguratorApp) -> Option + 'static, - to_message: impl Fn(usize, String) -> Message + 'static, - validate: impl Fn(&ConfiguratorApp) -> Option + 'static, - ) -> adw::EntryRow { - let row = adw::EntryRow::builder().title(title).build(); - let slot = self.slot; - let handler = { - let sender = self.page.sender(); - row.connect_changed(move |row| { - sender.input(to_message(slot, row.text().to_string())); - }) - }; - self.expander.add_row(&row); - { - let row = row.clone(); - self.page.bind(move |app, _search| { - if let Some(value) = get(app) { - // Blocked: the draft owns this text, and a load reporting - // its own value back as a user edit clears that load's - // diagnostics from the status line. - set_text_blocked(&row, &handler, &value); - } - set_row_error(&row, validate(app)); - }); - } - row - } - - /// A single-choice row sending `to_message(slot, value)` on selection. - fn combo_row( - &mut self, - title: &str, - values: Vec, - labels: Vec, - get: impl Fn(&ConfiguratorApp) -> Option + 'static, - to_message: impl Fn(usize, O) -> Message + 'static, - ) -> adw::ComboRow - where - O: Copy + PartialEq + 'static, - { - let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); - let row = adw::ComboRow::builder() - .title(title) - .model(>k::StringList::new(&label_refs)) - .build(); - let slot = self.slot; - let handler: SignalHandlerId = { - let sender = self.page.sender(); - let values = values.clone(); - row.connect_selected_notify(move |row| { - if let Some(value) = values.get(row.selected() as usize) { - sender.input(to_message(slot, *value)); - } - }) - }; - self.expander.add_row(&row); - { - let row = row.clone(); - self.page.bind(move |app, _search| { - let Some(current) = get(app) else { - return; - }; - if let Some(index) = values.iter().position(|value| *value == current) { - // Blocked: the draft chose this, and reporting it back as - // a user pick clears the status line a load just wrote. - set_selected_blocked(&row, &handler, index as u32); - } - }); - } - row - } - - /// A Default/On/Off row for one of the slot's override fields. - fn override_row( - &mut self, - title: &str, - field: PresetToggleField, - get: impl Fn(&ConfiguratorApp) -> Option + 'static, - ) -> adw::ComboRow { - let values = OverrideOption::list(); - let labels = labels_of(&values, OverrideOption::label); - self.combo_row(title, values, labels, get, move |slot, value| { - Message::PresetToggleOptionChanged(slot, field, value) - }) - } - - /// Binds a row's visibility to a model condition. - fn visible_when( - &mut self, - row: &impl IsA, - visible: impl Fn(&ConfiguratorApp) -> bool + 'static, - ) { - let row = row.clone().upcast::(); - self.page.bind(move |app, _search| { - let value = visible(app); - if row.is_visible() != value { - row.set_visible(value); - } - }); - } -} - -/// A flat header button for one of the slot's actions. -fn slot_button(page: &PageBuilder, label: &str, tooltip: &str, message: Message) -> gtk::Button { - let button = gtk::Button::builder() - .label(label) - .tooltip_text(tooltip) - .valign(gtk::Align::Center) - .css_classes(["flat"]) - .build(); - let sender = page.sender(); - button.connect_clicked(move |_| sender.input(message.clone())); - button -} - -/// A read-only preview of the slot's resolved color. -/// -/// Blank until its binding installs a draw function carrying the color, so -/// the widget holds no state the binding has to encode and decode. -fn color_swatch() -> gtk::DrawingArea { - gtk::DrawingArea::builder() - .content_width(24) - .content_height(24) - .valign(gtk::Align::Center) - .css_classes(["card"]) - .build() -} - -fn set_row_error(row: &adw::EntryRow, error: Option) { - let has_error_class = row.has_css_class("error"); - match error { - Some(message) => { - if !has_error_class { - row.add_css_class("error"); - } - if row.tooltip_text().as_deref() != Some(message.as_str()) { - row.set_tooltip_text(Some(&message)); - } - } - None => { - if has_error_class { - row.remove_css_class("error"); - row.set_tooltip_text(None); - } - } - } -} - -fn labels_of(values: &[O], label: impl Fn(&O) -> &'static str) -> Vec { - values - .iter() - .map(|value| label(value).to_string()) - .collect() -} diff --git a/configurator/src/app/pages/presets/color.rs b/configurator/src/app/pages/presets/color.rs new file mode 100644 index 00000000..e86e56fa --- /dev/null +++ b/configurator/src/app/pages/presets/color.rs @@ -0,0 +1,122 @@ +use super::rows::{SlotBuilder, color_swatch, labels_of}; +use relm4::adw::prelude::*; + +use crate::messages::Message; +use crate::models::{ColorMode, NamedColorOption, PresetTextField}; + +use super::super::validate_u32_range; + +/// The slot's color block: a mode chooser with a live preview, then the +/// rows the chosen mode edits. +pub(super) fn build_color_rows(rows: &mut SlotBuilder<'_>, slot: usize) { + let mode_row = rows.combo_row( + "Color mode", + vec![ColorMode::Named, ColorMode::Rgb], + vec!["Named".to_string(), "RGB".to_string()], + move |app| app.draft.presets.slot(slot).map(|draft| draft.color.mode), + Message::PresetColorModeChanged, + ); + let swatch = color_swatch(); + mode_row.add_suffix(&swatch); + // The color the swatch is currently drawing, owned by the binding that + // draws it: a new draw function is what redraws the widget, so it is only + // installed when the color actually moved. + let mut shown: Option<[f64; 3]> = None; + rows.page.bind(move |app, _search| { + let color = app + .draft + .presets + .slot(slot) + .and_then(|draft| draft.color.preview_color()) + .map(|color| [color.r, color.g, color.b]); + if shown == color { + return; + } + shown = color; + match color { + Some(rgb) => swatch.set_draw_func(move |_, context, width, height| { + context.set_source_rgb(rgb[0], rgb[1], rgb[2]); + context.rectangle(0.0, 0.0, f64::from(width), f64::from(height)); + // A failed fill only leaves the swatch blank. + let _ = context.fill(); + }), + // A slot whose color resolves to nothing draws nothing. + None => swatch.set_draw_func(|_, _, _, _| {}), + } + }); + + let named = NamedColorOption::list(); + let named_labels = labels_of(&named, NamedColorOption::label); + let named_row = rows.combo_row( + "Named color", + named, + named_labels, + move |app| { + app.draft + .presets + .slot(slot) + .map(|draft| draft.color.selected_named) + }, + Message::PresetNamedColorSelected, + ); + rows.visible_when(&named_row, move |app| { + app.draft + .presets + .slot(slot) + .is_some_and(|draft| draft.color.mode == ColorMode::Named) + }); + + let custom_name = rows.entry_row_validated( + "Custom color name", + move |app| { + app.draft + .presets + .slot(slot) + .map(|draft| draft.color.name.clone()) + }, + move |slot, value| Message::PresetTextChanged(slot, PresetTextField::ColorName, value), + move |app| { + let color = &app.draft.presets.slot(slot)?.color; + if color.mode != ColorMode::Named { + return None; + } + (color.preview_color().is_none() && !color.name.trim().is_empty()) + .then(|| "Unknown color name.".to_string()) + }, + ); + rows.visible_when(&custom_name, move |app| { + app.draft.presets.slot(slot).is_some_and(|draft| { + draft.color.mode == ColorMode::Named && draft.color.selected_named_is_custom() + }) + }); + + for (component, title) in [ + (0usize, "Red (0-255)"), + (1, "Green (0-255)"), + (2, "Blue (0-255)"), + ] { + let row = rows.entry_row_validated( + title, + move |app| { + app.draft + .presets + .slot(slot) + .and_then(|draft| draft.color.rgb.get(component).cloned()) + }, + move |slot, value| Message::PresetColorComponentChanged(slot, component, value), + move |app| { + let color = &app.draft.presets.slot(slot)?.color; + if color.mode != ColorMode::Rgb { + return None; + } + validate_u32_range(color.rgb.get(component)?, 0, 255) + }, + ); + rows.visible_when(&row, move |app| { + app.draft + .presets + .slot(slot) + .is_some_and(|draft| draft.color.mode == ColorMode::Rgb) + }); + } +} diff --git a/configurator/src/app/pages/presets/rows.rs b/configurator/src/app/pages/presets/rows.rs new file mode 100644 index 00000000..0f6a6600 --- /dev/null +++ b/configurator/src/app/pages/presets/rows.rs @@ -0,0 +1,197 @@ +use relm4::{adw, gtk}; + +use adw::prelude::*; +use gtk::glib::SignalHandlerId; + +use crate::messages::Message; +use crate::models::{OverrideOption, PresetToggleField}; + +use super::super::super::state::ConfiguratorApp; +use super::super::{PageBuilder, set_selected_blocked, set_text_blocked}; + +/// Row builder for one slot. +/// +/// Mirrors the [`PageBuilder`] row helpers, but adds rows to the slot's +/// expander and reads the slot out of the model, so a slot the draft does +/// not hold simply leaves its rows alone. +pub(super) struct SlotBuilder<'a> { + pub(super) page: &'a mut PageBuilder, + pub(super) expander: adw::ExpanderRow, + pub(super) slot: usize, +} + +impl SlotBuilder<'_> { + /// A free-text row sending `to_message(slot, text)` on change. + pub(super) fn entry_row( + &mut self, + title: &str, + get: impl Fn(&ConfiguratorApp) -> Option + 'static, + to_message: impl Fn(usize, String) -> Message + 'static, + ) -> adw::EntryRow { + self.entry_row_validated(title, get, to_message, |_app| None) + } + + /// A free-text row with live validation: a non-`None` result marks the + /// row `.error` and shows the text as its tooltip. + pub(super) fn entry_row_validated( + &mut self, + title: &str, + get: impl Fn(&ConfiguratorApp) -> Option + 'static, + to_message: impl Fn(usize, String) -> Message + 'static, + validate: impl Fn(&ConfiguratorApp) -> Option + 'static, + ) -> adw::EntryRow { + let row = adw::EntryRow::builder().title(title).build(); + let slot = self.slot; + let handler = { + let sender = self.page.sender(); + row.connect_changed(move |row| { + sender.input(to_message(slot, row.text().to_string())); + }) + }; + self.expander.add_row(&row); + { + let row = row.clone(); + self.page.bind(move |app, _search| { + if let Some(value) = get(app) { + // Blocked: the draft owns this text, and a load reporting + // its own value back as a user edit clears that load's + // diagnostics from the status line. + set_text_blocked(&row, &handler, &value); + } + set_row_error(&row, validate(app)); + }); + } + row + } + + /// A single-choice row sending `to_message(slot, value)` on selection. + pub(super) fn combo_row( + &mut self, + title: &str, + values: Vec, + labels: Vec, + get: impl Fn(&ConfiguratorApp) -> Option + 'static, + to_message: impl Fn(usize, O) -> Message + 'static, + ) -> adw::ComboRow + where + O: Copy + PartialEq + 'static, + { + let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let row = adw::ComboRow::builder() + .title(title) + .model(>k::StringList::new(&label_refs)) + .build(); + let slot = self.slot; + let handler: SignalHandlerId = { + let sender = self.page.sender(); + let values = values.clone(); + row.connect_selected_notify(move |row| { + if let Some(value) = values.get(row.selected() as usize) { + sender.input(to_message(slot, *value)); + } + }) + }; + self.expander.add_row(&row); + { + let row = row.clone(); + self.page.bind(move |app, _search| { + let Some(current) = get(app) else { + return; + }; + if let Some(index) = values.iter().position(|value| *value == current) { + // Blocked: the draft chose this, and reporting it back as + // a user pick clears the status line a load just wrote. + set_selected_blocked(&row, &handler, index as u32); + } + }); + } + row + } + + /// A Default/On/Off row for one of the slot's override fields. + pub(super) fn override_row( + &mut self, + title: &str, + field: PresetToggleField, + get: impl Fn(&ConfiguratorApp) -> Option + 'static, + ) -> adw::ComboRow { + let values = OverrideOption::list(); + let labels = labels_of(&values, OverrideOption::label); + self.combo_row(title, values, labels, get, move |slot, value| { + Message::PresetToggleOptionChanged(slot, field, value) + }) + } + + /// Binds a row's visibility to a model condition. + pub(super) fn visible_when( + &mut self, + row: &impl IsA, + visible: impl Fn(&ConfiguratorApp) -> bool + 'static, + ) { + let row = row.clone().upcast::(); + self.page.bind(move |app, _search| { + let value = visible(app); + if row.is_visible() != value { + row.set_visible(value); + } + }); + } +} + +/// A flat header button for one of the slot's actions. +pub(super) fn slot_button( + page: &PageBuilder, + label: &str, + tooltip: &str, + message: Message, +) -> gtk::Button { + let button = gtk::Button::builder() + .label(label) + .tooltip_text(tooltip) + .valign(gtk::Align::Center) + .css_classes(["flat"]) + .build(); + let sender = page.sender(); + button.connect_clicked(move |_| sender.input(message.clone())); + button +} + +/// A read-only preview of the slot's resolved color. +/// +/// Blank until its binding installs a draw function carrying the color, so +/// the widget holds no state the binding has to encode and decode. +pub(super) fn color_swatch() -> gtk::DrawingArea { + gtk::DrawingArea::builder() + .content_width(24) + .content_height(24) + .valign(gtk::Align::Center) + .css_classes(["card"]) + .build() +} + +fn set_row_error(row: &adw::EntryRow, error: Option) { + let has_error_class = row.has_css_class("error"); + match error { + Some(message) => { + if !has_error_class { + row.add_css_class("error"); + } + if row.tooltip_text().as_deref() != Some(message.as_str()) { + row.set_tooltip_text(Some(&message)); + } + } + None => { + if has_error_class { + row.remove_css_class("error"); + row.set_tooltip_text(None); + } + } + } +} + +pub(super) fn labels_of(values: &[O], label: impl Fn(&O) -> &'static str) -> Vec { + values + .iter() + .map(|value| label(value).to_string()) + .collect() +} diff --git a/configurator/src/app/pages/render_profiles.rs b/configurator/src/app/pages/render_profiles.rs index e49898ce..9d21a4cc 100644 --- a/configurator/src/app/pages/render_profiles.rs +++ b/configurator/src/app/pages/render_profiles.rs @@ -23,23 +23,26 @@ //! reports a pick as `ColorPickerHexChanged`, which rewrites the mapping in //! canonical form, so an echo would dirty a freshly loaded file. +mod mapping; +mod rows; +mod section; +#[cfg(test)] +mod tests; + use relm4::prelude::*; use relm4::{adw, gtk}; -use adw::prelude::*; -use gtk::glib::SignalHandlerId; - use crate::messages::Message; -use crate::models::color::parse_hex; use crate::models::{ - ColorPickerId, RenderProfileExportOption, RenderProfileMappingSide, - RenderProfileSelectionOption, RenderProfileTextField, TabId, + ColorPickerId, RenderProfileExportOption, RenderProfileSelectionOption, TabId, }; +use adw::prelude::*; use super::super::search::{AppSearchSummary, SearchArea, TabSearchSummary}; use super::super::state::ConfiguratorApp; -use super::color_rows::{dialog_hex, mark_hex_error, set_swatch_blocked}; -use super::{BuiltPage, PageBuilder, set_text_blocked}; +use super::{BuiltPage, PageBuilder}; +use rows::{picker_hex, selected_string, sync_combo}; +use section::{BoundProfileSection, rebuild_sections}; /// `GTK_INVALID_LIST_POSITION`: what a `GtkSingleSelection` reads as "nothing /// is selected", which is how the Iced pick list rendered an unknown id. @@ -284,469 +287,3 @@ struct MappingValues<'a> { from: &'a str, to: &'a str, } - -// ---- Sections ---------------------------------------------------------- - -/// One section's refresh: built beside its row, so it owns that row's typed -/// widget handles and the signal handler ids guarding each write. -type ProfileRowRefresh = Box)>; - -struct BoundProfileSection { - layout: SectionLayout, - refresh: ProfileRowRefresh, -} - -/// One profile's section: the list box, and the closure that writes the -/// values the layout deliberately left out. -struct ProfileSection { - section: gtk::ListBox, - refresh: ProfileRowRefresh, -} - -fn rebuild_sections( - container: >k::Box, - layouts: &[SectionLayout], - sender: &ComponentSender, -) -> Vec { - // Draining the container, not walking it for a control: the sections that - // replace these carry their own refresh closures. - while let Some(child) = container.first_child() { - container.remove(&child); - } - - let mut sections = Vec::with_capacity(layouts.len()); - for (index, layout) in layouts.iter().enumerate() { - let built = build_section(index, layout, sender); - container.append(&built.section); - sections.push(BoundProfileSection { - layout: layout.clone(), - refresh: built.refresh, - }); - } - sections -} - -fn build_section( - index: usize, - layout: &SectionLayout, - sender: &ComponentSender, -) -> ProfileSection { - let section = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) - .visible(layout.visible) - .build(); - - let title = gtk::Label::builder() - .xalign(0.0) - .hexpand(true) - .css_classes(["heading"]) - .build(); - section.append(&build_header_row(index, &title, sender)); - - let id = build_text_row("Profile id", index, RenderProfileTextField::Id, sender); - id.row.set_visible(layout.controls); - section.append(&id.row); - - let name = build_text_row("Display name", index, RenderProfileTextField::Name, sender); - name.row.set_visible(layout.controls); - section.append(&name.row); - - let mut mappings: Vec = Vec::with_capacity(layout.mappings.len()); - for (mapping, visible) in layout.mappings.iter().enumerate() { - let row = build_mapping_row(index, mapping, sender); - row.row.set_visible(*visible); - section.append(&row.row); - mappings.push(row); - } - - let add = build_add_mapping_row(index, sender); - add.set_visible(layout.controls); - section.append(&add); - - let refresh: ProfileRowRefresh = Box::new(move |values| { - let heading = if values.name.trim().is_empty() { - "Profile" - } else { - values.name.trim() - }; - if title.text() != heading { - title.set_text(heading); - } - - set_text_blocked(&id.row, &id.handler, values.id); - set_text_blocked(&name.row, &name.handler, values.name); - - for (row, hex) in mappings.iter().zip(values.mappings.iter()) { - row.from.refresh(hex.from); - row.to.refresh(hex.to); - } - }); - - ProfileSection { section, refresh } -} - -// ---- Header row -------------------------------------------------------- - -fn build_header_row( - index: usize, - title: >k::Label, - sender: &ComponentSender, -) -> gtk::ListBoxRow { - let content = row_content_box(); - content.append(title); - - let duplicate = gtk::Button::builder() - .label("Duplicate") - .valign(gtk::Align::Center) - .build(); - connect_button(&duplicate, Message::RenderProfileDuplicate(index), sender); - content.append(&duplicate); - - let remove = gtk::Button::builder() - .label("Delete") - .valign(gtk::Align::Center) - .css_classes(["destructive-action"]) - .build(); - connect_button(&remove, Message::RenderProfileRemove(index), sender); - content.append(&remove); - - plain_row(&content) -} - -// ---- Mapping row ------------------------------------------------------- - -/// One side of a mapping: the hex field the Iced view had, plus the native -/// color dialog standing in for its popup picker. -struct ColorField { - hex: gtk::Entry, - hex_handler: SignalHandlerId, - swatch: gtk::ColorDialogButton, - swatch_handler: SignalHandlerId, -} - -impl ColorField { - fn refresh(&self, hex: &str) { - set_text_blocked(&self.hex, &self.hex_handler, hex); - // The same predicate the save gate counts with, so a field styled - // clean can never be one the save refuses. - mark_hex_error(&self.hex, hex); - - let Some((rgb, _)) = parse_hex(hex) else { - // Half-typed hex: leave the swatch on the last color that parsed - // rather than flashing it to black on every keystroke. - return; - }; - let rgba = gtk::gdk::RGBA::new(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32, 1.0); - set_swatch_blocked(&self.swatch, &self.swatch_handler, &rgba); - } -} - -struct MappingRow { - row: gtk::ListBoxRow, - from: ColorField, - to: ColorField, -} - -fn build_mapping_row( - index: usize, - mapping: usize, - sender: &ComponentSender, -) -> MappingRow { - let content = row_content_box(); - - content.append(&side_label("From")); - let from = build_color_field(index, mapping, RenderProfileMappingSide::From, sender); - content.append(&from.hex); - content.append(&from.swatch); - - content.append(&side_label("\u{2192}")); - content.append(&side_label("To")); - let to = build_color_field(index, mapping, RenderProfileMappingSide::To, sender); - content.append(&to.hex); - content.append(&to.swatch); - - let remove = gtk::Button::builder() - .label("Remove") - .valign(gtk::Align::Center) - .halign(gtk::Align::End) - .hexpand(true) - .build(); - connect_button( - &remove, - Message::RenderProfileMappingRemove(index, mapping), - sender, - ); - content.append(&remove); - - MappingRow { - row: plain_row(&content), - from, - to, - } -} - -fn build_color_field( - index: usize, - mapping: usize, - side: RenderProfileMappingSide, - sender: &ComponentSender, -) -> ColorField { - let hex = gtk::Entry::builder() - .placeholder_text("#RRGGBB") - .width_chars(9) - .max_width_chars(9) - .build(); - let hex_handler = { - let sender = sender.clone(); - hex.connect_changed(move |entry| { - sender.input(Message::RenderProfileMappingColorChanged( - index, - mapping, - side, - entry.text().to_string(), - )); - }) - }; - - let swatch = - gtk::ColorDialogButton::new(Some(gtk::ColorDialog::builder().with_alpha(false).build())); - swatch.set_valign(gtk::Align::Center); - let id = match side { - RenderProfileMappingSide::From => ColorPickerId::RenderProfileMappingFrom(index, mapping), - RenderProfileMappingSide::To => ColorPickerId::RenderProfileMappingTo(index, mapping), - }; - let swatch_handler = { - let sender = sender.clone(); - swatch.connect_rgba_notify(move |button| { - sender.input(Message::ColorPickerHexChanged( - id, - dialog_hex(&button.rgba()), - )); - }) - }; - - ColorField { - hex, - hex_handler, - swatch, - swatch_handler, - } -} - -// ---- Add-mapping row --------------------------------------------------- - -fn build_add_mapping_row( - index: usize, - sender: &ComponentSender, -) -> gtk::ListBoxRow { - let content = row_content_box(); - let button = gtk::Button::builder() - .label("Add mapping") - .halign(gtk::Align::Start) - .build(); - connect_button(&button, Message::RenderProfileMappingAdd(index), sender); - content.append(&button); - plain_row(&content) -} - -// ---- Small shared plumbing -------------------------------------------- - -/// An entry row whose text the model owns, kept with the handler a refresh -/// has to block before writing it. -struct TextRow { - row: adw::EntryRow, - handler: SignalHandlerId, -} - -fn build_text_row( - title: &str, - index: usize, - field: RenderProfileTextField, - sender: &ComponentSender, -) -> TextRow { - let row = adw::EntryRow::builder().title(title).build(); - let handler = { - let sender = sender.clone(); - row.connect_changed(move |row| { - sender.input(Message::RenderProfileTextChanged( - index, - field, - row.text().to_string(), - )); - }) - }; - TextRow { row, handler } -} - -fn connect_button( - button: >k::Button, - message: Message, - sender: &ComponentSender, -) { - let sender = sender.clone(); - button.connect_clicked(move |_| sender.input(message.clone())); -} - -/// Rewrites a combo's model only when the choices themselves changed, and -/// writes both model and selection with the change handler blocked: replacing -/// a model resets the selection to the first row, which would otherwise be -/// reported as if the user had picked it. -/// -/// `shown` is what the combo currently offers, owned by the binding that -/// calls this — the entries themselves, not a rendering of them. -fn sync_combo( - row: &adw::ComboRow, - handler: &SignalHandlerId, - shown: &mut Vec, - entries: &[String], - selected: Option, -) { - let rebuild = shown.as_slice() != entries; - let target = selected.map_or(NO_SELECTION, |index| index as u32); - if !rebuild && row.selected() == target { - return; - } - - row.block_signal(handler); - if rebuild { - let refs: Vec<&str> = entries.iter().map(String::as_str).collect(); - row.set_model(Some(>k::StringList::new(&refs))); - shown.clear(); - shown.extend_from_slice(entries); - } - if row.selected() != target { - row.set_selected(target); - } - row.unblock_signal(handler); -} - -fn selected_string(row: &adw::ComboRow) -> Option { - let item = row - .selected_item() - .and_then(|item| item.downcast::().ok())?; - Some(item.string().to_string()) -} - -fn row_content_box() -> gtk::Box { - gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(6) - .margin_top(8) - .margin_bottom(8) - .margin_start(12) - .margin_end(12) - .build() -} - -fn plain_row(content: &impl IsA) -> gtk::ListBoxRow { - gtk::ListBoxRow::builder() - .child(content) - .activatable(false) - .selectable(false) - .build() -} - -fn side_label(text: &str) -> gtk::Label { - gtk::Label::builder() - .label(text) - .valign(gtk::Align::Center) - .css_classes(["dim-label", "caption"]) - .build() -} - -/// The picker's editing text, falling back to the stored mapping value the -/// way the Iced hex field did. -fn picker_hex<'a>(app: &'a ConfiguratorApp, id: ColorPickerId, value: &'a str) -> &'a str { - app.color_picker_hex.get(&id).map_or(value, String::as_str) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::SearchQuery; - - fn app_with_a_profile() -> ConfiguratorApp { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let profile = app.draft.render_profiles.new_profile(); - app.draft.render_profiles.profiles.push(profile); - app - } - - /// The law the binding rests on: one layout per profile, and a row's - /// values carry exactly the mappings that layout built rows for. - #[test] - fn a_row_carries_one_value_per_mapping_row_the_layout_built() { - let app = app_with_a_profile(); - let layouts = section_layouts(&app, &app.search_summary()); - - assert_eq!(layouts.len(), app.draft.render_profiles.profiles.len()); - for (profile, layout) in app - .draft - .render_profiles - .profiles - .iter() - .zip(layouts.iter()) - { - assert_eq!(profile.mappings.len(), layout.mappings.len()); - } - } - - /// The caret guarantee, stated as the layout law it rests on: no keystroke - /// in a profile's text may rebuild the row it lands in. - #[test] - fn typing_into_a_profile_field_leaves_the_layout_alone() { - let mut app = app_with_a_profile(); - let before = section_layouts(&app, &app.search_summary()); - - let Some(profile) = app.draft.render_profiles.profiles.first_mut() else { - return; - }; - profile.name = "Half typ".to_string(); - profile.id = "half-typ".to_string(); - if let Some(mapping) = profile.mappings.first_mut() { - mapping.from = "#00FF0".to_string(); - } - - assert_eq!(before, section_layouts(&app, &app.search_summary())); - assert_eq!(app.draft.render_profiles.profiles[0].id, "half-typ"); - } - - #[test] - fn adding_a_mapping_changes_the_layout() { - let mut app = app_with_a_profile(); - let before = section_layouts(&app, &app.search_summary()); - - let Some(profile) = app.draft.render_profiles.profiles.first_mut() else { - return; - }; - let Some(mapping) = profile.mappings.first().cloned() else { - return; - }; - profile.mappings.push(mapping); - - assert_ne!(before, section_layouts(&app, &app.search_summary())); - } - - /// Search visibility belongs to the layout too: a rebuild is what applies - /// it now that nothing refreshes a section in place. - #[test] - fn a_search_that_hides_a_profile_changes_the_layout() { - let mut app = app_with_a_profile(); - let second = app.draft.render_profiles.new_profile(); - app.draft.render_profiles.profiles.push(second); - let Some(profile) = app.draft.render_profiles.profiles.first_mut() else { - return; - }; - profile.name = "zqxwvu".to_string(); - let before = section_layouts(&app, &app.search_summary()); - - app.search_query = SearchQuery::new("zqxwvu"); - let layouts = section_layouts(&app, &app.search_summary()); - - assert_ne!(before, layouts); - let visible: Vec = layouts.iter().map(|layout| layout.visible).collect(); - assert_eq!(visible.first(), Some(&true)); - assert!(visible.iter().skip(1).all(|visible| !visible)); - } -} diff --git a/configurator/src/app/pages/render_profiles/mapping.rs b/configurator/src/app/pages/render_profiles/mapping.rs new file mode 100644 index 00000000..8ba5669e --- /dev/null +++ b/configurator/src/app/pages/render_profiles/mapping.rs @@ -0,0 +1,147 @@ +use super::rows::{connect_button, plain_row, row_content_box, side_label}; +use relm4::{ComponentSender, gtk}; + +use gtk::glib::SignalHandlerId; +use gtk::prelude::*; + +use crate::messages::Message; +use crate::models::color::parse_hex; +use crate::models::{ColorPickerId, RenderProfileMappingSide}; + +use super::super::super::state::ConfiguratorApp; +use super::super::color_rows::{dialog_hex, mark_hex_error, set_swatch_blocked}; +use super::super::set_text_blocked; + +/// One side of a mapping: the hex field the Iced view had, plus the native +/// color dialog standing in for its popup picker. +pub(super) struct ColorField { + hex: gtk::Entry, + hex_handler: SignalHandlerId, + swatch: gtk::ColorDialogButton, + swatch_handler: SignalHandlerId, +} + +impl ColorField { + pub(super) fn refresh(&self, hex: &str) { + set_text_blocked(&self.hex, &self.hex_handler, hex); + // The same predicate the save gate counts with, so a field styled + // clean can never be one the save refuses. + mark_hex_error(&self.hex, hex); + + let Some((rgb, _)) = parse_hex(hex) else { + // Half-typed hex: leave the swatch on the last color that parsed + // rather than flashing it to black on every keystroke. + return; + }; + let rgba = gtk::gdk::RGBA::new(rgb[0] as f32, rgb[1] as f32, rgb[2] as f32, 1.0); + set_swatch_blocked(&self.swatch, &self.swatch_handler, &rgba); + } +} + +pub(super) struct MappingRow { + pub(super) row: gtk::ListBoxRow, + pub(super) from: ColorField, + pub(super) to: ColorField, +} + +pub(super) fn build_mapping_row( + index: usize, + mapping: usize, + sender: &ComponentSender, +) -> MappingRow { + let content = row_content_box(); + + content.append(&side_label("From")); + let from = build_color_field(index, mapping, RenderProfileMappingSide::From, sender); + content.append(&from.hex); + content.append(&from.swatch); + + content.append(&side_label("\u{2192}")); + content.append(&side_label("To")); + let to = build_color_field(index, mapping, RenderProfileMappingSide::To, sender); + content.append(&to.hex); + content.append(&to.swatch); + + let remove = gtk::Button::builder() + .label("Remove") + .valign(gtk::Align::Center) + .halign(gtk::Align::End) + .hexpand(true) + .build(); + connect_button( + &remove, + Message::RenderProfileMappingRemove(index, mapping), + sender, + ); + content.append(&remove); + + MappingRow { + row: plain_row(&content), + from, + to, + } +} + +fn build_color_field( + index: usize, + mapping: usize, + side: RenderProfileMappingSide, + sender: &ComponentSender, +) -> ColorField { + let hex = gtk::Entry::builder() + .placeholder_text("#RRGGBB") + .width_chars(9) + .max_width_chars(9) + .build(); + let hex_handler = { + let sender = sender.clone(); + hex.connect_changed(move |entry| { + sender.input(Message::RenderProfileMappingColorChanged( + index, + mapping, + side, + entry.text().to_string(), + )); + }) + }; + + let swatch = + gtk::ColorDialogButton::new(Some(gtk::ColorDialog::builder().with_alpha(false).build())); + swatch.set_valign(gtk::Align::Center); + let id = match side { + RenderProfileMappingSide::From => ColorPickerId::RenderProfileMappingFrom(index, mapping), + RenderProfileMappingSide::To => ColorPickerId::RenderProfileMappingTo(index, mapping), + }; + let swatch_handler = { + let sender = sender.clone(); + swatch.connect_rgba_notify(move |button| { + sender.input(Message::ColorPickerHexChanged( + id, + dialog_hex(&button.rgba()), + )); + }) + }; + + ColorField { + hex, + hex_handler, + swatch, + swatch_handler, + } +} + +// ---- Add-mapping row --------------------------------------------------- + +pub(super) fn build_add_mapping_row( + index: usize, + sender: &ComponentSender, +) -> gtk::ListBoxRow { + let content = row_content_box(); + let button = gtk::Button::builder() + .label("Add mapping") + .halign(gtk::Align::Start) + .build(); + connect_button(&button, Message::RenderProfileMappingAdd(index), sender); + content.append(&button); + plain_row(&content) +} diff --git a/configurator/src/app/pages/render_profiles/rows.rs b/configurator/src/app/pages/render_profiles/rows.rs new file mode 100644 index 00000000..bf758748 --- /dev/null +++ b/configurator/src/app/pages/render_profiles/rows.rs @@ -0,0 +1,149 @@ +use relm4::{ComponentSender, adw, gtk}; + +use adw::prelude::*; +use gtk::glib::SignalHandlerId; + +use crate::messages::Message; +use crate::models::{ColorPickerId, RenderProfileTextField}; + +use super::super::super::state::ConfiguratorApp; +use super::NO_SELECTION; + +pub(super) fn build_header_row( + index: usize, + title: >k::Label, + sender: &ComponentSender, +) -> gtk::ListBoxRow { + let content = row_content_box(); + content.append(title); + + let duplicate = gtk::Button::builder() + .label("Duplicate") + .valign(gtk::Align::Center) + .build(); + connect_button(&duplicate, Message::RenderProfileDuplicate(index), sender); + content.append(&duplicate); + + let remove = gtk::Button::builder() + .label("Delete") + .valign(gtk::Align::Center) + .css_classes(["destructive-action"]) + .build(); + connect_button(&remove, Message::RenderProfileRemove(index), sender); + content.append(&remove); + + plain_row(&content) +} + +/// An entry row whose text the model owns, kept with the handler a refresh +/// has to block before writing it. +pub(super) struct TextRow { + pub(super) row: adw::EntryRow, + pub(super) handler: SignalHandlerId, +} + +pub(super) fn build_text_row( + title: &str, + index: usize, + field: RenderProfileTextField, + sender: &ComponentSender, +) -> TextRow { + let row = adw::EntryRow::builder().title(title).build(); + let handler = { + let sender = sender.clone(); + row.connect_changed(move |row| { + sender.input(Message::RenderProfileTextChanged( + index, + field, + row.text().to_string(), + )); + }) + }; + TextRow { row, handler } +} + +pub(super) fn connect_button( + button: >k::Button, + message: Message, + sender: &ComponentSender, +) { + let sender = sender.clone(); + button.connect_clicked(move |_| sender.input(message.clone())); +} + +/// Rewrites a combo's model only when the choices themselves changed, and +/// writes both model and selection with the change handler blocked: replacing +/// a model resets the selection to the first row, which would otherwise be +/// reported as if the user had picked it. +/// +/// `shown` is what the combo currently offers, owned by the binding that +/// calls this — the entries themselves, not a rendering of them. +pub(super) fn sync_combo( + row: &adw::ComboRow, + handler: &SignalHandlerId, + shown: &mut Vec, + entries: &[String], + selected: Option, +) { + let rebuild = shown.as_slice() != entries; + let target = selected.map_or(NO_SELECTION, |index| index as u32); + if !rebuild && row.selected() == target { + return; + } + + row.block_signal(handler); + if rebuild { + let refs: Vec<&str> = entries.iter().map(String::as_str).collect(); + row.set_model(Some(>k::StringList::new(&refs))); + shown.clear(); + shown.extend_from_slice(entries); + } + if row.selected() != target { + row.set_selected(target); + } + row.unblock_signal(handler); +} + +pub(super) fn selected_string(row: &adw::ComboRow) -> Option { + let item = row + .selected_item() + .and_then(|item| item.downcast::().ok())?; + Some(item.string().to_string()) +} + +pub(super) fn row_content_box() -> gtk::Box { + gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .margin_top(8) + .margin_bottom(8) + .margin_start(12) + .margin_end(12) + .build() +} + +pub(super) fn plain_row(content: &impl IsA) -> gtk::ListBoxRow { + gtk::ListBoxRow::builder() + .child(content) + .activatable(false) + .selectable(false) + .build() +} + +pub(super) fn side_label(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .valign(gtk::Align::Center) + .css_classes(["dim-label", "caption"]) + .build() +} + +/// The picker's editing text, falling back to the stored mapping value the +/// way the Iced hex field did. +pub(super) fn picker_hex<'a>( + app: &'a ConfiguratorApp, + id: ColorPickerId, + value: &'a str, +) -> &'a str { + app.color_picker_hex.get(&id).map_or(value, String::as_str) +} diff --git a/configurator/src/app/pages/render_profiles/section.rs b/configurator/src/app/pages/render_profiles/section.rs new file mode 100644 index 00000000..7aaecd54 --- /dev/null +++ b/configurator/src/app/pages/render_profiles/section.rs @@ -0,0 +1,110 @@ +use super::mapping::{MappingRow, build_add_mapping_row, build_mapping_row}; +use super::rows::{build_header_row, build_text_row}; +use relm4::{ComponentSender, gtk}; + +use gtk::prelude::*; + +use crate::models::RenderProfileTextField; + +use super::super::super::state::ConfiguratorApp; +use super::super::set_text_blocked; +use super::{ProfileValues, SectionLayout}; + +/// One section's refresh: built beside its row, so it owns that row's typed +/// widget handles and the signal handler ids guarding each write. +type ProfileRowRefresh = Box)>; + +pub(super) struct BoundProfileSection { + pub(super) layout: SectionLayout, + pub(super) refresh: ProfileRowRefresh, +} + +/// One profile's section: the list box, and the closure that writes the +/// values the layout deliberately left out. +struct ProfileSection { + section: gtk::ListBox, + refresh: ProfileRowRefresh, +} + +pub(super) fn rebuild_sections( + container: >k::Box, + layouts: &[SectionLayout], + sender: &ComponentSender, +) -> Vec { + // Draining the container, not walking it for a control: the sections that + // replace these carry their own refresh closures. + while let Some(child) = container.first_child() { + container.remove(&child); + } + + let mut sections = Vec::with_capacity(layouts.len()); + for (index, layout) in layouts.iter().enumerate() { + let built = build_section(index, layout, sender); + container.append(&built.section); + sections.push(BoundProfileSection { + layout: layout.clone(), + refresh: built.refresh, + }); + } + sections +} + +fn build_section( + index: usize, + layout: &SectionLayout, + sender: &ComponentSender, +) -> ProfileSection { + let section = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .visible(layout.visible) + .build(); + + let title = gtk::Label::builder() + .xalign(0.0) + .hexpand(true) + .css_classes(["heading"]) + .build(); + section.append(&build_header_row(index, &title, sender)); + + let id = build_text_row("Profile id", index, RenderProfileTextField::Id, sender); + id.row.set_visible(layout.controls); + section.append(&id.row); + + let name = build_text_row("Display name", index, RenderProfileTextField::Name, sender); + name.row.set_visible(layout.controls); + section.append(&name.row); + + let mut mappings: Vec = Vec::with_capacity(layout.mappings.len()); + for (mapping, visible) in layout.mappings.iter().enumerate() { + let row = build_mapping_row(index, mapping, sender); + row.row.set_visible(*visible); + section.append(&row.row); + mappings.push(row); + } + + let add = build_add_mapping_row(index, sender); + add.set_visible(layout.controls); + section.append(&add); + + let refresh: ProfileRowRefresh = Box::new(move |values| { + let heading = if values.name.trim().is_empty() { + "Profile" + } else { + values.name.trim() + }; + if title.text() != heading { + title.set_text(heading); + } + + set_text_blocked(&id.row, &id.handler, values.id); + set_text_blocked(&name.row, &name.handler, values.name); + + for (row, hex) in mappings.iter().zip(values.mappings.iter()) { + row.from.refresh(hex.from); + row.to.refresh(hex.to); + } + }); + + ProfileSection { section, refresh } +} diff --git a/configurator/src/app/pages/render_profiles/tests.rs b/configurator/src/app/pages/render_profiles/tests.rs new file mode 100644 index 00000000..ca090162 --- /dev/null +++ b/configurator/src/app/pages/render_profiles/tests.rs @@ -0,0 +1,86 @@ +use super::*; +use crate::models::SearchQuery; + +fn app_with_a_profile() -> ConfiguratorApp { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let profile = app.draft.render_profiles.new_profile(); + app.draft.render_profiles.profiles.push(profile); + app +} + +/// The law the binding rests on: one layout per profile, and a row's +/// values carry exactly the mappings that layout built rows for. +#[test] +fn a_row_carries_one_value_per_mapping_row_the_layout_built() { + let app = app_with_a_profile(); + let layouts = section_layouts(&app, &app.search_summary()); + + assert_eq!(layouts.len(), app.draft.render_profiles.profiles.len()); + for (profile, layout) in app + .draft + .render_profiles + .profiles + .iter() + .zip(layouts.iter()) + { + assert_eq!(profile.mappings.len(), layout.mappings.len()); + } +} + +/// The caret guarantee, stated as the layout law it rests on: no keystroke +/// in a profile's text may rebuild the row it lands in. +#[test] +fn typing_into_a_profile_field_leaves_the_layout_alone() { + let mut app = app_with_a_profile(); + let before = section_layouts(&app, &app.search_summary()); + + let Some(profile) = app.draft.render_profiles.profiles.first_mut() else { + return; + }; + profile.name = "Half typ".to_string(); + profile.id = "half-typ".to_string(); + if let Some(mapping) = profile.mappings.first_mut() { + mapping.from = "#00FF0".to_string(); + } + + assert_eq!(before, section_layouts(&app, &app.search_summary())); + assert_eq!(app.draft.render_profiles.profiles[0].id, "half-typ"); +} + +#[test] +fn adding_a_mapping_changes_the_layout() { + let mut app = app_with_a_profile(); + let before = section_layouts(&app, &app.search_summary()); + + let Some(profile) = app.draft.render_profiles.profiles.first_mut() else { + return; + }; + let Some(mapping) = profile.mappings.first().cloned() else { + return; + }; + profile.mappings.push(mapping); + + assert_ne!(before, section_layouts(&app, &app.search_summary())); +} + +/// Search visibility belongs to the layout too: a rebuild is what applies +/// it now that nothing refreshes a section in place. +#[test] +fn a_search_that_hides_a_profile_changes_the_layout() { + let mut app = app_with_a_profile(); + let second = app.draft.render_profiles.new_profile(); + app.draft.render_profiles.profiles.push(second); + let Some(profile) = app.draft.render_profiles.profiles.first_mut() else { + return; + }; + profile.name = "zqxwvu".to_string(); + let before = section_layouts(&app, &app.search_summary()); + + app.search_query = SearchQuery::new("zqxwvu"); + let layouts = section_layouts(&app, &app.search_summary()); + + assert_ne!(before, layouts); + let visible: Vec = layouts.iter().map(|layout| layout.visible).collect(); + assert_eq!(visible.first(), Some(&true)); + assert!(visible.iter().skip(1).all(|visible| !visible)); +} diff --git a/configurator/src/app/pages/session.rs b/configurator/src/app/pages/session.rs index 0690e9a5..49830deb 100644 --- a/configurator/src/app/pages/session.rs +++ b/configurator/src/app/pages/session.rs @@ -1,897 +1,20 @@ -//! Session page: persistence settings and the saved-session catalog. -//! -//! The settings half is ordinary `PageBuilder` rows. The catalog half is a -//! list whose length the model owns, so each built card owns the typed layout -//! that produced it and its refresh closure. A card and its refresh therefore -//! cannot drift apart, and nothing has to rediscover a control by name or -//! position. Everything that changes without changing what a rebuilt -//! card would render — entry contents, button sensitivity, the two-step -//! clear, search filtering — is written in place by those closures, with the -//! entry handlers blocked so a refresh is never mistaken for typing. +//! Session persistence settings and the saved-session catalog. -use relm4::prelude::*; -use relm4::{adw, gtk}; +mod catalog; +mod settings; -use adw::prelude::*; -use gtk::glib::SignalHandlerId; +use relm4::prelude::*; -use crate::messages::Message; -use crate::models::{ - SessionCatalogItem, SessionCatalogOperation, SessionCompressionOption, - SessionStorageModeOption, TabId, TextField, ToggleField, -}; +use crate::models::TabId; -use super::super::search::{AppSearchSummary, SearchArea}; use super::super::state::ConfiguratorApp; -use super::{BuiltPage, PageBuilder, set_text_blocked}; +use super::{BuiltPage, PageBuilder}; +/// The Session page keeps one small interface while its two independent +/// areas own their construction and refresh details. pub(super) fn build(sender: &ComponentSender) -> BuiltPage { let mut page = PageBuilder::new(sender, TabId::Session); - - page.group_in_area("Session Persistence", SearchArea::SessionPersistence) - .switch_row( - "Persist transparent mode drawings", - "", - |app| app.draft.session_persist_transparent, - |value| Message::ToggleChanged(ToggleField::SessionPersistTransparent, value), - ) - .switch_row( - "Persist whiteboard mode drawings", - "", - |app| app.draft.session_persist_whiteboard, - |value| Message::ToggleChanged(ToggleField::SessionPersistWhiteboard, value), - ) - .switch_row( - "Persist blackboard mode drawings", - "", - |app| app.draft.session_persist_blackboard, - |value| Message::ToggleChanged(ToggleField::SessionPersistBlackboard, value), - ) - .switch_row( - "Persist undo/redo history", - "", - |app| app.draft.session_persist_history, - |value| Message::ToggleChanged(ToggleField::SessionPersistHistory, value), - ) - .switch_row( - "Restore tool state on startup", - "", - |app| app.draft.session_restore_tool_state, - |value| Message::ToggleChanged(ToggleField::SessionRestoreToolState, value), - ) - .switch_row( - "Per-output persistence", - "", - |app| app.draft.session_per_output, - |value| Message::ToggleChanged(ToggleField::SessionPerOutput, value), - ); - - page.group_in_area("Storage", SearchArea::SessionPersistence) - .combo_row( - "Storage mode", - "", - SessionStorageModeOption::list(), - option_labels(SessionStorageModeOption::list(), |option| option.label()), - |app| app.draft.session_storage_mode, - Message::SessionStorageModeChanged, - ); - custom_directory_row(&mut page); - page.combo_row( - "Compression", - "", - SessionCompressionOption::list(), - option_labels(SessionCompressionOption::list(), |option| option.label()), - |app| app.draft.session_compression, - Message::SessionCompressionChanged, - ) - .entry_row_validated( - "Max shapes per frame", - |app| app.draft.session_max_shapes_per_frame.clone(), - |value| Message::TextChanged(TextField::SessionMaxShapesPerFrame, value), - |app| validate_whole_number(&app.draft.session_max_shapes_per_frame, 1, u64::MAX), - ) - .entry_row( - "Max persisted undo depth (blank = runtime limit)", - |app| app.draft.session_max_persisted_undo_depth.clone(), - |value| Message::TextChanged(TextField::SessionMaxPersistedUndoDepth, value), - ) - .entry_row_validated( - "Max file size (MB)", - |app| app.draft.session_max_file_size_mb.clone(), - |value| Message::TextChanged(TextField::SessionMaxFileSizeMb, value), - |app| validate_whole_number(&app.draft.session_max_file_size_mb, 1, 1024), - ) - .entry_row_validated( - "Auto-compress threshold (KB)", - |app| app.draft.session_auto_compress_threshold_kb.clone(), - |value| Message::TextChanged(TextField::SessionAutoCompressThresholdKb, value), - |app| validate_whole_number(&app.draft.session_auto_compress_threshold_kb, 1, u64::MAX), - ) - .entry_row( - "Backup retention count", - |app| app.draft.session_backup_retention.clone(), - |value| Message::TextChanged(TextField::SessionBackupRetention, value), - ); - - page.group_in_area("Autosave", SearchArea::SessionPersistence) - .switch_row( - "Enable autosave", - "", - |app| app.draft.session_autosave_enabled, - |value| Message::ToggleChanged(ToggleField::SessionAutosaveEnabled, value), - ) - .entry_row_validated( - "Autosave idle (ms)", - |app| app.draft.session_autosave_idle_ms.clone(), - |value| Message::TextChanged(TextField::SessionAutosaveIdleMs, value), - |app| validate_whole_number(&app.draft.session_autosave_idle_ms, 1000, u64::MAX), - ) - .entry_row_validated( - "Autosave interval (ms)", - |app| app.draft.session_autosave_interval_ms.clone(), - |value| Message::TextChanged(TextField::SessionAutosaveIntervalMs, value), - |app| validate_whole_number(&app.draft.session_autosave_interval_ms, 1000, u64::MAX), - ) - .entry_row_validated( - "Autosave failure backoff (ms)", - |app| app.draft.session_autosave_failure_backoff_ms.clone(), - |value| Message::TextChanged(TextField::SessionAutosaveFailureBackoffMs, value), - |app| { - validate_whole_number( - &app.draft.session_autosave_failure_backoff_ms, - 1000, - u64::MAX, - ) - }, - ); - - page.group_in_area("Saved Sessions", SearchArea::SessionCatalog); - catalog_section(&mut page); - + settings::add(&mut page); + catalog::add(&mut page); page.finish() } - -fn option_labels(options: Vec, label: impl Fn(&O) -> &'static str) -> Vec { - options - .iter() - .map(|option| label(option).to_string()) - .collect() -} - -/// The custom directory only applies to one storage mode, so it is a row the -/// mode shows rather than a row that is always there and usually inert. -fn custom_directory_row(page: &mut PageBuilder) { - let row = adw::EntryRow::builder().title("Custom directory").build(); - let handler = { - let sender = page.sender(); - row.connect_changed(move |row| { - sender.input(Message::TextChanged( - TextField::SessionCustomDirectory, - row.text().to_string(), - )); - }) - }; - page.custom(&row); - page.bind(move |app, _summary| { - set_visible( - &row, - app.draft.session_storage_mode == SessionStorageModeOption::Custom, - ); - // Blocked: the model owns this text, and a load reporting its own - // value back as a user edit clears the diagnostics that load produced. - set_text_blocked(&row, &handler, &app.draft.session_custom_directory); - }); -} - -// ---- Catalog ----------------------------------------------------------- - -fn catalog_section(page: &mut PageBuilder) { - let sender = page.sender(); - - let body = column_box(); - let refresh = message_button("Refresh", &sender, Message::SessionCatalogRefreshRequested); - let toolbar = row_box(); - toolbar.append(&refresh); - body.append(&toolbar); - body.append(&hint_label( - "Clear Tool State applies config defaults without deleting boards.", - )); - body.append(&hint_label("Clear Saved Data removes saved session files.")); - - let blocker_label = warning_label(""); - body.append(&blocker_label); - let loading_label = body_label("Loading sessions..."); - body.append(&loading_label); - let empty_label = body_label("No named sessions in the catalog yet."); - body.append(&empty_label); - - let list = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(12) - .build(); - body.append(&list); - - page.custom(&body); - let mut cards: Vec = Vec::new(); - page.bind(move |app, summary| { - let catalog = &app.session_catalog; - let gates = CatalogGates::of(app); - set_sensitive(&refresh, !gates.busy); - - match SessionCatalogOperation::Clear.cached_status_blocker(app.daemon_status.as_ref()) { - Some(blocker) => { - set_label(&blocker_label, blocker); - set_visible(&blocker_label, true); - } - None => set_visible(&blocker_label, false), - } - set_visible(&loading_label, catalog.is_loading); - set_visible( - &empty_label, - !catalog.is_loading && catalog.items.is_empty(), - ); - - let layout = catalog_layout(app); - if !cards - .iter() - .map(|card| &card.layout) - .eq(layout.items.iter()) - { - cards = rebuild_items(&list, &layout, &sender); - } - for (item, card) in catalog.items.iter().zip(cards.iter()) { - let values = catalog_row_values(app, summary, &gates, item); - (card.refresh)(&values); - } - }); -} - -/// Everything a rebuilt card would render, and nothing else. -/// -/// Entry contents, button states, and the two-step clear are deliberately -/// absent: those are written in place, so typing never destroys the entry -/// being typed into. -#[derive(Debug, Default, Clone, PartialEq, Eq)] -struct CatalogLayout { - /// Empty while loading: the list renders nothing then, so there is - /// nothing to build and nothing to refresh. - items: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct CatalogItemLayout { - id: String, - display_name: String, - path_label: String, - canonical_path_label: Option, - created_label: String, - last_opened_label: String, - last_saved_label: String, - artifacts: String, -} - -fn catalog_layout(app: &ConfiguratorApp) -> CatalogLayout { - if app.session_catalog.is_loading { - return CatalogLayout::default(); - } - - CatalogLayout { - items: app - .session_catalog - .items - .iter() - .map(|item| CatalogItemLayout { - id: item.id.clone(), - display_name: item.display_name.clone(), - path_label: item.path_label.clone(), - canonical_path_label: item.canonical_path_label.clone(), - created_label: item.created_label.clone(), - last_opened_label: item.last_opened_label.clone(), - last_saved_label: item.last_saved_label.clone(), - artifacts: item.artifacts.status_label(), - }) - .collect(), - } -} - -/// What a card's controls are allowed to do, resolved once per refresh: the -/// blockers are the same answer for every row and cost a lookup each. -struct CatalogGates { - busy: bool, - duplicate_blocked: bool, - move_blocked: bool, - tool_state_blocked: bool, - clear_blocked: bool, -} - -impl CatalogGates { - fn of(app: &ConfiguratorApp) -> Self { - let status = app.daemon_status.as_ref(); - Self { - busy: app.session_catalog.busy || app.session_catalog.is_loading, - duplicate_blocked: SessionCatalogOperation::Duplicate - .cached_status_blocker(status) - .is_some(), - move_blocked: SessionCatalogOperation::Move - .cached_status_blocker(status) - .is_some(), - tool_state_blocked: SessionCatalogOperation::ClearToolState - .cached_status_blocker(status) - .is_some(), - clear_blocked: SessionCatalogOperation::Clear - .cached_status_blocker(status) - .is_some(), - } - } -} - -/// One card's values: the model-owned entry text and every action's state. -struct CatalogRowValues { - visible: bool, - rename: String, - rename_enabled: bool, - duplicate: String, - duplicate_enabled: bool, - move_target: String, - move_enabled: bool, - /// Reveal and Forget, which only wait on the catalog being idle. - actions_enabled: bool, - tool_state_enabled: bool, - clear_enabled: bool, - clear_armed: bool, -} - -fn catalog_row_values( - app: &ConfiguratorApp, - summary: &AppSearchSummary, - gates: &CatalogGates, - item: &SessionCatalogItem, -) -> CatalogRowValues { - let catalog = &app.session_catalog; - let id = item.id.as_str(); - let rename = catalog.rename_value(id, &item.display_name); - let duplicate = catalog.duplicate_value(id, &item.path); - let move_target = catalog.move_value(id, &item.path); - CatalogRowValues { - visible: item_visible(summary, id), - rename_enabled: !gates.busy - && rename.trim() != item.display_name.trim() - && !rename.trim().is_empty(), - duplicate_enabled: !gates.busy && !gates.duplicate_blocked && !duplicate.trim().is_empty(), - move_enabled: !gates.busy && !gates.move_blocked && !move_target.trim().is_empty(), - actions_enabled: !gates.busy, - tool_state_enabled: !gates.busy && !gates.tool_state_blocked, - clear_enabled: !gates.busy && !gates.clear_blocked, - clear_armed: clear_armed(catalog.pending_clear_id.as_deref(), id), - rename, - duplicate, - move_target, - } -} - -/// One card's refresh: built beside its card, so it owns that card's typed -/// widget handles and the signal handler ids guarding each write. -type CatalogRowRefresh = Box; - -struct BoundCatalogCard { - layout: CatalogItemLayout, - refresh: CatalogRowRefresh, -} - -fn rebuild_items( - list: >k::Box, - layout: &CatalogLayout, - sender: &ComponentSender, -) -> Vec { - // Draining the list, not walking it for a control: the cards that replace - // these carry their own refresh closures. - while let Some(child) = list.first_child() { - list.remove(&child); - } - - let mut cards = Vec::with_capacity(layout.items.len()); - for item in &layout.items { - let built = item_card(item, sender); - list.append(&built.card); - cards.push(BoundCatalogCard { - layout: item.clone(), - refresh: built.refresh, - }); - } - cards -} - -/// One catalog card: the widget, and the closure that writes the values the -/// layout deliberately left out. -struct CatalogRow { - card: gtk::Box, - refresh: CatalogRowRefresh, -} - -fn item_card(item: &CatalogItemLayout, sender: &ComponentSender) -> CatalogRow { - let card = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .css_classes(["card"]) - .build(); - - let content = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .margin_top(12) - .margin_bottom(12) - .margin_start(12) - .margin_end(12) - .build(); - card.append(&content); - - let header = row_box(); - let name = body_label(&item.display_name); - name.add_css_class("heading"); - header.append(&name); - header.append(&hint_label(&item.artifacts)); - content.append(&header); - - content.append(&caption_label(&item.path_label)); - if let Some(canonical) = item.canonical_path_label.as_deref() { - content.append(&caption_label(&format!("Canonical: {canonical}"))); - } - let times = row_box(); - times.append(&hint_label(&format!("Created: {}", item.created_label))); - times.append(&hint_label(&format!("Opened: {}", item.last_opened_label))); - times.append(&hint_label(&format!("Saved: {}", item.last_saved_label))); - content.append(×); - - let rename = input_row( - InputRow { - id: &item.id, - placeholder: "Display name", - button_label: "Save Name", - on_input: Message::SessionCatalogRenameInputChanged, - request: Message::SessionCatalogRenameRequested(item.id.clone()), - }, - sender, - ); - content.append(&rename.container); - let duplicate = input_row( - InputRow { - id: &item.id, - placeholder: "Duplicate target path", - button_label: "Duplicate", - on_input: Message::SessionCatalogDuplicateInputChanged, - request: Message::SessionCatalogDuplicateRequested(item.id.clone()), - }, - sender, - ); - content.append(&duplicate.container); - let move_row = input_row( - InputRow { - id: &item.id, - placeholder: "Move target path", - button_label: "Move", - on_input: Message::SessionCatalogMoveInputChanged, - request: Message::SessionCatalogMoveRequested(item.id.clone()), - }, - sender, - ); - content.append(&move_row.container); - - let actions = row_box(); - let reveal = message_button( - "Reveal File", - sender, - Message::SessionCatalogRevealRequested(item.id.clone()), - ); - actions.append(&reveal); - let tool_state = message_button( - "Clear Tool State", - sender, - Message::SessionCatalogClearToolStateRequested(item.id.clone()), - ); - actions.append(&tool_state); - content.append(&actions); - - let danger = row_box(); - let clear = message_button( - "Clear Saved Data", - sender, - Message::SessionCatalogClearRequested(item.id.clone()), - ); - clear.add_css_class("destructive-action"); - danger.append(&clear); - - // Both halves of the two-step clear exist from the start; arming the - // pending id swaps which one is visible. - let confirm = row_box(); - let confirm_button = message_button( - "Confirm Clear", - sender, - Message::SessionCatalogClearConfirmed(item.id.clone()), - ); - confirm_button.add_css_class("destructive-action"); - confirm.append(&confirm_button); - let cancel_button = message_button( - "Cancel", - sender, - Message::SessionCatalogClearCanceled(item.id.clone()), - ); - cancel_button.add_css_class("flat"); - confirm.append(&cancel_button); - danger.append(&confirm); - - let forget = message_button( - "Forget", - sender, - Message::SessionCatalogForgetRequested(item.id.clone()), - ); - forget.add_css_class("flat"); - danger.append(&forget); - content.append(&danger); - - let handle = card.clone(); - let refresh: CatalogRowRefresh = Box::new(move |values| { - set_visible(&handle, values.visible); - - // Blocked: these entries carry text the model owns, and a refresh - // reporting it back as typing would pin an input the user never made. - set_text_blocked(&rename.entry, &rename.handler, &values.rename); - set_sensitive(&rename.button, values.rename_enabled); - set_text_blocked(&duplicate.entry, &duplicate.handler, &values.duplicate); - set_sensitive(&duplicate.button, values.duplicate_enabled); - set_text_blocked(&move_row.entry, &move_row.handler, &values.move_target); - set_sensitive(&move_row.button, values.move_enabled); - - set_sensitive(&reveal, values.actions_enabled); - set_sensitive(&forget, values.actions_enabled); - set_sensitive(&tool_state, values.tool_state_enabled); - - let clear_arming = values.clear_armed && !confirm.get_visible(); - set_visible(&clear, !values.clear_armed); - set_sensitive(&clear, values.clear_enabled); - set_visible(&confirm, values.clear_armed); - if clear_arming { - // The destructive action just stepped aside. Move keyboard focus - // to the revealed answer rather than leaving it hidden. - confirm_button.grab_focus(); - } - }); - - CatalogRow { card, refresh } -} - -struct InputRow<'a> { - id: &'a str, - placeholder: &'a str, - button_label: &'a str, - on_input: fn(String, String) -> Message, - request: Message, -} - -/// An entry the model owns beside the button that acts on it, kept with the -/// handler a refresh has to block before writing the entry. -struct InputRowWidgets { - container: gtk::Box, - entry: gtk::Entry, - handler: SignalHandlerId, - button: gtk::Button, -} - -fn input_row(row: InputRow<'_>, sender: &ComponentSender) -> InputRowWidgets { - let container = row_box(); - let entry = gtk::Entry::builder() - .hexpand(true) - .placeholder_text(row.placeholder) - .build(); - let handler = { - let sender = sender.clone(); - let id = row.id.to_string(); - let on_input = row.on_input; - entry.connect_changed(move |entry| { - sender.input(on_input(id.clone(), entry.text().to_string())); - }) - }; - container.append(&entry); - - let button = message_button(row.button_label, sender, row.request); - container.append(&button); - InputRowWidgets { - container, - entry, - handler, - button, - } -} - -fn clear_armed(pending: Option<&str>, id: &str) -> bool { - pending == Some(id) -} - -fn item_visible(summary: &AppSearchSummary, id: &str) -> bool { - !summary.is_active() - || summary - .tab(TabId::Session) - .is_none_or(|tab| tab.session_item_visible(id)) -} - -/// Error text for a whole-number field, `None` while the input is -/// acceptable. `u64::MAX` as the upper bound means "no maximum" and reports -/// only the minimum, matching the Iced view's two validators. -fn validate_whole_number(value: &str, min: u64, max: u64) -> Option { - let Ok(parsed) = value.trim().parse::() else { - return Some("Expected a whole number".to_string()); - }; - if (min..=max).contains(&parsed) { - return None; - } - Some(if max == u64::MAX { - format!("Minimum: {min}") - } else { - format!("Range: {min}-{max}") - }) -} - -// ---- Widget helpers ---------------------------------------------------- - -fn column_box() -> gtk::Box { - gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .build() -} - -fn row_box() -> gtk::Box { - gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(8) - .build() -} - -fn body_label(text: &str) -> gtk::Label { - gtk::Label::builder() - .label(text) - .xalign(0.0) - .wrap(true) - .halign(gtk::Align::Start) - .valign(gtk::Align::Center) - .build() -} - -fn caption_label(text: &str) -> gtk::Label { - let label = body_label(text); - label.add_css_class("caption"); - label -} - -fn hint_label(text: &str) -> gtk::Label { - let label = caption_label(text); - label.add_css_class("dim-label"); - label -} - -fn warning_label(text: &str) -> gtk::Label { - let label = caption_label(text); - label.add_css_class("warning"); - label -} - -fn message_button( - label: &str, - sender: &ComponentSender, - message: Message, -) -> gtk::Button { - let button = gtk::Button::builder() - .label(label) - .valign(gtk::Align::Center) - .build(); - let sender = sender.clone(); - button.connect_clicked(move |_| sender.input(message.clone())); - button -} - -fn set_label(label: >k::Label, text: &str) { - if label.label() != text { - label.set_label(text); - } -} - -/// Writes the widget's own visibility flag, never `is_visible`: a row inside -/// a hidden group reports invisible while its own flag still says otherwise, -/// and skipping the write there would leak the stale state the moment the -/// group comes back. -fn set_visible(widget: &impl IsA, visible: bool) { - if widget.get_visible() != visible { - widget.set_visible(visible); - } -} - -fn set_sensitive(widget: &impl IsA, sensitive: bool) { - if widget.is_sensitive() != sensitive { - widget.set_sensitive(sensitive); - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::*; - use crate::models::SessionCatalogItem; - use crate::models::session::SessionArtifactSummary; - - fn test_item(id: &str, display_name: &str) -> SessionCatalogItem { - SessionCatalogItem { - id: id.to_string(), - display_name: display_name.to_string(), - path: PathBuf::from(format!("/tmp/{id}.wayscriber-session")), - path_label: format!("/tmp/{id}.wayscriber-session"), - canonical_path_label: None, - created_label: "2026-01-01 10:00".to_string(), - last_opened_label: "Never".to_string(), - last_saved_label: "Never".to_string(), - artifacts: SessionArtifactSummary { - primary_exists: true, - backup_exists: false, - recovery_exists: false, - clear_marker_exists: false, - lock_exists: false, - non_lock_size_bytes: 2048, - }, - } - } - - fn app_with_items(items: Vec) -> ConfiguratorApp { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.session_catalog.replace_items(items); - app - } - - /// The caret guarantee: typing in a card's entry, arming the clear, or a - /// running action must not rebuild the card being typed into. - #[test] - fn layout_ignores_input_edits_and_pending_clear() { - let mut app = app_with_items(vec![test_item("one", "First")]); - let baseline = catalog_layout(&app); - - app.session_catalog - .rename_inputs - .insert("one".to_string(), "First draft".to_string()); - app.session_catalog.pending_clear_id = Some("one".to_string()); - app.session_catalog.busy = true; - - assert_eq!(catalog_layout(&app), baseline); - // The edit rides the card's values instead, where a blocked write can - // put it in the entry without a rebuild. - let summary = app.search_summary(); - let values = catalog_row_values( - &app, - &summary, - &CatalogGates::of(&app), - &app.session_catalog.items[0], - ); - assert_eq!(values.rename, "First draft"); - } - - #[test] - fn layout_tracks_rendered_item_text() { - let renamed = catalog_layout(&app_with_items(vec![test_item("one", "Renamed")])); - let original = catalog_layout(&app_with_items(vec![test_item("one", "First")])); - assert_ne!(renamed, original); - - let two_items = catalog_layout(&app_with_items(vec![ - test_item("one", "First"), - test_item("two", "Second"), - ])); - assert_ne!(two_items, original); - } - - #[test] - fn loading_and_an_empty_catalog_both_build_no_cards() { - let empty = app_with_items(Vec::new()); - let loading = ConfiguratorApp::new_app().0; - - assert!(loading.session_catalog.is_loading); - // Nothing is rendered while loading, so nothing is built for it and - // there is no card left over to refresh. - assert!(catalog_layout(&empty).items.is_empty()); - assert!(catalog_layout(&loading).items.is_empty()); - } - - /// A card's buttons follow the model, and the rename button waits for an - /// edit that is actually a change. - #[test] - fn a_busy_catalog_leaves_every_action_unpressable() { - let mut app = app_with_items(vec![test_item("one", "First")]); - let summary = app.search_summary(); - let idle = catalog_row_values( - &app, - &summary, - &CatalogGates::of(&app), - &app.session_catalog.items[0], - ); - assert!(idle.actions_enabled); - assert!(!idle.rename_enabled, "an unchanged name is not a rename"); - - app.session_catalog - .rename_inputs - .insert("one".to_string(), "Second".to_string()); - let changed = catalog_row_values( - &app, - &summary, - &CatalogGates::of(&app), - &app.session_catalog.items[0], - ); - assert!(changed.rename_enabled); - - app.session_catalog.busy = true; - let busy = catalog_row_values( - &app, - &summary, - &CatalogGates::of(&app), - &app.session_catalog.items[0], - ); - assert!(!busy.actions_enabled); - assert!(!busy.rename_enabled); - assert!(!busy.duplicate_enabled); - assert!(!busy.move_enabled); - } - - #[test] - fn clear_confirmation_is_armed_for_one_row_only() { - assert!(clear_armed(Some("one"), "one")); - assert!(!clear_armed(Some("one"), "two")); - assert!(!clear_armed(None, "one")); - } - - /// Confirming consumes the pending id as it sets the catalog busy, so the - /// card leaves its armed state in the same refresh that starts the work: - /// the Confirm/Cancel pair goes away and the button that asks comes back - /// unpressable, rather than offering a confirm the model would refuse. - #[test] - fn a_confirmed_clear_collapses_the_armed_row_into_the_busy_one() { - let mut app = app_with_items(vec![test_item("one", "First")]); - app.session_catalog.pending_clear_id = Some("one".to_string()); - let summary = app.search_summary(); - let armed = catalog_row_values( - &app, - &summary, - &CatalogGates::of(&app), - &app.session_catalog.items[0], - ); - assert!(armed.clear_armed); - - // What `handle_session_catalog_clear_confirmed` leaves behind: the - // answered question consumed, the clear running. - app.session_catalog.pending_clear_id = None; - app.session_catalog.busy = true; - let running = catalog_row_values( - &app, - &summary, - &CatalogGates::of(&app), - &app.session_catalog.items[0], - ); - - assert!(!running.clear_armed); - assert!(!running.clear_enabled); - } - - #[test] - fn whole_number_validation_matches_the_old_hints() { - assert_eq!(validate_whole_number("1000", 1000, u64::MAX), None); - assert_eq!( - validate_whole_number("999", 1000, u64::MAX).as_deref(), - Some("Minimum: 1000") - ); - assert_eq!( - validate_whole_number("", 1, u64::MAX).as_deref(), - Some("Expected a whole number") - ); - assert_eq!( - validate_whole_number("-4", 1, u64::MAX).as_deref(), - Some("Expected a whole number") - ); - assert_eq!(validate_whole_number(" 512 ", 1, 1024), None); - assert_eq!( - validate_whole_number("2048", 1, 1024).as_deref(), - Some("Range: 1-1024") - ); - } -} diff --git a/configurator/src/app/pages/session/catalog.rs b/configurator/src/app/pages/session/catalog.rs new file mode 100644 index 00000000..78b1831f --- /dev/null +++ b/configurator/src/app/pages/session/catalog.rs @@ -0,0 +1,312 @@ +//! Saved-session catalog orchestration and model-to-row projection. +//! +//! The catalog owns the dynamic list layout. Each card owns the typed layout +//! that built it and the refresh closure for its stable controls, so layout and +//! widget state cannot drift and entry refreshes never masquerade as typing. + +mod card; +#[cfg(test)] +mod tests; + +use relm4::gtk; +use relm4::prelude::*; + +use gtk::prelude::*; + +use crate::messages::Message; +use crate::models::{SessionCatalogItem, SessionCatalogOperation, TabId}; + +use super::super::super::search::{AppSearchSummary, SearchArea}; +use super::super::super::state::ConfiguratorApp; +use super::super::PageBuilder; +use card::{BoundCatalogCard, rebuild_items}; + +pub(super) fn add(page: &mut PageBuilder) { + page.group_in_area("Saved Sessions", SearchArea::SessionCatalog); + + let sender = page.sender(); + let body = column_box(); + let refresh = message_button("Refresh", &sender, Message::SessionCatalogRefreshRequested); + let toolbar = row_box(); + toolbar.append(&refresh); + body.append(&toolbar); + body.append(&hint_label( + "Clear Tool State applies config defaults without deleting boards.", + )); + body.append(&hint_label("Clear Saved Data removes saved session files.")); + + let blocker_label = warning_label(""); + body.append(&blocker_label); + let loading_label = body_label("Loading sessions..."); + body.append(&loading_label); + let empty_label = body_label("No named sessions in the catalog yet."); + body.append(&empty_label); + + let list = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .build(); + body.append(&list); + + page.custom(&body); + let mut cards: Vec = Vec::new(); + page.bind(move |app, summary| { + let catalog = &app.session_catalog; + let gates = CatalogGates::of(app); + set_sensitive(&refresh, !gates.busy); + + match SessionCatalogOperation::Clear.cached_status_blocker(app.daemon_status.as_ref()) { + Some(blocker) => { + set_label(&blocker_label, blocker); + set_visible(&blocker_label, true); + } + None => set_visible(&blocker_label, false), + } + set_visible(&loading_label, catalog.is_loading); + set_visible( + &empty_label, + !catalog.is_loading && catalog.items.is_empty(), + ); + + let layout = catalog_layout(app); + if !cards + .iter() + .map(BoundCatalogCard::layout) + .eq(layout.items.iter()) + { + cards = rebuild_items(&list, &layout, &sender, &body); + } + for (item, card) in catalog.items.iter().zip(cards.iter()) { + let values = catalog_row_values(app, summary, &gates, item); + card.refresh(&values); + } + + // Confirm can temporarily park focus on the catalog while every row + // action is disabled. Once the operation finishes, return to the + // enabled Refresh action and remove the structural box from the normal + // tab chain. If the user already moved focus elsewhere, only remove the + // temporary focusability; do not steal focus back. + if should_release_catalog_focus_fallback(gates.busy, body.is_focusable()) { + if body.has_focus() { + refresh.grab_focus(); + } + body.set_focusable(false); + } + }); +} + +/// Everything a rebuilt card would render, and nothing else. +/// +/// Entry contents, button states, and the two-step clear are deliberately +/// absent: those are written in place, so typing never destroys the entry +/// being typed into. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct CatalogLayout { + /// Empty while loading: the list renders nothing then, so there is + /// nothing to build and nothing to refresh. + items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CatalogItemLayout { + id: String, + display_name: String, + path_label: String, + canonical_path_label: Option, + created_label: String, + last_opened_label: String, + last_saved_label: String, + artifacts: String, +} + +fn catalog_layout(app: &ConfiguratorApp) -> CatalogLayout { + if app.session_catalog.is_loading { + return CatalogLayout::default(); + } + + CatalogLayout { + items: app + .session_catalog + .items + .iter() + .map(|item| CatalogItemLayout { + id: item.id.clone(), + display_name: item.display_name.clone(), + path_label: item.path_label.clone(), + canonical_path_label: item.canonical_path_label.clone(), + created_label: item.created_label.clone(), + last_opened_label: item.last_opened_label.clone(), + last_saved_label: item.last_saved_label.clone(), + artifacts: item.artifacts.status_label(), + }) + .collect(), + } +} + +/// What a card's controls are allowed to do, resolved once per refresh: the +/// blockers are the same answer for every row and cost a lookup each. +struct CatalogGates { + busy: bool, + duplicate_blocked: bool, + move_blocked: bool, + tool_state_blocked: bool, + clear_blocked: bool, +} + +impl CatalogGates { + fn of(app: &ConfiguratorApp) -> Self { + let status = app.daemon_status.as_ref(); + Self { + busy: app.session_catalog.busy || app.session_catalog.is_loading, + duplicate_blocked: SessionCatalogOperation::Duplicate + .cached_status_blocker(status) + .is_some(), + move_blocked: SessionCatalogOperation::Move + .cached_status_blocker(status) + .is_some(), + tool_state_blocked: SessionCatalogOperation::ClearToolState + .cached_status_blocker(status) + .is_some(), + clear_blocked: SessionCatalogOperation::Clear + .cached_status_blocker(status) + .is_some(), + } + } +} + +/// One card's model-owned entry text and every action's state. +struct CatalogRowValues { + visible: bool, + rename: String, + rename_enabled: bool, + duplicate: String, + duplicate_enabled: bool, + move_target: String, + move_enabled: bool, + /// Reveal and Forget, which only wait on the catalog being idle. + actions_enabled: bool, + tool_state_enabled: bool, + clear_enabled: bool, + clear_armed: bool, +} + +fn catalog_row_values( + app: &ConfiguratorApp, + summary: &AppSearchSummary, + gates: &CatalogGates, + item: &SessionCatalogItem, +) -> CatalogRowValues { + let catalog = &app.session_catalog; + let id = item.id.as_str(); + let rename = catalog.rename_value(id, &item.display_name); + let duplicate = catalog.duplicate_value(id, &item.path); + let move_target = catalog.move_value(id, &item.path); + CatalogRowValues { + visible: item_visible(summary, id), + rename_enabled: !gates.busy + && rename.trim() != item.display_name.trim() + && !rename.trim().is_empty(), + duplicate_enabled: !gates.busy && !gates.duplicate_blocked && !duplicate.trim().is_empty(), + move_enabled: !gates.busy && !gates.move_blocked && !move_target.trim().is_empty(), + actions_enabled: !gates.busy, + tool_state_enabled: !gates.busy && !gates.tool_state_blocked, + clear_enabled: !gates.busy && !gates.clear_blocked, + clear_armed: clear_armed(app.pending_session_clear_id(), id), + rename, + duplicate, + move_target, + } +} + +fn clear_armed(pending: Option<&str>, id: &str) -> bool { + pending == Some(id) +} + +fn should_release_catalog_focus_fallback(catalog_busy: bool, fallback_focusable: bool) -> bool { + !catalog_busy && fallback_focusable +} + +fn item_visible(summary: &AppSearchSummary, id: &str) -> bool { + !summary.is_active() + || summary + .tab(TabId::Session) + .is_none_or(|tab| tab.session_item_visible(id)) +} + +fn column_box() -> gtk::Box { + gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build() +} + +fn row_box() -> gtk::Box { + gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build() +} + +fn body_label(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .xalign(0.0) + .wrap(true) + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .build() +} + +fn caption_label(text: &str) -> gtk::Label { + let label = body_label(text); + label.add_css_class("caption"); + label +} + +fn hint_label(text: &str) -> gtk::Label { + let label = caption_label(text); + label.add_css_class("dim-label"); + label +} + +fn warning_label(text: &str) -> gtk::Label { + let label = caption_label(text); + label.add_css_class("warning"); + label +} + +fn message_button( + label: &str, + sender: &ComponentSender, + message: Message, +) -> gtk::Button { + let button = gtk::Button::builder() + .label(label) + .valign(gtk::Align::Center) + .build(); + let sender = sender.clone(); + button.connect_clicked(move |_| sender.input(message.clone())); + button +} + +fn set_label(label: >k::Label, text: &str) { + if label.label() != text { + label.set_label(text); + } +} + +/// Writes the widget's own visibility flag, never `is_visible`: a row inside +/// a hidden group reports invisible while its own flag still says otherwise, +/// and skipping the write there would leak the stale state the moment the +/// group comes back. +fn set_visible(widget: &impl IsA, visible: bool) { + if widget.get_visible() != visible { + widget.set_visible(visible); + } +} + +fn set_sensitive(widget: &impl IsA, sensitive: bool) { + if widget.is_sensitive() != sensitive { + widget.set_sensitive(sensitive); + } +} diff --git a/configurator/src/app/pages/session/catalog/card.rs b/configurator/src/app/pages/session/catalog/card.rs new file mode 100644 index 00000000..bc3fd25b --- /dev/null +++ b/configurator/src/app/pages/session/catalog/card.rs @@ -0,0 +1,340 @@ +//! Construction and in-place refresh of one saved-session catalog card. + +use relm4::gtk; +use relm4::prelude::*; + +use gtk::glib::SignalHandlerId; +use gtk::prelude::*; + +use crate::messages::Message; + +use super::super::super::super::state::ConfiguratorApp; +use super::super::super::set_text_blocked; +use super::{ + CatalogItemLayout, CatalogLayout, CatalogRowValues, body_label, caption_label, hint_label, + message_button, row_box, set_sensitive, set_visible, +}; + +type CatalogRowRefresh = Box; + +pub(super) struct BoundCatalogCard { + layout: CatalogItemLayout, + refresh: CatalogRowRefresh, +} + +impl BoundCatalogCard { + pub(super) fn layout(&self) -> &CatalogItemLayout { + &self.layout + } + + pub(super) fn refresh(&self, values: &CatalogRowValues) { + (self.refresh)(values); + } +} + +pub(super) fn rebuild_items( + list: >k::Box, + layout: &CatalogLayout, + sender: &ComponentSender, + catalog_focus_target: >k::Box, +) -> Vec { + // Draining the list, not walking it for a control: the cards that replace + // these carry their own refresh closures. + while let Some(child) = list.first_child() { + list.remove(&child); + } + + let mut cards = Vec::with_capacity(layout.items.len()); + for item in &layout.items { + let built = item_card(item, sender, catalog_focus_target); + list.append(&built.card); + cards.push(BoundCatalogCard { + layout: item.clone(), + refresh: built.refresh, + }); + } + cards +} + +/// One catalog card: the widget, and the closure that writes the values the +/// layout deliberately left out. +struct CatalogRow { + card: gtk::Box, + refresh: CatalogRowRefresh, +} + +fn item_card( + item: &CatalogItemLayout, + sender: &ComponentSender, + catalog_focus_target: >k::Box, +) -> CatalogRow { + let card = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .css_classes(["card"]) + .build(); + + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .margin_top(12) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .build(); + card.append(&content); + + let header = row_box(); + let name = body_label(&item.display_name); + name.add_css_class("heading"); + header.append(&name); + header.append(&hint_label(&item.artifacts)); + content.append(&header); + + content.append(&caption_label(&item.path_label)); + if let Some(canonical) = item.canonical_path_label.as_deref() { + content.append(&caption_label(&format!("Canonical: {canonical}"))); + } + let times = row_box(); + times.append(&hint_label(&format!("Created: {}", item.created_label))); + times.append(&hint_label(&format!("Opened: {}", item.last_opened_label))); + times.append(&hint_label(&format!("Saved: {}", item.last_saved_label))); + content.append(×); + + let rename = input_row( + InputRow { + id: &item.id, + placeholder: "Display name", + button_label: "Save Name", + on_input: Message::SessionCatalogRenameInputChanged, + request: Message::SessionCatalogRenameRequested(item.id.clone()), + }, + sender, + ); + content.append(&rename.container); + let duplicate = input_row( + InputRow { + id: &item.id, + placeholder: "Duplicate target path", + button_label: "Duplicate", + on_input: Message::SessionCatalogDuplicateInputChanged, + request: Message::SessionCatalogDuplicateRequested(item.id.clone()), + }, + sender, + ); + content.append(&duplicate.container); + let move_row = input_row( + InputRow { + id: &item.id, + placeholder: "Move target path", + button_label: "Move", + on_input: Message::SessionCatalogMoveInputChanged, + request: Message::SessionCatalogMoveRequested(item.id.clone()), + }, + sender, + ); + content.append(&move_row.container); + + let actions = row_box(); + let reveal = message_button( + "Reveal File", + sender, + Message::SessionCatalogRevealRequested(item.id.clone()), + ); + actions.append(&reveal); + let tool_state = message_button( + "Clear Tool State", + sender, + Message::SessionCatalogClearToolStateRequested(item.id.clone()), + ); + actions.append(&tool_state); + content.append(&actions); + + let danger = row_box(); + let clear = message_button( + "Clear Saved Data", + sender, + Message::SessionCatalogClearRequested(item.id.clone()), + ); + clear.add_css_class("destructive-action"); + danger.append(&clear); + + // Both halves of the two-step clear exist from the start; arming the + // pending id swaps which one is visible. + let confirm = row_box(); + let confirm_button = message_button( + "Confirm Clear", + sender, + Message::SessionCatalogClearConfirmed(item.id.clone()), + ); + confirm_button.add_css_class("destructive-action"); + confirm.append(&confirm_button); + let cancel_button = message_button( + "Cancel", + sender, + Message::SessionCatalogClearCanceled(item.id.clone()), + ); + cancel_button.add_css_class("flat"); + confirm.append(&cancel_button); + danger.append(&confirm); + + let forget = message_button( + "Forget", + sender, + Message::SessionCatalogForgetRequested(item.id.clone()), + ); + forget.add_css_class("flat"); + danger.append(&forget); + content.append(&danger); + + let handle = card.clone(); + let catalog_focus_target = catalog_focus_target.clone(); + let refresh: CatalogRowRefresh = Box::new(move |values| { + set_visible(&handle, values.visible); + + // Blocked: these entries carry text the model owns, and a refresh + // reporting it back as typing would pin an input the user never made. + set_text_blocked(&rename.entry, &rename.handler, &values.rename); + set_sensitive(&rename.button, values.rename_enabled); + set_text_blocked(&duplicate.entry, &duplicate.handler, &values.duplicate); + set_sensitive(&duplicate.button, values.duplicate_enabled); + set_text_blocked(&move_row.entry, &move_row.handler, &values.move_target); + set_sensitive(&move_row.button, values.move_enabled); + + set_sensitive(&reveal, values.actions_enabled); + set_sensitive(&forget, values.actions_enabled); + set_sensitive(&tool_state, values.tool_state_enabled); + + let clear_was_armed = confirm.get_visible(); + let answer_has_focus = confirm_button.has_focus() || cancel_button.has_focus(); + let focus_after_refresh = clear_focus_after_refresh( + clear_was_armed, + values.clear_armed, + values.clear_enabled, + answer_has_focus, + ); + if focus_after_refresh == ClearFocusTarget::Catalog { + // Confirm enters the busy state and disables the row's actions. + // Temporarily make the stable page target focusable and move there + // before hiding the focused answer controls. The page binding + // removes it from the tab chain as soon as the catalog is idle. + catalog_focus_target.set_focusable(true); + catalog_focus_target.grab_focus(); + } + set_visible(&clear, !values.clear_armed); + set_sensitive(&clear, values.clear_enabled); + set_visible(&confirm, values.clear_armed); + match focus_after_refresh { + ClearFocusTarget::Confirm => { + // The destructive action just stepped aside. Move keyboard + // focus to the revealed answer rather than leaving it hidden. + confirm_button.grab_focus(); + } + ClearFocusTarget::ClearAction => { + // Cancel restores the still-enabled action in this row. + clear.grab_focus(); + } + ClearFocusTarget::Catalog | ClearFocusTarget::Unchanged => {} + } + }); + + CatalogRow { card, refresh } +} + +struct InputRow<'a> { + id: &'a str, + placeholder: &'a str, + button_label: &'a str, + on_input: fn(String, String) -> Message, + request: Message, +} + +/// An entry the model owns beside the button that acts on it, kept with the +/// handler a refresh has to block before writing the entry. +struct InputRowWidgets { + container: gtk::Box, + entry: gtk::Entry, + handler: SignalHandlerId, + button: gtk::Button, +} + +fn input_row(row: InputRow<'_>, sender: &ComponentSender) -> InputRowWidgets { + let container = row_box(); + let entry = gtk::Entry::builder() + .hexpand(true) + .placeholder_text(row.placeholder) + .build(); + let handler = { + let sender = sender.clone(); + let id = row.id.to_string(); + let on_input = row.on_input; + entry.connect_changed(move |entry| { + sender.input(on_input(id.clone(), entry.text().to_string())); + }) + }; + container.append(&entry); + + let button = message_button(row.button_label, sender, row.request); + container.append(&button); + InputRowWidgets { + container, + entry, + handler, + button, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ClearFocusTarget { + Unchanged, + Confirm, + ClearAction, + Catalog, +} + +fn clear_focus_after_refresh( + was_armed: bool, + is_armed: bool, + clear_enabled: bool, + answer_has_focus: bool, +) -> ClearFocusTarget { + if is_armed && !was_armed { + return ClearFocusTarget::Confirm; + } + if was_armed && !is_armed && answer_has_focus { + return if clear_enabled { + ClearFocusTarget::ClearAction + } else { + ClearFocusTarget::Catalog + }; + } + ClearFocusTarget::Unchanged +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clear_confirmation_focus_has_enabled_targets_for_every_transition() { + assert_eq!( + clear_focus_after_refresh(false, true, true, false), + ClearFocusTarget::Confirm, + "arming focuses the newly revealed confirmation" + ); + assert_eq!( + clear_focus_after_refresh(true, false, true, true), + ClearFocusTarget::ClearAction, + "cancel returns to the restored clear action" + ); + assert_eq!( + clear_focus_after_refresh(true, false, false, true), + ClearFocusTarget::Catalog, + "confirm moves away from the row actions disabled by busy state" + ); + assert_eq!( + clear_focus_after_refresh(true, false, false, false), + ClearFocusTarget::Unchanged, + "a pointer-triggered transition does not steal keyboard focus" + ); + } +} diff --git a/configurator/src/app/pages/session/catalog/tests.rs b/configurator/src/app/pages/session/catalog/tests.rs new file mode 100644 index 00000000..ca61b912 --- /dev/null +++ b/configurator/src/app/pages/session/catalog/tests.rs @@ -0,0 +1,177 @@ +use std::path::PathBuf; + +use super::*; +use crate::app::state::PendingConfirmation; +use crate::models::session::SessionArtifactSummary; + +fn test_item(id: &str, display_name: &str) -> SessionCatalogItem { + SessionCatalogItem { + id: id.to_string(), + display_name: display_name.to_string(), + path: PathBuf::from(format!("/tmp/{id}.wayscriber-session")), + path_label: format!("/tmp/{id}.wayscriber-session"), + canonical_path_label: None, + created_label: "2026-01-01 10:00".to_string(), + last_opened_label: "Never".to_string(), + last_saved_label: "Never".to_string(), + artifacts: SessionArtifactSummary { + primary_exists: true, + backup_exists: false, + recovery_exists: false, + clear_marker_exists: false, + lock_exists: false, + non_lock_size_bytes: 2048, + }, + } +} + +fn app_with_items(items: Vec) -> ConfiguratorApp { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.session_catalog.replace_items(items); + app +} + +/// The caret guarantee: typing in a card's entry, arming the clear, or a +/// running action must not rebuild the card being typed into. +#[test] +fn layout_ignores_input_edits_and_pending_clear() { + let mut app = app_with_items(vec![test_item("one", "First")]); + let baseline = catalog_layout(&app); + + app.session_catalog + .rename_inputs + .insert("one".to_string(), "First draft".to_string()); + app.pending_confirmation = Some(PendingConfirmation::SessionClear("one".to_string())); + app.session_catalog.busy = true; + + assert_eq!(catalog_layout(&app), baseline); + // The edit rides the card's values instead, where a blocked write can + // put it in the entry without a rebuild. + let summary = app.search_summary(); + let values = catalog_row_values( + &app, + &summary, + &CatalogGates::of(&app), + &app.session_catalog.items[0], + ); + assert_eq!(values.rename, "First draft"); +} + +#[test] +fn layout_tracks_rendered_item_text() { + let renamed = catalog_layout(&app_with_items(vec![test_item("one", "Renamed")])); + let original = catalog_layout(&app_with_items(vec![test_item("one", "First")])); + assert_ne!(renamed, original); + + let two_items = catalog_layout(&app_with_items(vec![ + test_item("one", "First"), + test_item("two", "Second"), + ])); + assert_ne!(two_items, original); +} + +#[test] +fn loading_and_an_empty_catalog_both_build_no_cards() { + let empty = app_with_items(Vec::new()); + let loading = ConfiguratorApp::new_app().0; + + assert!(loading.session_catalog.is_loading); + // Nothing is rendered while loading, so nothing is built for it and + // there is no card left over to refresh. + assert!(catalog_layout(&empty).items.is_empty()); + assert!(catalog_layout(&loading).items.is_empty()); +} + +/// A card's buttons follow the model, and the rename button waits for an +/// edit that is actually a change. +#[test] +fn a_busy_catalog_leaves_every_action_unpressable() { + let mut app = app_with_items(vec![test_item("one", "First")]); + let summary = app.search_summary(); + let idle = catalog_row_values( + &app, + &summary, + &CatalogGates::of(&app), + &app.session_catalog.items[0], + ); + assert!(idle.actions_enabled); + assert!(!idle.rename_enabled, "an unchanged name is not a rename"); + + app.session_catalog + .rename_inputs + .insert("one".to_string(), "Second".to_string()); + let changed = catalog_row_values( + &app, + &summary, + &CatalogGates::of(&app), + &app.session_catalog.items[0], + ); + assert!(changed.rename_enabled); + + app.session_catalog.busy = true; + let busy = catalog_row_values( + &app, + &summary, + &CatalogGates::of(&app), + &app.session_catalog.items[0], + ); + assert!(!busy.actions_enabled); + assert!(!busy.rename_enabled); + assert!(!busy.duplicate_enabled); + assert!(!busy.move_enabled); +} + +#[test] +fn clear_confirmation_is_armed_for_one_row_only() { + assert!(clear_armed(Some("one"), "one")); + assert!(!clear_armed(Some("one"), "two")); + assert!(!clear_armed(None, "one")); +} + +/// Confirming consumes the pending id as it sets the catalog busy, so the +/// card leaves its armed state in the same refresh that starts the work: the +/// Confirm/Cancel pair goes away and the button that asks comes back +/// unpressable, rather than offering a confirm the model would refuse. +#[test] +fn a_confirmed_clear_collapses_the_armed_row_into_the_busy_one() { + let mut app = app_with_items(vec![test_item("one", "First")]); + app.pending_confirmation = Some(PendingConfirmation::SessionClear("one".to_string())); + let summary = app.search_summary(); + let armed = catalog_row_values( + &app, + &summary, + &CatalogGates::of(&app), + &app.session_catalog.items[0], + ); + assert!(armed.clear_armed); + + // What `handle_session_catalog_clear_confirmed` leaves behind: the + // answered question consumed, the clear running. + app.pending_confirmation = None; + app.session_catalog.busy = true; + let running = catalog_row_values( + &app, + &summary, + &CatalogGates::of(&app), + &app.session_catalog.items[0], + ); + + assert!(!running.clear_armed); + assert!(!running.clear_enabled); +} + +#[test] +fn catalog_focus_fallback_leaves_the_tab_chain_when_busy_work_finishes() { + assert!( + !should_release_catalog_focus_fallback(true, true), + "the temporary target remains available while row actions are disabled" + ); + assert!( + should_release_catalog_focus_fallback(false, true), + "an idle refresh returns focus to an action and removes the fallback" + ); + assert!( + !should_release_catalog_focus_fallback(false, false), + "ordinary idle browsing does not add or manage a structural tab stop" + ); +} diff --git a/configurator/src/app/pages/session/settings.rs b/configurator/src/app/pages/session/settings.rs new file mode 100644 index 00000000..47c2cca8 --- /dev/null +++ b/configurator/src/app/pages/session/settings.rs @@ -0,0 +1,212 @@ +//! Session persistence, storage, and autosave settings. + +use relm4::{adw, gtk}; + +use adw::prelude::*; + +use crate::messages::Message; +use crate::models::{SessionCompressionOption, SessionStorageModeOption, TextField, ToggleField}; + +use super::super::super::search::SearchArea; +use super::super::{PageBuilder, set_text_blocked}; + +pub(super) fn add(page: &mut PageBuilder) { + page.group_in_area("Session Persistence", SearchArea::SessionPersistence) + .switch_row( + "Persist transparent mode drawings", + "", + |app| app.draft.session_persist_transparent, + |value| Message::ToggleChanged(ToggleField::SessionPersistTransparent, value), + ) + .switch_row( + "Persist whiteboard mode drawings", + "", + |app| app.draft.session_persist_whiteboard, + |value| Message::ToggleChanged(ToggleField::SessionPersistWhiteboard, value), + ) + .switch_row( + "Persist blackboard mode drawings", + "", + |app| app.draft.session_persist_blackboard, + |value| Message::ToggleChanged(ToggleField::SessionPersistBlackboard, value), + ) + .switch_row( + "Persist undo/redo history", + "", + |app| app.draft.session_persist_history, + |value| Message::ToggleChanged(ToggleField::SessionPersistHistory, value), + ) + .switch_row( + "Restore tool state on startup", + "", + |app| app.draft.session_restore_tool_state, + |value| Message::ToggleChanged(ToggleField::SessionRestoreToolState, value), + ) + .switch_row( + "Per-output persistence", + "", + |app| app.draft.session_per_output, + |value| Message::ToggleChanged(ToggleField::SessionPerOutput, value), + ); + + page.group_in_area("Storage", SearchArea::SessionPersistence) + .combo_row( + "Storage mode", + "", + SessionStorageModeOption::list(), + option_labels(SessionStorageModeOption::list(), |option| option.label()), + |app| app.draft.session_storage_mode, + Message::SessionStorageModeChanged, + ); + custom_directory_row(page); + page.combo_row( + "Compression", + "", + SessionCompressionOption::list(), + option_labels(SessionCompressionOption::list(), |option| option.label()), + |app| app.draft.session_compression, + Message::SessionCompressionChanged, + ) + .entry_row_validated( + "Max shapes per frame", + |app| app.draft.session_max_shapes_per_frame.clone(), + |value| Message::TextChanged(TextField::SessionMaxShapesPerFrame, value), + |app| validate_whole_number(&app.draft.session_max_shapes_per_frame, 1, u64::MAX), + ) + .entry_row( + "Max persisted undo depth (blank = runtime limit)", + |app| app.draft.session_max_persisted_undo_depth.clone(), + |value| Message::TextChanged(TextField::SessionMaxPersistedUndoDepth, value), + ) + .entry_row_validated( + "Max file size (MB)", + |app| app.draft.session_max_file_size_mb.clone(), + |value| Message::TextChanged(TextField::SessionMaxFileSizeMb, value), + |app| validate_whole_number(&app.draft.session_max_file_size_mb, 1, 1024), + ) + .entry_row_validated( + "Auto-compress threshold (KB)", + |app| app.draft.session_auto_compress_threshold_kb.clone(), + |value| Message::TextChanged(TextField::SessionAutoCompressThresholdKb, value), + |app| validate_whole_number(&app.draft.session_auto_compress_threshold_kb, 1, u64::MAX), + ) + .entry_row( + "Backup retention count", + |app| app.draft.session_backup_retention.clone(), + |value| Message::TextChanged(TextField::SessionBackupRetention, value), + ); + + page.group_in_area("Autosave", SearchArea::SessionPersistence) + .switch_row( + "Enable autosave", + "", + |app| app.draft.session_autosave_enabled, + |value| Message::ToggleChanged(ToggleField::SessionAutosaveEnabled, value), + ) + .entry_row_validated( + "Autosave idle (ms)", + |app| app.draft.session_autosave_idle_ms.clone(), + |value| Message::TextChanged(TextField::SessionAutosaveIdleMs, value), + |app| validate_whole_number(&app.draft.session_autosave_idle_ms, 1000, u64::MAX), + ) + .entry_row_validated( + "Autosave interval (ms)", + |app| app.draft.session_autosave_interval_ms.clone(), + |value| Message::TextChanged(TextField::SessionAutosaveIntervalMs, value), + |app| validate_whole_number(&app.draft.session_autosave_interval_ms, 1000, u64::MAX), + ) + .entry_row_validated( + "Autosave failure backoff (ms)", + |app| app.draft.session_autosave_failure_backoff_ms.clone(), + |value| Message::TextChanged(TextField::SessionAutosaveFailureBackoffMs, value), + |app| { + validate_whole_number( + &app.draft.session_autosave_failure_backoff_ms, + 1000, + u64::MAX, + ) + }, + ); +} + +fn option_labels(options: Vec, label: impl Fn(&O) -> &'static str) -> Vec { + options + .iter() + .map(|option| label(option).to_string()) + .collect() +} + +/// The custom directory only applies to one storage mode, so it is a row the +/// mode shows rather than a row that is always there and usually inert. +fn custom_directory_row(page: &mut PageBuilder) { + let row = adw::EntryRow::builder().title("Custom directory").build(); + let handler = { + let sender = page.sender(); + row.connect_changed(move |row| { + sender.input(Message::TextChanged( + TextField::SessionCustomDirectory, + row.text().to_string(), + )); + }) + }; + page.custom(&row); + page.bind(move |app, _summary| { + set_visible( + &row, + app.draft.session_storage_mode == SessionStorageModeOption::Custom, + ); + // Blocked: the model owns this text, and a load reporting its own + // value back as a user edit clears the diagnostics that load produced. + set_text_blocked(&row, &handler, &app.draft.session_custom_directory); + }); +} + +/// Error text for a whole-number field, `None` while the input is +/// acceptable. `u64::MAX` as the upper bound means "no maximum" and reports +/// only the minimum, matching the legacy view's two validators. +fn validate_whole_number(value: &str, min: u64, max: u64) -> Option { + let Ok(parsed) = value.trim().parse::() else { + return Some("Expected a whole number".to_string()); + }; + if (min..=max).contains(&parsed) { + return None; + } + Some(if max == u64::MAX { + format!("Minimum: {min}") + } else { + format!("Range: {min}-{max}") + }) +} + +fn set_visible(widget: &impl IsA, visible: bool) { + if widget.get_visible() != visible { + widget.set_visible(visible); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn whole_number_validation_matches_the_old_hints() { + assert_eq!(validate_whole_number("1000", 1000, u64::MAX), None); + assert_eq!( + validate_whole_number("999", 1000, u64::MAX).as_deref(), + Some("Minimum: 1000") + ); + assert_eq!( + validate_whole_number("", 1, u64::MAX).as_deref(), + Some("Expected a whole number") + ); + assert_eq!( + validate_whole_number("-4", 1, u64::MAX).as_deref(), + Some("Expected a whole number") + ); + assert_eq!(validate_whole_number(" 512 ", 1, 1024), None); + assert_eq!( + validate_whole_number("2048", 1, 1024).as_deref(), + Some("Range: 1-1024") + ); + } +} diff --git a/configurator/src/app/pages/ui.rs b/configurator/src/app/pages/ui.rs index 535ac127..4b627ce8 100644 --- a/configurator/src/app/pages/ui.rs +++ b/configurator/src/app/pages/ui.rs @@ -7,37 +7,33 @@ //! the visible child from `active_ui_tab`, so a deep link and the search //! realignment that moves that field both land on the right sub-page. +mod click_highlight; +mod general; +mod help_overlay; +mod input_hud; +mod presenter_mode; +mod status_bar; +mod toolbar; +mod toolbar_visibility; + +use relm4::gtk; use relm4::prelude::*; -use relm4::{adw, gtk}; -use adw::prelude::*; -use gtk::glib; - -use wayscriber::config::{ - ResolvedToolbarItems, ToolbarItemCategory, ToolbarItemDefinition, ToolbarItemId, - ToolbarItemOrderGroup, ToolbarItemSurface, ToolbarItemsConfig, toolbar_item_definitions, - toolbar_item_ids, toolbar_item_order_group, -}; +use gtk::prelude::*; use crate::messages::Message; use crate::models::color::parse_quad_values; -use crate::models::{ - ColorPickerId, InputHudModeOption, InputHudPositionOption, OverrideOption, - PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption, - StatusPositionOption, TabId, TextField, ToggleField, ToolbarLayoutModeOption, - ToolbarOverrideField, ToolbarRebindModifierOption, ToolbarSideLayoutOption, UiTabId, - UiThemeOption, ZoomChipDisplayOption, -}; +use crate::models::{TabId, UiTabId}; use super::super::search::SearchArea; use super::super::state::ConfiguratorApp; -use super::color_rows::{ResolvedColor, color_row}; -use super::{Binding, BuiltPage, PageBuilder, validate_u32_range}; +use super::color_rows::ResolvedColor; +use super::{Binding, BuiltPage}; pub(super) fn build(sender: &ComponentSender) -> BuiltPage { let mut bindings: Vec = Vec::new(); - let general = build_general(sender); + let general = general::build(sender); bindings.extend(general.bindings); let general_widget = general.widget; // Natural height inside the shared scroller: the general section and the @@ -160,1086 +156,16 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { fn build_ui_tab(sender: &ComponentSender, tab: UiTabId) -> BuiltPage { match tab { - UiTabId::Toolbar => build_toolbar(sender), - UiTabId::ToolbarVisibility => build_toolbar_visibility(sender), - UiTabId::StatusBar => build_status_bar(sender), - UiTabId::HelpOverlay => build_help_overlay(sender), - UiTabId::ClickHighlight => build_click_highlight(sender), - UiTabId::InputHud => build_input_hud(sender), - UiTabId::PresenterMode => build_presenter_mode(sender), - } -} - -// ---- General --------------------------------------------------------------- - -fn build_general(sender: &ComponentSender) -> BuiltPage { - let (themes, theme_labels) = options(UiThemeOption::list(), |value| value.label()); - let (motions, motion_labels) = options(ReducedMotionOption::list(), |value| value.label()); - - let mut page = PageBuilder::new(sender, TabId::Ui); - page.group_in_area("General UI", SearchArea::UiGeneral) - .combo_row( - "Theme", - "\"Auto\" currently uses the dark theme; \"Light\" takes effect as overlay surfaces adopt the runtime theme.", - themes, - theme_labels, - |app| app.draft.ui_theme, - Message::UiThemeChanged, - ) - .combo_row( - "Reduced motion", - "\"On\" disables UI animations. \"Auto\" follows the system preference in a future release and keeps full motion for now.", - motions, - motion_labels, - |app| app.draft.ui_reduced_motion, - Message::UiReducedMotionChanged, - ) - .entry_row( - "Preferred output (GNOME fallback)", - |app| app.draft.ui_preferred_output.clone(), - |value| Message::TextChanged(TextField::UiPreferredOutput, value), - ) - .switch_row( - "Use fullscreen xdg fallback", - "Applies to the GNOME xdg-shell fallback overlay.", - |app| app.draft.ui_xdg_fullscreen, - |value| Message::ToggleChanged(ToggleField::UiXdgFullscreen, value), - ) - .switch_row( - "Keep open on xdg focus loss", - "", - |app| app.draft.ui_xdg_keep_on_focus_loss, - |value| Message::ToggleChanged(ToggleField::UiXdgKeepOnFocusLoss, value), - ) - .switch_row( - "Enable context menu", - "", - |app| app.draft.ui_context_menu_enabled, - |value| Message::ToggleChanged(ToggleField::UiContextMenuEnabled, value), - ) - .switch_row( - "Show capabilities warning toast", - "", - |app| app.draft.ui_show_capabilities_warning, - |value| Message::ToggleChanged(ToggleField::UiShowCapabilitiesWarning, value), - ) - .entry_row( - "Command palette toast (ms)", - |app| app.draft.ui_command_palette_toast_duration_ms.clone(), - |value| Message::TextChanged(TextField::UiCommandPaletteToastDurationMs, value), - ); - - page.finish() -} - -// ---- Toolbar --------------------------------------------------------------- - -fn build_toolbar(sender: &ComponentSender) -> BuiltPage { - let (layout_modes, layout_labels) = - options(ToolbarLayoutModeOption::list(), |value| value.label()); - let (side_layouts, side_layout_labels) = - options(ToolbarSideLayoutOption::list(), |value| value.label()); - let (zoom_chips, zoom_chip_labels) = - options(ZoomChipDisplayOption::list(), |value| value.label()); - let (rebinds, rebind_labels) = options(ToolbarRebindModifierOption::ALL.to_vec(), |value| { - value.label() - }); - let (override_modes, override_mode_labels) = - options(ToolbarLayoutModeOption::list(), |value| value.label()); - - let mut page = PageBuilder::new(sender, TabId::Ui); - - page.group("Toolbar").custom(¬e( - "These settings are configured defaults. Toolbar pins, position, display form, item visibility/order, pane state, and board pins changed in the overlay are saved separately as runtime preferences.", - )); - - page.group("Layout") - .combo_row( - "Layout mode", - "", - layout_modes, - layout_labels, - |app| app.draft.ui_toolbar_layout_mode, - Message::ToolbarLayoutModeChanged, - ) - .combo_row( - "Side layout", - "Pill (the default) retires the side palette: drawing properties live in the top strip's style pill, canvas management in the status HUD and board picker, and Session/Settings in popovers on the top strip's overflow menu. Panel is the legacy escape hatch restoring the classic side palette; it is deprecated and planned for removal one release after the pill default.", - side_layouts, - side_layout_labels, - |app| app.draft.ui_toolbar_side_layout, - Message::ToolbarSideLayoutChanged, - ) - .combo_row( - "Zoom chip", - "", - zoom_chips, - zoom_chip_labels, - |app| app.draft.ui_toolbar_zoom_chip_display, - Message::ToolbarZoomChipDisplayChanged, - ) - .switch_row( - "Show zoom chip", - "", - |app| app.draft.ui_toolbar_show_zoom_chip, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowZoomChip, value), - ) - .combo_row( - "Shortcut edit click", - "", - rebinds, - rebind_labels, - |app| app.draft.ui_toolbar_rebind_modifier, - Message::ToolbarRebindModifierChanged, - ) - .switch_row( - "Configured default: pin top toolbar", - "", - |app| app.draft.ui_toolbar_top_pinned, - |value| Message::ToggleChanged(ToggleField::UiToolbarTopPinned, value), - ) - .switch_row( - "Configured default: pin side toolbar", - "", - |app| app.draft.ui_toolbar_side_pinned, - |value| Message::ToggleChanged(ToggleField::UiToolbarSidePinned, value), - ) - .switch_row( - "Use icon-only buttons", - "", - |app| app.draft.ui_toolbar_use_icons, - |value| Message::ToggleChanged(ToggleField::UiToolbarUseIcons, value), - ); - - page.group("Sections") - .switch_row( - "Show extended colors", - "", - |app| app.draft.ui_toolbar_show_more_colors, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowMoreColors, value), - ) - .switch_row( - "Show presets", - "", - |app| app.draft.ui_toolbar_show_presets, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowPresets, value), - ) - .switch_row( - "Show actions", - "", - |app| app.draft.ui_toolbar_show_actions_section, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowActionsSection, value), - ) - .switch_row( - "Show zoom actions", - "", - |app| app.draft.ui_toolbar_show_zoom_actions, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowZoomActions, value), - ) - .switch_row( - "Show advanced actions", - "", - |app| app.draft.ui_toolbar_show_actions_advanced, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowActionsAdvanced, value), - ) - .switch_row( - "Show pages section", - "", - |app| app.draft.ui_toolbar_show_pages_section, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowPagesSection, value), - ) - .switch_row( - "Show boards section", - "", - |app| app.draft.ui_toolbar_show_boards_section, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowBoardsSection, value), - ) - .switch_row( - "Show multi-step undo/redo", - "", - |app| app.draft.ui_toolbar_show_step_section, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowStepSection, value), - ) - .switch_row( - "Always show text controls", - "", - |app| app.draft.ui_toolbar_show_text_controls, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowTextControls, value), - ) - .switch_row( - "Show delay sliders", - "", - |app| app.draft.ui_toolbar_show_delay_sliders, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowDelaySliders, value), - ) - .switch_row( - "Show marker opacity controls", - "", - |app| app.draft.ui_toolbar_show_marker_opacity_section, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowMarkerOpacitySection, value), - ) - .switch_row( - "Show tool preview bubble", - "", - |app| app.draft.ui_toolbar_show_tool_preview, - |value| Message::ToggleChanged(ToggleField::UiToolbarShowToolPreview, value), - ) - .switch_row( - "Show preset action toasts", - "", - |app| app.draft.ui_toolbar_show_preset_toasts, - |value| Message::ToggleChanged(ToggleField::UiToolbarPresetToasts, value), - ) - .switch_row( - "Force inline toolbars", - "", - |app| app.draft.ui_toolbar_force_inline, - |value| Message::ToggleChanged(ToggleField::UiToolbarForceInline, value), - ); - - page.group("Mode overrides").combo_row( - "Edit mode", - "Overrides below apply to the mode selected here; \"Default\" keeps the mode preset.", - override_modes, - override_mode_labels, - |app| app.override_mode, - Message::ToolbarOverrideModeChanged, - ); - for field in [ - ToolbarOverrideField::ShowPresets, - ToolbarOverrideField::ShowActionsSection, - ToolbarOverrideField::ShowZoomActions, - ToolbarOverrideField::ShowActionsAdvanced, - ToolbarOverrideField::ShowPagesSection, - ToolbarOverrideField::ShowBoardsSection, - ToolbarOverrideField::ShowStepSection, - ToolbarOverrideField::ShowTextControls, - ] { - let (values, labels) = options(OverrideOption::list(), |value| value.label()); - page.combo_row( - field.label(), - "", - values, - labels, - move |app| toolbar_override(app, field), - move |value| Message::ToolbarOverrideChanged(field, value), - ); - } - - page.group("Placement offsets") - .entry_row( - "Top offset X (px)", - |app| app.draft.ui_toolbar_top_offset.clone(), - |value| Message::TextChanged(TextField::ToolbarTopOffset, value), - ) - .entry_row( - "Top offset Y (px)", - |app| app.draft.ui_toolbar_top_offset_y.clone(), - |value| Message::TextChanged(TextField::ToolbarTopOffsetY, value), - ) - .entry_row( - "Side offset Y (px)", - |app| app.draft.ui_toolbar_side_offset.clone(), - |value| Message::TextChanged(TextField::ToolbarSideOffset, value), - ) - .entry_row( - "Side offset X (px)", - |app| app.draft.ui_toolbar_side_offset_x.clone(), - |value| Message::TextChanged(TextField::ToolbarSideOffsetX, value), - ) - .custom(¬e( - "Configured defaults. Dragging a toolbar in the overlay saves that position as a runtime preference; editing a value here takes over from the saved drag.", - )); - - page.finish() -} - -fn toolbar_override(app: &ConfiguratorApp, field: ToolbarOverrideField) -> OverrideOption { - let overrides = app - .draft - .ui_toolbar_mode_overrides - .for_mode(app.override_mode); - match field { - ToolbarOverrideField::ShowPresets => overrides.show_presets, - ToolbarOverrideField::ShowActionsSection => overrides.show_actions_section, - ToolbarOverrideField::ShowActionsAdvanced => overrides.show_actions_advanced, - ToolbarOverrideField::ShowZoomActions => overrides.show_zoom_actions, - ToolbarOverrideField::ShowPagesSection => overrides.show_pages_section, - ToolbarOverrideField::ShowBoardsSection => overrides.show_boards_section, - ToolbarOverrideField::ShowStepSection => overrides.show_step_section, - ToolbarOverrideField::ShowTextControls => overrides.show_text_controls, - } -} - -// ---- Toolbar visibility ---------------------------------------------------- - -/// One preferences group of item rows: a surface/category batch, or one of -/// the three order groups the configurator can reorder. -struct ItemSection { - title: String, - order_group: Option, - definitions: Vec<&'static ToolbarItemDefinition>, -} - -struct ItemRow { - id: ToolbarItemId, - row: adw::SwitchRow, - /// Kept so the refresh can write the switch with the handler blocked. - /// `ToolbarItemVisibilityChanged` is not a plain setter: it pins an - /// explicit visibility entry for section ids, so a refresh reporting the - /// resolved value back would add entries to a config nobody edited — and - /// the first refresh, which lifts every visible row off its built-in - /// `false`, would do it to every section on startup. - handler: glib::SignalHandlerId, - move_buttons: Option<(gtk::Button, gtk::Button)>, -} - -/// The widgets one [`ItemSection`] refreshes, kept so a single binding can -/// resolve the item config once for the whole page. -struct SectionWidgets { - order_group: Option, - built_in_order: Vec, - list: gtk::ListBox, - reset: Option, - rows: Vec, -} - -fn build_toolbar_visibility(sender: &ComponentSender) -> BuiltPage { - let built_in = ToolbarItemsConfig::default().resolved(); - let mut page = PageBuilder::new(sender, TabId::Ui); - - // Shown by the binding only while the config carries ids this build does - // not know. - let unknown_notice = note(""); - unknown_notice.set_visible(false); - page.group("Toolbar Visibility") - .custom(¬e( - "These are configured visibility defaults. Overlay customizations are stored separately as runtime preferences. Enabled items are shown; section toggles and mode overrides can still hide them.", - )) - .custom(&unknown_notice); - - let mut sections: Vec = Vec::new(); - for section in item_sections(&built_in) { - let list = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) - .build(); - - let mut rows: Vec = Vec::new(); - for definition in §ion.definitions { - let id = definition.id; - let row = adw::SwitchRow::builder() - .title(definition.label) - .subtitle(format!( - "{} - built-in default: {}", - id.as_str(), - visibility_label(!built_in.is_hidden(id)) - )) - .build(); - let handler = { - let sender = page.sender(); - row.connect_active_notify(move |row| { - sender.input(Message::ToolbarItemVisibilityChanged(id, row.is_active())); - }) - }; - - let mut move_buttons = None; - if let Some(group) = section.order_group { - let up = move_button(&page.sender(), group, id, -1); - let down = move_button(&page.sender(), group, id, 1); - row.add_suffix(&up); - row.add_suffix(&down); - move_buttons = Some((up, down)); - } - - list.append(&row); - rows.push(ItemRow { - id, - row, - handler, - move_buttons, - }); - } - - page.group(§ion.title).custom(&list); - - let mut reset = None; - if let Some(group) = section.order_group { - let button = gtk::Button::builder() - .label("Restore built-in order") - .halign(gtk::Align::End) - .margin_top(6) - .build(); - { - let sender = page.sender(); - button.connect_clicked(move |_| { - sender.input(Message::ToolbarItemOrderReset(group)); - }); - } - page.custom(&button); - reset = Some(button); - } - - sections.push(SectionWidgets { - order_group: section.order_group, - built_in_order: section - .order_group - .map(|group| built_in.order.ordered_ids(group).to_vec()) - .unwrap_or_default(), - list, - reset, - rows, - }); - } - - // One binding for the whole page: resolving the item config allocates, - // and doing it per row would repeat that work a hundred times a refresh. - page.bind(move |app, _summary| { - let resolved = app.draft.ui_toolbar_items.resolved(); - - let unknown = resolved.unknown_hidden.len() + resolved.unknown_shown.len(); - let notice_text = if unknown > 0 { - format!("Preserving {unknown} unknown toolbar item id(s) from config.") - } else { - String::new() - }; - if unknown_notice.text() != notice_text { - unknown_notice.set_text(¬ice_text); - } - if unknown_notice.is_visible() != (unknown > 0) { - unknown_notice.set_visible(unknown > 0); - } - - for section in §ions { - for item in §ion.rows { - let visible = !resolved.is_hidden(item.id); - if item.row.is_active() != visible { - item.row.block_signal(&item.handler); - item.row.set_active(visible); - item.row.unblock_signal(&item.handler); - } - - let (Some(group), Some((up, down))) = (section.order_group, &item.move_buttons) - else { - continue; - }; - let index = resolved.order.index_of(group, item.id); - let length = resolved.order.ordered_ids(group).len(); - let can_move_up = index.is_some_and(|index| index > 0); - let can_move_down = index.is_some_and(|index| index + 1 < length); - if up.is_sensitive() != can_move_up { - up.set_sensitive(can_move_up); - } - if down.is_sensitive() != can_move_down { - down.set_sensitive(can_move_down); - } - } - - let Some(group) = section.order_group else { - continue; - }; - let ordered = resolved.order.ordered_ids(group); - let desired: Vec = ordered - .iter() - .copied() - .filter(|id| section.rows.iter().any(|item| item.id == *id)) - .collect(); - if current_row_order(section) != desired { - for item in §ion.rows { - section.list.remove(&item.row); - } - for id in &desired { - if let Some(item) = section.rows.iter().find(|item| item.id == *id) { - section.list.append(&item.row); - } - } - } - - if let Some(reset) = §ion.reset { - // The Iced view only offered the restore action once the - // order left the built-in one; insensitive keeps the button - // in place instead of making the section jump. - let restorable = ordered != section.built_in_order; - if reset.is_sensitive() != restorable { - reset.set_sensitive(restorable); - } - } - } - }); - - page.finish() -} - -/// Item rows grouped the way the Iced list read: by toolbar surface and -/// category, with each reorderable order group in a section of its own so it -/// can carry the move buttons and its restore action. -fn item_sections(built_in: &ResolvedToolbarItems) -> Vec { - let mut sections: Vec = Vec::new(); - for definition in toolbar_item_definitions() { - if definition.id == toolbar_item_ids::SIDE_GROUP_SETTINGS - || definition.id == toolbar_item_ids::TOP_CHROME_OVERFLOW - { - continue; - } - - let order_group = configurator_order_group(definition); - let title = match order_group { - Some((_, label)) => format!( - "{}: {} (reorderable)", - surface_label(definition.surface), - label - ), - None => format!( - "{}: {}", - surface_label(definition.surface), - category_label(definition.category) - ), - }; - - match sections.iter_mut().find(|section| section.title == title) { - Some(section) => section.definitions.push(definition), - None => sections.push(ItemSection { - title, - order_group: order_group.map(|(group, _)| group), - definitions: vec![definition], - }), - } - } - - // Reorderable sections start in the built-in order; the binding puts them - // in the configured order on the first refresh. - for section in &mut sections { - let Some(group) = section.order_group else { - continue; - }; - let order = built_in.order.ordered_ids(group); - section.definitions.sort_by_key(|definition| { - order - .iter() - .position(|id| *id == definition.id) - .unwrap_or(usize::MAX) - }); - } - - sections -} - -/// The order groups this page can reorder, with their section label. The -/// remaining groups keep the order the config resolves them in. -fn configurator_order_group( - definition: &ToolbarItemDefinition, -) -> Option<(ToolbarItemOrderGroup, &'static str)> { - match toolbar_item_order_group(definition)? { - ToolbarItemOrderGroup::TopTools => Some((ToolbarItemOrderGroup::TopTools, "Tools")), - ToolbarItemOrderGroup::TopControls => { - Some((ToolbarItemOrderGroup::TopControls, "Controls")) - } - ToolbarItemOrderGroup::SideSections => { - Some((ToolbarItemOrderGroup::SideSections, "Sections")) - } - _ => None, - } -} - -/// The ids currently laid out in a section, read back from the list itself so -/// the reorder pass needs no state of its own. -fn current_row_order(section: &SectionWidgets) -> Vec { - let mut order = Vec::with_capacity(section.rows.len()); - let mut child = section.list.first_child(); - while let Some(widget) = child { - if let Some(item) = section - .rows - .iter() - .find(|item| item.row.upcast_ref::() == &widget) - { - order.push(item.id); - } - child = widget.next_sibling(); - } - order -} - -fn move_button( - sender: &ComponentSender, - group: ToolbarItemOrderGroup, - id: ToolbarItemId, - delta: isize, -) -> gtk::Button { - let (icon, tooltip) = if delta < 0 { - ("go-up-symbolic", "Move up") - } else { - ("go-down-symbolic", "Move down") - }; - let button = gtk::Button::builder() - .icon_name(icon) - .tooltip_text(tooltip) - .valign(gtk::Align::Center) - .css_classes(["flat"]) - .build(); - let sender = sender.clone(); - button.connect_clicked(move |_| { - sender.input(Message::ToolbarItemMoveRequested(group, id, delta)); - }); - button -} - -fn visibility_label(visible: bool) -> &'static str { - if visible { "shown" } else { "hidden" } -} - -fn surface_label(surface: ToolbarItemSurface) -> &'static str { - match surface { - ToolbarItemSurface::Top => "Top toolbar", - ToolbarItemSurface::Side => "Side toolbar", + UiTabId::Toolbar => toolbar::build(sender), + UiTabId::ToolbarVisibility => toolbar_visibility::build(sender), + UiTabId::StatusBar => status_bar::build(sender), + UiTabId::HelpOverlay => help_overlay::build(sender), + UiTabId::ClickHighlight => click_highlight::build(sender), + UiTabId::InputHud => input_hud::build(sender), + UiTabId::PresenterMode => presenter_mode::build(sender), } } -fn category_label(category: ToolbarItemCategory) -> &'static str { - match category { - ToolbarItemCategory::Chrome => "Toolbar controls", - ToolbarItemCategory::Tool => "Tools", - ToolbarItemCategory::Utility => "Utilities", - ToolbarItemCategory::Group => "Sections", - ToolbarItemCategory::Action => "Actions", - ToolbarItemCategory::Page => "Pages", - ToolbarItemCategory::Board => "Boards", - ToolbarItemCategory::Setting => "Settings", - ToolbarItemCategory::Session => "Sessions", - ToolbarItemCategory::ToolOption => "Tool options", - } -} - -// ---- Status bar ------------------------------------------------------------ - -fn build_status_bar(sender: &ComponentSender) -> BuiltPage { - let (positions, position_labels) = options(StatusPositionOption::list(), |value| value.label()); - - let mut page = PageBuilder::new(sender, TabId::Ui); - - page.group("Status Bar") - .switch_row( - "Show status bar", - "", - |app| app.draft.ui_show_status_bar, - |value| Message::ToggleChanged(ToggleField::UiShowStatusBar, value), - ) - .switch_row( - "Clickable status bar segments", - "", - |app| app.draft.ui_status_bar_interactive, - |value| Message::ToggleChanged(ToggleField::UiStatusBarInteractive, value), - ); - - page.group("Contents") - .switch_row( - "Show active output", - "", - |app| app.draft.ui_active_output_badge, - |value| Message::ToggleChanged(ToggleField::UiActiveOutputBadge, value), - ) - .switch_row( - "Show selection dimensions", - "", - |app| app.draft.ui_show_status_selection_info, - |value| Message::ToggleChanged(ToggleField::UiShowStatusSelectionInfo, value), - ) - .switch_row( - "Show board label", - "", - |app| app.draft.ui_show_status_board_badge, - |value| Message::ToggleChanged(ToggleField::UiShowStatusBoardBadge, value), - ) - .switch_row( - "Show page counter", - "", - |app| app.draft.ui_show_status_page_badge, - |value| Message::ToggleChanged(ToggleField::UiShowStatusPageBadge, value), - ) - .switch_row( - "Show current color", - "", - |app| app.draft.ui_show_status_color, - |value| Message::ToggleChanged(ToggleField::UiShowStatusColor, value), - ) - .switch_row( - "Show active tool", - "", - |app| app.draft.ui_show_status_tool, - |value| Message::ToggleChanged(ToggleField::UiShowStatusTool, value), - ) - .switch_row( - "Show tool size", - "", - |app| app.draft.ui_show_status_size, - |value| Message::ToggleChanged(ToggleField::UiShowStatusSize, value), - ) - .switch_row( - "Show context indicators", - "", - |app| app.draft.ui_show_status_context_indicators, - |value| Message::ToggleChanged(ToggleField::UiShowStatusContextIndicators, value), - ) - .switch_row( - "Show toolbar hint while toolbars are hidden", - "", - |app| app.draft.ui_show_toolbar_hint, - |value| Message::ToggleChanged(ToggleField::UiShowToolbarHint, value), - ) - .switch_row( - "Show Help shortcut", - "", - |app| app.draft.ui_show_status_help, - |value| Message::ToggleChanged(ToggleField::UiShowStatusHelp, value), - ) - .switch_row( - "Show About and version", - "", - |app| app.draft.ui_show_status_about, - |value| Message::ToggleChanged(ToggleField::UiShowStatusAbout, value), - ); - - page.group("Additional Badges") - .switch_row( - "Show board/page badge", - "", - |app| app.draft.ui_show_floating_badge, - |value| Message::ToggleChanged(ToggleField::UiShowFloatingBadge, value), - ) - .switch_row( - "Also show badge with status bar", - "", - |app| app.draft.ui_show_page_badge_with_status_bar, - |value| Message::ToggleChanged(ToggleField::UiShowPageBadgeWithStatusBar, value), - ) - .switch_row( - "Show frozen badge", - "", - |app| app.draft.ui_show_frozen_badge, - |value| Message::ToggleChanged(ToggleField::UiShowFrozenBadge, value), - ) - .combo_row( - "Status bar position", - "", - positions, - position_labels, - |app| app.draft.ui_status_position, - Message::StatusPositionChanged, - ); - - page.group("Status Bar Style"); - color_row( - &mut page, - "Background (hex)", - ColorPickerId::StatusBarBg, - |app| quad_color(&app.draft.status_bar_bg_color.components), - ); - color_row( - &mut page, - "Text (hex)", - ColorPickerId::StatusBarText, - |app| quad_color(&app.draft.status_bar_text_color.components), - ); - page.entry_row( - "Font size", - |app| app.draft.status_font_size.clone(), - |value| Message::TextChanged(TextField::StatusFontSize, value), - ) - .entry_row( - "Padding", - |app| app.draft.status_padding.clone(), - |value| Message::TextChanged(TextField::StatusPadding, value), - ) - .entry_row( - "Dot radius", - |app| app.draft.status_dot_radius.clone(), - |value| Message::TextChanged(TextField::StatusDotRadius, value), - ); - - page.finish() -} - -// ---- Help overlay ---------------------------------------------------------- - -fn build_help_overlay(sender: &ComponentSender) -> BuiltPage { - let mut page = PageBuilder::new(sender, TabId::Ui); - - page.group("Help Overlay").switch_row( - "Filter sections by enabled features", - "", - |app| app.draft.help_context_filter, - |value| Message::ToggleChanged(ToggleField::UiHelpOverlayContextFilter, value), - ); - - page.group("Help Overlay Style"); - color_row( - &mut page, - "Background (hex)", - ColorPickerId::HelpBg, - |app| quad_color(&app.draft.help_bg_color.components), - ); - color_row( - &mut page, - "Border (hex)", - ColorPickerId::HelpBorder, - |app| quad_color(&app.draft.help_border_color.components), - ); - color_row(&mut page, "Text (hex)", ColorPickerId::HelpText, |app| { - quad_color(&app.draft.help_text_color.components) - }); - page.entry_row( - "Font family", - |app| app.draft.help_font_family.clone(), - |value| Message::TextChanged(TextField::HelpFontFamily, value), - ) - .entry_row( - "Font size", - |app| app.draft.help_font_size.clone(), - |value| Message::TextChanged(TextField::HelpFontSize, value), - ) - .entry_row( - "Line height", - |app| app.draft.help_line_height.clone(), - |value| Message::TextChanged(TextField::HelpLineHeight, value), - ) - .entry_row( - "Padding", - |app| app.draft.help_padding.clone(), - |value| Message::TextChanged(TextField::HelpPadding, value), - ) - .entry_row( - "Border width", - |app| app.draft.help_border_width.clone(), - |value| Message::TextChanged(TextField::HelpBorderWidth, value), - ); - - page.finish() -} - -// ---- Click highlight ------------------------------------------------------- - -fn build_click_highlight(sender: &ComponentSender) -> BuiltPage { - let mut page = PageBuilder::new(sender, TabId::Ui); - - page.group("Click Highlight") - .switch_row( - "Enable click highlight", - "", - |app| app.draft.click_highlight_enabled, - |value| Message::ToggleChanged(ToggleField::UiClickHighlightEnabled, value), - ) - .switch_row( - "Show ring while highlight tool is active", - "", - |app| app.draft.click_highlight_show_on_highlight_tool, - |value| Message::ToggleChanged(ToggleField::UiClickHighlightShowOnHighlightTool, value), - ) - .switch_row( - "Link highlight color to current pen", - "", - |app| app.draft.click_highlight_use_pen_color, - |value| Message::ToggleChanged(ToggleField::UiClickHighlightUsePenColor, value), - ) - .switch_row( - "Force on when entering light mode", - "", - |app| app.draft.click_highlight_force_in_light_mode, - |value| Message::ToggleChanged(ToggleField::UiClickHighlightForceInLightMode, value), - ); - - page.group("Ring") - .entry_row_validated( - "Radius", - |app| app.draft.click_highlight_radius.clone(), - |value| Message::TextChanged(TextField::HighlightRadius, value), - |app| validate_f64_range(&app.draft.click_highlight_radius, 16.0, 160.0), - ) - .entry_row_validated( - "Outline thickness", - |app| app.draft.click_highlight_outline_thickness.clone(), - |value| Message::TextChanged(TextField::HighlightOutlineThickness, value), - |app| validate_f64_range(&app.draft.click_highlight_outline_thickness, 1.0, 12.0), - ) - .entry_row_validated( - "Duration (ms)", - |app| app.draft.click_highlight_duration_ms.clone(), - |value| Message::TextChanged(TextField::HighlightDurationMs, value), - |app| validate_u32_range(&app.draft.click_highlight_duration_ms, 150, 1500), - ); - - page.group("Colors"); - color_row( - &mut page, - "Fill (hex)", - ColorPickerId::HighlightFill, - |app| quad_color(&app.draft.click_highlight_fill_color.components), - ); - color_row( - &mut page, - "Outline (hex)", - ColorPickerId::HighlightOutline, - |app| quad_color(&app.draft.click_highlight_outline_color.components), - ); - - page.finish() -} - -// ---- Input HUD ------------------------------------------------------------- - -fn build_input_hud(sender: &ComponentSender) -> BuiltPage { - let (modes, mode_labels) = options(InputHudModeOption::list(), |value| value.label()); - let (positions, position_labels) = - options(InputHudPositionOption::list(), |value| value.label()); - - let mut page = PageBuilder::new(sender, TabId::Ui); - - page.group("Input HUD") - .custom(¬e( - "Show a live row of keystroke and click chips for demos and screencasts.", - )) - .switch_row( - "Enable input HUD", - "", - |app| app.draft.input_hud_enabled, - |value| Message::ToggleChanged(ToggleField::UiInputHudEnabled, value), - ) - .combo_row( - "Input source", - "\"Overlay only\" shows what Wayscriber itself receives. \"System-wide\" also shows input that goes to other apps; it needs a build with the input-monitor feature and read access to /dev/input (usually `input` group membership), and it sees every keystroke on the seat - including passwords typed elsewhere.", - modes, - mode_labels, - |app| app.draft.input_hud_mode, - Message::InputHudModeChanged, - ) - .combo_row( - "Screen position", - "", - positions, - position_labels, - |app| app.draft.input_hud_position, - Message::InputHudPositionChanged, - ) - .switch_row( - "Show mouse buttons and scroll", - "", - |app| app.draft.input_hud_show_mouse, - |value| Message::ToggleChanged(ToggleField::UiInputHudShowMouse, value), - ) - .switch_row( - "Show bare modifier taps", - "", - |app| app.draft.input_hud_show_bare_modifiers, - |value| Message::ToggleChanged(ToggleField::UiInputHudShowBareModifiers, value), - ) - .switch_row( - "Combine repeats into a counter", - "", - |app| app.draft.input_hud_combine_repeats, - |value| Message::ToggleChanged(ToggleField::UiInputHudCombineRepeats, value), - ); - - page.group("Chips") - .entry_row_validated( - "Hold (ms)", - |app| app.draft.input_hud_display_ms.clone(), - |value| Message::TextChanged(TextField::InputHudDisplayMs, value), - |app| validate_u32_range(&app.draft.input_hud_display_ms, 200, 30_000), - ) - .entry_row_validated( - "Fade (ms)", - |app| app.draft.input_hud_fade_ms.clone(), - |value| Message::TextChanged(TextField::InputHudFadeMs, value), - |app| validate_u32_range(&app.draft.input_hud_fade_ms, 0, 5_000), - ) - .entry_row_validated( - "Max chips", - |app| app.draft.input_hud_max_entries.clone(), - |value| Message::TextChanged(TextField::InputHudMaxEntries, value), - |app| validate_u32_range(&app.draft.input_hud_max_entries, 1, 16), - ) - .entry_row_validated( - "Font size", - |app| app.draft.input_hud_font_size.clone(), - |value| Message::TextChanged(TextField::InputHudFontSize, value), - |app| validate_f64_range(&app.draft.input_hud_font_size, 6.0, 72.0), - ); - - page.finish() -} - -// ---- Presenter mode -------------------------------------------------------- - -fn build_presenter_mode(sender: &ComponentSender) -> BuiltPage { - let (toolbar_modes, toolbar_mode_labels) = - options(PresenterToolbarModeOption::list(), |value| value.label()); - let (behaviors, behavior_labels) = - options(PresenterToolBehaviorOption::list(), |value| value.label()); - - let mut page = PageBuilder::new(sender, TabId::Ui); - - page.group("Presenter Mode") - .custom(¬e("Customize what presenter mode changes when toggled.")) - .switch_row( - "Hide status bar", - "", - |app| app.draft.presenter_hide_status_bar, - |value| Message::ToggleChanged(ToggleField::PresenterHideStatusBar, value), - ) - .switch_row( - "Hide toolbars", - "", - |app| app.draft.presenter_hide_toolbars, - |value| Message::ToggleChanged(ToggleField::PresenterHideToolbars, value), - ) - .combo_row( - "Top toolbar while presenting", - "", - toolbar_modes, - toolbar_mode_labels, - |app| app.draft.presenter_toolbar_mode, - Message::PresenterToolbarModeChanged, - ) - .switch_row( - "Hide tool preview", - "", - |app| app.draft.presenter_hide_tool_preview, - |value| Message::ToggleChanged(ToggleField::PresenterHideToolPreview, value), - ) - .switch_row( - "Close help overlay on entry", - "", - |app| app.draft.presenter_close_help_overlay, - |value| Message::ToggleChanged(ToggleField::PresenterCloseHelpOverlay, value), - ) - .switch_row( - "Force click highlights on", - "", - |app| app.draft.presenter_enable_click_highlight, - |value| Message::ToggleChanged(ToggleField::PresenterEnableClickHighlight, value), - ) - .switch_row( - "Force input HUD on", - "", - |app| app.draft.presenter_enable_input_hud, - |value| Message::ToggleChanged(ToggleField::PresenterEnableInputHud, value), - ) - .combo_row( - "Tool behavior", - "", - behaviors, - behavior_labels, - |app| app.draft.presenter_tool_behavior, - Message::PresenterToolBehaviorChanged, - ) - .switch_row( - "Show enter/exit toast", - "", - |app| app.draft.presenter_show_toast, - |value| Message::ToggleChanged(ToggleField::PresenterShowToast, value), - ); - - page.finish() -} - // ---- Shared helpers -------------------------------------------------------- /// A combo row's values with their labels, in one call. diff --git a/configurator/src/app/pages/ui/click_highlight.rs b/configurator/src/app/pages/ui/click_highlight.rs new file mode 100644 index 00000000..2089bc0a --- /dev/null +++ b/configurator/src/app/pages/ui/click_highlight.rs @@ -0,0 +1,75 @@ +use relm4::ComponentSender; + +use crate::messages::Message; +use crate::models::{ColorPickerId, TabId, TextField, ToggleField}; + +use super::super::super::state::ConfiguratorApp; +use super::super::color_rows::color_row; +use super::super::{BuiltPage, PageBuilder, validate_u32_range}; +use super::{quad_color, validate_f64_range}; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let mut page = PageBuilder::new(sender, TabId::Ui); + + page.group("Click Highlight") + .switch_row( + "Enable click highlight", + "", + |app| app.draft.click_highlight_enabled, + |value| Message::ToggleChanged(ToggleField::UiClickHighlightEnabled, value), + ) + .switch_row( + "Show ring while highlight tool is active", + "", + |app| app.draft.click_highlight_show_on_highlight_tool, + |value| Message::ToggleChanged(ToggleField::UiClickHighlightShowOnHighlightTool, value), + ) + .switch_row( + "Link highlight color to current pen", + "", + |app| app.draft.click_highlight_use_pen_color, + |value| Message::ToggleChanged(ToggleField::UiClickHighlightUsePenColor, value), + ) + .switch_row( + "Force on when entering light mode", + "", + |app| app.draft.click_highlight_force_in_light_mode, + |value| Message::ToggleChanged(ToggleField::UiClickHighlightForceInLightMode, value), + ); + + page.group("Ring") + .entry_row_validated( + "Radius", + |app| app.draft.click_highlight_radius.clone(), + |value| Message::TextChanged(TextField::HighlightRadius, value), + |app| validate_f64_range(&app.draft.click_highlight_radius, 16.0, 160.0), + ) + .entry_row_validated( + "Outline thickness", + |app| app.draft.click_highlight_outline_thickness.clone(), + |value| Message::TextChanged(TextField::HighlightOutlineThickness, value), + |app| validate_f64_range(&app.draft.click_highlight_outline_thickness, 1.0, 12.0), + ) + .entry_row_validated( + "Duration (ms)", + |app| app.draft.click_highlight_duration_ms.clone(), + |value| Message::TextChanged(TextField::HighlightDurationMs, value), + |app| validate_u32_range(&app.draft.click_highlight_duration_ms, 150, 1500), + ); + + page.group("Colors"); + color_row( + &mut page, + "Fill (hex)", + ColorPickerId::HighlightFill, + |app| quad_color(&app.draft.click_highlight_fill_color.components), + ); + color_row( + &mut page, + "Outline (hex)", + ColorPickerId::HighlightOutline, + |app| quad_color(&app.draft.click_highlight_outline_color.components), + ); + + page.finish() +} diff --git a/configurator/src/app/pages/ui/general.rs b/configurator/src/app/pages/ui/general.rs new file mode 100644 index 00000000..5517ec5c --- /dev/null +++ b/configurator/src/app/pages/ui/general.rs @@ -0,0 +1,69 @@ +use relm4::ComponentSender; + +use crate::messages::Message; +use crate::models::{ReducedMotionOption, TabId, TextField, ToggleField, UiThemeOption}; + +use super::super::super::search::SearchArea; +use super::super::super::state::ConfiguratorApp; +use super::super::{BuiltPage, PageBuilder}; +use super::options; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let (themes, theme_labels) = options(UiThemeOption::list(), |value| value.label()); + let (motions, motion_labels) = options(ReducedMotionOption::list(), |value| value.label()); + + let mut page = PageBuilder::new(sender, TabId::Ui); + page.group_in_area("General UI", SearchArea::UiGeneral) + .combo_row( + "Theme", + "\"Auto\" currently uses the dark theme; \"Light\" takes effect as overlay surfaces adopt the runtime theme.", + themes, + theme_labels, + |app| app.draft.ui_theme, + Message::UiThemeChanged, + ) + .combo_row( + "Reduced motion", + "\"On\" disables UI animations. \"Auto\" follows the system preference in a future release and keeps full motion for now.", + motions, + motion_labels, + |app| app.draft.ui_reduced_motion, + Message::UiReducedMotionChanged, + ) + .entry_row( + "Preferred output (GNOME fallback)", + |app| app.draft.ui_preferred_output.clone(), + |value| Message::TextChanged(TextField::UiPreferredOutput, value), + ) + .switch_row( + "Use fullscreen xdg fallback", + "Applies to the GNOME xdg-shell fallback overlay.", + |app| app.draft.ui_xdg_fullscreen, + |value| Message::ToggleChanged(ToggleField::UiXdgFullscreen, value), + ) + .switch_row( + "Keep open on xdg focus loss", + "", + |app| app.draft.ui_xdg_keep_on_focus_loss, + |value| Message::ToggleChanged(ToggleField::UiXdgKeepOnFocusLoss, value), + ) + .switch_row( + "Enable context menu", + "", + |app| app.draft.ui_context_menu_enabled, + |value| Message::ToggleChanged(ToggleField::UiContextMenuEnabled, value), + ) + .switch_row( + "Show capabilities warning toast", + "", + |app| app.draft.ui_show_capabilities_warning, + |value| Message::ToggleChanged(ToggleField::UiShowCapabilitiesWarning, value), + ) + .entry_row( + "Command palette toast (ms)", + |app| app.draft.ui_command_palette_toast_duration_ms.clone(), + |value| Message::TextChanged(TextField::UiCommandPaletteToastDurationMs, value), + ); + + page.finish() +} diff --git a/configurator/src/app/pages/ui/help_overlay.rs b/configurator/src/app/pages/ui/help_overlay.rs new file mode 100644 index 00000000..7cafaca6 --- /dev/null +++ b/configurator/src/app/pages/ui/help_overlay.rs @@ -0,0 +1,64 @@ +use relm4::ComponentSender; + +use crate::messages::Message; +use crate::models::{ColorPickerId, TabId, TextField, ToggleField}; + +use super::super::super::state::ConfiguratorApp; +use super::super::color_rows::color_row; +use super::super::{BuiltPage, PageBuilder}; +use super::quad_color; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let mut page = PageBuilder::new(sender, TabId::Ui); + + page.group("Help Overlay").switch_row( + "Filter sections by enabled features", + "", + |app| app.draft.help_context_filter, + |value| Message::ToggleChanged(ToggleField::UiHelpOverlayContextFilter, value), + ); + + page.group("Help Overlay Style"); + color_row( + &mut page, + "Background (hex)", + ColorPickerId::HelpBg, + |app| quad_color(&app.draft.help_bg_color.components), + ); + color_row( + &mut page, + "Border (hex)", + ColorPickerId::HelpBorder, + |app| quad_color(&app.draft.help_border_color.components), + ); + color_row(&mut page, "Text (hex)", ColorPickerId::HelpText, |app| { + quad_color(&app.draft.help_text_color.components) + }); + page.entry_row( + "Font family", + |app| app.draft.help_font_family.clone(), + |value| Message::TextChanged(TextField::HelpFontFamily, value), + ) + .entry_row( + "Font size", + |app| app.draft.help_font_size.clone(), + |value| Message::TextChanged(TextField::HelpFontSize, value), + ) + .entry_row( + "Line height", + |app| app.draft.help_line_height.clone(), + |value| Message::TextChanged(TextField::HelpLineHeight, value), + ) + .entry_row( + "Padding", + |app| app.draft.help_padding.clone(), + |value| Message::TextChanged(TextField::HelpPadding, value), + ) + .entry_row( + "Border width", + |app| app.draft.help_border_width.clone(), + |value| Message::TextChanged(TextField::HelpBorderWidth, value), + ); + + page.finish() +} diff --git a/configurator/src/app/pages/ui/input_hud.rs b/configurator/src/app/pages/ui/input_hud.rs new file mode 100644 index 00000000..9f06ce4d --- /dev/null +++ b/configurator/src/app/pages/ui/input_hud.rs @@ -0,0 +1,89 @@ +use relm4::ComponentSender; + +use crate::messages::Message; +use crate::models::{InputHudModeOption, InputHudPositionOption, TabId, TextField, ToggleField}; + +use super::super::super::state::ConfiguratorApp; +use super::super::{BuiltPage, PageBuilder, validate_u32_range}; +use super::{note, options, validate_f64_range}; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let (modes, mode_labels) = options(InputHudModeOption::list(), |value| value.label()); + let (positions, position_labels) = + options(InputHudPositionOption::list(), |value| value.label()); + + let mut page = PageBuilder::new(sender, TabId::Ui); + + page.group("Input HUD") + .custom(¬e( + "Show a live row of keystroke and click chips for demos and screencasts.", + )) + .switch_row( + "Enable input HUD", + "", + |app| app.draft.input_hud_enabled, + |value| Message::ToggleChanged(ToggleField::UiInputHudEnabled, value), + ) + .combo_row( + "Input source", + "\"Overlay only\" shows what Wayscriber itself receives. \"System-wide\" also shows input that goes to other apps; it needs a build with the input-monitor feature and read access to /dev/input (usually `input` group membership), and it sees every keystroke on the seat - including passwords typed elsewhere.", + modes, + mode_labels, + |app| app.draft.input_hud_mode, + Message::InputHudModeChanged, + ) + .combo_row( + "Screen position", + "", + positions, + position_labels, + |app| app.draft.input_hud_position, + Message::InputHudPositionChanged, + ) + .switch_row( + "Show mouse buttons and scroll", + "", + |app| app.draft.input_hud_show_mouse, + |value| Message::ToggleChanged(ToggleField::UiInputHudShowMouse, value), + ) + .switch_row( + "Show bare modifier taps", + "", + |app| app.draft.input_hud_show_bare_modifiers, + |value| Message::ToggleChanged(ToggleField::UiInputHudShowBareModifiers, value), + ) + .switch_row( + "Combine repeats into a counter", + "", + |app| app.draft.input_hud_combine_repeats, + |value| Message::ToggleChanged(ToggleField::UiInputHudCombineRepeats, value), + ); + + page.group("Chips") + .entry_row_validated( + "Hold (ms)", + |app| app.draft.input_hud_display_ms.clone(), + |value| Message::TextChanged(TextField::InputHudDisplayMs, value), + |app| validate_u32_range(&app.draft.input_hud_display_ms, 200, 30_000), + ) + .entry_row_validated( + "Fade (ms)", + |app| app.draft.input_hud_fade_ms.clone(), + |value| Message::TextChanged(TextField::InputHudFadeMs, value), + |app| validate_u32_range(&app.draft.input_hud_fade_ms, 0, 5_000), + ) + .entry_row_validated( + "Max chips", + |app| app.draft.input_hud_max_entries.clone(), + |value| Message::TextChanged(TextField::InputHudMaxEntries, value), + |app| validate_u32_range(&app.draft.input_hud_max_entries, 1, 16), + ) + .entry_row_validated( + "Font size", + |app| app.draft.input_hud_font_size.clone(), + |value| Message::TextChanged(TextField::InputHudFontSize, value), + |app| validate_f64_range(&app.draft.input_hud_font_size, 6.0, 72.0), + ); + + page.finish() +} diff --git a/configurator/src/app/pages/ui/presenter_mode.rs b/configurator/src/app/pages/ui/presenter_mode.rs new file mode 100644 index 00000000..dd22f98f --- /dev/null +++ b/configurator/src/app/pages/ui/presenter_mode.rs @@ -0,0 +1,80 @@ +use relm4::ComponentSender; + +use crate::messages::Message; +use crate::models::{PresenterToolBehaviorOption, PresenterToolbarModeOption, TabId, ToggleField}; + +use super::super::super::state::ConfiguratorApp; +use super::super::{BuiltPage, PageBuilder}; +use super::{note, options}; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let (toolbar_modes, toolbar_mode_labels) = + options(PresenterToolbarModeOption::list(), |value| value.label()); + let (behaviors, behavior_labels) = + options(PresenterToolBehaviorOption::list(), |value| value.label()); + + let mut page = PageBuilder::new(sender, TabId::Ui); + + page.group("Presenter Mode") + .custom(¬e("Customize what presenter mode changes when toggled.")) + .switch_row( + "Hide status bar", + "", + |app| app.draft.presenter_hide_status_bar, + |value| Message::ToggleChanged(ToggleField::PresenterHideStatusBar, value), + ) + .switch_row( + "Hide toolbars", + "", + |app| app.draft.presenter_hide_toolbars, + |value| Message::ToggleChanged(ToggleField::PresenterHideToolbars, value), + ) + .combo_row( + "Top toolbar while presenting", + "", + toolbar_modes, + toolbar_mode_labels, + |app| app.draft.presenter_toolbar_mode, + Message::PresenterToolbarModeChanged, + ) + .switch_row( + "Hide tool preview", + "", + |app| app.draft.presenter_hide_tool_preview, + |value| Message::ToggleChanged(ToggleField::PresenterHideToolPreview, value), + ) + .switch_row( + "Close help overlay on entry", + "", + |app| app.draft.presenter_close_help_overlay, + |value| Message::ToggleChanged(ToggleField::PresenterCloseHelpOverlay, value), + ) + .switch_row( + "Force click highlights on", + "", + |app| app.draft.presenter_enable_click_highlight, + |value| Message::ToggleChanged(ToggleField::PresenterEnableClickHighlight, value), + ) + .switch_row( + "Force input HUD on", + "", + |app| app.draft.presenter_enable_input_hud, + |value| Message::ToggleChanged(ToggleField::PresenterEnableInputHud, value), + ) + .combo_row( + "Tool behavior", + "", + behaviors, + behavior_labels, + |app| app.draft.presenter_tool_behavior, + Message::PresenterToolBehaviorChanged, + ) + .switch_row( + "Show enter/exit toast", + "", + |app| app.draft.presenter_show_toast, + |value| Message::ToggleChanged(ToggleField::PresenterShowToast, value), + ); + + page.finish() +} diff --git a/configurator/src/app/pages/ui/status_bar.rs b/configurator/src/app/pages/ui/status_bar.rs new file mode 100644 index 00000000..1d76dc5f --- /dev/null +++ b/configurator/src/app/pages/ui/status_bar.rs @@ -0,0 +1,156 @@ +use relm4::ComponentSender; + +use crate::messages::Message; +use crate::models::{ColorPickerId, StatusPositionOption, TabId, TextField, ToggleField}; + +use super::super::super::state::ConfiguratorApp; +use super::super::color_rows::color_row; +use super::super::{BuiltPage, PageBuilder}; +use super::{options, quad_color}; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let (positions, position_labels) = options(StatusPositionOption::list(), |value| value.label()); + + let mut page = PageBuilder::new(sender, TabId::Ui); + + page.group("Status Bar") + .switch_row( + "Show status bar", + "", + |app| app.draft.ui_show_status_bar, + |value| Message::ToggleChanged(ToggleField::UiShowStatusBar, value), + ) + .switch_row( + "Clickable status bar segments", + "", + |app| app.draft.ui_status_bar_interactive, + |value| Message::ToggleChanged(ToggleField::UiStatusBarInteractive, value), + ); + + page.group("Contents") + .switch_row( + "Show active output", + "", + |app| app.draft.ui_active_output_badge, + |value| Message::ToggleChanged(ToggleField::UiActiveOutputBadge, value), + ) + .switch_row( + "Show selection dimensions", + "", + |app| app.draft.ui_show_status_selection_info, + |value| Message::ToggleChanged(ToggleField::UiShowStatusSelectionInfo, value), + ) + .switch_row( + "Show board label", + "", + |app| app.draft.ui_show_status_board_badge, + |value| Message::ToggleChanged(ToggleField::UiShowStatusBoardBadge, value), + ) + .switch_row( + "Show page counter", + "", + |app| app.draft.ui_show_status_page_badge, + |value| Message::ToggleChanged(ToggleField::UiShowStatusPageBadge, value), + ) + .switch_row( + "Show current color", + "", + |app| app.draft.ui_show_status_color, + |value| Message::ToggleChanged(ToggleField::UiShowStatusColor, value), + ) + .switch_row( + "Show active tool", + "", + |app| app.draft.ui_show_status_tool, + |value| Message::ToggleChanged(ToggleField::UiShowStatusTool, value), + ) + .switch_row( + "Show tool size", + "", + |app| app.draft.ui_show_status_size, + |value| Message::ToggleChanged(ToggleField::UiShowStatusSize, value), + ) + .switch_row( + "Show context indicators", + "", + |app| app.draft.ui_show_status_context_indicators, + |value| Message::ToggleChanged(ToggleField::UiShowStatusContextIndicators, value), + ) + .switch_row( + "Show toolbar hint while toolbars are hidden", + "", + |app| app.draft.ui_show_toolbar_hint, + |value| Message::ToggleChanged(ToggleField::UiShowToolbarHint, value), + ) + .switch_row( + "Show Help shortcut", + "", + |app| app.draft.ui_show_status_help, + |value| Message::ToggleChanged(ToggleField::UiShowStatusHelp, value), + ) + .switch_row( + "Show About and version", + "", + |app| app.draft.ui_show_status_about, + |value| Message::ToggleChanged(ToggleField::UiShowStatusAbout, value), + ); + + page.group("Additional Badges") + .switch_row( + "Show board/page badge", + "", + |app| app.draft.ui_show_floating_badge, + |value| Message::ToggleChanged(ToggleField::UiShowFloatingBadge, value), + ) + .switch_row( + "Also show badge with status bar", + "", + |app| app.draft.ui_show_page_badge_with_status_bar, + |value| Message::ToggleChanged(ToggleField::UiShowPageBadgeWithStatusBar, value), + ) + .switch_row( + "Show frozen badge", + "", + |app| app.draft.ui_show_frozen_badge, + |value| Message::ToggleChanged(ToggleField::UiShowFrozenBadge, value), + ) + .combo_row( + "Status bar position", + "", + positions, + position_labels, + |app| app.draft.ui_status_position, + Message::StatusPositionChanged, + ); + + page.group("Status Bar Style"); + color_row( + &mut page, + "Background (hex)", + ColorPickerId::StatusBarBg, + |app| quad_color(&app.draft.status_bar_bg_color.components), + ); + color_row( + &mut page, + "Text (hex)", + ColorPickerId::StatusBarText, + |app| quad_color(&app.draft.status_bar_text_color.components), + ); + page.entry_row( + "Font size", + |app| app.draft.status_font_size.clone(), + |value| Message::TextChanged(TextField::StatusFontSize, value), + ) + .entry_row( + "Padding", + |app| app.draft.status_padding.clone(), + |value| Message::TextChanged(TextField::StatusPadding, value), + ) + .entry_row( + "Dot radius", + |app| app.draft.status_dot_radius.clone(), + |value| Message::TextChanged(TextField::StatusDotRadius, value), + ); + + page.finish() +} diff --git a/configurator/src/app/pages/ui/toolbar.rs b/configurator/src/app/pages/ui/toolbar.rs new file mode 100644 index 00000000..c2232a30 --- /dev/null +++ b/configurator/src/app/pages/ui/toolbar.rs @@ -0,0 +1,248 @@ +use relm4::ComponentSender; + +use crate::messages::Message; +use crate::models::{ + OverrideOption, TabId, TextField, ToggleField, ToolbarLayoutModeOption, ToolbarOverrideField, + ToolbarRebindModifierOption, ToolbarSideLayoutOption, ZoomChipDisplayOption, +}; + +use super::super::super::state::ConfiguratorApp; +use super::super::{BuiltPage, PageBuilder}; +use super::{note, options}; + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let (layout_modes, layout_labels) = + options(ToolbarLayoutModeOption::list(), |value| value.label()); + let (side_layouts, side_layout_labels) = + options(ToolbarSideLayoutOption::list(), |value| value.label()); + let (zoom_chips, zoom_chip_labels) = + options(ZoomChipDisplayOption::list(), |value| value.label()); + let (rebinds, rebind_labels) = options(ToolbarRebindModifierOption::ALL.to_vec(), |value| { + value.label() + }); + let (override_modes, override_mode_labels) = + options(ToolbarLayoutModeOption::list(), |value| value.label()); + + let mut page = PageBuilder::new(sender, TabId::Ui); + + page.group("Toolbar").custom(¬e( + "These settings are configured defaults. Toolbar pins, position, display form, item visibility/order, pane state, and board pins changed in the overlay are saved separately as runtime preferences.", + )); + + page.group("Layout") + .combo_row( + "Layout mode", + "", + layout_modes, + layout_labels, + |app| app.draft.ui_toolbar_layout_mode, + Message::ToolbarLayoutModeChanged, + ) + .combo_row( + "Side layout", + "Pill (the default) retires the side palette: drawing properties live in the top strip's style pill, canvas management in the status HUD and board picker, and Session/Settings in popovers on the top strip's overflow menu. Panel is the legacy escape hatch restoring the classic side palette; it is deprecated and planned for removal one release after the pill default.", + side_layouts, + side_layout_labels, + |app| app.draft.ui_toolbar_side_layout, + Message::ToolbarSideLayoutChanged, + ) + .combo_row( + "Zoom chip", + "", + zoom_chips, + zoom_chip_labels, + |app| app.draft.ui_toolbar_zoom_chip_display, + Message::ToolbarZoomChipDisplayChanged, + ) + .switch_row( + "Show zoom chip", + "", + |app| app.draft.ui_toolbar_show_zoom_chip, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowZoomChip, value), + ) + .combo_row( + "Shortcut edit click", + "", + rebinds, + rebind_labels, + |app| app.draft.ui_toolbar_rebind_modifier, + Message::ToolbarRebindModifierChanged, + ) + .switch_row( + "Configured default: pin top toolbar", + "", + |app| app.draft.ui_toolbar_top_pinned, + |value| Message::ToggleChanged(ToggleField::UiToolbarTopPinned, value), + ) + .switch_row( + "Configured default: pin side toolbar", + "", + |app| app.draft.ui_toolbar_side_pinned, + |value| Message::ToggleChanged(ToggleField::UiToolbarSidePinned, value), + ) + .switch_row( + "Use icon-only buttons", + "", + |app| app.draft.ui_toolbar_use_icons, + |value| Message::ToggleChanged(ToggleField::UiToolbarUseIcons, value), + ); + + page.group("Sections") + .switch_row( + "Show extended colors", + "", + |app| app.draft.ui_toolbar_show_more_colors, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowMoreColors, value), + ) + .switch_row( + "Show presets", + "", + |app| app.draft.ui_toolbar_show_presets, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowPresets, value), + ) + .switch_row( + "Show actions", + "", + |app| app.draft.ui_toolbar_show_actions_section, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowActionsSection, value), + ) + .switch_row( + "Show zoom actions", + "", + |app| app.draft.ui_toolbar_show_zoom_actions, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowZoomActions, value), + ) + .switch_row( + "Show advanced actions", + "", + |app| app.draft.ui_toolbar_show_actions_advanced, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowActionsAdvanced, value), + ) + .switch_row( + "Show pages section", + "", + |app| app.draft.ui_toolbar_show_pages_section, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowPagesSection, value), + ) + .switch_row( + "Show boards section", + "", + |app| app.draft.ui_toolbar_show_boards_section, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowBoardsSection, value), + ) + .switch_row( + "Show multi-step undo/redo", + "", + |app| app.draft.ui_toolbar_show_step_section, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowStepSection, value), + ) + .switch_row( + "Always show text controls", + "", + |app| app.draft.ui_toolbar_show_text_controls, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowTextControls, value), + ) + .switch_row( + "Show delay sliders", + "", + |app| app.draft.ui_toolbar_show_delay_sliders, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowDelaySliders, value), + ) + .switch_row( + "Show marker opacity controls", + "", + |app| app.draft.ui_toolbar_show_marker_opacity_section, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowMarkerOpacitySection, value), + ) + .switch_row( + "Show tool preview bubble", + "", + |app| app.draft.ui_toolbar_show_tool_preview, + |value| Message::ToggleChanged(ToggleField::UiToolbarShowToolPreview, value), + ) + .switch_row( + "Show preset action toasts", + "", + |app| app.draft.ui_toolbar_show_preset_toasts, + |value| Message::ToggleChanged(ToggleField::UiToolbarPresetToasts, value), + ) + .switch_row( + "Force inline toolbars", + "", + |app| app.draft.ui_toolbar_force_inline, + |value| Message::ToggleChanged(ToggleField::UiToolbarForceInline, value), + ); + + page.group("Mode overrides").combo_row( + "Edit mode", + "Overrides below apply to the mode selected here; \"Default\" keeps the mode preset.", + override_modes, + override_mode_labels, + |app| app.override_mode, + Message::ToolbarOverrideModeChanged, + ); + for field in [ + ToolbarOverrideField::ShowPresets, + ToolbarOverrideField::ShowActionsSection, + ToolbarOverrideField::ShowZoomActions, + ToolbarOverrideField::ShowActionsAdvanced, + ToolbarOverrideField::ShowPagesSection, + ToolbarOverrideField::ShowBoardsSection, + ToolbarOverrideField::ShowStepSection, + ToolbarOverrideField::ShowTextControls, + ] { + let (values, labels) = options(OverrideOption::list(), |value| value.label()); + page.combo_row( + field.label(), + "", + values, + labels, + move |app| toolbar_override(app, field), + move |value| Message::ToolbarOverrideChanged(field, value), + ); + } + + page.group("Placement offsets") + .entry_row( + "Top offset X (px)", + |app| app.draft.ui_toolbar_top_offset.clone(), + |value| Message::TextChanged(TextField::ToolbarTopOffset, value), + ) + .entry_row( + "Top offset Y (px)", + |app| app.draft.ui_toolbar_top_offset_y.clone(), + |value| Message::TextChanged(TextField::ToolbarTopOffsetY, value), + ) + .entry_row( + "Side offset Y (px)", + |app| app.draft.ui_toolbar_side_offset.clone(), + |value| Message::TextChanged(TextField::ToolbarSideOffset, value), + ) + .entry_row( + "Side offset X (px)", + |app| app.draft.ui_toolbar_side_offset_x.clone(), + |value| Message::TextChanged(TextField::ToolbarSideOffsetX, value), + ) + .custom(¬e( + "Configured defaults. Dragging a toolbar in the overlay saves that position as a runtime preference; editing a value here takes over from the saved drag.", + )); + + page.finish() +} + +fn toolbar_override(app: &ConfiguratorApp, field: ToolbarOverrideField) -> OverrideOption { + let overrides = app + .draft + .ui_toolbar_mode_overrides + .for_mode(app.override_mode); + match field { + ToolbarOverrideField::ShowPresets => overrides.show_presets, + ToolbarOverrideField::ShowActionsSection => overrides.show_actions_section, + ToolbarOverrideField::ShowActionsAdvanced => overrides.show_actions_advanced, + ToolbarOverrideField::ShowZoomActions => overrides.show_zoom_actions, + ToolbarOverrideField::ShowPagesSection => overrides.show_pages_section, + ToolbarOverrideField::ShowBoardsSection => overrides.show_boards_section, + ToolbarOverrideField::ShowStepSection => overrides.show_step_section, + ToolbarOverrideField::ShowTextControls => overrides.show_text_controls, + } +} diff --git a/configurator/src/app/pages/ui/toolbar_visibility.rs b/configurator/src/app/pages/ui/toolbar_visibility.rs new file mode 100644 index 00000000..d9e4c768 --- /dev/null +++ b/configurator/src/app/pages/ui/toolbar_visibility.rs @@ -0,0 +1,353 @@ +use relm4::{ComponentSender, adw, gtk}; + +use adw::prelude::*; +use gtk::glib; + +use wayscriber::config::{ + ResolvedToolbarItems, ToolbarItemCategory, ToolbarItemDefinition, ToolbarItemId, + ToolbarItemOrderGroup, ToolbarItemSurface, ToolbarItemsConfig, toolbar_item_definitions, + toolbar_item_ids, toolbar_item_order_group, +}; + +use crate::messages::Message; +use crate::models::TabId; + +use super::super::super::state::ConfiguratorApp; +use super::super::{BuiltPage, PageBuilder}; +use super::note; + +/// One preferences group of item rows: a surface/category batch, or one of +/// the three order groups the configurator can reorder. +struct ItemSection { + title: String, + order_group: Option, + definitions: Vec<&'static ToolbarItemDefinition>, +} + +struct ItemRow { + id: ToolbarItemId, + row: adw::SwitchRow, + /// Kept so the refresh can write the switch with the handler blocked. + /// `ToolbarItemVisibilityChanged` is not a plain setter: it pins an + /// explicit visibility entry for section ids, so a refresh reporting the + /// resolved value back would add entries to a config nobody edited — and + /// the first refresh, which lifts every visible row off its built-in + /// `false`, would do it to every section on startup. + handler: glib::SignalHandlerId, + move_buttons: Option<(gtk::Button, gtk::Button)>, +} + +/// The widgets one [`ItemSection`] refreshes, kept so a single binding can +/// resolve the item config once for the whole page. +struct SectionWidgets { + order_group: Option, + built_in_order: Vec, + list: gtk::ListBox, + reset: Option, + rows: Vec, +} + +pub(super) fn build(sender: &ComponentSender) -> BuiltPage { + let built_in = ToolbarItemsConfig::default().resolved(); + let mut page = PageBuilder::new(sender, TabId::Ui); + + // Shown by the binding only while the config carries ids this build does + // not know. + let unknown_notice = note(""); + unknown_notice.set_visible(false); + page.group("Toolbar Visibility") + .custom(¬e( + "These are configured visibility defaults. Overlay customizations are stored separately as runtime preferences. Enabled items are shown; section toggles and mode overrides can still hide them.", + )) + .custom(&unknown_notice); + + let mut sections: Vec = Vec::new(); + for section in item_sections(&built_in) { + let list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + + let mut rows: Vec = Vec::new(); + for definition in §ion.definitions { + let id = definition.id; + let row = adw::SwitchRow::builder() + .title(definition.label) + .subtitle(format!( + "{} - built-in default: {}", + id.as_str(), + visibility_label(!built_in.is_hidden(id)) + )) + .build(); + let handler = { + let sender = page.sender(); + row.connect_active_notify(move |row| { + sender.input(Message::ToolbarItemVisibilityChanged(id, row.is_active())); + }) + }; + + let mut move_buttons = None; + if let Some(group) = section.order_group { + let up = move_button(&page.sender(), group, id, -1); + let down = move_button(&page.sender(), group, id, 1); + row.add_suffix(&up); + row.add_suffix(&down); + move_buttons = Some((up, down)); + } + + list.append(&row); + rows.push(ItemRow { + id, + row, + handler, + move_buttons, + }); + } + + page.group(§ion.title).custom(&list); + + let mut reset = None; + if let Some(group) = section.order_group { + let button = gtk::Button::builder() + .label("Restore built-in order") + .halign(gtk::Align::End) + .margin_top(6) + .build(); + { + let sender = page.sender(); + button.connect_clicked(move |_| { + sender.input(Message::ToolbarItemOrderReset(group)); + }); + } + page.custom(&button); + reset = Some(button); + } + + sections.push(SectionWidgets { + order_group: section.order_group, + built_in_order: section + .order_group + .map(|group| built_in.order.ordered_ids(group).to_vec()) + .unwrap_or_default(), + list, + reset, + rows, + }); + } + + // One binding for the whole page: resolving the item config allocates, + // and doing it per row would repeat that work a hundred times a refresh. + page.bind(move |app, _summary| { + let resolved = app.draft.ui_toolbar_items.resolved(); + + let unknown = resolved.unknown_hidden.len() + resolved.unknown_shown.len(); + let notice_text = if unknown > 0 { + format!("Preserving {unknown} unknown toolbar item id(s) from config.") + } else { + String::new() + }; + if unknown_notice.text() != notice_text { + unknown_notice.set_text(¬ice_text); + } + if unknown_notice.is_visible() != (unknown > 0) { + unknown_notice.set_visible(unknown > 0); + } + + for section in §ions { + for item in §ion.rows { + let visible = !resolved.is_hidden(item.id); + if item.row.is_active() != visible { + item.row.block_signal(&item.handler); + item.row.set_active(visible); + item.row.unblock_signal(&item.handler); + } + + let (Some(group), Some((up, down))) = (section.order_group, &item.move_buttons) + else { + continue; + }; + let index = resolved.order.index_of(group, item.id); + let length = resolved.order.ordered_ids(group).len(); + let can_move_up = index.is_some_and(|index| index > 0); + let can_move_down = index.is_some_and(|index| index + 1 < length); + if up.is_sensitive() != can_move_up { + up.set_sensitive(can_move_up); + } + if down.is_sensitive() != can_move_down { + down.set_sensitive(can_move_down); + } + } + + let Some(group) = section.order_group else { + continue; + }; + let ordered = resolved.order.ordered_ids(group); + let desired: Vec = ordered + .iter() + .copied() + .filter(|id| section.rows.iter().any(|item| item.id == *id)) + .collect(); + if current_row_order(section) != desired { + for item in §ion.rows { + section.list.remove(&item.row); + } + for id in &desired { + if let Some(item) = section.rows.iter().find(|item| item.id == *id) { + section.list.append(&item.row); + } + } + } + + if let Some(reset) = §ion.reset { + // The Iced view only offered the restore action once the + // order left the built-in one; insensitive keeps the button + // in place instead of making the section jump. + let restorable = ordered != section.built_in_order; + if reset.is_sensitive() != restorable { + reset.set_sensitive(restorable); + } + } + } + }); + + page.finish() +} + +/// Item rows grouped the way the Iced list read: by toolbar surface and +/// category, with each reorderable order group in a section of its own so it +/// can carry the move buttons and its restore action. +fn item_sections(built_in: &ResolvedToolbarItems) -> Vec { + let mut sections: Vec = Vec::new(); + for definition in toolbar_item_definitions() { + if definition.id == toolbar_item_ids::SIDE_GROUP_SETTINGS + || definition.id == toolbar_item_ids::TOP_CHROME_OVERFLOW + { + continue; + } + + let order_group = configurator_order_group(definition); + let title = match order_group { + Some((_, label)) => format!( + "{}: {} (reorderable)", + surface_label(definition.surface), + label + ), + None => format!( + "{}: {}", + surface_label(definition.surface), + category_label(definition.category) + ), + }; + + match sections.iter_mut().find(|section| section.title == title) { + Some(section) => section.definitions.push(definition), + None => sections.push(ItemSection { + title, + order_group: order_group.map(|(group, _)| group), + definitions: vec![definition], + }), + } + } + + // Reorderable sections start in the built-in order; the binding puts them + // in the configured order on the first refresh. + for section in &mut sections { + let Some(group) = section.order_group else { + continue; + }; + let order = built_in.order.ordered_ids(group); + section.definitions.sort_by_key(|definition| { + order + .iter() + .position(|id| *id == definition.id) + .unwrap_or(usize::MAX) + }); + } + + sections +} + +/// The order groups this page can reorder, with their section label. The +/// remaining groups keep the order the config resolves them in. +fn configurator_order_group( + definition: &ToolbarItemDefinition, +) -> Option<(ToolbarItemOrderGroup, &'static str)> { + match toolbar_item_order_group(definition)? { + ToolbarItemOrderGroup::TopTools => Some((ToolbarItemOrderGroup::TopTools, "Tools")), + ToolbarItemOrderGroup::TopControls => { + Some((ToolbarItemOrderGroup::TopControls, "Controls")) + } + ToolbarItemOrderGroup::SideSections => { + Some((ToolbarItemOrderGroup::SideSections, "Sections")) + } + _ => None, + } +} + +/// The ids currently laid out in a section, read back from the list itself so +/// the reorder pass needs no state of its own. +fn current_row_order(section: &SectionWidgets) -> Vec { + let mut order = Vec::with_capacity(section.rows.len()); + let mut child = section.list.first_child(); + while let Some(widget) = child { + if let Some(item) = section + .rows + .iter() + .find(|item| item.row.upcast_ref::() == &widget) + { + order.push(item.id); + } + child = widget.next_sibling(); + } + order +} + +fn move_button( + sender: &ComponentSender, + group: ToolbarItemOrderGroup, + id: ToolbarItemId, + delta: isize, +) -> gtk::Button { + let (icon, tooltip) = if delta < 0 { + ("go-up-symbolic", "Move up") + } else { + ("go-down-symbolic", "Move down") + }; + let button = gtk::Button::builder() + .icon_name(icon) + .tooltip_text(tooltip) + .valign(gtk::Align::Center) + .css_classes(["flat"]) + .build(); + let sender = sender.clone(); + button.connect_clicked(move |_| { + sender.input(Message::ToolbarItemMoveRequested(group, id, delta)); + }); + button +} + +fn visibility_label(visible: bool) -> &'static str { + if visible { "shown" } else { "hidden" } +} + +fn surface_label(surface: ToolbarItemSurface) -> &'static str { + match surface { + ToolbarItemSurface::Top => "Top toolbar", + ToolbarItemSurface::Side => "Side toolbar", + } +} + +fn category_label(category: ToolbarItemCategory) -> &'static str { + match category { + ToolbarItemCategory::Chrome => "Toolbar controls", + ToolbarItemCategory::Tool => "Tools", + ToolbarItemCategory::Utility => "Utilities", + ToolbarItemCategory::Group => "Sections", + ToolbarItemCategory::Action => "Actions", + ToolbarItemCategory::Page => "Pages", + ToolbarItemCategory::Board => "Boards", + ToolbarItemCategory::Setting => "Settings", + ToolbarItemCategory::Session => "Sessions", + ToolbarItemCategory::ToolOption => "Tool options", + } +} diff --git a/configurator/src/app/state.rs b/configurator/src/app/state.rs index 3c40b175..8e7445d3 100644 --- a/configurator/src/app/state.rs +++ b/configurator/src/app/state.rs @@ -35,7 +35,12 @@ pub(crate) struct ConfiguratorApp { pub(crate) is_loading: bool, pub(crate) is_saving: bool, pub(crate) is_dirty: bool, - pub(crate) defaults_reset_pending: bool, + /// The destructive question the user can currently answer. + /// + /// One typed identity owns both confirmation surfaces so opening one + /// replaces the other instead of leaving two independently armed actions + /// on screen. + pub(crate) pending_confirmation: Option, /// What an accepted migration would change in the loaded configuration. /// Held here rather than in `status` so an expired or replaced status /// message cannot take the offer away with it. @@ -77,6 +82,21 @@ pub(crate) enum ConfirmationPrompt { SessionClear, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PendingConfirmation { + DefaultsReset, + SessionClear(String), +} + +impl PendingConfirmation { + pub(crate) fn prompt(&self) -> ConfirmationPrompt { + match self { + PendingConfirmation::DefaultsReset => ConfirmationPrompt::DefaultsReset, + PendingConfirmation::SessionClear(_) => ConfirmationPrompt::SessionClear, + } + } +} + impl ConfirmationPrompt { pub(crate) fn message(self) -> &'static str { match self { @@ -194,7 +214,7 @@ impl ConfiguratorApp { is_loading: true, is_saving: false, is_dirty: false, - defaults_reset_pending: false, + pending_confirmation: None, migration_preview: None, migration_dismissed: None, pending_save_validation: ConfigValidationReport::default(), @@ -227,10 +247,36 @@ impl ConfiguratorApp { } pub(super) fn refresh_dirty_flag(&mut self) { - self.defaults_reset_pending = false; + self.clear_defaults_confirmation(); self.is_dirty = self.draft != self.baseline; } + pub(crate) fn defaults_reset_pending(&self) -> bool { + matches!( + self.pending_confirmation, + Some(PendingConfirmation::DefaultsReset) + ) + } + + pub(crate) fn pending_session_clear_id(&self) -> Option<&str> { + match self.pending_confirmation.as_ref() { + Some(PendingConfirmation::SessionClear(id)) => Some(id.as_str()), + Some(PendingConfirmation::DefaultsReset) | None => None, + } + } + + pub(super) fn clear_defaults_confirmation(&mut self) { + if self.defaults_reset_pending() { + self.pending_confirmation = None; + } + } + + pub(super) fn clear_session_confirmation(&mut self) { + if self.pending_session_clear_id().is_some() { + self.pending_confirmation = None; + } + } + /// The migration offer to show, if there is one to show. /// /// Dismissing hides the offer for the rest of this app run, including diff --git a/configurator/src/app/update/config.rs b/configurator/src/app/update/config.rs index 82c62a83..a5056a53 100644 --- a/configurator/src/app/update/config.rs +++ b/configurator/src/app/update/config.rs @@ -1,1819 +1,9 @@ -use wayscriber::config::{ - Config, ConfigDiagnosticKind, ConfigDocument, ConfigValidationReport, InvalidKeybinding, - KeybindingConflictResolution, MigrationPreview, -}; - -use crate::messages::ConfigSaveResult; -use crate::models::error::FormError; -use crate::models::{ConfigDraft, KeybindingField}; - -use super::super::effects::Effect; -use super::super::state::{ConfiguratorApp, ConfirmationPrompt, StatusMessage}; - -impl ConfiguratorApp { - pub(super) fn handle_config_loaded( - &mut self, - result: Result<(Box, Option), String>, - ) -> Vec { - self.is_loading = false; - match result { - Ok((document, repair_warning)) => { - let draft = ConfigDraft::from_config(document.config()); - self.draft = draft.clone(); - self.baseline = draft; - self.override_mode = self.draft.ui_toolbar_layout_mode; - self.boards_collapsed = vec![false; self.draft.boards.items.len()]; - self.color_picker_hex.clear(); - self.sync_all_color_picker_hex(); - self.is_dirty = false; - self.defaults_reset_pending = false; - self.refresh_migration_preview(&document); - self.status = repair_warning.map_or_else( - || config_document_status(&document, "Configuration loaded from disk."), - |warning| { - StatusMessage::warning(format!( - "The configuration could not be parsed, so built-in defaults were loaded for repair. Saving will create a backup before replacing the unreadable configuration with this draft. Unknown settings are retained only when the TOML structure is parseable and they can be separated safely; malformed TOML content remains only in the backup.\n{warning}" - )) - }, - ); - // Last, so everything above reads the document by reference and - // the model takes ownership of exactly one copy. - self.base_document = Some(*document); - } - Err(err) => { - self.status = - StatusMessage::error(format!("Failed to load config from disk: {err}")); - } - } - - // After the status is set, so a note about a startup argument can be - // added to this file's diagnostics instead of replacing them. This is - // also the only place a destination is applied: the tabs it chooses - // are only meaningful once the configuration behind them has loaded. - self.apply_startup_request() - } - - pub(super) fn handle_reload_requested(&mut self) -> Vec { - if !self.is_loading && !self.is_saving { - self.is_loading = true; - self.defaults_reset_pending = false; - self.status = StatusMessage::info("Reloading configuration..."); - return vec![Effect::LoadConfig]; - } - - Vec::new() - } - - /// Arms the confirmation, and only that. - /// - /// Asking is one message and answering is another, so no amount of - /// pressing the control that asks can replace the draft: a repeat while - /// the confirmation already stands is nothing, which is what makes a - /// double-click on "Defaults" harmless. Any other edit disarms it through - /// `refresh_dirty_flag`, which is the same standing-down the Cancel - /// control asks for explicitly. - pub(super) fn handle_reset_to_defaults_requested(&mut self) -> Vec { - if self.is_loading || self.is_saving || self.defaults_reset_pending { - return Vec::new(); - } - - self.defaults_reset_pending = true; - self.status = StatusMessage::confirmation(ConfirmationPrompt::DefaultsReset); - Vec::new() - } - - /// Applies the defaults, and only while the confirmation this answers is - /// still armed. - /// - /// The pending flag is the whole guard: every transition that could have - /// invalidated the question — a load, a save, a reload, an edit to the - /// draft the user is about to lose — clears it, so a confirmation that - /// outlived its question answers nothing. - pub(super) fn handle_reset_to_defaults_confirmed(&mut self) -> Vec { - if !self.defaults_reset_pending { - return Vec::new(); - } - - self.draft = self.defaults.clone(); - self.override_mode = self.draft.ui_toolbar_layout_mode; - self.boards_collapsed = vec![false; self.draft.boards.items.len()]; - self.color_picker_hex.clear(); - self.sync_all_color_picker_hex(); - self.defaults_reset_pending = false; - self.status = StatusMessage::info("Loaded default configuration (not saved)."); - self.refresh_dirty_flag(); - Vec::new() - } - - /// Stands the confirmation down, and takes its hint off the status line - /// only while that hint is what the line still holds. - /// - /// Guarded on the same flag as the confirm: with nothing armed there is - /// no question to withdraw, so a stray cancel must not wipe a status the - /// user is reading. Disarming and clearing are separate because another - /// operation may have replaced the hint with newer feedback while the - /// question remained open. - pub(super) fn handle_reset_to_defaults_canceled(&mut self) -> Vec { - if !self.defaults_reset_pending { - return Vec::new(); - } - - self.defaults_reset_pending = false; - if self - .status - .is_confirmation(ConfirmationPrompt::DefaultsReset) - { - self.status = StatusMessage::idle(); - } - Vec::new() - } - - pub(super) fn handle_save_requested(&mut self) -> Vec { - // `is_loading` counts too: a reload replaces the draft and base - // document when it lands, so a save started underneath it would write - // the pre-reload draft and then be judged against a document it never - // saw — leaving stale fields marked clean and the next save rejected. - if self.is_saving || self.is_loading { - return Vec::new(); - } - self.defaults_reset_pending = false; - - // Before the document moves anywhere: a hex field the parser rejects - // was never applied to the draft, so saving would write the last value - // that did parse and the reload would wipe the text the user is still - // fixing. - let invalid_hex = self.invalid_color_hex_count(); - if invalid_hex > 0 { - self.status = StatusMessage::error(invalid_color_hex_message(invalid_hex)); - return Vec::new(); - } - - // The write needs the document itself, so the model gives up its only - // copy here and gets one back from `handle_config_saved` either way. - // Taking it is also the "nothing loaded" check: there is one `Option` - // to read, and reading it is what moves the value. - let Some(document) = self.base_document.take() else { - self.status = StatusMessage::error( - "Configuration has not loaded successfully. Reload before saving.", - ); - return Vec::new(); - }; - - match self.prepare_config_to_save(&document) { - Ok(config) => { - self.is_saving = true; - self.status = StatusMessage::info("Saving configuration..."); - vec![Effect::SaveConfig { - document: Box::new(document), - config: Box::new(config), - }] - } - Err(errors) => { - // No write starts, so the document goes straight back: this - // handler must not be a way to lose it. - self.base_document = Some(document); - let message = errors - .into_iter() - .map(|err| format!("{}: {}", err.field, err.message)) - .collect::>() - .join("\n"); - self.status = StatusMessage::error(format!( - "Cannot save due to validation errors:\n{message}" - )); - Vec::new() - } - } - } - - /// The configuration a Save writes, with what validating it had to change - /// in `[keybindings]` kept for the status the completed write reports. - /// - /// The draft rebuilds that section from the editor's own fields, so every - /// list in it is authored and a duplicate the user typed is arbitrated by - /// traversal order rather than filtered as an unauthored default. The - /// arbitration edits the configuration on its way to disk, and the saved - /// file then spells both lists out — leaving nothing for the reloaded - /// document to rediscover — so this is the only place the loss can be seen. - fn prepare_config_to_save( - &mut self, - document: &ConfigDocument, - ) -> Result> { - let mut config = self.draft.to_config(document.config())?; - self.pending_save_validation = config.validate_and_clamp(); - Ok(config) - } - - pub(super) fn handle_config_saved(&mut self, result: ConfigSaveResult) -> Vec { - self.is_saving = false; - // Either outcome answers this write; a failed one wrote nothing, so - // there is no resolution to report for it. - let validation = std::mem::take(&mut self.pending_save_validation); - match result { - Ok((backup, saved_document)) => { - let draft = ConfigDraft::from_config(saved_document.config()); - self.last_backup_path = backup.clone(); - self.draft = draft.clone(); - self.baseline = draft; - self.boards_collapsed = vec![false; self.draft.boards.items.len()]; - self.color_picker_hex.clear(); - self.sync_all_color_picker_hex(); - self.is_dirty = false; - self.defaults_reset_pending = false; - // The file just changed, so the offer has to be recomputed - // against it: an applied migration leaves nothing to propose, - // and an unrelated save leaves the same proposal standing. - self.refresh_migration_preview(&saved_document); - let mut msg = "Configuration saved successfully.".to_string(); - if let Some(path) = backup { - msg.push_str(&format!("\nBackup created at {}", path.display())); - } - let mut status = config_document_status(&saved_document, &msg); - if let Some(note) = save_validation_note(&validation) { - status = status.with_note(¬e); - } - self.status = status; - self.base_document = Some(*saved_document); - } - Err((document, err)) => { - // The write borrowed the model's only document; a failure hands - // it straight back so the draft stays savable. The one case - // with nothing to hand back is a blocking job that never - // returned, which leaves a reload as the way forward. - let restored = document.is_some(); - self.base_document = document.map(|document| *document); - let mut message = format!("Failed to save configuration: {err}"); - if !restored { - message.push_str( - "\nThe loaded configuration did not come back from the failed write. Reload before saving again.", - ); - } - self.status = StatusMessage::error(message); - } - } - - Vec::new() - } - - /// Recomputes what a migration would propose for the document now in hand. - /// - /// The authored values are the ones to diff: proposing a change to a - /// binding that only exists because loading dropped a contested key would - /// offer the user an edit their file never contained. - /// - /// A dismissal answers the question for one file, so it survives a reload - /// of that same file and no other. With `config.toml` a link into one - /// profile among several, retargeting it and pressing Reload brings up a - /// configuration the user has never been asked about; keeping the earlier - /// answer would hide its offer until the app is restarted. The document's - /// destination is what tells the two apart — the path is the same either - /// way. - fn refresh_migration_preview(&mut self, document: &ConfigDocument) { - if self.migration_dismissed.as_deref() != Some(document.destination()) { - self.migration_dismissed = None; - } - self.migration_preview = MigrationPreview::for_authored_config(document.authored_config()); - } - - pub(super) fn handle_migration_apply_requested(&mut self) -> Vec { - if self.is_loading || self.is_saving { - return Vec::new(); - } - let Some(preview) = self.pending_migration().cloned() else { - return Vec::new(); - }; - - let mut applied = 0usize; - let mut kept = Vec::new(); - for change in preview.changes() { - // A key this build has no field for cannot be shown or edited, so - // it is left alone rather than written blind. - let Some(field) = KeybindingField::from_field_key(change.config_key()) else { - continue; - }; - // The preview was computed when the file loaded; the draft has been - // editable ever since. A field that no longer reads as the "before" - // the proposal was built from is the user's own edit, and applying - // the proposal's "after" over it would silently discard what they - // typed — so it is kept and reported instead. - if self.draft.keybindings.parses_to(field, change.before()) { - self.draft.keybindings.set(field, change.after().join(", ")); - applied += 1; - } else if !self.draft.keybindings.parses_to(field, change.after()) { - kept.push(change.action_label()); - } - } - // Apply answers the migration question even when the user's own edits - // cover every proposed field. Those edits are kept above; recording the - // revision says this generation was reviewed, not that every shipped - // default was copied verbatim. Without the stamp, customized fields make - // the recipes decline on the next load anyway, leaving an old revision - // while the status incorrectly promises the offer will return. - self.draft.config_revision = Some(preview.proposed_revision()); - self.migration_preview = None; - let label = if applied == 1 { - "shortcut update" - } else { - "shortcut updates" - }; - let mut message = format!("Applied {applied} {label} to the draft."); - if !kept.is_empty() { - message.push_str(&format!( - " Kept your edit to {}.", - list_with_overflow(&kept, ", ") - )); - } - message.push_str(" Nothing is written until you press Save."); - self.status = StatusMessage::info(message); - self.refresh_dirty_flag(); - - Vec::new() - } - - pub(super) fn handle_migration_dismissed(&mut self) -> Vec { - // Left silent on purpose: the status banner may be carrying the load - // diagnostics for this file, and hiding the offer is not worth losing - // them over. - // - // Recorded against the file the offer was about, not the path that - // reached it: only a reload landing on that same file is the reload - // this answer covers. Without a document in hand there is no file to - // name — no load has produced one, or a running save is holding it — - // and an answer already given stands rather than being cleared. - if let Some(document) = self.base_document.as_ref() { - self.migration_dismissed = Some(document.destination().to_path_buf()); - } - - Vec::new() - } -} - -const SHOWN_DIAGNOSTICS: usize = 8; - -/// Why a save was refused before it began. -/// -/// The count is all the banner can give: which rows are at fault is the row's -/// own job to show, and the fix is the same for every one of them — type a -/// color. Clearing the field is not a way out: the picker edits a color the -/// config requires, so an empty field is refused like any other text that is -/// not a color. -fn invalid_color_hex_message(count: usize) -> String { - if count == 1 { - return "1 color field does not hold a color. Enter #RRGGBB or #RRGGBBAA before saving." - .to_string(); - } - format!("{count} color fields do not hold a color. Enter #RRGGBB or #RRGGBBAA before saving.") -} - -fn config_document_status(document: &ConfigDocument, success: &str) -> StatusMessage { - let diagnostics = document.diagnostics(); - if diagnostics.is_empty() { - return StatusMessage::success(success); - } - - let mut message = success.to_string(); - let mut unknown = Vec::new(); - let mut conflicts = Vec::new(); - let mut invalid = Vec::new(); - let mut skipped_defaults = Vec::new(); - // Exhaustive on purpose: a kind with no section here would leave the - // status warning-styled and empty of the very thing it is warning about, - // so a new variant has to be a compile error rather than a silent drop. - for diagnostic in diagnostics { - match diagnostic.kind() { - ConfigDiagnosticKind::UnknownSetting => unknown.push(diagnostic.path().to_string()), - // Every keybinding kind is resolved in memory only, so the file - // the editor is showing still contains them: carry the diagnostic's - // own wording, which names the actions, instead of just the path. - ConfigDiagnosticKind::KeybindingConflict => conflicts.push(diagnostic.to_string()), - ConfigDiagnosticKind::InvalidKeybinding => invalid.push(diagnostic.to_string()), - ConfigDiagnosticKind::DefaultShortcutSkipped => { - skipped_defaults.push(diagnostic.to_string()); - } - } - } - - if !unknown.is_empty() { - message.push_str(&format!( - "\nUnrecognized settings were preserved: {}.", - list_with_overflow(&borrowed(&unknown), ", ") - )); - } - if !invalid.is_empty() { - message.push_str(&format!( - "\nShortcuts that could not be parsed are ignored for the running session; the file still has them: {}.", - list_with_overflow(&borrowed(&invalid), "; ") - )); - } - if !conflicts.is_empty() { - message.push_str(&format!( - "\nConflicting shortcuts were resolved for the running session only; the file still has them: {}.", - list_with_overflow(&borrowed(&conflicts), "; ") - )); - } - // Its own sentence, and the last one: nothing in the file is wrong here. - // An action this configuration never mentions was offered a shortcut this - // build added, and the configuration already spends that key. - if !skipped_defaults.is_empty() { - message.push_str(&format!( - "\nNew default shortcuts stayed inactive because this configuration already uses those keys: {}.", - list_with_overflow(&borrowed(&skipped_defaults), "; ") - )); - } - - StatusMessage::warning(message) -} - -/// What validating the saved configuration changed in the shortcuts the user -/// typed, or `None` when it changed nothing. -/// -/// The load-time sentences in [`config_document_status`] all end in "the file -/// still has them", because loading resolves in memory only. These are the -/// other case: the draft is the authored text, the resolution is what reached -/// `config.toml`, and the reloaded document no longer contains the collision -/// to report. Naming which action kept the key and which lost it is therefore -/// the only account the user gets of an edit their Save made for them. -/// -/// A skipped default cannot appear here: the draft spells every action out -/// (`ConfigDraft::to_config` marks the section explicit), so the omitted-default -/// pass has nothing to offer and reports nothing. -fn save_validation_note(validation: &ConfigValidationReport) -> Option { - // The summaries, not the full `Display` forms: those say the file keeps - // the shortcut and the session does without it, which is the load story. - let invalid = clauses( - validation - .invalid_keybindings - .iter() - .map(InvalidKeybinding::summary), - ); - let conflicts = clauses( - validation - .keybinding_conflicts - .iter() - .map(KeybindingConflictResolution::summary), - ); - if invalid.is_empty() && conflicts.is_empty() { - return None; - } - - let mut note = String::new(); - if !invalid.is_empty() { - note.push_str(&format!( - "Shortcuts that could not be parsed were left out of the saved configuration: {}.", - list_with_overflow(&borrowed(&invalid), "; ") - )); - } - if !conflicts.is_empty() { - if !note.is_empty() { - note.push('\n'); - } - note.push_str(&format!( - "Shortcuts two actions claimed were settled before saving, and the saved configuration keeps that outcome: {}.", - list_with_overflow(&borrowed(&conflicts), "; ") - )); - } - Some(note) -} - -/// Toast-sized summaries as list items: each is a finished sentence, and the -/// sentence they are listed inside supplies the final stop. -fn clauses(summaries: impl Iterator) -> Vec { - summaries - .map(|summary| summary.trim_end_matches('.').to_string()) - .collect() -} - -fn borrowed(entries: &[String]) -> Vec<&str> { - entries.iter().map(String::as_str).collect() -} - -fn list_with_overflow(entries: &[&str], separator: &str) -> String { - let shown = entries - .iter() - .take(SHOWN_DIAGNOSTICS) - .copied() - .collect::>() - .join(separator); - match entries.len().saturating_sub(SHOWN_DIAGNOSTICS) { - 0 => shown, - remaining => format!("{shown}{separator}and {remaining} more"), - } -} - -/// The migration offer as the banner shows it, with its whole change list in -/// view. -/// -/// The list is not behind a Review button: a recipe proposes at most a handful -/// of shortcuts, and putting Apply next to something the user has not read yet -/// is the one thing this flow exists to avoid. -pub(crate) fn migration_offer_text(preview: &MigrationPreview) -> String { - let mut lines = vec![ - "Configuration update available".to_string(), - format!( - "Shortcut defaults changed since this configuration was written. Applying updates this draft only; nothing reaches the file until you press Save, which also records revision {}.", - preview.proposed_revision() - ), - ]; - for change in preview.changes() { - lines.push(format!( - "{} ({}): {} → {}", - change.action_label(), - change.config_key(), - binding_summary(change.before()), - binding_summary(change.after()), - )); - } - lines.join("\n") -} - -fn binding_summary(bindings: &[String]) -> String { - if bindings.is_empty() { - return "unbound".to_string(); - } - bindings.join(", ") -} - +mod defaults; +mod load; +mod migration; +mod save; +mod status; #[cfg(test)] -mod tests { - use std::path::{Path, PathBuf}; - use std::sync::atomic::{AtomicU64, Ordering}; - - use wayscriber::config::{Action, CURRENT_CONFIG_REVISION}; - - use super::*; - use crate::models::{ColorPickerId, ToggleField}; - use crate::test_temp::TempDir; - - fn status_contains(status: &StatusMessage, needle: &str) -> bool { - status.text().is_some_and(|text| text.contains(needle)) - } - - static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); - - fn temp_config_document(name: &str, contents: &str) -> (PathBuf, Box) { - let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "wayscriber-configurator-update-config-{}-{sequence}-{name}.toml", - std::process::id(), - )); - std::fs::write(&path, contents).expect("write test config"); - let document = ConfigDocument::load_from_path(&path).expect("load test config document"); - (path, Box::new(document)) - } - - #[test] - fn handle_config_loaded_success_resets_loading_and_dirty_state() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.is_dirty = true; - - let (path, document) = temp_config_document("loaded", ""); - let _ = app.handle_config_loaded(Ok((document, None))); - - assert!(!app.is_loading); - assert!(!app.is_dirty); - assert_eq!(app.boards_collapsed.len(), app.draft.boards.items.len()); - assert!(status_contains( - &app.status, - "Configuration loaded from disk." - )); - let _ = std::fs::remove_file(path); - } - - #[test] - fn handle_config_loaded_uses_startup_search_focus_fallback_once() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - - let (first_path, first) = temp_config_document("focus-first", ""); - let _ = app.handle_config_loaded(Ok((first, None))); - - assert_eq!(app.search_focus_serial, 1); - assert!(!app.startup_search_focus_pending); - - // A reload is not a relaunch: the offer was answered by the first load, - // so the caret stays wherever the user put it. - let (second_path, second) = temp_config_document("focus-second", ""); - let _ = app.handle_config_loaded(Ok((second, None))); - - assert_eq!(app.search_focus_serial, 1); - let _ = std::fs::remove_file(first_path); - let _ = std::fs::remove_file(second_path); - } - - #[test] - fn handle_config_loaded_error_preserves_the_last_good_document_and_draft() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let (path, document) = temp_config_document("before-reload-error", ""); - let destination = document.destination().to_path_buf(); - let _ = app.handle_config_loaded(Ok((document, None))); - app.draft.capture_enabled = !app.draft.capture_enabled; - let draft = app.draft.clone(); - - let _ = app.handle_config_loaded(Err("broken".to_string())); - - assert!(!app.is_loading); - assert_eq!( - app.base_document - .as_ref() - .expect("last good document") - .destination(), - destination, - "a failed reload keeps the document the last good load produced" - ); - assert_eq!(app.draft, draft); - assert!(status_contains( - &app.status, - "Failed to load config from disk: broken" - )); - let _ = std::fs::remove_file(path); - } - - #[test] - fn handle_config_loaded_repair_document_allows_saving() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let (path, document) = temp_config_document("repair", ""); - - let _ = app.handle_config_loaded(Ok(( - document, - Some("invalid type: string, expected u32".to_string()), - ))); - - assert!(app.base_document.is_some()); - assert!(matches!(app.status, StatusMessage::Warning(_))); - assert!(status_contains(&app.status, "loaded for repair")); - assert!(status_contains( - &app.status, - "malformed TOML content remains only in the backup" - )); - let _ = app.handle_save_requested(); - assert!(app.is_saving); - let _ = std::fs::remove_file(path); - } - - #[test] - fn handle_config_loaded_surfaces_preserved_unknown_settings() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let (path, document) = - temp_config_document("unknown", "future_configurator_option = true\n"); - - let _ = app.handle_config_loaded(Ok((document, None))); - - assert!(matches!(app.status, StatusMessage::Warning(_))); - assert!(status_contains(&app.status, "future_configurator_option")); - assert!(status_contains(&app.status, "were preserved")); - let _ = std::fs::remove_file(path); - } - - /// A resolved shortcut conflict is never written back, so the editor is - /// where the user has to be able to find it (#293). Both sides here are - /// spelled out in the file, which is what makes it their conflict. - #[test] - fn handle_config_loaded_surfaces_resolved_shortcut_conflicts() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let (path, document) = temp_config_document( - "shortcut-conflict", - &format!( - "config_revision = {}\n\n[keybindings]\ntoggle_toolbar = [\"F2\"]\ncycle_toolbar_display = [\"F2\"]\n", - wayscriber::config::CURRENT_CONFIG_REVISION - ), - ); - - let _ = app.handle_config_loaded(Ok((document, None))); - - assert!(matches!(app.status, StatusMessage::Warning(_))); - assert!(status_contains(&app.status, "F2")); - assert!(status_contains(&app.status, "Toggle Toolbar")); - assert!(status_contains(&app.status, "Cycle Toolbar Display")); - assert!(status_contains(&app.status, "running session only")); - assert!( - !status_contains(&app.status, "Unrecognized settings"), - "a conflict is not an unknown setting" - ); - let _ = std::fs::remove_file(path); - } - - /// A default this build added and the file never mentions gets its own - /// sentence: the user's configuration is fine, and the shortcut they read - /// about in the release notes simply is not theirs (#293). - #[test] - fn handle_config_loaded_surfaces_skipped_default_shortcuts() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let (path, document) = temp_config_document( - "skipped-default", - &format!( - "config_revision = {}\n\n[keybindings]\ntoggle_toolbar = [\"F2\", \"F9\"]\n", - wayscriber::config::CURRENT_CONFIG_REVISION - ), - ); - - let _ = app.handle_config_loaded(Ok((document, None))); - - assert!(matches!(app.status, StatusMessage::Warning(_))); - assert!(status_contains(&app.status, "F2")); - assert!(status_contains(&app.status, "Cycle Toolbar Display")); - assert!(status_contains( - &app.status, - "New default shortcuts stayed inactive" - )); - assert!( - !status_contains(&app.status, "Conflicting shortcuts") - && !status_contains(&app.status, "Unrecognized settings"), - "a skipped default is neither a conflict nor an unknown setting" - ); - let _ = std::fs::remove_file(path); - } - - /// A string the parser rejects is dropped for the session and kept by the - /// file, so the editor is where the user has to be able to find it. With - /// nothing else wrong in the file, this section is the entire warning. - #[test] - fn handle_config_loaded_surfaces_shortcuts_that_could_not_be_parsed() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let (path, document) = temp_config_document( - "invalid-shortcut", - &format!( - "config_revision = {}\n\n[keybindings]\nclear_canvas = [\"Ctrl+Shift\"]\n", - wayscriber::config::CURRENT_CONFIG_REVISION - ), - ); - - let _ = app.handle_config_loaded(Ok((document, None))); - - assert!(matches!(app.status, StatusMessage::Warning(_))); - assert!(status_contains(&app.status, "Ctrl+Shift")); - assert!(status_contains(&app.status, "Clear Canvas")); - assert!(status_contains(&app.status, "could not be parsed")); - assert!( - !status_contains(&app.status, "Unrecognized settings") - && !status_contains(&app.status, "Conflicting shortcuts"), - "an unparseable shortcut is neither an unknown setting nor a conflict" - ); - let _ = std::fs::remove_file(path); - } - - /// All three keybinding kinds can land in one file, and each gets its own - /// sentence: they need different fixes, and one of them needs no fix. - #[test] - fn handle_config_loaded_separates_every_keybinding_diagnostic_kind() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let (path, document) = temp_config_document( - "invalid-and-conflicting", - &format!( - "config_revision = {}\n\n[keybindings]\nclear_canvas = [\"Ctrl+Shift\"]\ntoggle_toolbar = [\"F2\", \"F9\"]\nundo = [\"Ctrl+Alt+U\"]\nredo = [\"Ctrl+Alt+U\"]\n", - wayscriber::config::CURRENT_CONFIG_REVISION - ), - ); - - let _ = app.handle_config_loaded(Ok((document, None))); - - assert!(matches!(app.status, StatusMessage::Warning(_))); - assert!(status_contains(&app.status, "could not be parsed")); - assert!(status_contains(&app.status, "running session only")); - assert!(status_contains( - &app.status, - "New default shortcuts stayed inactive" - )); - assert!(status_contains(&app.status, "Ctrl+Shift")); - assert!(status_contains(&app.status, "Ctrl+Alt+U")); - assert!(status_contains(&app.status, "F2")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn handle_save_requested_blocks_without_loaded_document() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - // A fresh app is still running its startup load; this test is about - // the load having finished without producing a document. - app.is_loading = false; - - let effects = app.handle_save_requested(); - - assert!(effects.is_empty()); - assert!(!app.is_saving); - assert!(status_contains( - &app.status, - "Configuration has not loaded successfully" - )); - } - - #[test] - fn handle_save_requested_sets_saving_for_valid_draft() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.is_saving = false; - let (path, document) = temp_config_document("save-request", ""); - let _ = app.handle_config_loaded(Ok((document, None))); - - let effects = app.handle_save_requested(); - - assert!(matches!(effects.as_slice(), [Effect::SaveConfig { .. }])); - assert!(app.is_saving); - assert!(status_contains(&app.status, "Saving configuration...")); - let _ = std::fs::remove_file(path); - } - - /// The document is the model's only copy, and the write needs it moved. It - /// therefore leaves while the write runs and the result brings one back — - /// the saved document on success. - #[test] - fn the_running_save_holds_the_document_and_the_result_returns_one() { - let (mut app, _dir, _path) = app_with_config_file(""); - - let (document, config) = save_effect(&mut app); - - assert!( - app.base_document.is_none(), - "the write holds the document while it runs" - ); - let (saved, backup) = document - .save_with_backup(*config) - .expect("the document saves") - .into_parts(); - let _ = app.handle_config_saved(Ok((backup, Box::new(saved)))); - - assert!( - app.base_document.is_some(), - "a finished save hands a document back" - ); - } - - /// A write that failed wrote nothing, so the document it borrowed is still - /// the one the editor is against: it comes back, and the next Save works. - #[test] - fn a_failed_save_hands_the_document_back() { - let (mut app, _dir, _path) = app_with_config_file(""); - app.draft.drawing_default_thickness = "6".to_string(); - app.refresh_dirty_flag(); - - let (document, _config) = save_effect(&mut app); - assert!(app.base_document.is_none()); - - let _ = app.handle_config_saved(Err((Some(document), "Permission denied".to_string()))); - - assert!(!app.is_saving); - assert!( - app.base_document.is_some(), - "the document the failed write borrowed must return to the model" - ); - assert!(app.is_dirty, "the draft is still unsaved"); - assert!(status_contains( - &app.status, - "Failed to save configuration: Permission denied" - )); - assert!( - !status_contains(&app.status, "Reload before saving again"), - "the document came back, so there is nothing to reload for: {:?}", - app.status - ); - - // The proof that it came back whole: the very next Save is accepted. - let effects = app.handle_save_requested(); - assert!(matches!(effects.as_slice(), [Effect::SaveConfig { .. }])); - } - - /// The one failure with nothing to hand back is a blocking job that never - /// returned. Saving again cannot work until a reload produces a document, - /// so the status has to say so. - #[test] - fn a_save_whose_job_never_returned_asks_for_a_reload() { - let (mut app, _dir, _path) = app_with_config_file(""); - let (_document, _config) = save_effect(&mut app); - - let _ = - app.handle_config_saved(Err((None, "config save blocking job panicked".to_string()))); - - assert!(app.base_document.is_none()); - assert!(status_contains(&app.status, "Reload before saving again")); - } - - /// A draft the converter rejects never reaches a write, so the document - /// must be back in the model by the time the handler returns. - #[test] - fn a_draft_the_converter_rejects_keeps_the_document() { - let (mut app, _dir, _path) = app_with_config_file(""); - app.draft.drawing_default_thickness = "thick".to_string(); - - let effects = app.handle_save_requested(); - - assert!(effects.is_empty()); - assert!(!app.is_saving); - assert!( - app.base_document.is_some(), - "a refused save must not take the document with it" - ); - assert!(status_contains( - &app.status, - "Cannot save due to validation" - )); - } - - /// Hex text the parser rejects was never applied to the draft, so a save - /// would write the last value that did parse and the reload would replace - /// the text with it. The Save is refused instead. - #[test] - fn a_color_field_holding_invalid_hex_blocks_the_save() { - let (mut app, _dir, _path) = app_with_config_file(""); - app.draft.drawing_default_thickness = "6".to_string(); - app.refresh_dirty_flag(); - let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#12zz".to_string()); - - assert_eq!(app.invalid_color_hex_count(), 1); - - let effects = app.handle_save_requested(); - - assert!(effects.is_empty()); - assert!(!app.is_saving); - assert!(app.base_document.is_some(), "nothing was written or taken"); - assert!(status_contains(&app.status, "1 color field")); - assert!(status_contains( - &app.status, - "Enter #RRGGBB or #RRGGBBAA before saving" - )); - } - - /// The one way out of the refusal: type a color that parses. - #[test] - fn correcting_the_color_field_allows_the_save_again() { - let (mut app, _dir, _path) = app_with_config_file(""); - app.draft.drawing_default_thickness = "6".to_string(); - app.refresh_dirty_flag(); - let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#12zz".to_string()); - assert!(app.handle_save_requested().is_empty()); - - let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#102030".to_string()); - - assert_eq!(app.invalid_color_hex_count(), 0); - assert!(matches!( - app.handle_save_requested().as_slice(), - [Effect::SaveConfig { .. }] - )); - } - - /// Clearing is not a way out. The picker edits a color the config - /// requires, so an empty field is an edit the save cannot write: letting - /// it through would keep the previous color and put it straight back in - /// the field on the next reload. - #[test] - fn clearing_the_color_field_keeps_the_save_blocked() { - for cleared in ["", " "] { - let (mut app, _dir, _path) = app_with_config_file(""); - app.draft.drawing_default_thickness = "6".to_string(); - app.refresh_dirty_flag(); - - let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, cleared.to_string()); - - assert_eq!(app.invalid_color_hex_count(), 1, "{cleared:?}"); - let effects = app.handle_save_requested(); - assert!(effects.is_empty(), "{cleared:?} must not reach a save"); - assert!(status_contains(&app.status, "1 color field")); - } - } - - /// Deleting the row a refused color was in has to release the save with - /// it: the field is gone from the screen, so nothing is left to fix. - #[test] - fn removing_a_quick_color_releases_the_save_its_hex_had_refused() { - let (mut app, _dir, _path) = app_with_config_file(""); - let _ = app.handle_quick_color_added(); - let last = app.draft.drawing_quick_colors.entries.len() - 1; - let _ = app - .handle_color_picker_hex_changed(ColorPickerId::QuickColor(last), "#12zz".to_string()); - assert!(app.handle_save_requested().is_empty()); - - let _ = app.handle_quick_color_removed(last); - - assert_eq!(app.invalid_color_hex_count(), 0); - assert!(matches!( - app.handle_save_requested().as_slice(), - [Effect::SaveConfig { .. }] - )); - } - - /// The transient the empty rule could have wedged: editing a component - /// resyncs that picker's hex, so a normal edit never leaves the field - /// blank and the save gate never closes behind the user's back. - #[test] - fn applying_a_color_leaves_the_field_holding_that_color() { - let (mut app, _dir, _path) = app_with_config_file(""); - let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, String::new()); - assert_eq!(app.invalid_color_hex_count(), 1); - - let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#102030".to_string()); - - assert_eq!(app.invalid_color_hex_count(), 0); - } - - /// Several bad fields are one refusal, and the count is what tells the user - /// how much is left to fix. - #[test] - fn every_invalid_color_field_is_counted_for_the_refusal() { - let (mut app, _dir, _path) = app_with_config_file(""); - let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#12zz".to_string()); - let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpText, "nope".to_string()); - - assert_eq!(app.invalid_color_hex_count(), 2); - - let _ = app.handle_save_requested(); - - assert!(status_contains(&app.status, "2 color fields")); - assert!(status_contains( - &app.status, - "Enter #RRGGBB or #RRGGBBAA before saving" - )); - } - - #[test] - fn reset_to_defaults_requires_confirmation() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.is_loading = false; - app.draft.capture_enabled = !app.defaults.capture_enabled; - let changed_draft = app.draft.clone(); - - let _ = app.handle_reset_to_defaults_requested(); - - assert!(app.defaults_reset_pending); - assert_eq!(app.draft, changed_draft); - assert!(status_contains(&app.status, "Confirm Defaults")); - } - - /// Asking twice is still asking: the request message cannot apply the - /// defaults, so a double press on "Defaults" leaves the draft alone with - /// the question still standing. - #[test] - fn reset_to_defaults_repeated_request_is_a_no_op() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.is_loading = false; - app.draft.capture_enabled = !app.defaults.capture_enabled; - let changed_draft = app.draft.clone(); - - let _ = app.handle_reset_to_defaults_requested(); - let _ = app.handle_reset_to_defaults_requested(); - - assert!(app.defaults_reset_pending); - assert_eq!(app.draft, changed_draft); - assert!(status_contains(&app.status, "Confirm Defaults")); - } - - /// The confirm is the only message that replaces the draft, and it - /// disarms the question it answered. - #[test] - fn reset_to_defaults_confirmed_applies_the_defaults() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.is_loading = false; - app.draft.capture_enabled = !app.defaults.capture_enabled; - app.baseline.capture_enabled = !app.defaults.capture_enabled; - - let _ = app.handle_reset_to_defaults_requested(); - let _ = app.handle_reset_to_defaults_confirmed(); - - assert_eq!(app.draft, app.defaults); - assert!(!app.defaults_reset_pending); - assert!(status_contains(&app.status, "Loaded default configuration")); - assert!( - app.is_dirty, - "defaults differing from the loaded baseline must read as dirty" - ); - } - - /// A confirm with nothing armed answers no question, so it must not - /// replace a draft the user was never warned about. - #[test] - fn reset_to_defaults_confirmed_without_a_request_changes_nothing() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.is_loading = false; - app.draft.capture_enabled = !app.defaults.capture_enabled; - let changed_draft = app.draft.clone(); - - let _ = app.handle_reset_to_defaults_confirmed(); - - assert_eq!(app.draft, changed_draft); - assert!(!app.defaults_reset_pending); - assert!( - !status_contains(&app.status, "Loaded default configuration"), - "an unarmed confirm reports nothing, because it did nothing" - ); - } - - /// Cancel withdraws the question and takes its warning off the status - /// line, leaving the draft exactly as the user left it. - #[test] - fn reset_to_defaults_canceled_disarms_and_clears_the_hint() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.is_loading = false; - app.draft.capture_enabled = !app.defaults.capture_enabled; - let changed_draft = app.draft.clone(); - - let _ = app.handle_reset_to_defaults_requested(); - let _ = app.handle_reset_to_defaults_canceled(); - - assert!(!app.defaults_reset_pending); - assert_eq!(app.draft, changed_draft); - assert!(matches!(app.status, StatusMessage::Idle)); - - // Nothing is armed now, so the confirm that follows it is inert. - let _ = app.handle_reset_to_defaults_confirmed(); - assert_eq!(app.draft, changed_draft); - } - - #[test] - fn reset_to_defaults_canceled_keeps_status_that_replaced_the_hint() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.is_loading = false; - - let _ = app.handle_reset_to_defaults_requested(); - app.status = StatusMessage::error("Failed to clear session s-1: nope"); - - let _ = app.handle_reset_to_defaults_canceled(); - - assert!(!app.defaults_reset_pending); - assert!(matches!(app.status, StatusMessage::Error(_))); - assert!(status_contains(&app.status, "Failed to clear session s-1")); - } - - /// A cancel with nothing armed has no question to withdraw, so it must - /// not wipe the status the user is reading. - #[test] - fn reset_to_defaults_canceled_without_a_request_keeps_the_status() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.is_loading = false; - app.status = StatusMessage::error("Failed to load config from disk: nope"); - - let _ = app.handle_reset_to_defaults_canceled(); - - assert!(status_contains(&app.status, "Failed to load config")); - } - - #[test] - fn reset_to_defaults_confirmation_is_canceled_by_draft_edit() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.is_loading = false; - - let _ = app.handle_reset_to_defaults_requested(); - let _ = app.handle_toggle_changed(ToggleField::CaptureEnabled, !app.draft.capture_enabled); - - assert!(!app.defaults_reset_pending); - assert!(matches!(app.status, StatusMessage::Idle)); - } - - /// The disarming an edit does is what the confirm is guarded on: the - /// stale answer to a withdrawn question must not throw the edit away. - #[test] - fn a_draft_edit_between_request_and_confirm_refuses_the_confirm() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.is_loading = false; - - let _ = app.handle_reset_to_defaults_requested(); - let _ = app.handle_toggle_changed(ToggleField::CaptureEnabled, !app.draft.capture_enabled); - let edited_draft = app.draft.clone(); - let _ = app.handle_reset_to_defaults_confirmed(); - - assert_eq!(app.draft, edited_draft); - assert_ne!(app.draft, app.defaults); - } - - /// The reviewer's case: the file spells `undo` out and never mentions - /// `clear_canvas`, and the user then types `undo`'s shortcut into the - /// Clear canvas field. The draft is the authored text now, so both lists - /// are explicit and the traversal order settles the collision (core visits - /// `clear_canvas` before `undo`). Classifying the typed binding as an - /// omitted default instead would filter it away, save an empty list, and - /// report success. - #[test] - fn a_shortcut_typed_for_an_omitted_action_is_arbitrated_not_filtered() { - let (mut app, _dir, path) = app_with_config_file(&format!( - "config_revision = {CURRENT_CONFIG_REVISION}\n\n[keybindings]\nundo = [\"Ctrl+Alt+U\"]\n" - )); - app.draft - .keybindings - .set(KeybindingField::ClearCanvas, "Ctrl+Alt+U".to_string()); - - let document = app.base_document.as_ref().expect("a loaded document"); - let mut config = app - .draft - .to_config(document.config()) - .expect("the draft converts to a config"); - let report = config.validate_and_clamp(); - - assert!( - report.skipped_default_shortcuts.is_empty(), - "the user typed this binding; it is not an offer to filter: {:?}", - report.skipped_default_shortcuts - ); - assert_eq!( - config.keybindings.core.clear_canvas, - ["Ctrl+Alt+U"], - "the earlier action in traversal order keeps the key" - ); - assert!(config.keybindings.core.undo.is_empty()); - assert_eq!(report.keybinding_conflicts.len(), 1); - assert_eq!(report.keybinding_conflicts[0].kept(), Action::ClearCanvas); - assert_eq!(report.keybinding_conflicts[0].dropped(), Action::Undo); - - let _ = save_draft(&mut app); - - assert!( - matches!(app.status, StatusMessage::Warning(_)), - "a binding the save took away is not a plain success: {:?}", - app.status - ); - assert!(status_contains(&app.status, "settled before saving")); - assert!( - status_contains( - &app.status, - "Ctrl+Alt+U kept for Clear Canvas, dropped from Undo." - ), - "the status has to name the key, the winner, and the loser: {:?}", - app.status - ); - - let contents = read_config(&path); - assert_eq!( - config_setting(&contents, "clear_canvas").as_deref(), - Some("clear_canvas = [\"Ctrl+Alt+U\"]"), - "the typed binding reaches the file" - ); - assert_eq!( - config_setting(&contents, "undo").as_deref(), - Some("undo = []"), - "the loser is written out too, so the file and the report agree" - ); - } - - /// The same collision the other way around: nothing about the draft is - /// wrong, so a save that resolves nothing says nothing extra. - #[test] - fn a_save_without_shortcut_trouble_stays_a_plain_success() { - let (mut app, _dir, _path) = app_with_config_file(&format!( - "config_revision = {CURRENT_CONFIG_REVISION}\n\n[keybindings]\nundo = [\"Ctrl+Alt+U\"]\n" - )); - app.draft.drawing_default_thickness = "6".to_string(); - - let _ = save_draft(&mut app); - - assert!( - matches!(app.status, StatusMessage::Success(_)), - "unexpected status: {:?}", - app.status - ); - assert!(!status_contains(&app.status, "settled before saving")); - } - - /// A shortcut the editor accepts as text but the parser rejects never - /// reaches the file either, so the save status is the only place it can be - /// reported. - #[test] - fn a_typed_shortcut_the_parser_rejects_is_reported_by_the_save() { - let (mut app, _dir, _path) = app_with_config_file(&format!( - "config_revision = {CURRENT_CONFIG_REVISION}\n\n[keybindings]\nundo = [\"Ctrl+Alt+U\"]\n" - )); - app.draft - .keybindings - .set(KeybindingField::ClearCanvas, "Ctrl+Shift".to_string()); - - let _ = save_draft(&mut app); - - assert!( - matches!(app.status, StatusMessage::Warning(_)), - "unexpected status: {:?}", - app.status - ); - assert!(status_contains(&app.status, "Ctrl+Shift")); - assert!(status_contains(&app.status, "Clear Canvas")); - assert!(status_contains(&app.status, "could not be parsed")); - } - - /// A reload replaces the draft and base document when it lands, so a save - /// started underneath it would write the pre-reload draft and then be - /// judged against a document it never saw. - #[test] - fn save_is_refused_while_a_reload_is_in_flight() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let (path, document) = temp_config_document("save-during-reload", ""); - app.base_document = Some(*document); - app.is_loading = true; - app.is_dirty = true; - let before = app.status.clone(); - - let _ = app.handle_save_requested(); - - assert!( - !app.is_saving, - "no save may start under an in-flight reload" - ); - assert!(app.is_dirty, "the draft stays dirty for the next attempt"); - assert_eq!( - format!("{:?}", app.status), - format!("{before:?}"), - "a refused save must not claim it is saving" - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn handle_config_saved_success_clears_dirty_and_records_backup() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.is_saving = true; - app.is_dirty = true; - app.draft.capture_enabled = !app.draft.capture_enabled; - let backup = PathBuf::from("/tmp/wayscriber-config.bak"); - let (path, document) = temp_config_document("saved", ""); - - let _ = app.handle_config_saved(Ok((Some(backup.clone()), document))); - assert!(app.base_document.is_some()); - - assert!(!app.is_saving); - assert!(!app.is_dirty); - assert_eq!(app.last_backup_path, Some(backup)); - assert_eq!(app.draft, app.baseline); - assert!(status_contains( - &app.status, - "Configuration saved successfully." - )); - let _ = std::fs::remove_file(path); - } - - const LEGACY_REVISION_ZERO_CONFIG: &str = "config_revision = 0\n\n[drawing]\ndefault_thickness = 3.0\n\n[keybindings]\ntoggle_command_palette = [\"Ctrl+K\"]\ncapture_full_screen = [\"Ctrl+Shift+P\"]\n"; - - /// A config file of its own, in a directory the test owns: an applied - /// migration saves, and a save drops its `.bak` next to the file. - fn app_with_config_file(contents: &str) -> (ConfiguratorApp, TempDir, PathBuf) { - let dir = crate::test_temp::tempdir().expect("temporary test directory"); - let path = dir.path().join("config.toml"); - std::fs::write(&path, contents).expect("write test config"); - let (mut app, _effects) = ConfiguratorApp::new_app(); - load_config_file(&mut app, &path); - (app, dir, path) - } - - fn load_config_file(app: &mut ConfiguratorApp, path: &Path) { - let document = ConfigDocument::load_from_path(path).expect("load test config document"); - let _ = app.handle_config_loaded(Ok((Box::new(document), None))); - } - - /// The Save path with the executor left out: the handler produces exactly - /// this effect, and `save_config_to_disk` performs exactly this write - /// before handing the outcome back to `handle_config_saved`. - fn save_draft(app: &mut ConfiguratorApp) -> Option { - let (document, config) = save_effect(app); - let (saved, backup) = document - .save_with_backup(*config) - .expect("the document saves") - .into_parts(); - let _ = app.handle_config_saved(Ok((backup.clone(), Box::new(saved)))); - backup - } - - /// The write a Save asked for, unpacked. - fn save_effect(app: &mut ConfiguratorApp) -> (Box, Box) { - let mut effects = app.handle_save_requested(); - assert_eq!(effects.len(), 1, "a Save asks for exactly one write"); - match effects.remove(0) { - Effect::SaveConfig { document, config } => (document, config), - other => panic!("a Save must ask for a write, not {other:?}"), - } - } - - /// One setting exactly as the saved file spells it, with the line wrapping - /// the merge may choose for an array folded into single spaces. - fn config_setting(contents: &str, key: &str) -> Option { - let mut lines = contents.lines().map(str::trim).skip_while(|line| { - !(line.starts_with(key) && line[key.len()..].trim_start().starts_with('=')) - }); - let mut setting = lines.next()?.to_string(); - while setting.matches('[').count() > setting.matches(']').count() { - let Some(continuation) = lines.next() else { - break; - }; - setting.push(' '); - setting.push_str(continuation); - } - Some(setting.split_whitespace().collect::>().join(" ")) - } - - fn read_config(path: &Path) -> String { - std::fs::read_to_string(path).expect("read the saved config") - } - - /// The whole point of the review flow: an old file that the user never - /// migrated keeps both its shortcuts and its revision, however much else - /// they save. - #[test] - fn saving_an_unrelated_field_leaves_old_bindings_and_revision_alone() { - let (mut app, _dir, path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); - assert!(app.pending_migration().is_some()); - - app.draft.drawing_default_thickness = "6".to_string(); - let _ = save_draft(&mut app); - - let contents = read_config(&path); - assert_eq!( - config_setting(&contents, "toggle_command_palette").as_deref(), - Some("toggle_command_palette = [\"Ctrl+K\"]") - ); - assert_eq!( - config_setting(&contents, "capture_full_screen").as_deref(), - Some("capture_full_screen = [\"Ctrl+Shift+P\"]") - ); - assert_eq!( - config_setting(&contents, "config_revision").as_deref(), - Some("config_revision = 0") - ); - assert_eq!( - config_setting(&contents, "default_thickness").as_deref(), - Some("default_thickness = 6.0"), - "the field the user did edit still saves" - ); - assert!( - app.pending_migration().is_some(), - "an unrelated save does not answer the migration question" - ); - } - - #[test] - fn applying_and_saving_writes_the_reviewed_fields_the_revision_and_a_backup() { - let (mut app, _dir, path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); - - let _ = app.handle_migration_apply_requested(); - - assert!(app.is_dirty); - assert!( - app.pending_migration().is_none(), - "the offer is answered once it is applied" - ); - assert_eq!( - app.draft - .keybindings - .value_for(KeybindingField::ToggleCommandPalette), - Some("Ctrl+K, Ctrl+Shift+P") - ); - assert_eq!(app.draft.config_revision, Some(CURRENT_CONFIG_REVISION)); - - let backup = save_draft(&mut app).expect("the save creates a backup"); - assert_eq!( - std::fs::read_to_string(&backup).expect("read the backup"), - LEGACY_REVISION_ZERO_CONFIG, - "the backup holds the file as it was before the migration" - ); - - let contents = read_config(&path); - assert_eq!( - config_setting(&contents, "toggle_command_palette").as_deref(), - Some("toggle_command_palette = [\"Ctrl+K\", \"Ctrl+Shift+P\"]") - ); - assert_eq!( - config_setting(&contents, "capture_full_screen").as_deref(), - Some("capture_full_screen = [\"Ctrl+Alt+F\"]") - ); - assert_eq!( - config_setting(&contents, "config_revision"), - Some(format!("config_revision = {CURRENT_CONFIG_REVISION}")) - ); - assert_eq!( - config_setting(&contents, "default_thickness").as_deref(), - Some("default_thickness = 3.0"), - "an applied migration is a keybinding delta, not a rewrite" - ); - - load_config_file(&mut app, &path); - assert!( - app.pending_migration().is_none(), - "the reloaded file is current, so there is nothing left to offer" - ); - } - - /// The banner is the only place the user reads what Apply would do, so it - /// has to name every proposed change as before → after. - #[test] - fn the_migration_offer_text_lists_every_proposed_change() { - let (app, _dir, _path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); - let preview = app.pending_migration().expect("the fixture is out of date"); - - let text = migration_offer_text(preview); - - let mut lines = text.lines(); - assert_eq!(lines.next(), Some("Configuration update available")); - assert!( - lines - .next() - .is_some_and(|line| line.contains("nothing reaches the file until you press Save")), - "{text}" - ); - let changes = lines.collect::>(); - assert_eq!(changes.len(), preview.changes().len()); - for (line, change) in changes.iter().zip(preview.changes()) { - assert!(line.contains(change.config_key()), "{line}"); - assert!(line.contains(" → "), "{line}"); - } - } - - /// Dismissing answers the question for this app run. A reload recomputes - /// the preview, but the user already said no to this file. - #[test] - fn dismissing_hides_the_offer_and_keeps_it_out_of_an_unrelated_save() { - let (mut app, _dir, path) = app_with_config_file( - "[keybindings]\ntoggle_command_palette = [\"Ctrl+K\"]\ncapture_full_screen = [\"Ctrl+Shift+P\"]\n", - ); - assert!(app.pending_migration().is_some()); - - let _ = app.handle_migration_dismissed(); - - assert!(app.pending_migration().is_none()); - assert!(!app.is_dirty, "dismissing changes nothing in the draft"); - - app.draft.drawing_default_thickness = "6".to_string(); - let _ = save_draft(&mut app); - - let contents = read_config(&path); - assert_eq!( - config_setting(&contents, "toggle_command_palette").as_deref(), - Some("toggle_command_palette = [\"Ctrl+K\"]") - ); - assert_eq!( - config_setting(&contents, "capture_full_screen").as_deref(), - Some("capture_full_screen = [\"Ctrl+Shift+P\"]") - ); - assert_eq!( - config_setting(&contents, "config_revision"), - None, - "a file that never recorded a revision is not stamped by an unrelated save" - ); - assert!(app.pending_migration().is_none()); - - load_config_file(&mut app, &path); - assert!( - app.pending_migration().is_none(), - "pressing Reload is not the user asking again" - ); - } - - /// The label the offer itself gives a proposed field, so the assertions on - /// the status text stay in step with the wording the banner shows. - fn change_label(app: &ConfiguratorApp, config_key: &str) -> &'static str { - app.pending_migration() - .expect("a pending migration offer") - .changes() - .iter() - .find(|change| change.config_key() == config_key) - .expect("the offer proposes this key") - .action_label() - } - - /// The preview is computed when the file loads and the draft is editable - /// from that moment on, so Apply must not assume the fields still read the - /// way the proposal was built from. The one the user retyped is theirs; the - /// one they left alone still migrates. - #[test] - fn applying_keeps_a_field_the_user_edited_and_migrates_the_rest() { - let (mut app, _dir, path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); - let edited_label = change_label(&app, "toggle_command_palette"); - app.draft - .keybindings - .set(KeybindingField::ToggleCommandPalette, "Ctrl+M".to_string()); - - let _ = app.handle_migration_apply_requested(); - - assert_eq!( - app.draft - .keybindings - .value_for(KeybindingField::ToggleCommandPalette), - Some("Ctrl+M"), - "the user's own edit survives the migration they accepted" - ); - assert_eq!( - app.draft - .keybindings - .value_for(KeybindingField::CaptureFullScreen), - Some("Ctrl+Alt+F"), - "a field the user never touched still migrates" - ); - assert_eq!( - app.draft.config_revision, - Some(CURRENT_CONFIG_REVISION), - "one applied field is a migration, so the revision is recorded" - ); - assert!(status_contains(&app.status, "Applied 1 shortcut update")); - assert!( - status_contains(&app.status, &format!("Kept your edit to {edited_label}")), - "the status has to name what it did not apply: {:?}", - app.status - ); - - let _ = save_draft(&mut app); - let contents = read_config(&path); - assert_eq!( - config_setting(&contents, "toggle_command_palette").as_deref(), - Some("toggle_command_palette = [\"Ctrl+M\"]") - ); - assert_eq!( - config_setting(&contents, "capture_full_screen").as_deref(), - Some("capture_full_screen = [\"Ctrl+Alt+F\"]") - ); - } - - /// Comma spacing is formatting, not an edit: the draft reads its fields as - /// a comma-separated list, so text that parses to the proposal's "before" - /// is still the value the proposal was built from, however it is written. - #[test] - fn applying_is_not_defeated_by_the_spacing_of_an_untouched_field() { - // Revision 1 leaves the `toggle_toolbar` F2 split to propose, which is - // the migration whose "before" is a list of two. - let (mut app, _dir, _path) = app_with_config_file( - "config_revision = 1\n\n[keybindings]\ntoggle_toolbar = [\"F2\", \"F9\"]\n", - ); - assert_eq!( - app.draft - .keybindings - .value_for(KeybindingField::ToggleToolbar), - Some("F2, F9") - ); - app.draft - .keybindings - .set(KeybindingField::ToggleToolbar, "F2,F9".to_string()); - - let _ = app.handle_migration_apply_requested(); - - assert_eq!( - app.draft - .keybindings - .value_for(KeybindingField::ToggleToolbar), - Some("F9"), - "the same list written without a space is not an edit to keep" - ); - assert!(!status_contains(&app.status, "Kept your edit")); - assert_eq!(app.draft.config_revision, Some(CURRENT_CONFIG_REVISION)); - } - - /// Every proposed field already reads as its proposed value — the user - /// typed the migration themselves. Nothing is applied, but nothing is left - /// behind either, so the revision that stops the offer coming back is - /// still recorded. - #[test] - fn applying_records_the_revision_when_every_field_already_matches() { - let (mut app, _dir, _path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); - app.draft.keybindings.set( - KeybindingField::ToggleCommandPalette, - "Ctrl+K, Ctrl+Shift+P".to_string(), - ); - app.draft - .keybindings - .set(KeybindingField::CaptureFullScreen, "Ctrl+Alt+F".to_string()); - - let _ = app.handle_migration_apply_requested(); - - assert_eq!(app.draft.config_revision, Some(CURRENT_CONFIG_REVISION)); - assert!(status_contains(&app.status, "Applied 0 shortcut updates")); - assert!(!status_contains(&app.status, "Kept your edit")); - assert!(status_contains(&app.status, "until you press Save")); - } - - /// The user's own edits can answer every proposed field without taking any - /// of the proposal's exact values. Apply keeps those edits, but it is still - /// an explicit answer to the migration question, so saving records the - /// revision and the resolved offer does not return on the next launch. - #[test] - fn a_fully_customized_apply_records_the_revision_without_reoffering() { - let (mut app, _dir, path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); - let palette_label = change_label(&app, "toggle_command_palette"); - let capture_label = change_label(&app, "capture_full_screen"); - app.draft - .keybindings - .set(KeybindingField::ToggleCommandPalette, "Ctrl+M".to_string()); - app.draft - .keybindings - .set(KeybindingField::CaptureFullScreen, "Ctrl+M+F".to_string()); - let _ = app.handle_migration_apply_requested(); - - assert_eq!( - app.draft.config_revision, - Some(CURRENT_CONFIG_REVISION), - "Apply records that every proposed field was reviewed" - ); - assert!(status_contains(&app.status, "Applied 0 shortcut updates")); - assert!(status_contains(&app.status, palette_label)); - assert!(status_contains(&app.status, capture_label)); - assert!( - status_contains(&app.status, "until you press Save"), - "the status has to say the acknowledged revision is still only a draft: {:?}", - app.status - ); - - let _ = save_draft(&mut app); - let contents = read_config(&path); - assert_eq!( - config_setting(&contents, "config_revision"), - Some(format!("config_revision = {CURRENT_CONFIG_REVISION}")), - "saving records the reviewed migration generation" - ); - - load_config_file(&mut app, &path); - assert!( - app.pending_migration().is_none(), - "the user's custom values resolved the offer, so it stays answered after reload" - ); - } - - /// A dismissal answers the question about one configuration. With - /// `config.toml` a link into one profile among several, retargeting it and - /// pressing Reload brings up a file the user has never been asked about — - /// and the earlier answer must not hide its offer until a restart. - #[cfg(unix)] - #[test] - fn dismissal_follows_the_file_not_the_path_across_a_retarget() { - use std::os::unix::fs::symlink; - - let dir = crate::test_temp::tempdir().expect("temporary test directory"); - let first = dir.path().join("profile-a.toml"); - let second = dir.path().join("profile-b.toml"); - std::fs::write(&first, LEGACY_REVISION_ZERO_CONFIG).expect("write the first profile"); - std::fs::write(&second, LEGACY_REVISION_ZERO_CONFIG).expect("write the second profile"); - let link = dir.path().join("config.toml"); - symlink(&first, &link).expect("link the config path at the first profile"); - - let (mut app, _effects) = ConfiguratorApp::new_app(); - load_config_file(&mut app, &link); - assert!(app.pending_migration().is_some()); - - let _ = app.handle_migration_dismissed(); - assert!(app.pending_migration().is_none()); - - load_config_file(&mut app, &link); - assert!( - app.pending_migration().is_none(), - "the same file reloaded is not the user asking again" - ); - - std::fs::remove_file(&link).expect("unlink the config path"); - symlink(&second, &link).expect("retarget the config path at the second profile"); - load_config_file(&mut app, &link); - - assert!( - app.pending_migration().is_some(), - "a different file behind the same path has never been answered" - ); - } - - #[test] - fn a_current_revision_file_never_offers_a_migration() { - let (app, _dir, _path) = app_with_config_file(&format!( - "config_revision = {CURRENT_CONFIG_REVISION}\n\n[keybindings]\ntoggle_command_palette = [\"Ctrl+K\"]\n" - )); - - assert!(app.pending_migration().is_none()); - } - - /// An empty file spells no shortcut out, so every recipe declines and - /// there is nothing to review. A missing file is the same answer from the - /// other direction: the document is this build's defaults. - #[test] - fn an_empty_or_missing_file_never_offers_a_migration() { - let (app, _dir, _path) = app_with_config_file(""); - assert!(app.pending_migration().is_none()); - - let dir = crate::test_temp::tempdir().expect("temporary test directory"); - let (mut app, _effects) = ConfiguratorApp::new_app(); - load_config_file(&mut app, &dir.path().join("missing.toml")); - - assert!(app.pending_migration().is_none()); - } - - /// The input-HUD step proposes unbinding a default the file never spelled - /// out, and loading already dropped that default from the effective - /// keymap — so the draft text does not change at all. The revision is - /// what makes the applied migration savable. - #[test] - fn applying_marks_the_draft_dirty_when_only_the_revision_changes() { - let (mut app, _dir, path) = app_with_config_file( - "config_revision = 2\n\n[keybindings]\ncapture_clipboard_full = [\"Ctrl+Shift+K\"]\n", - ); - let text_before = app - .draft - .keybindings - .value_for(KeybindingField::ToggleInputHud) - .map(str::to_string); - - let _ = app.handle_migration_apply_requested(); - - assert_eq!( - app.draft - .keybindings - .value_for(KeybindingField::ToggleInputHud) - .map(str::to_string), - text_before, - "loading already resolved this binding away, so the text is unchanged" - ); - assert!(app.is_dirty, "the proposed revision is a draft change"); - - let _ = save_draft(&mut app); - - let contents = read_config(&path); - assert_eq!( - config_setting(&contents, "config_revision"), - Some(format!("config_revision = {CURRENT_CONFIG_REVISION}")) - ); - assert_eq!( - config_setting(&contents, "capture_clipboard_full").as_deref(), - Some("capture_clipboard_full = [\"Ctrl+Shift+K\"]"), - "the authored binding the migration protects is left alone" - ); +mod tests; - load_config_file(&mut app, &path); - assert!(app.pending_migration().is_none()); - } -} +pub(crate) use status::migration_offer_text; diff --git a/configurator/src/app/update/config/defaults.rs b/configurator/src/app/update/config/defaults.rs new file mode 100644 index 00000000..7f8cea0f --- /dev/null +++ b/configurator/src/app/update/config/defaults.rs @@ -0,0 +1,87 @@ +use super::super::super::effects::Effect; +use super::super::super::state::{ + ConfiguratorApp, ConfirmationPrompt, PendingConfirmation, StatusMessage, +}; + +impl ConfiguratorApp { + /// Arms the confirmation, and only that. + /// + /// Asking is one message and answering is another, so no amount of + /// pressing the control that asks can replace the draft: a repeat while + /// the confirmation already stands is nothing, which is what makes a + /// double-click on "Defaults" harmless. Any other edit disarms it through + /// `refresh_dirty_flag`, which is the same standing-down the Cancel + /// control asks for explicitly. + pub(in crate::app::update) fn handle_reset_to_defaults_requested(&mut self) -> Vec { + if self.is_loading || self.is_saving || self.defaults_reset_pending() { + return Vec::new(); + } + + self.pending_confirmation = Some(PendingConfirmation::DefaultsReset); + self.status = StatusMessage::confirmation(ConfirmationPrompt::DefaultsReset); + Vec::new() + } + + /// Applies the defaults, and only while the confirmation this answers is + /// still armed. + /// + /// The typed pending identity is the whole guard: every transition that could have + /// invalidated the question — a load, a save, a reload, an edit to the + /// draft the user is about to lose — clears it, so a confirmation that + /// outlived its question answers nothing. + pub(in crate::app::update) fn handle_reset_to_defaults_confirmed(&mut self) -> Vec { + if !self.defaults_reset_pending() { + return Vec::new(); + } + + self.draft = self.defaults.clone(); + self.override_mode = self.draft.ui_toolbar_layout_mode; + self.boards_collapsed = vec![false; self.draft.boards.items.len()]; + self.color_picker_hex.clear(); + self.sync_all_color_picker_hex(); + self.clear_defaults_confirmation(); + self.status = StatusMessage::info("Loaded default configuration (not saved)."); + self.refresh_dirty_flag(); + Vec::new() + } + + /// Stands the confirmation down, and takes its hint off the status line + /// only while that hint is what the line still holds. + /// + /// Guarded on the same flag as the confirm: with nothing armed there is + /// no question to withdraw, so a stray cancel must not wipe a status the + /// user is reading. Disarming and clearing are separate because another + /// operation may have replaced the hint with newer feedback while the + /// question remained open. + pub(in crate::app::update) fn handle_reset_to_defaults_canceled(&mut self) -> Vec { + if !self.defaults_reset_pending() { + return Vec::new(); + } + + self.clear_defaults_confirmation(); + if self + .status + .is_confirmation(ConfirmationPrompt::DefaultsReset) + { + self.status = StatusMessage::idle(); + } + Vec::new() + } + + /// Cancels the one confirmation currently owned by the model. + /// + /// Escape does not know which inline controls are visible; the typed + /// pending identity does. Taking that identity first makes a repeated key + /// press a no-op, and the status is cleared only while it still belongs to + /// the same question. + pub(in crate::app::update) fn handle_active_confirmation_canceled(&mut self) -> Vec { + let Some(pending) = self.pending_confirmation.take() else { + return Vec::new(); + }; + + if self.status.is_confirmation(pending.prompt()) { + self.status = StatusMessage::idle(); + } + Vec::new() + } +} diff --git a/configurator/src/app/update/config/load.rs b/configurator/src/app/update/config/load.rs new file mode 100644 index 00000000..02163728 --- /dev/null +++ b/configurator/src/app/update/config/load.rs @@ -0,0 +1,62 @@ +use wayscriber::config::ConfigDocument; + +use crate::models::ConfigDraft; + +use super::super::super::effects::Effect; +use super::super::super::state::{ConfiguratorApp, StatusMessage}; +use super::status::config_document_status; + +impl ConfiguratorApp { + pub(in crate::app::update) fn handle_config_loaded( + &mut self, + result: Result<(Box, Option), String>, + ) -> Vec { + self.is_loading = false; + match result { + Ok((document, repair_warning)) => { + let draft = ConfigDraft::from_config(document.config()); + self.draft = draft.clone(); + self.baseline = draft; + self.override_mode = self.draft.ui_toolbar_layout_mode; + self.boards_collapsed = vec![false; self.draft.boards.items.len()]; + self.color_picker_hex.clear(); + self.sync_all_color_picker_hex(); + self.is_dirty = false; + self.clear_defaults_confirmation(); + self.refresh_migration_preview(&document); + self.status = repair_warning.map_or_else( + || config_document_status(&document, "Configuration loaded from disk."), + |warning| { + StatusMessage::warning(format!( + "The configuration could not be parsed, so built-in defaults were loaded for repair. Saving will create a backup before replacing the unreadable configuration with this draft. Unknown settings are retained only when the TOML structure is parseable and they can be separated safely; malformed TOML content remains only in the backup.\n{warning}" + )) + }, + ); + // Last, so everything above reads the document by reference and + // the model takes ownership of exactly one copy. + self.base_document = Some(*document); + } + Err(err) => { + self.status = + StatusMessage::error(format!("Failed to load config from disk: {err}")); + } + } + + // After the status is set, so a note about a startup argument can be + // added to this file's diagnostics instead of replacing them. This is + // also the only place a destination is applied: the tabs it chooses + // are only meaningful once the configuration behind them has loaded. + self.apply_startup_request() + } + + pub(in crate::app::update) fn handle_reload_requested(&mut self) -> Vec { + if !self.is_loading && !self.is_saving { + self.is_loading = true; + self.clear_defaults_confirmation(); + self.status = StatusMessage::info("Reloading configuration..."); + return vec![Effect::LoadConfig]; + } + + Vec::new() + } +} diff --git a/configurator/src/app/update/config/migration.rs b/configurator/src/app/update/config/migration.rs new file mode 100644 index 00000000..82022dab --- /dev/null +++ b/configurator/src/app/update/config/migration.rs @@ -0,0 +1,101 @@ +use wayscriber::config::{ConfigDocument, MigrationPreview}; + +use crate::models::KeybindingField; + +use super::super::super::effects::Effect; +use super::super::super::state::{ConfiguratorApp, StatusMessage}; +use super::status::list_with_overflow; + +impl ConfiguratorApp { + /// Recomputes what a migration would propose for the document now in hand. + /// + /// The authored values are the ones to diff: proposing a change to a + /// binding that only exists because loading dropped a contested key would + /// offer the user an edit their file never contained. + /// + /// A dismissal answers the question for one file, so it survives a reload + /// of that same file and no other. With `config.toml` a link into one + /// profile among several, retargeting it and pressing Reload brings up a + /// configuration the user has never been asked about; keeping the earlier + /// answer would hide its offer until the app is restarted. The document's + /// destination is what tells the two apart — the path is the same either + /// way. + pub(super) fn refresh_migration_preview(&mut self, document: &ConfigDocument) { + if self.migration_dismissed.as_deref() != Some(document.destination()) { + self.migration_dismissed = None; + } + self.migration_preview = MigrationPreview::for_authored_config(document.authored_config()); + } + + pub(in crate::app::update) fn handle_migration_apply_requested(&mut self) -> Vec { + if self.is_loading || self.is_saving { + return Vec::new(); + } + let Some(preview) = self.pending_migration().cloned() else { + return Vec::new(); + }; + + let mut applied = 0usize; + let mut kept = Vec::new(); + for change in preview.changes() { + // A key this build has no field for cannot be shown or edited, so + // it is left alone rather than written blind. + let Some(field) = KeybindingField::from_field_key(change.config_key()) else { + continue; + }; + // The preview was computed when the file loaded; the draft has been + // editable ever since. A field that no longer reads as the "before" + // the proposal was built from is the user's own edit, and applying + // the proposal's "after" over it would silently discard what they + // typed — so it is kept and reported instead. + if self.draft.keybindings.parses_to(field, change.before()) { + self.draft.keybindings.set(field, change.after().join(", ")); + applied += 1; + } else if !self.draft.keybindings.parses_to(field, change.after()) { + kept.push(change.action_label()); + } + } + // Apply answers the migration question even when the user's own edits + // cover every proposed field. Those edits are kept above; recording the + // revision says this generation was reviewed, not that every shipped + // default was copied verbatim. Without the stamp, customized fields make + // the recipes decline on the next load anyway, leaving an old revision + // while the status incorrectly promises the offer will return. + self.draft.config_revision = Some(preview.proposed_revision()); + self.migration_preview = None; + let label = if applied == 1 { + "shortcut update" + } else { + "shortcut updates" + }; + let mut message = format!("Applied {applied} {label} to the draft."); + if !kept.is_empty() { + message.push_str(&format!( + " Kept your edit to {}.", + list_with_overflow(&kept, ", ") + )); + } + message.push_str(" Nothing is written until you press Save."); + self.status = StatusMessage::info(message); + self.refresh_dirty_flag(); + + Vec::new() + } + + pub(in crate::app::update) fn handle_migration_dismissed(&mut self) -> Vec { + // Left silent on purpose: the status banner may be carrying the load + // diagnostics for this file, and hiding the offer is not worth losing + // them over. + // + // Recorded against the file the offer was about, not the path that + // reached it: only a reload landing on that same file is the reload + // this answer covers. Without a document in hand there is no file to + // name — no load has produced one, or a running save is holding it — + // and an answer already given stands rather than being cleared. + if let Some(document) = self.base_document.as_ref() { + self.migration_dismissed = Some(document.destination().to_path_buf()); + } + + Vec::new() + } +} diff --git a/configurator/src/app/update/config/save.rs b/configurator/src/app/update/config/save.rs new file mode 100644 index 00000000..76112aed --- /dev/null +++ b/configurator/src/app/update/config/save.rs @@ -0,0 +1,140 @@ +use wayscriber::config::{Config, ConfigDocument}; + +use crate::messages::ConfigSaveResult; +use crate::models::ConfigDraft; +use crate::models::error::FormError; + +use super::super::super::effects::Effect; +use super::super::super::state::{ConfiguratorApp, StatusMessage}; +use super::status::{config_document_status, invalid_color_hex_message, save_validation_note}; + +impl ConfiguratorApp { + pub(in crate::app::update) fn handle_save_requested(&mut self) -> Vec { + // `is_loading` counts too: a reload replaces the draft and base + // document when it lands, so a save started underneath it would write + // the pre-reload draft and then be judged against a document it never + // saw — leaving stale fields marked clean and the next save rejected. + if self.is_saving || self.is_loading { + return Vec::new(); + } + self.clear_defaults_confirmation(); + + // Before the document moves anywhere: a hex field the parser rejects + // was never applied to the draft, so saving would write the last value + // that did parse and the reload would wipe the text the user is still + // fixing. + let invalid_hex = self.invalid_color_hex_count(); + if invalid_hex > 0 { + self.status = StatusMessage::error(invalid_color_hex_message(invalid_hex)); + return Vec::new(); + } + + // The write needs the document itself, so the model gives up its only + // copy here and gets one back from `handle_config_saved` either way. + // Taking it is also the "nothing loaded" check: there is one `Option` + // to read, and reading it is what moves the value. + let Some(document) = self.base_document.take() else { + self.status = StatusMessage::error( + "Configuration has not loaded successfully. Reload before saving.", + ); + return Vec::new(); + }; + + match self.prepare_config_to_save(&document) { + Ok(config) => { + self.is_saving = true; + self.status = StatusMessage::info("Saving configuration..."); + vec![Effect::SaveConfig { + document: Box::new(document), + config: Box::new(config), + }] + } + Err(errors) => { + // No write starts, so the document goes straight back: this + // handler must not be a way to lose it. + self.base_document = Some(document); + let message = errors + .into_iter() + .map(|err| format!("{}: {}", err.field, err.message)) + .collect::>() + .join("\n"); + self.status = StatusMessage::error(format!( + "Cannot save due to validation errors:\n{message}" + )); + Vec::new() + } + } + } + + /// The configuration a Save writes, with what validating it had to change + /// in `[keybindings]` kept for the status the completed write reports. + /// + /// The draft rebuilds that section from the editor's own fields, so every + /// list in it is authored and a duplicate the user typed is arbitrated by + /// traversal order rather than filtered as an unauthored default. The + /// arbitration edits the configuration on its way to disk, and the saved + /// file then spells both lists out — leaving nothing for the reloaded + /// document to rediscover — so this is the only place the loss can be seen. + fn prepare_config_to_save( + &mut self, + document: &ConfigDocument, + ) -> Result> { + let mut config = self.draft.to_config(document.config())?; + self.pending_save_validation = config.validate_and_clamp(); + Ok(config) + } + + pub(in crate::app::update) fn handle_config_saved( + &mut self, + result: ConfigSaveResult, + ) -> Vec { + self.is_saving = false; + // Either outcome answers this write; a failed one wrote nothing, so + // there is no resolution to report for it. + let validation = std::mem::take(&mut self.pending_save_validation); + match result { + Ok((backup, saved_document)) => { + let draft = ConfigDraft::from_config(saved_document.config()); + self.last_backup_path = backup.clone(); + self.draft = draft.clone(); + self.baseline = draft; + self.boards_collapsed = vec![false; self.draft.boards.items.len()]; + self.color_picker_hex.clear(); + self.sync_all_color_picker_hex(); + self.is_dirty = false; + self.clear_defaults_confirmation(); + // The file just changed, so the offer has to be recomputed + // against it: an applied migration leaves nothing to propose, + // and an unrelated save leaves the same proposal standing. + self.refresh_migration_preview(&saved_document); + let mut msg = "Configuration saved successfully.".to_string(); + if let Some(path) = backup { + msg.push_str(&format!("\nBackup created at {}", path.display())); + } + let mut status = config_document_status(&saved_document, &msg); + if let Some(note) = save_validation_note(&validation) { + status = status.with_note(¬e); + } + self.status = status; + self.base_document = Some(*saved_document); + } + Err((document, err)) => { + // The write borrowed the model's only document; a failure hands + // it straight back so the draft stays savable. The one case + // with nothing to hand back is a blocking job that never + // returned, which leaves a reload as the way forward. + let restored = document.is_some(); + self.base_document = document.map(|document| *document); + let mut message = format!("Failed to save configuration: {err}"); + if !restored { + message.push_str( + "\nThe loaded configuration did not come back from the failed write. Reload before saving again.", + ); + } + self.status = StatusMessage::error(message); + } + } + + Vec::new() + } +} diff --git a/configurator/src/app/update/config/status.rs b/configurator/src/app/update/config/status.rs new file mode 100644 index 00000000..6b9f2c9d --- /dev/null +++ b/configurator/src/app/update/config/status.rs @@ -0,0 +1,191 @@ +use wayscriber::config::{ + ConfigDiagnosticKind, ConfigDocument, ConfigValidationReport, InvalidKeybinding, + KeybindingConflictResolution, MigrationPreview, +}; + +use super::super::super::state::StatusMessage; + +const SHOWN_DIAGNOSTICS: usize = 8; + +/// Why a save was refused before it began. +/// +/// The count is all the banner can give: which rows are at fault is the row's +/// own job to show, and the fix is the same for every one of them — type a +/// color. Clearing the field is not a way out: the picker edits a color the +/// config requires, so an empty field is refused like any other text that is +/// not a color. +pub(super) fn invalid_color_hex_message(count: usize) -> String { + if count == 1 { + return "1 color field does not hold a color. Enter #RRGGBB or #RRGGBBAA before saving." + .to_string(); + } + format!("{count} color fields do not hold a color. Enter #RRGGBB or #RRGGBBAA before saving.") +} + +pub(super) fn config_document_status(document: &ConfigDocument, success: &str) -> StatusMessage { + let diagnostics = document.diagnostics(); + if diagnostics.is_empty() { + return StatusMessage::success(success); + } + + let mut message = success.to_string(); + let mut unknown = Vec::new(); + let mut conflicts = Vec::new(); + let mut invalid = Vec::new(); + let mut skipped_defaults = Vec::new(); + // Exhaustive on purpose: a kind with no section here would leave the + // status warning-styled and empty of the very thing it is warning about, + // so a new variant has to be a compile error rather than a silent drop. + for diagnostic in diagnostics { + match diagnostic.kind() { + ConfigDiagnosticKind::UnknownSetting => unknown.push(diagnostic.path().to_string()), + // Every keybinding kind is resolved in memory only, so the file + // the editor is showing still contains them: carry the diagnostic's + // own wording, which names the actions, instead of just the path. + ConfigDiagnosticKind::KeybindingConflict => conflicts.push(diagnostic.to_string()), + ConfigDiagnosticKind::InvalidKeybinding => invalid.push(diagnostic.to_string()), + ConfigDiagnosticKind::DefaultShortcutSkipped => { + skipped_defaults.push(diagnostic.to_string()); + } + } + } + + if !unknown.is_empty() { + message.push_str(&format!( + "\nUnrecognized settings were preserved: {}.", + list_with_overflow(&borrowed(&unknown), ", ") + )); + } + if !invalid.is_empty() { + message.push_str(&format!( + "\nShortcuts that could not be parsed are ignored for the running session; the file still has them: {}.", + list_with_overflow(&borrowed(&invalid), "; ") + )); + } + if !conflicts.is_empty() { + message.push_str(&format!( + "\nConflicting shortcuts were resolved for the running session only; the file still has them: {}.", + list_with_overflow(&borrowed(&conflicts), "; ") + )); + } + // Its own sentence, and the last one: nothing in the file is wrong here. + // An action this configuration never mentions was offered a shortcut this + // build added, and the configuration already spends that key. + if !skipped_defaults.is_empty() { + message.push_str(&format!( + "\nNew default shortcuts stayed inactive because this configuration already uses those keys: {}.", + list_with_overflow(&borrowed(&skipped_defaults), "; ") + )); + } + + StatusMessage::warning(message) +} + +/// What validating the saved configuration changed in the shortcuts the user +/// typed, or `None` when it changed nothing. +/// +/// The load-time sentences in [`config_document_status`] all end in "the file +/// still has them", because loading resolves in memory only. These are the +/// other case: the draft is the authored text, the resolution is what reached +/// `config.toml`, and the reloaded document no longer contains the collision +/// to report. Naming which action kept the key and which lost it is therefore +/// the only account the user gets of an edit their Save made for them. +/// +/// A skipped default cannot appear here: the draft spells every action out +/// (`ConfigDraft::to_config` marks the section explicit), so the omitted-default +/// pass has nothing to offer and reports nothing. +pub(super) fn save_validation_note(validation: &ConfigValidationReport) -> Option { + // The summaries, not the full `Display` forms: those say the file keeps + // the shortcut and the session does without it, which is the load story. + let invalid = clauses( + validation + .invalid_keybindings + .iter() + .map(InvalidKeybinding::summary), + ); + let conflicts = clauses( + validation + .keybinding_conflicts + .iter() + .map(KeybindingConflictResolution::summary), + ); + if invalid.is_empty() && conflicts.is_empty() { + return None; + } + + let mut note = String::new(); + if !invalid.is_empty() { + note.push_str(&format!( + "Shortcuts that could not be parsed were left out of the saved configuration: {}.", + list_with_overflow(&borrowed(&invalid), "; ") + )); + } + if !conflicts.is_empty() { + if !note.is_empty() { + note.push('\n'); + } + note.push_str(&format!( + "Shortcuts two actions claimed were settled before saving, and the saved configuration keeps that outcome: {}.", + list_with_overflow(&borrowed(&conflicts), "; ") + )); + } + Some(note) +} + +/// Toast-sized summaries as list items: each is a finished sentence, and the +/// sentence they are listed inside supplies the final stop. +fn clauses(summaries: impl Iterator) -> Vec { + summaries + .map(|summary| summary.trim_end_matches('.').to_string()) + .collect() +} + +fn borrowed(entries: &[String]) -> Vec<&str> { + entries.iter().map(String::as_str).collect() +} + +pub(super) fn list_with_overflow(entries: &[&str], separator: &str) -> String { + let shown = entries + .iter() + .take(SHOWN_DIAGNOSTICS) + .copied() + .collect::>() + .join(separator); + match entries.len().saturating_sub(SHOWN_DIAGNOSTICS) { + 0 => shown, + remaining => format!("{shown}{separator}and {remaining} more"), + } +} + +/// The migration offer as the banner shows it, with its whole change list in +/// view. +/// +/// The list is not behind a Review button: a recipe proposes at most a handful +/// of shortcuts, and putting Apply next to something the user has not read yet +/// is the one thing this flow exists to avoid. +pub(crate) fn migration_offer_text(preview: &MigrationPreview) -> String { + let mut lines = vec![ + "Configuration update available".to_string(), + format!( + "Shortcut defaults changed since this configuration was written. Applying updates this draft only; nothing reaches the file until you press Save, which also records revision {}.", + preview.proposed_revision() + ), + ]; + for change in preview.changes() { + lines.push(format!( + "{} ({}): {} → {}", + change.action_label(), + change.config_key(), + binding_summary(change.before()), + binding_summary(change.after()), + )); + } + lines.join("\n") +} + +fn binding_summary(bindings: &[String]) -> String { + if bindings.is_empty() { + return "unbound".to_string(); + } + bindings.join(", ") +} diff --git a/configurator/src/app/update/config/tests.rs b/configurator/src/app/update/config/tests.rs new file mode 100644 index 00000000..39ed0334 --- /dev/null +++ b/configurator/src/app/update/config/tests.rs @@ -0,0 +1,1309 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use wayscriber::config::{Action, CURRENT_CONFIG_REVISION, Config, ConfigDocument}; + +use super::*; +use crate::app::effects::Effect; +use crate::app::state::{ConfiguratorApp, StatusMessage}; +use crate::models::{ColorPickerId, KeybindingField, ToggleField}; +use crate::test_temp::TempDir; + +fn status_contains(status: &StatusMessage, needle: &str) -> bool { + status.text().is_some_and(|text| text.contains(needle)) +} + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +fn temp_config_document(name: &str, contents: &str) -> (PathBuf, Box) { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "wayscriber-configurator-update-config-{}-{sequence}-{name}.toml", + std::process::id(), + )); + std::fs::write(&path, contents).expect("write test config"); + let document = ConfigDocument::load_from_path(&path).expect("load test config document"); + (path, Box::new(document)) +} + +#[test] +fn handle_config_loaded_success_resets_loading_and_dirty_state() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_dirty = true; + + let (path, document) = temp_config_document("loaded", ""); + let _ = app.handle_config_loaded(Ok((document, None))); + + assert!(!app.is_loading); + assert!(!app.is_dirty); + assert_eq!(app.boards_collapsed.len(), app.draft.boards.items.len()); + assert!(status_contains( + &app.status, + "Configuration loaded from disk." + )); + let _ = std::fs::remove_file(path); +} + +#[test] +fn handle_config_loaded_uses_startup_search_focus_fallback_once() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + + let (first_path, first) = temp_config_document("focus-first", ""); + let _ = app.handle_config_loaded(Ok((first, None))); + + assert_eq!(app.search_focus_serial, 1); + assert!(!app.startup_search_focus_pending); + + // A reload is not a relaunch: the offer was answered by the first load, + // so the caret stays wherever the user put it. + let (second_path, second) = temp_config_document("focus-second", ""); + let _ = app.handle_config_loaded(Ok((second, None))); + + assert_eq!(app.search_focus_serial, 1); + let _ = std::fs::remove_file(first_path); + let _ = std::fs::remove_file(second_path); +} + +#[test] +fn handle_config_loaded_error_preserves_the_last_good_document_and_draft() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let (path, document) = temp_config_document("before-reload-error", ""); + let destination = document.destination().to_path_buf(); + let _ = app.handle_config_loaded(Ok((document, None))); + app.draft.capture_enabled = !app.draft.capture_enabled; + let draft = app.draft.clone(); + + let _ = app.handle_config_loaded(Err("broken".to_string())); + + assert!(!app.is_loading); + assert_eq!( + app.base_document + .as_ref() + .expect("last good document") + .destination(), + destination, + "a failed reload keeps the document the last good load produced" + ); + assert_eq!(app.draft, draft); + assert!(status_contains( + &app.status, + "Failed to load config from disk: broken" + )); + let _ = std::fs::remove_file(path); +} + +#[test] +fn handle_config_loaded_repair_document_allows_saving() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let (path, document) = temp_config_document("repair", ""); + + let _ = app.handle_config_loaded(Ok(( + document, + Some("invalid type: string, expected u32".to_string()), + ))); + + assert!(app.base_document.is_some()); + assert!(matches!(app.status, StatusMessage::Warning(_))); + assert!(status_contains(&app.status, "loaded for repair")); + assert!(status_contains( + &app.status, + "malformed TOML content remains only in the backup" + )); + let _ = app.handle_save_requested(); + assert!(app.is_saving); + let _ = std::fs::remove_file(path); +} + +#[test] +fn handle_config_loaded_surfaces_preserved_unknown_settings() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let (path, document) = temp_config_document("unknown", "future_configurator_option = true\n"); + + let _ = app.handle_config_loaded(Ok((document, None))); + + assert!(matches!(app.status, StatusMessage::Warning(_))); + assert!(status_contains(&app.status, "future_configurator_option")); + assert!(status_contains(&app.status, "were preserved")); + let _ = std::fs::remove_file(path); +} + +/// A resolved shortcut conflict is never written back, so the editor is +/// where the user has to be able to find it (#293). Both sides here are +/// spelled out in the file, which is what makes it their conflict. +#[test] +fn handle_config_loaded_surfaces_resolved_shortcut_conflicts() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let (path, document) = temp_config_document( + "shortcut-conflict", + &format!( + "config_revision = {}\n\n[keybindings]\ntoggle_toolbar = [\"F2\"]\ncycle_toolbar_display = [\"F2\"]\n", + wayscriber::config::CURRENT_CONFIG_REVISION + ), + ); + + let _ = app.handle_config_loaded(Ok((document, None))); + + assert!(matches!(app.status, StatusMessage::Warning(_))); + assert!(status_contains(&app.status, "F2")); + assert!(status_contains(&app.status, "Toggle Toolbar")); + assert!(status_contains(&app.status, "Cycle Toolbar Display")); + assert!(status_contains(&app.status, "running session only")); + assert!( + !status_contains(&app.status, "Unrecognized settings"), + "a conflict is not an unknown setting" + ); + let _ = std::fs::remove_file(path); +} + +/// A default this build added and the file never mentions gets its own +/// sentence: the user's configuration is fine, and the shortcut they read +/// about in the release notes simply is not theirs (#293). +#[test] +fn handle_config_loaded_surfaces_skipped_default_shortcuts() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let (path, document) = temp_config_document( + "skipped-default", + &format!( + "config_revision = {}\n\n[keybindings]\ntoggle_toolbar = [\"F2\", \"F9\"]\n", + wayscriber::config::CURRENT_CONFIG_REVISION + ), + ); + + let _ = app.handle_config_loaded(Ok((document, None))); + + assert!(matches!(app.status, StatusMessage::Warning(_))); + assert!(status_contains(&app.status, "F2")); + assert!(status_contains(&app.status, "Cycle Toolbar Display")); + assert!(status_contains( + &app.status, + "New default shortcuts stayed inactive" + )); + assert!( + !status_contains(&app.status, "Conflicting shortcuts") + && !status_contains(&app.status, "Unrecognized settings"), + "a skipped default is neither a conflict nor an unknown setting" + ); + let _ = std::fs::remove_file(path); +} + +/// A string the parser rejects is dropped for the session and kept by the +/// file, so the editor is where the user has to be able to find it. With +/// nothing else wrong in the file, this section is the entire warning. +#[test] +fn handle_config_loaded_surfaces_shortcuts_that_could_not_be_parsed() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let (path, document) = temp_config_document( + "invalid-shortcut", + &format!( + "config_revision = {}\n\n[keybindings]\nclear_canvas = [\"Ctrl+Shift\"]\n", + wayscriber::config::CURRENT_CONFIG_REVISION + ), + ); + + let _ = app.handle_config_loaded(Ok((document, None))); + + assert!(matches!(app.status, StatusMessage::Warning(_))); + assert!(status_contains(&app.status, "Ctrl+Shift")); + assert!(status_contains(&app.status, "Clear Canvas")); + assert!(status_contains(&app.status, "could not be parsed")); + assert!( + !status_contains(&app.status, "Unrecognized settings") + && !status_contains(&app.status, "Conflicting shortcuts"), + "an unparseable shortcut is neither an unknown setting nor a conflict" + ); + let _ = std::fs::remove_file(path); +} + +/// All three keybinding kinds can land in one file, and each gets its own +/// sentence: they need different fixes, and one of them needs no fix. +#[test] +fn handle_config_loaded_separates_every_keybinding_diagnostic_kind() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let (path, document) = temp_config_document( + "invalid-and-conflicting", + &format!( + "config_revision = {}\n\n[keybindings]\nclear_canvas = [\"Ctrl+Shift\"]\ntoggle_toolbar = [\"F2\", \"F9\"]\nundo = [\"Ctrl+Alt+U\"]\nredo = [\"Ctrl+Alt+U\"]\n", + wayscriber::config::CURRENT_CONFIG_REVISION + ), + ); + + let _ = app.handle_config_loaded(Ok((document, None))); + + assert!(matches!(app.status, StatusMessage::Warning(_))); + assert!(status_contains(&app.status, "could not be parsed")); + assert!(status_contains(&app.status, "running session only")); + assert!(status_contains( + &app.status, + "New default shortcuts stayed inactive" + )); + assert!(status_contains(&app.status, "Ctrl+Shift")); + assert!(status_contains(&app.status, "Ctrl+Alt+U")); + assert!(status_contains(&app.status, "F2")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn handle_save_requested_blocks_without_loaded_document() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + // A fresh app is still running its startup load; this test is about + // the load having finished without producing a document. + app.is_loading = false; + + let effects = app.handle_save_requested(); + + assert!(effects.is_empty()); + assert!(!app.is_saving); + assert!(status_contains( + &app.status, + "Configuration has not loaded successfully" + )); +} + +#[test] +fn handle_save_requested_sets_saving_for_valid_draft() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_saving = false; + let (path, document) = temp_config_document("save-request", ""); + let _ = app.handle_config_loaded(Ok((document, None))); + + let effects = app.handle_save_requested(); + + assert!(matches!(effects.as_slice(), [Effect::SaveConfig { .. }])); + assert!(app.is_saving); + assert!(status_contains(&app.status, "Saving configuration...")); + let _ = std::fs::remove_file(path); +} + +/// The document is the model's only copy, and the write needs it moved. It +/// therefore leaves while the write runs and the result brings one back — +/// the saved document on success. +#[test] +fn the_running_save_holds_the_document_and_the_result_returns_one() { + let (mut app, _dir, _path) = app_with_config_file(""); + + let (document, config) = save_effect(&mut app); + + assert!( + app.base_document.is_none(), + "the write holds the document while it runs" + ); + let (saved, backup) = document + .save_with_backup(*config) + .expect("the document saves") + .into_parts(); + let _ = app.handle_config_saved(Ok((backup, Box::new(saved)))); + + assert!( + app.base_document.is_some(), + "a finished save hands a document back" + ); +} + +/// A write that failed wrote nothing, so the document it borrowed is still +/// the one the editor is against: it comes back, and the next Save works. +#[test] +fn a_failed_save_hands_the_document_back() { + let (mut app, _dir, _path) = app_with_config_file(""); + app.draft.drawing_default_thickness = "6".to_string(); + app.refresh_dirty_flag(); + + let (document, _config) = save_effect(&mut app); + assert!(app.base_document.is_none()); + + let _ = app.handle_config_saved(Err((Some(document), "Permission denied".to_string()))); + + assert!(!app.is_saving); + assert!( + app.base_document.is_some(), + "the document the failed write borrowed must return to the model" + ); + assert!(app.is_dirty, "the draft is still unsaved"); + assert!(status_contains( + &app.status, + "Failed to save configuration: Permission denied" + )); + assert!( + !status_contains(&app.status, "Reload before saving again"), + "the document came back, so there is nothing to reload for: {:?}", + app.status + ); + + // The proof that it came back whole: the very next Save is accepted. + let effects = app.handle_save_requested(); + assert!(matches!(effects.as_slice(), [Effect::SaveConfig { .. }])); +} + +/// The one failure with nothing to hand back is a blocking job that never +/// returned. Saving again cannot work until a reload produces a document, +/// so the status has to say so. +#[test] +fn a_save_whose_job_never_returned_asks_for_a_reload() { + let (mut app, _dir, _path) = app_with_config_file(""); + let (_document, _config) = save_effect(&mut app); + + let _ = app.handle_config_saved(Err((None, "config save blocking job panicked".to_string()))); + + assert!(app.base_document.is_none()); + assert!(status_contains(&app.status, "Reload before saving again")); +} + +/// A draft the converter rejects never reaches a write, so the document +/// must be back in the model by the time the handler returns. +#[test] +fn a_draft_the_converter_rejects_keeps_the_document() { + let (mut app, _dir, _path) = app_with_config_file(""); + app.draft.drawing_default_thickness = "thick".to_string(); + + let effects = app.handle_save_requested(); + + assert!(effects.is_empty()); + assert!(!app.is_saving); + assert!( + app.base_document.is_some(), + "a refused save must not take the document with it" + ); + assert!(status_contains( + &app.status, + "Cannot save due to validation" + )); +} + +/// Hex text the parser rejects was never applied to the draft, so a save +/// would write the last value that did parse and the reload would replace +/// the text with it. The Save is refused instead. +#[test] +fn a_color_field_holding_invalid_hex_blocks_the_save() { + let (mut app, _dir, _path) = app_with_config_file(""); + app.draft.drawing_default_thickness = "6".to_string(); + app.refresh_dirty_flag(); + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#12zz".to_string()); + + assert_eq!(app.invalid_color_hex_count(), 1); + + let effects = app.handle_save_requested(); + + assert!(effects.is_empty()); + assert!(!app.is_saving); + assert!(app.base_document.is_some(), "nothing was written or taken"); + assert!(status_contains(&app.status, "1 color field")); + assert!(status_contains( + &app.status, + "Enter #RRGGBB or #RRGGBBAA before saving" + )); +} + +/// The one way out of the refusal: type a color that parses. +#[test] +fn correcting_the_color_field_allows_the_save_again() { + let (mut app, _dir, _path) = app_with_config_file(""); + app.draft.drawing_default_thickness = "6".to_string(); + app.refresh_dirty_flag(); + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#12zz".to_string()); + assert!(app.handle_save_requested().is_empty()); + + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#102030".to_string()); + + assert_eq!(app.invalid_color_hex_count(), 0); + assert!(matches!( + app.handle_save_requested().as_slice(), + [Effect::SaveConfig { .. }] + )); +} + +/// Clearing is not a way out. The picker edits a color the config +/// requires, so an empty field is an edit the save cannot write: letting +/// it through would keep the previous color and put it straight back in +/// the field on the next reload. +#[test] +fn clearing_the_color_field_keeps_the_save_blocked() { + for cleared in ["", " "] { + let (mut app, _dir, _path) = app_with_config_file(""); + app.draft.drawing_default_thickness = "6".to_string(); + app.refresh_dirty_flag(); + + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, cleared.to_string()); + + assert_eq!(app.invalid_color_hex_count(), 1, "{cleared:?}"); + let effects = app.handle_save_requested(); + assert!(effects.is_empty(), "{cleared:?} must not reach a save"); + assert!(status_contains(&app.status, "1 color field")); + } +} + +/// Deleting the row a refused color was in has to release the save with +/// it: the field is gone from the screen, so nothing is left to fix. +#[test] +fn removing_a_quick_color_releases_the_save_its_hex_had_refused() { + let (mut app, _dir, _path) = app_with_config_file(""); + let _ = app.handle_quick_color_added(); + let last = app.draft.drawing_quick_colors.entries.len() - 1; + let _ = + app.handle_color_picker_hex_changed(ColorPickerId::QuickColor(last), "#12zz".to_string()); + assert!(app.handle_save_requested().is_empty()); + + let _ = app.handle_quick_color_removed(last); + + assert_eq!(app.invalid_color_hex_count(), 0); + assert!(matches!( + app.handle_save_requested().as_slice(), + [Effect::SaveConfig { .. }] + )); +} + +/// The transient the empty rule could have wedged: editing a component +/// resyncs that picker's hex, so a normal edit never leaves the field +/// blank and the save gate never closes behind the user's back. +#[test] +fn applying_a_color_leaves_the_field_holding_that_color() { + let (mut app, _dir, _path) = app_with_config_file(""); + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, String::new()); + assert_eq!(app.invalid_color_hex_count(), 1); + + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#102030".to_string()); + + assert_eq!(app.invalid_color_hex_count(), 0); +} + +/// Several bad fields are one refusal, and the count is what tells the user +/// how much is left to fix. +#[test] +fn every_invalid_color_field_is_counted_for_the_refusal() { + let (mut app, _dir, _path) = app_with_config_file(""); + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpBg, "#12zz".to_string()); + let _ = app.handle_color_picker_hex_changed(ColorPickerId::HelpText, "nope".to_string()); + + assert_eq!(app.invalid_color_hex_count(), 2); + + let _ = app.handle_save_requested(); + + assert!(status_contains(&app.status, "2 color fields")); + assert!(status_contains( + &app.status, + "Enter #RRGGBB or #RRGGBBAA before saving" + )); +} + +#[test] +fn reset_to_defaults_requires_confirmation() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.draft.capture_enabled = !app.defaults.capture_enabled; + let changed_draft = app.draft.clone(); + + let _ = app.handle_reset_to_defaults_requested(); + + assert!(app.defaults_reset_pending()); + assert_eq!(app.draft, changed_draft); + assert!(status_contains(&app.status, "Confirm Defaults")); +} + +/// Asking twice is still asking: the request message cannot apply the +/// defaults, so a double press on "Defaults" leaves the draft alone with +/// the question still standing. +#[test] +fn reset_to_defaults_repeated_request_is_a_no_op() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.draft.capture_enabled = !app.defaults.capture_enabled; + let changed_draft = app.draft.clone(); + + let _ = app.handle_reset_to_defaults_requested(); + let _ = app.handle_reset_to_defaults_requested(); + + assert!(app.defaults_reset_pending()); + assert_eq!(app.draft, changed_draft); + assert!(status_contains(&app.status, "Confirm Defaults")); +} + +/// The confirm is the only message that replaces the draft, and it +/// disarms the question it answered. +#[test] +fn reset_to_defaults_confirmed_applies_the_defaults() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.draft.capture_enabled = !app.defaults.capture_enabled; + app.baseline.capture_enabled = !app.defaults.capture_enabled; + + let _ = app.handle_reset_to_defaults_requested(); + let _ = app.handle_reset_to_defaults_confirmed(); + + assert_eq!(app.draft, app.defaults); + assert!(!app.defaults_reset_pending()); + assert!(status_contains(&app.status, "Loaded default configuration")); + assert!( + app.is_dirty, + "defaults differing from the loaded baseline must read as dirty" + ); +} + +/// A confirm with nothing armed answers no question, so it must not +/// replace a draft the user was never warned about. +#[test] +fn reset_to_defaults_confirmed_without_a_request_changes_nothing() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.draft.capture_enabled = !app.defaults.capture_enabled; + let changed_draft = app.draft.clone(); + + let _ = app.handle_reset_to_defaults_confirmed(); + + assert_eq!(app.draft, changed_draft); + assert!(!app.defaults_reset_pending()); + assert!( + !status_contains(&app.status, "Loaded default configuration"), + "an unarmed confirm reports nothing, because it did nothing" + ); +} + +/// Cancel withdraws the question and takes its warning off the status +/// line, leaving the draft exactly as the user left it. +#[test] +fn reset_to_defaults_canceled_disarms_and_clears_the_hint() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.draft.capture_enabled = !app.defaults.capture_enabled; + let changed_draft = app.draft.clone(); + + let _ = app.handle_reset_to_defaults_requested(); + let _ = app.handle_reset_to_defaults_canceled(); + + assert!(!app.defaults_reset_pending()); + assert_eq!(app.draft, changed_draft); + assert!(matches!(app.status, StatusMessage::Idle)); + + // Nothing is armed now, so the confirm that follows it is inert. + let _ = app.handle_reset_to_defaults_confirmed(); + assert_eq!(app.draft, changed_draft); +} + +#[test] +fn reset_to_defaults_canceled_keeps_status_that_replaced_the_hint() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + + let _ = app.handle_reset_to_defaults_requested(); + app.status = StatusMessage::error("Failed to clear session s-1: nope"); + + let _ = app.handle_reset_to_defaults_canceled(); + + assert!(!app.defaults_reset_pending()); + assert!(matches!(app.status, StatusMessage::Error(_))); + assert!(status_contains(&app.status, "Failed to clear session s-1")); +} + +/// A cancel with nothing armed has no question to withdraw, so it must +/// not wipe the status the user is reading. +#[test] +fn reset_to_defaults_canceled_without_a_request_keeps_the_status() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.status = StatusMessage::error("Failed to load config from disk: nope"); + + let _ = app.handle_reset_to_defaults_canceled(); + + assert!(status_contains(&app.status, "Failed to load config")); +} + +#[test] +fn active_confirmation_cancel_uses_the_typed_owner() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + + let _ = app.handle_reset_to_defaults_requested(); + let effects = app.handle_active_confirmation_canceled(); + + assert!(effects.is_empty()); + assert!(app.pending_confirmation.is_none()); + assert!(matches!(app.status, StatusMessage::Idle)); +} + +#[test] +fn active_confirmation_cancel_preserves_newer_feedback() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + + let _ = app.handle_reset_to_defaults_requested(); + app.status = StatusMessage::error("A newer operation failed"); + let _ = app.handle_active_confirmation_canceled(); + + assert!(app.pending_confirmation.is_none()); + assert!(matches!(app.status, StatusMessage::Error(_))); + assert!(status_contains(&app.status, "newer operation failed")); +} + +#[test] +fn reset_to_defaults_confirmation_is_canceled_by_draft_edit() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + + let _ = app.handle_reset_to_defaults_requested(); + let _ = app.handle_toggle_changed(ToggleField::CaptureEnabled, !app.draft.capture_enabled); + + assert!(!app.defaults_reset_pending()); + assert!(matches!(app.status, StatusMessage::Idle)); +} + +/// The disarming an edit does is what the confirm is guarded on: the +/// stale answer to a withdrawn question must not throw the edit away. +#[test] +fn a_draft_edit_between_request_and_confirm_refuses_the_confirm() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + + let _ = app.handle_reset_to_defaults_requested(); + let _ = app.handle_toggle_changed(ToggleField::CaptureEnabled, !app.draft.capture_enabled); + let edited_draft = app.draft.clone(); + let _ = app.handle_reset_to_defaults_confirmed(); + + assert_eq!(app.draft, edited_draft); + assert_ne!(app.draft, app.defaults); +} + +/// The reviewer's case: the file spells `undo` out and never mentions +/// `clear_canvas`, and the user then types `undo`'s shortcut into the +/// Clear canvas field. The draft is the authored text now, so both lists +/// are explicit and the traversal order settles the collision (core visits +/// `clear_canvas` before `undo`). Classifying the typed binding as an +/// omitted default instead would filter it away, save an empty list, and +/// report success. +#[test] +fn a_shortcut_typed_for_an_omitted_action_is_arbitrated_not_filtered() { + let (mut app, _dir, path) = app_with_config_file(&format!( + "config_revision = {CURRENT_CONFIG_REVISION}\n\n[keybindings]\nundo = [\"Ctrl+Alt+U\"]\n" + )); + app.draft + .keybindings + .set(KeybindingField::ClearCanvas, "Ctrl+Alt+U".to_string()); + + let document = app.base_document.as_ref().expect("a loaded document"); + let mut config = app + .draft + .to_config(document.config()) + .expect("the draft converts to a config"); + let report = config.validate_and_clamp(); + + assert!( + report.skipped_default_shortcuts.is_empty(), + "the user typed this binding; it is not an offer to filter: {:?}", + report.skipped_default_shortcuts + ); + assert_eq!( + config.keybindings.core.clear_canvas, + ["Ctrl+Alt+U"], + "the earlier action in traversal order keeps the key" + ); + assert!(config.keybindings.core.undo.is_empty()); + assert_eq!(report.keybinding_conflicts.len(), 1); + assert_eq!(report.keybinding_conflicts[0].kept(), Action::ClearCanvas); + assert_eq!(report.keybinding_conflicts[0].dropped(), Action::Undo); + + let _ = save_draft(&mut app); + + assert!( + matches!(app.status, StatusMessage::Warning(_)), + "a binding the save took away is not a plain success: {:?}", + app.status + ); + assert!(status_contains(&app.status, "settled before saving")); + assert!( + status_contains( + &app.status, + "Ctrl+Alt+U kept for Clear Canvas, dropped from Undo." + ), + "the status has to name the key, the winner, and the loser: {:?}", + app.status + ); + + let contents = read_config(&path); + assert_eq!( + config_setting(&contents, "clear_canvas").as_deref(), + Some("clear_canvas = [\"Ctrl+Alt+U\"]"), + "the typed binding reaches the file" + ); + assert_eq!( + config_setting(&contents, "undo").as_deref(), + Some("undo = []"), + "the loser is written out too, so the file and the report agree" + ); +} + +/// The same collision the other way around: nothing about the draft is +/// wrong, so a save that resolves nothing says nothing extra. +#[test] +fn a_save_without_shortcut_trouble_stays_a_plain_success() { + let (mut app, _dir, _path) = app_with_config_file(&format!( + "config_revision = {CURRENT_CONFIG_REVISION}\n\n[keybindings]\nundo = [\"Ctrl+Alt+U\"]\n" + )); + app.draft.drawing_default_thickness = "6".to_string(); + + let _ = save_draft(&mut app); + + assert!( + matches!(app.status, StatusMessage::Success(_)), + "unexpected status: {:?}", + app.status + ); + assert!(!status_contains(&app.status, "settled before saving")); +} + +/// A shortcut the editor accepts as text but the parser rejects never +/// reaches the file either, so the save status is the only place it can be +/// reported. +#[test] +fn a_typed_shortcut_the_parser_rejects_is_reported_by_the_save() { + let (mut app, _dir, _path) = app_with_config_file(&format!( + "config_revision = {CURRENT_CONFIG_REVISION}\n\n[keybindings]\nundo = [\"Ctrl+Alt+U\"]\n" + )); + app.draft + .keybindings + .set(KeybindingField::ClearCanvas, "Ctrl+Shift".to_string()); + + let _ = save_draft(&mut app); + + assert!( + matches!(app.status, StatusMessage::Warning(_)), + "unexpected status: {:?}", + app.status + ); + assert!(status_contains(&app.status, "Ctrl+Shift")); + assert!(status_contains(&app.status, "Clear Canvas")); + assert!(status_contains(&app.status, "could not be parsed")); +} + +/// A reload replaces the draft and base document when it lands, so a save +/// started underneath it would write the pre-reload draft and then be +/// judged against a document it never saw. +#[test] +fn save_is_refused_while_a_reload_is_in_flight() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let (path, document) = temp_config_document("save-during-reload", ""); + app.base_document = Some(*document); + app.is_loading = true; + app.is_dirty = true; + let before = app.status.clone(); + + let _ = app.handle_save_requested(); + + assert!( + !app.is_saving, + "no save may start under an in-flight reload" + ); + assert!(app.is_dirty, "the draft stays dirty for the next attempt"); + assert_eq!( + format!("{:?}", app.status), + format!("{before:?}"), + "a refused save must not claim it is saving" + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn handle_config_saved_success_clears_dirty_and_records_backup() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_saving = true; + app.is_dirty = true; + app.draft.capture_enabled = !app.draft.capture_enabled; + let backup = PathBuf::from("/tmp/wayscriber-config.bak"); + let (path, document) = temp_config_document("saved", ""); + + let _ = app.handle_config_saved(Ok((Some(backup.clone()), document))); + assert!(app.base_document.is_some()); + + assert!(!app.is_saving); + assert!(!app.is_dirty); + assert_eq!(app.last_backup_path, Some(backup)); + assert_eq!(app.draft, app.baseline); + assert!(status_contains( + &app.status, + "Configuration saved successfully." + )); + let _ = std::fs::remove_file(path); +} + +const LEGACY_REVISION_ZERO_CONFIG: &str = "config_revision = 0\n\n[drawing]\ndefault_thickness = 3.0\n\n[keybindings]\ntoggle_command_palette = [\"Ctrl+K\"]\ncapture_full_screen = [\"Ctrl+Shift+P\"]\n"; + +/// A config file of its own, in a directory the test owns: an applied +/// migration saves, and a save drops its `.bak` next to the file. +fn app_with_config_file(contents: &str) -> (ConfiguratorApp, TempDir, PathBuf) { + let dir = crate::test_temp::tempdir().expect("temporary test directory"); + let path = dir.path().join("config.toml"); + std::fs::write(&path, contents).expect("write test config"); + let (mut app, _effects) = ConfiguratorApp::new_app(); + load_config_file(&mut app, &path); + (app, dir, path) +} + +fn load_config_file(app: &mut ConfiguratorApp, path: &Path) { + let document = ConfigDocument::load_from_path(path).expect("load test config document"); + let _ = app.handle_config_loaded(Ok((Box::new(document), None))); +} + +/// The Save path with the executor left out: the handler produces exactly +/// this effect, and `save_config_to_disk` performs exactly this write +/// before handing the outcome back to `handle_config_saved`. +fn save_draft(app: &mut ConfiguratorApp) -> Option { + let (document, config) = save_effect(app); + let (saved, backup) = document + .save_with_backup(*config) + .expect("the document saves") + .into_parts(); + let _ = app.handle_config_saved(Ok((backup.clone(), Box::new(saved)))); + backup +} + +/// The write a Save asked for, unpacked. +fn save_effect(app: &mut ConfiguratorApp) -> (Box, Box) { + let mut effects = app.handle_save_requested(); + assert_eq!(effects.len(), 1, "a Save asks for exactly one write"); + match effects.remove(0) { + Effect::SaveConfig { document, config } => (document, config), + other => panic!("a Save must ask for a write, not {other:?}"), + } +} + +/// One setting exactly as the saved file spells it, with the line wrapping +/// the merge may choose for an array folded into single spaces. +fn config_setting(contents: &str, key: &str) -> Option { + let mut lines = contents.lines().map(str::trim).skip_while(|line| { + !(line.starts_with(key) && line[key.len()..].trim_start().starts_with('=')) + }); + let mut setting = lines.next()?.to_string(); + while setting.matches('[').count() > setting.matches(']').count() { + let Some(continuation) = lines.next() else { + break; + }; + setting.push(' '); + setting.push_str(continuation); + } + Some(setting.split_whitespace().collect::>().join(" ")) +} + +fn read_config(path: &Path) -> String { + std::fs::read_to_string(path).expect("read the saved config") +} + +/// The whole point of the review flow: an old file that the user never +/// migrated keeps both its shortcuts and its revision, however much else +/// they save. +#[test] +fn saving_an_unrelated_field_leaves_old_bindings_and_revision_alone() { + let (mut app, _dir, path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); + assert!(app.pending_migration().is_some()); + + app.draft.drawing_default_thickness = "6".to_string(); + let _ = save_draft(&mut app); + + let contents = read_config(&path); + assert_eq!( + config_setting(&contents, "toggle_command_palette").as_deref(), + Some("toggle_command_palette = [\"Ctrl+K\"]") + ); + assert_eq!( + config_setting(&contents, "capture_full_screen").as_deref(), + Some("capture_full_screen = [\"Ctrl+Shift+P\"]") + ); + assert_eq!( + config_setting(&contents, "config_revision").as_deref(), + Some("config_revision = 0") + ); + assert_eq!( + config_setting(&contents, "default_thickness").as_deref(), + Some("default_thickness = 6.0"), + "the field the user did edit still saves" + ); + assert!( + app.pending_migration().is_some(), + "an unrelated save does not answer the migration question" + ); +} + +#[test] +fn applying_and_saving_writes_the_reviewed_fields_the_revision_and_a_backup() { + let (mut app, _dir, path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); + + let _ = app.handle_migration_apply_requested(); + + assert!(app.is_dirty); + assert!( + app.pending_migration().is_none(), + "the offer is answered once it is applied" + ); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ToggleCommandPalette), + Some("Ctrl+K, Ctrl+Shift+P") + ); + assert_eq!(app.draft.config_revision, Some(CURRENT_CONFIG_REVISION)); + + let backup = save_draft(&mut app).expect("the save creates a backup"); + assert_eq!( + std::fs::read_to_string(&backup).expect("read the backup"), + LEGACY_REVISION_ZERO_CONFIG, + "the backup holds the file as it was before the migration" + ); + + let contents = read_config(&path); + assert_eq!( + config_setting(&contents, "toggle_command_palette").as_deref(), + Some("toggle_command_palette = [\"Ctrl+K\", \"Ctrl+Shift+P\"]") + ); + assert_eq!( + config_setting(&contents, "capture_full_screen").as_deref(), + Some("capture_full_screen = [\"Ctrl+Alt+F\"]") + ); + assert_eq!( + config_setting(&contents, "config_revision"), + Some(format!("config_revision = {CURRENT_CONFIG_REVISION}")) + ); + assert_eq!( + config_setting(&contents, "default_thickness").as_deref(), + Some("default_thickness = 3.0"), + "an applied migration is a keybinding delta, not a rewrite" + ); + + load_config_file(&mut app, &path); + assert!( + app.pending_migration().is_none(), + "the reloaded file is current, so there is nothing left to offer" + ); +} + +/// The banner is the only place the user reads what Apply would do, so it +/// has to name every proposed change as before → after. +#[test] +fn the_migration_offer_text_lists_every_proposed_change() { + let (app, _dir, _path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); + let preview = app.pending_migration().expect("the fixture is out of date"); + + let text = migration_offer_text(preview); + + let mut lines = text.lines(); + assert_eq!(lines.next(), Some("Configuration update available")); + assert!( + lines + .next() + .is_some_and(|line| line.contains("nothing reaches the file until you press Save")), + "{text}" + ); + let changes = lines.collect::>(); + assert_eq!(changes.len(), preview.changes().len()); + for (line, change) in changes.iter().zip(preview.changes()) { + assert!(line.contains(change.config_key()), "{line}"); + assert!(line.contains(" → "), "{line}"); + } +} + +/// Dismissing answers the question for this app run. A reload recomputes +/// the preview, but the user already said no to this file. +#[test] +fn dismissing_hides_the_offer_and_keeps_it_out_of_an_unrelated_save() { + let (mut app, _dir, path) = app_with_config_file( + "[keybindings]\ntoggle_command_palette = [\"Ctrl+K\"]\ncapture_full_screen = [\"Ctrl+Shift+P\"]\n", + ); + assert!(app.pending_migration().is_some()); + + let _ = app.handle_migration_dismissed(); + + assert!(app.pending_migration().is_none()); + assert!(!app.is_dirty, "dismissing changes nothing in the draft"); + + app.draft.drawing_default_thickness = "6".to_string(); + let _ = save_draft(&mut app); + + let contents = read_config(&path); + assert_eq!( + config_setting(&contents, "toggle_command_palette").as_deref(), + Some("toggle_command_palette = [\"Ctrl+K\"]") + ); + assert_eq!( + config_setting(&contents, "capture_full_screen").as_deref(), + Some("capture_full_screen = [\"Ctrl+Shift+P\"]") + ); + assert_eq!( + config_setting(&contents, "config_revision"), + None, + "a file that never recorded a revision is not stamped by an unrelated save" + ); + assert!(app.pending_migration().is_none()); + + load_config_file(&mut app, &path); + assert!( + app.pending_migration().is_none(), + "pressing Reload is not the user asking again" + ); +} + +/// The label the offer itself gives a proposed field, so the assertions on +/// the status text stay in step with the wording the banner shows. +fn change_label(app: &ConfiguratorApp, config_key: &str) -> &'static str { + app.pending_migration() + .expect("a pending migration offer") + .changes() + .iter() + .find(|change| change.config_key() == config_key) + .expect("the offer proposes this key") + .action_label() +} + +/// The preview is computed when the file loads and the draft is editable +/// from that moment on, so Apply must not assume the fields still read the +/// way the proposal was built from. The one the user retyped is theirs; the +/// one they left alone still migrates. +#[test] +fn applying_keeps_a_field_the_user_edited_and_migrates_the_rest() { + let (mut app, _dir, path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); + let edited_label = change_label(&app, "toggle_command_palette"); + app.draft + .keybindings + .set(KeybindingField::ToggleCommandPalette, "Ctrl+M".to_string()); + + let _ = app.handle_migration_apply_requested(); + + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ToggleCommandPalette), + Some("Ctrl+M"), + "the user's own edit survives the migration they accepted" + ); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::CaptureFullScreen), + Some("Ctrl+Alt+F"), + "a field the user never touched still migrates" + ); + assert_eq!( + app.draft.config_revision, + Some(CURRENT_CONFIG_REVISION), + "one applied field is a migration, so the revision is recorded" + ); + assert!(status_contains(&app.status, "Applied 1 shortcut update")); + assert!( + status_contains(&app.status, &format!("Kept your edit to {edited_label}")), + "the status has to name what it did not apply: {:?}", + app.status + ); + + let _ = save_draft(&mut app); + let contents = read_config(&path); + assert_eq!( + config_setting(&contents, "toggle_command_palette").as_deref(), + Some("toggle_command_palette = [\"Ctrl+M\"]") + ); + assert_eq!( + config_setting(&contents, "capture_full_screen").as_deref(), + Some("capture_full_screen = [\"Ctrl+Alt+F\"]") + ); +} + +/// Comma spacing is formatting, not an edit: the draft reads its fields as +/// a comma-separated list, so text that parses to the proposal's "before" +/// is still the value the proposal was built from, however it is written. +#[test] +fn applying_is_not_defeated_by_the_spacing_of_an_untouched_field() { + // Revision 1 leaves the `toggle_toolbar` F2 split to propose, which is + // the migration whose "before" is a list of two. + let (mut app, _dir, _path) = app_with_config_file( + "config_revision = 1\n\n[keybindings]\ntoggle_toolbar = [\"F2\", \"F9\"]\n", + ); + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ToggleToolbar), + Some("F2, F9") + ); + app.draft + .keybindings + .set(KeybindingField::ToggleToolbar, "F2,F9".to_string()); + + let _ = app.handle_migration_apply_requested(); + + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ToggleToolbar), + Some("F9"), + "the same list written without a space is not an edit to keep" + ); + assert!(!status_contains(&app.status, "Kept your edit")); + assert_eq!(app.draft.config_revision, Some(CURRENT_CONFIG_REVISION)); +} + +/// Every proposed field already reads as its proposed value — the user +/// typed the migration themselves. Nothing is applied, but nothing is left +/// behind either, so the revision that stops the offer coming back is +/// still recorded. +#[test] +fn applying_records_the_revision_when_every_field_already_matches() { + let (mut app, _dir, _path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); + app.draft.keybindings.set( + KeybindingField::ToggleCommandPalette, + "Ctrl+K, Ctrl+Shift+P".to_string(), + ); + app.draft + .keybindings + .set(KeybindingField::CaptureFullScreen, "Ctrl+Alt+F".to_string()); + + let _ = app.handle_migration_apply_requested(); + + assert_eq!(app.draft.config_revision, Some(CURRENT_CONFIG_REVISION)); + assert!(status_contains(&app.status, "Applied 0 shortcut updates")); + assert!(!status_contains(&app.status, "Kept your edit")); + assert!(status_contains(&app.status, "until you press Save")); +} + +/// The user's own edits can answer every proposed field without taking any +/// of the proposal's exact values. Apply keeps those edits, but it is still +/// an explicit answer to the migration question, so saving records the +/// revision and the resolved offer does not return on the next launch. +#[test] +fn a_fully_customized_apply_records_the_revision_without_reoffering() { + let (mut app, _dir, path) = app_with_config_file(LEGACY_REVISION_ZERO_CONFIG); + let palette_label = change_label(&app, "toggle_command_palette"); + let capture_label = change_label(&app, "capture_full_screen"); + app.draft + .keybindings + .set(KeybindingField::ToggleCommandPalette, "Ctrl+M".to_string()); + app.draft + .keybindings + .set(KeybindingField::CaptureFullScreen, "Ctrl+M+F".to_string()); + let _ = app.handle_migration_apply_requested(); + + assert_eq!( + app.draft.config_revision, + Some(CURRENT_CONFIG_REVISION), + "Apply records that every proposed field was reviewed" + ); + assert!(status_contains(&app.status, "Applied 0 shortcut updates")); + assert!(status_contains(&app.status, palette_label)); + assert!(status_contains(&app.status, capture_label)); + assert!( + status_contains(&app.status, "until you press Save"), + "the status has to say the acknowledged revision is still only a draft: {:?}", + app.status + ); + + let _ = save_draft(&mut app); + let contents = read_config(&path); + assert_eq!( + config_setting(&contents, "config_revision"), + Some(format!("config_revision = {CURRENT_CONFIG_REVISION}")), + "saving records the reviewed migration generation" + ); + + load_config_file(&mut app, &path); + assert!( + app.pending_migration().is_none(), + "the user's custom values resolved the offer, so it stays answered after reload" + ); +} + +/// A dismissal answers the question about one configuration. With +/// `config.toml` a link into one profile among several, retargeting it and +/// pressing Reload brings up a file the user has never been asked about — +/// and the earlier answer must not hide its offer until a restart. +#[cfg(unix)] +#[test] +fn dismissal_follows_the_file_not_the_path_across_a_retarget() { + use std::os::unix::fs::symlink; + + let dir = crate::test_temp::tempdir().expect("temporary test directory"); + let first = dir.path().join("profile-a.toml"); + let second = dir.path().join("profile-b.toml"); + std::fs::write(&first, LEGACY_REVISION_ZERO_CONFIG).expect("write the first profile"); + std::fs::write(&second, LEGACY_REVISION_ZERO_CONFIG).expect("write the second profile"); + let link = dir.path().join("config.toml"); + symlink(&first, &link).expect("link the config path at the first profile"); + + let (mut app, _effects) = ConfiguratorApp::new_app(); + load_config_file(&mut app, &link); + assert!(app.pending_migration().is_some()); + + let _ = app.handle_migration_dismissed(); + assert!(app.pending_migration().is_none()); + + load_config_file(&mut app, &link); + assert!( + app.pending_migration().is_none(), + "the same file reloaded is not the user asking again" + ); + + std::fs::remove_file(&link).expect("unlink the config path"); + symlink(&second, &link).expect("retarget the config path at the second profile"); + load_config_file(&mut app, &link); + + assert!( + app.pending_migration().is_some(), + "a different file behind the same path has never been answered" + ); +} + +#[test] +fn a_current_revision_file_never_offers_a_migration() { + let (app, _dir, _path) = app_with_config_file(&format!( + "config_revision = {CURRENT_CONFIG_REVISION}\n\n[keybindings]\ntoggle_command_palette = [\"Ctrl+K\"]\n" + )); + + assert!(app.pending_migration().is_none()); +} + +/// An empty file spells no shortcut out, so every recipe declines and +/// there is nothing to review. A missing file is the same answer from the +/// other direction: the document is this build's defaults. +#[test] +fn an_empty_or_missing_file_never_offers_a_migration() { + let (app, _dir, _path) = app_with_config_file(""); + assert!(app.pending_migration().is_none()); + + let dir = crate::test_temp::tempdir().expect("temporary test directory"); + let (mut app, _effects) = ConfiguratorApp::new_app(); + load_config_file(&mut app, &dir.path().join("missing.toml")); + + assert!(app.pending_migration().is_none()); +} + +/// The input-HUD step proposes unbinding a default the file never spelled +/// out, and loading already dropped that default from the effective +/// keymap — so the draft text does not change at all. The revision is +/// what makes the applied migration savable. +#[test] +fn applying_marks_the_draft_dirty_when_only_the_revision_changes() { + let (mut app, _dir, path) = app_with_config_file( + "config_revision = 2\n\n[keybindings]\ncapture_clipboard_full = [\"Ctrl+Shift+K\"]\n", + ); + let text_before = app + .draft + .keybindings + .value_for(KeybindingField::ToggleInputHud) + .map(str::to_string); + + let _ = app.handle_migration_apply_requested(); + + assert_eq!( + app.draft + .keybindings + .value_for(KeybindingField::ToggleInputHud) + .map(str::to_string), + text_before, + "loading already resolved this binding away, so the text is unchanged" + ); + assert!(app.is_dirty, "the proposed revision is a draft change"); + + let _ = save_draft(&mut app); + + let contents = read_config(&path); + assert_eq!( + config_setting(&contents, "config_revision"), + Some(format!("config_revision = {CURRENT_CONFIG_REVISION}")) + ); + assert_eq!( + config_setting(&contents, "capture_clipboard_full").as_deref(), + Some("capture_clipboard_full = [\"Ctrl+Shift+K\"]"), + "the authored binding the migration protects is left alone" + ); + + load_config_file(&mut app, &path); + assert!(app.pending_migration().is_none()); +} diff --git a/configurator/src/app/update/fields.rs b/configurator/src/app/update/fields.rs index 7d30586f..dd96ddc8 100644 --- a/configurator/src/app/update/fields.rs +++ b/configurator/src/app/update/fields.rs @@ -1,14 +1,15 @@ -use wayscriber::config::{PerformanceFieldId, ToolbarItemId, ToolbarItemOrderGroup}; +mod drawing; +#[cfg(test)] +mod tests; +mod ui; + +use wayscriber::config::PerformanceFieldId; use crate::models::{ - ColorMode, ColorPickerId, DragColorOption, DragMouseButton, DragToolField, DragToolOption, - EraserModeOption, FontStyleOption, FontWeightOption, InputHudModeOption, - InputHudPositionOption, KeybindingField, NamedColorOption, OverrideOption, PdfFitModeOption, + FontStyleOption, FontWeightOption, KeybindingField, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, - ReducedMotionOption, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, - TextField, ToggleField, ToolbarLayoutModeOption, ToolbarOverrideField, - ToolbarRebindModifierOption, ToolbarSideLayoutOption, UiThemeOption, ZoomChipDisplayOption, + SessionCompressionOption, SessionStorageModeOption, TextField, ToggleField, }; #[cfg(feature = "tablet-input")] use crate::models::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; @@ -31,327 +32,6 @@ impl ConfiguratorApp { Vec::new() } - pub(super) fn handle_color_mode_changed(&mut self, mode: ColorMode) -> Vec { - self.status = StatusMessage::idle(); - if matches!(mode, ColorMode::Rgb) { - self.draft.drawing_color.sync_rgb_from_preview(); - } - self.draft.drawing_color.mode = mode; - if matches!(mode, ColorMode::Named) { - if self.draft.drawing_color.name.trim().is_empty() { - self.draft.drawing_color.selected_named = NamedColorOption::Red; - self.draft.drawing_color.name = self - .draft - .drawing_color - .selected_named - .as_value() - .to_string(); - } else { - self.draft.drawing_color.update_named_from_current(); - } - } - self.sync_color_picker_hex_for_id(ColorPickerId::DrawingColor); - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_named_color_selected(&mut self, option: NamedColorOption) -> Vec { - self.status = StatusMessage::idle(); - self.draft.drawing_color.selected_named = option; - if option != NamedColorOption::Custom { - self.draft.drawing_color.name = option.as_value().to_string(); - } - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_quick_color_mode_changed( - &mut self, - index: usize, - mode: ColorMode, - ) -> Vec { - self.status = StatusMessage::idle(); - let Some(entry) = self.draft.drawing_quick_colors.get_mut(index) else { - return Vec::new(); - }; - let quick_color = &mut entry.color; - if matches!(mode, ColorMode::Rgb) { - quick_color.sync_rgb_from_preview(); - } - quick_color.mode = mode; - if matches!(mode, ColorMode::Named) { - if quick_color.name.trim().is_empty() { - quick_color.selected_named = NamedColorOption::Red; - quick_color.name = quick_color.selected_named.as_value().to_string(); - } else { - quick_color.update_named_from_current(); - } - } - self.sync_color_picker_hex_for_id(ColorPickerId::QuickColor(index)); - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_quick_named_color_selected( - &mut self, - index: usize, - option: NamedColorOption, - ) -> Vec { - self.status = StatusMessage::idle(); - let Some(entry) = self.draft.drawing_quick_colors.get_mut(index) else { - return Vec::new(); - }; - let quick_color = &mut entry.color; - quick_color.selected_named = option; - if option != NamedColorOption::Custom { - quick_color.name = option.as_value().to_string(); - } - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_quick_color_added(&mut self) -> Vec { - self.status = StatusMessage::idle(); - let new_index = self.draft.drawing_quick_colors.entries.len(); - self.draft.drawing_quick_colors.add_entry(); - self.remap_quick_color_pickers(|index| (index < new_index).then_some(index)); - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_quick_color_removed(&mut self, index: usize) -> Vec { - self.status = StatusMessage::idle(); - if self.draft.drawing_quick_colors.remove_entry(index) { - self.remap_quick_color_pickers(|picker_index| match picker_index.cmp(&index) { - std::cmp::Ordering::Less => Some(picker_index), - std::cmp::Ordering::Equal => None, - std::cmp::Ordering::Greater => picker_index.checked_sub(1), - }); - self.refresh_dirty_flag(); - } - Vec::new() - } - - pub(super) fn handle_quick_color_moved(&mut self, index: usize, delta: isize) -> Vec { - self.status = StatusMessage::idle(); - let Some(target) = index.checked_add_signed(delta) else { - return Vec::new(); - }; - if self.draft.drawing_quick_colors.move_entry(index, delta) { - self.remap_quick_color_pickers(|picker_index| { - if picker_index == target { - Some(index) - } else if picker_index == index { - Some(target) - } else { - Some(picker_index) - } - }); - self.refresh_dirty_flag(); - } - Vec::new() - } - - /// Carries each surviving quick-color edit buffer to its row's new index. - /// - /// The buffer can be half-typed and therefore absent from the draft. Add, - /// move, and removal must preserve it with the row that survives, while a - /// deleted row's buffer must disappear so it cannot hold Save hostage. - fn remap_quick_color_pickers(&mut self, remap: impl Fn(usize) -> Option) { - let count = self.draft.drawing_quick_colors.entries.len(); - let mut preserved = Vec::new(); - self.color_picker_hex.retain(|id, text| match id { - ColorPickerId::QuickColor(index) => { - if let Some(next) = remap(*index).filter(|next| *next < count) { - preserved.push((next, text.clone())); - } - false - } - _ => true, - }); - for (index, text) in preserved { - self.color_picker_hex - .insert(ColorPickerId::QuickColor(index), text); - } - for index in 0..count { - let id = ColorPickerId::QuickColor(index); - if !self.color_picker_hex.contains_key(&id) { - self.sync_color_picker_hex_for_id(id); - } - } - } - - pub(super) fn handle_eraser_mode_changed(&mut self, option: EraserModeOption) -> Vec { - self.status = StatusMessage::idle(); - self.draft.drawing_default_eraser_mode = option; - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_drawing_mouse_drag_tool_changed( - &mut self, - button: DragMouseButton, - field: DragToolField, - option: DragToolOption, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.set_mouse_drag_tool(button, field, option); - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_drawing_mouse_drag_color_changed( - &mut self, - button: DragMouseButton, - field: DragToolField, - option: DragColorOption, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.set_mouse_drag_color(button, field, option); - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_status_position_changed( - &mut self, - option: StatusPositionOption, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.ui_status_position = option; - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_input_hud_mode_changed( - &mut self, - option: InputHudModeOption, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.input_hud_mode = option; - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_input_hud_position_changed( - &mut self, - option: InputHudPositionOption, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.input_hud_position = option; - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_ui_theme_changed(&mut self, option: UiThemeOption) -> Vec { - self.status = StatusMessage::idle(); - self.draft.ui_theme = option; - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_ui_reduced_motion_changed( - &mut self, - option: ReducedMotionOption, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.ui_reduced_motion = option; - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_toolbar_layout_mode_changed( - &mut self, - option: ToolbarLayoutModeOption, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.apply_toolbar_layout_mode(option); - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_toolbar_side_layout_changed( - &mut self, - option: ToolbarSideLayoutOption, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.ui_toolbar_side_layout = option; - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_toolbar_zoom_chip_display_changed( - &mut self, - option: ZoomChipDisplayOption, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.ui_toolbar_zoom_chip_display = option; - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_toolbar_rebind_modifier_changed( - &mut self, - option: ToolbarRebindModifierOption, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.ui_toolbar_rebind_modifier = option; - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_toolbar_override_mode_changed( - &mut self, - option: ToolbarLayoutModeOption, - ) -> Vec { - self.override_mode = option; - Vec::new() - } - - pub(super) fn handle_toolbar_override_changed( - &mut self, - field: ToolbarOverrideField, - option: OverrideOption, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft - .set_toolbar_override(self.override_mode, field, option); - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_toolbar_item_visibility_changed( - &mut self, - id: ToolbarItemId, - visible: bool, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.set_toolbar_item_visible(id, visible); - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_toolbar_item_move_requested( - &mut self, - group: ToolbarItemOrderGroup, - id: ToolbarItemId, - delta: isize, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.move_toolbar_item(group, id, delta); - self.refresh_dirty_flag(); - Vec::new() - } - - pub(super) fn handle_toolbar_item_order_reset( - &mut self, - group: ToolbarItemOrderGroup, - ) -> Vec { - self.status = StatusMessage::idle(); - self.draft.reset_toolbar_item_order(group); - self.refresh_dirty_flag(); - Vec::new() - } - pub(super) fn handle_session_storage_mode_changed( &mut self, option: SessionStorageModeOption, @@ -519,165 +199,3 @@ impl ConfiguratorApp { Vec::new() } } - -#[cfg(test)] -mod tests { - use super::*; - use wayscriber::config::{ColorSpec, Config}; - - #[test] - fn quick_color_mode_change_to_rgb_materializes_named_hex_preview() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let entry = &mut app.draft.drawing_quick_colors.entries[1]; - entry.color.mode = ColorMode::Named; - entry.color.selected_named = NamedColorOption::Custom; - entry.color.name = "#123456".to_string(); - - let _ = app.handle_quick_color_mode_changed(1, ColorMode::Rgb); - - assert_eq!( - app.draft.drawing_quick_colors.entries[1].color.rgb, - ["18", "52", "86"] - ); - - let saved = app - .draft - .to_config(&Config::default()) - .expect("expected quick color RGB to save"); - - assert_eq!( - saved.drawing.quick_colors.entries[1].color, - ColorSpec::Rgb([18, 52, 86]) - ); - } - - /// Deleting a quick color takes its picker's editing text with it. - /// - /// Without the prune the removed slot's text stays in the map with no row - /// left to show it, and text the save gate refuses keeps Save disabled - /// over a field the user can no longer reach. - #[test] - fn removing_the_last_quick_color_drops_the_hex_text_it_left_behind() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let _ = app.handle_quick_color_added(); - let last = app.draft.drawing_quick_colors.entries.len() - 1; - let _ = app - .handle_color_picker_hex_changed(ColorPickerId::QuickColor(last), "#12zz".to_string()); - assert_eq!(app.invalid_color_hex_count(), 1); - - let _ = app.handle_quick_color_removed(last); - - assert!( - !app.color_picker_hex - .contains_key(&ColorPickerId::QuickColor(last)), - "the removed slot's picker text must go with the row" - ); - assert_eq!(app.invalid_color_hex_count(), 0); - } - - #[test] - fn removing_a_different_quick_color_preserves_a_half_typed_hex() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let _ = app.handle_quick_color_added(); - let last = app.draft.drawing_quick_colors.entries.len() - 1; - let _ = - app.handle_color_picker_hex_changed(ColorPickerId::QuickColor(0), "#12zz".to_string()); - - let _ = app.handle_quick_color_removed(last); - - assert_eq!( - app.color_picker_hex - .get(&ColorPickerId::QuickColor(0)) - .map(String::as_str), - Some("#12zz") - ); - assert_eq!(app.invalid_color_hex_count(), 1); - } - - /// Reordering remaps every surviving slot, so the editing text follows the - /// row it was moved with rather than staying at its old position. - #[test] - fn moving_a_quick_color_resyncs_the_pickers_that_survive() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - app.draft.drawing_quick_colors.entries[0].color.mode = ColorMode::Rgb; - app.draft.drawing_quick_colors.entries[0].color.rgb = - ["1".to_string(), "2".to_string(), "3".to_string()]; - app.draft.drawing_quick_colors.entries[1].color.mode = ColorMode::Rgb; - app.draft.drawing_quick_colors.entries[1].color.rgb = - ["4".to_string(), "5".to_string(), "6".to_string()]; - app.sync_all_color_picker_hex(); - let first = app - .color_picker_hex - .get(&ColorPickerId::QuickColor(0)) - .cloned(); - - let _ = app.handle_quick_color_moved(0, 1); - - assert_eq!( - app.color_picker_hex.get(&ColorPickerId::QuickColor(1)), - first.as_ref() - ); - } - - #[test] - fn adding_a_quick_color_preserves_a_half_typed_surviving_hex() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let _ = - app.handle_color_picker_hex_changed(ColorPickerId::QuickColor(0), "#12zz".to_string()); - - let _ = app.handle_quick_color_added(); - - assert_eq!( - app.color_picker_hex - .get(&ColorPickerId::QuickColor(0)) - .map(String::as_str), - Some("#12zz") - ); - assert_eq!(app.invalid_color_hex_count(), 1); - } - - #[test] - fn moving_a_quick_color_carries_its_half_typed_hex_with_it() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - let _ = - app.handle_color_picker_hex_changed(ColorPickerId::QuickColor(0), "#12zz".to_string()); - - let _ = app.handle_quick_color_moved(0, 1); - - assert_eq!( - app.color_picker_hex - .get(&ColorPickerId::QuickColor(1)) - .map(String::as_str), - Some("#12zz") - ); - assert_ne!( - app.color_picker_hex - .get(&ColorPickerId::QuickColor(0)) - .map(String::as_str), - Some("#12zz") - ); - assert_eq!(app.invalid_color_hex_count(), 1); - } - - #[test] - fn quick_color_label_edit_does_not_change_slot_colors() { - let (mut app, _effects) = ConfiguratorApp::new_app(); - - let _ = app.handle_text_changed(TextField::QuickColorLabel(0), "RedNew".to_string()); - - // The built-in defaults are named colors resolving to the tuned - // palette, so the slots stay on their named values after a label edit. - assert_eq!(app.draft.drawing_quick_colors.entries[0].color.name, "red"); - assert_eq!( - app.draft.drawing_quick_colors.entries[1].color.name, - "green" - ); - assert_eq!(app.draft.drawing_quick_colors.entries[2].color.name, "blue"); - assert_eq!( - app.draft.drawing_quick_colors.entries[0] - .color - .selected_named, - NamedColorOption::Red - ); - } -} diff --git a/configurator/src/app/update/fields/drawing.rs b/configurator/src/app/update/fields/drawing.rs new file mode 100644 index 00000000..606ed409 --- /dev/null +++ b/configurator/src/app/update/fields/drawing.rs @@ -0,0 +1,206 @@ +use crate::models::{ + ColorMode, ColorPickerId, DragColorOption, DragMouseButton, DragToolField, DragToolOption, + EraserModeOption, NamedColorOption, +}; + +use super::super::super::effects::Effect; +use super::super::super::state::{ConfiguratorApp, StatusMessage}; + +impl ConfiguratorApp { + pub(in crate::app::update) fn handle_color_mode_changed( + &mut self, + mode: ColorMode, + ) -> Vec { + self.status = StatusMessage::idle(); + if matches!(mode, ColorMode::Rgb) { + self.draft.drawing_color.sync_rgb_from_preview(); + } + self.draft.drawing_color.mode = mode; + if matches!(mode, ColorMode::Named) { + if self.draft.drawing_color.name.trim().is_empty() { + self.draft.drawing_color.selected_named = NamedColorOption::Red; + self.draft.drawing_color.name = self + .draft + .drawing_color + .selected_named + .as_value() + .to_string(); + } else { + self.draft.drawing_color.update_named_from_current(); + } + } + self.sync_color_picker_hex_for_id(ColorPickerId::DrawingColor); + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_named_color_selected( + &mut self, + option: NamedColorOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.drawing_color.selected_named = option; + if option != NamedColorOption::Custom { + self.draft.drawing_color.name = option.as_value().to_string(); + } + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_quick_color_mode_changed( + &mut self, + index: usize, + mode: ColorMode, + ) -> Vec { + self.status = StatusMessage::idle(); + let Some(entry) = self.draft.drawing_quick_colors.get_mut(index) else { + return Vec::new(); + }; + let quick_color = &mut entry.color; + if matches!(mode, ColorMode::Rgb) { + quick_color.sync_rgb_from_preview(); + } + quick_color.mode = mode; + if matches!(mode, ColorMode::Named) { + if quick_color.name.trim().is_empty() { + quick_color.selected_named = NamedColorOption::Red; + quick_color.name = quick_color.selected_named.as_value().to_string(); + } else { + quick_color.update_named_from_current(); + } + } + self.sync_color_picker_hex_for_id(ColorPickerId::QuickColor(index)); + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_quick_named_color_selected( + &mut self, + index: usize, + option: NamedColorOption, + ) -> Vec { + self.status = StatusMessage::idle(); + let Some(entry) = self.draft.drawing_quick_colors.get_mut(index) else { + return Vec::new(); + }; + let quick_color = &mut entry.color; + quick_color.selected_named = option; + if option != NamedColorOption::Custom { + quick_color.name = option.as_value().to_string(); + } + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_quick_color_added(&mut self) -> Vec { + self.status = StatusMessage::idle(); + let new_index = self.draft.drawing_quick_colors.entries.len(); + self.draft.drawing_quick_colors.add_entry(); + self.remap_quick_color_pickers(|index| (index < new_index).then_some(index)); + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_quick_color_removed( + &mut self, + index: usize, + ) -> Vec { + self.status = StatusMessage::idle(); + if self.draft.drawing_quick_colors.remove_entry(index) { + self.remap_quick_color_pickers(|picker_index| match picker_index.cmp(&index) { + std::cmp::Ordering::Less => Some(picker_index), + std::cmp::Ordering::Equal => None, + std::cmp::Ordering::Greater => picker_index.checked_sub(1), + }); + self.refresh_dirty_flag(); + } + Vec::new() + } + + pub(in crate::app::update) fn handle_quick_color_moved( + &mut self, + index: usize, + delta: isize, + ) -> Vec { + self.status = StatusMessage::idle(); + let Some(target) = index.checked_add_signed(delta) else { + return Vec::new(); + }; + if self.draft.drawing_quick_colors.move_entry(index, delta) { + self.remap_quick_color_pickers(|picker_index| { + if picker_index == target { + Some(index) + } else if picker_index == index { + Some(target) + } else { + Some(picker_index) + } + }); + self.refresh_dirty_flag(); + } + Vec::new() + } + + /// Carries each surviving quick-color edit buffer to its row's new index. + /// + /// The buffer can be half-typed and therefore absent from the draft. Add, + /// move, and removal must preserve it with the row that survives, while a + /// deleted row's buffer must disappear so it cannot hold Save hostage. + fn remap_quick_color_pickers(&mut self, remap: impl Fn(usize) -> Option) { + let count = self.draft.drawing_quick_colors.entries.len(); + let mut preserved = Vec::new(); + self.color_picker_hex.retain(|id, text| match id { + ColorPickerId::QuickColor(index) => { + if let Some(next) = remap(*index).filter(|next| *next < count) { + preserved.push((next, text.clone())); + } + false + } + _ => true, + }); + for (index, text) in preserved { + self.color_picker_hex + .insert(ColorPickerId::QuickColor(index), text); + } + for index in 0..count { + let id = ColorPickerId::QuickColor(index); + if !self.color_picker_hex.contains_key(&id) { + self.sync_color_picker_hex_for_id(id); + } + } + } + + pub(in crate::app::update) fn handle_eraser_mode_changed( + &mut self, + option: EraserModeOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.drawing_default_eraser_mode = option; + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_drawing_mouse_drag_tool_changed( + &mut self, + button: DragMouseButton, + field: DragToolField, + option: DragToolOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.set_mouse_drag_tool(button, field, option); + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_drawing_mouse_drag_color_changed( + &mut self, + button: DragMouseButton, + field: DragToolField, + option: DragColorOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.set_mouse_drag_color(button, field, option); + self.refresh_dirty_flag(); + Vec::new() + } +} diff --git a/configurator/src/app/update/fields/tests.rs b/configurator/src/app/update/fields/tests.rs new file mode 100644 index 00000000..338361cd --- /dev/null +++ b/configurator/src/app/update/fields/tests.rs @@ -0,0 +1,158 @@ +use super::*; +use wayscriber::config::{ColorSpec, Config}; + +use crate::app::state::ConfiguratorApp; +use crate::models::{ColorMode, ColorPickerId, NamedColorOption}; + +#[test] +fn quick_color_mode_change_to_rgb_materializes_named_hex_preview() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let entry = &mut app.draft.drawing_quick_colors.entries[1]; + entry.color.mode = ColorMode::Named; + entry.color.selected_named = NamedColorOption::Custom; + entry.color.name = "#123456".to_string(); + + let _ = app.handle_quick_color_mode_changed(1, ColorMode::Rgb); + + assert_eq!( + app.draft.drawing_quick_colors.entries[1].color.rgb, + ["18", "52", "86"] + ); + + let saved = app + .draft + .to_config(&Config::default()) + .expect("expected quick color RGB to save"); + + assert_eq!( + saved.drawing.quick_colors.entries[1].color, + ColorSpec::Rgb([18, 52, 86]) + ); +} + +/// Deleting a quick color takes its picker's editing text with it. +/// +/// Without the prune the removed slot's text stays in the map with no row +/// left to show it, and text the save gate refuses keeps Save disabled +/// over a field the user can no longer reach. +#[test] +fn removing_the_last_quick_color_drops_the_hex_text_it_left_behind() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_quick_color_added(); + let last = app.draft.drawing_quick_colors.entries.len() - 1; + let _ = + app.handle_color_picker_hex_changed(ColorPickerId::QuickColor(last), "#12zz".to_string()); + assert_eq!(app.invalid_color_hex_count(), 1); + + let _ = app.handle_quick_color_removed(last); + + assert!( + !app.color_picker_hex + .contains_key(&ColorPickerId::QuickColor(last)), + "the removed slot's picker text must go with the row" + ); + assert_eq!(app.invalid_color_hex_count(), 0); +} + +#[test] +fn removing_a_different_quick_color_preserves_a_half_typed_hex() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_quick_color_added(); + let last = app.draft.drawing_quick_colors.entries.len() - 1; + let _ = app.handle_color_picker_hex_changed(ColorPickerId::QuickColor(0), "#12zz".to_string()); + + let _ = app.handle_quick_color_removed(last); + + assert_eq!( + app.color_picker_hex + .get(&ColorPickerId::QuickColor(0)) + .map(String::as_str), + Some("#12zz") + ); + assert_eq!(app.invalid_color_hex_count(), 1); +} + +/// Reordering remaps every surviving slot, so the editing text follows the +/// row it was moved with rather than staying at its old position. +#[test] +fn moving_a_quick_color_resyncs_the_pickers_that_survive() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.draft.drawing_quick_colors.entries[0].color.mode = ColorMode::Rgb; + app.draft.drawing_quick_colors.entries[0].color.rgb = + ["1".to_string(), "2".to_string(), "3".to_string()]; + app.draft.drawing_quick_colors.entries[1].color.mode = ColorMode::Rgb; + app.draft.drawing_quick_colors.entries[1].color.rgb = + ["4".to_string(), "5".to_string(), "6".to_string()]; + app.sync_all_color_picker_hex(); + let first = app + .color_picker_hex + .get(&ColorPickerId::QuickColor(0)) + .cloned(); + + let _ = app.handle_quick_color_moved(0, 1); + + assert_eq!( + app.color_picker_hex.get(&ColorPickerId::QuickColor(1)), + first.as_ref() + ); +} + +#[test] +fn adding_a_quick_color_preserves_a_half_typed_surviving_hex() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_color_picker_hex_changed(ColorPickerId::QuickColor(0), "#12zz".to_string()); + + let _ = app.handle_quick_color_added(); + + assert_eq!( + app.color_picker_hex + .get(&ColorPickerId::QuickColor(0)) + .map(String::as_str), + Some("#12zz") + ); + assert_eq!(app.invalid_color_hex_count(), 1); +} + +#[test] +fn moving_a_quick_color_carries_its_half_typed_hex_with_it() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let _ = app.handle_color_picker_hex_changed(ColorPickerId::QuickColor(0), "#12zz".to_string()); + + let _ = app.handle_quick_color_moved(0, 1); + + assert_eq!( + app.color_picker_hex + .get(&ColorPickerId::QuickColor(1)) + .map(String::as_str), + Some("#12zz") + ); + assert_ne!( + app.color_picker_hex + .get(&ColorPickerId::QuickColor(0)) + .map(String::as_str), + Some("#12zz") + ); + assert_eq!(app.invalid_color_hex_count(), 1); +} + +#[test] +fn quick_color_label_edit_does_not_change_slot_colors() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + + let _ = app.handle_text_changed(TextField::QuickColorLabel(0), "RedNew".to_string()); + + // The built-in defaults are named colors resolving to the tuned + // palette, so the slots stay on their named values after a label edit. + assert_eq!(app.draft.drawing_quick_colors.entries[0].color.name, "red"); + assert_eq!( + app.draft.drawing_quick_colors.entries[1].color.name, + "green" + ); + assert_eq!(app.draft.drawing_quick_colors.entries[2].color.name, "blue"); + assert_eq!( + app.draft.drawing_quick_colors.entries[0] + .color + .selected_named, + NamedColorOption::Red + ); +} diff --git a/configurator/src/app/update/fields/ui.rs b/configurator/src/app/update/fields/ui.rs new file mode 100644 index 00000000..6075612c --- /dev/null +++ b/configurator/src/app/update/fields/ui.rs @@ -0,0 +1,155 @@ +use wayscriber::config::{ToolbarItemId, ToolbarItemOrderGroup}; + +use crate::models::{ + InputHudModeOption, InputHudPositionOption, OverrideOption, ReducedMotionOption, + StatusPositionOption, ToolbarLayoutModeOption, ToolbarOverrideField, + ToolbarRebindModifierOption, ToolbarSideLayoutOption, UiThemeOption, ZoomChipDisplayOption, +}; + +use super::super::super::effects::Effect; +use super::super::super::state::{ConfiguratorApp, StatusMessage}; + +impl ConfiguratorApp { + pub(in crate::app::update) fn handle_status_position_changed( + &mut self, + option: StatusPositionOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.ui_status_position = option; + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_input_hud_mode_changed( + &mut self, + option: InputHudModeOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.input_hud_mode = option; + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_input_hud_position_changed( + &mut self, + option: InputHudPositionOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.input_hud_position = option; + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_ui_theme_changed( + &mut self, + option: UiThemeOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.ui_theme = option; + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_ui_reduced_motion_changed( + &mut self, + option: ReducedMotionOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.ui_reduced_motion = option; + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_toolbar_layout_mode_changed( + &mut self, + option: ToolbarLayoutModeOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.apply_toolbar_layout_mode(option); + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_toolbar_side_layout_changed( + &mut self, + option: ToolbarSideLayoutOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.ui_toolbar_side_layout = option; + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_toolbar_zoom_chip_display_changed( + &mut self, + option: ZoomChipDisplayOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.ui_toolbar_zoom_chip_display = option; + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_toolbar_rebind_modifier_changed( + &mut self, + option: ToolbarRebindModifierOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.ui_toolbar_rebind_modifier = option; + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_toolbar_override_mode_changed( + &mut self, + option: ToolbarLayoutModeOption, + ) -> Vec { + self.override_mode = option; + Vec::new() + } + + pub(in crate::app::update) fn handle_toolbar_override_changed( + &mut self, + field: ToolbarOverrideField, + option: OverrideOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft + .set_toolbar_override(self.override_mode, field, option); + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_toolbar_item_visibility_changed( + &mut self, + id: ToolbarItemId, + visible: bool, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.set_toolbar_item_visible(id, visible); + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_toolbar_item_move_requested( + &mut self, + group: ToolbarItemOrderGroup, + id: ToolbarItemId, + delta: isize, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.move_toolbar_item(group, id, delta); + self.refresh_dirty_flag(); + Vec::new() + } + + pub(in crate::app::update) fn handle_toolbar_item_order_reset( + &mut self, + group: ToolbarItemOrderGroup, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.reset_toolbar_item_order(group); + self.refresh_dirty_flag(); + Vec::new() + } +} diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index 61d6afba..6dc52fc9 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -46,6 +46,7 @@ impl ConfiguratorApp { Message::ResetToDefaultsRequested => self.handle_reset_to_defaults_requested(), Message::ResetToDefaultsConfirmed => self.handle_reset_to_defaults_confirmed(), Message::ResetToDefaultsCanceled => self.handle_reset_to_defaults_canceled(), + Message::ActiveConfirmationCanceled => self.handle_active_confirmation_canceled(), Message::SaveRequested => self.handle_save_requested(), Message::MigrationApplyRequested => self.handle_migration_apply_requested(), Message::MigrationDismissed => self.handle_migration_dismissed(), diff --git a/configurator/src/app/update/session_catalog.rs b/configurator/src/app/update/session_catalog.rs index fa618d75..778e5bec 100644 --- a/configurator/src/app/update/session_catalog.rs +++ b/configurator/src/app/update/session_catalog.rs @@ -3,7 +3,9 @@ use std::path::PathBuf; use crate::models::{SessionCatalogActionResult, SessionCatalogItem, SessionCatalogOperation}; use super::super::effects::Effect; -use super::super::state::{ConfiguratorApp, ConfirmationPrompt, StatusMessage}; +use super::super::state::{ + ConfiguratorApp, ConfirmationPrompt, PendingConfirmation, StatusMessage, +}; impl ConfiguratorApp { pub(super) fn handle_session_catalog_loaded( @@ -12,6 +14,7 @@ impl ConfiguratorApp { ) -> Vec { match result { Ok(items) => { + self.clear_session_confirmation(); self.session_catalog.replace_items(items); if matches!(self.status, StatusMessage::Info(_)) && self @@ -36,7 +39,7 @@ impl ConfiguratorApp { return Vec::new(); } self.session_catalog.is_loading = true; - self.session_catalog.pending_clear_id = None; + self.clear_session_confirmation(); self.status = StatusMessage::info("Loading sessions..."); vec![Effect::LoadSessionCatalog] } @@ -46,7 +49,7 @@ impl ConfiguratorApp { return Vec::new(); } self.session_catalog.busy = true; - self.session_catalog.pending_clear_id = None; + self.clear_session_confirmation(); self.status = StatusMessage::info("Forgetting session metadata..."); vec![Effect::ForgetSessionEntry { id }] } @@ -75,7 +78,7 @@ impl ConfiguratorApp { } self.session_catalog.busy = true; - self.session_catalog.pending_clear_id = None; + self.clear_session_confirmation(); self.status = StatusMessage::info("Renaming session..."); vec![Effect::RenameSessionEntry { id, display_name }] } @@ -110,7 +113,7 @@ impl ConfiguratorApp { } self.session_catalog.busy = true; - self.session_catalog.pending_clear_id = None; + self.clear_session_confirmation(); self.status = StatusMessage::info("Duplicating session..."); vec![Effect::DuplicateSessionEntry { id, @@ -148,7 +151,7 @@ impl ConfiguratorApp { } self.session_catalog.busy = true; - self.session_catalog.pending_clear_id = None; + self.clear_session_confirmation(); self.status = StatusMessage::info("Moving session..."); vec![Effect::MoveSessionEntry { id, @@ -161,7 +164,7 @@ impl ConfiguratorApp { return Vec::new(); } self.session_catalog.busy = true; - self.session_catalog.pending_clear_id = None; + self.clear_session_confirmation(); self.status = StatusMessage::info("Opening session folder..."); vec![Effect::RevealSessionEntry { id }] } @@ -185,7 +188,7 @@ impl ConfiguratorApp { } self.session_catalog.busy = true; - self.session_catalog.pending_clear_id = None; + self.clear_session_confirmation(); self.status = StatusMessage::info("Clearing saved tool state..."); vec![Effect::ClearSessionToolState { id }] } @@ -200,7 +203,11 @@ impl ConfiguratorApp { self.status = StatusMessage::warning(blocker); return Vec::new(); } - self.session_catalog.pending_clear_id = Some(id); + if self.session_catalog.item(&id).is_none() { + self.status = StatusMessage::error("Session is no longer in the catalog."); + return Vec::new(); + } + self.pending_confirmation = Some(PendingConfirmation::SessionClear(id)); self.status = StatusMessage::confirmation(ConfirmationPrompt::SessionClear); Vec::new() } @@ -209,25 +216,25 @@ impl ConfiguratorApp { if self.session_catalog.busy { return Vec::new(); } - if self.session_catalog.pending_clear_id.as_deref() != Some(id.as_str()) { + if self.pending_session_clear_id() != Some(id.as_str()) { return Vec::new(); } // Answered, so the question is gone: the row leaves its armed state // for the busy one in the same refresh instead of showing a Confirm // Clear the model would now refuse. The completion path clears this // too, which from here is redundant rather than load-bearing. - self.session_catalog.pending_clear_id = None; + self.clear_session_confirmation(); self.session_catalog.busy = true; self.status = StatusMessage::info("Clearing saved session data..."); vec![Effect::ClearSessionEntry { id }] } pub(super) fn handle_session_catalog_clear_canceled(&mut self, id: String) -> Vec { - if self.session_catalog.pending_clear_id.as_deref() != Some(id.as_str()) { + if self.pending_session_clear_id() != Some(id.as_str()) { return Vec::new(); } - self.session_catalog.pending_clear_id = None; + self.clear_session_confirmation(); if self .status .is_confirmation(ConfirmationPrompt::SessionClear) @@ -242,7 +249,7 @@ impl ConfiguratorApp { result: Result, ) -> Vec { self.session_catalog.busy = false; - self.session_catalog.pending_clear_id = None; + self.clear_session_confirmation(); match result { Ok(result) => { self.session_catalog.replace_items(result.items); diff --git a/configurator/src/app/update/session_catalog/tests.rs b/configurator/src/app/update/session_catalog/tests.rs index 04558e03..0db52cac 100644 --- a/configurator/src/app/update/session_catalog/tests.rs +++ b/configurator/src/app/update/session_catalog/tests.rs @@ -129,6 +129,27 @@ fn move_input_change_does_not_dirty_config() { ); } +#[test] +fn catalog_load_clears_only_a_session_confirmation() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.pending_confirmation = Some(PendingConfirmation::SessionClear("s-1".to_string())); + + let _ = app.handle_session_catalog_loaded(Ok(vec![catalog_item("s-1", "Lecture")])); + + assert!(app.pending_confirmation.is_none()); +} + +#[test] +fn catalog_load_preserves_a_defaults_confirmation() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + let _ = app.handle_reset_to_defaults_requested(); + + let _ = app.handle_session_catalog_loaded(Ok(vec![catalog_item("s-1", "Lecture")])); + + assert!(app.defaults_reset_pending()); +} + #[test] fn duplicate_request_blocks_without_daemon_status() { let temp = crate::test_temp::tempdir().unwrap(); @@ -215,7 +236,7 @@ fn clear_request_blocks_without_daemon_status() { let _ = app.handle_session_catalog_clear_requested("s-1".to_string()); - assert!(app.session_catalog.pending_clear_id.is_none()); + assert!(app.pending_session_clear_id().is_none()); assert!(status_contains(&app.status, "status finishes loading")); } @@ -253,7 +274,7 @@ fn clear_tool_state_request_sets_busy_when_safe() { [Effect::ClearSessionToolState { id }] if id == "s-1" )); assert!(app.session_catalog.busy); - assert!(app.session_catalog.pending_clear_id.is_none()); + assert!(app.pending_session_clear_id().is_none()); assert!(status_contains(&app.status, "Clearing saved tool state")); } @@ -271,10 +292,57 @@ fn clear_request_sets_pending_confirmation_when_safe() { // Confirmation first: nothing is cleared until the user says so again. assert!(effects.is_empty()); - assert_eq!(app.session_catalog.pending_clear_id.as_deref(), Some("s-1")); + assert_eq!(app.pending_session_clear_id(), Some("s-1")); assert!(status_contains(&app.status, "Confirm Clear")); } +#[test] +fn clear_request_rejects_a_session_that_is_no_longer_present() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.session_catalog = SessionCatalogState::loading(); + app.daemon_status = Some(inactive_daemon_status()); + + let effects = app.handle_session_catalog_clear_requested("missing".to_string()); + + assert!(effects.is_empty()); + assert!(app.pending_confirmation.is_none()); + assert!(status_contains(&app.status, "no longer in the catalog")); +} + +#[test] +fn session_clear_request_replaces_the_defaults_confirmation() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.session_catalog = SessionCatalogState::loading(); + app.session_catalog + .replace_items(vec![catalog_item("s-1", "Lecture")]); + app.daemon_status = Some(inactive_daemon_status()); + let _ = app.handle_reset_to_defaults_requested(); + + let _ = app.handle_session_catalog_clear_requested("s-1".to_string()); + + assert!(!app.defaults_reset_pending()); + assert_eq!(app.pending_session_clear_id(), Some("s-1")); + assert!(status_contains(&app.status, "Confirm Clear")); +} + +#[test] +fn defaults_request_replaces_the_session_clear_confirmation() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.is_loading = false; + app.session_catalog = SessionCatalogState::loading(); + app.session_catalog + .replace_items(vec![catalog_item("s-1", "Lecture")]); + app.daemon_status = Some(inactive_daemon_status()); + let _ = app.handle_session_catalog_clear_requested("s-1".to_string()); + + let _ = app.handle_reset_to_defaults_requested(); + + assert!(app.defaults_reset_pending()); + assert!(app.pending_session_clear_id().is_none()); + assert!(status_contains(&app.status, "Confirm Defaults")); +} + #[test] fn clear_canceled_disarms_and_clears_its_confirmation_status() { let (mut app, _effects) = ConfiguratorApp::new_app(); @@ -287,7 +355,23 @@ fn clear_canceled_disarms_and_clears_its_confirmation_status() { let effects = app.handle_session_catalog_clear_canceled("s-1".to_string()); assert!(effects.is_empty()); - assert!(app.session_catalog.pending_clear_id.is_none()); + assert!(app.pending_session_clear_id().is_none()); + assert!(matches!(app.status, StatusMessage::Idle)); +} + +#[test] +fn active_confirmation_cancel_disarms_session_clear() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.session_catalog = SessionCatalogState::loading(); + app.session_catalog + .replace_items(vec![catalog_item("s-1", "Lecture")]); + app.daemon_status = Some(inactive_daemon_status()); + let _ = app.handle_session_catalog_clear_requested("s-1".to_string()); + + let effects = app.handle_active_confirmation_canceled(); + + assert!(effects.is_empty()); + assert!(app.pending_confirmation.is_none()); assert!(matches!(app.status, StatusMessage::Idle)); } @@ -303,7 +387,7 @@ fn clear_canceled_preserves_status_that_replaced_its_confirmation() { let _ = app.handle_session_catalog_clear_canceled("s-1".to_string()); - assert!(app.session_catalog.pending_clear_id.is_none()); + assert!(app.pending_session_clear_id().is_none()); assert!(matches!(app.status, StatusMessage::Error(_))); assert!(status_contains( &app.status, @@ -314,7 +398,7 @@ fn clear_canceled_preserves_status_that_replaced_its_confirmation() { #[test] fn stray_clear_cancel_preserves_unrelated_status() { let (mut app, _effects) = ConfiguratorApp::new_app(); - app.session_catalog.pending_clear_id = None; + app.pending_confirmation = None; app.status = StatusMessage::success("A completed operation"); let _ = app.handle_session_catalog_clear_canceled("s-1".to_string()); @@ -338,7 +422,7 @@ fn stale_clear_cancel_does_not_disarm_a_newer_confirmation() { let effects = app.handle_session_catalog_clear_canceled("s-1".to_string()); assert!(effects.is_empty()); - assert_eq!(app.session_catalog.pending_clear_id.as_deref(), Some("s-2")); + assert_eq!(app.pending_session_clear_id(), Some("s-2")); assert!(status_contains(&app.status, "Confirm Clear")); } @@ -362,7 +446,7 @@ fn clear_confirmed_consumes_the_pending_confirmation() { effects.as_slice(), [Effect::ClearSessionEntry { id }] if id == "s-1" )); - assert!(app.session_catalog.pending_clear_id.is_none()); + assert!(app.pending_session_clear_id().is_none()); assert!(app.session_catalog.busy); assert!(status_contains(&app.status, "Clearing saved session data")); } @@ -385,7 +469,7 @@ fn clear_confirmed_twice_starts_only_one_clear() { assert!(effects.is_empty()); assert!(app.session_catalog.busy); - assert!(app.session_catalog.pending_clear_id.is_none()); + assert!(app.pending_session_clear_id().is_none()); assert!(status_contains(&app.status, "Clearing saved session data")); } @@ -408,16 +492,16 @@ fn clear_confirmed_for_another_row_leaves_the_pending_one_armed() { assert!(effects.is_empty()); assert!(!app.session_catalog.busy); - assert_eq!(app.session_catalog.pending_clear_id.as_deref(), Some("s-1")); + assert_eq!(app.pending_session_clear_id(), Some("s-1")); } -/// Completion clears the pending id too. Nothing reaches it armed anymore, +/// Completion clears the pending identity too. Nothing reaches it armed anymore, /// but the clear stays: it is what covers every other action's completion. #[test] fn action_completed_still_clears_a_pending_confirmation() { let (mut app, _effects) = ConfiguratorApp::new_app(); app.session_catalog.busy = true; - app.session_catalog.pending_clear_id = Some("s-1".to_string()); + app.pending_confirmation = Some(PendingConfirmation::SessionClear("s-1".to_string())); let _ = app.handle_session_catalog_action_completed(Ok(SessionCatalogActionResult { message: "Cleared.".to_string(), @@ -425,7 +509,7 @@ fn action_completed_still_clears_a_pending_confirmation() { warning: false, })); - assert!(app.session_catalog.pending_clear_id.is_none()); + assert!(app.pending_session_clear_id().is_none()); assert!(!app.session_catalog.busy); } diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index 9867edf4..c459868e 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -63,6 +63,9 @@ pub enum Message { ResetToDefaultsConfirmed, /// Answers an armed confirmation with no. ResetToDefaultsCanceled, + /// Cancels whichever destructive confirmation currently owns the answer + /// controls. Used by the window-level Escape key binding. + ActiveConfirmationCanceled, SaveRequested, MigrationApplyRequested, MigrationDismissed, diff --git a/configurator/src/models/session.rs b/configurator/src/models/session.rs index a1e39670..5627e3cb 100644 --- a/configurator/src/models/session.rs +++ b/configurator/src/models/session.rs @@ -16,7 +16,6 @@ pub struct SessionCatalogState { pub move_inputs: HashMap, pub is_loading: bool, pub busy: bool, - pub pending_clear_id: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -137,7 +136,6 @@ impl SessionCatalogState { move_inputs: HashMap::new(), is_loading: true, busy: false, - pending_clear_id: None, } } @@ -169,7 +167,6 @@ impl SessionCatalogState { self.items = items; self.is_loading = false; self.busy = false; - self.pending_clear_id = None; } pub fn rename_value(&self, id: &str, fallback: &str) -> String {