1
0
Fork 0
forked from wry/wry

feat: implement declarative scratchpads

This commit is contained in:
atagen 2026-06-03 16:51:26 +10:00
parent d756c8a6a2
commit b6502e1d8a
17 changed files with 549 additions and 78 deletions

View file

@ -1118,6 +1118,14 @@ impl ConfigProxyHandler {
})
}
fn handle_seat_cycle_scratchpad(&self, seat: Seat, name: &str) -> Result<(), CphError> {
self.state.with_linear_layout_animations(|| {
let seat = self.get_seat(seat)?;
self.state.cycle_scratchpad(&seat, name);
Ok(())
})
}
fn handle_set_window_workspace(&self, window: Window, ws: Workspace) -> Result<(), CphError> {
let window = self.get_window(window)?;
let name = self.get_workspace(ws)?;
@ -3021,6 +3029,9 @@ impl ConfigProxyHandler {
ClientMessage::SeatToggleScratchpad { seat, name } => self
.handle_seat_toggle_scratchpad(seat, name)
.wrn("seat_toggle_scratchpad")?,
ClientMessage::SeatCycleScratchpad { seat, name } => self
.handle_seat_cycle_scratchpad(seat, name)
.wrn("seat_cycle_scratchpad")?,
ClientMessage::GetConnector { ty, idx } => {
self.handle_get_connector(ty, idx).wrn("get_connector")?
}

View file

@ -298,6 +298,13 @@ impl TestConfig {
})
}
pub fn cycle_scratchpad(&self, seat: SeatId, name: &str) -> TestResult {
self.send(ClientMessage::SeatCycleScratchpad {
seat: Seat(seat.raw() as _),
name,
})
}
fn clear(&self) {
unsafe {
if let Some(srv) = self.srv.take() {

View file

@ -1,7 +1,7 @@
use {
crate::{
it::{test_error::TestResult, testrun::TestRun},
tree::Node,
tree::{Node, ToplevelNodeBase},
},
std::rc::Rc,
};
@ -45,6 +45,63 @@ async fn test(run: Rc<TestRun>) -> TestResult {
client.sync().await;
tassert!(win2.tl.server.node_visible());
tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "3");
// Scratchpad windows are always shown floating.
tassert!(win2.tl.server.tl_data().parent_is_float.get());
// Park win2 again, then build a multi-window scratchpad and cycle it.
run.cfg.toggle_scratchpad(ds.seat.id(), "term")?;
client.sync().await;
tassert!(!win2.tl.server.node_visible());
// Build a three-window scratchpad. Each window is focused right after it is
// mapped, so sending the focused window parks them in a known order.
let cyc1 = client.create_window().await?;
cyc1.map2().await?;
run.cfg.send_to_scratchpad(ds.seat.id(), "cyc")?;
let cyc2 = client.create_window().await?;
cyc2.map2().await?;
run.cfg.send_to_scratchpad(ds.seat.id(), "cyc")?;
let cyc3 = client.create_window().await?;
cyc3.map2().await?;
run.cfg.send_to_scratchpad(ds.seat.id(), "cyc")?;
client.sync().await;
tassert!(!cyc1.tl.server.node_visible());
tassert!(!cyc2.tl.server.node_visible());
tassert!(!cyc3.tl.server.node_visible());
// Nothing shown: cycle brings up the first window (insertion order: cyc1).
run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?;
client.sync().await;
tassert!(cyc1.tl.server.node_visible());
tassert!(!cyc2.tl.server.node_visible());
tassert!(!cyc3.tl.server.node_visible());
// Scratchpad windows are always shown floating.
tassert!(cyc1.tl.server.tl_data().parent_is_float.get());
// Cycle advances one at a time.
run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?;
client.sync().await;
tassert!(!cyc1.tl.server.node_visible());
tassert!(cyc2.tl.server.node_visible());
tassert!(!cyc3.tl.server.node_visible());
run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?;
client.sync().await;
tassert!(!cyc1.tl.server.node_visible());
tassert!(!cyc2.tl.server.node_visible());
tassert!(cyc3.tl.server.node_visible());
// On the final window, the next cycle hides everything.
run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?;
client.sync().await;
tassert!(!cyc1.tl.server.node_visible());
tassert!(!cyc2.tl.server.node_visible());
tassert!(!cyc3.tl.server.node_visible());
// And it wraps back to the first window.
run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?;
client.sync().await;
tassert!(cyc1.tl.server.node_visible());
Ok(())
}

View file

@ -114,7 +114,7 @@ use {
tree::{
ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode,
FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode,
PlaceholderNode, ScratchpadToplevelState, TearingMode, TileState, ToplevelData,
PlaceholderNode, TearingMode, TileState, ToplevelData,
ToplevelIdentifier, ToplevelNode, ToplevelNodeBase, Transform, VrrMode,
WorkspaceDisplayOrder, WorkspaceNode, WorkspaceNodeId, WsMoveConfig,
generic_node_visitor, move_ws_to_output, toplevel_hide_for_scratchpad,
@ -466,7 +466,6 @@ pub struct ScratchpadEntry {
node: Weak<dyn ToplevelNode>,
identifier: ToplevelIdentifier,
hidden: Cell<bool>,
restore: RefCell<Option<ScratchpadToplevelState>>,
}
impl ScratchpadEntry {
@ -1053,17 +1052,14 @@ impl State {
return;
}
let identifier = node.tl_data().identifier.get();
if !toplevel_hide_for_scratchpad(node.clone()) {
return;
}
let entry = Rc::new(ScratchpadEntry {
node: Rc::downgrade(&node),
identifier,
hidden: Cell::new(false),
restore: Default::default(),
hidden: Cell::new(true),
});
let Some(restore) = toplevel_hide_for_scratchpad(node) else {
return;
};
*entry.restore.borrow_mut() = Some(restore);
entry.hidden.set(true);
{
let mut scratchpads = self.scratchpads.borrow_mut();
for entries in scratchpads.values_mut() {
@ -1072,7 +1068,7 @@ impl State {
scratchpads
.entry(name.to_string())
.or_default()
.push(entry.clone());
.push(entry);
}
self.tree_changed();
}
@ -1084,29 +1080,19 @@ impl State {
return;
};
entries.retain(|entry| entry.alive());
// Prefer the currently-shown window; otherwise act on the most recent.
entries
.iter()
.rev()
.find(|entry| {
!entry.hidden.get() && entry.node().is_some_and(|node| node.node_visible())
})
.find(|entry| !entry.hidden.get())
.or_else(|| entries.last())
.cloned()
.or_else(|| {
entries
.iter()
.rev()
.find(|entry| {
entry.hidden.get()
|| entry.node().is_some_and(|node| !node.node_visible())
})
.cloned()
})
};
let Some(entry) = entry else {
return;
};
if entry.hidden.get() {
self.show_scratchpad_entry(seat, &entry);
self.show_scratchpad_entry(seat, name, &entry);
} else if entry.node().is_some_and(|node| !node.node_visible()) {
self.move_scratchpad_entry_to_current_workspace(seat, &entry);
} else {
@ -1114,12 +1100,39 @@ impl State {
}
}
/// Cycles through the windows of a scratchpad, one at a time:
/// nothing shown -> first window -> ... -> last window -> nothing shown.
pub fn cycle_scratchpad(self: &Rc<Self>, seat: &Rc<WlSeatGlobal>, name: &str) {
let (current, next) = {
let mut scratchpads = self.scratchpads.borrow_mut();
let Some(entries) = scratchpads.get_mut(name) else {
return;
};
entries.retain(|entry| entry.alive());
match entries.iter().position(|entry| !entry.hidden.get()) {
// Nothing shown yet: bring up the first window.
None => (None, entries.first().cloned()),
// Hide the shown window and advance; on the last window, `next`
// is `None`, so the scratchpad toggles off.
Some(i) => (entries.get(i).cloned(), entries.get(i + 1).cloned()),
}
};
if let Some(current) = &current {
self.hide_scratchpad_entry(current);
}
if let Some(next) = &next {
self.show_scratchpad_entry(seat, name, next);
}
}
fn hide_scratchpad_entry(self: &Rc<Self>, entry: &Rc<ScratchpadEntry>) {
if entry.hidden.get() {
return;
}
let Some(node) = entry.node() else {
return;
};
if let Some(restore) = toplevel_hide_for_scratchpad(node) {
*entry.restore.borrow_mut() = Some(restore);
if toplevel_hide_for_scratchpad(node) {
entry.hidden.set(true);
self.tree_changed();
}
@ -1128,6 +1141,7 @@ impl State {
fn show_scratchpad_entry(
self: &Rc<Self>,
seat: &Rc<WlSeatGlobal>,
name: &str,
entry: &Rc<ScratchpadEntry>,
) {
if !entry.hidden.get() {
@ -1136,12 +1150,22 @@ impl State {
let Some(node) = entry.node() else {
return;
};
let restore = entry.restore.borrow();
let Some(restore) = restore.as_ref() else {
return;
// Only one window of a scratchpad is visible at a time.
let siblings: Vec<_> = {
let scratchpads = self.scratchpads.borrow();
scratchpads
.get(name)
.into_iter()
.flatten()
.filter(|sibling| !Rc::ptr_eq(sibling, entry) && !sibling.hidden.get())
.cloned()
.collect()
};
for sibling in siblings {
self.hide_scratchpad_entry(&sibling);
}
let ws = seat.get_fallback_output().ensure_workspace();
toplevel_restore_from_scratchpad(self, node.clone(), &ws, restore);
toplevel_restore_from_scratchpad(self, node.clone(), &ws);
entry.hidden.set(false);
node.node_do_focus(seat, Direction::Unspecified);
seat.maybe_schedule_warp_mouse_to_focus();

View file

@ -1324,29 +1324,25 @@ pub fn toplevel_set_workspace(state: &Rc<State>, tl: Rc<dyn ToplevelNode>, ws: &
}
}
pub struct ScratchpadToplevelState {
pub floating: bool,
pub fullscreen: bool,
pub workspace: Option<Rc<WorkspaceNode>>,
}
pub fn toplevel_hide_for_scratchpad(tl: Rc<dyn ToplevelNode>) -> Option<ScratchpadToplevelState> {
/// Removes a toplevel from the tree so it can be parked in a scratchpad.
///
/// Returns `true` if the window was hidden. A placeholder, a window without a
/// parent, or a window that refuses to leave fullscreen cannot be parked.
pub fn toplevel_hide_for_scratchpad(tl: Rc<dyn ToplevelNode>) -> bool {
if tl.node_is_placeholder() {
return None;
return false;
}
let data = tl.tl_data();
let scratchpad_state = ScratchpadToplevelState {
floating: data.parent_is_float.get(),
fullscreen: data.is_fullscreen.get(),
workspace: data.workspace.get(),
};
let workspace = data.workspace.get();
if data.is_fullscreen.get() {
tl.clone().tl_set_fullscreen(false, None);
if data.is_fullscreen.get() {
return None;
return false;
}
}
let parent = data.parent.get()?;
let Some(parent) = data.parent.get() else {
return false;
};
let kb_foci = collect_kb_foci(tl.clone());
parent.cnode_remove_child2(&*tl, true);
data.parent.take();
@ -1358,29 +1354,23 @@ pub fn toplevel_hide_for_scratchpad(tl: Rc<dyn ToplevelNode>) -> Option<Scratchp
data.property_changed(TL_CHANGED_WORKSPACE);
}
tl.tl_set_visible(false);
if let Some(workspace) = &scratchpad_state.workspace {
if let Some(workspace) = &workspace {
for seat in kb_foci {
workspace
.clone()
.node_do_focus(&seat, Direction::Unspecified);
}
}
Some(scratchpad_state)
true
}
/// Maps a parked scratchpad window back onto `ws`. Scratchpad windows always
/// return floating, regardless of how they were laid out before parking.
pub fn toplevel_restore_from_scratchpad(
state: &Rc<State>,
tl: Rc<dyn ToplevelNode>,
ws: &Rc<WorkspaceNode>,
scratchpad_state: &ScratchpadToplevelState,
) {
if scratchpad_state.floating {
let (width, height) = tl.tl_data().float_size(ws);
state.map_floating(tl.clone(), width, height, ws, None);
} else {
state.map_tiled_on(tl.clone(), ws);
}
if scratchpad_state.fullscreen && ws.fullscreen.is_none() {
tl.tl_set_fullscreen(true, Some(ws.clone()));
}
let (width, height) = tl.tl_data().float_size(ws);
state.map_floating(tl.clone(), width, height, ws, None);
}