From 933ffcb229e9e678b271d4043b1d4d5e2b6fa073 Mon Sep 17 00:00:00 2001 From: rustN00b Date: Sat, 1 Nov 2025 09:30:35 +0300 Subject: [PATCH] Implement recent windows switcher (Alt-Tab) Historic commit description log: The MRU actions `focus-window-mru-previous` and `focus-window-mru-next` are used to navigate windows in most-recently-used or least-recently-used order. Whenever a window is focused, it records a timestamp that be used to sort windows in MRU order. This timestamp is not updated immediately, but only after a small delay (lock-in period) to ensure that the focus wasn't transfered to another window in the meantime. This strategy avoids upsetting the MRU order with focus events generated by intermediate windows when moving between two non contiguous windows. The lock-in delay can be configured using the `focus-lockin-ms` configuration argument. Calling either of the `focus-window-mru` actions starts an MRU window traversal sequence if one isn't already in progress. When a sequence is in progress, focus timestamps are no longer updated. A traversal sequence ends when: - either the `Mod` key is released, the focus then stays on the chosen window and its timestamp is immediately refreshed, - or if the `Escape` key is pressed, the focus returns to the window that initially had the focus when the sequence was started. Rename WindowMRU fields Improve window close handling during MRU traversal When the focused window is closed during an MRU traversal, it moves to the previous window in MRU order instead of the default behavior. Removed dbg! calls Merge remote-tracking branch 'upstream/main' into window-mru Hardcode Alt-Tab/Alt-shift-Tab for MRU window nav - Add a `PRESET_BINDINGS` containing MRU navigation actions. `PRESET_BINDINGS` are overridden by user configuration so these remain available if the user needs them for another purpose - Releasing the `Alt` key ends any in-progress MRU window traversal Remove `focus-window-mru` actions from config These actions are configured in presets but no longer available for the bindings section of the configuration Cancel MRU traversal with Alt-Esc Had been forgotten in prior commit and was using `Mod` instead of `Alt` Rephrase some comments Fix Alt-Esc not cancelling window-mru Merge remote-tracking branch 'upstream/main' into window-mru Lock-in focus immediately on user interaction As per suggestion by @bbb651, focus is locked-in immediately if a window is interacted with, ie. receives key events or pointer clicks. This change is also an opportunity to make the lockin timer less aggresive. Merge remote-tracking branch 'upstream/main' into window-mru Simplify WindowMRU::new Now that there is a more general Niri::lockin_focus method, leverage it in WindowMRU. Replace Duration with Instant in WindowMRU timestamp Merge remote-tracking branch 'upstream/main' into window-mru Address PR comments - partial - Swapped meaning of next and previous for MRU traversal - Fixed comment that still referred to `Mod` as leader key for MRU traversal instead of `Alt` - Fixed doc comments that were missing a period - Stop using BinaryHeap in `WindowMRU::new()` - Replaced `WindowMRU::mru_with()` method with a simpler `advance()` - Simplified `Alt` key release handling code in `State::on_keyboard()` Simplify early-mru-commit logic No longer perform the mru-commit/lockin_focus in the next event loop callback. Instead it is handled directly when it is determined that an event (pointer or kbd) is forwarded to the active window. Handle PR comments - `focus_lockin` variables and configuration item renamed to `mru_commit`. - added the Esc key to `suppressed_keys` if it was used to cancel an MRU traversal. - removed `WindowMRU::mru_next` and `WindowMRU::mru_previous` methods as they didn't really provide more than the generic `WindowMRU::advance` method. - removed obsolete `Niri::event_forwarded_to_focused_client` boolean - added calls to `mru_commit()` (formerly `focus_lockin`) in: - `State::on_pointer_axis()` - `State::on_tablet_tool_axis()` - `State::on_tablet_tool_tip()` - `State::on_tablet_tool_proximity()` - `State::on_tablet_tool_button()` - `State::on_gesture_swipe_begin()` - `State::on_gesture_pinch_begin()` - `State::on_gesture_hold_begin()` - `State::on_touch_down()` Merge remote-tracking branch 'upstream/main' into window-mru Merge remote-tracking branch 'upstream/main' into window-mru Add MRU window navigation actions The MRU actions `focus-window-mru-previous` and `focus-window-mru-next` are used to navigate windows in most-recently-used or least-recently-used order. Whenever a window is focused, it records a timestamp that be used to sort windows in MRU order. This timestamp is not updated immediately, but only after a small delay (lock-in period) to ensure that the focus wasn't transfered to another window in the meantime. This strategy avoids upsetting the MRU order with focus events generated by intermediate windows when moving between two non contiguous windows. The lock-in delay can be configured using the `focus-lockin-ms` configuration argument. Calling either of the `focus-window-mru` actions starts an MRU window traversal sequence if one isn't already in progress. When a sequence is in progress, focus timestamps are no longer updated. A traversal sequence ends when: - either the `Mod` key is released, the focus then stays on the chosen window and its timestamp is immediately refreshed, - or if the `Escape` key is pressed, the focus returns to the window that initially had the focus when the sequence was started. Rename WindowMRU fields Improve window close handling during MRU traversal When the focused window is closed during an MRU traversal, it moves to the previous window in MRU order instead of the default behavior. Removed dbg! calls Merge remote-tracking branch 'upstream/main' into window-mru Hardcode Alt-Tab/Alt-shift-Tab for MRU window nav - Add a `PRESET_BINDINGS` containing MRU navigation actions. `PRESET_BINDINGS` are overridden by user configuration so these remain available if the user needs them for another purpose - Releasing the `Alt` key ends any in-progress MRU window traversal Remove `focus-window-mru` actions from config These actions are configured in presets but no longer available for the bindings section of the configuration Cancel MRU traversal with Alt-Esc Had been forgotten in prior commit and was using `Mod` instead of `Alt` Rephrase some comments Fix Alt-Esc not cancelling window-mru Merge remote-tracking branch 'upstream/main' into window-mru Lock-in focus immediately on user interaction As per suggestion by @bbb651, focus is locked-in immediately if a window is interacted with, ie. receives key events or pointer clicks. This change is also an opportunity to make the lockin timer less aggresive. Merge remote-tracking branch 'upstream/main' into window-mru Simplify WindowMRU::new Now that there is a more general Niri::lockin_focus method, leverage it in WindowMRU. Replace Duration with Instant in WindowMRU timestamp Merge remote-tracking branch 'upstream/main' into window-mru Address PR comments - partial - Swapped meaning of next and previous for MRU traversal - Fixed comment that still referred to `Mod` as leader key for MRU traversal instead of `Alt` - Fixed doc comments that were missing a period - Stop using BinaryHeap in `WindowMRU::new()` - Replaced `WindowMRU::mru_with()` method with a simpler `advance()` - Simplified `Alt` key release handling code in `State::on_keyboard()` Simplify early-mru-commit logic No longer perform the mru-commit/lockin_focus in the next event loop callback. Instead it is handled directly when it is determined that an event (pointer or kbd) is forwarded to the active window. Handle PR comments - `focus_lockin` variables and configuration item renamed to `mru_commit`. - added the Esc key to `suppressed_keys` if it was used to cancel an MRU traversal. - removed `WindowMRU::mru_next` and `WindowMRU::mru_previous` methods as they didn't really provide more than the generic `WindowMRU::advance` method. - removed obsolete `Niri::event_forwarded_to_focused_client` boolean - added calls to `mru_commit()` (formerly `focus_lockin`) in: - `State::on_pointer_axis()` - `State::on_tablet_tool_axis()` - `State::on_tablet_tool_tip()` - `State::on_tablet_tool_proximity()` - `State::on_tablet_tool_button()` - `State::on_gesture_swipe_begin()` - `State::on_gesture_pinch_begin()` - `State::on_gesture_hold_begin()` - `State::on_touch_down()` Merge remote-tracking branch 'upstream/main' into window-mru Merge remote-tracking branch 'upstream/main' into window-mru Include never focused windows in MRU list Remove mru_commit_ms from configurable options For now the value is hard-coded to 750ms Merge remote-tracking branch 'upstream/main' into HEAD Add hotkey_overlay_tile for PRESET_BINDINGS Merge remote-tracking branch 'origin/window-mru' into HEAD Merge remote-tracking branch 'upstream/main' into window-mru Merge remote-tracking branch 'upstream/main' into window-mru Merge remote-tracking branch 'upstream/main' into window-mru Firt shot an MruUi The UI doesn't actually do anything yet. For now it just puts up thumbnails for existing windows in MRU order. Added MRU texture cache + simplifications Working version Removed previous Mru code Tidy up Action names Added Home/End bindings Merge remote-tracking branch 'upstream/main' into window-mru-ui Add scope and filtering to Mru window navigation Feed todo list Merge remote-tracking branch 'upstream/main' into window-mru-ui Clippy: Boxed the focus ring The UI object doesn't get moved around much so it isn't clear if this actually important. Boxing keeps clippy happy because of the size difference between an Open vs a Closed MRU UI. Bump rust version to 1.83 Avoids getting yelled at by clippy for using features that weren't yet available in 1.80.1 Applied clippy lints Fix MruFilter::None conversion MruFilter variant was getting ignored cargo fmt Update rust tool chain in CI Had only been updated in Cargo.toml, this causes build failures on Github Support changing Mru modes with the Mru UI open Fix texture cache optimization When the Mru parameters were changed while the MruUI was open, the texture cache is rebuilt but attempts to reuse existing Textures that are still usable in the updated Mru list. The index of the retained texture could be miscalculated and resulted in the wrong texture being used for a given window Id. Make MruAdvance available as a Bind action For consistency, MruAdvance bindings are carried over when the MruUI is open. Merge remote-tracking branch 'upstream/main' into window-mru-ui Preset binds added as a source for MRU UI binds Surprisingly the status prior to the patch should have prevented the UI bindings to advance through the Mru list from working properly. Use iterators to find bindings This allows the caller, eg. `on_keyboard` to choose the full list of bindings that should be searched through by composing iterators. Prior to the change the PRESET_BINDINGS were always included regardless of caller. With this approach, `on_keyboard` can add in the MRU_UI- specific bindings if it detects that the MRU UI is open. Make scope and filter optional in mru-advance This avoids unexpected behavior when navigating MRU with a filter, e.g. App-Id, with arrow keys for instance, which would result in changing navigation to ignore the app-id filter. With the change, mru-advance has an optional scope and filter that allows a key bind to leave the current navigation mode unchanged. Add title under window thumbnails - Reworked the texture cache to use TextureBuffer-s instead of BakedBuffer. - Add convenience methods to access TextureCache content. Some tidying up. Fade title out if it doesn't fit in available size Add bindings to change the MruScope Fix panic rendering title when cairo surface was busy Also avoid interpreting markup in window titles. Bring branch in line with window-mru-ui-squashed Add navigation animation in MRU UI Only handles motion between thumbnails Add thumbnail close animation For now, the animation only tracks when the corresponding window is closed. Add animations on filter and scope changes Add open/close animation to MRU Ui Merge remote-tracking branch 'upstream/main' into window-mru-ui Fix animations on scope/filter changes Previous implementation would evict wrong textures from the cache. And get thumbnail animations wrong. Merge remote-tracking branch 'upstream/main' into window-mru-ui Fix panic on change of scope/filter when Mru list is empty. Add doc comment to method that could trigger a panic Simplify thumbnail ordering logic Improve scope/filter change animations - direction is no longer a factor when an Mru UI is opened (previously the first thumbnail would be the currently focused window when moving in the "forward" direction, and when moving in the "backward" direction the focused window would have its thumbnail last in the list. This made animations kind of confusing when switching scopes or filtering. The updated version always places the thumbnails in most recent focus order. So when the MRU UI is brought up in the "backward" direction, the last thumbnail in the MRU list starts selected. - closing animations no longer use the view referential, but use the output referential instead. This makes disappearing thumbnails appear stationary on screen even if the view is moving. This tends to look less confusing than the previous approach. Applied clippy lints Preserve scope during fwd/backward navigation Change preset keybinding declarations from const to static Add thumbnail selection animation This is still very much a work in progress: - the focus ring is not shown until the animation completes - if the tile is resized during the animation, the net effect looks pretty bad because proportions skip directly to those requested instead of transitioning smoothly. Both points should be addressed by using regular tile rendering to an OffscreenBuffer but I haven't much success there. Merge remote-tracking branch 'upstream/main' into window-mru-ui Fix niri-config parse test Use OffscreenBuffer to render ThumbnailSelection animation todo: fix thumbnail destination if the target workspace is being swapped. Handle workspace switch during thumbnail select animation Close Overview when MRU UI is opened Add configuration option to disable MRU UI Make mod-key for MRU UI configurable Avoid collecting MRU UI bindings on each input Bindings are cached when first accessed, the cache is invalidated whenever the configuration changes. Close MRU UI when Overview is opened Merge remote-tracking branch 'upstream/main' into window-mru-ui Fix MRU UI opened bindings always active Remove mru-advance from actions available for config keybind Because the MRU UI assumes that all key-bindings use the mod-key defined in for `recent-windows`, behavior can be disconcerting if arbitrary keybindings are allowed in the configuration (e.g. UI opens and immediately closes because the mod-key is not being held). Include focus timestamp in Window IPC messages Timestamps are serialized as time::SystemTime, which in JSON form is represented as *two* fields, secs and nanos. Merge remote-tracking branch 'upstream/main' into window-mru-ui Only do Thumbnail Select Anim if MRU UI stayed open long enough Threshold is hard coded in window_mru_ui.rs (250ms). Merge remote-tracking branch 'upstream/main' into window-mru-ui Add a few WindowMru tests Forward Mod-key release when closing MRU UI Merge remote-tracking branch 'upstream/main' into window-mru-ui Remove extraneous thumbnail motion on Mru filter change Fix missing alpha in Mru thumbnail open animation Add Mod+h and Mod+l bindings for MRU navigation Change CloseWindow binding in MRU to Mod+Shift+q Keep MRU UI on display it was initially opened on Bump up the MRU IU selection anim threshold Allow MRU thumbnail selection with mouse pointer Allow MRU thumbnail selection using touch Needs testing, Idk if this works for lack of a touchscreen. Fix missing fade-out animation for thumbnails on MRU UI close Merge remote-tracking branch 'upstream/main' into window-mru-ui Make thumbnail selection animation optional Merge remote-tracking branch 'upstream/main' into window-mru-ui Fix niri-config parse test case Add shortcut to cycle through MRU scopes - added MruCycleScope action to trigger cycling - added an indication panel to show the current scope - recall previous scope when opening the MRU UI Merge remote-tracking branch 'upstream/main' into window-mru-ui Improve MRU thumbnail scaling Prior to the commit, thumbnails were just 2x downscaling of their corresponding window. Now they are also scaled based on the relative height of the window on its output display. This avoids having a thumbnail taking up the entire screen on the display where the MRU UI is displayed. Merge remote-tracking branch 'upstream/main' into window-mru-ui Use resolved window rules for thumbnails Previously parameters such as the corner-radius didn't follow the general config and used an MRU UI specific default. Align thumbnail size and position to physical pixels clarify param names in generate_tile_texture Revert MSRV 1.83 Close MRU UI on click/touch outside of a thumbnail MRU - display window title under all thumbnails MRU - revert to pre-defined thumbnail corner radius MRU - Removed thumb title font size adjustment This didn't look as if it was necessary. (unscientific assesment) MRU - reverted to Mod+Q to quit selected thumbnail Merge remote-tracking branch 'upstream/main' into window-mru-ui MRU - Update focus ring when moving mouse over a thumbnail restore code that went missing switch focus timestamp to monotonic time We don't want the monotonicity of SystemClock here. Instant itself isn't serializable, but our monotonic clock timestamps are, and they are consistent across processes too. axe thumbnail close animation I'm still not quite convinced about it. Maybe we'll reintroduce it later with better architecture; for now though, it causes quite a bit of complexity. minor cleanups remove unnecessary option replace open animation with delay Avoids flashing the whole screen for quick Alt-Tabs. Duration taken from GNOME Shell. make mod key different in nested replace SelectedThumbnail with MappedId don't hide focus ring during alt tab wip refactor everything and render live windows rename some constants replace focus ring with background + border extract thumbnail constructors reimplement title fade with a shader reimplement ui fade out on closing fix preview scaling add min scale for very small windows add keyboard focus for mru fixes activating alt on target window revert/simplify pointer code changes fixes mouse not clamped to output when in alt-tab; should fix touch going through move touch handling to below screenshot ui remove unneeded touch overview grab code rename to mru.rs move mru tests into separate file also close mru when clicking on other outputs roll back no longer necessary event filtering rework mru keyboard binds convert some regular binds to MRU binds hide window title when blocked out verify that mru bind uses a keyboard key improve selection visibility & indicate urgency freeze alt-tab view on pointer motion add WindowFocusTimestampChanged event, separate struct for Timestamp minor cleanups scope panel fixes simplify scope cycling honor geometry corner radius don't trigger focus-follows-mouse in the MRU remove unnecessary argument cache backdrop buffers remove unnecessary mru close allow to screenshot the mru support bob offset improve mru redraws pass config instead of options add open-delay-ms option add highlight options rename window-mru-ui-open-close to recent-windows-close add preview options fix scope change and remove window delta anim improve unselected scope panel text contrast move panel back up so it doesn't overlap the screenshot one rename preview to previews in config render highlight background with focusring fix highlight pos rounding add highlight corner-radius setting remove allocation from inner render use offscreen for mru closing fade make scope only affect MRU open otherwise you can't change scope at runtime easily replace todo with fixme include title height in thumbnail under remove cloning from set scope/filter remove animate close todo update field name in mapped remove commented out closing thumbnails I decided not to do this for now. rename filter from None to All and skip in knuffel None is confusing with Option write docs make inactive urgent more prominent remove reopen from scartch todo explicitly mention app id in filter make scroll binds work in the mru add fixmes don't select next window when nothing is focused add missing anim config merge fixes replace click selection with pointer motion + confirm simplify close mru ui call rename mrucloserequest variants mru confirm fixes support tablet input mru commit cleanups remove most mru commit calls they didn't actualy do anything as implemented. If we want to bring them back we need to refactor a bit to join them with activate_window() call. make regular mouse binds also work in mru fixes fixes move types up fix tracy span --- docs/mkdocs.yaml | 1 + docs/wiki/Configuration:-Animations.md | 18 + docs/wiki/Configuration:-Introduction.md | 1 + docs/wiki/Configuration:-Recent-Windows.md | 166 ++ docs/wiki/_Sidebar.md | 1 + niri-config/src/animations.rs | 36 + niri-config/src/binds.rs | 21 + niri-config/src/lib.rs | 156 +- niri-config/src/recent_windows.rs | 401 ++++ src/a11y.rs | 7 + src/handlers/compositor.rs | 8 + src/handlers/xdg_shell.rs | 8 +- src/input/mod.rs | 373 +++- src/niri.rs | 154 +- src/ui/mod.rs | 1 + src/ui/mru.rs | 1929 ++++++++++++++++++++ src/ui/mru/tests.rs | 135 ++ src/window/mapped.rs | 12 + 18 files changed, 3350 insertions(+), 78 deletions(-) create mode 100644 docs/wiki/Configuration:-Recent-Windows.md create mode 100644 niri-config/src/recent_windows.rs create mode 100644 src/ui/mru.rs create mode 100644 src/ui/mru/tests.rs 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,