diff --git a/src/app.rs b/src/app.rs index 1665db1..c0a6851 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,6 +4,7 @@ mod debounce; mod hardware_queue; mod hotkey_editor; mod hotkey_views; +mod modal; mod monitor_views; mod monitors; mod profile_handlers; @@ -55,7 +56,6 @@ pub enum Page { Monitor(u32), // 1-indexed monitor ID Hotkeys, Profiles, - Settings, About, } @@ -83,7 +83,10 @@ pub enum Message { HotkeyTriggered(u32), ToggleHotkeys(bool), AddHotkey, + SetHotkeyLabel(String, String), ToggleHotkeyEditor(String), + RequestDeleteHotkey(String), + CancelDeleteHotkey, DeleteHotkey(String), StartRecording(String), CancelRecording, @@ -107,6 +110,8 @@ pub enum Message { ToggleStartWithWindows(bool), ToggleStartMinimized(bool), SaveConfig, + ToggleSettings, + CloseSettings, RetryConfig, RecoverConfigBackup, RequestResetConfig, @@ -130,6 +135,9 @@ pub enum Message { HideWindow, WindowClosed(window::Id), OpenUrl(String), + /// Discard edits to the read-only configuration path field. + ConfigPathInput, + CopyConfigPath, // Errors Error(String), } @@ -165,9 +173,11 @@ pub struct AppModel { status_message: String, recording_state: RecordingState, expanded_hotkey: Option, + pending_hotkey_delete: Option, value_drafts: HashMap<(String, usize), String>, vcp_drafts: HashMap<(String, usize), String>, config_dirty: bool, + config_path: String, about: widget::about::About, slider_debounce: SliderDebounce, profiles: Vec, @@ -188,10 +198,6 @@ const INPUT_SOURCES: &[InputSource] = &[ InputSource::Dp2, InputSource::UsbC1, InputSource::UsbC2, - InputSource::Vga1, - InputSource::Vga2, - InputSource::Dvi1, - InputSource::Dvi2, ]; const POWER_MODES: &[PowerMode] = &[ @@ -287,6 +293,7 @@ impl cosmic::Application for AppModel { } }; + let config_path = config_store.path().display().to_string(); let mut app = AppModel { core, nav, @@ -304,9 +311,11 @@ impl cosmic::Application for AppModel { status_message: "Starting...".into(), recording_state: RecordingState::NotRecording, expanded_hotkey: None, + pending_hotkey_delete: None, value_drafts, vcp_drafts, config_dirty: false, + config_path, about, slider_debounce: SliderDebounce::default(), profiles: Vec::new(), @@ -334,6 +343,37 @@ impl cosmic::Application for AppModel { self.update_title() } + fn dialog(&self) -> Option> { + if let Some(name) = self.pending_profile_delete.clone() { + return Some(modal::confirm_dialog( + "Delete profile?", + format!("Delete \"{name}\"? This removes the saved layout."), + "Delete", + Message::CancelDeleteProfile, + Message::DeleteProfile(name), + )); + } + let id = self.pending_hotkey_delete.clone()?; + let title = self.hotkey_delete_title(&id)?; + Some(modal::confirm_dialog( + "Delete hotkey?", + format!("Delete \"{title}\"? This removes the hotkey and its actions."), + "Delete", + Message::CancelDeleteHotkey, + Message::DeleteHotkey(id), + )) + } + + fn on_escape(&mut self) -> cosmic::app::Task { + if self.pending_profile_delete.is_some() { + self.cancel_delete_profile(); + } + if self.pending_hotkey_delete.is_some() { + self.cancel_delete_hotkey(); + } + cosmic::app::Task::none() + } + // Intercept the header-bar close button → hide to tray instead of exiting fn on_app_exit(&mut self) -> Option { if self.tray.is_some() { @@ -401,6 +441,14 @@ impl cosmic::Application for AppModel { fn update(&mut self, message: Self::Message) -> cosmic::app::Task { // The store also guards saves; this gate prevents editing or executing // placeholder settings while a failed load awaits the user's decision. + if self + .pending_hotkey_delete + .as_ref() + .is_some_and(|id| self.hotkey_delete_title(id).is_none()) + { + self.pending_hotkey_delete = None; + } + if self.config_store.recovery_error().is_some() && matches!( message, @@ -408,6 +456,8 @@ impl cosmic::Application for AppModel { | Message::ToggleHotkeys(_) | Message::SaveConfig | Message::AddHotkey + | Message::SetHotkeyLabel(_, _) + | Message::RequestDeleteHotkey(_) | Message::DeleteHotkey(_) | Message::StartRecording(_) | Message::ClearBinding(_) @@ -483,7 +533,10 @@ impl cosmic::Application for AppModel { // -- Hotkey actions --------------------------------------------- Message::HotkeyTriggered(id) => return self.handle_hotkey_triggered(id), Message::AddHotkey => self.add_hotkey(), + Message::SetHotkeyLabel(id, label) => self.set_hotkey_label(id, label), Message::ToggleHotkeyEditor(id) => self.toggle_hotkey_editor(id), + Message::RequestDeleteHotkey(id) => self.request_delete_hotkey(id), + Message::CancelDeleteHotkey => self.cancel_delete_hotkey(), Message::DeleteHotkey(id) => self.delete_hotkey(id), Message::StartRecording(hotkey_id) => self.start_recording(hotkey_id), Message::CancelRecording => self.cancel_recording(), @@ -529,6 +582,10 @@ impl cosmic::Application for AppModel { } Message::SaveConfig => return self.save_config(), + Message::ToggleSettings => { + self.set_show_context(!self.core.window.show_context); + } + Message::CloseSettings => self.set_show_context(false), Message::ToggleHotkeys(enabled) => return self.toggle_hotkeys(enabled), Message::RetryConfig => self.retry_config(), Message::RecoverConfigBackup => self.recover_config_backup(), @@ -605,6 +662,11 @@ impl cosmic::Application for AppModel { } } + Message::ConfigPathInput => {} + Message::CopyConfigPath => { + self.status_message = "Copied configuration path".into(); + return cosmic::iced::clipboard::write(self.config_path.clone()); + } Message::OpenUrl(url) => { if let Err(error) = std::process::Command::new("rundll32.exe") .args(["url.dll,FileProtocolHandler", &url]) @@ -643,7 +705,6 @@ impl cosmic::Application for AppModel { Page::Monitor(monitor_id) => self.view_monitor(monitor_id), Page::Hotkeys => self.view_hotkeys_current(), Page::Profiles => self.view_profiles(), - Page::Settings => self.view_settings(), Page::About => self.view_about(), }; @@ -655,7 +716,7 @@ impl cosmic::Application for AppModel { if recovering { layout = layout.push(self.view_config_recovery()); } - if !recovering || !matches!(page, Page::Hotkeys | Page::Settings) { + if !recovering || !matches!(page, Page::Hotkeys) { layout = layout.push(content); } let layout = layout @@ -677,12 +738,45 @@ impl cosmic::Application for AppModel { } fn header_start(&self) -> Vec> { + vec![crate::icons::header_icon_button( + crate::icons::AppIcon::Refresh, + "Refresh", + false, + Message::RefreshMonitors, + )] + } + + fn header_end(&self) -> Vec> { + let save_tip = if self.config_dirty { + "Save configuration (unsaved changes)" + } else { + "Save configuration" + }; vec![ - widget::button::text("Refresh") - .on_press(Message::RefreshMonitors) - .into(), + crate::icons::header_icon_button( + crate::icons::AppIcon::Save, + save_tip, + self.config_dirty, + Message::SaveConfig, + ), + crate::icons::header_icon_button( + crate::icons::AppIcon::Settings, + "Settings", + self.core.window.show_context, + Message::ToggleSettings, + ), ] } + + fn context_drawer(&self) -> Option> { + if !self.core.window.show_context { + return None; + } + Some( + cosmic::app::context_drawer(self.view_settings(), Message::CloseSettings) + .title("Settings"), + ) + } } // --------------------------------------------------------------------------- diff --git a/src/app/hotkey_editor.rs b/src/app/hotkey_editor.rs index e452fc0..f30364c 100644 --- a/src/app/hotkey_editor.rs +++ b/src/app/hotkey_editor.rs @@ -2,7 +2,7 @@ use super::AppModel; use super::{Message, Page, RecordingState}; use crate::config::{ ActionTarget, ActionType, AppConfig, Hotkey, HotkeyActionSpec, HotkeyBinding, MonitorInput, - MonitorTarget, + MonitorTarget, hotkey_headings, }; use crate::ddc::{InputSource, MonitorKey, PowerMode}; use crate::hotkeys::HotkeyManager; @@ -305,6 +305,19 @@ impl AppModel { cosmic::app::Task::none() } + pub(super) fn set_hotkey_label(&mut self, id: String, label: String) { + if let Some(hotkey) = self + .config + .hotkeys + .hotkeys + .iter_mut() + .find(|hotkey| hotkey.id == id) + { + hotkey.label = label; + } + self.config_dirty = true; + } + pub(super) fn add_hotkey(&mut self) { let hotkey = Hotkey::new_empty(); let id = hotkey.id.clone(); @@ -324,7 +337,29 @@ impl AppModel { } } + pub(super) fn request_delete_hotkey(&mut self, id: String) { + self.pending_profile_delete = None; + self.pending_hotkey_delete = Some(id); + } + + pub(super) fn cancel_delete_hotkey(&mut self) { + self.pending_hotkey_delete = None; + } + + /// Card heading for `id`, matching the hotkey list. `None` when that hotkey is gone. + pub(super) fn hotkey_delete_title(&self, id: &str) -> Option { + let headings = hotkey_headings(&self.config.hotkeys.hotkeys); + self.config + .hotkeys + .hotkeys + .iter() + .zip(headings) + .find(|(hotkey, _)| hotkey.id == id) + .map(|(_, heading)| heading.title) + } + pub(super) fn delete_hotkey(&mut self, id: String) { + self.pending_hotkey_delete = None; self.config.hotkeys.hotkeys.retain(|hotkey| hotkey.id != id); self.clear_hotkey_drafts(&id); if self.expanded_hotkey.as_deref() == Some(&id) { diff --git a/src/app/hotkey_views.rs b/src/app/hotkey_views.rs index 19c2719..8d1ca50 100644 --- a/src/app/hotkey_views.rs +++ b/src/app/hotkey_views.rs @@ -2,11 +2,16 @@ use super::hotkey_editor::{ action_master_selected, action_monitor_selected, parse_value_draft, parse_vcp_draft, }; use super::{AppModel, INPUT_SOURCES, Message, POWER_MODES, RecordingState}; -use crate::config::{ActionTarget, ActionType, Hotkey, HotkeyActionSpec, MonitorTarget}; +use crate::config::{ + ActionTarget, ActionType, Hotkey, HotkeyActionSpec, HotkeyHeading, MonitorTarget, + hotkey_headings, +}; use crate::ddc::PowerMode; +use crate::icons::{self, AppIcon}; use cosmic::Element; use cosmic::iced::alignment::Horizontal; use cosmic::iced::{Alignment, Length}; +use cosmic::theme; use cosmic::widget; fn power_mode_index(mode: &PowerMode) -> Option { @@ -20,8 +25,9 @@ impl AppModel { .spacing(space_s) .width(Length::Fill); - for hotkey in &self.config.hotkeys.hotkeys { - hotkeys = hotkeys.push(self.view_hotkey_card(hotkey, space_s)); + let headings = hotkey_headings(&self.config.hotkeys.hotkeys); + for (hotkey, heading) in self.config.hotkeys.hotkeys.iter().zip(headings) { + hotkeys = hotkeys.push(self.view_hotkey_card(hotkey, heading, space_s)); } let add_row = widget::row::with_capacity(2) @@ -29,19 +35,13 @@ impl AppModel { .push(widget::Space::new().width(Length::Fill)) .spacing(space_s); - let save_label = if self.config_dirty { - "Save Configuration *" - } else { - "Save Configuration" - }; - let content = widget::column::with_capacity(6) + let content = widget::column::with_capacity(5) .push(widget::text::title3("Hotkeys")) .push(widget::text::body( "Configure global hotkeys and ordered display actions.", )) .push(add_row) .push(hotkeys) - .push(widget::button::suggested(save_label).on_press(Message::SaveConfig)) .spacing(space_s) .width(Length::Fill); @@ -55,7 +55,12 @@ impl AppModel { .into() } - fn view_hotkey_card(&self, hotkey: &Hotkey, space_s: u16) -> Element<'_, Message> { + fn view_hotkey_card<'a>( + &'a self, + hotkey: &'a Hotkey, + heading: HotkeyHeading, + space_s: u16, + ) -> Element<'a, Message> { let id = hotkey.id.clone(); let expanded = self.expanded_hotkey.as_deref() == Some(id.as_str()); let active = self.hotkey_status.get(&id).copied().unwrap_or(false); @@ -69,11 +74,22 @@ impl AppModel { let summary = widget::row::with_capacity(5) .push(widget::text::body(hotkey.binding.to_string()).width(Length::Fill)) .push(widget::text::caption(status)) - .push( - widget::button::standard(if expanded { "Collapse" } else { "Edit" }) - .on_press(Message::ToggleHotkeyEditor(id.clone())), - ) - .push(widget::button::destructive("Delete").on_press(Message::DeleteHotkey(id.clone()))) + .push(icons::icon_button( + if expanded { + AppIcon::Collapse + } else { + AppIcon::Edit + }, + if expanded { "Collapse" } else { "Edit" }, + theme::Button::Standard, + Message::ToggleHotkeyEditor(id.clone()), + )) + .push(icons::icon_button( + AppIcon::Trash, + "Delete", + theme::Button::Destructive, + Message::RequestDeleteHotkey(id.clone()), + )) .spacing(space_s) .align_y(Alignment::Center); @@ -103,6 +119,19 @@ impl AppModel { } .spacing(space_s) .align_y(Alignment::Center); + let label_row = widget::row::with_capacity(2) + .push(widget::text::body("Label").width(Length::Fixed(112.0))) + .push( + widget::text_input(heading.fallback, &hotkey.label) + .on_input({ + let id = id.clone(); + move |value| Message::SetHotkeyLabel(id.clone(), value) + }) + .width(Length::Fill), + ) + .spacing(space_s) + .align_y(Alignment::Center); + content = content.push(label_row); content = content.push(binding_row); for (idx, action) in hotkey.actions.iter().enumerate() { @@ -114,7 +143,7 @@ impl AppModel { } cosmic::widget::settings::section() - .title(format!("Hotkey ({})", hotkey.actions.len())) + .title(heading.title) .add(content) .into() } @@ -142,10 +171,12 @@ impl AppModel { move |selected| Message::SetActionType(id.clone(), idx, type_options[selected]) })) .push(widget::Space::new().width(Length::Fill)) - .push( - widget::button::destructive("Delete Action") - .on_press(Message::DeleteAction(id.clone(), idx)), - ) + .push(icons::icon_button( + AppIcon::Trash, + "Delete Action", + theme::Button::Destructive, + Message::DeleteAction(id.clone(), idx), + )) .spacing(space_s) .align_y(Alignment::Center); let mut fields = widget::column::with_capacity(5) diff --git a/src/app/modal.rs b/src/app/modal.rs new file mode 100644 index 0000000..bf52234 --- /dev/null +++ b/src/app/modal.rs @@ -0,0 +1,41 @@ +use super::Message; +use cosmic::Element; +use cosmic::iced::widget::{center, container, mouse_area, opaque}; +use cosmic::iced::{Color, Length}; +use cosmic::widget; +use std::borrow::Cow; + +/// Centered confirmation over a dimmed scrim. +/// +/// The shell already places this above the window, so the returned element is +/// only the scrim layer: clicks on the dimmed area cancel, and the card is +/// opaque so its own clicks do not. +pub(super) fn confirm_dialog<'a>( + title: impl Into>, + body: impl Into>, + confirm_label: impl Into>, + on_cancel: Message, + on_confirm: Message, +) -> Element<'a, Message> { + let card: Element<'a, Message> = widget::dialog() + .title(title) + .body(body) + .width(Length::Fixed(420.0)) + .secondary_action(widget::button::standard("Cancel").on_press(on_cancel.clone())) + .primary_action(widget::button::destructive(confirm_label).on_press(on_confirm)) + .into(); + let scrim = center(opaque(card)) + .width(Length::Fill) + .height(Length::Fill) + .style(|_theme| container::Style { + background: Some( + Color { + a: 0.8, + ..Color::BLACK + } + .into(), + ), + ..container::Style::default() + }); + opaque(mouse_area(scrim).on_press(on_cancel)) +} diff --git a/src/app/monitor_views.rs b/src/app/monitor_views.rs index a793a26..14426a9 100644 --- a/src/app/monitor_views.rs +++ b/src/app/monitor_views.rs @@ -213,10 +213,6 @@ impl AppModel { "DisplayPort 2", "USB-C 1", "USB-C 2", - "VGA 1", - "VGA 2", - "DVI 1", - "DVI 2", ]; let mut input_section = cosmic::widget::settings::section() @@ -248,7 +244,7 @@ impl AppModel { ) .width(Length::Fixed(300.0)) .into(), - None => widget::text::body("Retry reads to adjust").into(), + None => widget::text::body("Refresh to adjust").into(), }; let mut brightness_section = cosmic::widget::settings::section().title("Brightness").add( @@ -275,7 +271,7 @@ impl AppModel { ) .width(Length::Fixed(300.0)) .into(), - None => widget::text::body("Retry reads to adjust").into(), + None => widget::text::body("Refresh to adjust").into(), }; let mut contrast_section = cosmic::widget::settings::section().title("Contrast").add( @@ -288,7 +284,7 @@ impl AppModel { ))); } - let mut content = widget::column::with_capacity(7) + let mut content = widget::column::with_capacity(6) .push(header) .push(resolution_label) .push(input_section) @@ -301,10 +297,6 @@ impl AppModel { "Could not refresh monitor: {error}" ))); } - content = content.push( - widget::button::standard("Retry reads") - .on_press(Message::RetryMonitor(monitor_id)), - ); widget::scrollable(content) .width(Length::Fill) .height(Length::Fill) diff --git a/src/app/monitors.rs b/src/app/monitors.rs index 6395f2a..be6038e 100644 --- a/src/app/monitors.rs +++ b/src/app/monitors.rs @@ -64,11 +64,6 @@ impl AppModel { .insert() .text("Profiles") .data::(Page::Profiles); - // Settings page - self.nav - .insert() - .text("Settings") - .data::(Page::Settings); self.nav.insert().text("About").data::(Page::About); // Activate first monitor diff --git a/src/app/profile_handlers.rs b/src/app/profile_handlers.rs index 7ff47c0..c88f4bc 100644 --- a/src/app/profile_handlers.rs +++ b/src/app/profile_handlers.rs @@ -73,6 +73,7 @@ impl AppModel { } pub(super) fn request_delete_profile(&mut self, name: String) { + self.pending_hotkey_delete = None; self.pending_profile_delete = Some(name); } diff --git a/src/app/profile_views.rs b/src/app/profile_views.rs index c8df578..d0bf88a 100644 --- a/src/app/profile_views.rs +++ b/src/app/profile_views.rs @@ -1,6 +1,8 @@ use super::{AppModel, Message}; +use crate::icons::{self, AppIcon}; use cosmic::Element; use cosmic::iced::{Alignment, Length}; +use cosmic::theme; use cosmic::widget; impl AppModel { @@ -45,8 +47,7 @@ impl AppModel { profiles = profiles.push(widget::text::body("No profiles saved yet.")); } for name in &self.profiles { - let deleting = self.pending_profile_delete.as_deref() == Some(name.as_str()); - let mut row = widget::row::with_capacity(5) + let row = widget::row::with_capacity(4) .push(widget::text::body(name.clone()).width(Length::Fill)) .push( widget::button::suggested("Apply") @@ -56,21 +57,14 @@ impl AppModel { widget::button::standard("Hotkey") .on_press(Message::AddProfileHotkey(name.clone())), ) + .push(icons::icon_button( + AppIcon::Trash, + "Delete", + theme::Button::Destructive, + Message::RequestDeleteProfile(name.clone()), + )) .spacing(space_s) .align_y(Alignment::Center); - if deleting { - row = row - .push(widget::button::standard("Cancel").on_press(Message::CancelDeleteProfile)) - .push( - widget::button::destructive("Confirm Delete") - .on_press(Message::DeleteProfile(name.clone())), - ); - } else { - row = row.push( - widget::button::destructive("Delete") - .on_press(Message::RequestDeleteProfile(name.clone())), - ); - } profiles = profiles.push(row); } let content = widget::column::with_capacity(4) diff --git a/src/app/settings_views.rs b/src/app/settings_views.rs index d3acdd7..2826a7b 100644 --- a/src/app/settings_views.rs +++ b/src/app/settings_views.rs @@ -44,11 +44,10 @@ impl AppModel { cosmic::app::Task::none() } - /// View for the settings page. + /// Settings shown in the header context drawer. pub(super) fn view_settings(&self) -> Element<'_, Message> { let space_s = cosmic::theme::spacing().space_s; - let header = widget::text::title3("Settings"); let description = widget::text::body("Configure startup, hotkeys, and how displays are turned off."); @@ -97,19 +96,13 @@ impl AppModel { ); } - let content = widget::column::with_capacity(5) - .push(header) + widget::column::with_capacity(4) .push(description) .push(startup_section) .push(hotkeys_section) .push(power_section) .spacing(space_s) - .width(Length::Fill); - - // Wrap in scrollable to ensure all content is accessible - widget::scrollable(content) .width(Length::Fill) - .height(Length::Fill) .into() } } diff --git a/src/app/views.rs b/src/app/views.rs index 3b8e633..6ef1cba 100644 --- a/src/app/views.rs +++ b/src/app/views.rs @@ -1,16 +1,37 @@ use super::{AppModel, Message}; use cosmic::Element; -use cosmic::iced::Length; +use cosmic::iced::{Alignment, Length}; use cosmic::widget; impl AppModel { pub(super) fn view_about(&self) -> Element<'_, Message> { - widget::scrollable(cosmic::widget::about(&self.about, |url| { - Message::OpenUrl(url.to_owned()) - })) - .width(Length::Fill) - .height(Length::Fill) - .into() + let space_s = cosmic::theme::spacing().space_s; + let config_path = widget::column::with_capacity(2) + .push(widget::text::caption("Configuration file")) + .push( + widget::row::with_capacity(2) + .push( + widget::text_input("", &self.config_path) + .on_input(|_| Message::ConfigPathInput) + .width(Length::Fill), + ) + .push(widget::button::standard("Copy").on_press(Message::CopyConfigPath)) + .spacing(space_s) + .align_y(Alignment::Center), + ) + .spacing(space_s) + .width(Length::Fill); + let content = widget::column::with_capacity(2) + .push(cosmic::widget::about(&self.about, |url| { + Message::OpenUrl(url.to_owned()) + })) + .push(config_path) + .spacing(space_s) + .width(Length::Fill); + widget::scrollable(content) + .width(Length::Fill) + .height(Length::Fill) + .into() } pub(super) fn view_config_recovery(&self) -> Element<'_, Message> { diff --git a/src/config.rs b/src/config.rs index c0f438b..e7b6724 100644 --- a/src/config.rs +++ b/src/config.rs @@ -331,6 +331,9 @@ impl Default for HotkeyActionSpec { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Hotkey { pub id: String, + /// Optional name shown on the hotkey card. Blank labels use a numbered title. + #[serde(default)] + pub label: String, pub binding: HotkeyBinding, pub actions: Vec, } @@ -340,6 +343,7 @@ impl Hotkey { pub fn new_empty() -> Self { Self { id: new_id(), + label: String::new(), binding: HotkeyBinding::unbound(), actions: vec![HotkeyActionSpec::default()], } @@ -349,6 +353,7 @@ impl Hotkey { pub fn new_for_profile(profile_name: String) -> Self { Self { id: new_id(), + label: String::new(), binding: HotkeyBinding::unbound(), actions: vec![HotkeyActionSpec { action_type: ActionType::Set, @@ -361,6 +366,41 @@ impl Hotkey { } } +/// Card heading for one hotkey: the visible title and the empty-field placeholder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HotkeyHeading { + /// Trimmed custom label, or [`Self::fallback`] when the label is blank. + pub title: String, + /// Numbered name (`Hotkey (n)`) used when the custom label is empty. + pub fallback: String, +} + +/// Headings in list order. A non-blank label is the title and does not consume +/// a number. A blank or whitespace-only label becomes `Hotkey (n)`, then `n` +/// increases by 1. +pub fn hotkey_headings(hotkeys: &[Hotkey]) -> Vec { + let mut next = 1u32; + hotkeys + .iter() + .map(|hotkey| { + let fallback = format!("Hotkey ({next})"); + let label = hotkey.label.trim(); + if label.is_empty() { + next += 1; + HotkeyHeading { + title: fallback.clone(), + fallback, + } + } else { + HotkeyHeading { + title: label.to_string(), + fallback, + } + } + }) + .collect() +} + /// Generate a unique id for a new [`Hotkey`]. pub fn new_id() -> String { static NEXT: AtomicU64 = AtomicU64::new(0); @@ -510,6 +550,7 @@ impl HotkeyConfig { for b in self.input_switch_bindings.drain(..) { self.hotkeys.push(Hotkey { id: new_id(), + label: String::new(), binding: b.hotkey, actions: vec![HotkeyActionSpec { action_type: ActionType::Set, @@ -533,6 +574,7 @@ impl HotkeyConfig { }; self.hotkeys.push(Hotkey { id: new_id(), + label: String::new(), binding: b.hotkey, actions: vec![HotkeyActionSpec { action_type: ActionType::Offset, @@ -552,6 +594,7 @@ impl HotkeyConfig { }; self.hotkeys.push(Hotkey { id: new_id(), + label: String::new(), binding: b.hotkey, actions: vec![HotkeyActionSpec { action_type: ActionType::Offset, @@ -567,6 +610,7 @@ impl HotkeyConfig { for b in self.power_mode_bindings.drain(..) { self.hotkeys.push(Hotkey { id: new_id(), + label: String::new(), binding: b.hotkey, actions: vec![HotkeyActionSpec { action_type: ActionType::Set, @@ -582,6 +626,7 @@ impl HotkeyConfig { for b in self.profile_bindings.drain(..) { self.hotkeys.push(Hotkey { id: new_id(), + label: String::new(), binding: b.hotkey, actions: vec![HotkeyActionSpec { action_type: ActionType::Set, @@ -1249,4 +1294,35 @@ mod tests { assert_eq!(power.monitors, vec![7.into()]); assert_eq!(power.power_mode, PowerMode::Off); } + + #[test] + fn missing_hotkey_label_deserializes_as_empty() { + let hotkey = Hotkey::new_empty(); + let mut value = serde_json::to_value(&hotkey).unwrap(); + value.as_object_mut().unwrap().remove("label"); + let loaded: Hotkey = serde_json::from_value(value).unwrap(); + assert!(loaded.label.is_empty()); + } + + #[test] + fn blank_hotkey_labels_are_numbered_and_custom_labels_skip_the_count() { + let mut hotkeys = vec![ + Hotkey::new_empty(), + Hotkey::new_empty(), + Hotkey::new_empty(), + Hotkey::new_empty(), + ]; + hotkeys[1].label = " Work ".into(); + hotkeys[2].label = " ".into(); + + let headings = hotkey_headings(&hotkeys); + assert_eq!(headings[0].title, "Hotkey (1)"); + assert_eq!(headings[0].fallback, "Hotkey (1)"); + assert_eq!(headings[1].title, "Work"); + assert_eq!(headings[1].fallback, "Hotkey (2)"); + assert_eq!(headings[2].title, "Hotkey (2)"); + assert_eq!(headings[2].fallback, "Hotkey (2)"); + assert_eq!(headings[3].title, "Hotkey (3)"); + assert_eq!(headings[3].fallback, "Hotkey (3)"); + } } diff --git a/src/icons.rs b/src/icons.rs new file mode 100644 index 0000000..edf39ba --- /dev/null +++ b/src/icons.rs @@ -0,0 +1,128 @@ +//! Theme-colored icon buttons from the symbolic icons bundled with libcosmic. + +use std::sync::OnceLock; + +use cosmic::Element; +use cosmic::widget::{self, icon}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AppIcon { + Edit, + Collapse, + Trash, + Refresh, + Save, + Settings, +} + +pub fn icon_button<'a, Message>( + icon: AppIcon, + label: &'a str, + class: cosmic::theme::Button, + message: Message, +) -> Element<'a, Message> +where + Message: Clone + 'static, +{ + with_tooltip( + widget::button::icon(handle(icon)) + .class(class) + .on_press(message), + label, + widget::tooltip::Position::Bottom, + ) +} + +/// Header control matching the sidebar toggle: no fill until hover, or while `emphasized`. +pub fn header_icon_button<'a, Message>( + icon: AppIcon, + label: &'a str, + emphasized: bool, + message: Message, +) -> Element<'a, Message> +where + Message: Clone + 'static, +{ + let class = if emphasized { + emphasized_header_class() + } else { + cosmic::theme::Button::NavToggle + }; + with_tooltip( + widget::button::icon(handle(icon)) + .padding([8, 16]) + .class(class) + .on_press(message), + label, + widget::tooltip::Position::Bottom, + ) +} + +fn emphasized_header_class() -> cosmic::theme::Button { + cosmic::theme::Button::Custom { + active: Box::new(|focused, theme| header_style(theme, focused, false)), + hovered: Box::new(|focused, theme| header_style(theme, focused, true)), + pressed: Box::new(|focused, theme| header_style(theme, focused, true)), + disabled: Box::new(|theme| { + let mut style = header_style(theme, false, false); + if let Some(cosmic::iced::Background::Color(color)) = &mut style.background { + color.a *= 0.5; + } + style + }), + } +} + +fn header_style( + theme: &cosmic::Theme, + focused: bool, + pressed: bool, +) -> cosmic::widget::button::Style { + let cosmic = theme.cosmic(); + let component = &cosmic.icon_button; + let fill = if pressed { + component.pressed + } else { + component.hover + }; + let mut style = cosmic::widget::button::Style::new(); + style.background = Some(cosmic::iced::Background::Color(fill.into())); + style.border_radius = cosmic.corner_radii.radius_s.into(); + if focused { + style.outline_width = 1.0; + style.outline_color = cosmic.accent.base.into(); + style.border_width = 2.0; + style.border_color = cosmic::iced::Color::TRANSPARENT; + } + style +} + +fn with_tooltip<'a, Message>( + button: impl Into>, + label: &'a str, + position: widget::tooltip::Position, +) -> Element<'a, Message> +where + Message: Clone + 'static, +{ + widget::tooltip(button, widget::text::body(label), position).into() +} + +fn handle(app_icon: AppIcon) -> icon::Handle { + let (slot, name) = match app_icon { + AppIcon::Edit => (&EDIT, "edit-symbolic"), + AppIcon::Collapse => (&COLLAPSE, "pan-up-symbolic"), + AppIcon::Trash => (&TRASH, "edit-delete-symbolic"), + AppIcon::Refresh => (&REFRESH, "view-refresh-symbolic"), + AppIcon::Save => (&SAVE, "media-floppy-symbolic"), + AppIcon::Settings => (&SETTINGS, "preferences-system-symbolic"), + }; + slot.get_or_init(|| icon::from_name(name).into()).clone() +} + +static EDIT: OnceLock = OnceLock::new(); +static COLLAPSE: OnceLock = OnceLock::new(); +static TRASH: OnceLock = OnceLock::new(); +static REFRESH: OnceLock = OnceLock::new(); +static SAVE: OnceLock = OnceLock::new(); +static SETTINGS: OnceLock = OnceLock::new(); diff --git a/src/main.rs b/src/main.rs index a5efd20..8da66f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod ccd; mod config; mod ddc; mod hotkeys; +mod icons; mod persistence; mod profiles; mod startup;