diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 57075e68..151e7591 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -648,6 +648,10 @@ impl ConfigClient { 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) { self.send(&ClientMessage::WindowSendToScratchpad { window, name }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 20ca2269..743acc57 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -294,6 +294,10 @@ pub enum ClientMessage<'a> { seat: Seat, name: &'a str, }, + SeatCycleScratchpad { + seat: Seat, + name: &'a str, + }, GetTimer { name: &'a str, }, diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index 560197c4..450597e2 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -477,11 +477,22 @@ impl Seat { /// /// 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. + /// Scratchpad windows are always shown floating. /// Use an empty string for the default scratchpad. pub fn toggle_scratchpad(self, name: &str) { 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. pub fn toggle_fullscreen(self) { let c = get!(); diff --git a/src/config/handler.rs b/src/config/handler.rs index f6bc224f..68ea93f5 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -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")? } diff --git a/src/it/test_config.rs b/src/it/test_config.rs index 5eba8aca..8cb39935 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -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() { diff --git a/src/it/tests/t0055_scratchpad.rs b/src/it/tests/t0055_scratchpad.rs index 2519335a..5abf2440 100644 --- a/src/it/tests/t0055_scratchpad.rs +++ b/src/it/tests/t0055_scratchpad.rs @@ -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) -> 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(()) } diff --git a/src/state.rs b/src/state.rs index d10ff054..74facaf1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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, identifier: ToplevelIdentifier, hidden: Cell, - restore: RefCell>, } 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, seat: &Rc, 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) = ¤t { + self.hide_scratchpad_entry(current); + } + if let Some(next) = &next { + self.show_scratchpad_entry(seat, name, next); + } + } + fn hide_scratchpad_entry(self: &Rc, entry: &Rc) { + 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, seat: &Rc, + name: &str, entry: &Rc, ) { 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(); diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 85661202..c0a2f013 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -1324,29 +1324,25 @@ pub fn toplevel_set_workspace(state: &Rc, tl: Rc, ws: & } } -pub struct ScratchpadToplevelState { - pub floating: bool, - pub fullscreen: bool, - pub workspace: Option>, -} - -pub fn toplevel_hide_for_scratchpad(tl: Rc) -> Option { +/// 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) -> 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) -> Option, tl: Rc, ws: &Rc, - 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); } diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 894cb072..b57de5ad 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -66,6 +66,7 @@ pub enum SimpleCommand { SetFullscreen(bool), SendToScratchpad, ToggleScratchpad, + CycleScratchpad, Forward(bool), EnableWindowManagement(bool), SetFloatAboveFullscreen(bool), @@ -138,6 +139,9 @@ pub enum Action { ToggleScratchpad { name: String, }, + CycleScratchpad { + name: String, + }, Multi { actions: Vec, }, diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index e353a2f8..98d3ab73 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -41,6 +41,7 @@ pub mod modified_keysym; mod output; mod output_match; mod repeat_rate; +mod scratchpad; pub mod shortcuts; mod simple_im; mod status; diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 1afd8740..29fdc3e4 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -119,6 +119,7 @@ impl ActionParser<'_> { "exit-fullscreen" => SetFullscreen(false), "send-to-scratchpad" => SendToScratchpad, "toggle-scratchpad" => ToggleScratchpad, + "cycle-scratchpad" => CycleScratchpad, "focus-parent" => FocusParent, "close" => Close, "disable-pointer-constraint" => DisablePointerConstraint, @@ -242,6 +243,15 @@ impl ActionParser<'_> { Ok(Action::ToggleScratchpad { name }) } + fn parse_cycle_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult { + 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 { let con = ext .extract(val("connector"))? @@ -573,6 +583,7 @@ impl Parser for ActionParser<'_> { "move-to-workspace" => self.parse_move_to_workspace(&mut ext), "send-to-scratchpad" => self.parse_send_to_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-input" => self.parse_configure_input(&mut ext), "configure-output" => self.parse_configure_output(&mut ext), diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index 112f7471..8e776860 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -28,6 +28,7 @@ use { log_level::LogLevelParser, output::OutputsParser, repeat_rate::RepeatRateParser, + scratchpad::ScratchpadsParser, shortcuts::{ ComplexShortcutsParser, ShortcutsParser, ShortcutsParserError, 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 { keymap, repeat_rate, diff --git a/toml-config/src/config/parsers/scratchpad.rs b/toml-config/src/config/parsers/scratchpad.rs new file mode 100644 index 00000000..17cc5238 --- /dev/null +++ b/toml-config/src/config/parsers/scratchpad.rs @@ -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>, + ) -> ParseResult { + 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; + type Error = ScratchpadParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table, DataType::Array]; + + fn parse_array(&mut self, _span: Span, array: &[Spanned]) -> ParseResult { + 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>, + ) -> ParseResult { + log::warn!( + "`scratchpads` value should be an array: {}", + self.0.error3(span) + ); + ScratchpadParser(self.0) + .parse_table(span, table) + .map(|v| vec![v]) + } +} diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index cc09047b..6e3430f8 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -15,7 +15,7 @@ use { config::{ Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice, 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}, shortcuts::ModeState, @@ -175,6 +175,7 @@ impl Action { SimpleCommand::SetFullscreen(b) => window_or_seat!(s, s.set_fullscreen(b)), SimpleCommand::SendToScratchpad => window_or_seat!(s, s.send_to_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::Close => window_or_seat!(s, s.close()), SimpleCommand::DisablePointerConstraint => { @@ -310,6 +311,7 @@ impl Action { } Action::SendToScratchpad { name } => window_or_seat!(s, s.send_to_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 || { for c in connectors() { if con.match_.matches(c) { @@ -1461,6 +1463,43 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc