diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index d622bfcd..77d1c4dd 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -23,7 +23,8 @@ use smithay::input::keyboard::xkb::{keysym_from_name, KEYSYM_CASE_INSENSITIVE}; use smithay::input::keyboard::{Keysym, XkbConfig}; use smithay::reexports::input; -pub const DEFAULT_BACKGROUND_COLOR: Color = Color::from_array_unpremul([0.2, 0.2, 0.2, 1.]); +pub const DEFAULT_BACKGROUND_COLOR: Color = Color::from_array_unpremul([0.25, 0.25, 0.25, 1.]); +pub const DEFAULT_BACKDROP_COLOR: Color = Color::from_array_unpremul([0.15, 0.15, 0.15, 1.]); pub mod layer_rule; @@ -984,6 +985,8 @@ pub struct Animations { pub config_notification_open_close: ConfigNotificationOpenCloseAnim, #[knuffel(child, default)] pub screenshot_ui_open: ScreenshotUiOpenAnim, + #[knuffel(child, default)] + pub overview_open_close: OverviewOpenCloseAnim, } impl Default for Animations { @@ -999,6 +1002,7 @@ impl Default for Animations { window_resize: Default::default(), config_notification_open_close: Default::default(), screenshot_ui_open: Default::default(), + overview_open_close: Default::default(), } } } @@ -1146,6 +1150,22 @@ impl Default for ScreenshotUiOpenAnim { } } +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OverviewOpenCloseAnim(pub Animation); + +impl Default for OverviewOpenCloseAnim { + fn default() -> Self { + Self(Animation { + off: false, + kind: AnimationKind::Spring(SpringParams { + damping_ratio: 1., + stiffness: 800, + epsilon: 0.0001, + }), + }) + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub struct Animation { pub off: bool, @@ -1183,6 +1203,8 @@ pub struct SpringParams { pub struct Gestures { #[knuffel(child, default)] pub dnd_edge_view_scroll: DndEdgeViewScroll, + #[knuffel(child, default)] + pub dnd_edge_workspace_switch: DndEdgeWorkspaceSwitch, } #[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)] @@ -1205,6 +1227,26 @@ impl Default for DndEdgeViewScroll { } } +#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)] +pub struct DndEdgeWorkspaceSwitch { + #[knuffel(child, unwrap(argument), default = Self::default().trigger_height)] + pub trigger_height: FloatOrInt<0, 65535>, + #[knuffel(child, unwrap(argument), default = Self::default().delay_ms)] + pub delay_ms: u16, + #[knuffel(child, unwrap(argument), default = Self::default().max_speed)] + pub max_speed: FloatOrInt<0, 1_000_000>, +} + +impl Default for DndEdgeWorkspaceSwitch { + fn default() -> Self { + Self { + trigger_height: FloatOrInt(50.), + delay_ms: 100, + max_speed: FloatOrInt(1500.), + } + } +} + #[derive(knuffel::Decode, Debug, Default, Clone, PartialEq, Eq)] pub struct Environment(#[knuffel(children)] pub Vec); @@ -1716,6 +1758,7 @@ pub enum Action { SetDynamicCastWindowById(u64), SetDynamicCastMonitor(#[knuffel(argument)] Option), ClearDynamicCastTarget, + ToggleOverview, } impl From for Action { @@ -1980,6 +2023,7 @@ impl From for Action { Self::SetDynamicCastMonitor(output) } niri_ipc::Action::ClearDynamicCastTarget {} => Self::ClearDynamicCastTarget, + niri_ipc::Action::ToggleOverview {} => Self::ToggleOverview, } } } @@ -2964,6 +3008,21 @@ where } } +impl knuffel::Decode for OverviewOpenCloseAnim +where + S: knuffel::traits::ErrorSpan, +{ + fn decode_node( + node: &knuffel::ast::SpannedNode, + ctx: &mut knuffel::decode::Context, + ) -> Result> { + let default = Self::default().0; + Ok(Self(Animation::decode_node(node, ctx, default, |_, _| { + Ok(false) + })?)) + } +} + impl Animation { pub fn new_off() -> Self { Self { @@ -4459,6 +4518,18 @@ mod tests { ), }, ), + overview_open_close: OverviewOpenCloseAnim( + Animation { + off: false, + kind: Spring( + SpringParams { + damping_ratio: 1.0, + stiffness: 800, + epsilon: 0.0001, + }, + ), + }, + ), }, gestures: Gestures { dnd_edge_view_scroll: DndEdgeViewScroll { @@ -4470,6 +4541,15 @@ mod tests { 50.0, ), }, + dnd_edge_workspace_switch: DndEdgeWorkspaceSwitch { + trigger_height: FloatOrInt( + 50.0, + ), + delay_ms: 100, + max_speed: FloatOrInt( + 1500.0, + ), + }, }, environment: Environment( [ diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index d004a26b..29523166 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -764,6 +764,8 @@ pub enum Action { }, /// Clear the dynamic cast target, making it show nothing. ClearDynamicCastTarget {}, + /// Toggle the Overview. + ToggleOverview {}, } /// Change in window or column size. diff --git a/niri-visual-tests/src/cases/layout.rs b/niri-visual-tests/src/cases/layout.rs index 23f2bc8d..81748e8c 100644 --- a/niri-visual-tests/src/cases/layout.rs +++ b/niri-visual-tests/src/cases/layout.rs @@ -266,6 +266,7 @@ impl TestCase for Layout { .monitor_for_output(&self.output) .unwrap() .render_elements(renderer, RenderTarget::Output, true) + .flat_map(|(_, iter)| iter) .map(|elem| Box::new(elem) as _) .collect() } diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs index e06d1fca..4dfce86d 100644 --- a/src/handlers/xdg_shell.rs +++ b/src/handlers/xdg_shell.rs @@ -153,7 +153,7 @@ impl XdgShellHandler for State { match start_data { PointerOrTouchStartData::Pointer(start_data) => { - let grab = MoveGrab::new(start_data, window); + let grab = MoveGrab::new(start_data, window, false); pointer.set_grab(self, grab, serial, Focus::Clear); } PointerOrTouchStartData::Touch(start_data) => { @@ -316,6 +316,9 @@ impl XdgShellHandler for State { } else if let Some(output) = self.niri.layout.active_output() { let layers = layer_map_for_output(output); + // FIXME: somewhere here we probably need to check is_overview_open to match the logic + // in update_keyboard_focus(). + if layers .layer_for_surface(&root, WindowSurfaceType::TOPLEVEL) .is_none() diff --git a/src/input/mod.rs b/src/input/mod.rs index 28e254e4..bf5aadd6 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -29,7 +29,7 @@ use smithay::input::touch::{ }; use smithay::input::SeatHandler; use smithay::output::Output; -use smithay::utils::{Logical, Point, Rectangle, Transform, SERIAL_COUNTER}; +use smithay::utils::{Logical, Point, Rectangle, Size, Transform, SERIAL_COUNTER}; use smithay::wayland::keyboard_shortcuts_inhibit::KeyboardShortcutsInhibitor; use smithay::wayland::pointer_constraints::{with_pointer_constraint, PointerConstraint}; use smithay::wayland::selection::data_device::DnDGrab; @@ -393,6 +393,15 @@ impl State { return FilterResult::Intercept(None); } + if this.niri.keyboard_focus.is_overview() + && pressed + && matches!(raw, Some(Keysym::Escape | Keysym::Return)) + { + this.niri.layout.toggle_overview(); + this.niri.suppressed_keys.insert(key_code); + return FilterResult::Intercept(None); + } + let bindings = &this.niri.config.borrow().binds; should_intercept_key( &mut this.niri.suppressed_keys, @@ -1915,10 +1924,18 @@ impl State { Action::ClearDynamicCastTarget => { self.set_dynamic_cast_target(CastTarget::Nothing); } + Action::ToggleOverview => { + self.niri.layout.toggle_overview(); + self.niri.queue_redraw_all(); + } } } fn on_pointer_motion(&mut self, event: I::PointerMotionEvent) { + let was_inside_hot_corner = self.niri.pointer_inside_hot_corner; + // Any of the early returns here mean that the pointer is not inside the hot corner. + self.niri.pointer_inside_hot_corner = false; + // We need an output to be able to move the pointer. if self.niri.global_space.outputs().next().is_none() { return; @@ -2095,6 +2112,18 @@ impl State { pointer.frame(self); + // contents_under() will return no surface when the hot corner should trigger. + if pointer.current_focus().is_none() { + let hot_corner = Rectangle::from_size(Size::from((1., 1.))); + if let Some((_, pos_within_output)) = self.niri.output_under(pos) { + let inside_hot_corner = hot_corner.contains(pos_within_output); + if inside_hot_corner && !was_inside_hot_corner { + self.niri.layout.toggle_overview(); + } + self.niri.pointer_inside_hot_corner = inside_hot_corner; + } + } + // Activate a new confinement if necessary. self.niri.maybe_activate_pointer_constraint(); @@ -2119,6 +2148,10 @@ impl State { &mut self, event: I::PointerMotionAbsoluteEvent, ) { + let was_inside_hot_corner = self.niri.pointer_inside_hot_corner; + // Any of the early returns here mean that the pointer is not inside the hot corner. + self.niri.pointer_inside_hot_corner = false; + let Some(pos) = self.compute_absolute_location(&event, None).or_else(|| { self.global_bounding_rectangle().map(|output_geo| { event.position_transformed(output_geo.size) + output_geo.loc.to_f64() @@ -2164,6 +2197,18 @@ impl State { pointer.frame(self); + // contents_under() will return no surface when the hot corner should trigger. + if pointer.current_focus().is_none() { + let hot_corner = Rectangle::from_size(Size::from((1., 1.))); + if let Some((_, pos_within_output)) = self.niri.output_under(pos) { + let inside_hot_corner = hot_corner.contains(pos_within_output); + if inside_hot_corner && !was_inside_hot_corner { + self.niri.layout.toggle_overview(); + } + self.niri.pointer_inside_hot_corner = inside_hot_corner; + } + } + self.niri.maybe_activate_pointer_constraint(); // We moved the pointer, show it. @@ -2235,10 +2280,54 @@ impl State { self.niri.pointer_hidden = false; self.niri.tablet_cursor_location = None; + let is_overview_open = self.niri.layout.is_overview_open(); + + if is_overview_open && !pointer.is_grabbed() && button == Some(MouseButton::Right) { + if let Some((output, ws)) = self.niri.workspace_under_cursor(true) { + let ws_id = ws.id(); + let ws_idx = self.niri.layout.find_workspace_by_id(ws_id).unwrap().0; + + self.niri.layout.focus_output(&output); + + let location = pointer.current_location(); + let start_data = PointerGrabStartData { + focus: None, + button: button_code, + location, + }; + self.niri + .layout + .view_offset_gesture_begin(&output, Some(ws_idx), false); + let grab = SpatialMovementGrab::new(start_data, output, ws_id, true); + pointer.set_grab(self, grab, serial, Focus::Clear); + self.niri + .cursor_manager + .set_cursor_image(CursorImageStatus::Named(CursorIcon::AllScroll)); + + // FIXME: granular. + self.niri.queue_redraw_all(); + + // Don't activate the window under the cursor to avoid unnecessary + // scrolling when e.g. Mod+MMB clicking on a partially off-screen window. + return; + } + } + if button == Some(MouseButton::Middle) && !pointer.is_grabbed() { let mod_down = modifiers_from_state(mods).contains(mod_key.to_modifiers()); if mod_down { - if let Some(output) = self.niri.output_under_cursor() { + let output_ws = if is_overview_open { + self.niri.workspace_under_cursor(true) + } else { + self.niri.output_under_cursor().and_then(|output| { + let mon = self.niri.layout.monitor_for_output(&output)?; + Some((output, mon.active_workspace_ref())) + }) + }; + + if let Some((output, ws)) = output_ws { + let ws_id = ws.id(); + self.niri.layout.focus_output(&output); let location = pointer.current_location(); @@ -2247,7 +2336,7 @@ impl State { button: button_code, location, }; - let grab = SpatialMovementGrab::new(start_data, output); + let grab = SpatialMovementGrab::new(start_data, output, ws_id, false); pointer.set_grab(self, grab, serial, Focus::Clear); self.niri .cursor_manager @@ -2269,12 +2358,14 @@ impl State { // Check if we need to start an interactive move. if button == Some(MouseButton::Left) && !pointer.is_grabbed() { let mod_down = modifiers_from_state(mods).contains(mod_key.to_modifiers()); - if mod_down { + if is_overview_open || mod_down { let location = pointer.current_location(); let (output, pos_within_output) = self.niri.output_under(location).unwrap(); let output = output.clone(); - self.niri.layout.activate_window(&window); + if !is_overview_open { + self.niri.layout.activate_window(&window); + } if self.niri.layout.interactive_move_begin( window.clone(), @@ -2286,11 +2377,14 @@ impl State { button: button_code, location, }; - let grab = MoveGrab::new(start_data, window.clone()); + let grab = MoveGrab::new(start_data, window.clone(), is_overview_open); pointer.set_grab(self, grab, serial, Focus::Clear); - self.niri - .cursor_manager - .set_cursor_image(CursorImageStatus::Named(CursorIcon::Move)); + + if !is_overview_open { + self.niri + .cursor_manager + .set_cursor_image(CursorImageStatus::Named(CursorIcon::Move)); + } } } } @@ -2365,7 +2459,20 @@ impl State { } } - self.niri.layout.activate_window(&window); + if !is_overview_open { + self.niri.layout.activate_window(&window); + } + + // FIXME: granular. + self.niri.queue_redraw_all(); + } else if let Some((output, ws)) = is_overview_open + .then(|| self.niri.workspace_under_cursor(false)) + .flatten() + { + let ws_idx = self.niri.layout.find_workspace_by_id(ws.id()).unwrap().0; + + self.niri.layout.focus_output(&output); + self.niri.layout.toggle_overview_to_workspace(ws_idx); // FIXME: granular. self.niri.queue_redraw_all(); @@ -2437,23 +2544,63 @@ impl State { let horizontal_amount_v120 = event.amount_v120(Axis::Horizontal); let vertical_amount_v120 = event.amount_v120(Axis::Vertical); + let is_overview_open = self.niri.layout.is_overview_open(); + // Handle wheel scroll bindings. if source == AxisSource::Wheel { // If we have a scroll bind with current modifiers, then accumulate and don't pass to // Wayland. If there's no bind, reset the accumulator. let mods = self.niri.seat.get_keyboard().unwrap().modifier_state(); let modifiers = modifiers_from_state(mods); - if self.niri.mods_with_wheel_binds.contains(&modifiers) { + let should_handle = + is_overview_open || self.niri.mods_with_wheel_binds.contains(&modifiers); + if should_handle { let horizontal = horizontal_amount_v120.unwrap_or(0.); let ticks = self.niri.horizontal_wheel_tracker.accumulate(horizontal); if ticks != 0 { - let config = self.niri.config.borrow(); - let bindings = &config.binds; - let bind_left = - find_configured_bind(bindings, mod_key, Trigger::WheelScrollLeft, mods); - let bind_right = - find_configured_bind(bindings, mod_key, Trigger::WheelScrollRight, mods); - drop(config); + let (bind_left, bind_right) = if is_overview_open { + if modifiers.is_empty() { + let bind_left = Some(Bind { + key: Key { + trigger: Trigger::WheelScrollLeft, + modifiers: Modifiers::empty(), + }, + action: Action::FocusColumnLeft, + repeat: true, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: false, + hotkey_overlay_title: None, + }); + let bind_right = Some(Bind { + key: Key { + trigger: Trigger::WheelScrollRight, + modifiers: Modifiers::empty(), + }, + action: Action::FocusColumnRight, + repeat: true, + cooldown: None, + allow_when_locked: false, + allow_inhibiting: false, + hotkey_overlay_title: None, + }); + (bind_left, bind_right) + } else { + (None, None) + } + } else { + let config = self.niri.config.borrow(); + let bindings = &config.binds; + let bind_left = + find_configured_bind(bindings, mod_key, Trigger::WheelScrollLeft, mods); + let bind_right = find_configured_bind( + bindings, + mod_key, + Trigger::WheelScrollRight, + mods, + ); + (bind_left, bind_right) + }; if let Some(right) = bind_right { for _ in 0..ticks { @@ -2470,13 +2617,45 @@ impl State { let vertical = vertical_amount_v120.unwrap_or(0.); let ticks = self.niri.vertical_wheel_tracker.accumulate(vertical); if ticks != 0 { - let config = self.niri.config.borrow(); - let bindings = &config.binds; - let bind_up = - find_configured_bind(bindings, mod_key, Trigger::WheelScrollUp, mods); - let bind_down = - find_configured_bind(bindings, mod_key, Trigger::WheelScrollDown, mods); - drop(config); + let (bind_up, bind_down) = if is_overview_open { + if modifiers.is_empty() { + let bind_up = Some(Bind { + key: Key { + trigger: Trigger::WheelScrollUp, + modifiers: Modifiers::empty(), + }, + action: Action::FocusWorkspaceUp, + repeat: true, + cooldown: Some(Duration::from_millis(50)), + allow_when_locked: false, + allow_inhibiting: false, + hotkey_overlay_title: None, + }); + let bind_down = Some(Bind { + key: Key { + trigger: Trigger::WheelScrollDown, + modifiers: Modifiers::empty(), + }, + action: Action::FocusWorkspaceDown, + repeat: true, + cooldown: Some(Duration::from_millis(50)), + allow_when_locked: false, + allow_inhibiting: false, + hotkey_overlay_title: None, + }); + (bind_up, bind_down) + } else { + (None, None) + } + } else { + let config = self.niri.config.borrow(); + let bindings = &config.binds; + let bind_up = + find_configured_bind(bindings, mod_key, Trigger::WheelScrollUp, mods); + let bind_down = + find_configured_bind(bindings, mod_key, Trigger::WheelScrollDown, mods); + (bind_up, bind_down) + }; if let Some(down) = bind_down { for _ in 0..ticks { @@ -2771,6 +2950,12 @@ impl State { if event.fingers() == 3 { self.niri.gesture_swipe_3f_cumulative = Some((0., 0.)); + // We handled this event. + return; + } else if event.fingers() == 4 { + self.niri.layout.overview_gesture_begin(); + self.niri.queue_redraw_all(); + // We handled this event. return; } @@ -2816,6 +3001,8 @@ impl State { } } + let is_overview_open = self.niri.layout.is_overview_open(); + if let Some((cx, cy)) = &mut self.niri.gesture_swipe_3f_cumulative { *cx += delta_x; *cy += delta_y; @@ -2827,7 +3014,21 @@ impl State { if let Some(output) = self.niri.output_under_cursor() { if cx.abs() > cy.abs() { - self.niri.layout.view_offset_gesture_begin(&output, true); + let output_ws = if is_overview_open { + self.niri.workspace_under_cursor(true) + } else { + self.niri.output_under_cursor().and_then(|output| { + let mon = self.niri.layout.monitor_for_output(&output)?; + Some((output, mon.active_workspace_ref())) + }) + }; + + if let Some((output, ws)) = output_ws { + let ws_idx = self.niri.layout.find_workspace_by_id(ws.id()).unwrap().0; + self.niri + .layout + .view_offset_gesture_begin(&output, Some(ws_idx), true); + } } else { self.niri .layout @@ -2862,6 +3063,14 @@ impl State { handled = true; } + let res = self.niri.layout.overview_gesture_update(delta_y, timestamp); + if let Some(redraw) = res { + if redraw { + self.niri.queue_redraw_all(); + } + handled = true; + } + if handled { // We handled this event. return; @@ -2904,6 +3113,12 @@ impl State { handled = true; } + let res = self.niri.layout.overview_gesture_end(); + if res { + self.niri.queue_redraw_all(); + handled = true; + } + if handled { // We handled this event. return; diff --git a/src/input/move_grab.rs b/src/input/move_grab.rs index 96362b45..e939696b 100644 --- a/src/input/move_grab.rs +++ b/src/input/move_grab.rs @@ -1,10 +1,11 @@ use smithay::backend::input::ButtonState; use smithay::desktop::Window; use smithay::input::pointer::{ - AxisFrame, ButtonEvent, CursorImageStatus, GestureHoldBeginEvent, GestureHoldEndEvent, - GesturePinchBeginEvent, GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent, - GestureSwipeEndEvent, GestureSwipeUpdateEvent, GrabStartData as PointerGrabStartData, - MotionEvent, PointerGrab, PointerInnerHandle, RelativeMotionEvent, + AxisFrame, ButtonEvent, CursorIcon, CursorImageStatus, GestureHoldBeginEvent, + GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent, GesturePinchUpdateEvent, + GestureSwipeBeginEvent, GestureSwipeEndEvent, GestureSwipeUpdateEvent, + GrabStartData as PointerGrabStartData, MotionEvent, PointerGrab, PointerInnerHandle, + RelativeMotionEvent, }; use smithay::input::SeatHandler; use smithay::utils::{IsAlive, Logical, Point}; @@ -15,14 +16,32 @@ pub struct MoveGrab { start_data: PointerGrabStartData, last_location: Point, window: Window, + gesture: GestureState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GestureState { + Recognizing, + Move, } impl MoveGrab { - pub fn new(start_data: PointerGrabStartData, window: Window) -> Self { + pub fn new( + start_data: PointerGrabStartData, + window: Window, + use_threshold: bool, + ) -> Self { + let gesture = if use_threshold { + GestureState::Recognizing + } else { + GestureState::Move + }; + Self { last_location: start_data.location, start_data, window, + gesture, } } @@ -53,6 +72,24 @@ impl PointerGrab for MoveGrab { let output = output.clone(); let event_delta = event.location - self.last_location; self.last_location = event.location; + + if self.gesture == GestureState::Recognizing { + let c = event.location - self.start_data.location; + + // Check if the gesture moved far enough to decide. + if c.x * c.x + c.y * c.y >= 8. * 8. { + self.gesture = GestureState::Move; + + data.niri + .cursor_manager + .set_cursor_image(CursorImageStatus::Named(CursorIcon::Move)); + } + } + + if self.gesture != GestureState::Move { + return; + } + let ongoing = data.niri.layout.interactive_move_update( &self.window, event_delta, diff --git a/src/input/spatial_movement_grab.rs b/src/input/spatial_movement_grab.rs index 56a36ce3..e14fa79d 100644 --- a/src/input/spatial_movement_grab.rs +++ b/src/input/spatial_movement_grab.rs @@ -10,12 +10,14 @@ use smithay::input::SeatHandler; use smithay::output::Output; use smithay::utils::{Logical, Point}; +use crate::layout::workspace::WorkspaceId; use crate::niri::State; pub struct SpatialMovementGrab { start_data: PointerGrabStartData, last_location: Point, output: Output, + workspace_id: WorkspaceId, gesture: GestureState, } @@ -27,12 +29,24 @@ enum GestureState { } impl SpatialMovementGrab { - pub fn new(start_data: PointerGrabStartData, output: Output) -> Self { + pub fn new( + start_data: PointerGrabStartData, + output: Output, + workspace_id: WorkspaceId, + is_view_offset: bool, + ) -> Self { + let gesture = if is_view_offset { + GestureState::ViewOffset + } else { + GestureState::Recognizing + }; + Self { last_location: start_data.location, start_data, output, - gesture: GestureState::Recognizing, + workspace_id, + gesture, } } @@ -81,8 +95,16 @@ impl PointerGrab for SpatialMovementGrab { if c.x * c.x + c.y * c.y >= 8. * 8. { if c.x.abs() > c.y.abs() { self.gesture = GestureState::ViewOffset; - layout.view_offset_gesture_begin(&self.output, false); - layout.view_offset_gesture_update(-c.x, timestamp, false) + if let Some((ws_idx, ws)) = layout.find_workspace_by_id(self.workspace_id) { + if ws.current_output() == Some(&self.output) { + layout.view_offset_gesture_begin(&self.output, Some(ws_idx), false); + layout.view_offset_gesture_update(-c.x, timestamp, false) + } else { + None + } + } else { + None + } } else { self.gesture = GestureState::WorkspaceSwitch; layout.workspace_switch_gesture_begin(&self.output, false); diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 948fd1a9..2449eb7b 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, InsertHint, InsertPosition}; 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,17 @@ const INTERACTIVE_MOVE_START_THRESHOLD: f64 = 256. * 256.; /// Opacity of interactively moved tiles targeting the scrolling layout. const INTERACTIVE_MOVE_ALPHA: f64 = 0.75; +/// Scale of the workspaces when the overview is fully open. +const OVERVIEW_WORKSPACE_SCALE: f64 = 0.25; + +/// 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; @@ -288,11 +301,18 @@ pub struct Layout { /// Ongoing interactive move. interactive_move: Option>, /// Ongoing drag-and-drop operation. - dnd: Option, + dnd: Option>, /// Clock for driving animations. 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, } @@ -414,11 +434,26 @@ struct InteractiveMoveData { } #[derive(Debug)] -pub struct DndData { +pub struct DndData { /// Output where the pointer is currently located. output: Output, /// Current pointer position within output. pointer_pos_within_output: Point, + /// Ongoing DnD hold to activate something. + hold: Option>, +} + +#[derive(Debug)] +struct DndHold { + /// Time when we started holding on the target. + start_time: Duration, + target: DndHoldTarget, +} + +#[derive(Debug, PartialEq, Eq)] +enum DndHoldTarget { + Window(WindowId), + Workspace(WorkspaceId), } #[derive(Debug, Clone, Copy)] @@ -493,6 +528,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 +560,16 @@ impl InteractiveMoveState { } impl InteractiveMoveData { - fn tile_render_location(&self) -> Point { + fn tile_render_location(&self, window_scale: 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(window_scale); // Round to physical pixels. pos.to_physical_precise_round(scale).to_logical(scale) } @@ -553,6 +603,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 { @@ -611,6 +670,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 +697,8 @@ impl Layout { dnd: None, clock, update_render_elements_time: Duration::ZERO, + overview_open: false, + overview_progress: None, options: Rc::new(options), } } @@ -648,6 +722,8 @@ impl Layout { dnd: None, clock, update_render_elements_time: Duration::ZERO, + overview_open: false, + overview_progress: None, options: opts, } } @@ -751,6 +827,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 +867,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], @@ -1112,6 +1192,12 @@ impl Layout { unreachable!() }; + if let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set { + for mon in monitors { + mon.dnd_scroll_gesture_end(); + } + } + // Unlock the view on the workspaces. for ws in self.workspaces_mut() { ws.dnd_scroll_gesture_end(); @@ -1418,7 +1504,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; } @@ -1576,6 +1662,32 @@ impl Layout { } } + pub fn activate_window_without_switching_workspace(&mut self, window: &W::Id) { + if let Some(InteractiveMoveState::Moving(move_)) = &self.interactive_move { + if move_.tile.window().id() == window { + return; + } + } + + let MonitorSet::Normal { + monitors, + active_monitor_idx, + .. + } = &mut self.monitor_set + else { + return; + }; + + for (monitor_idx, mon) in monitors.iter_mut().enumerate() { + for ws in &mut mon.workspaces { + if ws.activate_window(window) { + *active_monitor_idx = monitor_idx; + return; + } + } + } + } + pub fn active_output(&self) -> Option<&Output> { let MonitorSet::Normal { monitors, @@ -2290,7 +2402,8 @@ impl Layout { }; if let Some(InteractiveMoveState::Moving(move_)) = &self.interactive_move { - let tile_pos = move_.tile_render_location(); + // TODO scale + let tile_pos = move_.tile_render_location(1.); return HitType::hit_tile(&move_.tile, tile_pos, pos_within_output); }; @@ -2311,6 +2424,40 @@ 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> { + let MonitorSet::Normal { monitors, .. } = &self.monitor_set else { + return None; + }; + + if let Some(InteractiveMoveState::Moving(move_)) = &self.interactive_move { + // TODO scale + let tile_pos = move_.tile_render_location(1.); + if HitType::hit_tile(&move_.tile, tile_pos, pos_within_output).is_some() { + 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 workspace_scale(&self) -> f64 { + if let Some(p) = &self.overview_progress { + (1. - p.value() * (1. - OVERVIEW_WORKSPACE_SCALE)).max(0.) + } else { + 1. + } + } + #[cfg(test)] fn verify_invariants(&self) { use std::collections::HashSet; @@ -2319,6 +2466,8 @@ impl Layout { use crate::layout::monitor::WorkspaceSwitch; + let ws_scale = self.workspace_scale(); + let mut move_win_id = None; if let Some(state) = &self.interactive_move { match state { @@ -2347,7 +2496,7 @@ impl Layout { base options adjusted for output scale" ); - let tile_pos = move_.tile_render_location(); + let tile_pos = move_.tile_render_location(ws_scale); let rounded_pos = tile_pos.to_physical_precise_round(scale).to_logical(scale); // Tile position must be rounded to physical pixels. @@ -2455,6 +2604,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; @@ -2619,29 +2774,125 @@ impl Layout { let _span = tracy_client::span!("Layout::advance_animations"); let mut dnd_scroll = None; + let mut is_dnd = false; if let Some(dnd) = &self.dnd { - dnd_scroll = Some((dnd.output.clone(), dnd.pointer_pos_within_output)); + dnd_scroll = Some((dnd.output.clone(), dnd.pointer_pos_within_output, true)); + is_dnd = true; } if let Some(InteractiveMoveState::Moving(move_)) = &mut self.interactive_move { move_.tile.advance_animations(); - if !move_.is_floating && dnd_scroll.is_none() { - dnd_scroll = Some((move_.output.clone(), move_.pointer_pos_within_output)); + if dnd_scroll.is_none() { + dnd_scroll = Some(( + move_.output.clone(), + move_.pointer_pos_within_output, + !move_.is_floating, + )); } } + let is_overview_open = self.overview_open; + // Scroll the view if needed. - if let Some((output, pos_within_output)) = dnd_scroll { + if let Some((output, pos_within_output, is_scrolling)) = dnd_scroll { if let Some(mon) = self.monitor_for_output_mut(&output) { - if let Some((ws, geo)) = mon.workspace_under(pos_within_output) { - let ws_id = ws.id(); - let ws = mon - .workspaces - .iter_mut() - .find(|ws| ws.id() == ws_id) - .unwrap(); - ws.dnd_scroll_gesture_scroll(pos_within_output - geo.loc); + let mut scrolled = false; + + let ws_scale = mon.workspace_scale().max(0.0001); + scrolled |= mon.dnd_scroll_gesture_scroll(pos_within_output, 1. / ws_scale); + + if is_scrolling { + if let Some((ws, geo)) = mon.workspace_under(pos_within_output) { + let ws_id = ws.id(); + let ws = mon + .workspaces + .iter_mut() + .find(|ws| ws.id() == ws_id) + .unwrap(); + // 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)); + scrolled |= + ws.dnd_scroll_gesture_scroll(pos_within_output - ws_pos, 1. / ws_scale); + } + } + + if scrolled { + // Don't trigger DnD hold while scrolling. + if let Some(dnd) = &mut self.dnd { + dnd.hold = None; + } + } else if is_dnd { + let target = mon + .window_under(pos_within_output) + .map(|(win, _)| DndHoldTarget::Window(win.id().clone())) + .or_else(|| { + mon.workspace_under_narrow(pos_within_output) + .map(|ws| DndHoldTarget::Workspace(ws.id())) + }); + + let dnd = self.dnd.as_mut().unwrap(); + if let Some(target) = target { + let now = self.clock.now_unadjusted(); + let start_time = if let Some(hold) = &mut dnd.hold { + if hold.target != target { + hold.start_time = now; + } + hold.target = target; + hold.start_time + } else { + let hold = dnd.hold.insert(DndHold { + start_time: now, + target, + }); + hold.start_time + }; + + // Delay copied from gnome-shell. + let delay = Duration::from_millis(750); + if delay <= now.saturating_sub(start_time) { + let hold = dnd.hold.take().unwrap(); + + // Synchronize workspace switch to overview close to get a monotonic + // animation. + let config = is_overview_open + .then_some(self.options.animations.overview_open_close.0); + + let mon = self.monitor_for_output_mut(&output).unwrap(); + + let ws_idx = match hold.target { + DndHoldTarget::Window(id) => mon + .workspaces + .iter_mut() + .position(|ws| ws.activate_window(&id)) + .unwrap(), + DndHoldTarget::Workspace(id) => { + mon.workspaces.iter().position(|ws| ws.id() == id).unwrap() + } + }; + + mon.dnd_scroll_gesture_end(); + mon.activate_workspace_with_anim_config(ws_idx, config); + + self.focus_output(&output); + + if is_overview_open { + self.toggle_overview(); + } + } + } else { + // No target, reset the hold timer. + dnd.hold = None; + } + } + } + } + + if !self.overview_open { + if let Some(OverviewProgress::Animation(anim)) = &mut self.overview_progress { + if anim.is_done() { + self.overview_progress = None; } } } @@ -2649,6 +2900,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(); } } @@ -2681,6 +2933,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; }; @@ -2703,9 +2963,10 @@ impl Layout { self.update_render_elements_time = self.clock.now(); + let ws_scale = self.workspace_scale(); 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(ws_scale); let view_rect = Rectangle::new(pos_within_output.upscale(-1.), output_size(&move_.output)); move_.tile.update_render_elements(true, view_rect); @@ -2729,6 +2990,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); } } @@ -2781,6 +3043,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 ws_scale = mon.workspace_scale().max(0.0001); if let Some((ws, geo)) = mon.workspace_under(move_.pointer_pos_within_output) { let ws_id = ws.id(); let ws = mon @@ -2789,7 +3052,9 @@ impl Layout { .find(|ws| ws.id() == ws_id) .unwrap(); - let position = ws.get_insert_position(move_.pointer_pos_within_output - geo.loc); + let pos_within_workspace = + (move_.pointer_pos_within_output - geo.loc).downscale(ws_scale); + let position = ws.get_insert_position(pos_within_workspace); let rules = move_.tile.window().rules(); let border_width = move_.tile.effective_border_width().unwrap_or(0.); @@ -3598,7 +3863,12 @@ impl Layout { None } - pub fn view_offset_gesture_begin(&mut self, output: &Output, is_touchpad: bool) { + pub fn view_offset_gesture_begin( + &mut self, + output: &Output, + workspace_idx: Option, + is_touchpad: bool, + ) { let monitors = match &mut self.monitor_set { MonitorSet::Normal { monitors, .. } => monitors, MonitorSet::NoOutputs { .. } => unreachable!(), @@ -3607,7 +3877,9 @@ impl Layout { for monitor in monitors { for (idx, ws) in monitor.workspaces.iter_mut().enumerate() { // Cancel the gesture on other workspaces. - if &monitor.output != output || idx != monitor.active_workspace_idx { + if &monitor.output != output + || idx != workspace_idx.unwrap_or(monitor.active_workspace_idx) + { ws.view_offset_gesture_end(true, None); continue; } @@ -3623,6 +3895,9 @@ impl Layout { timestamp: Duration, is_touchpad: bool, ) -> Option> { + let ws_scale = self.workspace_scale().max(0.0001); + let delta_x = delta_x / ws_scale; + let monitors = match &mut self.monitor_set { MonitorSet::Normal { monitors, .. } => monitors, MonitorSet::NoOutputs { .. } => return None, @@ -3666,6 +3941,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, @@ -3692,6 +4038,8 @@ impl Layout { return false; } + let ws_scale = mon.workspace_scale(); + let is_floating = ws.is_floating(&window_id); let (tile, tile_offset, _visible) = ws .tiles_with_render_positions() @@ -3699,10 +4047,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(ws_scale); - 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(ws_scale); + let window_size = tile.window_size().upscale(ws_scale); 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.), @@ -3714,6 +4063,12 @@ impl Layout { pointer_ratio_within_window, }); + if let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set { + for mon in monitors { + mon.dnd_scroll_gesture_begin(); + } + } + // Lock the view for scrolling interactive move. if !is_floating { for ws in self.workspaces_mut() { @@ -3750,6 +4105,9 @@ impl Layout { return false; } + let ws_scale = self.workspace_scale().max(0.0001); + let delta = delta.downscale(ws_scale); + pointer_delta += delta; let (cx, cy) = (pointer_delta.x, pointer_delta.y); @@ -3806,7 +4164,8 @@ impl Layout { .find(|(tile, _, _)| tile.window().id() == window) .unwrap(); - tile_pos = Some(ws_geo.loc + tile_offset); + let ws_scale = mon.workspace_scale().max(0.0001); + tile_pos = Some((ws_geo.loc + tile_offset.upscale(ws_scale), ws_scale)); } } } @@ -3885,9 +4244,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, ws_scale)) = tile_pos { + let new_tile_pos = data.tile_render_location(ws_scale); + data.tile + .animate_move_from((tile_pos - new_tile_pos).downscale(ws_scale)); } self.interactive_move = Some(InteractiveMoveState::Moving(data)); @@ -3942,12 +4302,22 @@ impl Layout { unreachable!() }; + if let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set { + for mon in monitors { + mon.dnd_scroll_gesture_end(); + } + } + + 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, @@ -3962,6 +4332,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.toggle_overview(); + } + return; } InteractiveMoveState::Moving(move_) => move_, @@ -3975,6 +4371,12 @@ impl Layout { unreachable!() }; + if let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set { + for mon in monitors { + mon.dnd_scroll_gesture_end(); + } + } + // Unlock the view on the workspaces. if !move_.is_floating { for ws in self.workspaces_mut() { @@ -3989,20 +4391,32 @@ 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, ws_scale) = if let Some(mon) = monitors.iter_mut().find(|mon| mon.output == move_.output) { + let ws_scale = mon.workspace_scale().max(0.0001); + 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? + // + // TODO: in the overview we need to pick the last workspace if we're below + // last. .unwrap_or_else(|| mon.workspaces_with_render_geo().next().unwrap()); let ws_id = ws.id(); @@ -4015,13 +4429,16 @@ impl Layout { let position = if move_.is_floating { InsertPosition::Floating } else { + let pos_within_workspace = + (move_.pointer_pos_within_output - ws_geo.loc).downscale(ws_scale); let ws = &mut mon.workspaces[ws_idx]; - ws.get_insert_position(move_.pointer_pos_within_output - ws_geo.loc) + ws.get_insert_position(pos_within_workspace) }; - (mon, ws_idx, position, ws_geo.loc) + (mon, ws_idx, position, ws_geo.loc, ws_scale) } else { let mon = &mut monitors[*active_monitor_idx]; + let ws_scale = mon.workspace_scale().max(0.0001); // No point in trying to use the pointer position on the wrong output. let (ws, ws_geo) = mon.workspaces_with_render_geo().next().unwrap(); @@ -4037,11 +4454,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, ws_scale) }; 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(ws_scale) + move_.tile.window_loc(); match position { InsertPosition::NewColumn(column_idx) => { @@ -4052,7 +4470,7 @@ impl Layout { id: ws_id, column_idx: Some(column_idx), }, - ActivateWindow::Yes, + activate, move_.width, move_.is_full_width, false, @@ -4064,11 +4482,12 @@ impl Layout { column_idx, Some(tile_idx), move_.tile, - true, + activate == ActivateWindow::Yes, ); } InsertPosition::Floating => { - let pos = move_.tile_render_location() - offset; + let pos = + (move_.tile_render_location(ws_scale) - offset).downscale(ws_scale); let mut tile = move_.tile; let pos = mon.workspaces[ws_idx].floating_logical_to_size_frac(pos); @@ -4087,7 +4506,7 @@ impl Layout { id: ws_id, column_idx: None, }, - ActivateWindow::Yes, + activate, move_.width, move_.is_full_width, true, @@ -4096,15 +4515,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) + let (tile, tile_render_loc) = mon + .workspaces + .iter_mut() + .flat_map(|ws| ws.tiles_with_render_positions_mut(false)) .find(|(tile, _)| tile.window().id() == &win_id) .unwrap(); - let new_window_render_loc = offset + tile_render_loc + tile.window_loc(); + let new_window_render_loc = + offset + (tile_render_loc + tile.window_loc()).upscale(ws_scale); - tile.animate_move_from(window_render_loc - new_window_render_loc); + tile.animate_move_from( + (window_render_loc - new_window_render_loc).downscale(ws_scale), + ); } MonitorSet::NoOutputs { workspaces, .. } => { if workspaces.is_empty() { @@ -4119,7 +4541,7 @@ impl Layout { ws.add_tile( move_.tile, WorkspaceAddWindowTarget::Auto, - ActivateWindow::Yes, + activate, move_.width, move_.is_full_width, move_.is_floating, @@ -4142,9 +4564,16 @@ impl Layout { self.dnd = Some(DndData { output, pointer_pos_within_output, + hold: None, }); if begin_gesture { + if let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set { + for mon in monitors { + mon.dnd_scroll_gesture_begin(); + } + } + for ws in self.workspaces_mut() { ws.dnd_scroll_gesture_begin(); } @@ -4158,6 +4587,12 @@ impl Layout { self.dnd = None; + if let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set { + for mon in monitors { + mon.dnd_scroll_gesture_end(); + } + } + for ws in self.workspaces_mut() { ws.dnd_scroll_gesture_end(); } @@ -4357,6 +4792,42 @@ 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 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 { @@ -4441,12 +4912,14 @@ impl Layout { ) { let _span = tracy_client::span!("Layout::start_close_animation_for_window"); + let ws_scale = self.workspace_scale(); + 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(ws_scale); let tile_size = move_.tile.tile_size(); let output = move_.output.clone(); @@ -4497,7 +4970,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"); } @@ -4506,8 +4979,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 ws_scale = self.workspace_scale(); + let location = move_.tile_render_location(ws_scale); + let iter = move_ + .tile + .render(renderer, location, true, target) + .map(move |elem| { + RescaleRenderElement::from_element( + elem, + location.to_physical_precise_round(scale), + ws_scale, + ) + }); + rv = Some(iter); } } @@ -4558,6 +5043,14 @@ impl Layout { let is_active = self.is_active && idx == *active_monitor_idx && !matches!(self.interactive_move, Some(InteractiveMoveState::Moving(_))); + + if ongoing_scrolling_dnd.is_some() && self.overview_open { + // Begin the scroll on new monitors and when opening the overview. + mon.dnd_scroll_gesture_begin(); + } else if !self.overview_open { + mon.dnd_scroll_gesture_end(); + } + for (ws_idx, ws) in mon.workspaces.iter_mut().enumerate() { ws.refresh(is_active); @@ -4570,7 +5063,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(false, None); } } @@ -4665,6 +5158,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 { diff --git a/src/layout/monitor.rs b/src/layout/monitor.rs index af71ccc4..277917f5 100644 --- a/src/layout/monitor.rs +++ b/src/layout/monitor.rs @@ -4,7 +4,7 @@ use std::rc::Rc; use std::time::Duration; use smithay::backend::renderer::element::utils::{ - CropRenderElement, Relocate, RelocateRenderElement, + CropRenderElement, Relocate, RelocateRenderElement, RescaleRenderElement, }; use smithay::output::Output; use smithay::utils::{Logical, Point, Rectangle, Size}; @@ -14,14 +14,16 @@ use super::tile::Tile; use super::workspace::{ OutputId, Workspace, WorkspaceAddWindowTarget, WorkspaceId, WorkspaceRenderElement, }; -use super::{ActivateWindow, HitType, LayoutElement, Options}; +use super::{ActivateWindow, HitType, LayoutElement, Options, OVERVIEW_WORKSPACE_SCALE}; 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; -use crate::utils::{output_size, ResizeEdge}; +use crate::utils::{output_size, round_logical_in_physical, ResizeEdge}; /// Amount of touchpad movement to scroll the height of one workspace. const WORKSPACE_GESTURE_MOVEMENT: f64 = 300.; @@ -31,6 +33,11 @@ const WORKSPACE_GESTURE_RUBBER_BAND: RubberBand = RubberBand { limit: 0.05, }; +/// Amount of DnD edge scrolling to scroll the height of one workspace. +/// +/// This constant is tied to the default dnd-edge-workspace-switch max-speed setting. +const WORKSPACE_DND_EDGE_SCROLL_MOVEMENT: f64 = 1500.; + #[derive(Debug)] pub struct Monitor { /// Output for this monitor. @@ -45,6 +52,10 @@ pub struct Monitor { pub(super) previous_workspace_id: Option, /// In-progress switch between workspaces. pub(super) workspace_switch: 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. @@ -66,6 +77,22 @@ 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, + + // If this gesture is for drag-and-drop scrolling, this is the last event's unadjusted + // timestamp. + dnd_last_event_time: Option, + // Time when the drag-and-drop scroll delta became non-zero, used for debouncing. + // + // If `None` then the scroll delta is currently zero. + dnd_nonzero_start_time: Option, +} + +#[derive(Debug)] +pub(super) enum OverviewProgress { + Animation(Animation), + Value(f64), } /// Where to put a newly added window. @@ -85,8 +112,13 @@ pub enum MonitorAddWindowTarget<'a, W: LayoutElement> { NextTo(&'a W::Id), } -pub type MonitorRenderElement = - RelocateRenderElement>>; +niri_render_elements! { + MonitorRenderElement => { + Workspace = RelocateRenderElement>>>, + Shadow = RelocateRenderElement>, + } +} impl WorkspaceSwitch { pub fn current_idx(&self) -> f64 { @@ -126,6 +158,31 @@ impl WorkspaceSwitch { } } +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), + } + } +} + impl Monitor { pub fn new( output: Output, @@ -139,6 +196,8 @@ impl Monitor { workspaces, active_workspace_idx: 0, previous_workspace_id: None, + overview_open: false, + overview_progress: None, workspace_switch: None, clock, options, @@ -212,17 +271,21 @@ impl Monitor { self.workspaces.push(ws); } - fn activate_workspace(&mut self, idx: usize) { + pub fn activate_workspace(&mut self, idx: usize) { + self.activate_workspace_with_anim_config(idx, None); + } + + pub fn activate_workspace_with_anim_config( + &mut self, + idx: usize, + config: Option, + ) { if self.active_workspace_idx == idx { return; } // FIXME: also compute and use current velocity. - let current_idx = self - .workspace_switch - .as_ref() - .map(|s| s.current_idx()) - .unwrap_or(self.active_workspace_idx as f64); + let current_idx = self.workspace_render_idx(); self.previous_workspace_id = Some(self.workspaces[self.active_workspace_idx].id()); @@ -233,7 +296,7 @@ impl Monitor { current_idx, idx as f64, 0., - self.options.animations.workspace_switch.0, + config.unwrap_or(self.options.animations.workspace_switch.0), ))); } @@ -639,11 +702,32 @@ impl Monitor { } pub fn advance_animations(&mut self) { - if let Some(WorkspaceSwitch::Animation(anim)) = &mut self.workspace_switch { - if anim.is_done() { - self.workspace_switch = None; - self.clean_up_workspaces(); + match &mut self.workspace_switch { + Some(WorkspaceSwitch::Animation(anim)) => { + if anim.is_done() { + self.workspace_switch = None; + self.clean_up_workspaces(); + } } + Some(WorkspaceSwitch::Gesture(gesture)) => { + // Make sure the last event time doesn't go too much out of date (for + // monitors not under cursor), causing sudden jumps. + // + // This happens after any dnd_scroll_gesture_scroll() calls (in + // Layout::advance_animations()), so it doesn't mess up the time delta there. + if let Some(last_time) = &mut gesture.dnd_last_event_time { + let now = self.clock.now_unadjusted(); + if *last_time != now { + *last_time = now; + + // If last_time was already == now, then dnd_scroll_gesture_scroll() must've + // updated the gesture already. Therefore, when this code runs, the pointer + // must be outside the DnD scrolling zone. + gesture.dnd_nonzero_start_time = None; + } + } + } + None => (), } for ws in &mut self.workspaces { @@ -667,8 +751,9 @@ impl Monitor { } pub fn update_render_elements(&mut self, is_active: bool) { + let is_overview_open = self.overview_open; for (ws, _) in self.workspaces_with_render_geo_mut() { - ws.update_render_elements(is_active); + ws.update_render_elements(is_active, is_overview_open); } } @@ -804,6 +889,10 @@ impl Monitor { /// /// During animations, assumes the final view position. pub fn active_tile_visual_rectangle(&self) -> Option> { + if self.overview_open { + return None; + } + // TODO: unify logic let mut rect = self.active_workspace_ref().active_tile_visual_rectangle()?; if let Some(switch) = &self.workspace_switch { @@ -819,32 +908,140 @@ impl Monitor { Some(rect) } - pub fn workspaces_render_geo(&self) -> impl Iterator> { - let render_idx = if let Some(switch) = &self.workspace_switch { + pub fn workspace_scale(&self) -> f64 { + if let Some(p) = &self.overview_progress { + (1. - p.value() * (1. - OVERVIEW_WORKSPACE_SCALE)).max(0.) + } else { + 1. + } + } + + 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 + { + let scale = self.output.current_scale().fractional_scale(); + let size = output_size(&self.output); + + #[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_ws_scale. + // + // - Current height: + // current_height = (size.h + gap) * ws_scale. + // + // - 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_ws_scale = (1. - from * (1. - OVERVIEW_WORKSPACE_SCALE)).max(0.); + let from_ws_height = round_logical_in_physical(scale, size.h * from_ws_scale); + let from_gap = round_logical_in_physical(scale, size.h * from_ws_scale * 0.1); + let from_ws_height_gap = from_ws_height + from_gap; + + let ws_scale = self.workspace_scale(); + let ws_height = round_logical_in_physical(scale, size.h * ws_scale); + let gap = round_logical_in_physical(scale, size.h * ws_scale * 0.1); + let ws_height_gap = ws_height + gap; + + let first_ws_y = -switch_anim.value() * from_ws_height_gap + + switch_anim.to() * (from_ws_height_gap - ws_height_gap); + + return -first_ws_y / ws_height_gap; + } + }; + + if let Some(switch) = &self.workspace_switch { switch.current_idx() } else { self.active_workspace_idx as f64 - }; - - let before_idx = render_idx.floor(); + } + } + pub fn workspaces_render_geo(&self) -> impl Iterator> { let scale = self.output.current_scale().fractional_scale(); let size = output_size(&self.output); + let ws_scale = self.workspace_scale(); - // Compute the offset in such a way that if render_idx is active_workspace_idx, then its - // offset will be (0., 0.). - let before_ws_y = (before_idx - render_idx) * size.h; - - // Ceil the workspace size in physical pixels. - let ws_size = Size::from((size.w, size.h)) + let ws_size = size + .upscale(ws_scale) .to_physical_precise_ceil(scale) .to_logical(scale); - let first_ws_y = before_ws_y - ws_size.h * before_idx; + let gap = round_logical_in_physical(scale, size.h * 0.1 * ws_scale); + let ws_height_gap = ws_size.h + gap; + + let static_offset = (size.to_point() - ws_size.to_point()).downscale(2.); + // let static_offset = Point::from((0., 0.)); + + // Compute the offset in such a way that if render_idx is active_workspace_idx and the + // workspace scale is 1., then its offset will be (0., 0.). + let first_ws_y = -self.workspace_render_idx() * ws_height_gap; (0..self.workspaces.len()).map(move |idx| { - let y = first_ws_y + idx as f64 * ws_size.h; - let loc = Point::from((0., y)); + let y = first_ws_y + idx as f64 * ws_height_gap; + let loc = Point::from((0., y)) + static_offset; let loc = loc.to_physical_precise_round(scale).to_logical(scale); Rectangle::new(loc, ws_size) }) @@ -890,20 +1087,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 ws_scale = self.workspace_scale().max(0.0001); + let pos_within_workspace = (pos_within_output - geo.loc).downscale(ws_scale); + 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; } @@ -916,7 +1135,12 @@ impl Monitor { renderer: &'a mut R, target: RenderTarget, focus_ring: bool, - ) -> impl Iterator> + 'a { + ) -> impl Iterator< + Item = ( + Rectangle, + impl Iterator>, + ), + > + 'a { let _span = tracy_client::span!("Monitor::render_elements"); let scale = self.output.current_scale().fractional_scale(); @@ -934,7 +1158,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)), @@ -946,38 +1170,102 @@ impl Monitor { ) }; - self.workspaces_with_render_geo() - .flat_map(move |(ws, geo)| { - ws.render_elements(renderer, target, focus_ring) - .filter_map(move |elem| { - CropRenderElement::from_element(elem, scale, crop_bounds) - }) - .map(move |elem| { - RelocateRenderElement::from_element( - elem, - // The offset we get from workspaces_with_render_positions() is already - // rounded to physical pixels, but it's in the logical coordinate - // space, so we need to convert it to physical. - geo.loc.to_physical_precise_round(scale), - Relocate::Relative, - ) - }) - }) + let ws_scale = self.workspace_scale(); + let overview_clamped_progress = self.overview_progress.as_ref().map(|p| p.clamped_value()); + let is_overview_open = self.overview_open; + self.workspaces_with_render_geo().map(move |(ws, geo)| { + let iter = ws + .render_elements(renderer, target, focus_ring, is_overview_open) + .filter_map(move |elem| CropRenderElement::from_element(elem, scale, crop_bounds)) + // .map(move |elem| { + // let elem_scale = 1. - (1. - ws_scale) / OVERVIEW_WORKSPACE_SCALE * 0.03; + // RescaleRenderElement::from_element( + // elem, + // size.downscale(2.) + // .to_physical_precise_round(scale) + // .to_point(), + // elem_scale, + // ) + // }) + .map(move |elem| { + RescaleRenderElement::from_element(elem, Point::from((0, 0)), ws_scale) + }) + .map(move |elem| { + RelocateRenderElement::from_element( + elem, + // The offset we get from workspaces_with_render_positions() is already + // rounded to physical pixels, but it's in the logical coordinate + // space, so we need to convert it to physical. + geo.loc.to_physical_precise_round(scale), + Relocate::Relative, + ) + }) + .map(MonitorRenderElement::Workspace); + let shadow = if let Some(value) = overview_clamped_progress { + Vec::from_iter( + ws.render_shadow(renderer) + .map(move |elem| elem.with_alpha(value.clamp(0., 1.) as f32)) + .map(move |elem| { + RescaleRenderElement::from_element(elem, Point::from((0, 0)), ws_scale) + }) + .map(move |elem| { + RelocateRenderElement::from_element( + elem, + geo.loc.to_physical_precise_round(scale), + Relocate::Relative, + ) + }) + .map(MonitorRenderElement::Shadow), + ) + } else { + Vec::new() + }; + (geo, iter.chain(shadow)) + }) } pub fn workspace_switch_gesture_begin(&mut self, is_touchpad: bool) { let center_idx = self.active_workspace_idx; - let current_idx = self - .workspace_switch - .as_ref() - .map(|s| s.current_idx()) - .unwrap_or(center_idx as f64); + let current_idx = self.workspace_render_idx(); let gesture = WorkspaceSwitchGesture { center_idx, current_idx, tracker: SwipeTracker::new(), is_touchpad, + is_clamped: !self.overview_open, + dnd_last_event_time: None, + dnd_nonzero_start_time: None, + }; + self.workspace_switch = Some(WorkspaceSwitch::Gesture(gesture)); + } + + pub fn dnd_scroll_gesture_begin(&mut self) { + if let Some(WorkspaceSwitch::Gesture(WorkspaceSwitchGesture { + dnd_last_event_time: Some(_), + .. + })) = &self.workspace_switch + { + // Already active. + return; + } + + if !self.overview_open { + // This gesture is only for the overview. + return; + } + + let center_idx = self.active_workspace_idx; + let current_idx = self.workspace_render_idx(); + + let gesture = WorkspaceSwitchGesture { + center_idx, + current_idx, + tracker: SwipeTracker::new(), + is_touchpad: false, + is_clamped: false, + dnd_last_event_time: Some(self.clock.now_unadjusted()), + dnd_nonzero_start_time: None, }; self.workspace_switch = Some(WorkspaceSwitch::Gesture(gesture)); } @@ -988,27 +1276,46 @@ impl Monitor { timestamp: Duration, is_touchpad: bool, ) -> Option { + let ws_scale = self.workspace_scale().max(0.0001); + let Some(WorkspaceSwitch::Gesture(gesture)) = &mut self.workspace_switch else { return None; }; - if gesture.is_touchpad != is_touchpad { + if gesture.is_touchpad != is_touchpad || gesture.dnd_last_event_time.is_some() { return None; } + // Reduce the effect of ws_scale on the touchpad somewhat. + let delta_scale = if gesture.is_touchpad { + (ws_scale - 1.) / 2.5 + 1. + } else { + ws_scale + }; + + let delta_y = delta_y / delta_scale; + let mut rubber_band = WORKSPACE_GESTURE_RUBBER_BAND; + rubber_band.limit /= ws_scale; + gesture.tracker.push(delta_y, timestamp); let total_height = if gesture.is_touchpad { WORKSPACE_GESTURE_MOVEMENT } else { - self.workspaces[0].view_size().h + // Account for the gap. + self.workspaces[0].view_size().h * 1.1 }; let pos = gesture.tracker.pos() / total_height; - let min = gesture.center_idx.saturating_sub(1) as f64; - let max = (gesture.center_idx + 1).min(self.workspaces.len() - 1) as f64; + let (min, max) = if gesture.is_clamped { + let min = gesture.center_idx.saturating_sub(1) as f64; + let max = (gesture.center_idx + 1).min(self.workspaces.len() - 1) as f64; + (min, max) + } else { + (0., (self.workspaces.len() - 1) as f64) + }; let new_idx = gesture.center_idx as f64 + 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); @@ -1018,11 +1325,102 @@ impl Monitor { Some(true) } + pub fn dnd_scroll_gesture_scroll(&mut self, pos: Point, speed: f64) -> bool { + let ws_scale = self.workspace_scale(); + + let Some(WorkspaceSwitch::Gesture(gesture)) = &mut self.workspace_switch else { + return false; + }; + + let Some(last_time) = gesture.dnd_last_event_time else { + // Not a DnD scroll. + return false; + }; + + let config = &self.options.gestures.dnd_edge_workspace_switch; + let trigger_height = config.trigger_height.0; + + // This working area intentionally does not include extra struts from Options. + // TODO: working area + let output_size = output_size(&self.output); + + let width = output_size.w * ws_scale; + let x = pos.x - (output_size.w - width) / 2.; + + let y = pos.y; + let height = output_size.h; + + let y = y.clamp(0., height); + let trigger_height = trigger_height.clamp(0., height / 2.); + + let delta = if x < 0. || width <= x { + // Outside the bounds horizontally. + 0. + } else if y < trigger_height { + -(trigger_height - y) + } else if height - y < trigger_height { + trigger_height - (height - y) + } else { + 0. + }; + + let delta = if trigger_height < 0.01 { + // Sanity check for trigger-height 0 or small window sizes. + 0. + } else { + // Normalize to [0, 1]. + delta / trigger_height + }; + let delta = delta * speed; + + let now = self.clock.now_unadjusted(); + gesture.dnd_last_event_time = Some(now); + + if delta == 0. { + // We're outside the scrolling zone. + gesture.dnd_nonzero_start_time = None; + return false; + } + + let nonzero_start = *gesture.dnd_nonzero_start_time.get_or_insert(now); + + // Delay starting the gesture a bit to avoid unwanted movement when dragging across + // monitors. + let delay = Duration::from_millis(u64::from(config.delay_ms)); + if now.saturating_sub(nonzero_start) < delay { + return true; + } + + let time_delta = now.saturating_sub(last_time).as_secs_f64(); + + let delta = delta * time_delta * config.max_speed.0; + + gesture.tracker.push(delta, now); + + let total_height = WORKSPACE_DND_EDGE_SCROLL_MOVEMENT; + let pos = gesture.tracker.pos() / total_height; + + let (min, max) = if gesture.is_clamped { + let min = gesture.center_idx.saturating_sub(1) as f64; + let max = (gesture.center_idx + 1).min(self.workspaces.len() - 1) as f64; + (min, max) + } else { + (0., (self.workspaces.len() - 1) as f64) + }; + let new_idx = gesture.center_idx as f64 + pos; + let new_idx = new_idx.clamp(min, max); + + gesture.current_idx = new_idx; + true + } + pub fn workspace_switch_gesture_end( &mut self, cancelled: bool, is_touchpad: Option, ) -> bool { + let ws_scale = self.workspace_scale().max(0.0001); + let Some(WorkspaceSwitch::Gesture(gesture)) = &mut self.workspace_switch else { return false; }; @@ -1041,28 +1439,35 @@ impl Monitor { let now = self.clock.now_unadjusted(); gesture.tracker.push(0., now); + let mut rubber_band = WORKSPACE_GESTURE_RUBBER_BAND; + rubber_band.limit /= ws_scale; + let total_height = if gesture.is_touchpad { WORKSPACE_GESTURE_MOVEMENT + } else if gesture.dnd_last_event_time.is_some() { + WORKSPACE_DND_EDGE_SCROLL_MOVEMENT } else { - self.workspaces[0].view_size().h + // Account for the gap. + self.workspaces[0].view_size().h * 1.1 }; 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 min = gesture.center_idx.saturating_sub(1) as f64; - let max = (gesture.center_idx + 1).min(self.workspaces.len() - 1) as f64; + let (min, max) = if gesture.is_clamped { + let min = gesture.center_idx.saturating_sub(1) as f64; + let max = (gesture.center_idx + 1).min(self.workspaces.len() - 1) as f64; + (min, max) + } else { + (0., (self.workspaces.len() - 1) as f64) + }; let new_idx = gesture.center_idx as f64 + 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.center_idx as f64 + current_pos, - ); + velocity *= rubber_band.clamp_derivative(min, max, gesture.center_idx as f64 + current_pos); self.previous_workspace_id = Some(self.workspaces[self.active_workspace_idx].id()); @@ -1077,4 +1482,19 @@ impl Monitor { true } + + pub fn dnd_scroll_gesture_end(&mut self) { + if !matches!( + self.workspace_switch, + Some(WorkspaceSwitch::Gesture(WorkspaceSwitchGesture { + dnd_last_event_time: Some(_), + .. + })) + ) { + // Not a DnD scroll. + return; + }; + + self.workspace_switch_gesture_end(false, None); + } } diff --git a/src/layout/scrolling.rs b/src/layout/scrolling.rs index 87f6c7ba..dbfa19b0 100644 --- a/src/layout/scrolling.rs +++ b/src/layout/scrolling.rs @@ -371,7 +371,7 @@ impl ScrollingSpace { || !self.closing_windows.is_empty() } - pub fn update_render_elements(&mut self, is_active: bool) { + pub fn update_render_elements(&mut self, is_active: bool, is_overview_open: bool) { let view_pos = Point::from((self.view_pos(), 0.)); let view_size = self.view_size; let active_idx = self.active_column_idx; @@ -384,7 +384,7 @@ impl ScrollingSpace { } if let Some(insert_hint) = &self.insert_hint { - if let Some(area) = self.insert_hint_area(insert_hint) { + if let Some(area) = self.insert_hint_area(insert_hint, !is_overview_open) { let view_rect = Rectangle::new(area.loc.upscale(-1.), view_size); self.insert_hint_element.update_render_elements( area.size, @@ -2274,7 +2274,11 @@ impl ScrollingSpace { }) } - fn insert_hint_area(&self, insert_hint: &InsertHint) -> Option> { + fn insert_hint_area( + &self, + insert_hint: &InsertHint, + clamp_to_view: bool, + ) -> Option> { let mut hint_area = match insert_hint.position { InsertPosition::NewColumn(column_index) => { if column_index == 0 || column_index == self.columns.len() { @@ -2369,7 +2373,7 @@ impl ScrollingSpace { let view_size = self.view_size; // Make sure the hint is at least partially visible. - if matches!(insert_hint.position, InsertPosition::NewColumn(_)) { + if clamp_to_view && matches!(insert_hint.position, InsertPosition::NewColumn(_)) { hint_area.loc.x = hint_area.loc.x.max(-hint_area.size.w / 2.); hint_area.loc.x = hint_area.loc.x.min(view_size.w - hint_area.size.w / 2.); } @@ -2724,6 +2728,7 @@ impl ScrollingSpace { renderer: &mut R, target: RenderTarget, focus_ring: bool, + is_overview_open: bool, ) -> Vec> { let mut rv = vec![]; @@ -2731,7 +2736,7 @@ impl ScrollingSpace { // Draw the insert hint. if let Some(insert_hint) = &self.insert_hint { - if let Some(area) = self.insert_hint_area(insert_hint) { + if let Some(area) = self.insert_hint_area(insert_hint, !is_overview_open) { rv.extend( self.insert_hint_element .render(renderer, area.loc) @@ -2916,14 +2921,14 @@ impl ScrollingSpace { Some(true) } - pub fn dnd_scroll_gesture_scroll(&mut self, delta: f64) { + pub fn dnd_scroll_gesture_scroll(&mut self, delta: f64) -> bool { let ViewOffset::Gesture(gesture) = &mut self.view_offset else { - return; + return false; }; let Some(last_time) = gesture.dnd_last_event_time else { // Not a DnD scroll. - return; + return false; }; let config = &self.options.gestures.dnd_edge_view_scroll; @@ -2934,7 +2939,7 @@ impl ScrollingSpace { if delta == 0. { // We're outside the scrolling zone. gesture.dnd_nonzero_start_time = None; - return; + return false; } let nonzero_start = *gesture.dnd_nonzero_start_time.get_or_insert(now); @@ -2943,7 +2948,7 @@ impl ScrollingSpace { // monitors. let delay = Duration::from_millis(u64::from(config.delay_ms)); if now.saturating_sub(nonzero_start) < delay { - return; + return true; } let time_delta = now.saturating_sub(last_time).as_secs_f64(); @@ -2987,6 +2992,7 @@ impl ScrollingSpace { gesture.delta_from_tracker += clamped_offset - view_offset; gesture.current_view_offset = clamped_offset; + true } pub fn view_offset_gesture_end(&mut self, _cancelled: bool, is_touchpad: Option) -> bool { diff --git a/src/layout/tests.rs b/src/layout/tests.rs index 5d45ee98..4a1d940a 100644 --- a/src/layout/tests.rs +++ b/src/layout/tests.rs @@ -576,6 +576,8 @@ enum Op { ViewOffsetGestureBegin { #[proptest(strategy = "1..=5usize")] output_idx: usize, + #[proptest(strategy = "proptest::option::of(0..=4usize)")] + workspace_idx: Option, is_touchpad: bool, }, ViewOffsetGestureUpdate { @@ -602,6 +604,13 @@ enum Op { cancelled: bool, is_touchpad: Option, }, + OverviewGestureBegin, + OverviewGestureUpdate { + #[proptest(strategy = "-400f64..400f64")] + delta: f64, + timestamp: Duration, + }, + OverviewGestureEnd, InteractiveMoveBegin { #[proptest(strategy = "1..=5usize")] window: usize, @@ -657,6 +666,7 @@ enum Op { #[proptest(strategy = "1..=5usize")] window: usize, }, + ToggleOverview, } impl Op { @@ -1345,6 +1355,7 @@ impl Op { } Op::ViewOffsetGestureBegin { output_idx: id, + workspace_idx, is_touchpad: normalize, } => { let name = format!("output{id}"); @@ -1352,7 +1363,7 @@ impl Op { return; }; - layout.view_offset_gesture_begin(&output, normalize); + layout.view_offset_gesture_begin(&output, workspace_idx, normalize); } Op::ViewOffsetGestureUpdate { delta, @@ -1389,6 +1400,15 @@ impl Op { } => { layout.workspace_switch_gesture_end(cancelled, 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, @@ -1442,6 +1462,9 @@ impl Op { Op::InteractiveResizeEnd { window } => { layout.interactive_resize_end(&window); } + Op::ToggleOverview => { + layout.toggle_overview(); + } } } } @@ -2265,6 +2288,7 @@ fn unfullscreen_view_offset_not_reset_on_gesture() { Op::FullscreenWindow(1), Op::ViewOffsetGestureBegin { output_idx: 1, + workspace_idx: None, is_touchpad: true, }, Op::ViewOffsetGestureEnd { diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs index 33e594fe..0828f1d6 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}; @@ -18,6 +21,7 @@ use super::scrolling::{ Column, ColumnWidth, InsertHint, InsertPosition, ScrollDirection, ScrollingSpace, ScrollingSpaceRenderElement, }; +use super::shadow::Shadow; use super::tile::{Tile, TileRenderSnapshot}; use super::{ ActivateWindow, HitType, InteractiveResizeData, LayoutElement, Options, RemovedTile, SizeFrac, @@ -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, @@ -336,13 +368,23 @@ impl Workspace { self.scrolling.are_transitions_ongoing() || self.floating.are_transitions_ongoing() } - pub fn update_render_elements(&mut self, is_active: bool) { - self.scrolling - .update_render_elements(is_active && !self.floating_is_active.get()); + pub fn update_render_elements(&mut self, is_active: bool, is_overview_open: bool) { + self.scrolling.update_render_elements( + is_active && !self.floating_is_active.get(), + is_overview_open, + ); 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 +412,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 + '_ { @@ -1409,11 +1452,15 @@ impl Workspace { renderer: &mut R, target: RenderTarget, focus_ring: bool, + is_overview_open: bool, ) -> impl Iterator> { let scrolling_focus_ring = focus_ring && !self.floating_is_active(); - let scrolling = self - .scrolling - .render_elements(renderer, target, scrolling_focus_ring); + let scrolling = self.scrolling.render_elements( + renderer, + target, + scrolling_focus_ring, + is_overview_open, + ); let scrolling = scrolling.into_iter().map(WorkspaceRenderElement::from); let floating_focus_ring = focus_ring && self.floating_is_active(); @@ -1428,6 +1475,13 @@ impl Workspace { floating.into_iter().flatten().chain(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() } @@ -1628,7 +1682,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) -> bool { let config = &self.options.gestures.dnd_edge_view_scroll; let trigger_width = config.trigger_width.0; @@ -1654,8 +1708,9 @@ impl Workspace { // Normalize to [0, 1]. delta / trigger_width }; + let delta = delta * speed; - self.scrolling.dnd_scroll_gesture_scroll(delta); + self.scrolling.dnd_scroll_gesture_scroll(delta) } pub fn dnd_scroll_gesture_end(&mut self) { diff --git a/src/niri.rs b/src/niri.rs index f893bb33..11c17a36 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -15,7 +15,7 @@ use anyhow::{bail, ensure, Context}; use calloop::futures::Scheduler; use niri_config::{ Config, FloatOrInt, Key, Modifiers, OutputName, PreviewRender, TrackLayout, - WarpMouseToFocusMode, WorkspaceReference, DEFAULT_BACKGROUND_COLOR, + WarpMouseToFocusMode, WorkspaceReference, DEFAULT_BACKDROP_COLOR, DEFAULT_BACKGROUND_COLOR, }; use smithay::backend::allocator::Fourcc; use smithay::backend::input::Keycode; @@ -26,7 +26,8 @@ use smithay::backend::renderer::element::surface::{ render_elements_from_surface_tree, WaylandSurfaceRenderElement, }; use smithay::backend::renderer::element::utils::{ - select_dmabuf_feedback, Relocate, RelocateRenderElement, + select_dmabuf_feedback, CropRenderElement, Relocate, RelocateRenderElement, + RescaleRenderElement, }; use smithay::backend::renderer::element::{ default_primary_scanout_output_compare, Id, Kind, PrimaryScanoutOutput, RenderElementStates, @@ -131,7 +132,7 @@ use crate::ipc::server::IpcServer; use crate::layer::mapped::LayerSurfaceRenderElement; use crate::layer::MappedLayer; use crate::layout::tile::TileRenderElement; -use crate::layout::workspace::WorkspaceId; +use crate::layout::workspace::{Workspace, WorkspaceId}; use crate::layout::{HitType, Layout, LayoutElement as _, MonitorRenderElement}; use crate::niri_render_elements; use crate::protocols::foreign_toplevel::{self, ForeignToplevelManagerState}; @@ -344,6 +345,7 @@ pub struct Niri { /// Used for limiting the notify to once per iteration, so that it's not spammed with high /// resolution mice. pub notified_activity_this_iteration: bool, + pub pointer_inside_hot_corner: bool, pub tablet_cursor_location: Option>, pub gesture_swipe_3f_cumulative: Option<(f64, f64)>, pub vertical_wheel_tracker: ScrollTracker, @@ -428,6 +430,7 @@ pub struct OutputState { /// Solid color buffer for the background that we use instead of clearing to avoid damage /// tracking issues and make screenshots easier. pub background_buffer: SolidColorBuffer, + pub backdrop_buffer: SolidColorBuffer, pub lock_render_state: LockRenderState, pub lock_surface: Option, pub lock_color_buffer: SolidColorBuffer, @@ -467,6 +470,7 @@ pub enum KeyboardFocus { LayerShell { surface: WlSurface }, LockScreen { surface: Option }, ScreenshotUi, + Overview, } #[derive(Default, Clone, PartialEq)] @@ -563,6 +567,7 @@ impl KeyboardFocus { KeyboardFocus::LayerShell { surface } => Some(surface), KeyboardFocus::LockScreen { surface } => surface.as_ref(), KeyboardFocus::ScreenshotUi => None, + KeyboardFocus::Overview => None, } } @@ -572,12 +577,17 @@ impl KeyboardFocus { KeyboardFocus::LayerShell { surface } => Some(surface), KeyboardFocus::LockScreen { surface } => surface, KeyboardFocus::ScreenshotUi => None, + KeyboardFocus::Overview => None, } } pub fn is_layout(&self) -> bool { matches!(self, KeyboardFocus::Layout { .. }) } + + pub fn is_overview(&self) -> bool { + matches!(self, KeyboardFocus::Overview) + } } pub struct State { @@ -1040,6 +1050,11 @@ impl State { surface = surface.or_else(|| focus_on_layer(Layer::Background)); } else { surface = surface.or_else(|| focus_on_layer(Layer::Top)); + + if self.niri.layout.is_overview_open() { + surface = Some(surface.unwrap_or(KeyboardFocus::Overview)); + } + surface = surface.or_else(|| on_d_focus_on_layer(Layer::Bottom)); surface = surface.or_else(|| on_d_focus_on_layer(Layer::Background)); surface = surface.or_else(layout_focus); @@ -2392,6 +2407,7 @@ impl Niri { pointer_inactivity_timer: None, pointer_inactivity_timer_got_reset: false, notified_activity_this_iteration: false, + pointer_inside_hot_corner: false, tablet_cursor_location: None, gesture_swipe_3f_cumulative: None, vertical_wheel_tracker: ScrollTracker::new(120), @@ -2640,6 +2656,9 @@ impl Niri { .to_array_unpremul(); background_color[3] = 1.; + let mut backdrop_color = DEFAULT_BACKDROP_COLOR.to_array_unpremul(); + backdrop_color[3] = 1.; + // FIXME: fix winit damage on other transforms. if name.connector == "winit" { transform = Transform::Flipped180; @@ -2673,6 +2692,7 @@ impl Niri { last_drm_sequence: None, frame_callback_sequence: 0, background_buffer: SolidColorBuffer::new(size, background_color), + backdrop_buffer: SolidColorBuffer::new(size, backdrop_color), lock_render_state, lock_surface: None, lock_color_buffer: SolidColorBuffer::new(size, CLEAR_COLOR_LOCKED), @@ -2777,6 +2797,7 @@ impl Niri { if let Some(state) = self.output_state.get_mut(output) { state.background_buffer.resize(output_size); + state.backdrop_buffer.resize(output_size); state.lock_color_buffer.resize(output_size); if let Some(lock_surface) = &state.lock_surface { @@ -2876,17 +2897,58 @@ impl Niri { return false; } - if layer_popup_under(Layer::Top) - || layer_toplevel_under(Layer::Top) - || layer_popup_under(Layer::Bottom) - || layer_popup_under(Layer::Background) - { + let hot_corner = Rectangle::from_size(Size::from((1., 1.))); + if hot_corner.contains(pos_within_output) { + return true; + } + + if layer_popup_under(Layer::Top) || layer_toplevel_under(Layer::Top) { + return true; + } + + if self.layout.is_overview_open() { + return false; + } + + if layer_popup_under(Layer::Bottom) || layer_popup_under(Layer::Background) { return true; } false } + /// Returns the workspace under the position to be activated. + /// + /// The return value is an output and a workspace index on it. + pub fn workspace_under( + &self, + extended_bounds: bool, + pos: Point, + ) -> Option<(Output, &Workspace)> { + if self.is_locked() || self.screenshot_ui.is_open() { + return None; + } + + let (output, pos_within_output) = self.output_under(pos)?; + + if self.is_layout_obscured_under(output, pos_within_output) { + return None; + } + + let ws = self + .layout + .workspace_under(extended_bounds, output, pos_within_output)?; + Some((output.clone(), ws)) + } + + pub fn workspace_under_cursor( + &self, + extended_bounds: bool, + ) -> Option<(Output, &Workspace)> { + let pos = self.seat.get_pointer().unwrap().current_location(); + self.workspace_under(extended_bounds, pos) + } + /// Returns the window under the position to be activated. /// /// The cursor may be inside the window's activation region, but not within the window's input @@ -3017,6 +3079,8 @@ impl Niri { let mut under = layer_popup_under(Layer::Overlay).or_else(|| layer_toplevel_under(Layer::Overlay)); + let is_overview_open = self.layout.is_overview_open(); + // When rendering above the top layer, we put the regular monitor elements first. // Otherwise, we will render all layer-shell pop-ups and the top layer on top. if mon.render_above_top_layer() { @@ -3029,14 +3093,28 @@ impl Niri { .or_else(|| layer_toplevel_under(Layer::Bottom)) .or_else(|| layer_toplevel_under(Layer::Background)); } else { + let hot_corner = Rectangle::from_size(Size::from((1., 1.))); + if hot_corner.contains(pos_within_output) { + return rv; + } + under = under .or_else(|| layer_popup_under(Layer::Top)) - .or_else(|| layer_toplevel_under(Layer::Top)) - .or_else(|| layer_popup_under(Layer::Bottom)) - .or_else(|| layer_popup_under(Layer::Background)) - .or_else(window_under) - .or_else(|| layer_toplevel_under(Layer::Bottom)) - .or_else(|| layer_toplevel_under(Layer::Background)); + .or_else(|| layer_toplevel_under(Layer::Top)); + + if !is_overview_open { + under = under + .or_else(|| layer_popup_under(Layer::Bottom)) + .or_else(|| layer_popup_under(Layer::Background)); + } + + under = under.or_else(window_under); + + if !is_overview_open { + under = under + .or_else(|| layer_toplevel_under(Layer::Bottom)) + .or_else(|| layer_toplevel_under(Layer::Background)); + } } let Some((mut surface_and_pos, (window, layer))) = under else { @@ -3495,6 +3573,7 @@ impl Niri { // layer-shell, the layout will briefly draw as active, despite never having focus. KeyboardFocus::LockScreen { .. } => true, KeyboardFocus::ScreenshotUi => true, + KeyboardFocus::Overview => true, }; self.layout.refresh(layout_is_active); @@ -3748,10 +3827,18 @@ impl Niri { return elements; } - // Prepare the background element. + // Prepare the background elements. let state = self.output_state.get(output).unwrap(); + let background_buffer = state.background_buffer.clone(); let background = SolidColorRenderElement::from_buffer( - &state.background_buffer, + &background_buffer, + (0, 0), + output_scale, + 1., + Kind::Unspecified, + ); + let backdrop = SolidColorRenderElement::from_buffer( + &state.backdrop_buffer, (0, 0), output_scale, 1., @@ -3768,8 +3855,8 @@ impl Niri { .map(OutputRenderElements::from), ); - // Add the background for outputs that were connected while the screenshot UI was open. - elements.push(background); + // Add the backdrop for outputs that were connected while the screenshot UI was open. + elements.push(backdrop); if self.debug_draw_opaque_regions { draw_opaque_regions(&mut elements, output_scale); @@ -3788,7 +3875,11 @@ impl Niri { // Get monitor elements. let mon = self.layout.monitor_for_output(output).unwrap(); - let monitor_elements: Vec<_> = mon.render_elements(renderer, target, focus_ring).collect(); + let ws_scale = mon.workspace_scale(); + let monitor_elements = Vec::from_iter( + mon.render_elements(renderer, target, focus_ring) + .map(|(geo, iter)| (geo, Vec::from_iter(iter))), + ); let int_move_elements: Vec<_> = self .layout .render_interactive_move_for_output(renderer, output, target) @@ -3824,27 +3915,71 @@ impl Niri { .into_iter() .map(OutputRenderElements::from), ); - elements.extend(monitor_elements.into_iter().map(OutputRenderElements::from)); + elements.extend( + monitor_elements + .into_iter() + .flat_map(|(_, iter)| iter) + .map(OutputRenderElements::from), + ); elements.extend(top_layer.into_iter().map(OutputRenderElements::from)); elements.extend(layer_elems.popups.drain(..).map(OutputRenderElements::from)); elements.extend(layer_elems.normal.drain(..).map(OutputRenderElements::from)); + + // TODO background } else { elements.extend(top_layer.into_iter().map(OutputRenderElements::from)); - elements.extend(layer_elems.popups.drain(..).map(OutputRenderElements::from)); + // TODO: adjust input to put interactive move above popups. elements.extend( int_move_elements .into_iter() .map(OutputRenderElements::from), ); - elements.extend(monitor_elements.into_iter().map(OutputRenderElements::from)); - elements.extend(layer_elems.normal.drain(..).map(OutputRenderElements::from)); + for (ws_geo, ws_elements) in monitor_elements { + // Collect all other layer-shell elements. + let mut layer_elems = SplitElements::default(); + extend_from_layer(&mut layer_elems, Layer::Bottom); + extend_from_layer(&mut layer_elems, Layer::Background); + + let ws_geo = ws_geo.to_physical_precise_round(output_scale); + for elem in layer_elems.popups { + let elem = + RescaleRenderElement::from_element(elem, Point::from((0, 0)), ws_scale); + let elem = + RelocateRenderElement::from_element(elem, ws_geo.loc, Relocate::Relative); + if let Some(elem) = CropRenderElement::from_element(elem, output_scale, ws_geo) + { + elements.push(OutputRenderElements::from(elem)); + } + } + + elements.extend(ws_elements.into_iter().map(OutputRenderElements::from)); + + for elem in layer_elems.normal { + let elem = + RescaleRenderElement::from_element(elem, Point::from((0, 0)), ws_scale); + let elem = + RelocateRenderElement::from_element(elem, ws_geo.loc, Relocate::Relative); + if let Some(elem) = CropRenderElement::from_element(elem, output_scale, ws_geo) + { + elements.push(OutputRenderElements::from(elem)); + } + } + + let elem = background.clone(); + let elem = RescaleRenderElement::from_element(elem, Point::from((0, 0)), ws_scale); + let elem = + RelocateRenderElement::from_element(elem, ws_geo.loc, Relocate::Relative); + if let Some(elem) = CropRenderElement::from_element(elem, output_scale, ws_geo) { + elements.push(OutputRenderElements::from(elem)); + } + } } - // Then the background. - elements.push(background); + // Then the backdrop. + elements.push(backdrop); if self.debug_draw_opaque_regions { draw_opaque_regions(&mut elements, output_scale); @@ -5433,7 +5568,7 @@ impl Niri { } if let Some(window) = &new_focus.window { - if current_focus.window.as_ref() != Some(window) { + if !self.layout.is_overview_open() && current_focus.window.as_ref() != Some(window) { let (window, hit) = window; // Don't trigger focus-follows-mouse over the tab indicator. @@ -5671,10 +5806,17 @@ niri_render_elements! { OutputRenderElements => { Monitor = MonitorRenderElement, Tile = TileRenderElement, + RescaledTile = RescaleRenderElement>, LayerSurface = LayerSurfaceRenderElement, + RelocatedLayerSurface = CropRenderElement + >>>, Wayland = WaylandSurfaceRenderElement, NamedPointer = MemoryRenderBufferRenderElement, SolidColor = SolidColorRenderElement, + RelocatedSolidColor = CropRenderElement>>, ScreenshotUi = ScreenshotUiRenderElement, Texture = PrimaryGpuTextureRenderElement, // Used for the CPU-rendered panels. diff --git a/src/render_helpers/shader_element.rs b/src/render_helpers/shader_element.rs index 5f7beed0..3c294971 100644 --- a/src/render_helpers/shader_element.rs +++ b/src/render_helpers/shader_element.rs @@ -245,6 +245,11 @@ impl ShaderRenderElement { self.area.loc = location; self } + + pub fn with_alpha(mut self, alpha: f32) -> Self { + self.alpha = alpha; + self + } } impl Element for ShaderRenderElement { diff --git a/src/render_helpers/shadow.rs b/src/render_helpers/shadow.rs index 0a8c731d..3fc623d0 100644 --- a/src/render_helpers/shadow.rs +++ b/src/render_helpers/shadow.rs @@ -175,6 +175,11 @@ impl ShadowRenderElement { self } + pub fn with_alpha(mut self, alpha: f32) -> Self { + self.inner = self.inner.with_alpha(alpha); + self + } + pub fn has_shader(renderer: &mut impl NiriRenderer) -> bool { Shaders::get(renderer) .program(ProgramType::Shadow)