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 bd715e8af5
commit e3c323c296
17 changed files with 549 additions and 78 deletions

View file

@ -648,6 +648,10 @@ impl ConfigClient {
self.send(&ClientMessage::SeatToggleScratchpad { seat, name }); self.send(&ClientMessage::SeatToggleScratchpad { seat, name });
} }
pub fn seat_cycle_scratchpad(&self, seat: Seat, name: &str) {
self.send(&ClientMessage::SeatCycleScratchpad { seat, name });
}
pub fn window_send_to_scratchpad(&self, window: Window, name: &str) { pub fn window_send_to_scratchpad(&self, window: Window, name: &str) {
self.send(&ClientMessage::WindowSendToScratchpad { window, name }); self.send(&ClientMessage::WindowSendToScratchpad { window, name });
} }

View file

@ -294,6 +294,10 @@ pub enum ClientMessage<'a> {
seat: Seat, seat: Seat,
name: &'a str, name: &'a str,
}, },
SeatCycleScratchpad {
seat: Seat,
name: &'a str,
},
GetTimer { GetTimer {
name: &'a str, name: &'a str,
}, },

View file

@ -477,11 +477,22 @@ impl Seat {
/// ///
/// If the scratchpad has a visible window, that window is hidden. Otherwise, the /// If the scratchpad has a visible window, that window is hidden. Otherwise, the
/// most recently hidden window in the scratchpad is shown on the current workspace. /// most recently hidden window in the scratchpad is shown on the current workspace.
/// Scratchpad windows are always shown floating.
/// Use an empty string for the default scratchpad. /// Use an empty string for the default scratchpad.
pub fn toggle_scratchpad(self, name: &str) { pub fn toggle_scratchpad(self, name: &str) {
get!().seat_toggle_scratchpad(self, name) get!().seat_toggle_scratchpad(self, name)
} }
/// Cycles through the windows of a scratchpad, one at a time.
///
/// With nothing shown, the first window is brought up; each further invocation
/// hides the current window and shows the next; after the last window the
/// scratchpad is hidden again. Scratchpad windows are always shown floating.
/// Use an empty string for the default scratchpad.
pub fn cycle_scratchpad(self, name: &str) {
get!().seat_cycle_scratchpad(self, name)
}
/// Toggles whether the currently focused window is fullscreen. /// Toggles whether the currently focused window is fullscreen.
pub fn toggle_fullscreen(self) { pub fn toggle_fullscreen(self) {
let c = get!(); let c = get!();

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> { fn handle_set_window_workspace(&self, window: Window, ws: Workspace) -> Result<(), CphError> {
let window = self.get_window(window)?; let window = self.get_window(window)?;
let name = self.get_workspace(ws)?; let name = self.get_workspace(ws)?;
@ -3021,6 +3029,9 @@ impl ConfigProxyHandler {
ClientMessage::SeatToggleScratchpad { seat, name } => self ClientMessage::SeatToggleScratchpad { seat, name } => self
.handle_seat_toggle_scratchpad(seat, name) .handle_seat_toggle_scratchpad(seat, name)
.wrn("seat_toggle_scratchpad")?, .wrn("seat_toggle_scratchpad")?,
ClientMessage::SeatCycleScratchpad { seat, name } => self
.handle_seat_cycle_scratchpad(seat, name)
.wrn("seat_cycle_scratchpad")?,
ClientMessage::GetConnector { ty, idx } => { ClientMessage::GetConnector { ty, idx } => {
self.handle_get_connector(ty, idx).wrn("get_connector")? 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) { fn clear(&self) {
unsafe { unsafe {
if let Some(srv) = self.srv.take() { if let Some(srv) = self.srv.take() {

View file

@ -1,7 +1,7 @@
use { use {
crate::{ crate::{
it::{test_error::TestResult, testrun::TestRun}, it::{test_error::TestResult, testrun::TestRun},
tree::Node, tree::{Node, ToplevelNodeBase},
}, },
std::rc::Rc, std::rc::Rc,
}; };
@ -45,6 +45,63 @@ async fn test(run: Rc<TestRun>) -> TestResult {
client.sync().await; client.sync().await;
tassert!(win2.tl.server.node_visible()); tassert!(win2.tl.server.node_visible());
tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "3"); 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(()) Ok(())
} }

View file

@ -113,7 +113,7 @@ use {
tree::{ tree::{
ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode, ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode,
FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode, FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode,
PlaceholderNode, ScratchpadToplevelState, TearingMode, TileState, ToplevelData, PlaceholderNode, TearingMode, TileState, ToplevelData,
ToplevelIdentifier, ToplevelNode, ToplevelNodeBase, Transform, VrrMode, ToplevelIdentifier, ToplevelNode, ToplevelNodeBase, Transform, VrrMode,
WorkspaceDisplayOrder, WorkspaceNode, WorkspaceNodeId, WsMoveConfig, WorkspaceDisplayOrder, WorkspaceNode, WorkspaceNodeId, WsMoveConfig,
generic_node_visitor, move_ws_to_output, toplevel_hide_for_scratchpad, generic_node_visitor, move_ws_to_output, toplevel_hide_for_scratchpad,
@ -462,7 +462,6 @@ pub struct ScratchpadEntry {
node: Weak<dyn ToplevelNode>, node: Weak<dyn ToplevelNode>,
identifier: ToplevelIdentifier, identifier: ToplevelIdentifier,
hidden: Cell<bool>, hidden: Cell<bool>,
restore: RefCell<Option<ScratchpadToplevelState>>,
} }
impl ScratchpadEntry { impl ScratchpadEntry {
@ -1055,17 +1054,14 @@ impl State {
return; return;
} }
let identifier = node.tl_data().identifier.get(); let identifier = node.tl_data().identifier.get();
if !toplevel_hide_for_scratchpad(node.clone()) {
return;
}
let entry = Rc::new(ScratchpadEntry { let entry = Rc::new(ScratchpadEntry {
node: Rc::downgrade(&node), node: Rc::downgrade(&node),
identifier, identifier,
hidden: Cell::new(false), hidden: Cell::new(true),
restore: Default::default(),
}); });
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(); let mut scratchpads = self.scratchpads.borrow_mut();
for entries in scratchpads.values_mut() { for entries in scratchpads.values_mut() {
@ -1074,7 +1070,7 @@ impl State {
scratchpads scratchpads
.entry(name.to_string()) .entry(name.to_string())
.or_default() .or_default()
.push(entry.clone()); .push(entry);
} }
self.tree_changed(); self.tree_changed();
} }
@ -1086,29 +1082,19 @@ impl State {
return; return;
}; };
entries.retain(|entry| entry.alive()); entries.retain(|entry| entry.alive());
// Prefer the currently-shown window; otherwise act on the most recent.
entries entries
.iter() .iter()
.rev() .rev()
.find(|entry| { .find(|entry| !entry.hidden.get())
!entry.hidden.get() && entry.node().is_some_and(|node| node.node_visible()) .or_else(|| entries.last())
})
.cloned() .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 { let Some(entry) = entry else {
return; return;
}; };
if entry.hidden.get() { 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()) { } else if entry.node().is_some_and(|node| !node.node_visible()) {
self.move_scratchpad_entry_to_current_workspace(seat, &entry); self.move_scratchpad_entry_to_current_workspace(seat, &entry);
} else { } else {
@ -1116,12 +1102,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>) { fn hide_scratchpad_entry(self: &Rc<Self>, entry: &Rc<ScratchpadEntry>) {
if entry.hidden.get() {
return;
}
let Some(node) = entry.node() else { let Some(node) = entry.node() else {
return; return;
}; };
if let Some(restore) = toplevel_hide_for_scratchpad(node) { if toplevel_hide_for_scratchpad(node) {
*entry.restore.borrow_mut() = Some(restore);
entry.hidden.set(true); entry.hidden.set(true);
self.tree_changed(); self.tree_changed();
} }
@ -1130,6 +1143,7 @@ impl State {
fn show_scratchpad_entry( fn show_scratchpad_entry(
self: &Rc<Self>, self: &Rc<Self>,
seat: &Rc<WlSeatGlobal>, seat: &Rc<WlSeatGlobal>,
name: &str,
entry: &Rc<ScratchpadEntry>, entry: &Rc<ScratchpadEntry>,
) { ) {
if !entry.hidden.get() { if !entry.hidden.get() {
@ -1138,12 +1152,22 @@ impl State {
let Some(node) = entry.node() else { let Some(node) = entry.node() else {
return; return;
}; };
let restore = entry.restore.borrow(); // Only one window of a scratchpad is visible at a time.
let Some(restore) = restore.as_ref() else { let siblings: Vec<_> = {
return; 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(); 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); entry.hidden.set(false);
node.node_do_focus(seat, Direction::Unspecified); node.node_do_focus(seat, Direction::Unspecified);
seat.maybe_schedule_warp_mouse_to_focus(); 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 { /// Removes a toplevel from the tree so it can be parked in a scratchpad.
pub floating: bool, ///
pub fullscreen: bool, /// Returns `true` if the window was hidden. A placeholder, a window without a
pub workspace: Option<Rc<WorkspaceNode>>, /// parent, or a window that refuses to leave fullscreen cannot be parked.
} pub fn toplevel_hide_for_scratchpad(tl: Rc<dyn ToplevelNode>) -> bool {
pub fn toplevel_hide_for_scratchpad(tl: Rc<dyn ToplevelNode>) -> Option<ScratchpadToplevelState> {
if tl.node_is_placeholder() { if tl.node_is_placeholder() {
return None; return false;
} }
let data = tl.tl_data(); let data = tl.tl_data();
let scratchpad_state = ScratchpadToplevelState { let workspace = data.workspace.get();
floating: data.parent_is_float.get(),
fullscreen: data.is_fullscreen.get(),
workspace: data.workspace.get(),
};
if data.is_fullscreen.get() { if data.is_fullscreen.get() {
tl.clone().tl_set_fullscreen(false, None); tl.clone().tl_set_fullscreen(false, None);
if data.is_fullscreen.get() { 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()); let kb_foci = collect_kb_foci(tl.clone());
parent.cnode_remove_child2(&*tl, true); parent.cnode_remove_child2(&*tl, true);
data.parent.take(); 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); data.property_changed(TL_CHANGED_WORKSPACE);
} }
tl.tl_set_visible(false); tl.tl_set_visible(false);
if let Some(workspace) = &scratchpad_state.workspace { if let Some(workspace) = &workspace {
for seat in kb_foci { for seat in kb_foci {
workspace workspace
.clone() .clone()
.node_do_focus(&seat, Direction::Unspecified); .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( pub fn toplevel_restore_from_scratchpad(
state: &Rc<State>, state: &Rc<State>,
tl: Rc<dyn ToplevelNode>, tl: Rc<dyn ToplevelNode>,
ws: &Rc<WorkspaceNode>, ws: &Rc<WorkspaceNode>,
scratchpad_state: &ScratchpadToplevelState,
) { ) {
if scratchpad_state.floating { let (width, height) = tl.tl_data().float_size(ws);
let (width, height) = tl.tl_data().float_size(ws); state.map_floating(tl.clone(), width, height, ws, None);
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()));
}
} }

View file

@ -66,6 +66,7 @@ pub enum SimpleCommand {
SetFullscreen(bool), SetFullscreen(bool),
SendToScratchpad, SendToScratchpad,
ToggleScratchpad, ToggleScratchpad,
CycleScratchpad,
Forward(bool), Forward(bool),
EnableWindowManagement(bool), EnableWindowManagement(bool),
SetFloatAboveFullscreen(bool), SetFloatAboveFullscreen(bool),
@ -138,6 +139,9 @@ pub enum Action {
ToggleScratchpad { ToggleScratchpad {
name: String, name: String,
}, },
CycleScratchpad {
name: String,
},
Multi { Multi {
actions: Vec<Action>, actions: Vec<Action>,
}, },

View file

@ -41,6 +41,7 @@ pub mod modified_keysym;
mod output; mod output;
mod output_match; mod output_match;
mod repeat_rate; mod repeat_rate;
mod scratchpad;
pub mod shortcuts; pub mod shortcuts;
mod simple_im; mod simple_im;
mod status; mod status;

View file

@ -119,6 +119,7 @@ impl ActionParser<'_> {
"exit-fullscreen" => SetFullscreen(false), "exit-fullscreen" => SetFullscreen(false),
"send-to-scratchpad" => SendToScratchpad, "send-to-scratchpad" => SendToScratchpad,
"toggle-scratchpad" => ToggleScratchpad, "toggle-scratchpad" => ToggleScratchpad,
"cycle-scratchpad" => CycleScratchpad,
"focus-parent" => FocusParent, "focus-parent" => FocusParent,
"close" => Close, "close" => Close,
"disable-pointer-constraint" => DisablePointerConstraint, "disable-pointer-constraint" => DisablePointerConstraint,
@ -242,6 +243,15 @@ impl ActionParser<'_> {
Ok(Action::ToggleScratchpad { name }) Ok(Action::ToggleScratchpad { name })
} }
fn parse_cycle_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult<Self> {
let name = ext
.extract(opt(str("name")))?
.map(|name| name.value)
.unwrap_or("")
.to_string();
Ok(Action::CycleScratchpad { name })
}
fn parse_configure_connector(&mut self, ext: &mut Extractor<'_>) -> ParseResult<Self> { fn parse_configure_connector(&mut self, ext: &mut Extractor<'_>) -> ParseResult<Self> {
let con = ext let con = ext
.extract(val("connector"))? .extract(val("connector"))?
@ -573,6 +583,7 @@ impl Parser for ActionParser<'_> {
"move-to-workspace" => self.parse_move_to_workspace(&mut ext), "move-to-workspace" => self.parse_move_to_workspace(&mut ext),
"send-to-scratchpad" => self.parse_send_to_scratchpad(&mut ext), "send-to-scratchpad" => self.parse_send_to_scratchpad(&mut ext),
"toggle-scratchpad" => self.parse_toggle_scratchpad(&mut ext), "toggle-scratchpad" => self.parse_toggle_scratchpad(&mut ext),
"cycle-scratchpad" => self.parse_cycle_scratchpad(&mut ext),
"configure-connector" => self.parse_configure_connector(&mut ext), "configure-connector" => self.parse_configure_connector(&mut ext),
"configure-input" => self.parse_configure_input(&mut ext), "configure-input" => self.parse_configure_input(&mut ext),
"configure-output" => self.parse_configure_output(&mut ext), "configure-output" => self.parse_configure_output(&mut ext),

View file

@ -28,6 +28,7 @@ use {
log_level::LogLevelParser, log_level::LogLevelParser,
output::OutputsParser, output::OutputsParser,
repeat_rate::RepeatRateParser, repeat_rate::RepeatRateParser,
scratchpad::ScratchpadsParser,
shortcuts::{ shortcuts::{
ComplexShortcutsParser, ShortcutsParser, ShortcutsParserError, ComplexShortcutsParser, ShortcutsParser, ShortcutsParserError,
parse_modified_keysym_str, parse_modified_keysym_str,
@ -570,6 +571,13 @@ impl Parser for ConfigParser<'_> {
} }
} }
} }
let mut scratchpads = vec![];
if let Some(value) = scratchpads_val {
match value.parse(&mut ScratchpadsParser(self.0)) {
Ok(v) => scratchpads = v,
Err(e) => log::warn!("Could not parse the scratchpads: {}", self.0.error(e)),
}
}
Ok(Config { Ok(Config {
keymap, keymap,
repeat_rate, repeat_rate,

View file

@ -0,0 +1,87 @@
use {
crate::{
config::{
Scratchpad,
context::Context,
extractor::{Extractor, ExtractorError, opt, str, val},
parser::{DataType, ParseResult, Parser, UnexpectedDataType},
parsers::exec::{ExecParser, ExecParserError},
},
toml::{
toml_span::{Span, Spanned},
toml_value::Value,
},
},
indexmap::IndexMap,
thiserror::Error,
};
#[derive(Debug, Error)]
pub enum ScratchpadParserError {
#[error(transparent)]
Expected(#[from] UnexpectedDataType),
#[error(transparent)]
Extract(#[from] ExtractorError),
#[error(transparent)]
Exec(#[from] ExecParserError),
}
pub struct ScratchpadParser<'a>(pub &'a Context<'a>);
impl Parser for ScratchpadParser<'_> {
type Value = Scratchpad;
type Error = ScratchpadParserError;
const EXPECTED: &'static [DataType] = &[DataType::Table];
fn parse_table(
&mut self,
span: Span,
table: &IndexMap<Spanned<String>, Spanned<Value>>,
) -> ParseResult<Self> {
let mut ext = Extractor::new(self.0, span, table);
let (name, exec_val) = ext.extract((str("name"), opt(val("exec"))))?;
let exec = match exec_val {
None => None,
Some(e) => Some(e.parse_map(&mut ExecParser(self.0))?),
};
Ok(Scratchpad {
name: name.value.to_string(),
exec,
})
}
}
pub struct ScratchpadsParser<'a>(pub &'a Context<'a>);
impl Parser for ScratchpadsParser<'_> {
type Value = Vec<Scratchpad>;
type Error = ScratchpadParserError;
const EXPECTED: &'static [DataType] = &[DataType::Table, DataType::Array];
fn parse_array(&mut self, _span: Span, array: &[Spanned<Value>]) -> ParseResult<Self> {
let mut res = vec![];
for el in array {
match el.parse(&mut ScratchpadParser(self.0)) {
Ok(o) => res.push(o),
Err(e) => {
log::warn!("Could not parse scratchpad: {}", self.0.error(e));
}
}
}
Ok(res)
}
fn parse_table(
&mut self,
span: Span,
table: &IndexMap<Spanned<String>, Spanned<Value>>,
) -> ParseResult<Self> {
log::warn!(
"`scratchpads` value should be an array: {}",
self.0.error3(span)
);
ScratchpadParser(self.0)
.parse_table(span, table)
.map(|v| vec![v])
}
}

View file

@ -15,7 +15,7 @@ use {
config::{ config::{
Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice, Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice,
ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output,
OutputMatch, SimpleCommand, Status, Theme, WindowRule, parse_config, OutputMatch, SimpleCommand, Status, Theme, WindowMatch, WindowRule, parse_config,
}, },
rules::{MatcherTemp, RuleMapper}, rules::{MatcherTemp, RuleMapper},
shortcuts::ModeState, shortcuts::ModeState,
@ -175,6 +175,7 @@ impl Action {
SimpleCommand::SetFullscreen(b) => window_or_seat!(s, s.set_fullscreen(b)), SimpleCommand::SetFullscreen(b) => window_or_seat!(s, s.set_fullscreen(b)),
SimpleCommand::SendToScratchpad => window_or_seat!(s, s.send_to_scratchpad("")), SimpleCommand::SendToScratchpad => window_or_seat!(s, s.send_to_scratchpad("")),
SimpleCommand::ToggleScratchpad => b.new(move || s.toggle_scratchpad("")), SimpleCommand::ToggleScratchpad => b.new(move || s.toggle_scratchpad("")),
SimpleCommand::CycleScratchpad => b.new(move || s.cycle_scratchpad("")),
SimpleCommand::FocusParent => b.new(move || s.focus_parent()), SimpleCommand::FocusParent => b.new(move || s.focus_parent()),
SimpleCommand::Close => window_or_seat!(s, s.close()), SimpleCommand::Close => window_or_seat!(s, s.close()),
SimpleCommand::DisablePointerConstraint => { SimpleCommand::DisablePointerConstraint => {
@ -310,6 +311,7 @@ impl Action {
} }
Action::SendToScratchpad { name } => window_or_seat!(s, s.send_to_scratchpad(&name)), Action::SendToScratchpad { name } => window_or_seat!(s, s.send_to_scratchpad(&name)),
Action::ToggleScratchpad { name } => b.new(move || s.toggle_scratchpad(&name)), Action::ToggleScratchpad { name } => b.new(move || s.toggle_scratchpad(&name)),
Action::CycleScratchpad { name } => b.new(move || s.cycle_scratchpad(&name)),
Action::ConfigureConnector { con } => b.new(move || { Action::ConfigureConnector { con } => b.new(move || {
for c in connectors() { for c in connectors() {
if con.match_.matches(c) { if con.match_.matches(c) {
@ -1461,6 +1463,43 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc<Persistent
window: Default::default(), window: Default::default(),
}); });
state.clear_modes_after_reload(); state.clear_modes_after_reload();
// Desugar `[[scratchpads]]` into spawn-on-graphics-init plus an internal
// window rule that parks the spawned window. Each spawned process gets a
// unique tag so only its own windows are captured, never other windows of
// the same application.
if !config.scratchpads.is_empty() {
let mut spawn_actions = vec![];
for (i, sp) in config.scratchpads.drain(..).enumerate() {
let Some(mut exec) = sp.exec else {
continue;
};
let tag = exec
.tag
.clone()
.unwrap_or_else(|| format!("__scratchpad.{i}.{}", sp.name));
exec.tag = Some(tag.clone());
spawn_actions.push(Action::Exec { exec });
config.window_rules.push(WindowRule {
name: None,
match_: WindowMatch {
tag: Some(tag),
..Default::default()
},
action: Some(Action::SendToScratchpad { name: sp.name }),
latch: None,
auto_focus: None,
initial_tile_state: None,
});
}
if !spawn_actions.is_empty() {
let mut actions = Vec::with_capacity(spawn_actions.len() + 1);
if let Some(existing) = config.on_graphics_initialized.take() {
actions.push(existing);
}
actions.extend(spawn_actions);
config.on_graphics_initialized = Some(Action::Multi { actions });
}
}
let (client_rules, client_rule_mapper) = state.create_rules(&config.client_rules); let (client_rules, client_rule_mapper) = state.create_rules(&config.client_rules);
persistent.client_rules.set(client_rules); persistent.client_rules.set(client_rules);
*state.persistent.client_rule_mapper.borrow_mut() = Some(client_rule_mapper); *state.persistent.client_rule_mapper.borrow_mut() = Some(client_rule_mapper);

View file

@ -163,7 +163,7 @@
] ]
}, },
{ {
"description": "Sends the currently focused window to a scratchpad and hides it.\n\nIf `name` is omitted, the default scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-shift-minus = { type = \"send-to-scratchpad\", name = \"terminal\" }\n ```\n", "description": "Sends the currently focused window to a scratchpad and hides it.\n\nA scratchpad can hold any number of windows. If `name` is omitted, the\ndefault scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-shift-minus = { type = \"send-to-scratchpad\", name = \"terminal\" }\n ```\n",
"type": "object", "type": "object",
"properties": { "properties": {
"type": { "type": {
@ -179,7 +179,7 @@
] ]
}, },
{ {
"description": "Toggles a scratchpad.\n\nIf the scratchpad has a visible window, that window is hidden. Otherwise, the\nmost recently hidden window in the scratchpad is shown on the current workspace.\nIf `name` is omitted, the default scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-minus = { type = \"toggle-scratchpad\", name = \"terminal\" }\n ```\n", "description": "Toggles a scratchpad.\n\nIf the scratchpad has a visible window, that window is hidden. Otherwise, the\nmost recently hidden window in the scratchpad is shown on the current workspace.\nOnly one window of a scratchpad is shown at a time, and scratchpad windows are\nalways shown floating. If `name` is omitted, the default scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-minus = { type = \"toggle-scratchpad\", name = \"terminal\" }\n ```\n",
"type": "object", "type": "object",
"properties": { "properties": {
"type": { "type": {
@ -194,6 +194,22 @@
"type" "type"
] ]
}, },
{
"description": "Cycles through the windows of a scratchpad, one at a time.\n\nWith no window shown, the first window is brought up. Each further invocation\nhides the current window and shows the next; after the last window the\nscratchpad is hidden again. Scratchpad windows are always shown floating.\nIf `name` is omitted, the default scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-minus = { type = \"cycle-scratchpad\", name = \"terminal\" }\n ```\n",
"type": "object",
"properties": {
"type": {
"const": "cycle-scratchpad"
},
"name": {
"type": "string",
"description": "The name of the scratchpad."
}
},
"required": [
"type"
]
},
{ {
"description": "Moves a workspace to a different output.\n\n- Example 1: Move a specific workspace to a named output\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", workspace = \"1\", output.name = \"right\" }\n ```\n\n- Example 2: Move the current workspace to a named output\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", output.name = \"right\" }\n ```\n\n- Example 3: Move the current workspace to the output on the right (directional)\n\n ```toml\n [shortcuts]\n \"logo+ctrl+shift+Right\" = { type = \"move-to-output\", direction = \"right\" }\n \"logo+ctrl+shift+Left\" = { type = \"move-to-output\", direction = \"left\" }\n \"logo+ctrl+shift+Up\" = { type = \"move-to-output\", direction = \"up\" }\n \"logo+ctrl+shift+Down\" = { type = \"move-to-output\", direction = \"down\" }\n ```\n", "description": "Moves a workspace to a different output.\n\n- Example 1: Move a specific workspace to a named output\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", workspace = \"1\", output.name = \"right\" }\n ```\n\n- Example 2: Move the current workspace to a named output\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", output.name = \"right\" }\n ```\n\n- Example 3: Move the current workspace to the output on the right (directional)\n\n ```toml\n [shortcuts]\n \"logo+ctrl+shift+Right\" = { type = \"move-to-output\", direction = \"right\" }\n \"logo+ctrl+shift+Left\" = { type = \"move-to-output\", direction = \"left\" }\n \"logo+ctrl+shift+Up\" = { type = \"move-to-output\", direction = \"up\" }\n \"logo+ctrl+shift+Down\" = { type = \"move-to-output\", direction = \"down\" }\n ```\n",
"type": "object", "type": "object",
@ -1272,6 +1288,14 @@
"egui": { "egui": {
"description": "Sets the egui settings of the compositor.\n", "description": "Sets the egui settings of the compositor.\n",
"$ref": "#/$defs/Egui" "$ref": "#/$defs/Egui"
},
"scratchpads": {
"type": "array",
"description": "An array of pre-configured scratchpads.\n\nEach entry launches a program when the graphics are first initialized and\nimmediately parks its window in the named scratchpad. The window is captured\nvia a unique tag attached to the spawned process, so other windows of the\nsame application are never affected.\n\nUse a `toggle-scratchpad` or `cycle-scratchpad` action to bring the windows\nup; they are always shown floating.\n\n- Example:\n\n ```toml\n [[scratchpads]]\n name = \"term\"\n exec = \"foot\"\n\n [[scratchpads]]\n name = \"notes\"\n exec = [\"obsidian\"]\n ```\n",
"items": {
"description": "",
"$ref": "#/$defs/Scratchpad"
}
} }
}, },
"required": [] "required": []
@ -2086,6 +2110,23 @@
}, },
"required": [] "required": []
}, },
"Scratchpad": {
"description": "A pre-configured scratchpad whose program is launched at startup and parked\nin the scratchpad.\n\n- Example:\n\n ```toml\n [[scratchpads]]\n name = \"term\"\n exec = \"foot\"\n ```\n",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the scratchpad that the spawned window is parked in."
},
"exec": {
"description": "The program to launch when the graphics are first initialized.\n\nIf omitted, no program is launched and the scratchpad is only created on\ndemand by `send-to-scratchpad`.\n",
"$ref": "#/$defs/Exec"
}
},
"required": [
"name"
]
},
"SimpleActionName": { "SimpleActionName": {
"type": "string", "type": "string",
"description": "The name of a `simple` Action.\n\nWhen used inside a window rule, the following actions apply to the matched window\ninstead fo the focused window:\n\n- `move-left`\n- `move-down`\n- `move-up`\n- `move-right`\n- `toggle-fullscreen`\n- `enter-fullscreen`\n- `exit-fullscreen`\n- `close`\n- `toggle-floating`\n- `float`\n- `tile`\n- `toggle-float-pinned`\n- `pin-float`\n- `unpin-float`\n\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-q = \"quit\"\n ```\n", "description": "The name of a `simple` Action.\n\nWhen used inside a window rule, the following actions apply to the matched window\ninstead fo the focused window:\n\n- `move-left`\n- `move-down`\n- `move-up`\n- `move-right`\n- `toggle-fullscreen`\n- `enter-fullscreen`\n- `exit-fullscreen`\n- `close`\n- `toggle-floating`\n- `float`\n- `tile`\n- `toggle-float-pinned`\n- `pin-float`\n- `unpin-float`\n\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-q = \"quit\"\n ```\n",
@ -2112,6 +2153,7 @@
"exit-fullscreen", "exit-fullscreen",
"send-to-scratchpad", "send-to-scratchpad",
"toggle-scratchpad", "toggle-scratchpad",
"cycle-scratchpad",
"focus-parent", "focus-parent",
"close", "close",
"disable-pointer-constraint", "disable-pointer-constraint",

View file

@ -289,11 +289,12 @@ This table is a tagged union. The variant is determined by the `type` field. It
- `send-to-scratchpad`: - `send-to-scratchpad`:
Sends the currently focused window to a scratchpad and hides it. Sends the currently focused window to a scratchpad and hides it.
If `name` is omitted, the default scratchpad is used. A scratchpad can hold any number of windows. If `name` is omitted, the
default scratchpad is used.
- Example: - Example:
```toml ```toml
[shortcuts] [shortcuts]
alt-shift-minus = { type = "send-to-scratchpad", name = "terminal" } alt-shift-minus = { type = "send-to-scratchpad", name = "terminal" }
@ -310,13 +311,14 @@ This table is a tagged union. The variant is determined by the `type` field. It
- `toggle-scratchpad`: - `toggle-scratchpad`:
Toggles a scratchpad. Toggles a scratchpad.
If the scratchpad has a visible window, that window is hidden. Otherwise, the If the scratchpad has a visible window, that window is hidden. Otherwise, the
most recently hidden window in the scratchpad is shown on the current workspace. most recently hidden window in the scratchpad is shown on the current workspace.
If `name` is omitted, the default scratchpad is used. Only one window of a scratchpad is shown at a time, and scratchpad windows are
always shown floating. If `name` is omitted, the default scratchpad is used.
- Example: - Example:
```toml ```toml
[shortcuts] [shortcuts]
alt-minus = { type = "toggle-scratchpad", name = "terminal" } alt-minus = { type = "toggle-scratchpad", name = "terminal" }
@ -330,6 +332,30 @@ This table is a tagged union. The variant is determined by the `type` field. It
The value of this field should be a string. The value of this field should be a string.
- `cycle-scratchpad`:
Cycles through the windows of a scratchpad, one at a time.
With no window shown, the first window is brought up. Each further invocation
hides the current window and shows the next; after the last window the
scratchpad is hidden again. Scratchpad windows are always shown floating.
If `name` is omitted, the default scratchpad is used.
- Example:
```toml
[shortcuts]
alt-minus = { type = "cycle-scratchpad", name = "terminal" }
```
The table has the following fields:
- `name` (optional):
The name of the scratchpad.
The value of this field should be a string.
- `move-to-output`: - `move-to-output`:
Moves a workspace to a different output. Moves a workspace to a different output.
@ -1074,7 +1100,7 @@ The table has the following fields:
- `enabled` (optional): - `enabled` (optional):
Enables or disables window animations. Enables or disables window animations.
The default is `false`. The default is `false`.
The value of this field should be a boolean. The value of this field should be a boolean.
@ -1082,7 +1108,7 @@ The table has the following fields:
- `duration-ms` (optional): - `duration-ms` (optional):
Sets the animation duration in milliseconds. Sets the animation duration in milliseconds.
The default is `160`. The default is `160`.
The value of this field should be a number. The value of this field should be a number.
@ -1092,7 +1118,7 @@ The table has the following fields:
- `style` (optional): - `style` (optional):
Sets the animation style used for tiled window movement animations. Sets the animation style used for tiled window movement animations.
The default is `multiphase`. The default is `multiphase`.
The value of this field should be a [AnimationStyle](#types-AnimationStyle). The value of this field should be a [AnimationStyle](#types-AnimationStyle).
@ -1100,7 +1126,7 @@ The table has the following fields:
- `curve` (optional): - `curve` (optional):
Sets the animation curve. Sets the animation curve.
The default is `ease-out`. The default is `ease-out`.
The value of this field should be a [AnimationCurve](#types-AnimationCurve). The value of this field should be a [AnimationCurve](#types-AnimationCurve).
@ -2336,11 +2362,11 @@ The table has the following fields:
- `animations` (optional): - `animations` (optional):
Configures window animations. Configures window animations.
Animations are disabled by default. Animations are disabled by default.
- Example: - Example:
```toml ```toml
[animations] [animations]
enabled = true enabled = true
@ -2646,6 +2672,32 @@ The table has the following fields:
The value of this field should be a [Egui](#types-Egui). The value of this field should be a [Egui](#types-Egui).
- `scratchpads` (optional):
An array of pre-configured scratchpads.
Each entry launches a program when the graphics are first initialized and
immediately parks its window in the named scratchpad. The window is captured
via a unique tag attached to the spawned process, so other windows of the
same application are never affected.
Use a `toggle-scratchpad` or `cycle-scratchpad` action to bring the windows
up; they are always shown floating.
- Example:
```toml
[[scratchpads]]
name = "term"
exec = "foot"
[[scratchpads]]
name = "notes"
exec = ["obsidian"]
```
The value of this field should be an array of [Scratchpads](#types-Scratchpad).
<a name="types-Connector"></a> <a name="types-Connector"></a>
### `Connector` ### `Connector`
@ -4579,6 +4631,40 @@ The table has the following fields:
The value of this field should be a string. The value of this field should be a string.
<a name="types-Scratchpad"></a>
### `Scratchpad`
A pre-configured scratchpad whose program is launched at startup and parked
in the scratchpad.
- Example:
```toml
[[scratchpads]]
name = "term"
exec = "foot"
```
Values of this type should be tables.
The table has the following fields:
- `name` (required):
The name of the scratchpad that the spawned window is parked in.
The value of this field should be a string.
- `exec` (optional):
The program to launch when the graphics are first initialized.
If omitted, no program is launched and the scratchpad is only created on
demand by `send-to-scratchpad`.
The value of this field should be a [Exec](#types-Exec).
<a name="types-SimpleActionName"></a> <a name="types-SimpleActionName"></a>
### `SimpleActionName` ### `SimpleActionName`
@ -4702,6 +4788,10 @@ The string should have one of the following values:
Toggles the default scratchpad. Toggles the default scratchpad.
- `cycle-scratchpad`:
Cycles through the windows of the default scratchpad.
- `focus-parent`: - `focus-parent`:
Focus the parent of the currently focused window. Focus the parent of the currently focused window.
@ -5883,3 +5973,5 @@ The table has the following fields:
The scaling mode of X windows. The scaling mode of X windows.
The value of this field should be a [XScalingMode](#types-XScalingMode). The value of this field should be a [XScalingMode](#types-XScalingMode).

View file

@ -349,7 +349,8 @@ Action:
description: | description: |
Sends the currently focused window to a scratchpad and hides it. Sends the currently focused window to a scratchpad and hides it.
If `name` is omitted, the default scratchpad is used. A scratchpad can hold any number of windows. If `name` is omitted, the
default scratchpad is used.
- Example: - Example:
@ -368,7 +369,8 @@ Action:
If the scratchpad has a visible window, that window is hidden. Otherwise, the If the scratchpad has a visible window, that window is hidden. Otherwise, the
most recently hidden window in the scratchpad is shown on the current workspace. most recently hidden window in the scratchpad is shown on the current workspace.
If `name` is omitted, the default scratchpad is used. Only one window of a scratchpad is shown at a time, and scratchpad windows are
always shown floating. If `name` is omitted, the default scratchpad is used.
- Example: - Example:
@ -381,6 +383,26 @@ Action:
description: The name of the scratchpad. description: The name of the scratchpad.
required: false required: false
kind: string kind: string
cycle-scratchpad:
description: |
Cycles through the windows of a scratchpad, one at a time.
With no window shown, the first window is brought up. Each further invocation
hides the current window and shows the next; after the last window the
scratchpad is hidden again. Scratchpad windows are always shown floating.
If `name` is omitted, the default scratchpad is used.
- Example:
```toml
[shortcuts]
alt-minus = { type = "cycle-scratchpad", name = "terminal" }
```
fields:
name:
description: The name of the scratchpad.
required: false
kind: string
move-to-output: move-to-output:
description: | description: |
Moves a workspace to a different output. Moves a workspace to a different output.
@ -1116,6 +1138,8 @@ SimpleActionName:
description: Sends the currently focused window to the default scratchpad. description: Sends the currently focused window to the default scratchpad.
- value: toggle-scratchpad - value: toggle-scratchpad
description: Toggles the default scratchpad. description: Toggles the default scratchpad.
- value: cycle-scratchpad
description: Cycles through the windows of the default scratchpad.
- value: focus-parent - value: focus-parent
description: Focus the parent of the currently focused window. description: Focus the parent of the currently focused window.
- value: close - value: close
@ -3286,6 +3310,61 @@ Config:
required: false required: false
description: | description: |
Sets the egui settings of the compositor. Sets the egui settings of the compositor.
scratchpads:
kind: array
items:
ref: Scratchpad
required: false
description: |
An array of pre-configured scratchpads.
Each entry launches a program when the graphics are first initialized and
immediately parks its window in the named scratchpad. The window is captured
via a unique tag attached to the spawned process, so other windows of the
same application are never affected.
Use a `toggle-scratchpad` or `cycle-scratchpad` action to bring the windows
up; they are always shown floating.
- Example:
```toml
[[scratchpads]]
name = "term"
exec = "foot"
[[scratchpads]]
name = "notes"
exec = ["obsidian"]
```
Scratchpad:
kind: table
description: |
A pre-configured scratchpad whose program is launched at startup and parked
in the scratchpad.
- Example:
```toml
[[scratchpads]]
name = "term"
exec = "foot"
```
fields:
name:
kind: string
required: true
description: The name of the scratchpad that the spawned window is parked in.
exec:
ref: Exec
required: false
description: |
The program to launch when the graphics are first initialized.
If omitted, no program is launched and the scratchpad is only created on
demand by `send-to-scratchpad`.
Idle: Idle: