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` + +Since: next release + +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 Since: 0.1.5 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 + +Since: next release + +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 AltTab / ModTab to switch across all windows, and Alt\` / Mod\` to switch between windows of the current application. +Adding Shift 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 AltTab 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 Alt or Mod 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: + +- Escape cancels the switcher. +- Enter closes the switcher confirming the current window. +- A, W, O select a specific scope. +- S cycles between scopes, as indicated by the panel at the top. +- , , Home, End 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 ModShiftC bound to `close-window`, in the window switcher pressing C 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, #[knuffel(child)] pub overview_open_close: Option, + #[knuffel(child)] + pub recent_windows_close: Option, } impl MergeWith for Animations { @@ -92,6 +96,7 @@ impl MergeWith 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 knuffel::Decode for WorkspaceSwitchAnim where S: knuffel::traits::ErrorSpan, @@ -488,6 +509,21 @@ where } } +impl knuffel::Decode for RecentWindowsCloseAnim +where + S: knuffel::traits::ErrorSpan, +{ + fn decode_node( + node: &knuffel::ast::SpannedNode, + ctx: &mut knuffel::decode::Context, + ) -> Result> { + 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, + filter: Option, + }, + #[knuffel(skip)] + MruConfirm, + #[knuffel(skip)] + MruCancel, + #[knuffel(skip)] + MruCloseCurrentWindow, + #[knuffel(skip)] + MruFirst, + #[knuffel(skip)] + MruLast, + #[knuffel(skip)] + MruSetScope(MruScope), + #[knuffel(skip)] + MruCycleScope, } impl From 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, + pub recent_windows: RecentWindows, } #[derive(Debug, Clone)] @@ -118,6 +124,7 @@ struct IncludeErrors(Vec); // // We don't *need* it because we have a recursion limit, but it makes for nicer error messages. struct IncludeStack(HashSet); +struct SawMruBinds(Rc>); // 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::>>().unwrap().clone(); let include_errors = ctx.get::>>().unwrap().clone(); let recursion = ctx.get::().unwrap().0; + let saw_mru_binds = ctx.get::().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::().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, +} + +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, + #[knuffel(child)] + pub highlight: Option, + #[knuffel(child)] + pub previews: Option, + #[knuffel(child)] + pub binds: Option, +} + +impl MergeWith 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, + #[knuffel(child)] + pub urgent_color: Option, + #[knuffel(child, unwrap(argument))] + pub padding: Option>, + #[knuffel(child, unwrap(argument))] + pub corner_radius: Option>, +} + +impl MergeWith 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>, + #[knuffel(child, unwrap(argument))] + pub max_scale: Option>, +} + +impl MergeWith 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>, +} + +impl From 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, + #[knuffel(property(name = "filter"), default)] MruFilter, + ), + PreviousWindow( + #[knuffel(property(name = "scope"))] Option, + #[knuffel(property(name = "filter"), default)] MruFilter, + ), +} + +impl From 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); + +fn default_binds() -> Vec { + 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 knuffel::Decode for MruBinds +where + S: knuffel::traits::ErrorSpan, +{ + fn decode_node( + node: &knuffel::ast::SpannedNode, + ctx: &mut knuffel::decode::Context, + ) -> Result> { + 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 knuffel::Decode for MruBind +where + S: knuffel::traits::ErrorSpan, +{ + fn decode_node( + node: &knuffel::ast::SpannedNode, + ctx: &mut knuffel::decode::Context, + ) -> Result> { + 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::() + .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", + "keybind must have a modifier key", + )); + } + + // FIXME: To support this, all the mods_with_mouse_binds()/mods_with_wheel_binds()/etc. + // will need to learn about recent-windows bindings. + if !matches!(key.trigger, Trigger::Keysym(_)) { + ctx.emit_error(DecodeError::unexpected( + &node.node_name, + "key", + "key must be a keyboard key (others are unsupported here for now)", + )); + } + + let mut allow_inhibiting = true; + let mut hotkey_overlay_title = None; + for (name, val) in &node.properties { + match &***name { + "allow-inhibiting" => { + allow_inhibiting = knuffel::traits::DecodeScalar::decode(val, ctx)?; + } + "hotkey-overlay-title" => { + hotkey_overlay_title = Some(knuffel::traits::DecodeScalar::decode(val, ctx)?); + } + name_str => { + ctx.emit_error(DecodeError::unexpected( + name, + "property", + format!("unexpected property `{}`", name_str.escape_default()), + )); + } + } + } + + let mut children = node.children(); + + // If the action is invalid but the key is fine, we still want to return something. + // That way, the parent can handle the existence of duplicate keybinds, + // even if their contents are not valid. + let dummy = Self { + key, + action: MruAction::NextWindow(None, MruFilter::All), + allow_inhibiting: true, + hotkey_overlay_title: None, + }; + + if let Some(child) = children.next() { + for unwanted_child in children { + ctx.emit_error(DecodeError::unexpected( + unwanted_child, + "node", + "only one action is allowed per keybind", + )); + } + match MruAction::decode_node(child, ctx) { + Ok(action) => Ok(Self { + key, + action, + allow_inhibiting, + hotkey_overlay_title, + }), + Err(e) => { + ctx.emit_error(e); + Ok(dummy) + } + } + } else { + ctx.emit_error(DecodeError::missing( + node, + "expected an action for this keybind", + )); + Ok(dummy) + } + } +} diff --git a/src/a11y.rs b/src/a11y.rs index 04b92dbf..f6553138 100644 --- a/src/a11y.rs +++ b/src/a11y.rs @@ -16,6 +16,7 @@ const ID_ANNOUNCEMENT: NodeId = NodeId(1); const ID_SCREENSHOT_UI: NodeId = NodeId(2); const ID_EXIT_CONFIRM_DIALOG: NodeId = NodeId(3); const ID_OVERVIEW: NodeId = NodeId(4); +const ID_MRU: NodeId = NodeId(5); pub struct A11y { event_loop: LoopHandle<'static, State>, @@ -205,6 +206,7 @@ impl Niri { KeyboardFocus::ScreenshotUi => ID_SCREENSHOT_UI, KeyboardFocus::ExitConfirmDialog => ID_EXIT_CONFIRM_DIALOG, KeyboardFocus::Overview => ID_OVERVIEW, + KeyboardFocus::Mru => ID_MRU, _ => ID_ROOT, } } @@ -237,12 +239,16 @@ impl Niri { let mut overview = Node::new(Role::Group); overview.set_label("Overview"); + let mut mru = Node::new(Role::Group); + mru.set_label("Recent windows"); + let mut root = Node::new(Role::Window); root.set_children(vec![ ID_ANNOUNCEMENT, ID_SCREENSHOT_UI, ID_EXIT_CONFIRM_DIALOG, ID_OVERVIEW, + ID_MRU, ]); let tree = Tree { @@ -260,6 +266,7 @@ impl Niri { (ID_SCREENSHOT_UI, screenshot_ui), (ID_EXIT_CONFIRM_DIALOG, exit_confirm_dialog), (ID_OVERVIEW, overview), + (ID_MRU, mru), ], tree: Some(tree), focus, diff --git a/src/handlers/compositor.rs b/src/handlers/compositor.rs index a7761824..dd5bb761 100644 --- a/src/handlers/compositor.rs +++ b/src/handlers/compositor.rs @@ -291,6 +291,7 @@ impl CompositorHandler for State { self.niri .stop_casts_for_target(CastTarget::Window { id: id.get() }); + self.niri.window_mru_ui.remove_window(id); self.niri.layout.remove_window(&window, transaction.clone()); self.add_default_dmabuf_pre_commit_hook(surface); @@ -311,6 +312,7 @@ impl CompositorHandler for State { if let Some(output) = output { self.niri.queue_redraw(&output); + self.niri.queue_redraw_mru_output(); } return; } @@ -337,6 +339,7 @@ impl CompositorHandler for State { } // The toplevel remains mapped. + self.niri.window_mru_ui.update_window(&self.niri.layout, id); self.niri.layout.update_window(&window, serial); // Move the toplevel according to the attach offset. @@ -357,6 +360,7 @@ impl CompositorHandler for State { if let Some(output) = output { self.niri.queue_redraw(&output); + self.niri.queue_redraw_mru_output(); } return; } @@ -370,9 +374,13 @@ impl CompositorHandler for State { let window = mapped.window.clone(); let output = output.cloned(); window.on_commit(); + self.niri + .window_mru_ui + .update_window(&self.niri.layout, mapped.id()); self.niri.layout.update_window(&window, None); if let Some(output) = output { self.niri.queue_redraw(&output); + self.niri.queue_redraw_mru_output(); } return; } diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs index e5d91f16..20f348ba 100644 --- a/src/handlers/xdg_shell.rs +++ b/src/handlers/xdg_shell.rs @@ -864,9 +864,9 @@ impl XdgShellHandler for State { let window = mapped.window.clone(); let output = output.cloned(); - self.niri.stop_casts_for_target(CastTarget::Window { - id: mapped.id().get(), - }); + let id = mapped.id(); + self.niri + .stop_casts_for_target(CastTarget::Window { id: id.get() }); self.backend.with_primary_renderer(|renderer| { self.niri.layout.store_unmap_snapshot(renderer, &window); @@ -883,6 +883,7 @@ impl XdgShellHandler for State { let active_window = self.niri.layout.focus().map(|m| &m.window); let was_active = active_window == Some(&window); + self.niri.window_mru_ui.remove_window(id); self.niri.layout.remove_window(&window, transaction.clone()); self.add_default_dmabuf_pre_commit_hook(surface.wl_surface()); @@ -898,6 +899,7 @@ impl XdgShellHandler for State { if let Some(output) = output { self.niri.queue_redraw(&output); + self.niri.queue_redraw_mru_output(); } } diff --git a/src/input/mod.rs b/src/input/mod.rs index 5e9e321e..a6fc549f 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -6,7 +6,9 @@ use std::time::Duration; use calloop::timer::{TimeoutAction, Timer}; use input::event::gesture::GestureEventCoordinates as _; -use niri_config::{Action, Bind, Binds, Key, ModKey, Modifiers, SwitchBinds, Trigger}; +use niri_config::{ + Action, Bind, Binds, Config, Key, ModKey, Modifiers, MruDirection, SwitchBinds, Trigger, +}; use niri_ipc::LayoutSwitchTarget; use smithay::backend::input::{ AbsolutePositionEvent, Axis, AxisSource, ButtonState, Device, DeviceCapability, Event, @@ -43,6 +45,7 @@ use self::spatial_movement_grab::SpatialMovementGrab; use crate::layout::scrolling::ScrollDirection; use crate::layout::{ActivateWindow, LayoutElement as _}; use crate::niri::{CastTarget, PointerVisibility, State}; +use crate::ui::mru::{WindowMru, WindowMruUi}; use crate::ui::screenshot_ui::ScreenshotUi; use crate::utils::spawning::{spawn, spawn_sh}; use crate::utils::{center, get_monotonic_time, ResizeEdge}; @@ -385,6 +388,7 @@ impl State { let key_code = event.key_code(); let modified = keysym.modified_sym(); let raw = keysym.raw_latin_sym_or_raw_current_sym(); + let modifiers = modifiers_from_state(*mods); if this.niri.exit_confirm_dialog.is_open() && pressed { if raw == Some(Keysym::Return) { @@ -397,6 +401,18 @@ impl State { return FilterResult::Intercept(None); } + // Check if all modifiers were released while the MRU UI was open. If so, close the + // UI (which will also transfer the focus to the current MRU UI selection). + if this.niri.window_mru_ui.is_open() && !pressed && modifiers.is_empty() { + this.do_action(Action::MruConfirm, false); + + if this.niri.suppressed_keys.remove(&key_code) { + return FilterResult::Intercept(None); + } else { + return FilterResult::Forward; + } + } + if pressed && raw == Some(Keysym::Escape) && (this.niri.pick_window.is_some() || this.niri.pick_color.is_some()) @@ -416,20 +432,25 @@ impl State { this.niri.screenshot_ui.set_space_down(pressed); } - let bindings = &this.niri.config.borrow().binds; - let res = should_intercept_key( - &mut this.niri.suppressed_keys, - &bindings.0, - mod_key, - key_code, - modified, - raw, - pressed, - *mods, - &this.niri.screenshot_ui, - this.niri.config.borrow().input.disable_power_key_handling, - is_inhibiting_shortcuts, - ); + let res = { + let config = this.niri.config.borrow(); + let bindings = + make_binds_iter(&config, &mut this.niri.window_mru_ui, modifiers); + + should_intercept_key( + &mut this.niri.suppressed_keys, + bindings, + mod_key, + key_code, + modified, + raw, + pressed, + *mods, + &this.niri.screenshot_ui, + this.niri.config.borrow().input.disable_power_key_handling, + is_inhibiting_shortcuts, + ) + }; if matches!(res, FilterResult::Forward) { // If we didn't find any bind, try other hardcoded keys. @@ -440,6 +461,10 @@ impl State { return FilterResult::Intercept(Some(bind)); } } + + // Interaction with the active window, immediately update the active window's + // focus timestamp without waiting for a possible pending MRU lock-in delay. + this.niri.mru_apply_keyboard_commit(); } res @@ -641,6 +666,7 @@ impl State { } Action::Screenshot(show_cursor, path) => { self.open_screenshot_ui(show_cursor, path); + self.niri.cancel_mru(); } Action::ScreenshotWindow(write_to_disk, path) => { let focus = self.niri.layout.focus_with_output(); @@ -2179,6 +2205,90 @@ impl State { watcher.load_config(); } } + Action::MruConfirm => { + self.confirm_mru(); + } + Action::MruCancel => { + self.niri.cancel_mru(); + } + Action::MruAdvance { + direction, + scope, + filter, + } => { + if self.niri.window_mru_ui.is_open() { + self.niri.window_mru_ui.advance(direction, filter); + self.niri.queue_redraw_mru_output(); + } else if self.niri.config.borrow().recent_windows.on { + self.niri.mru_apply_keyboard_commit(); + + let config = self.niri.config.borrow(); + let scope = scope.unwrap_or(self.niri.window_mru_ui.scope()); + + let mut wmru = WindowMru::new(&self.niri); + if !wmru.is_empty() { + wmru.set_scope(scope); + if let Some(filter) = filter { + wmru.set_filter(filter); + } + + if let Some(output) = self.niri.layout.active_output() { + self.niri.window_mru_ui.open( + self.niri.clock.clone(), + wmru, + output.clone(), + ); + + // Only select the *next* window if some window (which should be the + // first one) is already focused. If nothing is focused, keep the first + // window (which is logically the "previously selected" one). + let keep_first = direction == MruDirection::Forward + && self.niri.layout.focus().is_none(); + if !keep_first { + self.niri.window_mru_ui.advance(direction, None); + } + + drop(config); + self.niri.queue_redraw_all(); + } + } + } + } + Action::MruCloseCurrentWindow => { + if self.niri.window_mru_ui.is_open() { + if let Some(id) = self.niri.window_mru_ui.current_window_id() { + if let Some(w) = self.niri.find_window_by_id(id) { + if let Some(tl) = w.toplevel() { + tl.send_close(); + } + } + } + } + } + Action::MruFirst => { + if self.niri.window_mru_ui.is_open() { + self.niri.window_mru_ui.first(); + self.niri.queue_redraw_mru_output(); + } + } + Action::MruLast => { + if self.niri.window_mru_ui.is_open() { + self.niri.window_mru_ui.last(); + self.niri.queue_redraw_mru_output(); + } + } + Action::MruSetScope(scope) => { + if self.niri.window_mru_ui.is_open() { + self.niri.window_mru_ui.set_scope(scope); + self.niri.queue_redraw_mru_output(); + } + } + Action::MruCycleScope => { + if self.niri.window_mru_ui.is_open() { + self.niri.window_mru_ui.cycle_scope(); + self.niri.queue_redraw_mru_output(); + } + } } } @@ -2301,6 +2411,14 @@ impl State { self.niri.screenshot_ui.pointer_motion(point, None); } + if let Some(mru_output) = self.niri.window_mru_ui.output() { + if let Some((output, pos_within_output)) = self.niri.output_under(new_pos) { + if mru_output == output { + self.niri.window_mru_ui.pointer_motion(pos_within_output); + } + } + } + let under = self.niri.contents_under(new_pos); // Handle confined pointer. @@ -2431,6 +2549,14 @@ impl State { self.niri.screenshot_ui.pointer_motion(point, None); } + if let Some(mru_output) = self.niri.window_mru_ui.output() { + if let Some((output, pos_within_output)) = self.niri.output_under(pos) { + if mru_output == output { + self.niri.window_mru_ui.pointer_motion(pos_within_output); + } + } + } + let under = self.niri.contents_under(pos); self.niri.handle_focus_follows_mouse(&under); @@ -2509,7 +2635,29 @@ impl State { let mods = self.niri.seat.get_keyboard().unwrap().modifier_state(); let modifiers = modifiers_from_state(mods); - if self.niri.mods_with_mouse_binds.contains(&modifiers) { + let mut is_mru_open = false; + if let Some(mru_output) = self.niri.window_mru_ui.output() { + is_mru_open = true; + if let Some(MouseButton::Left) = button { + let location = pointer.current_location(); + let (output, pos_within_output) = self.niri.output_under(location).unwrap(); + if mru_output == output { + let id = self.niri.window_mru_ui.pointer_motion(pos_within_output); + if id.is_some() { + self.confirm_mru(); + } else { + self.niri.cancel_mru(); + } + } else { + self.niri.cancel_mru(); + } + + self.niri.suppressed_buttons.insert(button_code); + return; + } + } + + if is_mru_open || self.niri.mods_with_mouse_binds.contains(&modifiers) { if let Some(bind) = match button { Some(MouseButton::Left) => Some(Trigger::MouseLeft), Some(MouseButton::Right) => Some(Trigger::MouseRight), @@ -2520,7 +2668,8 @@ impl State { } .and_then(|trigger| { let config = self.niri.config.borrow(); - let bindings = &config.binds.0; + let bindings = + make_binds_iter(&config, &mut self.niri.window_mru_ui, modifiers); find_configured_bind(bindings, mod_key, trigger, mods) }) { self.niri.suppressed_buttons.insert(button_code); @@ -2824,59 +2973,66 @@ impl State { false }; + let is_mru_open = self.niri.window_mru_ui.is_open(); + // Handle wheel scroll bindings. if source == AxisSource::Wheel { // If we have a scroll bind with current modifiers, then accumulate and don't pass to // Wayland. If there's no bind, reset the accumulator. let mods = self.niri.seat.get_keyboard().unwrap().modifier_state(); let modifiers = modifiers_from_state(mods); - let should_handle = - should_handle_in_overview || self.niri.mods_with_wheel_binds.contains(&modifiers); + let should_handle = should_handle_in_overview + || is_mru_open + || self.niri.mods_with_wheel_binds.contains(&modifiers); if should_handle { let horizontal = horizontal_amount_v120.unwrap_or(0.); let ticks = self.niri.horizontal_wheel_tracker.accumulate(horizontal); if ticks != 0 { - let (bind_left, bind_right) = if should_handle_in_overview - && modifiers.is_empty() - { - let bind_left = Some(Bind { - key: Key { - trigger: Trigger::WheelScrollLeft, - modifiers: Modifiers::empty(), - }, - action: Action::FocusColumnLeftUnderMouse, - repeat: true, - cooldown: None, - allow_when_locked: false, - allow_inhibiting: false, - hotkey_overlay_title: None, - }); - let bind_right = Some(Bind { - key: Key { - trigger: Trigger::WheelScrollRight, - modifiers: Modifiers::empty(), - }, - action: Action::FocusColumnRightUnderMouse, - repeat: true, - cooldown: None, - allow_when_locked: false, - allow_inhibiting: false, - hotkey_overlay_title: None, - }); - (bind_left, bind_right) - } else { - let config = self.niri.config.borrow(); - let bindings = &config.binds.0; - let bind_left = - find_configured_bind(bindings, mod_key, Trigger::WheelScrollLeft, mods); - let bind_right = find_configured_bind( - bindings, - mod_key, - Trigger::WheelScrollRight, - mods, - ); - (bind_left, bind_right) - }; + let (bind_left, bind_right) = + if should_handle_in_overview && modifiers.is_empty() { + let bind_left = Some(Bind { + key: Key { + trigger: Trigger::WheelScrollLeft, + modifiers: Modifiers::empty(), + }, + action: Action::FocusColumnLeftUnderMouse, + repeat: true, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: false, + hotkey_overlay_title: None, + }); + let bind_right = Some(Bind { + key: Key { + trigger: Trigger::WheelScrollRight, + modifiers: Modifiers::empty(), + }, + action: Action::FocusColumnRightUnderMouse, + repeat: true, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: false, + hotkey_overlay_title: None, + }); + (bind_left, bind_right) + } else { + let config = self.niri.config.borrow(); + let bindings = + make_binds_iter(&config, &mut self.niri.window_mru_ui, modifiers); + let bind_left = find_configured_bind( + bindings.clone(), + mod_key, + Trigger::WheelScrollLeft, + mods, + ); + let bind_right = find_configured_bind( + bindings, + mod_key, + Trigger::WheelScrollRight, + mods, + ); + (bind_left, bind_right) + }; if let Some(right) = bind_right { for _ in 0..ticks { @@ -2948,9 +3104,14 @@ impl State { (bind_up, bind_down) } else { let config = self.niri.config.borrow(); - let bindings = &config.binds.0; - let bind_up = - find_configured_bind(bindings, mod_key, Trigger::WheelScrollUp, mods); + let bindings = + make_binds_iter(&config, &mut self.niri.window_mru_ui, modifiers); + let bind_up = find_configured_bind( + bindings.clone(), + mod_key, + Trigger::WheelScrollUp, + mods, + ); let bind_down = find_configured_bind(bindings, mod_key, Trigger::WheelScrollDown, mods); (bind_up, bind_down) @@ -3081,16 +3242,21 @@ impl State { } } - if self.niri.mods_with_finger_scroll_binds.contains(&modifiers) { + if is_mru_open || self.niri.mods_with_finger_scroll_binds.contains(&modifiers) { let ticks = self .niri .horizontal_finger_scroll_tracker .accumulate(horizontal); if ticks != 0 { let config = self.niri.config.borrow(); - let bindings = &config.binds.0; - let bind_left = - find_configured_bind(bindings, mod_key, Trigger::TouchpadScrollLeft, mods); + let bindings = + make_binds_iter(&config, &mut self.niri.window_mru_ui, modifiers); + let bind_left = find_configured_bind( + bindings.clone(), + mod_key, + Trigger::TouchpadScrollLeft, + mods, + ); let bind_right = find_configured_bind(bindings, mod_key, Trigger::TouchpadScrollRight, mods); drop(config); @@ -3113,9 +3279,14 @@ impl State { .accumulate(vertical); if ticks != 0 { let config = self.niri.config.borrow(); - let bindings = &config.binds.0; - let bind_up = - find_configured_bind(bindings, mod_key, Trigger::TouchpadScrollUp, mods); + let bindings = + make_binds_iter(&config, &mut self.niri.window_mru_ui, modifiers); + let bind_up = find_configured_bind( + bindings.clone(), + mod_key, + Trigger::TouchpadScrollUp, + mods, + ); let bind_down = find_configured_bind(bindings, mod_key, Trigger::TouchpadScrollDown, mods); drop(config); @@ -3234,6 +3405,14 @@ impl State { self.niri.screenshot_ui.pointer_motion(point, None); } + if let Some(mru_output) = self.niri.window_mru_ui.output() { + if let Some((output, pos_within_output)) = self.niri.output_under(pos) { + if mru_output == output { + self.niri.window_mru_ui.pointer_motion(pos_within_output); + } + } + } + let under = self.niri.contents_under(pos); let tablet_seat = self.niri.seat.tablet_seat(); @@ -3311,6 +3490,19 @@ impl State { self.niri.queue_redraw_all(); } } + } else if let Some(mru_output) = self.niri.window_mru_ui.output() { + if let Some((output, pos_within_output)) = self.niri.output_under(pos) { + if mru_output == output { + let id = self.niri.window_mru_ui.pointer_motion(pos_within_output); + if id.is_some() { + self.confirm_mru(); + } else { + self.niri.cancel_mru(); + } + } else { + self.niri.cancel_mru(); + } + } } else if let Some((window, _)) = under.window { if let Some(output) = is_overview_open.then_some(under.output).flatten() { let mut workspaces = self.niri.layout.workspaces(); @@ -3425,6 +3617,11 @@ impl State { } fn on_gesture_swipe_begin(&mut self, event: I::GestureSwipeBeginEvent) { + if self.niri.window_mru_ui.is_open() { + // Don't start swipe gestures while in the MRU. + return; + } + if event.fingers() == 3 { self.niri.gesture_swipe_3f_cumulative = Some((0., 0.)); @@ -3772,6 +3969,19 @@ impl State { self.niri.queue_redraw_all(); } } + } else if let Some(mru_output) = self.niri.window_mru_ui.output() { + if let Some((output, pos_within_output)) = self.niri.output_under(pos) { + if mru_output == output { + let id = self.niri.window_mru_ui.pointer_motion(pos_within_output); + if id.is_some() { + self.confirm_mru(); + } else { + self.niri.cancel_mru(); + } + } else { + self.niri.cancel_mru(); + } + } } else if !handle.is_grabbed() { let mods = self.niri.seat.get_keyboard().unwrap().modifier_state(); let mods = modifiers_from_state(mods); @@ -4696,6 +4906,29 @@ fn grab_allows_hot_corner(grab: &(dyn PointerGrab + 'static)) -> bool { true } +/// Returns an iterator over bindings. +/// +/// Includes dynamically populated bindings like the MRU UI. +fn make_binds_iter<'a>( + config: &'a Config, + mru: &'a mut WindowMruUi, + mods: Modifiers, +) -> impl Iterator + Clone { + // Figure out the binds to use depending on whether the MRU is enabled and/or open. + let general_binds = (!mru.is_open()).then_some(config.binds.0.iter()); + let general_binds = general_binds.into_iter().flatten(); + + let mru_binds = + (config.recent_windows.on || mru.is_open()).then_some(config.recent_windows.binds.iter()); + let mru_binds = mru_binds.into_iter().flatten(); + + let mru_open_binds = mru.is_open().then(|| mru.opened_bindings(mods)); + let mru_open_binds = mru_open_binds.into_iter().flatten(); + + // MRU binds take precedence over general ones. + mru_binds.chain(mru_open_binds).chain(general_binds) +} + #[cfg(test)] mod tests { use std::cell::Cell; diff --git a/src/niri.rs b/src/niri.rs index 551439c3..b6db2a25 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -16,7 +16,7 @@ use calloop::futures::Scheduler; use niri_config::debug::PreviewRender; use niri_config::{ Config, FloatOrInt, Key, Modifiers, OutputName, TrackLayout, WarpMouseToFocusMode, - WorkspaceReference, Xkb, + WorkspaceReference, Xkb, DEFAULT_MRU_COMMIT_MS, }; use smithay::backend::allocator::Fourcc; use smithay::backend::input::Keycode; @@ -165,6 +165,7 @@ use crate::render_helpers::{ use crate::ui::config_error_notification::ConfigErrorNotification; use crate::ui::exit_confirm_dialog::{ExitConfirmDialog, ExitConfirmDialogRenderElement}; use crate::ui::hotkey_overlay::HotkeyOverlay; +use crate::ui::mru::{MruCloseRequest, WindowMruUi, WindowMruUiRenderElement}; use crate::ui::screen_transition::{self, ScreenTransition}; use crate::ui::screenshot_ui::{OutputScreenshot, ScreenshotUi, ScreenshotUiRenderElement}; use crate::utils::scale::{closest_representable_scale, guess_monitor_scale}; @@ -384,6 +385,9 @@ pub struct Niri { pub hotkey_overlay: HotkeyOverlay, pub exit_confirm_dialog: ExitConfirmDialog, + pub window_mru_ui: WindowMruUi, + pub pending_mru_commit: Option, + pub pick_window: Option>>, pub pick_color: Option>>, @@ -520,6 +524,7 @@ pub enum KeyboardFocus { ScreenshotUi, ExitConfirmDialog, Overview, + Mru, } #[derive(Default, Clone, PartialEq)] @@ -582,6 +587,14 @@ pub enum CastTarget { Window { id: u64 }, } +/// Pending update to a window's focus timestamp. +#[derive(Debug)] +pub struct PendingMruCommit { + id: MappedId, + token: RegistrationToken, + stamp: Duration, +} + impl RedrawState { fn queue_redraw(self) -> Self { match self { @@ -620,6 +633,7 @@ impl KeyboardFocus { KeyboardFocus::ScreenshotUi => None, KeyboardFocus::ExitConfirmDialog => None, KeyboardFocus::Overview => None, + KeyboardFocus::Mru => None, } } @@ -631,6 +645,7 @@ impl KeyboardFocus { KeyboardFocus::ScreenshotUi => None, KeyboardFocus::ExitConfirmDialog => None, KeyboardFocus::Overview => None, + KeyboardFocus::Mru => None, } } @@ -939,6 +954,12 @@ impl State { self.niri.queue_redraw_all(); } + pub fn confirm_mru(&mut self) { + if let Some(window) = self.niri.close_mru(MruCloseRequest::Confirm) { + self.focus_window(&window); + } + } + pub fn maybe_warp_cursor_to_focus(&mut self) -> bool { let focused = match self.niri.config.borrow().input.warp_mouse_to_focus { None => return false, @@ -1099,6 +1120,8 @@ impl State { } } else if self.niri.screenshot_ui.is_open() { KeyboardFocus::ScreenshotUi + } else if self.niri.window_mru_ui.is_open() { + KeyboardFocus::Mru } else if let Some(output) = self.niri.layout.active_output() { let mon = self.niri.layout.monitor_for_output(output).unwrap(); let layers = layer_map_for_output(output); @@ -1225,6 +1248,38 @@ impl State { { if let Some((mapped, _)) = self.niri.layout.find_window_and_output_mut(surface) { mapped.set_is_focused(true); + + // If `mapped` does not have a focus timestamp, then the window is newly + // created/mapped and a timestamp is unconditionally created. + // + // If `mapped` already has a timestamp only update it after the focus lock-in + // period has gone by without the focus having elsewhere. + let stamp = get_monotonic_time(); + + if mapped.get_focus_timestamp().is_none() { + mapped.set_focus_timestamp(stamp); + } else { + let timer = + Timer::from_duration(Duration::from_millis(DEFAULT_MRU_COMMIT_MS)); + + let focus_token = self + .niri + .event_loop + .insert_source(timer, move |_, _, state| { + state.niri.mru_apply_keyboard_commit(); + TimeoutAction::Drop + }) + .unwrap(); + if let Some(PendingMruCommit { token, .. }) = + self.niri.pending_mru_commit.replace(PendingMruCommit { + id: mapped.id(), + token: focus_token, + stamp, + }) + { + self.niri.event_loop.remove(token); + } + } } } @@ -1411,6 +1466,7 @@ impl State { let mut layer_rules_changed = false; let mut shaders_changed = false; let mut cursor_inactivity_timeout_changed = false; + let mut recent_windows_changed = false; let mut xwls_changed = false; let mut old_config = self.niri.config.borrow_mut(); @@ -1459,8 +1515,9 @@ impl State { preserved_output_config = Some(mem::take(&mut old_config.outputs)); } + let binds_changed = config.binds != old_config.binds; let new_mod_key = self.backend.mod_key(&config); - if new_mod_key != self.backend.mod_key(&old_config) || config.binds != old_config.binds { + if new_mod_key != self.backend.mod_key(&old_config) || binds_changed { self.niri .hotkey_overlay .on_hotkey_config_updated(new_mod_key); @@ -1530,6 +1587,10 @@ impl State { output_config_changed = true; } + if config.recent_windows != old_config.recent_windows { + recent_windows_changed = true; + } + if config.xwayland_satellite != old_config.xwayland_satellite { xwls_changed = true; } @@ -1600,6 +1661,14 @@ impl State { self.niri.reset_pointer_inactivity_timer(); } + if binds_changed { + self.niri.window_mru_ui.update_binds(); + } + + if recent_windows_changed { + self.niri.window_mru_ui.update_config(); + } + if xwls_changed { // If xwl-s was previously working and is now off, we don't try to kill it or stop // watching the sockets, for simplicity's sake. @@ -2552,6 +2621,7 @@ impl Niri { let mods_with_finger_scroll_binds = mods_with_finger_scroll_binds(mod_key, &config_.binds); let screenshot_ui = ScreenshotUi::new(animation_clock.clone(), config.clone()); + let window_mru_ui = WindowMruUi::new(config.clone()); let config_error_notification = ConfigErrorNotification::new(animation_clock.clone(), config.clone()); @@ -2753,6 +2823,9 @@ impl Niri { hotkey_overlay, exit_confirm_dialog, + window_mru_ui, + pending_mru_commit: None, + pick_window: None, pick_color: None, @@ -3109,6 +3182,10 @@ impl Niri { .set_cursor_image(CursorImageStatus::default_named()); self.queue_redraw_all(); } + + if self.window_mru_ui.output() == Some(output) { + self.cancel_mru(); + } } pub fn output_resized(&mut self, output: &Output) { @@ -3376,7 +3453,11 @@ impl Niri { /// The cursor may be inside the window's activation region, but not within the window's input /// region. pub fn window_under(&self, pos: Point) -> Option<&Mapped> { - if self.exit_confirm_dialog.is_open() || self.is_locked() || self.screenshot_ui.is_open() { + if self.exit_confirm_dialog.is_open() + || self.is_locked() + || self.screenshot_ui.is_open() + || self.window_mru_ui.is_open() + { return None; } @@ -3455,7 +3536,7 @@ impl Niri { return rv; } - if self.screenshot_ui.is_open() { + if self.screenshot_ui.is_open() || self.window_mru_ui.is_open() { return rv; } @@ -3732,6 +3813,13 @@ impl Niri { Some((target_output.cloned(), target_workspace_index)) } + pub fn find_window_by_id(&self, id: MappedId) -> Option { + self.layout + .windows() + .find(|(_, m)| m.id() == id) + .map(|(_, m)| m.window.clone()) + } + pub fn output_for_tablet(&self) -> Option<&Output> { let config = self.config.borrow(); let map_to_output = config.input.tablet.map_to_output.as_ref(); @@ -4059,6 +4147,7 @@ impl Niri { KeyboardFocus::ScreenshotUi => true, KeyboardFocus::ExitConfirmDialog => true, KeyboardFocus::Overview => true, + KeyboardFocus::Mru => true, }; self.layout.refresh(layout_is_active); @@ -4190,6 +4279,7 @@ impl Niri { self.config_error_notification.advance_animations(); self.exit_confirm_dialog.advance_animations(); self.screenshot_ui.advance_animations(); + self.window_mru_ui.advance_animations(); for state in self.output_state.values_mut() { if let Some(transition) = &mut state.screen_transition { @@ -4346,6 +4436,15 @@ impl Niri { elements.push(element.into()); } + // Then, the Alt-Tab switcher. + let mru_elements = self + .window_mru_ui + .render_output(self, output, renderer, target) + .into_iter() + .flatten() + .map(OutputRenderElements::from); + elements.extend(mru_elements); + // Don't draw the focus ring on the workspaces while interactively moving above those // workspaces, since the interactively-moved window already has a focus ring. let focus_ring = !self.layout.interactive_move_is_moving_above_output(output); @@ -4537,6 +4636,7 @@ impl Niri { self.config_error_notification.are_animations_ongoing(); state.unfinished_animations_remain |= self.exit_confirm_dialog.are_animations_ongoing(); state.unfinished_animations_remain |= self.screenshot_ui.are_animations_ongoing(); + state.unfinished_animations_remain |= self.window_mru_ui.are_animations_ongoing(); state.unfinished_animations_remain |= state.screen_transition.is_some(); // Also keep redrawing if the current cursor is animated. @@ -5923,6 +6023,7 @@ impl Niri { self.screenshot_ui.close(); self.cursor_manager .set_cursor_image(CursorImageStatus::default_named()); + self.cancel_mru(); if self.output_state.is_empty() { // There are no outputs, lock the session right away. @@ -6181,6 +6282,10 @@ impl Niri { return; } + if self.window_mru_ui.is_open() { + return; + } + // Recompute the current pointer focus because we don't update it during animations. let current_focus = self.contents_under(pointer.current_location()); @@ -6407,6 +6512,46 @@ impl Niri { self.notified_activity_this_iteration = true; } + + pub fn close_mru(&mut self, close_request: MruCloseRequest) -> Option { + if !self.window_mru_ui.is_open() { + return None; + } + self.queue_redraw_all(); + + let id = self.window_mru_ui.close(close_request)?; + self.find_window_by_id(id) + } + + pub fn cancel_mru(&mut self) { + self.close_mru(MruCloseRequest::Cancel); + } + + /// Apply a pending MRU commit immediately. + /// + /// Called for example on keyboard events that reach the active window, which immediately adds + /// it to the MRU. + pub fn mru_apply_keyboard_commit(&mut self) { + let Some(pending) = self.pending_mru_commit.take() else { + return; + }; + self.event_loop.remove(pending.token); + + if let Some(window) = self + .layout + .workspaces_mut() + .flat_map(|ws| ws.windows_mut()) + .find(|w| w.id() == pending.id) + { + window.set_focus_timestamp(pending.stamp); + } + } + + pub fn queue_redraw_mru_output(&mut self) { + if let Some(output) = self.window_mru_ui.output().cloned() { + self.queue_redraw(&output); + } + } } pub struct NewClient { @@ -6454,6 +6599,7 @@ niri_render_elements! { NamedPointer = MemoryRenderBufferRenderElement, SolidColor = SolidColorRenderElement, ScreenshotUi = ScreenshotUiRenderElement, + WindowMruUi = WindowMruUiRenderElement, ExitConfirmDialog = ExitConfirmDialogRenderElement, Texture = PrimaryGpuTextureRenderElement, // Used for the CPU-rendered panels. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b546bda5..c194a247 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,6 @@ pub mod config_error_notification; pub mod exit_confirm_dialog; pub mod hotkey_overlay; +pub mod mru; pub mod screen_transition; pub mod screenshot_ui; diff --git a/src/ui/mru.rs b/src/ui/mru.rs new file mode 100644 index 00000000..736e2661 --- /dev/null +++ b/src/ui/mru.rs @@ -0,0 +1,1929 @@ +use std::cell::RefCell; +use std::cmp::min; +use std::collections::HashMap; +use std::mem; +use std::rc::Rc; +use std::time::Duration; + +use anyhow::ensure; +use niri_config::{ + Action, Bind, Color, Config, CornerRadius, GradientInterpolation, Key, Modifiers, MruDirection, + MruFilter, MruScope, Trigger, +}; +use pango::FontDescription; +use pangocairo::cairo::{self, ImageSurface}; +use smithay::backend::allocator::Fourcc; +use smithay::backend::renderer::element::utils::{ + Relocate, RelocateRenderElement, RescaleRenderElement, +}; +use smithay::backend::renderer::element::Kind; +use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture}; +use smithay::backend::renderer::Color32F; +use smithay::input::keyboard::Keysym; +use smithay::output::Output; +use smithay::utils::{Logical, Point, Rectangle, Scale, Size, Transform}; + +use crate::animation::{Animation, Clock}; +use crate::layout::focus_ring::{FocusRing, FocusRingRenderElement}; +use crate::layout::{Layout, LayoutElement as _, LayoutElementRenderElement}; +use crate::niri::Niri; +use crate::niri_render_elements; +use crate::render_helpers::border::BorderRenderElement; +use crate::render_helpers::clipped_surface::ClippedSurfaceRenderElement; +use crate::render_helpers::gradient_fade_texture::GradientFadeTextureRenderElement; +use crate::render_helpers::offscreen::{OffscreenBuffer, OffscreenRenderElement}; +use crate::render_helpers::primary_gpu_texture::PrimaryGpuTextureRenderElement; +use crate::render_helpers::renderer::NiriRenderer; +use crate::render_helpers::solid_color::{SolidColorBuffer, SolidColorRenderElement}; +use crate::render_helpers::texture::{TextureBuffer, TextureRenderElement}; +use crate::render_helpers::RenderTarget; +use crate::utils::{ + baba_is_float_offset, output_size, round_logical_in_physical, to_physical_precise_round, + with_toplevel_role, +}; +use crate::window::mapped::MappedId; +use crate::window::Mapped; + +#[cfg(test)] +mod tests; + +/// Windows up to this size don't get scaled further down. +const PREVIEW_MIN_SIZE: f64 = 16.; + +/// Border width on the selected window preview. +const BORDER: f64 = 2.; + +/// Gap from the window preview to the window title. +const TITLE_GAP: f64 = 14.; + +/// Gap between thumbnails. +const GAP: f64 = 16.; + +/// How much of the next window will always peek from the side of the screen. +const STRUT: f64 = 192.; + +/// Padding in the scope indication panel. +const PANEL_PADDING: i32 = 12; + +/// Border size of the scope indication panel. +const PANEL_BORDER: i32 = 4; + +/// Backdrop color behind the previews. +const BACKDROP_COLOR: Color32F = Color32F::new(0., 0., 0., 0.8); + +/// Font used to render the window titles. +const FONT: &str = "sans 14px"; + +/// Scopes in the order they are cycled through. +/// +/// Count must match one defined in `generate_scope_panels()`. +static SCOPE_CYCLE: [MruScope; 3] = [MruScope::All, MruScope::Workspace, MruScope::Output]; + +/// Window MRU traversal context. +#[derive(Debug)] +pub struct WindowMru { + /// Windows in MRU order. + thumbnails: Vec, + + /// Id of the currently selected window. + current_id: Option, + + /// Current scope. + scope: MruScope, + + /// Current filter. + app_id_filter: Option, +} + +pub struct WindowMruUi { + state: UiState, + preset_opened_binds: Vec, + dynamic_opened_binds: Vec, + config: Rc>, +} + +pub enum MruCloseRequest { + Cancel, + Confirm, +} + +niri_render_elements! { + ThumbnailRenderElement => { + LayoutElement = LayoutElementRenderElement, + ClippedSurface = ClippedSurfaceRenderElement, + Border = BorderRenderElement, + } +} + +niri_render_elements! { + WindowMruUiRenderElement => { + SolidColor = SolidColorRenderElement, + TextureElement = PrimaryGpuTextureRenderElement, + GradientFadeElem = GradientFadeTextureRenderElement, + FocusRing = FocusRingRenderElement, + Offscreen = OffscreenRenderElement, + Thumbnail = RelocateRenderElement>>, + } +} + +enum UiState { + Open(Inner), + Closing { + inner: Inner, + anim: Animation, + }, + Closed { + /// Scope used when the UI was last opened. + previous_scope: MruScope, + }, +} + +/// State of an opened MRU UI. +struct Inner { + /// List of Window Ids to display in the MRU UI. + wmru: WindowMru, + + /// View position relative to the leftmost visible window. + view_pos: ViewPos, + + // If true, don't automatically move the current thumbnail in-view. Set on pointer motion. + freeze_view: bool, + + /// Animation clock. + clock: Clock, + + /// Current config. + config: Rc>, + + /// Time when the UI should appear. + open_at: Duration, + + /// Output the UI was opened on. + output: Output, + + /// Scope panel textures. + scope_panel: RefCell, + + /// Backdrop buffers for each output. + backdrop_buffers: RefCell>, + + /// Offscreen buffer for the closing fade animation on the main output. + offscreen: OffscreenBuffer, +} + +#[derive(Debug)] +enum ViewPos { + /// The view position is static. + Static(f64), + /// The view position is animating. + Animation(Animation), +} + +#[derive(Debug)] +struct MoveAnimation { + anim: Animation, + from: f64, +} + +type MruTexture = TextureBuffer; + +/// Cached title texture. +#[derive(Debug, Default)] +struct TitleTexture { + title: String, + scale: f64, + texture: Option>, +} + +/// Cached scope panel textures. +#[derive(Debug, Default)] +struct ScopePanel { + scale: f64, + textures: Option>, +} + +#[derive(Debug)] +struct Thumbnail { + id: MappedId, + + /// Focus timestamp, if any. + timestamp: Option, + /// Whether the window is on the current MRU workspace. + on_current_workspace: bool, + /// Whether the window is on the current MRU output. + on_current_output: bool, + + /// Cached app ID of the window. + /// + /// Currently not updated live to avoid having to refilter windows. + app_id: Option, + /// Cached size of the window. + size: Size, + + clock: Clock, + config: niri_config::MruPreviews, + open_animation: Option, + move_animation: Option, + title_texture: RefCell, + background: RefCell, + border: RefCell, +} + +impl Thumbnail { + fn from_mapped(mapped: &Mapped, clock: Clock, config: niri_config::MruPreviews) -> Self { + let app_id = with_toplevel_role(mapped.toplevel(), |role| role.app_id.clone()); + + let background = FocusRing::new(niri_config::FocusRing { + off: false, + width: 0., + active_gradient: None, + ..Default::default() + }); + let border = FocusRing::new(niri_config::FocusRing { + off: false, + active_gradient: None, + ..Default::default() + }); + + Self { + id: mapped.id(), + timestamp: mapped.get_focus_timestamp(), + on_current_output: false, + on_current_workspace: false, + app_id, + size: mapped.size(), + clock, + config, + open_animation: None, + move_animation: None, + title_texture: Default::default(), + background: RefCell::new(background), + border: RefCell::new(border), + } + } + + fn are_animations_ongoing(&self) -> bool { + self.open_animation.is_some() || self.move_animation.is_some() + } + + fn advance_animations(&mut self) { + self.open_animation.take_if(|a| a.is_done()); + self.move_animation.take_if(|a| a.anim.is_done()); + } + + /// Animate thumbnail motion from given location. + fn animate_move_from_with_config(&mut self, from: f64, config: niri_config::Animation) { + let current_offset = self.render_offset(); + + // Preserve the previous config if ongoing. + let anim = self.move_animation.take().map(|ma| ma.anim); + let anim = anim + .map(|anim| anim.restarted(1., 0., 0.)) + .unwrap_or_else(|| Animation::new(self.clock.clone(), 1., 0., 0., config)); + + self.move_animation = Some(MoveAnimation { + anim, + from: from + current_offset, + }); + } + + fn animate_open_with_config(&mut self, config: niri_config::Animation) { + self.open_animation = Some(Animation::new(self.clock.clone(), 0., 1., 0., config)); + } + + fn render_offset(&self) -> f64 { + self.move_animation + .as_ref() + .map(|ma| ma.from * ma.anim.value()) + .unwrap_or_default() + } + + fn update_window(&mut self, mapped: &Mapped) { + self.size = mapped.size(); + } + + fn preview_size(&self, output_size: Size, scale: f64) -> Size { + let max_height = f64::max(1., self.config.max_height); + let max_scale = f64::max(0.001, self.config.max_scale); + + let max_height = f64::min(max_height, output_size.h * max_scale); + let output_ratio = output_size.w / output_size.h; + let max_width = max_height * output_ratio; + + let size = self.size.to_f64(); + let min_scale = f64::min(1., PREVIEW_MIN_SIZE / f64::max(size.w, size.h)); + + let thumb_scale = f64::min(max_width / size.w, max_height / size.h); + let thumb_scale = f64::min(max_scale, thumb_scale); + let thumb_scale = f64::max(min_scale, thumb_scale); + let size = size.to_f64().upscale(thumb_scale); + + // Round to physical pixels. + size.to_physical_precise_round(scale).to_logical(scale) + } + + fn title_texture( + &self, + renderer: &mut GlesRenderer, + mapped: &Mapped, + scale: f64, + ) -> Option { + with_toplevel_role(mapped.toplevel(), |role| { + role.title + .as_ref() + .and_then(|title| self.title_texture.borrow_mut().get(renderer, title, scale)) + }) + } + + #[allow(clippy::too_many_arguments)] + fn render( + &self, + renderer: &mut R, + config: &niri_config::RecentWindows, + mapped: &Mapped, + preview_geo: Rectangle, + scale: f64, + is_active: bool, + bob_y: f64, + target: RenderTarget, + ) -> impl Iterator> { + let _span = tracy_client::span!("Thumbnail::render"); + + let round = move |logical: f64| round_logical_in_physical(scale, logical); + let padding = round(config.highlight.padding); + let title_gap = round(TITLE_GAP); + + let s = Scale::from(scale); + + let preview_alpha = self + .open_animation + .as_ref() + .map_or(1., |a| a.clamped_value() as f32) + .clamp(0., 1.); + + let bob_y = if mapped.rules().baba_is_float == Some(true) { + bob_y + } else { + 0. + }; + let bob_offset = Point::new(0., bob_y); + + // FIXME: this could use mipmaps, for that it should be rendered through an offscreen. + let elems = mapped + .render_normal(renderer, Point::new(0., 0.), s, preview_alpha, target) + .into_iter(); + + // Clip thumbnails to their geometry. + let radius = if mapped.sizing_mode().is_normal() { + mapped.rules().geometry_corner_radius + } else { + None + } + .unwrap_or_default(); + + let has_border_shader = BorderRenderElement::has_shader(renderer); + let clip_shader = ClippedSurfaceRenderElement::shader(renderer).cloned(); + let geo = Rectangle::from_size(self.size.to_f64()); + // FIXME: deduplicate code with Tile::render_inner() + let elems = elems.map(move |elem| match elem { + LayoutElementRenderElement::Wayland(elem) => { + if let Some(shader) = clip_shader.clone() { + if ClippedSurfaceRenderElement::will_clip(&elem, s, geo, radius) { + let elem = + ClippedSurfaceRenderElement::new(elem, s, geo, shader.clone(), radius); + return ThumbnailRenderElement::ClippedSurface(elem); + } + } + + // If we don't have the shader, render it normally. + let elem = LayoutElementRenderElement::Wayland(elem); + ThumbnailRenderElement::LayoutElement(elem) + } + LayoutElementRenderElement::SolidColor(elem) => { + // In this branch we're rendering a blocked-out window with a solid + // color. We need to render it with a rounded corner shader even if + // clip_to_geometry is false, because in this case we're assuming that + // the unclipped window CSD already has corners rounded to the + // user-provided radius, so our blocked-out rendering should match that + // radius. + if radius != CornerRadius::default() && has_border_shader { + return BorderRenderElement::new( + geo.size, + Rectangle::from_size(geo.size), + GradientInterpolation::default(), + Color::from_color32f(elem.color()), + Color::from_color32f(elem.color()), + 0., + Rectangle::from_size(geo.size), + 0., + radius, + scale as f32, + 1., + ) + .into(); + } + + // Otherwise, render the solid color as is. + LayoutElementRenderElement::SolidColor(elem).into() + } + }); + + let elems = elems.map(move |elem| { + let thumb_scale = Scale { + x: preview_geo.size.w / geo.size.w, + y: preview_geo.size.h / geo.size.h, + }; + let offset = Point::new( + preview_geo.size.w - (geo.size.w * thumb_scale.x), + preview_geo.size.h - (geo.size.h * thumb_scale.y), + ) + .downscale(2.); + let elem = RescaleRenderElement::from_element(elem, Point::new(0, 0), thumb_scale); + let elem = RelocateRenderElement::from_element( + elem, + (preview_geo.loc + offset + bob_offset).to_physical_precise_round(scale), + Relocate::Relative, + ); + WindowMruUiRenderElement::Thumbnail(elem) + }); + + let mut title_size = None; + let title_texture = self.title_texture(renderer.as_gles_renderer(), mapped, scale); + let title_texture = title_texture.map(|texture| { + let mut size = texture.logical_size(); + size.w = f64::min(size.w, preview_geo.size.w); + title_size = Some(size); + (texture, size) + }); + + // Hide title for blocked-out windows, but only after computing the title size. This way, + // the background and the border won't have to oscillate in size between normal and + // screencast renders, causing excessive damage. + let should_block_out = target.should_block_out(mapped.rules().block_out_from); + let title_texture = title_texture.filter(|_| !should_block_out); + + let title_elems = title_texture.map(|(texture, size)| { + // Clip from the right if it doesn't fit. + let src = Rectangle::from_size(size); + + let loc = preview_geo.loc + + Point::new( + (preview_geo.size.w - size.w) / 2., + preview_geo.size.h + title_gap, + ); + let loc = loc.to_physical_precise_round(scale).to_logical(scale); + let texture = TextureRenderElement::from_texture_buffer( + texture, + loc, + preview_alpha, + Some(src), + None, + Kind::Unspecified, + ); + + let renderer = renderer.as_gles_renderer(); + if let Some(program) = GradientFadeTextureRenderElement::shader(renderer) { + let elem = GradientFadeTextureRenderElement::new(texture, program); + WindowMruUiRenderElement::GradientFadeElem(elem) + } else { + let elem = PrimaryGpuTextureRenderElement(texture); + WindowMruUiRenderElement::TextureElement(elem) + } + }); + + let is_urgent = mapped.is_urgent(); + let background_elems = (is_active || is_urgent).then(|| { + let padding = Point::new(padding, padding); + + let mut size = preview_geo.size; + size += padding.to_size().upscale(2.); + + if let Some(title_size) = title_size { + size.h += title_gap + title_size.h; + // Subtract half the padding so it looks more balanced visually. + size.h -= round(padding.y / 2.); + } + + // FIXME: gradient support (will require passing down correct view_rect). + let mut color = if is_urgent { + config.highlight.urgent_color + } else { + config.highlight.active_color + }; + if !is_active { + color *= 0.4; + } + + let radius = CornerRadius::from(config.highlight.corner_radius as f32); + + let loc = preview_geo.loc - padding; + + let mut background = self.background.borrow_mut(); + let mut config = *background.config(); + config.active_color = color; + background.update_config(config); + background.update_render_elements( + size, + true, + false, + false, + Rectangle::default(), + radius, + scale, + 0.5, + ); + let bg_elems = background + .render(renderer, loc) + .map(WindowMruUiRenderElement::FocusRing); + + let mut border = self.border.borrow_mut(); + let mut config = *border.config(); + config.off = !is_active; + config.width = round(BORDER); + config.active_color = color; + border.update_config(config); + border.set_thicken_corners(false); + border.update_render_elements( + size, + true, + true, + false, + Rectangle::default(), + radius.expanded_by(config.width as f32), + scale, + 1., + ); + + let border_elems = border + .render(renderer, loc) + .map(WindowMruUiRenderElement::FocusRing); + + bg_elems.chain(border_elems) + }); + let background_elems = background_elems.into_iter().flatten(); + + elems.chain(title_elems).chain(background_elems) + } +} + +impl WindowMru { + pub fn new(niri: &Niri) -> Self { + let Some(output) = niri.layout.active_output() else { + return Self { + thumbnails: Vec::new(), + current_id: None, + scope: MruScope::All, + app_id_filter: None, + }; + }; + + let config = niri.config.borrow().recent_windows.previews; + let mut thumbnails = Vec::new(); + for (mon, ws_idx, ws) in niri.layout.workspaces() { + let mon = mon.expect("an active output exists so all workspaces have a monitor"); + let on_current_output = mon.output() == output; + let on_current_workspace = on_current_output && mon.active_workspace_idx() == ws_idx; + + for mapped in ws.windows() { + let mut thumbnail = Thumbnail::from_mapped(mapped, niri.clock.clone(), config); + thumbnail.on_current_output = on_current_output; + thumbnail.on_current_workspace = on_current_workspace; + thumbnails.push(thumbnail); + } + } + + thumbnails + .sort_by(|Thumbnail { timestamp: t1, .. }, Thumbnail { timestamp: t2, .. }| t2.cmp(t1)); + + let current_id = thumbnails.first().map(|t| t.id); + Self { + thumbnails, + current_id, + scope: MruScope::All, + app_id_filter: None, + } + } + + pub fn is_empty(&self) -> bool { + self.thumbnails.is_empty() + } + + #[cfg(test)] + fn verify_invariants(&self) { + if let Some(id) = self.current_id { + assert!( + self.thumbnails().any(|thumbnail| thumbnail.id == id), + "current_id must be present in the current filtered thumbnail list", + ); + } else { + assert!( + self.thumbnails().next().is_none(), + "unset current_id must mean that the filtered thumbnail list is empty", + ); + } + } + + fn thumbnails(&self) -> impl DoubleEndedIterator { + let matches = match_filter(self.scope, self.app_id_filter.as_deref()); + self.thumbnails.iter().filter(move |t| matches(t)) + } + + fn thumbnails_mut(&mut self) -> impl DoubleEndedIterator { + let matches = match_filter(self.scope, self.app_id_filter.as_deref()); + self.thumbnails.iter_mut().filter(move |t| matches(t)) + } + + fn thumbnails_with_idx(&self) -> impl DoubleEndedIterator { + let matches = match_filter(self.scope, self.app_id_filter.as_deref()); + self.thumbnails + .iter() + .enumerate() + .filter(move |(_, t)| matches(t)) + } + + fn are_animations_ongoing(&self) -> bool { + self.thumbnails.iter().any(|t| t.are_animations_ongoing()) + } + + fn advance_animations(&mut self) { + for thumbnail in &mut self.thumbnails { + thumbnail.advance_animations(); + } + } + + fn forward(&mut self) { + let Some(id) = self.current_id else { + return; + }; + + let next = self.thumbnails().skip_while(|t| t.id != id).nth(1); + self.current_id = Some(if let Some(next) = next { + next.id + } else { + // We wrapped around. + self.thumbnails().next().unwrap().id + }); + } + + fn backward(&mut self) { + let Some(id) = self.current_id else { + return; + }; + + let next = self.thumbnails().rev().skip_while(|t| t.id != id).nth(1); + self.current_id = Some(if let Some(next) = next { + next.id + } else { + // We wrapped around. + self.thumbnails().next_back().unwrap().id + }); + } + + fn set_current(&mut self, id: MappedId) { + if self.thumbnails().any(|thumbnail| thumbnail.id == id) { + self.current_id = Some(id); + } + } + + fn first_id(&self) -> Option { + self.thumbnails().next().map(|thumbnail| thumbnail.id) + } + + fn first(&mut self) { + self.current_id = self.first_id(); + } + + fn last(&mut self) { + let id = self.thumbnails().next_back().map(|thumbnail| thumbnail.id); + self.current_id = id; + } + + pub fn set_scope(&mut self, scope: MruScope) -> Option { + if self.scope == scope { + return None; + } + let rv = Some(self.scope); + + if let Some(id) = self.current_id { + let (current_idx, _) = self + .thumbnails_with_idx() + .find(|(_, thumbnail)| thumbnail.id == id) + .unwrap(); + + self.scope = scope; + + // Try to select the same, or the first thumbnail to the left. Failing that, select the + // first one to the right. + let mut id = self.first_id(); + + for (idx, thumbnail) in self.thumbnails_with_idx() { + if idx > current_idx { + break; + } + id = Some(thumbnail.id); + } + self.current_id = id; + } else { + self.scope = scope; + self.current_id = self.first_id(); + } + + rv + } + + pub fn set_filter(&mut self, filter: MruFilter) -> Option> { + if self.app_id_filter.is_some() == (filter == MruFilter::AppId) { + // Filter unchanged. + return None; + } + + if let Some(id) = self.current_id { + let (current_idx, current_thumbnail) = self + .thumbnails_with_idx() + .find(|(_, thumbnail)| thumbnail.id == id) + .unwrap(); + + let old = match filter { + MruFilter::All => { + let old = self.app_id_filter.take(); + Some(old.expect("verified by early return at the top")) + } + MruFilter::AppId => { + // If the current thumbnail is missing an app id, we can't set the filter. + let current = current_thumbnail.app_id.clone()?; + let old = self.app_id_filter.replace(current); + assert!(old.is_none(), "verified by early return at the top"); + None + } + }; + + // Try to select the same, or the first thumbnail to the left. Failing that, select the + // first one to the right. + let mut id = self.first_id(); + + for (idx, thumbnail) in self.thumbnails_with_idx() { + if idx > current_idx { + break; + } + id = Some(thumbnail.id); + } + self.current_id = id; + + Some(old) + } else { + match filter { + MruFilter::All => { + let old = self.app_id_filter.take(); + let old = old.expect("verified by early return at the top"); + self.current_id = self.first_id(); + Some(Some(old)) + } + MruFilter::AppId => { + // We don't have a current window to set the app id filter. + None + } + } + } + } + + fn idx_of(&self, id: MappedId) -> Option { + self.thumbnails.iter().position(|t| t.id == id) + } + + fn remove_by_idx(&mut self, idx: usize) -> Option { + let id = self.thumbnails[idx].id; + + // Try to pick a different window when removing the current one. + if self.current_id == Some(id) { + self.forward(); + } + + // If we're still on the same window, that means it's the last visible one. + if self.current_id == Some(id) { + self.current_id = None; + } + + Some(self.thumbnails.remove(idx)) + } + + /// Returns the thumbnail if it's visible to the left of the currently selected one. + fn thumbnail_left_of_current(&self, id: MappedId) -> Option<&Thumbnail> { + for thumbnail in self.thumbnails() { + if Some(thumbnail.id) == self.current_id { + // We found the current window first, so the queried one is *not* to the left. + return None; + } else if thumbnail.id == id { + // We found the queried window first, so the current one is to the right of it. + return Some(thumbnail); + } + } + None + } +} + +fn matches(scope: MruScope, app_id_filter: Option<&str>, thumbnail: &Thumbnail) -> bool { + let x = match scope { + MruScope::All => true, + MruScope::Output => thumbnail.on_current_output, + MruScope::Workspace => thumbnail.on_current_workspace, + }; + if !x { + return false; + } + + if let Some(app_id) = app_id_filter { + thumbnail.app_id.as_deref() == Some(app_id) + } else { + true + } +} + +fn match_filter(scope: MruScope, app_id_filter: Option<&str>) -> impl Fn(&Thumbnail) -> bool + '_ { + move |thumbnail| matches(scope, app_id_filter, thumbnail) +} + +impl ViewPos { + fn current(&self) -> f64 { + match self { + ViewPos::Static(pos) => *pos, + ViewPos::Animation(anim) => anim.value(), + } + } + + fn target(&self) -> f64 { + match self { + ViewPos::Static(pos) => *pos, + ViewPos::Animation(anim) => anim.to(), + } + } + + fn are_animations_ongoing(&self) -> bool { + match self { + ViewPos::Static(_) => false, + ViewPos::Animation(_) => true, + } + } + + fn advance_animations(&mut self) { + if let ViewPos::Animation(anim) = self { + if anim.is_done() { + *self = ViewPos::Static(anim.to()); + } + } + } + + fn animate_from_with_config( + &mut self, + from: f64, + config: niri_config::Animation, + clock: Clock, + ) { + // FIXME: also compute and use current velocity. + let anim = Animation::new(clock, self.current() + from, self.target(), 0., config); + *self = ViewPos::Animation(anim); + } + + fn offset(&mut self, delta: f64) { + match self { + ViewPos::Static(pos) => *pos += delta, + ViewPos::Animation(anim) => anim.offset(delta), + } + } +} + +impl WindowMruUi { + pub fn new(config: Rc>) -> Self { + let mut rv = Self { + state: UiState::Closed { + previous_scope: MruScope::default(), + }, + preset_opened_binds: make_preset_opened_binds(), + dynamic_opened_binds: Vec::new(), + config, + }; + rv.update_binds(); + rv + } + + pub fn update_binds(&mut self) { + self.dynamic_opened_binds = make_dynamic_opened_binds(&self.config.borrow()); + } + + pub fn update_config(&mut self) { + let inner = match &mut self.state { + UiState::Open(inner) => inner, + UiState::Closing { inner, .. } => inner, + UiState::Closed { .. } => return, + }; + inner.update_config(); + } + + pub fn is_open(&self) -> bool { + matches!(self.state, UiState::Open { .. }) + } + + pub fn open(&mut self, clock: Clock, wmru: WindowMru, output: Output) { + if self.is_open() { + return; + } + + let open_delay = self.config.borrow().recent_windows.open_delay_ms; + let open_delay = Duration::from_millis(u64::from(open_delay)); + + let mut inner = Inner { + wmru, + view_pos: ViewPos::Static(0.), + freeze_view: false, + open_at: clock.now_unadjusted() + open_delay, + clock, + config: self.config.clone(), + output, + scope_panel: Default::default(), + backdrop_buffers: Default::default(), + offscreen: OffscreenBuffer::default(), + }; + inner.view_pos = ViewPos::Static(inner.compute_view_pos()); + + self.state = UiState::Open(inner); + } + + pub fn close(&mut self, close_request: MruCloseRequest) -> Option { + if !self.is_open() { + return None; + } + let state = mem::replace( + &mut self.state, + UiState::Closed { + previous_scope: MruScope::default(), + }, + ); + let UiState::Open(inner) = state else { + unreachable!(); + }; + + let response = match close_request { + MruCloseRequest::Cancel => None, + MruCloseRequest::Confirm => inner.wmru.current_id, + }; + + if inner.clock.now_unadjusted() < inner.open_at { + // Hasn't displayed yet, no need to fade out. + let UiState::Closed { previous_scope } = &mut self.state else { + unreachable!() + }; + *previous_scope = inner.wmru.scope; + return response; + } + + let config = self.config.borrow(); + let config = config.animations.recent_windows_close.0; + + let anim = Animation::new(inner.clock.clone(), 1., 0., 0., config); + self.state = UiState::Closing { inner, anim }; + response + } + + pub fn advance(&mut self, dir: MruDirection, filter: Option) { + let UiState::Open(inner) = &mut self.state else { + return; + }; + inner.freeze_view = false; + + if let Some(filter) = filter { + inner.set_filter(filter); + } + + match dir { + MruDirection::Forward => inner.wmru.forward(), + MruDirection::Backward => inner.wmru.backward(), + } + } + + pub fn set_scope(&mut self, scope: MruScope) { + let UiState::Open(inner) = &mut self.state else { + return; + }; + inner.freeze_view = false; + inner.set_scope(scope); + } + + pub fn cycle_scope(&mut self) { + let UiState::Open(inner) = &mut self.state else { + return; + }; + + let scope = inner.wmru.scope; + let scope = SCOPE_CYCLE + .into_iter() + .cycle() + .skip_while(|s| *s != scope) + .nth(1) + .unwrap(); + self.set_scope(scope); + } + + pub fn pointer_motion(&mut self, pos_within_output: Point) -> Option { + let UiState::Open(inner) = &mut self.state else { + return None; + }; + + inner.freeze_view = true; + + let id = inner.thumbnail_under(pos_within_output); + if let Some(id) = id { + inner.wmru.set_current(id); + } + id + } + + pub fn first(&mut self) { + let UiState::Open(inner) = &mut self.state else { + return; + }; + inner.freeze_view = false; + inner.wmru.first(); + } + + pub fn last(&mut self) { + let UiState::Open(inner) = &mut self.state else { + return; + }; + inner.freeze_view = false; + inner.wmru.last(); + } + + pub fn scope(&self) -> MruScope { + match &self.state { + UiState::Closed { previous_scope, .. } => *previous_scope, + UiState::Open(inner) | UiState::Closing { inner, .. } => inner.wmru.scope, + } + } + + pub fn current_window_id(&self) -> Option { + let UiState::Open(inner) = &self.state else { + return None; + }; + inner.wmru.current_id + } + + pub fn update_window(&mut self, layout: &Layout, id: MappedId) { + let UiState::Open(inner) = &mut self.state else { + return; + }; + inner.update_window(layout, id); + } + + pub fn remove_window(&mut self, id: MappedId) { + let UiState::Open(inner) = &mut self.state else { + return; + }; + + let Some(_thumbnail) = inner.remove_window(id) else { + return; + }; + + if inner.wmru.thumbnails.is_empty() { + self.close(MruCloseRequest::Cancel); + } + } + + pub fn render_output<'a, R: NiriRenderer>( + &'a self, + niri: &'a Niri, + output: &Output, + renderer: &'a mut R, + target: RenderTarget, + ) -> Option> + 'a> { + let (inner, progress) = match &self.state { + UiState::Closed { .. } => return None, + UiState::Closing { inner, anim } => (inner, anim.clamped_value()), + UiState::Open(inner) => { + if inner.open_at <= inner.clock.now_unadjusted() { + (inner, 1.) + } else { + return None; + } + } + }; + + let span = tracy_client::span!("mru render"); + + let alpha = progress.clamp(0., 1.) as f32; + + // Put a backdrop above the current desktop view to contrast the thumbnails. + let mut buffers = inner.backdrop_buffers.borrow_mut(); + let buffer = buffers.entry(output.clone()).or_default(); + buffer.resize(output_size(output)); + buffer.set_color(BACKDROP_COLOR); + let render_backdrop = |alpha| { + SolidColorRenderElement::from_buffer( + buffer, + Point::new(0., 0.), + alpha, + Kind::Unspecified, + ) + // Can't wrap into WindowMruUiRenderElement::SolidColor() right here since we have + // different generic in offscreen vs. normal path. + }; + + // During the closing fade, use an offscreen to avoid transparent compositing artifacts. + let offscreen_elem = if *output == inner.output && alpha < 1. { + let renderer = renderer.as_gles_renderer(); + let mut elems = Vec::from_iter(inner.render(niri, renderer, target)); + elems.push(WindowMruUiRenderElement::SolidColor(render_backdrop(1.))); + + let scale = output.current_scale().fractional_scale(); + match inner.offscreen.render(renderer, Scale::from(scale), &elems) { + Ok((elem, _sync, _data)) => { + // FIXME: would be good to passthrough offscreen data to visible windows here. + // As is, during the closing fade, windows from other workspaces stop receiving + // frame callbacks. + // + // However, we need to refactor our offscreen data a bit to make this nicer. + // Currently it supports a stack of offscreens, but not a several unrelated + // offscreens showing the same window (possibly in addition to the window + // itself). + // + // Anyhow, this is not very noticable since Alt-Tab closing happens quickly. + Some(WindowMruUiRenderElement::Offscreen(elem.with_alpha(alpha))) + } + Err(err) => { + warn!("error rendering MRU to offscreen for fade-out: {err:?}"); + None + } + } + } else { + None + }; + + // When alpha is 1., render everything directly, without an offscreen. + // + // This is not used as fallback when offscreen fails to render because it looks better to + // hide the previews immediately than to render them with alpha = 1. during a fade-out. + let normal_elems = + (*output == inner.output && alpha == 1.).then(|| inner.render(niri, renderer, target)); + let normal_elems = normal_elems.into_iter().flatten(); + + // This is used for both normal elems and for other outputs. + let backdrop_elem = (offscreen_elem.is_none()) + .then(|| WindowMruUiRenderElement::SolidColor(render_backdrop(alpha))); + + // Make sure the span includes consuming the iterator. + let drop_span = std::iter::once(span).filter_map(|_| None); + + Some( + offscreen_elem + .into_iter() + .chain(normal_elems) + .chain(backdrop_elem) + .chain(drop_span), + ) + } + + pub fn are_animations_ongoing(&self) -> bool { + match &self.state { + UiState::Open(inner) => inner.are_animations_ongoing(), + UiState::Closing { .. } => true, + UiState::Closed { .. } => false, + } + } + + pub fn advance_animations(&mut self) { + match &mut self.state { + UiState::Open(inner) => inner.advance_animations(), + UiState::Closing { inner, anim } => { + if anim.is_done() { + self.state = UiState::Closed { + previous_scope: inner.wmru.scope, + }; + return; + } + inner.advance_animations(); + } + UiState::Closed { .. } => {} + } + } + + pub fn opened_bindings(&mut self, mods: Modifiers) -> impl Iterator + Clone { + // Fill modifiers with the current mods. + for bind in &mut self.preset_opened_binds { + bind.key.modifiers = mods; + } + for bind in &mut self.dynamic_opened_binds { + bind.key.modifiers = mods; + } + + self.preset_opened_binds + .iter() + .chain(&self.dynamic_opened_binds) + } + + pub fn output(&self) -> Option<&Output> { + match &self.state { + UiState::Open(inner) => Some(&inner.output), + _ => None, + } + } +} + +fn compute_view_offset(cur_x: f64, working_width: f64, new_col_x: f64, new_col_width: f64) -> f64 { + let new_x = new_col_x; + let new_right_x = new_col_x + new_col_width; + + // If the column is already fully visible, leave the view as is. + if cur_x <= new_x && new_right_x <= cur_x + working_width { + return -(new_col_x - cur_x); + } + + // Otherwise, prefer the alignment that results in less motion from the current position. + let dist_to_left = (cur_x - new_x).abs(); + let dist_to_right = ((cur_x + working_width) - new_right_x).abs(); + if dist_to_left <= dist_to_right { + 0. + } else { + -(working_width - new_col_width) + } +} + +impl Inner { + fn update_config(&mut self) { + self.freeze_view = false; + + let config = self.config.borrow().recent_windows.previews; + for thumbnail in &mut self.wmru.thumbnails { + thumbnail.config = config; + } + } + + fn are_animations_ongoing(&self) -> bool { + self.clock.now_unadjusted() < self.open_at + || self.view_pos.are_animations_ongoing() + || self.wmru.are_animations_ongoing() + } + + fn advance_animations(&mut self) { + self.view_pos.advance_animations(); + self.wmru.advance_animations(); + + if !self.freeze_view { + let new_view_pos = self.compute_view_pos(); + let delta = new_view_pos - self.view_pos.target(); + let pixel = 1. / self.output.current_scale().fractional_scale(); + if delta.abs() > pixel { + self.animate_view_pos_from(-delta); + } + self.view_pos.offset(delta); + } + } + + fn animate_view_pos_from(&mut self, from: f64) { + let config = self.config.borrow().animations.window_movement.0; + self.view_pos + .animate_from_with_config(from, config, self.clock.clone()); + } + + fn compute_view_pos(&self) -> f64 { + let Some(current_id) = self.wmru.current_id else { + return 0.; + }; + + let output_size = output_size(&self.output); + + let working_x = STRUT + GAP; + let working_width = (output_size.w - working_x * 2.).max(0.); + + let mut current_geo = Rectangle::default(); + let mut strip_width = 0.; + for (thumbnail, geo) in self.thumbnails() { + if thumbnail.id == current_id { + current_geo = geo; + } + strip_width = geo.loc.x + geo.size.w; + + // If we found current_geo, and the strip width is already bigger than the working + // width, no need to compute further. + if current_geo.size.w != 0. && strip_width > working_width { + break; + } + } + + // If the whole strip fits on screen, center it. + if strip_width <= working_width { + return -(output_size.w - strip_width) / 2.; + } + + compute_view_offset( + self.view_pos.target() + working_x, + working_width, + current_geo.loc.x, + current_geo.size.w, + ) + current_geo.loc.x + - working_x + } + + fn update_window(&mut self, layout: &Layout, id: MappedId) { + let output_size = output_size(&self.output); + let scale = self.output.current_scale().fractional_scale(); + + // If the updated window is to the left of the currently selected one, we need to offset + // the view position to compensate for the change in size. + let left = self.wmru.thumbnail_left_of_current(id); + let prev_size = left.map(|thumbnail| thumbnail.preview_size(output_size, scale)); + + let Some(thumbnail) = self.wmru.thumbnails.iter_mut().find(|t| t.id == id) else { + return; + }; + + let Some((_, mapped)) = layout.windows().find(|(_, m)| m.id() == id) else { + error!("window in the MRU must be present in the layout"); + return; + }; + + thumbnail.update_window(mapped); + + if let Some(prev) = prev_size { + let new = thumbnail.preview_size(output_size, scale); + let delta = new.w - prev.w; + self.view_pos.offset(delta); + } + } + + fn remove_window(&mut self, id: MappedId) -> Option { + let idx = self.wmru.idx_of(id)?; + + let last_visible = self.wmru.thumbnails().next_back(); + let removing_last_visible = last_visible.is_some_and(|t| t.id == id); + + // When removing the last visible thumbnail, nothing needs to be animated. + // - If it's not currently selected, then it can't cause changes to view position. + // - If it's currently selected, then the first step in removal (focusing the next window) + // will wrap back to the start, and no animations should happen. + if !removing_last_visible { + let output_size = output_size(&self.output); + let scale = self.output.current_scale().fractional_scale(); + let round = move |logical: f64| round_logical_in_physical(scale, logical); + + let padding = self.config.borrow().recent_windows.highlight.padding; + let padding = round(padding) + round(BORDER); + let gap = padding + round(GAP) + padding; + + let prev_size = self.wmru.thumbnails[idx].preview_size(output_size, scale); + let delta = prev_size.w + gap; + + let config = self.config.borrow().animations.window_movement.0; + + // If the removed window is to the left of the currently selected one, we need to offset + // the view position to compensate for the change. + if self.wmru.thumbnail_left_of_current(id).is_some() { + self.view_pos.offset(-delta); + + // And animate movement of windows left of it. + for thumbnail in self.wmru.thumbnails_mut().take_while(|t| t.id != id) { + thumbnail.animate_move_from_with_config(-delta, config); + } + } else { + // Otherwise, animate movement of windows right of it. + for thumbnail in self.wmru.thumbnails_mut().rev().take_while(|t| t.id != id) { + thumbnail.animate_move_from_with_config(delta, config); + } + } + } + + self.wmru.remove_by_idx(idx) + } + + fn set_scope(&mut self, scope: MruScope) { + let was_empty = self.wmru.current_id.is_none(); + if let Some(old_scope) = self.wmru.set_scope(scope) { + self.animate_scope_filter_change(was_empty, old_scope, None); + } + } + + fn set_filter(&mut self, filter: MruFilter) { + let was_empty = self.wmru.current_id.is_none(); + if let Some(old_filter) = self.wmru.set_filter(filter) { + let old_filter = Some(old_filter.as_deref()); + self.animate_scope_filter_change(was_empty, self.wmru.scope, old_filter); + } + } + + fn animate_scope_filter_change( + &mut self, + was_empty: bool, + old_scope: MruScope, + old_filter: Option>, + ) { + let Some(id) = self.wmru.current_id else { + // If there's no current_id then the new filter caused all windows to disappear, so + // there's nothing to animate. + return; + }; + let idx = self.wmru.idx_of(id).unwrap(); + + // Animate opening for newly appeared thumbnails. + let config = self.config.borrow().animations.window_open.anim; + let old_filter = old_filter.unwrap_or(self.wmru.app_id_filter.as_deref()); + let matches_old = match_filter(old_scope, old_filter); + let matches_new = match_filter(self.wmru.scope, self.wmru.app_id_filter.as_deref()); + for thumbnail in &mut self.wmru.thumbnails { + if matches_new(thumbnail) && !matches_old(thumbnail) { + thumbnail.animate_open_with_config(config); + } + } + + if was_empty { + self.view_pos = ViewPos::Static(self.compute_view_pos()); + return; + } + + let output_size = output_size(&self.output); + let scale = self.output.current_scale().fractional_scale(); + let round = move |logical: f64| round_logical_in_physical(scale, logical); + + let padding = self.config.borrow().recent_windows.highlight.padding; + let padding = round(padding) + round(BORDER); + let gap = padding + round(GAP) + padding; + + let config = self.config.borrow().animations.window_movement.0; + + let mut delta = 0.; + for t in &mut self.wmru.thumbnails[idx + 1..] { + match (matches_old(t), matches_new(t)) { + (true, true) => t.animate_move_from_with_config(delta, config), + (true, false) => delta += t.preview_size(output_size, scale).w + gap, + (false, true) => delta -= t.preview_size(output_size, scale).w + gap, + (false, false) => (), + } + } + + let mut delta = 0.; + for t in self.wmru.thumbnails[..idx].iter_mut().rev() { + match (matches_old(t), matches_new(t)) { + (true, true) => t.animate_move_from_with_config(-delta, config), + (true, false) => delta += t.preview_size(output_size, scale).w + gap, + (false, true) => delta -= t.preview_size(output_size, scale).w + gap, + (false, false) => (), + } + } + + self.view_pos.offset(-delta); + } + + fn thumbnails(&self) -> impl Iterator)> { + let output_size = output_size(&self.output); + let scale = self.output.current_scale().fractional_scale(); + let round = move |logical: f64| round_logical_in_physical(scale, logical); + + let padding = self.config.borrow().recent_windows.highlight.padding; + let padding = round(padding) + round(BORDER); + let gap = padding + round(GAP) + padding; + + let mut x = 0.; + self.wmru.thumbnails().map(move |thumbnail| { + let size = thumbnail.preview_size(output_size, scale); + let y = round((output_size.h - size.h) / 2.); + + let loc = Point::new(x, y); + x += size.w + gap; + + let geo = Rectangle::new(loc, size); + (thumbnail, geo) + }) + } + + fn thumbnails_in_view_static( + &self, + ) -> impl Iterator)> { + let output_size = output_size(&self.output); + let scale = self.output.current_scale().fractional_scale(); + let round = |logical: f64| round_logical_in_physical(scale, logical); + + let view_pos = round(self.view_pos.current()); + + let leftmost = view_pos; + let rightmost = view_pos + output_size.w; + + self.thumbnails() + .skip_while(move |(_, geo)| geo.loc.x + geo.size.w <= leftmost) + .map_while(move |(thumbnail, mut geo)| { + if rightmost <= geo.loc.x { + return None; + } + + geo.loc.x -= view_pos; + Some((thumbnail, geo)) + }) + } + + fn thumbnails_in_view_render( + &self, + ) -> impl Iterator)> { + let output_size = output_size(&self.output); + let scale = self.output.current_scale().fractional_scale(); + let round = move |logical: f64| round_logical_in_physical(scale, logical); + + let view_pos = round(self.view_pos.current()); + + self.thumbnails().filter_map(move |(thumbnail, mut geo)| { + geo.loc.x -= view_pos; + geo.loc.x += round(thumbnail.render_offset()); + + if geo.loc.x + geo.size.w < 0. || output_size.w < geo.loc.x { + return None; + } + + Some((thumbnail, geo)) + }) + } + + fn render<'a, R: NiriRenderer>( + &'a self, + niri: &'a Niri, + renderer: &'a mut R, + target: RenderTarget, + ) -> impl Iterator> + 'a { + let output_size = output_size(&self.output); + let scale = self.output.current_scale().fractional_scale(); + + let panel_texture = + self.scope_panel + .borrow_mut() + .get(renderer.as_gles_renderer(), scale, self.wmru.scope); + let panel = panel_texture.map(move |texture| { + let padding = round_logical_in_physical(scale, f64::from(PANEL_PADDING)); + + let size = texture.logical_size(); + let location = Point::new((output_size.w - size.w) / 2., padding * 2.); + let elem = PrimaryGpuTextureRenderElement(TextureRenderElement::from_texture_buffer( + texture.clone(), + location, + 1., + None, + None, + Kind::Unspecified, + )); + WindowMruUiRenderElement::TextureElement(elem) + }); + let panel = panel.into_iter(); + + let current_id = self.wmru.current_id; + + let bob_y = baba_is_float_offset(self.clock.now(), output_size.h); + let bob_y = round_logical_in_physical(scale, bob_y); + + let config = self.config.borrow(); + + let thumbnails = self + .thumbnails_in_view_render() + .filter_map(move |(thumbnail, geo)| { + let id = thumbnail.id; + let Some((_, mapped)) = niri.layout.windows().find(|(_, m)| m.id() == id) else { + error!("window in the MRU must be present in the layout"); + return None; + }; + + let config = &config.recent_windows; + + let is_active = Some(id) == current_id; + let elems = thumbnail.render( + renderer, config, mapped, geo, scale, is_active, bob_y, target, + ); + Some(elems) + }); + let thumbnails = thumbnails.flatten(); + + panel.chain(thumbnails) + } + + fn thumbnail_under(&self, pos: Point) -> Option { + let scale = self.output.current_scale().fractional_scale(); + let round = move |logical: f64| round_logical_in_physical(scale, logical); + let padding = self.config.borrow().recent_windows.highlight.padding; + let padding = round(padding) + round(BORDER); + let padding = Point::new(padding, padding); + let title_gap = round(TITLE_GAP); + + for (thumbnail, mut geo) in self.thumbnails_in_view_static() { + geo.loc -= padding; + geo.size += padding.to_size().upscale(2.); + + // It doesn't really matter all that much if the title texture is stale here, and it + // would be annoying to thread the rendering into this function. The texture might be + // one frame stale or so. + if let Some(texture) = thumbnail.title_texture.borrow().get_stale() { + let title_size = texture.logical_size(); + geo.size.h += title_gap + title_size.h; + // Subtract half the padding so it looks more balanced visually. + geo.size.h -= round(padding.y / 2.); + } + + if geo.contains(pos) { + return Some(thumbnail.id); + } + } + + None + } +} + +impl TitleTexture { + fn get(&mut self, renderer: &mut GlesRenderer, title: &str, scale: f64) -> Option { + if self.title != title || self.scale != scale { + self.texture = None; + self.title = title.to_owned(); + self.scale = scale; + } + + self.texture + .get_or_insert_with(|| generate_title_texture(renderer, title, scale).ok()) + .clone() + } + + fn get_stale(&self) -> Option<&MruTexture> { + if let Some(Some(texture)) = &self.texture { + Some(texture) + } else { + None + } + } +} + +fn generate_title_texture( + renderer: &mut GlesRenderer, + title: &str, + scale: f64, +) -> anyhow::Result { + let _span = tracy_client::span!("mru::generate_title_texture"); + + let mut font = FontDescription::from_string(FONT); + font.set_absolute_size(to_physical_precise_round(scale, font.size())); + + let surface = ImageSurface::create(cairo::Format::ARgb32, 0, 0)?; + let cr = cairo::Context::new(&surface)?; + let layout = pangocairo::functions::create_layout(&cr); + layout.context().set_round_glyph_positions(false); + // On Window CSD, line breaks are either stripped or replaced with the linebreak symbol anyway. + // No use rendering it as multiple lines. + layout.set_single_paragraph_mode(true); + layout.set_font_description(Some(&font)); + layout.set_text(title); + + let (width, height) = layout.pixel_size(); + ensure!(width > 0 && height > 0); + + // Guard against overly long window titles. + let width = min(width, 16383); + let height = min(height, 16383); + + let surface = ImageSurface::create(cairo::Format::ARgb32, width, height)?; + let cr = cairo::Context::new(&surface)?; + cr.set_source_rgb(1., 1., 1.); + pangocairo::functions::show_layout(&cr, &layout); + + drop(cr); + let data = surface.take_data().unwrap(); + let buffer = TextureBuffer::from_memory( + renderer, + &data, + Fourcc::Argb8888, + (width, height), + false, + scale, + Transform::Normal, + Vec::new(), + )?; + + Ok(buffer) +} + +impl ScopePanel { + fn get( + &mut self, + renderer: &mut GlesRenderer, + scale: f64, + scope: MruScope, + ) -> Option { + if self.scale != scale { + self.textures = None; + self.scale = scale; + } + + self.textures + .get_or_insert_with(|| generate_scope_panels(renderer, scale).ok()) + .as_ref() + .map(|x| x[scope as usize].clone()) + } +} + +fn generate_scope_panels( + renderer: &mut GlesRenderer, + scale: f64, +) -> anyhow::Result<[MruTexture; 3]> { + fn make_panel_text(idx: usize) -> String { + let span_unselected = ""; + let span_end = ""; + let span_shortcut = ""; + let span_shortcut_end = ""; + + // Starts with a zero-width space to make letter_spacing work on the left. + let mut buf = + format!("\u{200B}{span_unselected}{span_shortcut}S{span_shortcut_end}cope:{span_end}"); + + for scope in SCOPE_CYCLE { + buf.push_str(" "); + if scope as usize != idx { + buf.push_str(span_unselected); + } + let text = match scope { + MruScope::All => format!("{span_shortcut}A{span_shortcut_end}ll"), + MruScope::Output => format!("{span_shortcut}O{span_shortcut_end}utput"), + MruScope::Workspace => format!("{span_shortcut}W{span_shortcut_end}orkspace"), + }; + buf.push_str(&text); + if scope as usize != idx { + buf.push_str(span_end); + } + } + + buf + } + + // Can't wait for array::try_map() + Ok([ + render_panel(renderer, scale, &make_panel_text(0))?, + render_panel(renderer, scale, &make_panel_text(1))?, + render_panel(renderer, scale, &make_panel_text(2))?, + ]) +} + +fn render_panel(renderer: &mut GlesRenderer, scale: f64, text: &str) -> anyhow::Result { + let _span = tracy_client::span!("mru::render_panel"); + + let mut font = FontDescription::from_string(FONT); + font.set_absolute_size(to_physical_precise_round(scale, font.size())); + + let padding: i32 = to_physical_precise_round(scale, PANEL_PADDING); + // Keep the border width even to avoid blurry edges. + // Render to a dummy surface to determine the size. + let surface = ImageSurface::create(cairo::Format::ARgb32, 0, 0)?; + let cr = cairo::Context::new(&surface)?; + let layout = pangocairo::functions::create_layout(&cr); + layout.context().set_round_glyph_positions(false); + layout.set_font_description(Some(&font)); + layout.set_markup(text); + let (mut width, mut height) = layout.pixel_size(); + + width += padding * 2; + height += padding * 2; + + let surface = ImageSurface::create(cairo::Format::ARgb32, width, height)?; + let cr = cairo::Context::new(&surface)?; + cr.set_source_rgb(0.1, 0.1, 0.1); + cr.paint()?; + + let padding = f64::from(padding); + + cr.move_to(padding, padding); + + let layout = pangocairo::functions::create_layout(&cr); + layout.context().set_round_glyph_positions(false); + layout.set_font_description(Some(&font)); + layout.set_markup(text); + + cr.set_source_rgb(1., 1., 1.); + pangocairo::functions::show_layout(&cr, &layout); + + cr.move_to(0., 0.); + cr.line_to(width.into(), 0.); + cr.line_to(width.into(), height.into()); + cr.line_to(0., height.into()); + cr.line_to(0., 0.); + cr.set_source_rgb(0.5, 0.5, 0.5); + cr.set_line_width((f64::from(PANEL_BORDER) / 2. * scale).round() * 2.); + cr.stroke()?; + + drop(cr); + let data = surface.take_data().unwrap(); + let buffer = TextureBuffer::from_memory( + renderer, + &data, + Fourcc::Argb8888, + (width, height), + false, + scale, + Transform::Normal, + Vec::new(), + )?; + + Ok(buffer) +} + +/// Returns key bindings available when the MRU UI is open. +fn make_preset_opened_binds() -> Vec { + let mut rv = Vec::new(); + + let mut push = |trigger, action| { + rv.push(Bind { + key: Key { + trigger: Trigger::Keysym(trigger), + // The modifier is filled dynamically. + modifiers: Modifiers::empty(), + }, + action, + repeat: true, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: false, + hotkey_overlay_title: None, + }) + }; + + push(Keysym::Escape, Action::MruCancel); + push(Keysym::Return, Action::MruConfirm); + push(Keysym::a, Action::MruSetScope(MruScope::All)); + push(Keysym::o, Action::MruSetScope(MruScope::Output)); + push(Keysym::w, Action::MruSetScope(MruScope::Workspace)); + push(Keysym::s, Action::MruCycleScope); + + // Leave these in since they are the most expected and generally uncontroversial keys, so that + // they work even if these actions are absent from the normal binds. + push(Keysym::Home, Action::MruFirst); + push(Keysym::End, Action::MruLast); + push( + Keysym::Left, + Action::MruAdvance { + direction: MruDirection::Backward, + scope: None, + filter: None, + }, + ); + push( + Keysym::Right, + Action::MruAdvance { + direction: MruDirection::Forward, + scope: None, + filter: None, + }, + ); + + rv +} + +/// Returns dynamic key bindings available when the MRU UI is open. +/// +/// These ones are generated based on the normal bindings. +fn make_dynamic_opened_binds(config: &Config) -> Vec { + let mut binds: HashMap> = HashMap::new(); + + for bind in &config.binds.0 { + let action = match &bind.action { + Action::FocusColumnRight + | Action::FocusColumnRightOrFirst + | Action::FocusColumnOrMonitorRight + | Action::FocusWindowDownOrColumnRight => Action::MruAdvance { + direction: MruDirection::Forward, + scope: None, + filter: None, + }, + Action::FocusColumnLeft + | Action::FocusColumnLeftOrLast + | Action::FocusColumnOrMonitorLeft + | Action::FocusWindowUpOrColumnLeft => Action::MruAdvance { + direction: MruDirection::Backward, + scope: None, + filter: None, + }, + Action::FocusColumnFirst => Action::MruFirst, + Action::FocusColumnLast => Action::MruLast, + Action::CloseWindow => Action::MruCloseCurrentWindow, + x @ Action::Screenshot(_, _) => x.clone(), + _ => continue, + }; + + binds.entry(bind.key.trigger).or_default().push(Bind { + action, + ..bind.clone() + }); + } + + let mut rv = Vec::new(); + + // For each trigger, take the bind with the lowest number of modifiers. + for binds in binds.into_values() { + let bind = binds + .into_iter() + .min_by_key(|bind| bind.key.modifiers.iter().count()) + .unwrap(); + + rv.push(Bind { + key: Key { + trigger: bind.key.trigger, + // The modifier is filled dynamically. + modifiers: Modifiers::empty(), + }, + ..bind + }); + } + + rv +} diff --git a/src/ui/mru/tests.rs b/src/ui/mru/tests.rs new file mode 100644 index 00000000..8e3d935f --- /dev/null +++ b/src/ui/mru/tests.rs @@ -0,0 +1,135 @@ +use proptest::prelude::*; +use proptest_derive::Arbitrary; + +use super::*; + +fn create_thumbnail() -> Thumbnail { + Thumbnail { + id: MappedId::next(), + timestamp: None, + on_current_output: false, + on_current_workspace: false, + app_id: None, + size: Size::new(100, 100), + clock: Clock::with_time(Duration::ZERO), + config: niri_config::MruPreviews::default(), + open_animation: None, + move_animation: None, + title_texture: Default::default(), + background: RefCell::new(FocusRing::new(Default::default())), + border: RefCell::new(FocusRing::new(Default::default())), + } +} + +#[test] +fn remove_last_window_out_of_two() { + let ops = [Op::Backward, Op::Remove(1)]; + + let thumbnails = vec![create_thumbnail(), create_thumbnail()]; + let current_id = thumbnails.first().map(|t| t.id); + let mut mru = WindowMru { + thumbnails, + current_id, + scope: MruScope::All, + app_id_filter: None, + }; + + check_ops(&mut mru, &ops); +} + +fn arbitrary_scope() -> impl Strategy { + prop_oneof![ + Just(MruScope::All), + Just(MruScope::Output), + Just(MruScope::Workspace), + ] +} + +fn arbitrary_filter() -> impl Strategy { + prop_oneof![Just(MruFilter::All), Just(MruFilter::AppId)] +} + +fn arbitrary_app_id() -> impl Strategy> { + prop_oneof![Just(None), Just(Some(1)), Just(Some(2))] + .prop_map(|id| id.map(|id| format!("app-{id}"))) +} + +prop_compose! { + fn arbitrary_thumbnail()( + timestamp: Option, + on_current_output: bool, + on_current_workspace: bool, + app_id in arbitrary_app_id(), + ) -> Thumbnail { + let mut thumbnail = create_thumbnail(); + thumbnail.timestamp = timestamp; + thumbnail.on_current_workspace = on_current_workspace; + thumbnail.on_current_output = on_current_output; + thumbnail.app_id = app_id; + thumbnail + } +} + +prop_compose! { + fn arbitrary_mru()( + thumbnails in proptest::collection::vec(arbitrary_thumbnail(), 1..10), + ) -> WindowMru { + let current_id = thumbnails.first().map(|t| t.id); + WindowMru { + thumbnails, + current_id, + scope: MruScope::All, + app_id_filter: None, + } + } +} + +#[derive(Debug, Clone, Arbitrary)] +enum Op { + Forward, + Backward, + First, + Last, + SetScope(#[proptest(strategy = "arbitrary_scope()")] MruScope), + SetFilter(#[proptest(strategy = "arbitrary_filter()")] MruFilter), + Remove(#[proptest(strategy = "1..10usize")] usize), +} + +impl Op { + fn apply(&self, mru: &mut WindowMru) { + match self { + Op::Forward => mru.forward(), + Op::Backward => mru.backward(), + Op::First => mru.first(), + Op::Last => mru.last(), + Op::SetScope(scope) => { + mru.set_scope(*scope); + } + Op::SetFilter(filter) => { + mru.set_filter(*filter); + } + Op::Remove(idx) => { + if *idx < mru.thumbnails.len() { + mru.remove_by_idx(*idx); + } + } + } + } +} + +fn check_ops(mru: &mut WindowMru, ops: &[Op]) { + for op in ops { + op.apply(mru); + mru.verify_invariants(); + } +} + +proptest! { + #[test] + fn random_operations_dont_panic( + mut mru in arbitrary_mru(), + ops: Vec, + ) { + check_ops(&mut mru, &ops); + } +} diff --git a/src/window/mapped.rs b/src/window/mapped.rs index 238f77a0..6481e9b8 100644 --- a/src/window/mapped.rs +++ b/src/window/mapped.rs @@ -183,6 +183,9 @@ pub struct Mapped { /// These have been "sent" to the window in form of configures, but the window hadn't committed /// in response yet. uncommitted_maximized: Vec<(Serial, bool)>, + + /// Most recent monotonic time when the window had the focus. + focus_timestamp: Option, } niri_render_elements! { @@ -279,6 +282,7 @@ impl Mapped { is_maximized: false, is_pending_maximized: false, uncommitted_maximized: Vec::new(), + focus_timestamp: None, }; rv.is_maximized = rv.sizing_mode().is_maximized(); @@ -515,6 +519,14 @@ impl Mapped { }) } + pub fn get_focus_timestamp(&self) -> Option { + self.focus_timestamp + } + + pub fn set_focus_timestamp(&mut self, timestamp: Duration) { + self.focus_timestamp.replace(timestamp); + } + pub fn send_frame( &mut self, output: &Output,