From cc441c049e561ebd6e7459b873f47078c55f775a Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 30 Sep 2026 02:03:55 +0100 Subject: [PATCH] Show an active keybinding guide until disabled in Settings --- Cargo.lock | 1 + README.md | 2 + compositor/src/config.rs | 101 ++++++++++ compositor/src/ipc.rs | 10 + docs/backlog.md | 2 +- docs/configuration.md | 1 + shell/ferese-settings/src/schema.rs | 8 +- shell/ferese-shell/Cargo.toml | 1 + shell/ferese-shell/src/config.rs | 13 ++ shell/ferese-shell/src/keybinding_guide.rs | 44 +++++ shell/ferese-shell/src/main.rs | 104 +++++++++-- shell/ferese-shell/src/system_modal.rs | 206 +++++++++++++++------ 12 files changed, 414 insertions(+), 79 deletions(-) create mode 100644 shell/ferese-shell/src/keybinding_guide.rs diff --git a/Cargo.lock b/Cargo.lock index 3d2b33b..f459429 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1771,6 +1771,7 @@ version = "0.1.0" dependencies = [ "ferese-config", "ferese-core", + "ferese-ipc", "ferese-protocols", "ferese-theme", "iced_accessibility", diff --git a/README.md b/README.md index f37650a..22249a7 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ target/release/ferese --backend nested --grant-effects --grant-shell-control -- ## Get around These are the built-in defaults; every shortcut and gesture can be changed. +The login guide shows your active shortcuts. Disable it in **Settings → Shortcuts** +when you no longer need it. | Shortcut | Action | | --- | --- | diff --git a/compositor/src/config.rs b/compositor/src/config.rs index cfd171f..5a9ad18 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -566,6 +566,84 @@ impl Binding { } } + pub(crate) fn guide_entry(&self, keymap: Option<&xkb::Keymap>) -> Option { + let description = match &self.action { + BindingAction::None => return None, + BindingAction::Spawn(argv) + if argv + .first() + .is_some_and(|program| program == "ferese-screenshot") => + { + if argv.iter().any(|arg| arg == "--full") { + "Capture whole screen" + } else { + "Capture selected area" + } + .into() + } + BindingAction::Spawn(argv) => format!( + "Launch {}", + argv.first() + .and_then(|program| std::path::Path::new(program).file_name()) + .and_then(|name| name.to_str()) + .unwrap_or("application") + ), + BindingAction::Close => "Close window".into(), + BindingAction::Exit => "Log out (with confirmation)".into(), + BindingAction::Focus(direction) => format!("Focus window {direction:?}"), + BindingAction::Move(direction) => format!("Move window {direction:?}"), + BindingAction::Resize(direction) => format!("Resize window {direction:?}"), + BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"), + BindingAction::MoveToWorkspace(index) => format!("Move window to workspace {index}"), + BindingAction::SwitchRelativeWorkspace(next) => if *next { + "Next workspace" + } else { + "Previous workspace" + } + .into(), + BindingAction::ToggleFullscreen => "Toggle fullscreen".into(), + BindingAction::ToggleMaximized => "Toggle full-width window".into(), + BindingAction::ToggleLayout => "Switch workspace layout".into(), + BindingAction::CycleColumnWidth => "Cycle column width".into(), + BindingAction::CenterColumn => "Center focused column".into(), + BindingAction::Consume => "Join window into column".into(), + BindingAction::Expel => "Move window out of column".into(), + BindingAction::ToggleFloating => "Toggle floating window".into(), + BindingAction::ToggleOverview => "Open or close overview".into(), + }; + let mut parts = Vec::new(); + for (enabled, label) in [ + (self.modifiers.logo, "Super"), + (self.modifiers.ctrl, "Ctrl"), + (self.modifiers.alt, "Alt"), + (self.modifiers.shift, "Shift"), + ] { + if enabled { + parts.push(label.to_owned()); + } + } + parts.push(match self.trigger { + BindingTrigger::Keysym(symbol) => { + let name = xkb::keysym_get_name(xkb::Keysym::new(symbol)); + match name.as_str() { + "Return" => "Enter".into(), + "Print" => "Print Screen".into(), + "space" => "Space".into(), + _ if name.len() == 1 => name.to_uppercase(), + _ => name, + } + } + BindingTrigger::Physical(code) => keymap + .and_then(|map| map.key_get_name(code)) + .map(|name| format!("Physical {name}")) + .unwrap_or_else(|| format!("Keycode {}", u32::from(code))), + BindingTrigger::Swipe { fingers, direction } => { + format!("{fingers}-finger swipe {direction:?}") + } + }); + Some(serde_json::json!({"keys": parts.join(" + "), "description": description})) + } + pub(crate) fn swipe_fingers(&self, count: u32) -> bool { matches!(self.trigger, BindingTrigger::Swipe { fingers, .. } if fingers == count) } @@ -2352,6 +2430,29 @@ mod tests { ); } + #[test] + fn guide_uses_effective_bindings_including_overrides_and_unbindings() { + let config = parse( + "binding keys=\"Super+Q\" action=\"none\"\nbinding keys=\"Super+Tab\" action=\"toggle-floating\"\n", + ); + let input = config.input_settings().unwrap(); + let map = physical_keymap(&input).unwrap(); + let entries = config + .bindings(&input) + .unwrap() + .iter() + .filter_map(|binding| binding.guide_entry(Some(&map))) + .collect::>(); + assert!( + !entries + .iter() + .any(|entry| entry["description"] == "Close window") + ); + assert!(entries.iter().any(|entry| entry["keys"] == "Super + Tab" + && entry["description"] == "Toggle floating window")); + assert!(entries.iter().any(|entry| entry["keys"] == "Super + Enter")); + } + #[test] fn supplies_complete_v0_bindings_and_terminal_command() { let config = parse(""); diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 0852dd5..3f3455b 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -414,6 +414,16 @@ impl Ferese { "close" => self.close_focused_window(), "get-focused-window" => return Ok(self.focused_window_json()), "get-windows" => return Ok(self.windows_json()), + "get-keybindings" => { + let map = crate::config::physical_keymap(&self.input_settings).ok(); + return Ok(json!( + self.bindings + .iter() + .filter_map(|binding| binding.guide_entry(map.as_ref())) + .take(256) + .collect::>() + )); + } "has-client-surfaces" => { let pid = args .get("pid") diff --git a/docs/backlog.md b/docs/backlog.md index b542948..796eb90 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -1,3 +1,3 @@ # Backlog -- [ ] Add a first-run keybinding guide modal showing important keybindings and their functions. Keep showing it until the user disables the guide in Settings. +- [ ] Review the lock screen against swaylock for practical feature parity: dim and sleep after inactivity, and reduce the oversized password input. Keep the implementation simple. diff --git a/docs/configuration.md b/docs/configuration.md index 8447690..f08a79e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -389,6 +389,7 @@ returns. Ferese does not change the system's suspend policy. | `status` key | Type | Default | Meaning | | --- | --- | --- | --- | +| `keybinding-guide` | boolean | `true` | Show the active shortcut guide at login until disabled in Settings → Shortcuts. | | `window_title` | boolean | `true` | Focused window title in the bar center when space allows | | `battery_percentage` | boolean | `true` | Show percentage beside icon | | `low_battery_threshold` | integer 0–100 | `20` | Warning-color threshold | diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 7aff3ea..b7605e7 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -950,6 +950,12 @@ pub fn fields(page: Page) -> Vec { true, ), ], - Page::Shortcuts | Page::Startup | Page::Displays => vec![], + Page::Shortcuts => vec![toggle( + "status.keybinding_guide", + "Show keybinding guide at login", + "Keep the shortcut guide until you are comfortable with Ferese.", + true, + )], + Page::Startup | Page::Displays => vec![], } } diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index e079a2a..211003d 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version = "1.93" [dependencies] +ferese-ipc = { path = "../../crates/ferese-ipc" } ferese-theme = { path = "../../crates/ferese-theme" } ferese-config = { path = "../../crates/ferese-config" } ferese-core = { path = "../../crates/ferese-core" } diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 38278af..b6f7b19 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -19,6 +19,7 @@ pub(crate) struct ShellConfig { #[derive(Clone, Debug, Deserialize)] #[serde(default)] pub(crate) struct StatusConfig { + pub(crate) keybinding_guide: bool, pub(crate) battery_percentage: bool, pub(crate) window_title: bool, pub(crate) low_battery_threshold: u8, @@ -28,6 +29,7 @@ pub(crate) struct StatusConfig { impl Default for StatusConfig { fn default() -> Self { Self { + keybinding_guide: true, battery_percentage: true, window_title: true, low_battery_threshold: 20, @@ -567,6 +569,17 @@ mod tests { } use super::*; + #[test] + fn keybinding_guide_is_enabled_until_explicitly_disabled() { + assert!(parse_source("").unwrap().status.keybinding_guide); + assert!( + !parse_source("status { keybinding-guide #false; }") + .unwrap() + .status + .keybinding_guide + ); + } + #[test] fn parses_theme_without_rejecting_compositor_sections() { let config: FereseConfig = ferese_config::from_str( diff --git a/shell/ferese-shell/src/keybinding_guide.rs b/shell/ferese-shell/src/keybinding_guide.rs new file mode 100644 index 0000000..3613432 --- /dev/null +++ b/shell/ferese-shell/src/keybinding_guide.rs @@ -0,0 +1,44 @@ +use std::{os::unix::net::UnixStream, path::PathBuf, time::Duration}; + +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +pub(super) struct Entry { + pub keys: String, + pub description: String, +} + +pub(super) fn load() -> Result, String> { + let path = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .ok_or("Missing runtime directory")? + .join("ferese/control.sock"); + let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .map_err(|error| error.to_string())?; + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .map_err(|error| error.to_string())?; + let request = ferese_ipc::Request { + version: ferese_ipc::VERSION, + id: 1, + kind: "command".into(), + command: "get-keybindings".into(), + args: serde_json::json!({}), + }; + ferese_ipc::write_frame(&mut stream, &request).map_err(|error| error.to_string())?; + let response: ferese_ipc::Response = + ferese_ipc::read_frame(&mut stream).map_err(|error| error.to_string())?; + if let Some(error) = response.error { + return Err(error.message); + } + let entries: Vec = serde_json::from_value(response.result.ok_or("Missing keybindings")?) + .map_err(|error| error.to_string())?; + if entries.len() > 256 + || entries + .iter() + .any(|entry| entry.keys.len() > 256 || entry.description.len() > 512) + { + return Err("Invalid keybinding guide".into()); + } + Ok(entries) +} diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 419cb49..41fa5c8 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -1,5 +1,6 @@ mod config; mod control; +mod keybinding_guide; mod motion; mod note_store; mod notification_ui; @@ -175,7 +176,10 @@ struct FereseShell { calendar_offset: i32, status_error: Option, menu: Option, - power_modal: Option, + system_modal: Option, + guide_shown: bool, + guide_loading: bool, + guide_attempts: u8, pending_power: Option<(window::Id, system_modal::PowerAction)>, note_editor: Option, note_drag: Option, @@ -250,6 +254,8 @@ enum Message { OpenMenu(status_ui::Menu, cosmic::iced::Rectangle), OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle), Control(status::Action), + ShowGuide, + GuideLoaded(Result, String>), ConfirmPower(status::Action), CancelPower, ExecutePower, @@ -292,7 +298,10 @@ impl cosmic::Application for FereseShell { calendar_offset: 0, status_error: None, menu: None, - power_modal: None, + system_modal: None, + guide_shown: false, + guide_loading: false, + guide_attempts: 0, pending_power: None, note_editor: None, note_drag: None, @@ -331,7 +340,7 @@ impl cosmic::Application for FereseShell { fn subscription(&self) -> Subscription { Subscription::batch([ if self - .power_modal + .system_modal .as_ref() .is_some_and(|modal| modal.motion.animating() || modal.motion.closing()) { @@ -628,14 +637,67 @@ impl cosmic::Application for FereseShell { self.open_menu(kind, anchor) } } + Message::ShowGuide => { + if self.guide_shown + || self.guide_loading + || self.guide_attempts >= 5 + || !self.config.status.keybinding_guide + || self.outputs.is_empty() + { + return Task::none(); + } + self.guide_loading = true; + self.guide_attempts += 1; + Task::perform( + async { + tokio::task::spawn_blocking(keybinding_guide::load) + .await + .map_err(|error| error.to_string()) + .and_then(|result| result) + }, + |result| cosmic::Action::App(Message::GuideLoaded(result)), + ) + } + Message::GuideLoaded(result) => { + self.guide_loading = false; + match result { + Ok(entries) + if self.config.status.keybinding_guide + && !self.outputs.is_empty() + && self.system_modal.is_none() + && self.pending_power.is_none() => + { + self.guide_shown = true; + self.open_guide(entries) + } + result => { + if let Err(error) = result { + eprintln!("ferese-shell: keybinding guide unavailable: {error}"); + } + if self.guide_attempts < 5 + && self.config.status.keybinding_guide + && !self.guide_shown + { + Task::perform( + async { + tokio::time::sleep(Duration::from_secs(2)).await; + }, + |_| cosmic::Action::App(Message::ShowGuide), + ) + } else { + Task::none() + } + } + } + } Message::ConfirmPower(action) => { if let Some(action) = system_modal::PowerAction::from_status(action) { - return self.open_power_modal(action, None); + return self.open_system_modal(action, None); } Task::none() } - Message::ExecutePower => self.execute_power_modal(), - Message::AnimatePower => self.animate_power_modal(), + Message::ExecutePower => self.execute_system_modal(), + Message::AnimatePower => self.animate_system_modal(), Message::PowerCompleted(id, result) => self.finish_power_action(id, result), Message::CalendarMonth(delta) => { self.calendar_offset = (self.calendar_offset + delta).clamp(-1200, 1200); @@ -645,10 +707,10 @@ impl cosmic::Application for FereseShell { self.calendar_offset = 0; Task::none() } - Message::CancelPower => self.close_power_modal(), + Message::CancelPower => self.close_system_modal(), Message::Control(action) => { if let Some(action) = system_modal::PowerAction::from_status(action.clone()) { - return self.open_power_modal(action, None); + return self.open_system_modal(action, None); } if let status::Action::PowerProfile(profile) = action { @@ -768,7 +830,7 @@ impl cosmic::Application for FereseShell { if let Some((serial, output)) = poll.logout { reload_task = Task::batch([ reload_task, - self.open_power_modal( + self.open_system_modal( system_modal::PowerAction::Logout(serial), Some(&output), ), @@ -874,6 +936,15 @@ impl FereseShell { Task::none() }]; + if !self.config.status.keybinding_guide + && self + .system_modal + .as_ref() + .is_some_and(|modal| modal.is_guide()) + { + tasks.push(self.destroy_system_modal(false)); + } + if old.palette() != theme.palette() { tasks.push(cosmic::command::set_theme(theme.palette().native_theme())); } @@ -941,7 +1012,7 @@ impl FereseShell { let mut tasks = vec![ destroy_layer_surface(entry.bar), menu, - self.destroy_power_modal(true), + self.rebuild_system_modal(), ]; if self.note_drag.as_ref().is_some_and(|drag| { entry.clock == Some(drag.source) @@ -1084,7 +1155,8 @@ impl FereseShell { Task::batch(tasks), self.rebuild_clocks(false), self.rebuild_notes(false), - self.destroy_power_modal(true), + self.rebuild_system_modal(), + cosmic::task::message(cosmic::Action::App(Message::ShowGuide)), ]) } @@ -1875,8 +1947,8 @@ impl FereseShell { .. }) ) { - if self.power_modal.is_some() { - return self.close_power_modal(); + if self.system_modal.is_some() { + return self.close_system_modal(); } if self.note_drag.is_some() { return self.finish_note_drag(false); @@ -1900,7 +1972,7 @@ impl FereseShell { if self.outputs.iter().any(|entry| entry.bar == id) || self.menu.as_ref().is_some_and(|menu| menu.id == id) || self - .power_modal + .system_modal .as_ref() .is_some_and(|modal| modal.contains(id)) || self @@ -1992,11 +2064,11 @@ impl FereseShell { } Event::Window(window::Event::Closed) if self - .power_modal + .system_modal .as_ref() .is_some_and(|modal| modal.contains(id)) => { - self.close_power_modal() + self.close_system_modal() } Event::Window(window::Event::Closed) => Task::none(), Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame( diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index da8da96..eeeb28d 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -54,27 +54,71 @@ struct ModalSurface { regions: motion::Regions, } -pub(super) struct PowerModal { - action: PowerAction, +#[derive(Clone, Debug, PartialEq)] +enum Content { + Power(PowerAction), + Guide(Vec), +} + +pub(super) struct SystemModal { + content: Content, surfaces: Vec, pub(super) motion: motion::PopupMotion, error: Option, } -impl PowerModal { +impl SystemModal { + pub(super) fn is_guide(&self) -> bool { + matches!(self.content, Content::Guide(_)) + } + pub(super) fn contains(&self, id: window::Id) -> bool { self.surfaces.iter().any(|surface| surface.id == id) } } impl FereseShell { - pub(super) fn open_power_modal( + pub(super) fn open_system_modal( &mut self, action: PowerAction, output_name: Option<&str>, ) -> Task { + self.open_modal(Content::Power(action), output_name) + } + + pub(super) fn rebuild_system_modal(&mut self) -> Task { + let guide = self + .system_modal + .as_ref() + .and_then(|modal| match &modal.content { + Content::Guide(entries) + if !modal.motion.closing() && self.config.status.keybinding_guide => + { + Some(entries.clone()) + } + _ => None, + }); + let destroy = self.destroy_system_modal(true); + if let Some(entries) = guide { + if self.outputs.is_empty() { + self.guide_shown = false; + self.guide_attempts = 0; + return destroy; + } + let open = self.open_guide(entries); + Task::batch([destroy, open]) + } else { + destroy + } + } + + pub(super) fn open_guide(&mut self, entries: Vec) -> Task { + self.open_modal(Content::Guide(entries), None) + } + + fn open_modal(&mut self, content: Content, output_name: Option<&str>) -> Task { if self.pending_power.is_some() || self.outputs.is_empty() { - if let PowerAction::Logout(serial) = action + if let Content::Power(PowerAction::Logout(serial)) = content && let Some(control) = &self.control { control.cancel_logout(serial); @@ -82,9 +126,9 @@ impl FereseShell { return Task::none(); } if self - .power_modal + .system_modal .as_ref() - .is_some_and(|modal| modal.action == action && !modal.motion.closing()) + .is_some_and(|modal| modal.content == content && !modal.motion.closing()) { return Task::none(); } @@ -101,7 +145,7 @@ impl FereseShell { }) .unwrap_or(0); let mut tasks = vec![ - self.destroy_power_modal(true), + self.destroy_system_modal(true), self.destroy_menu(), self.close_notification_history(), ]; @@ -135,12 +179,12 @@ impl FereseShell { size: Some((None, None)), ..Default::default() }, - Some(Box::new(move |app| app.view_power_modal(id))), + Some(Box::new(move |app| app.view_system_modal(id))), ); tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); } - self.power_modal = Some(PowerModal { - action, + self.system_modal = Some(SystemModal { + content, surfaces, motion: motion::PopupMotion::new(self.config.animations), error: None, @@ -153,7 +197,7 @@ impl FereseShell { id: window::Id, surface: &wl_surface::WlSurface, ) { - let Some(modal) = &mut self.power_modal else { + let Some(modal) = &mut self.system_modal else { return; }; let Some(entry) = modal.surfaces.iter_mut().find(|entry| entry.id == id) else { @@ -169,7 +213,7 @@ impl FereseShell { } pub(super) fn update_power_materials(&self) { - if let Some(modal) = &self.power_modal { + if let Some(modal) = &self.system_modal { for entry in &modal.surfaces { if let Some(effects) = &entry.effects { let regions = entry.regions.lock().unwrap().clone(); @@ -181,36 +225,36 @@ impl FereseShell { } } - pub(super) fn close_power_modal(&mut self) -> Task { - if let Some(modal) = &mut self.power_modal { - if let PowerAction::Logout(serial) = modal.action + pub(super) fn close_system_modal(&mut self) -> Task { + if let Some(modal) = &mut self.system_modal { + if let Content::Power(PowerAction::Logout(serial)) = modal.content && let Some(control) = &self.control { control.cancel_logout(serial); } modal.motion.retarget(0.0, Instant::now()); } - self.animate_power_modal() + self.animate_system_modal() } - pub(super) fn animate_power_modal(&mut self) -> Task { + pub(super) fn animate_system_modal(&mut self) -> Task { if self - .power_modal + .system_modal .as_ref() .is_some_and(|modal| modal.motion.closing() && !modal.motion.animating()) { - return self.destroy_power_modal(false); + return self.destroy_system_modal(false); } self.update_power_materials(); Task::none() } - pub(super) fn destroy_power_modal(&mut self, cancel: bool) -> Task { - let Some(modal) = self.power_modal.take() else { + pub(super) fn destroy_system_modal(&mut self, cancel: bool) -> Task { + let Some(modal) = self.system_modal.take() else { return Task::none(); }; if cancel - && let PowerAction::Logout(serial) = modal.action + && let Content::Power(PowerAction::Logout(serial)) = modal.content && let Some(control) = &self.control { control.cancel_logout(serial); @@ -225,28 +269,31 @@ impl FereseShell { pub(super) fn cancel_logout_modal(&mut self, serial: u32) -> Task { if self - .power_modal + .system_modal .as_ref() - .is_some_and(|modal| modal.action == PowerAction::Logout(serial)) + .is_some_and(|modal| modal.content == Content::Power(PowerAction::Logout(serial))) { - self.destroy_power_modal(false) + self.destroy_system_modal(false) } else { Task::none() } } - pub(super) fn execute_power_modal(&mut self) -> Task { - let Some(modal) = &mut self.power_modal else { + pub(super) fn execute_system_modal(&mut self) -> Task { + let Some(modal) = &mut self.system_modal else { return Task::none(); }; if modal.motion.closing() || self.pending_power.is_some() { return Task::none(); } - let action = match modal.action { + let Content::Power(power) = modal.content else { + return self.close_system_modal(); + }; + let action = match power { PowerAction::Logout(serial) => { if let Some(control) = &self.control { control.confirm_logout(serial); - return self.destroy_power_modal(false); + return self.destroy_system_modal(false); } modal.error = Some("Ferese shell control is unavailable".to_owned()); return Task::none(); @@ -256,8 +303,8 @@ impl FereseShell { PowerAction::Suspend => status::Action::Suspend, }; let id = modal.surfaces[0].id; - self.pending_power = Some((id, modal.action)); - let destroy = self.destroy_power_modal(false); + self.pending_power = Some((id, power)); + let destroy = self.destroy_system_modal(false); let execute = Task::perform( async move { tokio::task::spawn_blocking(move || status::execute_power(action)) @@ -285,8 +332,8 @@ impl FereseShell { match result { Ok(()) => Task::none(), Err(error) => { - let task = self.open_power_modal(action, None); - if let Some(modal) = &mut self.power_modal { + let task = self.open_system_modal(action, None); + if let Some(modal) = &mut self.system_modal { modal.error = Some(error); } task @@ -294,8 +341,8 @@ impl FereseShell { } } - fn view_power_modal(&self, id: window::Id) -> Element<'_, cosmic::Action> { - let Some(modal) = &self.power_modal else { + fn view_system_modal(&self, id: window::Id) -> Element<'_, cosmic::Action> { + let Some(modal) = &self.system_modal else { return text("").into(); }; let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else { @@ -312,34 +359,71 @@ impl FereseShell { palette.sidebar = palette.sidebar.scale_alpha(alpha); palette.card = palette.card.scale_alpha(alpha); palette.accent = palette.accent.scale_alpha(alpha); - let mut rows = column![ - text(modal.action.title()).font(shell_font()).size(23), - text(modal.action.description()) - .font(shell_font()) - .size(14) - .class(theme::Text::Color(palette.muted)), - ] - .spacing(12); - if let Some(error) = &modal.error { - rows = rows.push(text(error).size(13)); - } - let confirm = button::text(modal.action.label()) - .class(ferese_theme::controls::button_style(palette, true)) - .on_press(cosmic::Action::App(Message::ExecutePower)); - rows = rows.push( - row![ - Space::new().width(Length::Fill), - button::text("Cancel") - .class(ferese_theme::controls::button_style(palette, false)) - .on_press(cosmic::Action::App(Message::CancelPower)), - confirm, - ] - .spacing(10), - ); + let rows = match &modal.content { + Content::Power(action) => { + let mut rows = column![ + text(action.title()).font(shell_font()).size(23), + text(action.description()) + .font(shell_font()) + .size(14) + .class(theme::Text::Color(palette.muted)), + ] + .spacing(12); + if let Some(error) = &modal.error { + rows = rows.push(text(error).size(13)); + } + rows.push( + row![ + Space::new().width(Length::Fill), + button::text("Cancel") + .class(ferese_theme::controls::button_style(palette, false)) + .on_press(cosmic::Action::App(Message::CancelPower)), + button::text(action.label()) + .class(ferese_theme::controls::button_style(palette, true)) + .on_press(cosmic::Action::App(Message::ExecutePower)), + ] + .spacing(10), + ) + } + Content::Guide(entries) => { + let mut bindings = column::with_capacity(entries.len()).spacing(12); + for entry in entries { + bindings = bindings.push( + row![ + text(&entry.keys) + .font(shell_font()) + .size(13) + .width(Length::FillPortion(1)), + text(&entry.description) + .font(shell_font()) + .size(13) + .width(Length::FillPortion(1)), + ] + .spacing(16), + ); + } + let height = self + .outputs + .iter() + .filter_map(|output| output.size) + .map(|(_, height)| height) + .min() + .unwrap_or(720) + .saturating_sub(360) + .clamp(40, 400) as f32; + column![ + text("Welcome to Ferese").font(shell_font()).size(23), + text("Your active shortcuts").font(shell_font()).size(14).class(theme::Text::Color(palette.muted)), + container(cosmic::widget::scrollable(bindings).height(Length::Shrink)).max_height(height), + text("Disable this guide in Settings → Shortcuts. It appears at each login until disabled.").font(shell_font()).size(12).class(theme::Text::Color(palette.muted)), + row![Space::new().width(Length::Fill), button::text("Got it").class(ferese_theme::controls::button_style(palette, true)).on_press(cosmic::Action::App(Message::CancelPower))], + ].spacing(14) + } + }; container(rows) .id("ferese-blur-card") .width(Length::Fill) - .max_width(440) + .max_width(if modal.is_guide() { 600 } else { 440 }) .padding(24) .class(theme::Container::custom(move |_| container::Style { background: (!material).then_some(Background::Color(color_with_opacity(