a9ce3a13cf
Adds two synthetic multi-output tests (niri's layout harness supports several outputs) proving scratchpad_show targets the FOCUSED output, plus a control that it returns to the origin output when focus is unchanged. This is the substitute for manual multi-monitor verification on a single-display machine. 117 layout tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
500 lines
19 KiB
Diff
500 lines
19 KiB
Diff
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<niri_ipc::Action> 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<niri_ipc::Action> 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/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs
|
|
index 38440c90..a174921e 100644
|
|
--- a/src/handlers/xdg_shell.rs
|
|
+++ b/src/handlers/xdg_shell.rs
|
|
@@ -834,6 +834,15 @@ impl XdgShellHandler for State {
|
|
.find_window_and_output(surface.wl_surface());
|
|
|
|
let Some((mapped, output)) = win_out else {
|
|
+ // A window whose client died while stashed in the scratchpad is in no
|
|
+ // workspace, so find_window_and_output can't see it; evict it here.
|
|
+ if self
|
|
+ .niri
|
|
+ .layout
|
|
+ .remove_from_scratchpad(surface.wl_surface())
|
|
+ {
|
|
+ return;
|
|
+ }
|
|
// I have no idea how this can happen, but I saw it happen once, in a weird interaction
|
|
// involving laptop going to sleep and resuming.
|
|
error!("toplevel missing from both unmapped_windows and layout");
|
|
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..a90d9e6e 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<W: LayoutElement> {
|
|
interactive_move: Option<InteractiveMoveState<W>>,
|
|
/// Ongoing drag-and-drop operation.
|
|
dnd: Option<DndData<W>>,
|
|
+ /// 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<RemovedTile<W>>,
|
|
+ /// Id of the scratchpad window currently shown as a floating overlay, if any.
|
|
+ scratchpad_shown: Option<W::Id>,
|
|
/// 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<W: LayoutElement> {
|
|
tile: Tile<W>,
|
|
/// Width of the column the tile was in.
|
|
@@ -699,6 +706,8 @@ impl<W: LayoutElement> Layout<W> {
|
|
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<W: LayoutElement> Layout<W> {
|
|
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<W: LayoutElement> Layout<W> {
|
|
window: &W::Id,
|
|
transaction: Transaction,
|
|
) -> Option<RemovedTile<W>> {
|
|
+ // 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,106 @@ impl<W: LayoutElement> Layout<W> {
|
|
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.
|
|
+ }
|
|
+
|
|
+ /// Evict a stashed window from the scratchpad by its surface. Returns true
|
|
+ /// if one was removed. Needed on the destroy/unmap paths: a window whose
|
|
+ /// client dies *while stashed* is in no workspace, so `find_window_and_output`
|
|
+ /// (and therefore `remove_window`) never sees it — this is the only thing
|
|
+ /// that drops it. A shown scratchpad window lives in a workspace and is
|
|
+ /// handled by `remove_window` instead.
|
|
+ pub fn remove_from_scratchpad(&mut self, surface: &WlSurface) -> bool {
|
|
+ let before = self.scratchpad.len();
|
|
+ self.scratchpad
|
|
+ .retain(|rt| !rt.tile.window().is_wl_surface(surface));
|
|
+ self.scratchpad.len() != before
|
|
+ }
|
|
+
|
|
+ /// 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<W>) {
|
|
+ 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 +2562,24 @@ impl<W: LayoutElement> Layout<W> {
|
|
}
|
|
}
|
|
|
|
+ // 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::<String>::new();
|
|
|
|
diff --git a/src/layout/tests.rs b/src/layout/tests.rs
|
|
index 31265dd9..9037ff08 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<niri_config::LayoutPart>,
|
|
},
|
|
+ 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,169 @@ 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);
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn scratchpad_shows_on_the_focused_output() {
|
|
+ // First, confirm that while stashed the window lives on no monitor at all (checked against
|
|
+ // a standalone run so `check_ops`'s post-op invariant checks still see a fully-formed op
|
|
+ // sequence).
|
|
+ let stash_only_ops = [
|
|
+ Op::AddOutput(1),
|
|
+ Op::AddOutput(2),
|
|
+ Op::FocusOutput(1),
|
|
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
|
|
+ Op::ScratchpadStash(0),
|
|
+ ];
|
|
+ let stashed_layout = check_ops(stash_only_ops);
|
|
+ {
|
|
+ let MonitorSet::Normal { monitors, .. } = &stashed_layout.monitor_set else {
|
|
+ unreachable!()
|
|
+ };
|
|
+ assert!(
|
|
+ monitors.iter().all(|m| !m.windows().any(|w| *w.id() == 0)),
|
|
+ "stashed window must not be present on any monitor"
|
|
+ );
|
|
+ }
|
|
+
|
|
+ // Stash on output 1, then switch focus to output 2 before showing.
|
|
+ let ops = [
|
|
+ Op::AddOutput(1),
|
|
+ Op::AddOutput(2),
|
|
+ Op::FocusOutput(1),
|
|
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
|
|
+ Op::ScratchpadStash(0),
|
|
+ Op::FocusOutput(2),
|
|
+ Op::ScratchpadShow,
|
|
+ ];
|
|
+ let layout = check_ops(ops);
|
|
+
|
|
+ let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else {
|
|
+ unreachable!()
|
|
+ };
|
|
+
|
|
+ // The window must appear on whichever output is currently focused (output 2), not on the
|
|
+ // output it was originally stashed from (output 1).
|
|
+ for (idx, monitor) in monitors.iter().enumerate() {
|
|
+ let has_window = monitor.windows().any(|w| *w.id() == 0);
|
|
+ if idx == *active_monitor_idx {
|
|
+ assert!(has_window, "shown window should be on the focused output");
|
|
+ } else {
|
|
+ assert!(!has_window, "shown window should not be on a non-focused output");
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn scratchpad_shows_on_origin_output_when_focus_unchanged() {
|
|
+ let ops = [
|
|
+ Op::AddOutput(1),
|
|
+ Op::AddOutput(2),
|
|
+ Op::FocusOutput(1),
|
|
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
|
|
+ Op::ScratchpadStash(0),
|
|
+ // no focus change here
|
|
+ Op::ScratchpadShow,
|
|
+ ];
|
|
+ let layout = check_ops(ops);
|
|
+
|
|
+ let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else {
|
|
+ unreachable!()
|
|
+ };
|
|
+
|
|
+ // Focus never left output 1, so the window should reappear there.
|
|
+ assert_eq!(*active_monitor_idx, 0, "output 1 should still be active");
|
|
+ assert!(
|
|
+ monitors[*active_monitor_idx].windows().any(|w| *w.id() == 0),
|
|
+ "shown window should be on the still-focused origin output"
|
|
+ );
|
|
+}
|