aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/mkdocs.yaml1
-rw-r--r--docs/wiki/Configuration:-Animations.md18
-rw-r--r--docs/wiki/Configuration:-Introduction.md1
-rw-r--r--docs/wiki/Configuration:-Recent-Windows.md166
-rw-r--r--docs/wiki/_Sidebar.md1
-rw-r--r--niri-config/src/animations.rs36
-rw-r--r--niri-config/src/binds.rs21
-rw-r--r--niri-config/src/lib.rs156
-rw-r--r--niri-config/src/recent_windows.rs401
-rw-r--r--src/a11y.rs7
-rw-r--r--src/handlers/compositor.rs8
-rw-r--r--src/handlers/xdg_shell.rs8
-rw-r--r--src/input/mod.rs373
-rw-r--r--src/niri.rs154
-rw-r--r--src/ui/mod.rs1
-rw-r--r--src/ui/mru.rs1929
-rw-r--r--src/ui/mru/tests.rs135
-rw-r--r--src/window/mapped.rs12
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",