diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs index 0be7596f..e6a56939 100644 --- a/niri-config/src/binds.rs +++ b/niri-config/src/binds.rs @@ -233,6 +233,7 @@ pub enum Action { reference: WorkspaceReference, focus: bool, }, + MoveWindowToScratchpad, MoveColumnToWorkspaceDown(#[knuffel(property(name = "focus"), default = true)] bool), MoveColumnToWorkspaceUp(#[knuffel(property(name = "focus"), default = true)] bool), MoveColumnToWorkspace( @@ -359,6 +360,7 @@ pub enum Action { ClearDynamicCastTarget, #[knuffel(skip)] StopCast(u64), + ScratchpadShow, ToggleOverview, OpenOverview, CloseOverview, @@ -530,6 +532,7 @@ impl From for Action { reference: WorkspaceReference::from(reference), focus, }, + niri_ipc::Action::MoveWindowToScratchpad {} => Self::MoveWindowToScratchpad, niri_ipc::Action::MoveColumnToWorkspaceDown { focus } => { Self::MoveColumnToWorkspaceDown(focus) } @@ -694,6 +697,7 @@ impl From for Action { } niri_ipc::Action::ClearDynamicCastTarget {} => Self::ClearDynamicCastTarget, niri_ipc::Action::StopCast { session_id } => Self::StopCast(session_id), + niri_ipc::Action::ScratchpadShow {} => Self::ScratchpadShow, niri_ipc::Action::ToggleOverview {} => Self::ToggleOverview, niri_ipc::Action::OpenOverview {} => Self::OpenOverview, niri_ipc::Action::CloseOverview {} => Self::CloseOverview, diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index 0aa3cb4f..a5b712e2 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -524,6 +524,8 @@ pub enum Action { #[cfg_attr(feature = "clap", arg(long, action = clap::ArgAction::Set, default_value_t = true))] focus: bool, }, + /// Move the focused window to the scratchpad (hide it). + MoveWindowToScratchpad {}, /// Move the focused column to the workspace below. MoveColumnToWorkspaceDown { /// Whether the focus should follow the target workspace. @@ -908,6 +910,8 @@ pub enum Action { #[cfg_attr(feature = "clap", arg(long))] session_id: u64, }, + /// Show, toggle, or cycle the scratchpad. + ScratchpadShow {}, /// Toggle (open/close) the Overview. ToggleOverview {}, /// Open the Overview. diff --git a/src/input/mod.rs b/src/input/mod.rs index f1ad4932..91bc8942 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1376,6 +1376,13 @@ impl State { } } } + Action::MoveWindowToScratchpad => { + let focus = self.niri.layout.focus().map(|m| m.window.clone()); + if let Some(window) = focus { + self.niri.layout.move_to_scratchpad(&window); + self.niri.queue_redraw_all(); + } + } Action::MoveColumnToWorkspaceDown(focus) => { self.niri.layout.move_column_to_workspace_down(focus); self.maybe_warp_cursor_to_focus(); @@ -2245,6 +2252,10 @@ impl State { Action::StopCast(session_id) => { self.niri.stop_cast(CastSessionId::from(session_id)); } + Action::ScratchpadShow => { + self.niri.layout.scratchpad_show(); + self.niri.queue_redraw_all(); + } Action::ToggleOverview => { self.niri.layout.toggle_overview(); self.niri.queue_redraw_all(); diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 5c4dd639..077a0570 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -32,6 +32,7 @@ //! don't want an unassuming workspace to end up on it. use std::collections::HashMap; +use std::collections::VecDeque; use std::mem; use std::rc::Rc; use std::time::Duration; @@ -353,6 +354,11 @@ pub struct Layout { interactive_move: Option>, /// Ongoing drag-and-drop operation. dnd: Option>, + /// Windows hidden in the scratchpad, front = next to show. Not part of any + /// workspace, so they have no index and never appear in the workspace list. + scratchpad: VecDeque>, + /// Id of the scratchpad window currently shown as a floating overlay, if any. + scratchpad_shown: Option, /// Clock for driving animations. clock: Clock, /// Time that we last updated render elements for. @@ -489,6 +495,7 @@ pub enum ConfigureIntent { } /// Tile that was just removed from the layout. +#[derive(Debug)] pub struct RemovedTile { tile: Tile, /// Width of the column the tile was in. @@ -699,6 +706,8 @@ impl Layout { last_active_workspace_id: HashMap::new(), interactive_move: None, dnd: None, + scratchpad: VecDeque::new(), + scratchpad_shown: None, clock, update_render_elements_time: Duration::ZERO, overview_open: false, @@ -724,6 +733,8 @@ impl Layout { last_active_workspace_id: HashMap::new(), interactive_move: None, dnd: None, + scratchpad: VecDeque::new(), + scratchpad_shown: None, clock, update_render_elements_time: Duration::ZERO, overview_open: false, @@ -1114,6 +1125,13 @@ impl Layout { window: &W::Id, transaction: Transaction, ) -> Option> { + // A window may be sitting in the scratchpad (not in any workspace). + // Drop it here so both toplevel-destroy paths and the close op cover it. + self.scratchpad.retain(|rt| rt.tile.window().id() != window); + if self.scratchpad_shown.as_ref() == Some(window) { + self.scratchpad_shown = None; + } + if let Some(state) = &self.interactive_move { match state { InteractiveMoveState::Starting { window_id, .. } => { @@ -1204,6 +1222,93 @@ impl Layout { None } + /// Hide `window` into the scratchpad. Focus falls to the next window via the + /// normal remove path. No-op if the window is not in the layout. + pub fn move_to_scratchpad(&mut self, window: &W::Id) { + let Some(removed) = self.remove_window(window, Transaction::new()) else { + return; + }; + self.scratchpad.push_back(removed); + // scratchpad_shown is cleared by the eviction hook in remove_window + // (Task 4) if this window was the one shown. + } + + /// Show / toggle / cycle the scratchpad. See the state table in the plan. + pub fn scratchpad_show(&mut self) { + // Drop a stale "shown" id (window was closed or moved away). + if let Some(id) = self.scratchpad_shown.clone() { + if !self.has_window(&id) { + self.scratchpad_shown = None; + } + } + + if let Some(id) = self.scratchpad_shown.clone() { + let focused = self.focus().map(|w| w.id().clone()); + if focused.as_ref() == Some(&id) { + // shown + focused -> hide it + if let Some(removed) = self.remove_window(&id, Transaction::new()) { + self.scratchpad.push_back(removed); + } + self.scratchpad_shown = None; + } else { + // shown + not focused -> focus it in place. `activate_window` walks + // every monitor/workspace looking for the window and focuses it + // without moving it, which is exactly what we want here. + self.activate_window(&id); + } + return; + } + + // nothing shown -> show the front of the stash + let Some(mut removed) = self.scratchpad.pop_front() else { + return; + }; + removed.tile.stop_move_animations(); + + // First-show centering: niri's floating layer places a position-less tile + // using its own default placement, which is acceptable for now. Deferring + // precise centering geometry to manual verification (Task 7); no unit test + // exercises this here. + + let id = removed.tile.window().id().clone(); + let ws_id = match self.active_workspace() { + Some(ws) => ws.id(), + None => { + self.scratchpad.push_front(removed); // no active ws; put it back + return; + } + }; + self.add_removed_tile_floating(ws_id, removed); + self.scratchpad_shown = Some(id); + } + + /// Re-add a previously-removed tile to the workspace `ws_id`, forcing it onto + /// the floating layer. Mirrors the re-add pattern in `move_to_workspace`. + fn add_removed_tile_floating(&mut self, ws_id: WorkspaceId, removed: RemovedTile) { + let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set else { + return; + }; + let Some(mon) = monitors + .iter_mut() + .find(|mon| mon.workspaces.iter().any(|ws| ws.id() == ws_id)) + else { + return; + }; + + mon.add_tile( + removed.tile, + MonitorAddWindowTarget::Workspace { + id: ws_id, + column_idx: None, + }, + ActivateWindow::Yes, + true, + removed.width, + removed.is_full_width, + true, + ); + } + pub fn descendants_added(&mut self, id: &W::Id) -> bool { for ws in self.workspaces_mut() { if ws.descendants_added(id) { @@ -2444,6 +2549,24 @@ impl Layout { } } + // Scratchpad invariants: stashed windows belong to no workspace, and the + // shown window (if any) belongs to a workspace. This must run for both + // MonitorSet variants, so it lives before the match below (the + // MonitorSet::NoOutputs branch returns early). + for tile in &self.scratchpad { + let id = tile.tile.window().id(); + assert!( + !self.has_window(id), + "scratchpad window {id:?} must not also be in a workspace", + ); + } + if let Some(id) = &self.scratchpad_shown { + assert!( + self.has_window(id), + "scratchpad_shown window {id:?} must be present in a workspace", + ); + } + let mut seen_workspace_id = HashSet::new(); let mut seen_workspace_name = Vec::::new(); diff --git a/src/layout/tests.rs b/src/layout/tests.rs index 31265dd9..65e02f2b 100644 --- a/src/layout/tests.rs +++ b/src/layout/tests.rs @@ -753,6 +753,8 @@ enum Op { #[proptest(strategy = "arbitrary_layout_part().prop_map(Box::new)")] layout_config: Box, }, + ScratchpadStash(#[proptest(strategy = "1..=5usize")] usize), + ScratchpadShow, } impl Op { @@ -1625,6 +1627,12 @@ impl Op { layout.update_options(options); } + Op::ScratchpadStash(id) => { + layout.move_to_scratchpad(&id); + } + Op::ScratchpadShow => { + layout.scratchpad_show(); + } } } } @@ -3929,3 +3937,93 @@ proptest! { check_ops_with_options(options, ops); } } + +#[test] +fn scratchpad_stash_removes_window_from_workspace() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, + Op::ScratchpadStash(0), + ]; + let layout = check_ops(ops); + + // The window left every workspace... + assert!(!layout.has_window(&0), "window should not be in any workspace"); + // ...and now lives in the stash. + assert_eq!(layout.scratchpad.len(), 1); + assert_eq!(layout.scratchpad.back().unwrap().tile.window().id(), &0); +} + +#[test] +fn scratchpad_show_brings_window_back_focused() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, + Op::ScratchpadStash(0), + Op::ScratchpadShow, + ]; + let layout = check_ops(ops); + + assert!(layout.scratchpad.is_empty(), "stash drained on show"); + assert!(layout.has_window(&0), "window returned to a workspace"); + assert_eq!(layout.scratchpad_shown, Some(0)); + assert_eq!(layout.focus().map(|w| *w.id()), Some(0), "shown window is focused"); +} + +#[test] +fn scratchpad_show_twice_hides_again() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, + Op::ScratchpadStash(0), + Op::ScratchpadShow, // show + Op::ScratchpadShow, // focused -> hide + ]; + let layout = check_ops(ops); + + assert_eq!(layout.scratchpad.len(), 1, "window back in stash"); + assert!(!layout.has_window(&0)); + assert_eq!(layout.scratchpad_shown, None); +} + +#[test] +fn scratchpad_show_cycles_round_robin() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, + Op::AddWindow { params: TestWindowParams::new(1) }, + Op::ScratchpadStash(0), // stash: [0] + Op::ScratchpadStash(1), // stash: [0, 1] + Op::ScratchpadShow, // show 0 (front) + Op::ScratchpadShow, // 0 focused -> hide 0 -> stash: [1, 0] + Op::ScratchpadShow, // show 1 (new front) + ]; + let layout = check_ops(ops); + + assert_eq!(layout.scratchpad_shown, Some(1), "cycled to the next window"); + assert!(layout.has_window(&1)); +} + +#[test] +fn scratchpad_show_empty_is_noop() { + let ops = [Op::AddOutput(1), Op::ScratchpadShow]; + let layout = check_ops(ops); + assert_eq!(layout.scratchpad_shown, None); + assert!(layout.scratchpad.is_empty()); +} + +#[test] +fn scratchpad_evicts_closed_stashed_window() { + let ops = [ + Op::AddOutput(1), + Op::AddWindow { params: TestWindowParams::new(0) }, + Op::AddWindow { params: TestWindowParams::new(1) }, + Op::ScratchpadStash(0), + Op::ScratchpadStash(1), + Op::CloseWindow(0), // close a window while it is stashed + ]; + let layout = check_ops(ops); + + assert_eq!(layout.scratchpad.len(), 1, "closed window evicted from stash"); + assert_eq!(layout.scratchpad.front().unwrap().tile.window().id(), &1); +}