From af1fca35bb15b8010cd3a12bbafe71b55d9ecf57 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Fri, 25 Apr 2025 09:36:50 +0300 Subject: Implement an Overview --- src/layout/mod.rs | 438 +++++++++++++++++++++++++++++++++++++++++++----- src/layout/monitor.rs | 236 +++++++++++++++++++++++--- src/layout/tests.rs | 20 +++ src/layout/workspace.rs | 53 +++++- 4 files changed, 681 insertions(+), 66 deletions(-) (limited to 'src/layout') diff --git a/src/layout/mod.rs b/src/layout/mod.rs index cdb96113..019df993 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -45,6 +45,7 @@ use niri_config::{ use niri_ipc::{ColumnDisplay, PositionChange, SizeChange}; use scrolling::{Column, ColumnWidth}; use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement; +use smithay::backend::renderer::element::utils::RescaleRenderElement; use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture}; use smithay::output::{self, Output}; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; @@ -55,7 +56,8 @@ use workspace::{WorkspaceAddWindowTarget, WorkspaceId}; pub use self::monitor::MonitorRenderElement; use self::monitor::{Monitor, WorkspaceSwitch}; use self::workspace::{OutputId, Workspace}; -use crate::animation::Clock; +use crate::animation::{Animation, Clock}; +use crate::input::swipe_tracker::SwipeTracker; use crate::layout::scrolling::ScrollDirection; use crate::niri_render_elements; use crate::render_helpers::offscreen::OffscreenData; @@ -96,6 +98,14 @@ const INTERACTIVE_MOVE_START_THRESHOLD: f64 = 256. * 256.; /// Opacity of interactively moved tiles targeting the scrolling layout. const INTERACTIVE_MOVE_ALPHA: f64 = 0.75; +/// Amount of touchpad movement to toggle the overview. +const OVERVIEW_GESTURE_MOVEMENT: f64 = 300.; + +const OVERVIEW_GESTURE_RUBBER_BAND: RubberBand = RubberBand { + stiffness: 0.5, + limit: 0.05, +}; + /// Size-relative units. pub struct SizeFrac; @@ -293,6 +303,13 @@ pub struct Layout { clock: Clock, /// Time that we last updated render elements for. update_render_elements_time: Duration, + /// Whether the overview is open. + /// + /// This is a boolean flag that controls things like where input goes to. The actual animation + /// is controlled by overview_progress. + overview_open: bool, + /// The overview zoom progress. + overview_progress: Option, /// Configurable properties of the layout. options: Rc, } @@ -338,6 +355,7 @@ pub struct Options { pub preset_window_heights: Vec, pub animations: niri_config::Animations, pub gestures: niri_config::Gestures, + pub overview: niri_config::Overview, // Debug flags. pub disable_resize_throttling: bool, pub disable_transactions: bool, @@ -365,6 +383,7 @@ impl Default for Options { default_column_width: None, animations: Default::default(), gestures: Default::default(), + overview: Default::default(), disable_resize_throttling: false, disable_transactions: false, preset_window_heights: vec![ @@ -493,6 +512,21 @@ pub enum HitType { }, } +#[derive(Debug)] +enum OverviewProgress { + Animation(Animation), + Gesture(OverviewGesture), +} + +#[derive(Debug)] +struct OverviewGesture { + tracker: SwipeTracker, + /// Start point. + start: f64, + /// Current progress. + value: f64, +} + impl InteractiveMoveState { fn moving(&self) -> Option<&InteractiveMoveData> { match self { @@ -510,16 +544,16 @@ impl InteractiveMoveState { } impl InteractiveMoveData { - fn tile_render_location(&self) -> Point { + fn tile_render_location(&self, zoom: f64) -> Point { let scale = Scale::from(self.output.current_scale().fractional_scale()); let window_size = self.tile.window_size(); let pointer_offset_within_window = Point::from(( window_size.w * self.pointer_ratio_within_window.0, window_size.h * self.pointer_ratio_within_window.1, )); - let pos = - self.pointer_pos_within_output - pointer_offset_within_window - self.tile.window_loc() - + self.tile.render_offset(); + let pos = self.pointer_pos_within_output + - (pointer_offset_within_window + self.tile.window_loc() - self.tile.render_offset()) + .upscale(zoom); // Round to physical pixels. pos.to_physical_precise_round(scale).to_logical(scale) } @@ -553,6 +587,15 @@ impl HitType { tile.hit(pos_within_tile) .map(|hit| (tile.window(), hit.offset_win_pos(tile_pos))) } + + pub fn to_activate(self) -> Self { + match self { + HitType::Input { .. } => HitType::Activate { + is_tab_indicator: false, + }, + HitType::Activate { .. } => self, + } + } } impl Options { @@ -594,6 +637,7 @@ impl Options { default_column_width, animations: config.animations.clone(), gestures: config.gestures, + overview: config.overview, disable_resize_throttling: config.debug.disable_resize_throttling, disable_transactions: config.debug.disable_transactions, preset_window_heights, @@ -611,6 +655,19 @@ impl Options { } } +impl OverviewProgress { + fn value(&self) -> f64 { + match self { + OverviewProgress::Animation(anim) => anim.value(), + OverviewProgress::Gesture(gesture) => gesture.value, + } + } + + fn is_animation(&self) -> bool { + matches!(self, OverviewProgress::Animation(_)) + } +} + impl Layout { pub fn new(clock: Clock, config: &Config) -> Self { Self::with_options_and_workspaces(clock, config, Options::from_config(config)) @@ -625,6 +682,8 @@ impl Layout { dnd: None, clock, update_render_elements_time: Duration::ZERO, + overview_open: false, + overview_progress: None, options: Rc::new(options), } } @@ -648,6 +707,8 @@ impl Layout { dnd: None, clock, update_render_elements_time: Duration::ZERO, + overview_open: false, + overview_progress: None, options: opts, } } @@ -751,6 +812,8 @@ impl Layout { let mut monitor = Monitor::new(output, workspaces, self.clock.clone(), self.options.clone()); monitor.active_workspace_idx = active_workspace_idx; + monitor.overview_open = self.overview_open; + monitor.set_overview_progress(self.overview_progress.as_ref()); monitors.push(monitor); MonitorSet::Normal { @@ -789,6 +852,8 @@ impl Layout { let mut monitor = Monitor::new(output, workspaces, self.clock.clone(), self.options.clone()); monitor.active_workspace_idx = active_workspace_idx; + monitor.overview_open = self.overview_open; + monitor.set_overview_progress(self.overview_progress.as_ref()); MonitorSet::Normal { monitors: vec![monitor], @@ -1418,7 +1483,7 @@ impl Layout { let mut target = Rectangle::from_size(Size::from((width, height))); // FIXME: ideally this shouldn't include the tile render offset, but the code // duplication would be a bit annoying for this edge case. - target.loc.y -= move_.tile_render_location().y; + target.loc.y -= move_.tile_render_location(1.).y; target.loc.y -= move_.tile.window_loc().y; return target; } @@ -2279,8 +2344,19 @@ impl Layout { ) -> Option<(&W, HitType)> { if let Some(InteractiveMoveState::Moving(move_)) = &self.interactive_move { if move_.output == *output { - let tile_pos = move_.tile_render_location(); - HitType::hit_tile(&move_.tile, tile_pos, pos_within_output) + if self.overview_progress.is_some() { + let zoom = self.overview_zoom(); + let tile_pos = move_.tile_render_location(zoom); + let pos_within_tile = (pos_within_output - tile_pos).downscale(zoom); + // During the overview animation, we cannot do input hits because we cannot + // really represent scaled windows properly. + let (win, hit) = + HitType::hit_tile(&move_.tile, Point::from((0., 0.)), pos_within_tile)?; + Some((win, hit.to_activate())) + } else { + let tile_pos = move_.tile_render_location(1.); + HitType::hit_tile(&move_.tile, tile_pos, pos_within_output) + } } else { None } @@ -2316,6 +2392,36 @@ impl Layout { mon.resize_edges_under(pos_within_output) } + pub fn workspace_under( + &self, + extended_bounds: bool, + output: &Output, + pos_within_output: Point, + ) -> Option<&Workspace> { + if self + .interactive_moved_window_under(output, pos_within_output) + .is_some() + { + return None; + } + + let MonitorSet::Normal { monitors, .. } = &self.monitor_set else { + return None; + }; + + let mon = monitors.iter().find(|mon| &mon.output == output)?; + if extended_bounds { + mon.workspace_under(pos_within_output).map(|(ws, _)| ws) + } else { + mon.workspace_under_narrow(pos_within_output) + } + } + + pub fn overview_zoom(&self) -> f64 { + let progress = self.overview_progress.as_ref().map(|p| p.value()); + compute_overview_zoom(&self.options, progress) + } + #[cfg(test)] fn verify_invariants(&self) { use std::collections::HashSet; @@ -2324,6 +2430,8 @@ impl Layout { use crate::layout::monitor::WorkspaceSwitch; + let zoom = self.overview_zoom(); + let mut move_win_id = None; if let Some(state) = &self.interactive_move { match state { @@ -2352,7 +2460,7 @@ impl Layout { base options adjusted for output scale" ); - let tile_pos = move_.tile_render_location(); + let tile_pos = move_.tile_render_location(zoom); let rounded_pos = tile_pos.to_physical_precise_round(scale).to_logical(scale); // Tile position must be rounded to physical pixels. @@ -2460,6 +2568,12 @@ impl Layout { "monitor options must be synchronized with layout" ); + assert_eq!(self.overview_open, monitor.overview_open); + assert_eq!( + self.overview_progress.as_ref().map(|p| p.value()), + monitor.overview_progress_value() + ); + if let Some(WorkspaceSwitch::Animation(anim)) = &monitor.workspace_switch { let before_idx = anim.from() as usize; let after_idx = anim.to() as usize; @@ -2650,6 +2764,8 @@ impl Layout { // Scroll the view if needed. if let Some((output, pos_within_output)) = dnd_scroll { if let Some(mon) = self.monitor_for_output_mut(&output) { + let zoom = mon.overview_zoom(); + if let Some((ws, geo)) = mon.workspace_under(pos_within_output) { let ws_id = ws.id(); let ws = mon @@ -2657,7 +2773,18 @@ impl Layout { .iter_mut() .find(|ws| ws.id() == ws_id) .unwrap(); - ws.dnd_scroll_gesture_scroll(pos_within_output - geo.loc); + // As far as the DnD scroll gesture is concerned, the workspace spans across + // the whole monitor horizontally. + let ws_pos = Point::from((0., geo.loc.y)); + ws.dnd_scroll_gesture_scroll(pos_within_output - ws_pos, 1. / zoom); + } + } + } + + if !self.overview_open { + if let Some(OverviewProgress::Animation(anim)) = &mut self.overview_progress { + if anim.is_done() { + self.overview_progress = None; } } } @@ -2665,6 +2792,7 @@ impl Layout { match &mut self.monitor_set { MonitorSet::Normal { monitors, .. } => { for mon in monitors { + mon.set_overview_progress(self.overview_progress.as_ref()); mon.advance_animations(); } } @@ -2697,6 +2825,14 @@ impl Layout { } } + if self + .overview_progress + .as_ref() + .is_some_and(|p| p.is_animation()) + { + return true; + } + let MonitorSet::Normal { monitors, .. } = &self.monitor_set else { return false; }; @@ -2719,9 +2855,10 @@ impl Layout { self.update_render_elements_time = self.clock.now(); + let zoom = self.overview_zoom(); if let Some(InteractiveMoveState::Moving(move_)) = &mut self.interactive_move { if output.map_or(true, |output| move_.output == *output) { - let pos_within_output = move_.tile_render_location(); + let pos_within_output = move_.tile_render_location(zoom); let view_rect = Rectangle::new(pos_within_output.upscale(-1.), output_size(&move_.output)); move_.tile.update_render_elements(true, view_rect); @@ -2745,6 +2882,7 @@ impl Layout { let is_active = self.is_active && idx == *active_monitor_idx && !matches!(self.interactive_move, Some(InteractiveMoveState::Moving(_))); + mon.set_overview_progress(self.overview_progress.as_ref()); mon.update_render_elements(is_active); } } @@ -2798,6 +2936,7 @@ impl Layout { let _span = tracy_client::span!("Layout::update_insert_hint::update"); if let Some(mon) = self.monitor_for_output_mut(&move_.output) { + let zoom = mon.overview_zoom(); if let Some((ws, geo)) = mon.workspace_under(move_.pointer_pos_within_output) { let ws_id = ws.id(); let ws = mon @@ -2805,8 +2944,8 @@ impl Layout { .iter_mut() .find(|ws| ws.id() == ws_id) .unwrap(); - - let pos_within_workspace = move_.pointer_pos_within_output - geo.loc; + let pos_within_workspace = + (move_.pointer_pos_within_output - geo.loc).downscale(zoom); let position = ws.scrolling_insert_position(pos_within_workspace); let rules = move_.tile.window().rules(); @@ -3645,6 +3784,9 @@ impl Layout { timestamp: Duration, is_touchpad: bool, ) -> Option> { + let zoom = self.overview_zoom(); + let delta_x = delta_x / zoom; + let monitors = match &mut self.monitor_set { MonitorSet::Normal { monitors, .. } => monitors, MonitorSet::NoOutputs { .. } => return None, @@ -3684,6 +3826,77 @@ impl Layout { None } + pub fn overview_gesture_begin(&mut self) { + self.overview_open = true; + + let value = self.overview_progress.take().map_or(0., |p| p.value()); + let gesture = OverviewGesture { + tracker: SwipeTracker::new(), + start: value, + value, + }; + self.overview_progress = Some(OverviewProgress::Gesture(gesture)); + + self.set_monitors_overview_state(); + } + + pub fn overview_gesture_update(&mut self, delta_y: f64, timestamp: Duration) -> Option { + let Some(OverviewProgress::Gesture(gesture)) = &mut self.overview_progress else { + return None; + }; + + gesture.tracker.push(delta_y, timestamp); + + let total_height = OVERVIEW_GESTURE_MOVEMENT; + let pos = gesture.tracker.pos() / total_height; + let new_value = gesture.start + pos; + let new_value = OVERVIEW_GESTURE_RUBBER_BAND.clamp(0., 1., new_value); + + if gesture.value == new_value { + return Some(false); + } + + gesture.value = new_value; + self.set_monitors_overview_state(); + + Some(true) + } + + pub fn overview_gesture_end(&mut self) -> bool { + let Some(OverviewProgress::Gesture(gesture)) = &mut self.overview_progress else { + return false; + }; + + // Take into account any idle time between the last event and now. + let now = self.clock.now_unadjusted(); + gesture.tracker.push(0., now); + + let total_height = OVERVIEW_GESTURE_MOVEMENT; + + let mut velocity = gesture.tracker.velocity() / total_height; + let current_pos = gesture.tracker.pos() / total_height; + let pos = gesture.tracker.projected_end_pos() / total_height; + + let new_value = gesture.start + pos; + let new_value = new_value.clamp(0., 1.).round(); + + velocity *= + OVERVIEW_GESTURE_RUBBER_BAND.clamp_derivative(0., 1., gesture.start + current_pos); + + self.overview_open = new_value == 1.; + self.overview_progress = Some(OverviewProgress::Animation(Animation::new( + self.clock.clone(), + gesture.value, + new_value, + velocity, + self.options.animations.overview_open_close.0, + ))); + + self.set_monitors_overview_state(); + + true + } + pub fn interactive_move_begin( &mut self, window_id: W::Id, @@ -3710,6 +3923,8 @@ impl Layout { return false; } + let zoom = mon.overview_zoom(); + let is_floating = ws.is_floating(&window_id); let (tile, tile_offset, _visible) = ws .tiles_with_render_positions() @@ -3717,10 +3932,11 @@ impl Layout { .unwrap(); let window_offset = tile.window_loc(); - let tile_pos = ws_geo.loc + tile_offset; + let tile_pos = ws_geo.loc + tile_offset.upscale(zoom); - let pointer_offset_within_window = start_pos_within_output - tile_pos - window_offset; - let window_size = tile.window_size(); + let pointer_offset_within_window = + start_pos_within_output - tile_pos - window_offset.upscale(zoom); + let window_size = tile.window_size().upscale(zoom); let pointer_ratio_within_window = ( f64::clamp(pointer_offset_within_window.x / window_size.w, 0., 1.), f64::clamp(pointer_offset_within_window.y / window_size.h, 0., 1.), @@ -3768,6 +3984,9 @@ impl Layout { return false; } + let zoom = self.overview_zoom(); + let delta = delta.downscale(zoom); + pointer_delta += delta; let (cx, cy) = (pointer_delta.x, pointer_delta.y); @@ -3824,7 +4043,8 @@ impl Layout { .find(|(tile, _, _)| tile.window().id() == window) .unwrap(); - tile_pos = Some(ws_geo.loc + tile_offset); + let zoom = mon.overview_zoom(); + tile_pos = Some((ws_geo.loc + tile_offset.upscale(zoom), zoom)); } } } @@ -3903,9 +4123,10 @@ impl Layout { pointer_ratio_within_window, }; - if let Some(tile_pos) = tile_pos { - let new_tile_pos = data.tile_render_location(); - data.tile.animate_move_from(tile_pos - new_tile_pos); + if let Some((tile_pos, zoom)) = tile_pos { + let new_tile_pos = data.tile_render_location(zoom); + data.tile + .animate_move_from((tile_pos - new_tile_pos).downscale(zoom)); } self.interactive_move = Some(InteractiveMoveState::Moving(data)); @@ -3960,12 +4181,16 @@ impl Layout { unreachable!() }; + let mut ws_id = None; for ws in self.workspaces_mut() { + let id = ws.id(); if let Some(tile) = ws.tiles_mut().find(|tile| *tile.window().id() == window_id) { let offset = tile.interactive_move_offset; tile.interactive_move_offset = Point::from((0., 0.)); tile.animate_move_from(offset); + + ws_id = Some(id); } // Unlock the view on the workspaces, but if the moved window was active, @@ -3980,6 +4205,32 @@ impl Layout { } } + // In the overview, we want to click on a window to focus it, and also to + // click-and-drag to move the window. The way we handle this is by always starting + // the interactive move (to get frozen view), then, when in the overview, *not* + // calling interactive_move_update() until the cursor moves far enough. This means + // that if we "just click" then we end up in this branch with state == Starting. + // Close the overview in this case. + if self.overview_open { + let ws_id = ws_id.unwrap(); + if let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set { + for mon in monitors { + if let Some(ws_idx) = + mon.workspaces.iter().position(|ws| ws.id() == ws_id) + { + mon.activate_workspace_with_anim_config( + ws_idx, + Some(self.options.animations.overview_open_close.0), + ); + break; + } + } + } + + self.activate_window(&window_id); + self.close_overview(); + } + return; } InteractiveMoveState::Moving(move_) => move_, @@ -4007,20 +4258,28 @@ impl Layout { ); } + // Dragging in the overview shouldn't switch the workspace and so on. + let activate = if self.overview_open { + ActivateWindow::No + } else { + ActivateWindow::Yes + }; + match &mut self.monitor_set { MonitorSet::Normal { monitors, active_monitor_idx, .. } => { - let (mon, ws_idx, position, offset) = if let Some(mon) = + let (mon, ws_idx, position, offset, zoom) = if let Some(mon) = monitors.iter_mut().find(|mon| mon.output == move_.output) { + let zoom = mon.overview_zoom(); + let (ws, ws_geo) = mon .workspace_under(move_.pointer_pos_within_output) // If the pointer is somehow outside the move output and a workspace switch - // is in progress, this won't necessarily do the expected thing, but also - // that is not really supposed to happen so eh? + // is in progress, this won't necessarily do the expected thing. .unwrap_or_else(|| mon.workspaces_with_render_geo().next().unwrap()); let ws_id = ws.id(); @@ -4033,14 +4292,16 @@ impl Layout { let position = if move_.is_floating { InsertPosition::Floating } else { - let pos_within_workspace = move_.pointer_pos_within_output - ws_geo.loc; + let pos_within_workspace = + (move_.pointer_pos_within_output - ws_geo.loc).downscale(zoom); let ws = &mut mon.workspaces[ws_idx]; ws.scrolling_insert_position(pos_within_workspace) }; - (mon, ws_idx, position, ws_geo.loc) + (mon, ws_idx, position, ws_geo.loc, zoom) } else { let mon = &mut monitors[*active_monitor_idx]; + let zoom = mon.overview_zoom(); // No point in trying to use the pointer position on the wrong output. let (ws, ws_geo) = mon.workspaces_with_render_geo().next().unwrap(); @@ -4056,11 +4317,12 @@ impl Layout { .iter_mut() .position(|ws| ws.id() == ws_id) .unwrap(); - (mon, ws_idx, position, ws_geo.loc) + + (mon, ws_idx, position, ws_geo.loc, zoom) }; let win_id = move_.tile.window().id().clone(); - let window_render_loc = move_.tile_render_location() + move_.tile.window_loc(); + let window_render_loc = move_.tile_render_location(zoom) + move_.tile.window_loc(); match position { InsertPosition::NewColumn(column_idx) => { @@ -4071,7 +4333,7 @@ impl Layout { id: ws_id, column_idx: Some(column_idx), }, - ActivateWindow::Yes, + activate, move_.width, move_.is_full_width, false, @@ -4083,13 +4345,15 @@ impl Layout { column_idx, Some(tile_idx), move_.tile, - true, + activate == ActivateWindow::Yes, ); } InsertPosition::Floating => { - let pos = move_.tile_render_location() - offset; + let tile_render_loc = move_.tile_render_location(zoom); let mut tile = move_.tile; + + let pos = (tile_render_loc - offset).downscale(zoom); let pos = mon.workspaces[ws_idx].floating_logical_to_size_frac(pos); tile.floating_pos = Some(pos); @@ -4106,7 +4370,7 @@ impl Layout { id: ws_id, column_idx: None, }, - ActivateWindow::Yes, + activate, move_.width, move_.is_full_width, true, @@ -4115,15 +4379,18 @@ impl Layout { } // needed because empty_workspace_above_first could have modified the idx - let ws_idx = mon.active_workspace_idx(); - let ws = &mut mon.workspaces[ws_idx]; - let (tile, tile_render_loc) = ws - .tiles_with_render_positions_mut(false) - .find(|(tile, _)| tile.window().id() == &win_id) + let (tile, tile_render_loc, ws_geo) = mon + .workspaces_with_render_geo_mut() + .find_map(|(ws, geo)| { + ws.tiles_with_render_positions_mut(false) + .find(|(tile, _)| tile.window().id() == &win_id) + .map(|(tile, tile_render_loc)| (tile, tile_render_loc, geo)) + }) .unwrap(); - let new_window_render_loc = offset + tile_render_loc + tile.window_loc(); + let new_window_render_loc = + ws_geo.loc + (tile_render_loc + tile.window_loc()).upscale(zoom); - tile.animate_move_from(window_render_loc - new_window_render_loc); + tile.animate_move_from((window_render_loc - new_window_render_loc).downscale(zoom)); } MonitorSet::NoOutputs { workspaces, .. } => { if workspaces.is_empty() { @@ -4138,7 +4405,7 @@ impl Layout { ws.add_tile( move_.tile, WorkspaceAddWindowTarget::Auto, - ActivateWindow::Yes, + activate, move_.width, move_.is_full_width, move_.is_floating, @@ -4376,6 +4643,60 @@ impl Layout { self.unname_workspace_by_id(id); } + pub fn set_monitors_overview_state(&mut self) { + let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set else { + return; + }; + + for mon in monitors { + mon.overview_open = self.overview_open; + mon.set_overview_progress(self.overview_progress.as_ref()); + } + } + + pub fn toggle_overview(&mut self) { + self.overview_open = !self.overview_open; + + let from = self.overview_progress.take().map_or(0., |p| p.value()); + let to = if self.overview_open { 1. } else { 0. }; + + self.overview_progress = Some(OverviewProgress::Animation(Animation::new( + self.clock.clone(), + from, + to, + 0., + self.options.animations.overview_open_close.0, + ))); + + self.set_monitors_overview_state(); + } + + pub fn open_overview(&mut self) -> bool { + if self.overview_open { + return false; + } + + self.toggle_overview(); + true + } + + pub fn close_overview(&mut self) -> bool { + if !self.overview_open { + return false; + } + + self.toggle_overview(); + true + } + + pub fn toggle_overview_to_workspace(&mut self, ws_idx: usize) { + let config = self.options.animations.overview_open_close.0; + if let Some(mon) = self.active_monitor() { + mon.activate_workspace_with_anim_config(ws_idx, Some(config)); + } + self.toggle_overview(); + } + pub fn start_open_animation_for_window(&mut self, window: &W::Id) { if let Some(InteractiveMoveState::Moving(move_)) = &self.interactive_move { if move_.tile.window().id() == window { @@ -4460,12 +4781,14 @@ impl Layout { ) { let _span = tracy_client::span!("Layout::start_close_animation_for_window"); + let zoom = self.overview_zoom(); + if let Some(InteractiveMoveState::Moving(move_)) = &mut self.interactive_move { if move_.tile.window().id() == window { let Some(snapshot) = move_.tile.take_unmap_snapshot() else { return; }; - let tile_pos = move_.tile_render_location(); + let tile_pos = move_.tile_render_location(zoom); let tile_size = move_.tile.tile_size(); let output = move_.output.clone(); @@ -4516,7 +4839,7 @@ impl Layout { renderer: &mut R, output: &Output, target: RenderTarget, - ) -> impl Iterator> + 'a { + ) -> impl Iterator>> + 'a { if self.update_render_elements_time != self.clock.now() { error!("clock moved between updating render elements and rendering"); } @@ -4525,8 +4848,20 @@ impl Layout { if let Some(InteractiveMoveState::Moving(move_)) = &self.interactive_move { if &move_.output == output { - let location = move_.tile_render_location(); - rv = Some(move_.tile.render(renderer, location, true, target)); + let scale = Scale::from(move_.output.current_scale().fractional_scale()); + let zoom = self.overview_zoom(); + let location = move_.tile_render_location(zoom); + let iter = move_ + .tile + .render(renderer, location, true, target) + .map(move |elem| { + RescaleRenderElement::from_element( + elem, + location.to_physical_precise_round(scale), + zoom, + ) + }); + rv = Some(iter); } } @@ -4589,7 +4924,7 @@ impl Layout { } } else { // Cancel the view offset gesture after workspace switches, moves, etc. - if ws_idx != mon.active_workspace_idx { + if !self.overview_open && ws_idx != mon.active_workspace_idx { ws.view_offset_gesture_end(None); } } @@ -4684,6 +5019,10 @@ impl Layout { self.windows().any(|(_, win)| win.id() == window) } + pub fn is_overview_open(&self) -> bool { + self.overview_open + } + fn resolve_scrolling_width(&self, window: &W, width: Option) -> ColumnWidth { let width = width.unwrap_or_else(|| PresetSize::Fixed(window.size().w)); match width { @@ -4709,3 +5048,14 @@ impl Default for MonitorSet { Self::NoOutputs { workspaces: vec![] } } } + +fn compute_overview_zoom(options: &Options, overview_progress: Option) -> f64 { + // Clamp to some sane values. + let zoom = options.overview.zoom.0.clamp(0.0001, 0.75); + + if let Some(p) = overview_progress { + (1. - p * (1. - zoom)).max(0.0001) + } else { + 1. + } +} diff --git a/src/layout/monitor.rs b/src/layout/monitor.rs index ab5384a8..54f8820f 100644 --- a/src/layout/monitor.rs +++ b/src/layout/monitor.rs @@ -5,7 +5,7 @@ use std::time::Duration; use niri_config::CornerRadius; use smithay::backend::renderer::element::utils::{ - CropRenderElement, Relocate, RelocateRenderElement, + CropRenderElement, Relocate, RelocateRenderElement, RescaleRenderElement, }; use smithay::output::Output; use smithay::utils::{Logical, Point, Rectangle, Size}; @@ -17,11 +17,12 @@ use super::workspace::{ compute_working_area, OutputId, Workspace, WorkspaceAddWindowTarget, WorkspaceId, WorkspaceRenderElement, }; -use super::{ActivateWindow, HitType, LayoutElement, Options}; +use super::{compute_overview_zoom, ActivateWindow, HitType, LayoutElement, Options}; use crate::animation::{Animation, Clock}; use crate::input::swipe_tracker::SwipeTracker; use crate::niri_render_elements; use crate::render_helpers::renderer::NiriRenderer; +use crate::render_helpers::shadow::ShadowRenderElement; use crate::render_helpers::RenderTarget; use crate::rubber_band::RubberBand; use crate::utils::transaction::Transaction; @@ -68,6 +69,10 @@ pub struct Monitor { insert_hint_element: InsertHintElement, /// Location to render the insert hint element. insert_hint_render_loc: Option, + /// Whether the overview is open. + pub(super) overview_open: bool, + /// Progress of the overview zoom animation, 1 is fully in overview. + overview_progress: Option, /// Clock for driving animations. pub(super) clock: Clock, /// Configurable properties of the layout. @@ -94,6 +99,8 @@ pub struct WorkspaceSwitchGesture { tracker: SwipeTracker, /// Whether the gesture is controlled by the touchpad. is_touchpad: bool, + /// Whether the gesture is clamped to +-1 workspace around the center. + is_clamped: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -116,6 +123,12 @@ struct InsertHintRenderLoc { location: Point, } +#[derive(Debug)] +pub(super) enum OverviewProgress { + Animation(Animation), + Value(f64), +} + /// Where to put a newly added window. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum MonitorAddWindowTarget<'a, W: LayoutElement> { @@ -137,10 +150,12 @@ niri_render_elements! { MonitorInnerRenderElement => { Workspace = CropRenderElement>, InsertHint = CropRenderElement, + Shadow = ShadowRenderElement, } } -pub type MonitorRenderElement = RelocateRenderElement>; +pub type MonitorRenderElement = + RelocateRenderElement>>; impl WorkspaceSwitch { pub fn current_idx(&self) -> f64 { @@ -183,9 +198,38 @@ impl WorkspaceSwitch { impl WorkspaceSwitchGesture { fn min_max(&self, workspace_count: usize) -> (f64, f64) { - let min = self.center_idx.saturating_sub(1) as f64; - let max = (self.center_idx + 1).min(workspace_count - 1) as f64; - (min, max) + if self.is_clamped { + let min = self.center_idx.saturating_sub(1) as f64; + let max = (self.center_idx + 1).min(workspace_count - 1) as f64; + (min, max) + } else { + (0., (workspace_count - 1) as f64) + } + } +} + +impl OverviewProgress { + pub fn value(&self) -> f64 { + match self { + OverviewProgress::Animation(anim) => anim.value(), + OverviewProgress::Value(v) => *v, + } + } + + pub fn clamped_value(&self) -> f64 { + match self { + OverviewProgress::Animation(anim) => anim.clamped_value(), + OverviewProgress::Value(v) => *v, + } + } +} + +impl From<&super::OverviewProgress> for OverviewProgress { + fn from(value: &super::OverviewProgress) -> Self { + match value { + super::OverviewProgress::Animation(anim) => Self::Animation(anim.clone()), + super::OverviewProgress::Gesture(gesture) => Self::Value(gesture.value), + } } } @@ -212,6 +256,8 @@ impl Monitor { insert_hint: None, insert_hint_element: InsertHintElement::new(options.insert_hint), insert_hint_render_loc: None, + overview_open: false, + overview_progress: None, workspace_switch: None, clock, options, @@ -769,7 +815,9 @@ impl Monitor { // Make sure the hint is at least partially visible. if matches!(hint.position, InsertPosition::NewColumn(_)) { + let zoom = self.overview_zoom(); let geo = insert_hint_ws_geo.unwrap(); + let geo = geo.downscale(zoom); area.loc.x = area.loc.x.max(-geo.loc.x - area.size.w / 2.); area.loc.x = area.loc.x.min(geo.loc.x + geo.size.w - area.size.w / 2.); @@ -942,6 +990,10 @@ impl Monitor { /// /// During animations, assumes the final view position. pub fn active_tile_visual_rectangle(&self) -> Option> { + if self.overview_open { + return None; + } + self.active_workspace_ref().active_tile_visual_rectangle() } @@ -962,7 +1014,100 @@ impl Monitor { self.workspace_size(zoom) + Size::from((0., gap)) } + pub fn overview_zoom(&self) -> f64 { + let progress = self.overview_progress.as_ref().map(|p| p.value()); + compute_overview_zoom(&self.options, progress) + } + + pub(super) fn set_overview_progress(&mut self, progress: Option<&super::OverviewProgress>) { + let prev_render_idx = self.workspace_render_idx(); + self.overview_progress = progress.map(OverviewProgress::from); + let new_render_idx = self.workspace_render_idx(); + + // If the view jumped (can happen when going from corrected to uncorrected render_idx, for + // example when toggling the overview in the middle of an overview animation), then restart + // the workspace switch to avoid jumps. + if prev_render_idx != new_render_idx { + if let Some(WorkspaceSwitch::Animation(anim)) = &mut self.workspace_switch { + // FIXME: maintain velocity. + *anim = anim.restarted(prev_render_idx, anim.to(), 0.); + } + } + } + + #[cfg(test)] + pub(super) fn overview_progress_value(&self) -> Option { + self.overview_progress.as_ref().map(|p| p.value()) + } + pub fn workspace_render_idx(&self) -> f64 { + // If workspace switch and overview progress are matching animations, then compute a + // correction term to make the movement appear monotonic. + if let ( + Some(WorkspaceSwitch::Animation(switch_anim)), + Some(OverviewProgress::Animation(progress_anim)), + ) = (&self.workspace_switch, &self.overview_progress) + { + if switch_anim.start_time() == progress_anim.start_time() + && (switch_anim.duration().as_secs_f64() - progress_anim.duration().as_secs_f64()) + .abs() + <= 0.001 + { + #[rustfmt::skip] + // How this was derived: + // + // - Assume we're animating a zoom + switch. Consider switch "from" and "to". + // These are render_idx values, so first workspace to second would have switch + // from = 0. and to = 1. regardless of the zoom level. + // + // - At the start, the point at "from" is at Y = 0. We're moving the point at "to" + // to Y = 0. We want this to be a monotonic motion in apparent coordinates (after + // zoom). + // + // - Height at the start: + // from_height = (size.h + gap) * from_zoom. + // + // - Current height: + // current_height = (size.h + gap) * zoom. + // + // - We're moving the "to" point to Y = 0: + // to_y = 0. + // + // - The initial position of the point we're moving: + // from_y = (to - from) * from_height. + // + // - We want this point to travel monotonically in apparent coordinates: + // current_y = from_y + (to_y - from_y) * progress, + // where progress is from 0 to 1, equals to the animation progress (switch and + // zoom are the same since they are synchronized). + // + // - Derive the Y of the first workspace from this: + // first_y = current_y - to * current_height. + // + // Now, let's substitute and rearrange the terms. + // + // - current_y = from_y + (0 - (to - from) * from_height) * progress + // - progress = (switch_anim.value() - from) / (to - from) + // - current_y = from_y - (to - from) * from_height * (switch_anim.value() - from) / (to - from) + // - current_y = from_y - from_height * (switch_anim.value() - from) + // - first_y = from_y - from_height * (switch_anim.value() - from) - to * current_height + // - first_y = (to - from) * from_height - from_height * (switch_anim.value() - from) - to * current_height + // - first_y = to * from_height - switch_anim.value() * from_height - to * current_height + // - first_y = -switch_anim.value() * from_height + to * (from_height - current_height) + let from = progress_anim.from(); + let from_zoom = compute_overview_zoom(&self.options, Some(from)); + let from_ws_height_with_gap = self.workspace_size_with_gap(from_zoom).h; + + let zoom = self.overview_zoom(); + let ws_height_with_gap = self.workspace_size_with_gap(zoom).h; + + let first_ws_y = -switch_anim.value() * from_ws_height_with_gap + + switch_anim.to() * (from_ws_height_with_gap - ws_height_with_gap); + + return -first_ws_y / ws_height_with_gap; + } + }; + if let Some(switch) = &self.workspace_switch { switch.current_idx() } else { @@ -972,19 +1117,24 @@ impl Monitor { pub fn workspaces_render_geo(&self) -> impl Iterator> { let scale = self.scale.fractional_scale(); - let zoom = 1.; + let zoom = self.overview_zoom(); let ws_size = self.workspace_size(zoom); let gap = self.workspace_gap(zoom); let ws_height_with_gap = ws_size.h + gap; + let static_offset = (self.view_size.to_point() - ws_size.to_point()).downscale(2.); + let static_offset = static_offset + .to_physical_precise_round(scale) + .to_logical(scale); + let first_ws_y = -self.workspace_render_idx() * ws_height_with_gap; let first_ws_y = round_logical_in_physical(scale, first_ws_y); // Return position for one-past-last workspace too. (0..=self.workspaces.len()).map(move |idx| { let y = first_ws_y + idx as f64 * ws_height_with_gap; - let loc = Point::from((0., y)); + let loc = Point::from((0., y)) + static_offset; Rectangle::new(loc, ws_size) }) } @@ -1026,20 +1176,42 @@ impl Monitor { Some((ws, geo)) } + pub fn workspace_under_narrow( + &self, + pos_within_output: Point, + ) -> Option<&Workspace> { + self.workspaces_with_render_geo() + .find_map(|(ws, geo)| geo.contains(pos_within_output).then_some(ws)) + } + pub fn window_under(&self, pos_within_output: Point) -> Option<(&W, HitType)> { let (ws, geo) = self.workspace_under(pos_within_output)?; - let (win, hit) = ws.window_under(pos_within_output - geo.loc)?; - Some((win, hit.offset_win_pos(geo.loc))) + + if self.overview_progress.is_some() { + let zoom = self.overview_zoom(); + let pos_within_workspace = (pos_within_output - geo.loc).downscale(zoom); + let (win, hit) = ws.window_under(pos_within_workspace)?; + // During the overview animation, we cannot do input hits because we cannot really + // represent scaled windows properly. + Some((win, hit.to_activate())) + } else { + let (win, hit) = ws.window_under(pos_within_output - geo.loc)?; + Some((win, hit.offset_win_pos(geo.loc))) + } } pub fn resize_edges_under(&self, pos_within_output: Point) -> Option { + if self.overview_progress.is_some() { + return None; + } + let (ws, geo) = self.workspace_under(pos_within_output)?; ws.resize_edges_under(pos_within_output - geo.loc) } pub fn render_above_top_layer(&self) -> bool { // Render above the top layer only if the view is stationary. - if self.workspace_switch.is_some() { + if self.workspace_switch.is_some() || self.overview_progress.is_some() { return false; } @@ -1074,7 +1246,7 @@ impl Monitor { // rendering for maximized GTK windows. // // FIXME: use proper bounds after fixing the Crop element. - let crop_bounds = if self.workspace_switch.is_some() { + let crop_bounds = if self.workspace_switch.is_some() || self.overview_progress.is_some() { Rectangle::new( Point::from((-i32::MAX / 2, 0)), Size::from((i32::MAX, height)), @@ -1086,6 +1258,9 @@ impl Monitor { ) }; + let zoom = self.overview_zoom(); + let overview_clamped_progress = self.overview_progress.as_ref().map(|p| p.clamped_value()); + // Draw the insert hint. let mut insert_hint = None; if !self.options.insert_hint.off { @@ -1109,6 +1284,13 @@ impl Monitor { let floating = floating.filter_map(map_ws_contents); let scrolling = scrolling.filter_map(map_ws_contents); + let shadow = overview_clamped_progress.map(|value| { + ws.render_shadow(renderer) + .map(move |elem| elem.with_alpha(value.clamp(0., 1.) as f32)) + .map(MonitorInnerRenderElement::Shadow) + }); + let shadow = shadow.into_iter().flatten(); + let hint = if matches!(insert_hint, Some((hint_ws_id, _)) if hint_ws_id == ws.id()) { let iter = insert_hint.take().unwrap().1; let iter = iter.filter_map(move |elem| { @@ -1122,9 +1304,10 @@ impl Monitor { }; let hint = hint.into_iter().flatten(); - let iter = floating.chain(hint).chain(scrolling); + let iter = floating.chain(hint).chain(scrolling).chain(shadow); let iter = iter.map(move |elem| { + let elem = RescaleRenderElement::from_element(elem, Point::from((0, 0)), zoom); RelocateRenderElement::from_element( elem, // The offset we get from workspaces_with_render_positions() is already @@ -1149,6 +1332,7 @@ impl Monitor { current_idx, tracker: SwipeTracker::new(), is_touchpad, + is_clamped: !self.overview_open, }; self.workspace_switch = Some(WorkspaceSwitch::Gesture(gesture)); } @@ -1167,6 +1351,7 @@ impl Monitor { return None; } + let zoom = self.overview_zoom(); let total_height = if gesture.is_touchpad { WORKSPACE_GESTURE_MOVEMENT } else { @@ -1177,13 +1362,24 @@ impl Monitor { return None; }; + // Reduce the effect of zoom on the touchpad somewhat. + let delta_scale = if gesture.is_touchpad { + (zoom - 1.) / 2.5 + 1. + } else { + zoom + }; + + let delta_y = delta_y / delta_scale; + let mut rubber_band = WORKSPACE_GESTURE_RUBBER_BAND; + rubber_band.limit /= zoom; + gesture.tracker.push(delta_y, timestamp); let pos = gesture.tracker.pos() / total_height; let (min, max) = gesture.min_max(self.workspaces.len()); let new_idx = gesture.start_idx + pos; - let new_idx = WORKSPACE_GESTURE_RUBBER_BAND.clamp(min, max, new_idx); + let new_idx = rubber_band.clamp(min, max, new_idx); if gesture.current_idx == new_idx { return Some(false); @@ -1202,6 +1398,7 @@ impl Monitor { return false; } + let zoom = self.overview_zoom(); let total_height = if gesture.is_touchpad { WORKSPACE_GESTURE_MOVEMENT } else { @@ -1216,6 +1413,9 @@ impl Monitor { let now = self.clock.now_unadjusted(); gesture.tracker.push(0., now); + let mut rubber_band = WORKSPACE_GESTURE_RUBBER_BAND; + rubber_band.limit /= zoom; + let mut velocity = gesture.tracker.velocity() / total_height; let current_pos = gesture.tracker.pos() / total_height; let pos = gesture.tracker.projected_end_pos() / total_height; @@ -1223,14 +1423,10 @@ impl Monitor { let (min, max) = gesture.min_max(self.workspaces.len()); let new_idx = gesture.start_idx + pos; - let new_idx = WORKSPACE_GESTURE_RUBBER_BAND.clamp(min, max, new_idx); + let new_idx = new_idx.clamp(min, max); let new_idx = new_idx.round() as usize; - velocity *= WORKSPACE_GESTURE_RUBBER_BAND.clamp_derivative( - min, - max, - gesture.start_idx + current_pos, - ); + velocity *= rubber_band.clamp_derivative(min, max, gesture.start_idx + current_pos); self.previous_workspace_id = Some(self.workspaces[self.active_workspace_idx].id()); diff --git a/src/layout/tests.rs b/src/layout/tests.rs index af8ba284..bd845903 100644 --- a/src/layout/tests.rs +++ b/src/layout/tests.rs @@ -603,6 +603,13 @@ enum Op { WorkspaceSwitchGestureEnd { is_touchpad: Option, }, + OverviewGestureBegin, + OverviewGestureUpdate { + #[proptest(strategy = "-400f64..400f64")] + delta: f64, + timestamp: Duration, + }, + OverviewGestureEnd, InteractiveMoveBegin { #[proptest(strategy = "1..=5usize")] window: usize, @@ -658,6 +665,7 @@ enum Op { #[proptest(strategy = "1..=5usize")] window: usize, }, + ToggleOverview, } impl Op { @@ -1387,6 +1395,15 @@ impl Op { Op::WorkspaceSwitchGestureEnd { is_touchpad } => { layout.workspace_switch_gesture_end(is_touchpad); } + Op::OverviewGestureBegin => { + layout.overview_gesture_begin(); + } + Op::OverviewGestureUpdate { delta, timestamp } => { + layout.overview_gesture_update(delta, timestamp); + } + Op::OverviewGestureEnd => { + layout.overview_gesture_end(); + } Op::InteractiveMoveBegin { window, output_idx, @@ -1440,6 +1457,9 @@ impl Op { Op::InteractiveResizeEnd { window } => { layout.interactive_resize_end(&window); } + Op::ToggleOverview => { + layout.toggle_overview(); + } } } } diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs index da04abed..af455f97 100644 --- a/src/layout/workspace.rs +++ b/src/layout/workspace.rs @@ -2,7 +2,10 @@ use std::cmp::max; use std::rc::Rc; use std::time::Duration; -use niri_config::{CenterFocusedColumn, OutputName, PresetSize, Workspace as WorkspaceConfig}; +use niri_config::{ + CenterFocusedColumn, CornerRadius, FloatOrInt, OutputName, PresetSize, + Workspace as WorkspaceConfig, +}; use niri_ipc::{ColumnDisplay, PositionChange, SizeChange}; use smithay::backend::renderer::gles::GlesRenderer; use smithay::desktop::{layer_map_for_output, Window}; @@ -17,6 +20,7 @@ use super::floating::{FloatingSpace, FloatingSpaceRenderElement}; use super::scrolling::{ Column, ColumnWidth, ScrollDirection, ScrollingSpace, ScrollingSpaceRenderElement, }; +use super::shadow::Shadow; use super::tile::{Tile, TileRenderSnapshot}; use super::{ ActivateWindow, HitType, InsertPosition, InteractiveResizeData, LayoutElement, Options, @@ -25,6 +29,7 @@ use super::{ use crate::animation::Clock; use crate::niri_render_elements; use crate::render_helpers::renderer::NiriRenderer; +use crate::render_helpers::shadow::ShadowRenderElement; use crate::render_helpers::RenderTarget; use crate::utils::id::IdCounter; use crate::utils::transaction::{Transaction, TransactionBlocker}; @@ -80,6 +85,9 @@ pub struct Workspace { /// zones. working_area: Rectangle, + /// This workspace's shadow in the overview. + shadow: Shadow, + /// Clock for driving animations. pub(super) clock: Clock, @@ -228,6 +236,17 @@ impl Workspace { options.clone(), ); + let shadow_config = niri_config::Shadow { + on: true, + offset: niri_config::ShadowOffset { + x: FloatOrInt(0.), + y: FloatOrInt(20.), + }, + softness: FloatOrInt(120.), + spread: FloatOrInt(20.), + ..Default::default() + }; + Self { scrolling, floating, @@ -237,6 +256,7 @@ impl Workspace { transform: output.current_transform(), view_size, working_area, + shadow: Shadow::new(shadow_config), output: Some(output), clock, base_options, @@ -281,6 +301,17 @@ impl Workspace { options.clone(), ); + let shadow_config = niri_config::Shadow { + on: true, + offset: niri_config::ShadowOffset { + x: FloatOrInt(0.), + y: FloatOrInt(20.), + }, + softness: FloatOrInt(120.), + spread: FloatOrInt(20.), + ..Default::default() + }; + Self { scrolling, floating, @@ -291,6 +322,7 @@ impl Workspace { original_output, view_size, working_area, + shadow: Shadow::new(shadow_config), clock, base_options, options, @@ -343,6 +375,14 @@ impl Workspace { let view_rect = Rectangle::from_size(self.view_size); self.floating .update_render_elements(is_active && self.floating_is_active.get(), view_rect); + + self.shadow.update_render_elements( + self.view_size, + true, + CornerRadius::default(), + self.scale.fractional_scale(), + 1., + ); } pub fn update_config(&mut self, base_options: Rc) { @@ -370,6 +410,7 @@ impl Workspace { pub fn update_shaders(&mut self) { self.scrolling.update_shaders(); self.floating.update_shaders(); + self.shadow.update_shaders(); } pub fn windows(&self) -> impl Iterator + '_ { @@ -1432,6 +1473,13 @@ impl Workspace { (floating, scrolling) } + pub fn render_shadow( + &self, + renderer: &mut R, + ) -> impl Iterator + '_ { + self.shadow.render(renderer, Point::from((0., 0.))) + } + pub fn render_above_top_layer(&self) -> bool { self.scrolling.render_above_top_layer() } @@ -1630,7 +1678,7 @@ impl Workspace { self.scrolling.dnd_scroll_gesture_begin(); } - pub fn dnd_scroll_gesture_scroll(&mut self, pos: Point) { + pub fn dnd_scroll_gesture_scroll(&mut self, pos: Point, speed: f64) { let config = &self.options.gestures.dnd_edge_view_scroll; let trigger_width = config.trigger_width.0; @@ -1656,6 +1704,7 @@ impl Workspace { // Normalize to [0, 1]. delta / trigger_width }; + let delta = delta * speed; self.scrolling.dnd_scroll_gesture_scroll(delta); } -- cgit