diff options
| -rw-r--r-- | docs/mkdocs.yaml | 1 | ||||
| -rw-r--r-- | docs/wiki/Configuration:-Animations.md | 18 | ||||
| -rw-r--r-- | docs/wiki/Configuration:-Introduction.md | 1 | ||||
| -rw-r--r-- | docs/wiki/Configuration:-Recent-Windows.md | 166 | ||||
| -rw-r--r-- | docs/wiki/_Sidebar.md | 1 | ||||
| -rw-r--r-- | niri-config/src/animations.rs | 36 | ||||
| -rw-r--r-- | niri-config/src/binds.rs | 21 | ||||
| -rw-r--r-- | niri-config/src/lib.rs | 156 | ||||
| -rw-r--r-- | niri-config/src/recent_windows.rs | 401 | ||||
| -rw-r--r-- | src/a11y.rs | 7 | ||||
| -rw-r--r-- | src/handlers/compositor.rs | 8 | ||||
| -rw-r--r-- | src/handlers/xdg_shell.rs | 8 | ||||
| -rw-r--r-- | src/input/mod.rs | 373 | ||||
| -rw-r--r-- | src/niri.rs | 154 | ||||
| -rw-r--r-- | src/ui/mod.rs | 1 | ||||
| -rw-r--r-- | src/ui/mru.rs | 1929 | ||||
| -rw-r--r-- | src/ui/mru/tests.rs | 135 | ||||
| -rw-r--r-- | src/window/mapped.rs | 12 |
18 files changed, 3350 insertions, 78 deletions
diff --git a/docs/mkdocs.yaml b/docs/mkdocs.yaml index b79807ad..3e33a92e 100644 --- a/docs/mkdocs.yaml +++ b/docs/mkdocs.yaml @@ -103,6 +103,7 @@ nav: - Layer Rules: Configuration:-Layer-Rules.md - Animations: Configuration:-Animations.md - Gestures: Configuration:-Gestures.md + - Recent Windows: Configuration:-Recent-Windows.md - Debug Options: Configuration:-Debug-Options.md - Include: Configuration:-Include.md - Development: diff --git a/docs/wiki/Configuration:-Animations.md b/docs/wiki/Configuration:-Animations.md index 2e59d62c..58b79b5f 100644 --- a/docs/wiki/Configuration:-Animations.md +++ b/docs/wiki/Configuration:-Animations.md @@ -58,6 +58,10 @@ animations { overview-open-close { spring damping-ratio=1.0 stiffness=800 epsilon=0.0001 } + + recent-windows-close { + spring damping-ratio=1.0 stiffness=800 epsilon=0.001 + } } ``` @@ -422,6 +426,20 @@ animations { } ``` +#### `recent-windows-close` + +<sup>Since: next release</sup> + +The close fade-out animation of the recent windows switcher. + +```kdl +animations { + recent-windows-close { + spring damping-ratio=1.0 stiffness=800 epsilon=0.001 + } +} +``` + ### Synchronized Animations <sup>Since: 0.1.5</sup> diff --git a/docs/wiki/Configuration:-Introduction.md b/docs/wiki/Configuration:-Introduction.md index ba9d08d9..28fa945e 100644 --- a/docs/wiki/Configuration:-Introduction.md +++ b/docs/wiki/Configuration:-Introduction.md @@ -12,6 +12,7 @@ You can find documentation for various sections of the config on these wiki page * [`layer-rule {}`](./Configuration:-Layer-Rules.md) * [`animations {}`](./Configuration:-Animations.md) * [`gestures {}`](./Configuration:-Gestures.md) +* [`recent-windows {}`](./Configuration:-Recent-Windows.md) * [`debug {}`](./Configuration:-Debug-Options.md) * [`include "other.kdl"`](./Configuration:-Include.md) diff --git a/docs/wiki/Configuration:-Recent-Windows.md b/docs/wiki/Configuration:-Recent-Windows.md new file mode 100644 index 00000000..07b166b0 --- /dev/null +++ b/docs/wiki/Configuration:-Recent-Windows.md @@ -0,0 +1,166 @@ +### Overview + +<sup>Since: next release</sup> + +In this section you can configure the recent windows switcher (Alt-Tab). + +Here is an outline of the available settings and their default values: + +```kdl +recent-windows { + // off + open-delay-ms 150 + + highlight { + active-color "#999999ff" + urgent-color "#ff9999ff" + padding 30 + corner-radius 0 + } + + previews { + max-height 480 + max-scale 0.5 + } + + binds { + Alt+Tab { next-window; } + Alt+Shift+Tab { previous-window; } + Alt+grave { next-window filter="app-id"; } + Alt+Shift+grave { previous-window filter="app-id"; } + + Mod+Tab { next-window; } + Mod+Shift+Tab { previous-window; } + Mod+grave { next-window filter="app-id"; } + Mod+Shift+grave { previous-window filter="app-id"; } + } +} +``` + +`off` disables the recent windows switcher altogether. + +### `open-delay-ms` + +Delay, in milliseconds, between pressing the Alt-Tab bind and the recent windows switcher visually appearing on screen. + +The switcher is delayed by default so that quickly tapping Alt-Tab to switch windows wouldn't cause annoying fullscreen visual changes. + +```kdl +recent-windows { + // Make the switcher appear instantly. + open-delay-ms 0 +} +``` + +### `highlight` + +Controls the highlight behind the focused window preview in the recent windows switcher. + +- `active-color`: normal color of the focused window highlight. +- `urgent-color`: color of an urgent focused window highlight, also visible in a darker shade on unfocused windows. +- `padding`: padding of the highlight around the window preview, in logical pixels. +- `corner-radius`: corner radius of the highlight. + +```kdl +recent-windows { + // Round the corners on the highlight. + highlight { + corner-radius 14 + } +} +``` + +### `previews` + +Controls the window previews in the switcher. + +- `max-scale`: maximum scale of the window previews. +Windows cannot be scaled bigger than this value. +- `max-height`: maximum height of the window previews. +Further limits the size of the previews in order to occupy less space on large monitors. + +On smaller monitors, the previews will be primarily limited by `max-scale`, and on larger monitors they will be primarily limited by `max-height`. + +The `max-scale` limit is imposed twice: on the final window scale, and on the window height which cannot exceed `monitor height × max scale`. + +```kdl +recent-windows { + // Make the previews smaller to fit more on screen. + previews { + max-height 320 + } +} +``` + +```kdl +recent-windows { + // Make the previews larger to see the window contents. + previews { + max-height 1080 + max-scale 0.75 + } +} +``` + +### `binds` + +Configure binds that open and navigate the recent windows switcher. + +The defaults are <kbd>Alt</kbd><kbd>Tab</kbd> / <kbd>Mod</kbd><kbd>Tab</kbd> to switch across all windows, and <kbd>Alt</kbd><kbd>\`</kbd> / <kbd>Mod</kbd><kbd>\`</kbd> to switch between windows of the current application. +Adding <kbd>Shift</kbd> will switch windows backwards. + +Adding the recent windows `binds {}` section to your config removes all default binds. +You can copy the ones you need from the summary at the top of this wiki page. + +```kdl +recent-windows { + // Even an empty binds {} section will remove all default binds. + binds { + } +} +``` + +The available actions are `next-window` and `previous-window`. +They can optionally have the following properties: + +- `filter="app-id"`: filters the switcher to the windows of the currently selected application, as determined by the Wayland app ID. +- `scope="all"`, `scope="output"`, `scope="workspace"`: sets the pre-selected scope when this bind is used to open the recent windows switcher. + +```kdl +recent-windows { + // Pre-select the "Output" scope when switching windows. + binds { + Mod+Tab { next-window scope="output"; } + Mod+Shift+Tab { previous-window scope="output"; } + Mod+grave { next-window scope="output" filter="app-id"; } + Mod+Shift+grave { previous-window scope="output" filter="app-id"; } + } +} +``` + +The recent windows binds have a precedence over the [normal binds](./Configuration:-Key-Bindings.md), meaning that if you have <kbd>Alt</kbd><kbd>Tab</kbd> bound to something else in the normal binds, the `recent-windows` bind will override it. + +All binds in this section must have a modifier key like <kbd>Alt</kbd> or <kbd>Mod</kbd> because the recent windows switcher remains open only while you hold any modifier key. + +#### Bindings inside the switcher + +When the switcher is open, some hardcoded binds are available: + +- <kbd>Escape</kbd> cancels the switcher. +- <kbd>Enter</kbd> closes the switcher confirming the current window. +- <kbd>A</kbd>, <kbd>W</kbd>, <kbd>O</kbd> select a specific scope. +- <kbd>S</kbd> cycles between scopes, as indicated by the panel at the top. +- <kbd>←</kbd>, <kbd>→</kbd>, <kbd>Home</kbd>, <kbd>End</kbd> move the selection directionally. + +Additionally, certain regular binds will automatically work in the switcher: + +- focus column left/right and their variants: will move the selection left/right inside the switcher. +- focus column first/last: will move the selection to the first or last window. +- close window: will close the window currently focused in the switcher. +- screenshot: will open the screenshot UI. + +The way this works is by finding all regular binds corresponding to these actions and taking just the trigger key without modifiers. +For example, if you have <kbd>Mod</kbd><kbd>Shift</kbd><kbd>C</kbd> bound to `close-window`, in the window switcher pressing <kbd>C</kbd> on its own will close the window. + +This way we don't need to hardcode things like HJKL directional movements. +If you have, say, Colemak-DH MNEI binds instead, they will work for you in the window switcher (as long as they don't conflict with the hardcoded ones). diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 448ec283..4b5c830d 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -33,6 +33,7 @@ * [Layer Rules](./Configuration:-Layer-Rules.md) * [Animations](./Configuration:-Animations.md) * [Gestures](./Configuration:-Gestures.md) +* [Recent Windows](./Configuration:-Recent-Windows.md) * [Debug Options](./Configuration:-Debug-Options.md) * [Include](./Configuration:-Include.md) diff --git a/niri-config/src/animations.rs b/niri-config/src/animations.rs index a3be59c9..346b6251 100644 --- a/niri-config/src/animations.rs +++ b/niri-config/src/animations.rs @@ -18,6 +18,7 @@ pub struct Animations { pub exit_confirmation_open_close: ExitConfirmationOpenCloseAnim, pub screenshot_ui_open: ScreenshotUiOpenAnim, pub overview_open_close: OverviewOpenCloseAnim, + pub recent_windows_close: RecentWindowsCloseAnim, } impl Default for Animations { @@ -35,6 +36,7 @@ impl Default for Animations { exit_confirmation_open_close: Default::default(), screenshot_ui_open: Default::default(), overview_open_close: Default::default(), + recent_windows_close: Default::default(), } } } @@ -67,6 +69,8 @@ pub struct AnimationsPart { pub screenshot_ui_open: Option<ScreenshotUiOpenAnim>, #[knuffel(child)] pub overview_open_close: Option<OverviewOpenCloseAnim>, + #[knuffel(child)] + pub recent_windows_close: Option<RecentWindowsCloseAnim>, } impl MergeWith<AnimationsPart> for Animations { @@ -92,6 +96,7 @@ impl MergeWith<AnimationsPart> for Animations { exit_confirmation_open_close, screenshot_ui_open, overview_open_close, + recent_windows_close, ); } } @@ -305,6 +310,22 @@ impl Default for OverviewOpenCloseAnim { } } +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RecentWindowsCloseAnim(pub Animation); + +impl Default for RecentWindowsCloseAnim { + fn default() -> Self { + Self(Animation { + off: false, + kind: Kind::Spring(SpringParams { + damping_ratio: 1., + stiffness: 800, + epsilon: 0.001, + }), + }) + } +} + impl<S> knuffel::Decode<S> for WorkspaceSwitchAnim where S: knuffel::traits::ErrorSpan, @@ -488,6 +509,21 @@ where } } +impl<S> knuffel::Decode<S> for RecentWindowsCloseAnim +where + S: knuffel::traits::ErrorSpan, +{ + fn decode_node( + node: &knuffel::ast::SpannedNode<S>, + ctx: &mut knuffel::decode::Context<S>, + ) -> Result<Self, DecodeError<S>> { + let default = Self::default().0; + Ok(Self(Animation::decode_node(node, ctx, default, |_, _| { + Ok(false) + })?)) + } +} + impl Animation { pub fn new_off() -> Self { Self { diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs index 1356d40f..378ae8ed 100644 --- a/niri-config/src/binds.rs +++ b/niri-config/src/binds.rs @@ -12,6 +12,7 @@ use smithay::input::keyboard::keysyms::KEY_NoSymbol; use smithay::input::keyboard::xkb::{keysym_from_name, KEYSYM_CASE_INSENSITIVE, KEYSYM_NO_FLAGS}; use smithay::input::keyboard::Keysym; +use crate::recent_windows::{MruDirection, MruFilter, MruScope}; use crate::utils::{expect_only_children, MergeWith}; #[derive(Debug, Default, PartialEq)] @@ -364,6 +365,26 @@ pub enum Action { UnsetWindowUrgent(u64), #[knuffel(skip)] LoadConfigFile, + #[knuffel(skip)] + MruAdvance { + direction: MruDirection, + scope: Option<MruScope>, + filter: Option<MruFilter>, + }, + #[knuffel(skip)] + MruConfirm, + #[knuffel(skip)] + MruCancel, + #[knuffel(skip)] + MruCloseCurrentWindow, + #[knuffel(skip)] + MruFirst, + #[knuffel(skip)] + MruLast, + #[knuffel(skip)] + MruSetScope(MruScope), + #[knuffel(skip)] + MruCycleScope, } impl From<niri_ipc::Action> for Action { diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index dda7dfd6..e458da39 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -13,7 +13,7 @@ #[macro_use] extern crate tracing; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashSet; use std::ffi::OsStr; use std::fs::{self, File}; @@ -39,6 +39,7 @@ pub mod layer_rule; pub mod layout; pub mod misc; pub mod output; +pub mod recent_windows; pub mod utils; pub mod window_rule; pub mod workspace; @@ -54,6 +55,10 @@ pub use crate::layer_rule::LayerRule; pub use crate::layout::*; pub use crate::misc::*; pub use crate::output::{Output, OutputName, Outputs, Position, Vrr}; +use crate::recent_windows::RecentWindowsPart; +pub use crate::recent_windows::{ + MruDirection, MruFilter, MruPreviews, MruScope, RecentWindows, DEFAULT_MRU_COMMIT_MS, +}; pub use crate::utils::FloatOrInt; use crate::utils::{Flag, MergeWith as _}; pub use crate::window_rule::{FloatingPosition, RelativeTo, WindowRule}; @@ -85,6 +90,7 @@ pub struct Config { pub switch_events: SwitchBinds, pub debug: Debug, pub workspaces: Vec<Workspace>, + pub recent_windows: RecentWindows, } #[derive(Debug, Clone)] @@ -118,6 +124,7 @@ struct IncludeErrors(Vec<knuffel::Error>); // // We don't *need* it because we have a recursion limit, but it makes for nicer error messages. struct IncludeStack(HashSet<PathBuf>); +struct SawMruBinds(Rc<Cell<bool>>); // Rather than listing all fields and deriving knuffel::Decode, we implement // knuffel::DecodeChildren by hand, since we need custom logic for every field anyway: we want to @@ -140,6 +147,7 @@ where let includes = ctx.get::<Rc<RefCell<Includes>>>().unwrap().clone(); let include_errors = ctx.get::<Rc<RefCell<IncludeErrors>>>().unwrap().clone(); let recursion = ctx.get::<Recursion>().unwrap().0; + let saw_mru_binds = ctx.get::<SawMruBinds>().unwrap().0.clone(); let mut seen = HashSet::new(); @@ -269,6 +277,21 @@ where config.borrow_mut().layout.merge_with(&part); } + "recent-windows" => { + let part = RecentWindowsPart::decode_node(node, ctx)?; + + let mut config = config.borrow_mut(); + + // When an MRU binds section is encountered for the first time, clear out the + // default MRU binds. + if !saw_mru_binds.get() && part.binds.is_some() { + saw_mru_binds.set(true); + config.recent_windows.binds.clear(); + } + + config.recent_windows.merge_with(&part); + } + "include" => { let path: PathBuf = utils::parse_arg_node("include", node, ctx)?; let base = ctx.get::<BasePath>().unwrap(); @@ -331,6 +354,7 @@ where ctx.set(includes.clone()); ctx.set(include_errors.clone()); ctx.set(IncludeStack(include_stack)); + ctx.set(SawMruBinds(saw_mru_binds.clone())); ctx.set(config.clone()); }); @@ -424,6 +448,7 @@ impl Config { ctx.set(includes.clone()); ctx.set(include_errors.clone()); ctx.set(IncludeStack(include_stack)); + ctx.set(SawMruBinds(Rc::new(Cell::new(false)))); ctx.set(config.clone()); }, ); @@ -766,6 +791,10 @@ mod tests { window-close { curve "cubic-bezier" 0.05 0.7 0.1 1 } + + recent-windows-close { + off + } } gestures { @@ -848,6 +877,25 @@ mod tests { } workspace "workspace-2" workspace "workspace-3" + + recent-windows { + off + + highlight { + padding 15 + active-color "#00ff00" + } + + previews { + max-height 960 + } + + binds { + Alt+Tab { next-window; } + Alt+grave { next-window filter="app-id"; } + Super+Tab { next-window scope="output"; } + } + } "##, ); @@ -1507,6 +1555,18 @@ mod tests { ), }, ), + recent_windows_close: RecentWindowsCloseAnim( + Animation { + off: true, + kind: Spring( + SpringParams { + damping_ratio: 1.0, + stiffness: 800, + epsilon: 0.001, + }, + ), + }, + ), }, gestures: Gestures { dnd_edge_view_scroll: DndEdgeViewScroll { @@ -2119,6 +2179,100 @@ mod tests { layout: None, }, ], + recent_windows: RecentWindows { + on: false, + open_delay_ms: 150, + highlight: MruHighlight { + active_color: Color { + r: 0.0, + g: 1.0, + b: 0.0, + a: 1.0, + }, + urgent_color: Color { + r: 1.0, + g: 0.6, + b: 0.6, + a: 1.0, + }, + padding: 15.0, + corner_radius: 0.0, + }, + previews: MruPreviews { + max_height: 960.0, + max_scale: 0.5, + }, + binds: [ + Bind { + key: Key { + trigger: Keysym( + XK_Tab, + ), + modifiers: Modifiers( + ALT, + ), + }, + action: MruAdvance { + direction: Forward, + scope: None, + filter: Some( + All, + ), + }, + repeat: true, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: true, + hotkey_overlay_title: None, + }, + Bind { + key: Key { + trigger: Keysym( + XK_grave, + ), + modifiers: Modifiers( + ALT, + ), + }, + action: MruAdvance { + direction: Forward, + scope: None, + filter: Some( + AppId, + ), + }, + repeat: true, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: true, + hotkey_overlay_title: None, + }, + Bind { + key: Key { + trigger: Keysym( + XK_Tab, + ), + modifiers: Modifiers( + SUPER, + ), + }, + action: MruAdvance { + direction: Forward, + scope: Some( + Output, + ), + filter: Some( + All, + ), + }, + repeat: true, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: true, + hotkey_overlay_title: None, + }, + ], + }, } "#); } diff --git a/niri-config/src/recent_windows.rs b/niri-config/src/recent_windows.rs new file mode 100644 index 00000000..0d293ba1 --- /dev/null +++ b/niri-config/src/recent_windows.rs @@ -0,0 +1,401 @@ +use std::collections::HashSet; + +use knuffel::errors::DecodeError; +use smithay::input::keyboard::Keysym; + +use crate::utils::{expect_only_children, MergeWith}; +use crate::{Action, Bind, Color, FloatOrInt, Key, Modifiers, Trigger}; + +/// Delay before the window focus is considered to be locked-in for Window +/// MRU ordering. For now the delay is not configurable. +pub const DEFAULT_MRU_COMMIT_MS: u64 = 750; + +#[derive(Debug, PartialEq)] +pub struct RecentWindows { + pub on: bool, + pub open_delay_ms: u16, + pub highlight: MruHighlight, + pub previews: MruPreviews, + pub binds: Vec<Bind>, +} + +impl Default for RecentWindows { + fn default() -> Self { + RecentWindows { + on: true, + open_delay_ms: 150, + highlight: MruHighlight::default(), + previews: MruPreviews::default(), + binds: default_binds(), + } + } +} + +#[derive(knuffel::Decode, Debug, Default, PartialEq)] +pub struct RecentWindowsPart { + #[knuffel(child)] + pub on: bool, + #[knuffel(child)] + pub off: bool, + #[knuffel(child, unwrap(argument))] + pub open_delay_ms: Option<u16>, + #[knuffel(child)] + pub highlight: Option<MruHighlightPart>, + #[knuffel(child)] + pub previews: Option<MruPreviewsPart>, + #[knuffel(child)] + pub binds: Option<MruBinds>, +} + +impl MergeWith<RecentWindowsPart> for RecentWindows { + fn merge_with(&mut self, part: &RecentWindowsPart) { + self.on |= part.on; + if part.off { + self.on = false; + } + + merge_clone!((self, part), open_delay_ms); + merge!((self, part), highlight, previews); + + if let Some(part) = &part.binds { + // Remove existing binds matching any new bind. + self.binds + .retain(|bind| !part.0.iter().any(|new| new.key == bind.key)); + // Add all new binds. + self.binds.extend(part.0.iter().cloned().map(Bind::from)); + } + } +} + +#[derive(Debug, PartialEq)] +pub struct MruHighlight { + pub active_color: Color, + pub urgent_color: Color, + pub padding: f64, + pub corner_radius: f64, +} + +impl Default for MruHighlight { + fn default() -> Self { + Self { + active_color: Color::new_unpremul(0.6, 0.6, 0.6, 1.), + urgent_color: Color::new_unpremul(1., 0.6, 0.6, 1.), + padding: 30., + corner_radius: 0., + } + } +} + +#[derive(knuffel::Decode, Debug, Default, PartialEq)] +pub struct MruHighlightPart { + #[knuffel(child)] + pub active_color: Option<Color>, + #[knuffel(child)] + pub urgent_color: Option<Color>, + #[knuffel(child, unwrap(argument))] + pub padding: Option<FloatOrInt<0, 65535>>, + #[knuffel(child, unwrap(argument))] + pub corner_radius: Option<FloatOrInt<0, 65535>>, +} + +impl MergeWith<MruHighlightPart> for MruHighlight { + fn merge_with(&mut self, part: &MruHighlightPart) { + merge_clone!((self, part), active_color, urgent_color); + merge!((self, part), padding, corner_radius); + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MruPreviews { + pub max_height: f64, + pub max_scale: f64, +} + +impl Default for MruPreviews { + fn default() -> Self { + Self { + max_height: 480., + max_scale: 0.5, + } + } +} + +#[derive(knuffel::Decode, Debug, Default, PartialEq)] +pub struct MruPreviewsPart { + #[knuffel(child, unwrap(argument))] + pub max_height: Option<FloatOrInt<1, 65535>>, + #[knuffel(child, unwrap(argument))] + pub max_scale: Option<FloatOrInt<0, 1>>, +} + +impl MergeWith<MruPreviewsPart> for MruPreviews { + fn merge_with(&mut self, part: &MruPreviewsPart) { + merge!((self, part), max_height, max_scale); + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MruBind { + // MRU bind keys must have a modifier, this is enforced during parsing. The switcher will close + // once all modifiers are released. + pub key: Key, + pub action: MruAction, + pub allow_inhibiting: bool, + pub hotkey_overlay_title: Option<Option<String>>, +} + +impl From<MruBind> for Bind { + fn from(x: MruBind) -> Self { + Self { + key: x.key, + action: Action::from(x.action), + repeat: true, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: x.allow_inhibiting, + hotkey_overlay_title: x.hotkey_overlay_title, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum MruDirection { + /// Most recently used to least. + #[default] + Forward, + /// Least recently used to most. + Backward, +} + +#[derive(knuffel::DecodeScalar, Clone, Copy, Debug, Default, PartialEq)] +pub enum MruScope { + /// All windows. + #[default] + All, + /// Windows on the active output. + Output, + /// Windows on the active workspace. + Workspace, +} + +#[derive(knuffel::DecodeScalar, Clone, Copy, Debug, Default, PartialEq)] +pub enum MruFilter { + /// All windows. + #[default] + #[knuffel(skip)] + All, + /// Windows with the same app id as the active window. + AppId, +} + +#[derive(knuffel::Decode, Debug, Clone, PartialEq)] +pub enum MruAction { + NextWindow( + #[knuffel(property(name = "scope"))] Option<MruScope>, + #[knuffel(property(name = "filter"), default)] MruFilter, + ), + PreviousWindow( + #[knuffel(property(name = "scope"))] Option<MruScope>, + #[knuffel(property(name = "filter"), default)] MruFilter, + ), +} + +impl From<MruAction> for Action { + fn from(x: MruAction) -> Self { + match x { + MruAction::NextWindow(scope, filter) => Self::MruAdvance { + direction: MruDirection::Forward, + scope, + filter: Some(filter), + }, + MruAction::PreviousWindow(scope, filter) => Self::MruAdvance { + direction: MruDirection::Backward, + scope, + filter: Some(filter), + }, + } + } +} + +#[derive(Debug, Default, PartialEq)] +pub struct MruBinds(pub Vec<MruBind>); + +fn default_binds() -> Vec<Bind> { + let mut rv = Vec::new(); + + let mut push = |trigger, base_mod, filter| { + rv.push(Bind::from(MruBind { + key: Key { + trigger: Trigger::Keysym(trigger), + modifiers: base_mod, + }, + action: MruAction::NextWindow(None, filter), + allow_inhibiting: true, + hotkey_overlay_title: None, + })); + rv.push(Bind::from(MruBind { + key: Key { + trigger: Trigger::Keysym(trigger), + modifiers: base_mod | Modifiers::SHIFT, + }, + action: MruAction::PreviousWindow(None, filter), + allow_inhibiting: true, + hotkey_overlay_title: None, + })); + }; + + for base_mod in [Modifiers::ALT, Modifiers::COMPOSITOR] { + push(Keysym::Tab, base_mod, MruFilter::All); + push(Keysym::grave, base_mod, MruFilter::AppId); + } + + rv +} + +impl<S> knuffel::Decode<S> for MruBinds +where + S: knuffel::traits::ErrorSpan, +{ + fn decode_node( + node: &knuffel::ast::SpannedNode<S>, + ctx: &mut knuffel::decode::Context<S>, + ) -> Result<Self, DecodeError<S>> { + expect_only_children(node, ctx); + + let mut seen_keys = HashSet::new(); + + let mut binds = Vec::new(); + + for child in node.children() { + match MruBind::decode_node(child, ctx) { + Ok(bind) => { + if !seen_keys.insert(bind.key) { + ctx.emit_error(DecodeError::unexpected( + &child.node_name, + "keybind", + "duplicate keybind", + )); + continue; + } + + binds.push(bind); + } + Err(e) => { + ctx.emit_error(e); + } + } + } + + Ok(Self(binds)) + } +} + +impl<S> knuffel::Decode<S> for MruBind +where + S: knuffel::traits::ErrorSpan, +{ + fn decode_node( + node: &knuffel::ast::SpannedNode<S>, + ctx: &mut knuffel::decode::Context<S>, + ) -> Result<Self, DecodeError<S>> { + if let Some(type_name) = &node.type_name { + ctx.emit_error(DecodeError::unexpected( + type_name, + "type name", + "no type name expected for this node", + )); + } + + for val in node.arguments.iter() { + ctx.emit_error(DecodeError::unexpected( + &val.literal, + "argument", + "no arguments expected for this node", + )); + } + + let key = node + .node_name + .parse::<Key>() + .map_err(|e| DecodeError::conversion(&node.node_name, e.wrap_err("invalid keybind")))?; + + // A modifier is required because MRU remains on screen as long as any modifier is held. + if key.modifiers.is_empty() { + ctx.emit_error(DecodeError::unexpected( + &node.node_name, + "keybind", |
