diff options
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 thumbnail list is empty", + ); + } + } + + fn thumbnails(&self) -> impl DoubleEndedIterator<Item = &Thumbnail> { + 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<Item = &mut Thumbnail> { + 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<Item = (usize, &Thumbnail)> { + 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<MappedId> { + 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<MruScope> { + 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<Option<String>> { + 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<usize> { + self.thumbnails.iter().position(|t| t.id == id) + } + + fn remove_by_idx(&mut self, idx: usize) -> Option<Thumbnail> { + 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<RefCell<Config>>) -> 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<MappedId> { + 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<MruFilter>) { + 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<f64, Logical>) -> Option<MappedId> { + 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: |
