diff options
| author | rustN00b <rustN00b@protonmail.com> | 2025-11-01 09:30:35 +0300 |
|---|---|---|
| committer | Ivan Molodetskikh <yalterz@gmail.com> | 2025-11-16 22:36:01 +0300 |
| commit | 933ffcb229e9e678b271d4043b1d4d5e2b6fa073 (patch) | |
| tree | 375c6bab6824619e476cc6fdf770d80ce51eca68 /src/ui | |
| parent | b774fc1bafd3f9c612ad117986510f8710fd7cc6 (diff) | |
| download | niri-933ffcb229e9e678b271d4043b1d4d5e2b6fa073.tar.gz niri-933ffcb229e9e678b271d4043b1d4d5e2b6fa073.tar.bz2 niri-933ffcb229e9e678b271d4043b1d4d5e2b6fa073.zip | |
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
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/mod.rs | 1 | ||||
| -rw-r--r-- | src/ui/mru.rs | 1929 | ||||
| -rw-r--r-- | src/ui/mru/tests.rs | 135 |
3 files changed, 2065 insertions, 0 deletions
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<Thumbnail>, + + /// Id of the currently selected window. + current_id: Option<MappedId>, + + /// Current scope. + scope: MruScope, + + /// Current filter. + app_id_filter: Option<String>, +} + +pub struct WindowMruUi { + state: UiState, + preset_opened_binds: Vec<Bind>, + dynamic_opened_binds: Vec<Bind>, + config: Rc<RefCell<Config>>, +} + +pub enum MruCloseRequest { + Cancel, + Confirm, +} + +niri_render_elements! { + ThumbnailRenderElement<R> => { + LayoutElement = LayoutElementRenderElement<R>, + ClippedSurface = ClippedSurfaceRenderElement<R>, + Border = BorderRenderElement, + } +} + +niri_render_elements! { + WindowMruUiRenderElement<R> => { + SolidColor = SolidColorRenderElement, + TextureElement = PrimaryGpuTextureRenderElement, + GradientFadeElem = GradientFadeTextureRenderElement, + FocusRing = FocusRingRenderElement, + Offscreen = OffscreenRenderElement, + Thumbnail = RelocateRenderElement<RescaleRenderElement<ThumbnailRenderElement<R>>>, + } +} + +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<RefCell<Config>>, + + /// Time when the UI should appear. + open_at: Duration, + + /// Output the UI was opened on. + output: Output, + + /// Scope panel textures. + scope_panel: RefCell<ScopePanel>, + + /// Backdrop buffers for each output. + backdrop_buffers: RefCell<HashMap<Output, SolidColorBuffer>>, + + /// 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<GlesTexture>; + +/// Cached title texture. +#[derive(Debug, Default)] +struct TitleTexture { + title: String, + scale: f64, + texture: Option<Option<MruTexture>>, +} + +/// Cached scope panel textures. +#[derive(Debug, Default)] +struct ScopePanel { + scale: f64, + textures: Option<Option<[MruTexture; 3]>>, +} + +#[derive(Debug)] +struct Thumbnail { + id: MappedId, + + /// Focus timestamp, if any. + timestamp: Option<Duration>, + /// 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<String>, + /// Cached size of the window. + size: Size<i32, Logical>, + + clock: Clock, + config: niri_config::MruPreviews, + open_animation: Option<Animation>, + move_animation: Option<MoveAnimation>, + title_texture: RefCell<TitleTexture>, + background: RefCell<FocusRing>, + border: RefCell<FocusRing>, +} + +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<f64, Logical>, scale: f64) -> Size<f64, Logical> { + 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<MruTexture> { + 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<R: NiriRenderer>( + &self, + renderer: &mut R, + config: &niri_config::RecentWindows, + mapped: &Mapped, + preview_geo: Rectangle<f64, Logical>, + scale: f64, + is_active: bool, + bob_y: f64, + target: RenderTarget, + ) -> impl Iterator<Item = WindowMruUiRenderElement<R>> { + 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 |
