From 2b9f30fa5dd449482b74ec05bb31b89b1d5ed409 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 1 Oct 2026 12:30:35 +0100 Subject: [PATCH] Add shortcut hint toggle with Super+F1 --- compositor/src/config.rs | 37 ++++++++- compositor/src/input.rs | 3 + compositor/src/ipc.rs | 8 ++ compositor/src/shell_control.rs | 20 ++++- docs/shortcuts.md | 5 ++ feresectl/src/main.rs | 11 ++- protocols/ferese-shell-v1.xml | 10 ++- shell/ferese-shell/src/control.rs | 9 +- shell/ferese-shell/src/keybinding_guide.rs | 95 ++++++++++++++++++++++ shell/ferese-shell/src/main.rs | 53 +++++++----- shell/ferese-shell/src/system_modal.rs | 14 +++- 11 files changed, 235 insertions(+), 30 deletions(-) diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 7e75da6..7685d21 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -514,6 +514,7 @@ pub enum BindingAction { Expel, ToggleFloating, ToggleOverview, + ToggleKeybindingGuide, } impl Binding { @@ -616,6 +617,7 @@ impl Binding { BindingAction::Expel => "Move window out of column".into(), BindingAction::ToggleFloating => "Toggle floating window".into(), BindingAction::ToggleOverview => "Open or close overview".into(), + BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(), }; let mut parts = Vec::new(); for (enabled, label) in [ @@ -1566,6 +1568,10 @@ fn parse_action( no_argument()?; Ok(BindingAction::ToggleOverview) } + "toggle-keybinding-guide" => { + no_argument()?; + Ok(BindingAction::ToggleKeybindingGuide) + } _ => Err(ConfigError::InvalidBinding(format!("unknown action {action:?}"))), } } @@ -1606,6 +1612,7 @@ fn default_bindings() -> Vec { binding("Super+]", "expel", None), binding("Super+Shift+Space", "toggle-floating", None), binding("Super+Tab", "toggle-overview", None), + binding("Super+F1", "toggle-keybinding-guide", None), binding("Super+Escape", "workspace-back-and-forth", None), binding("Super+Shift+E", "exit", None), ]; @@ -1888,6 +1895,30 @@ pub(crate) fn config_path() -> Option { mod tests { use super::*; + #[test] + fn shortcut_hint_has_a_default_binding_and_can_be_rebound() { + let bindings = parse("").runtime_config().unwrap().bindings; + assert!(bindings.iter().any(|binding| binding.modifiers.logo + && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_F1) + && binding.action == BindingAction::ToggleKeybindingGuide)); + let bindings = parse( + "binding keys=\"Super+F1\" disabled=#true\nbinding keys=\"Super+F2\" action=\"toggle-keybinding-guide\"\n", + ) + .runtime_config() + .unwrap() + .bindings; + assert!( + !bindings + .iter() + .any(|binding| binding.trigger == BindingTrigger::Keysym(keysyms::KEY_F1)) + ); + assert!( + bindings + .iter() + .any(|binding| binding.trigger == BindingTrigger::Keysym(keysyms::KEY_F2) + && binding.action == BindingAction::ToggleKeybindingGuide) + ); + } #[test] fn workspace_back_and_forth_config_is_optional_and_bindable() { let defaults = Config::default().runtime_config().unwrap(); @@ -2372,7 +2403,7 @@ mod tests { let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 49); + assert_eq!(bindings.len(), 50); for (shift, action) in [ (false, BindingAction::ToggleMaximized), (true, BindingAction::ToggleFullscreen), @@ -2415,14 +2446,14 @@ mod tests { let input = replaced.input_settings().unwrap(); let bindings = replaced.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 49); + assert_eq!(bindings.len(), 50); assert!(bindings.iter().any(|binding| { binding.action == BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()]) })); let input = unbound.input_settings().unwrap(); let bindings = unbound.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 48); + assert_eq!(bindings.len(), 49); assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close)); } diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 7827418..12baf53 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -1017,6 +1017,9 @@ impl Ferese { BindingAction::Expel => self.expel_focused_window(), BindingAction::ToggleFloating => self.toggle_focused_floating(), BindingAction::ToggleOverview => self.toggle_overview(), + BindingAction::ToggleKeybindingGuide => { + self.toggle_keybinding_guide(); + } } } } diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 0375f9b..265b199 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -657,6 +657,14 @@ impl Ferese { "toggle-maximized" => self.toggle_focused_maximized(), "toggle-layout" => self.toggle_layout_mode(), "toggle-overview" => self.toggle_overview(), + "toggle-keybinding-guide" => { + if !self.toggle_keybinding_guide() { + return Err(CommandError::new( + "unavailable", + "Shortcut hint requires an unlocked session and an updated Ferese shell", + )); + } + } "cycle-column-width" => self.cycle_focused_column_width(), "center-column" => self.center_focused_column(), "consume" => self.consume_focused_window(), diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index 0ac4757..7fa48f1 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -12,7 +12,7 @@ use crate::private_client::ClientCapabilities; use crate::state::ClientState; pub(crate) fn init_global(display: &DisplayHandle) { - display.create_global::(4, ()); + display.create_global::(5, ()); } pub(crate) fn config_chunks(source: &str) -> Vec<&str> { @@ -208,6 +208,24 @@ impl Ferese { } } + pub(crate) fn toggle_keybinding_guide(&mut self) -> bool { + if self.session_lock.active { + return false; + } + let Some(shell) = self + .shell_resources + .iter() + .filter_map(|shell| shell.upgrade().ok()) + .find(|shell| shell.version() >= 5) + else { + tracing::warn!("shortcut hint requires the updated Ferese shell"); + return false; + }; + let output = self.focused_output().map_or_else(String::new, |output| output.name()); + shell.toggle_keybinding_guide(output); + true + } + pub(crate) fn request_logout_confirmation(&mut self) { if self.session_lock.active { return; diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 5592359..f6d45dd 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -77,6 +77,11 @@ by default. The action can also be bound as `workspace-back-and-forth` or run wi | Print Screen | Capture the active monitor and open Satty | | Super + Shift + S | Select a screenshot area and open Satty | | Super + Shift + E | Ask to log out | +| Super + F1 | Open or close the shortcut hint | + +Super + F1 works even when the guide at login is disabled. It opens on the +focused monitor. Bind `toggle-keybinding-guide` to change the shortcut, or run +`feresectl toggle-keybinding-guide`. In Satty, Enter saves the edited screenshot and copies it to the clipboard; Escape discards it. See [Configuration](configuration.md#commands-and-bindings) diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index dddd917..67c15a7 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -146,6 +146,7 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), | "toggle-maximized" | "toggle-layout" | "toggle-overview" + | "toggle-keybinding-guide" | "cycle-column-width" | "center-column" | "consume" @@ -187,13 +188,21 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] mod tests { use super::*; + #[test] + fn shortcut_hint_command_rejects_extra_arguments() { + assert_eq!( + parse_args(["toggle-keybinding-guide".into()]).unwrap(), + ("toggle-keybinding-guide".into(), json!({})) + ); + assert!(parse_args(["toggle-keybinding-guide".into(), "extra".into()]).is_err()); + } #[test] fn overview_toggle_accepts_no_arguments() { assert_eq!( diff --git a/protocols/ferese-shell-v1.xml b/protocols/ferese-shell-v1.xml index 1b3cf29..2aafe3c 100644 --- a/protocols/ferese-shell-v1.xml +++ b/protocols/ferese-shell-v1.xml @@ -22,7 +22,7 @@ DEALINGS IN THE SOFTWARE. - + This private global is visible only to clients granted the shell-control capability. It does not assign a Wayland surface role. @@ -35,7 +35,7 @@ - + Window, workspace, and output identifiers are unsigned 64-bit values encoded as high and low 32-bit words. Each snapshot atomically replaces @@ -162,5 +162,11 @@ + + + Explicit requests are independent of the guide-at-login preference. + + + diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index 21ea44c..b9b7f92 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -59,6 +59,7 @@ pub(crate) struct ControlPoll { pub(crate) disconnected: bool, pub(crate) logout: Option<(u32, String)>, pub(crate) logout_cancelled: Vec, + pub(crate) guide_toggles: Vec, } enum ControlUpdate { @@ -68,6 +69,7 @@ enum ControlUpdate { Disconnected, Logout(u32, String), LogoutCancelled(u32), + ToggleGuide(String), } impl ShellControl { @@ -75,7 +77,7 @@ impl ShellControl { let connection = control_connection()?; let (globals, mut queue) = registry_queue_init::(&connection)?; let qh = queue.handle(); - let manager = globals.bind::(&qh, 4..=4, ())?; + let manager = globals.bind::(&qh, 4..=5, ())?; let shell = manager.get_shell(&qh, ()); let (sender, updates) = mpsc::channel(); let mut state = ControlState::new(sender); @@ -104,11 +106,13 @@ impl ShellControl { disconnected: false, logout: None, logout_cancelled: Vec::new(), + guide_toggles: Vec::new(), }; loop { match self.updates.try_recv() { Ok(ControlUpdate::Logout(serial, output)) => poll.logout = Some((serial, output)), + Ok(ControlUpdate::ToggleGuide(output)) => poll.guide_toggles.push(output), Ok(ControlUpdate::LogoutCancelled(serial)) => { if poll.logout.as_ref().is_some_and(|(pending, _)| *pending == serial) { poll.logout = None; @@ -298,6 +302,9 @@ impl Dispatch for ControlState { ferese_shell_v1::Event::LogoutRequested { serial, output_name } => { let _ = state.sender.send(ControlUpdate::Logout(serial, output_name)); } + ferese_shell_v1::Event::ToggleKeybindingGuide { output_name } => { + let _ = state.sender.send(ControlUpdate::ToggleGuide(output_name)); + } ferese_shell_v1::Event::LogoutCancelled { serial } => { let _ = state.sender.send(ControlUpdate::LogoutCancelled(serial)); } diff --git a/shell/ferese-shell/src/keybinding_guide.rs b/shell/ferese-shell/src/keybinding_guide.rs index 03de85b..a182362 100644 --- a/shell/ferese-shell/src/keybinding_guide.rs +++ b/shell/ferese-shell/src/keybinding_guide.rs @@ -16,3 +16,98 @@ pub(super) fn load() -> Result, String> { } Ok(entries) } + +impl crate::FereseShell { + pub(super) fn load_guide(&mut self, manual: bool, output: Option) -> cosmic::app::Task { + let generation = self.guide_load.begin(manual); + cosmic::app::Task::perform( + async { + tokio::task::spawn_blocking(load) + .await + .map_err(|error| error.to_string()) + .and_then(|result| result) + }, + move |result| { + cosmic::Action::App(crate::Message::GuideLoaded { + generation, + manual, + output: output.clone(), + result, + }) + }, + ) + } + + pub(super) fn toggle_guide(&mut self, output: String) -> cosmic::app::Task { + if let Some(modal) = &mut self.system_modal { + if !modal.is_guide() { + return cosmic::app::Task::none(); + } + if modal.motion.closing() { + modal.motion.retarget(1.0, std::time::Instant::now()); + return cosmic::app::Task::none(); + } + return self.close_system_modal(); + } + // A second press before the asynchronous fetch completes cancels it. + // Its eventual result cannot reopen the dialog or finish a newer load. + if self.guide_load.loading { + self.guide_load.cancel(); + self.guide_shown = true; + return cosmic::app::Task::none(); + } + if self.pending_power.is_some() || self.outputs.is_empty() { + return cosmic::app::Task::none(); + } + self.guide_shown = true; + self.load_guide(true, Some(output)) + } +} + +#[derive(Default)] +pub(super) struct LoadState { + pub(super) loading: bool, + pub(super) manual: bool, + generation: u64, +} + +impl LoadState { + fn begin(&mut self, manual: bool) -> u64 { + self.generation = self.generation.wrapping_add(1); + self.loading = true; + self.manual = manual; + self.generation + } + + fn cancel(&mut self) { + self.generation = self.generation.wrapping_add(1); + self.loading = false; + } + + pub(super) fn finish(&mut self, generation: u64) -> bool { + if generation != self.generation { + return false; + } + self.loading = false; + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancelling_and_reopening_rejects_delayed_loads() { + let mut state = LoadState::default(); + let initial = state.begin(false); + state.cancel(); + assert!(!state.finish(initial)); + assert!(!state.loading); + let manual = state.begin(true); + assert!(!state.finish(initial)); + assert!(state.loading && state.manual); + assert!(state.finish(manual)); + assert!(!state.loading && state.manual); + } +} diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 8250176..7b33612 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -170,7 +170,7 @@ struct FereseShell { menu: Option, system_modal: Option, guide_shown: bool, - guide_loading: bool, + guide_load: keybinding_guide::LoadState, guide_attempts: u8, pending_power: Option<(window::Id, system_modal::PowerAction)>, note_editor: Option, @@ -247,7 +247,12 @@ enum Message { OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle), Control(status::Action), ShowGuide, - GuideLoaded(Result, String>), + GuideLoaded { + generation: u64, + manual: bool, + output: Option, + result: Result, String>, + }, SystemInhibitors(window::Id, Result), ConfirmPower(status::Action), CancelPower, @@ -290,7 +295,7 @@ impl cosmic::Application for FereseShell { menu: None, system_modal: None, guide_shown: false, - guide_loading: false, + guide_load: Default::default(), guide_attempts: 0, pending_power: None, note_editor: None, @@ -624,42 +629,44 @@ impl cosmic::Application for FereseShell { } Message::ShowGuide => { if self.guide_shown - || self.guide_loading + || self.guide_load.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)), - ) + self.load_guide(false, None) } - Message::GuideLoaded(result) => { - self.guide_loading = false; + Message::GuideLoaded { + generation, + manual, + output, + result, + } => { + if !self.guide_load.finish(generation) { + return Task::none(); + } match result { Ok(entries) - if self.config.status.keybinding_guide + if (manual || 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) + self.open_guide_on(entries, output.as_deref()) } 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 { + if !manual + && self.guide_attempts < 5 + && self.config.status.keybinding_guide + && !self.guide_shown + { Task::perform( async { tokio::time::sleep(Duration::from_secs(2)).await; @@ -795,6 +802,9 @@ impl cosmic::Application for FereseShell { } reload_task = Task::batch(tasks); } + for output in poll.guide_toggles { + reload_task = Task::batch([reload_task, self.toggle_guide(output)]); + } for serial in poll.logout_cancelled { reload_task = Task::batch([reload_task, self.cancel_logout_modal(serial)]); } @@ -908,7 +918,10 @@ impl FereseShell { Task::none() }]; - if !self.config.status.keybinding_guide && self.system_modal.as_ref().is_some_and(|modal| modal.is_guide()) { + if !self.guide_load.manual + && !self.config.status.keybinding_guide + && self.system_modal.as_ref().is_some_and(|modal| modal.is_guide()) + { tasks.push(self.destroy_system_modal(false)); } diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index c538077..48fffe6 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -87,7 +87,9 @@ impl FereseShell { 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 => { + Content::Guide(entries) + if !modal.motion.closing() && (self.guide_load.manual || self.config.status.keybinding_guide) => + { Some(entries.clone()) } _ => None, @@ -107,7 +109,15 @@ impl FereseShell { } pub(super) fn open_guide(&mut self, entries: Vec) -> Task { - self.open_modal(Content::Guide(entries), None) + self.open_guide_on(entries, None) + } + + pub(super) fn open_guide_on( + &mut self, + entries: Vec, + output: Option<&str>, + ) -> Task { + self.open_modal(Content::Guide(entries), output) } fn open_modal(&mut self, content: Content, output_name: Option<&str>) -> Task {