diff --git a/book/src/tiling.md b/book/src/tiling.md index 650cd73a..2ff61d5e 100644 --- a/book/src/tiling.md +++ b/book/src/tiling.md @@ -77,6 +77,20 @@ You can also right-click any title in a container to toggle mono mode. In mono mode, scroll over the title bar to cycle between windows in the container. +## Autotiling + +Autotiling makes newly tiled windows alternate split direction from the focused +tiled window. The first split uses the containing group direction, then later +windows wrap the focused tile in the opposite direction, producing a horizontal, +vertical, horizontal pattern as the layout grows. + +```toml +[shortcuts] +alt-a = "toggle-autotile" +``` + +Manual grouping and split commands still use the direction you request. + ## Fullscreen Press `alt-u` (`toggle-fullscreen`) to make the focused window fill the entire diff --git a/crates/jay-config-schema/src/action.rs b/crates/jay-config-schema/src/action.rs index 647308b0..8a774a06 100644 --- a/crates/jay-config-schema/src/action.rs +++ b/crates/jay-config-schema/src/action.rs @@ -17,6 +17,9 @@ pub enum SimpleCommand { SetFloating(bool), ToggleFullscreen, SetFullscreen(bool), + SendToScratchpad, + ToggleScratchpad, + CycleScratchpad, Forward(bool), EnableWindowManagement(bool), SetFloatAboveFullscreen(bool), diff --git a/crates/jay-config-schema/src/command.rs b/crates/jay-config-schema/src/command.rs index 4dc63f98..062dee52 100644 --- a/crates/jay-config-schema/src/command.rs +++ b/crates/jay-config-schema/src/command.rs @@ -5,6 +5,7 @@ pub struct Exec { pub prog: String, pub args: Vec, pub envs: Vec<(String, String)>, + pub tag: Option, } #[derive(Debug, Clone)] diff --git a/crates/jay-config-schema/src/lib.rs b/crates/jay-config-schema/src/lib.rs index 432a7810..051a5ee7 100644 --- a/crates/jay-config-schema/src/lib.rs +++ b/crates/jay-config-schema/src/lib.rs @@ -20,7 +20,9 @@ pub use animations::{AnimationCurveConfig, Animations}; pub use command::{Exec, Status}; pub use input::InputMatch; pub use keymap::ConfigKeymap; -pub use model::{Action, ClientRule, Config, Input, InputMode, NamedAction, Shortcut, WindowRule}; +pub use model::{ + Action, ClientRule, Config, Input, InputMode, NamedAction, Scratchpad, Shortcut, WindowRule, +}; pub use options::{ ColorManagement, Float, FocusHistory, Libei, RepeatRate, SimpleIm, Tearing, UiDrag, Vrr, Xwayland, diff --git a/crates/jay-config-schema/src/model.rs b/crates/jay-config-schema/src/model.rs index 4a4bf430..291bb448 100644 --- a/crates/jay-config-schema/src/model.rs +++ b/crates/jay-config-schema/src/model.rs @@ -48,6 +48,15 @@ pub enum Action { MoveToWorkspace { name: String, }, + SendToScratchpad { + name: String, + }, + ToggleScratchpad { + name: String, + }, + CycleScratchpad { + name: String, + }, Multi { actions: Vec, }, @@ -236,4 +245,12 @@ pub struct Config { pub simple_im: Option, pub fallback_output_mode: Option, pub mouse_follows_focus: Option, + pub scratchpads: Vec, + pub autotile: Option, +} + +#[derive(Debug, Clone)] +pub struct Scratchpad { + pub name: String, + pub exec: Option, } diff --git a/crates/jay-config-schema/src/rules.rs b/crates/jay-config-schema/src/rules.rs index da36cc89..563c60e1 100644 --- a/crates/jay-config-schema/src/rules.rs +++ b/crates/jay-config-schema/src/rules.rs @@ -32,6 +32,8 @@ pub struct ClientMatch { pub comm_regex: Option, pub exe: Option, pub exe_regex: Option, + pub tag: Option, + pub tag_regex: Option, } #[derive(Default, Debug, Clone)] diff --git a/crates/jay-config/src/_private/client.rs b/crates/jay-config/src/_private/client.rs index c28f1597..4bd15acf 100644 --- a/crates/jay-config/src/_private/client.rs +++ b/crates/jay-config/src/_private/client.rs @@ -308,7 +308,15 @@ impl ConfigClient { .drain() .map(|(a, b)| (a, b.into_raw_fd())) .collect(); - if fds.is_empty() { + if command.tag.is_some() { + self.send(&ClientMessage::Run3 { + prog: &command.prog, + args: command.args.clone(), + env, + fds, + tag: command.tag.as_deref(), + }); + } else if fds.is_empty() { self.send(&ClientMessage::Run { prog: &command.prog, args: command.args.clone(), @@ -592,6 +600,22 @@ impl ConfigClient { self.send(&ClientMessage::SetWindowWorkspace { window, workspace }); } + pub fn seat_send_to_scratchpad(&self, seat: Seat, name: &str) { + self.send(&ClientMessage::SeatSendToScratchpad { seat, name }); + } + + pub fn seat_toggle_scratchpad(&self, seat: Seat, name: &str) { + 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 }); + } + pub fn seat_split(&self, seat: Seat) -> Axis { let res = self.send_with_response(&ClientMessage::GetSeatSplit { seat }); get_response!(res, Axis::Horizontal, GetSplit { axis }); @@ -1798,6 +1822,8 @@ impl ConfigClient { ClientCriterion::CommRegex(t) => string!(t, Comm, true), ClientCriterion::Exe(t) => string!(t, Exe, false), ClientCriterion::ExeRegex(t) => string!(t, Exe, true), + ClientCriterion::Tag(t) => string!(t, Tag, false), + ClientCriterion::TagRegex(t) => string!(t, Tag, true), }; let res = self.send_with_response(&ClientMessage::CreateClientMatcher { criterion }); get_response!( @@ -2016,6 +2042,12 @@ impl ConfigClient { self.send(&ClientMessage::SetAutotile { enabled }); } + pub fn get_autotile(&self) -> bool { + let res = self.send_with_response(&ClientMessage::GetAutotile); + get_response!(res, false, GetAutotile { enabled }); + enabled + } + pub fn set_tab_title_align(&self, align: u32) { self.send(&ClientMessage::SetTabTitleAlign { align }); } diff --git a/crates/jay-config/src/client.rs b/crates/jay-config/src/client.rs index 5365329c..5a2e4173 100644 --- a/crates/jay-config/src/client.rs +++ b/crates/jay-config/src/client.rs @@ -91,6 +91,10 @@ pub enum ClientCriterion<'a> { Exe(&'a str), /// Matches the `/proc/pid/exe` of the client with a regular expression. ExeRegex(&'a str), + /// Matches the tag of the client verbatim. + Tag(&'a str), + /// Matches the tag of the client with a regular expression. + TagRegex(&'a str), } impl ClientCriterion<'_> { diff --git a/crates/jay-config/src/exec.rs b/crates/jay-config/src/exec.rs index 4c858900..bedc70c3 100644 --- a/crates/jay-config/src/exec.rs +++ b/crates/jay-config/src/exec.rs @@ -22,6 +22,7 @@ pub struct Command { pub(crate) args: Vec, pub(crate) env: HashMap, pub(crate) fds: RefCell>, + pub(crate) tag: Option, } impl Command { @@ -37,6 +38,7 @@ impl Command { args: vec![], env: Default::default(), fds: Default::default(), + tag: Default::default(), } } @@ -82,6 +84,12 @@ impl Command { self.fd(2, fd) } + /// Adds a tag to Wayland connections created by the spawned command. + pub fn tag(&mut self, tag: &str) -> &mut Self { + self.tag = Some(tag.to_owned()); + self + } + /// Executes the command. /// /// This consumes all attached file descriptors. diff --git a/crates/jay-config/src/input.rs b/crates/jay-config/src/input.rs index 36f4bfa6..c052bba7 100644 --- a/crates/jay-config/src/input.rs +++ b/crates/jay-config/src/input.rs @@ -466,6 +466,33 @@ impl Seat { get!().set_seat_workspace(self, workspace) } + /// Sends the currently focused window to a scratchpad. + /// + /// Use an empty string for the default scratchpad. + pub fn send_to_scratchpad(self, name: &str) { + get!().seat_send_to_scratchpad(self, name) + } + + /// Toggles a scratchpad. + /// + /// 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/crates/jay-config/src/lib.rs b/crates/jay-config/src/lib.rs index c7e585bf..91dbbcae 100644 --- a/crates/jay-config/src/lib.rs +++ b/crates/jay-config/src/lib.rs @@ -437,14 +437,21 @@ pub fn get_corner_radius() -> f32 { /// Enables or disables autotiling. /// -/// When enabled, new windows are automatically placed in a perpendicular -/// sub-container if the predicted body would be narrower than tall (or vice versa). +/// When enabled, newly tiled windows alternate split orientation from the +/// focused tiled window: the first split uses the containing group's direction, +/// then subsequent splits wrap the focused window in the perpendicular +/// direction. /// /// The default is `false`. pub fn set_autotile(enabled: bool) { get!().set_autotile(enabled) } +/// Returns whether autotiling is enabled. +pub fn get_autotile() -> bool { + get!(false).get_autotile() +} + /// Sets the horizontal alignment of title text within tab buttons. /// /// - `"start"` — left-aligned (default) diff --git a/crates/jay-config/src/protocol.rs b/crates/jay-config/src/protocol.rs index 89bb5556..e45224a0 100644 --- a/crates/jay-config/src/protocol.rs +++ b/crates/jay-config/src/protocol.rs @@ -116,6 +116,7 @@ pub enum ClientCriterionStringField { SandboxInstanceId, Comm, Exe, + Tag, } #[derive(Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)] @@ -411,6 +412,18 @@ pub enum ClientMessage<'a> { seat: Seat, workspace: Workspace, }, + SeatSendToScratchpad { + seat: Seat, + name: &'a str, + }, + SeatToggleScratchpad { + seat: Seat, + name: &'a str, + }, + SeatCycleScratchpad { + seat: Seat, + name: &'a str, + }, GetTimer { name: &'a str, }, @@ -557,6 +570,13 @@ pub enum ClientMessage<'a> { env: Vec<(String, String)>, fds: Vec<(i32, i32)>, }, + Run3 { + prog: &'a str, + args: Vec, + env: Vec<(String, String)>, + fds: Vec<(i32, i32)>, + tag: Option<&'a str>, + }, DisableDefaultSeat, DestroyKeymap { keymap: Keymap, @@ -817,6 +837,10 @@ pub enum ClientMessage<'a> { window: Window, workspace: Workspace, }, + WindowSendToScratchpad { + window: Window, + name: &'a str, + }, SetWindowFullscreen { window: Window, fullscreen: bool, @@ -1038,6 +1062,7 @@ pub enum ClientMessage<'a> { SetAutotile { enabled: bool, }, + GetAutotile, SetTabTitleAlign { align: u32, }, @@ -1301,6 +1326,9 @@ pub enum Response { GetCornerRadius { radius: f32, }, + GetAutotile { + enabled: bool, + }, } #[derive(Serialize, Deserialize, Clone, Debug)] diff --git a/crates/jay-config/src/window.rs b/crates/jay-config/src/window.rs index 662cda44..96e4d3b1 100644 --- a/crates/jay-config/src/window.rs +++ b/crates/jay-config/src/window.rs @@ -205,6 +205,13 @@ impl Window { get!().set_window_workspace(self, workspace) } + /// Sends the window to a scratchpad. + /// + /// Use an empty string for the default scratchpad. + pub fn send_to_scratchpad(self, name: &str) { + get!().window_send_to_scratchpad(self, name) + } + /// Toggles whether the currently focused window is fullscreen. pub fn toggle_fullscreen(self) { self.set_fullscreen(!self.fullscreen()) diff --git a/crates/toml-config/src/config.rs b/crates/toml-config/src/config.rs index 6b82768b..f081f3fe 100644 --- a/crates/toml-config/src/config.rs +++ b/crates/toml-config/src/config.rs @@ -28,8 +28,8 @@ pub use jay_config_schema::{ Action, AnimationCurveConfig, Animations, ClientMatch, ClientRule, ColorManagement, Config, ConfigConnector, ConfigDrmDevice, ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Float, FocusHistory, GenericMatch, Input, InputMatch, InputMode, Libei, MatchExactly, Mode, - NamedAction, Output, OutputMatch, RepeatRate, Shortcut, SimpleCommand, SimpleIm, Status, - Tearing, Theme, UiDrag, Vrr, WindowMatch, WindowRule, Xwayland, + NamedAction, Output, OutputMatch, RepeatRate, Scratchpad, Shortcut, SimpleCommand, SimpleIm, + Status, Tearing, Theme, UiDrag, Vrr, WindowMatch, WindowRule, Xwayland, }; #[derive(Debug, Error)] diff --git a/crates/toml-config/src/config/parsers.rs b/crates/toml-config/src/config/parsers.rs index 5bea6371..4b9cdebd 100644 --- a/crates/toml-config/src/config/parsers.rs +++ b/crates/toml-config/src/config/parsers.rs @@ -40,6 +40,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/crates/toml-config/src/config/parsers/action.rs b/crates/toml-config/src/config/parsers/action.rs index 951cc929..d1cf153a 100644 --- a/crates/toml-config/src/config/parsers/action.rs +++ b/crates/toml-config/src/config/parsers/action.rs @@ -117,6 +117,9 @@ impl ActionParser<'_> { "toggle-fullscreen" => ToggleFullscreen, "enter-fullscreen" => SetFullscreen(true), "exit-fullscreen" => SetFullscreen(false), + "send-to-scratchpad" => SendToScratchpad, + "toggle-scratchpad" => ToggleScratchpad, + "cycle-scratchpad" => CycleScratchpad, "focus-parent" => FocusParent, "close" => Close, "disable-pointer-constraint" => DisablePointerConstraint, @@ -221,6 +224,33 @@ impl ActionParser<'_> { Ok(Action::MoveToWorkspace { name }) } + fn parse_send_to_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult { + let name = ext + .extract(opt(str("name")))? + .map(|name| name.value) + .unwrap_or("") + .to_string(); + Ok(Action::SendToScratchpad { name }) + } + + fn parse_toggle_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult { + let name = ext + .extract(opt(str("name")))? + .map(|name| name.value) + .unwrap_or("") + .to_string(); + 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"))? @@ -550,6 +580,9 @@ impl Parser for ActionParser<'_> { "switch-to-vt" => self.parse_switch_to_vt(&mut ext), "show-workspace" => self.parse_show_workspace(&mut ext), "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/crates/toml-config/src/config/parsers/client_match.rs b/crates/toml-config/src/config/parsers/client_match.rs index 1d8050c2..ab61f653 100644 --- a/crates/toml-config/src/config/parsers/client_match.rs +++ b/crates/toml-config/src/config/parsers/client_match.rs @@ -59,7 +59,7 @@ impl Parser for ClientMatchParser<'_> { exe, exe_regex, ), - (is_xwayland,), + (is_xwayland, tag, tag_regex), ) = ext.extract(( ( opt(str("name")), @@ -83,7 +83,11 @@ impl Parser for ClientMatchParser<'_> { opt(str("exe")), opt(str("exe-regex")), ), - (opt(bol("is-xwayland")),), + ( + opt(bol("is-xwayland")), + opt(str("tag")), + opt(str("tag-regex")), + ), ))?; let mut not = None; if let Some(value) = not_val { @@ -130,6 +134,8 @@ impl Parser for ClientMatchParser<'_> { comm_regex: comm_regex.despan_into(), exe: exe.despan_into(), exe_regex: exe_regex.despan_into(), + tag: tag.despan_into(), + tag_regex: tag_regex.despan_into(), }) } } diff --git a/crates/toml-config/src/config/parsers/config.rs b/crates/toml-config/src/config/parsers/config.rs index 0932a410..ed3b1e70 100644 --- a/crates/toml-config/src/config/parsers/config.rs +++ b/crates/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, @@ -156,6 +157,7 @@ impl Parser for ConfigParser<'_> { mouse_follows_focus, animations_val, ), + (scratchpads_val, autotile), ) = ext.extract(( ( opt(val("keymap")), @@ -217,6 +219,7 @@ impl Parser for ConfigParser<'_> { recover(opt(bol("unstable-mouse-follows-focus"))), opt(val("animations")), ), + (opt(val("scratchpads")), recover(opt(bol("autotile")))), ))?; let mut keymap = None; if let Some(value) = keymap_val { @@ -572,6 +575,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, @@ -624,6 +634,8 @@ impl Parser for ConfigParser<'_> { simple_im, fallback_output_mode, mouse_follows_focus: mouse_follows_focus.despan(), + scratchpads, + autotile: autotile.despan(), }) } } diff --git a/crates/toml-config/src/config/parsers/exec.rs b/crates/toml-config/src/config/parsers/exec.rs index 09473de3..6924bb4a 100644 --- a/crates/toml-config/src/config/parsers/exec.rs +++ b/crates/toml-config/src/config/parsers/exec.rs @@ -11,7 +11,7 @@ use { }, }, jay_toml::{ - toml_span::{Span, Spanned, SpannedExt}, + toml_span::{DespanExt, Span, Spanned, SpannedExt}, toml_value::Value, }, }, @@ -52,6 +52,7 @@ impl Parser for ExecParser<'_> { prog: string.to_string(), args: vec![], envs: vec![], + tag: None, }) } @@ -68,6 +69,7 @@ impl Parser for ExecParser<'_> { prog, args, envs: vec![], + tag: None, }) } @@ -77,11 +79,12 @@ impl Parser for ExecParser<'_> { table: &IndexMap, Spanned>, ) -> ParseResult { let mut ext = Extractor::new(self.0, span, table); - let (prog_opt, shell_opt, args_val, envs_val) = ext.extract(( + let (prog_opt, shell_opt, args_val, envs_val, tag) = ext.extract(( opt(str("prog")), opt(str("shell")), opt(arr("args")), opt(val("env")), + opt(str("tag")), ))?; let prog; let mut args = vec![]; @@ -113,6 +116,7 @@ impl Parser for ExecParser<'_> { prog, args, envs, + tag: tag.despan_into(), }) } } diff --git a/crates/toml-config/src/config/parsers/scratchpad.rs b/crates/toml-config/src/config/parsers/scratchpad.rs new file mode 100644 index 00000000..139935a1 --- /dev/null +++ b/crates/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}, + }, + jay_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/crates/toml-config/src/lib.rs b/crates/toml-config/src/lib.rs index 86b411fe..33bc3343 100644 --- a/crates/toml-config/src/lib.rs +++ b/crates/toml-config/src/lib.rs @@ -14,9 +14,10 @@ pub(crate) use jay_toml; use { crate::{ config::{ - Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice, - ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, - OutputMatch, SimpleCommand, Status, Theme, WindowRule, parse_config, + Action, AnimationCurveConfig, ClientMatch, ClientRule, Config, ConfigConnector, + ConfigDrmDevice, ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, + InputMatch, Output, OutputMatch, SimpleCommand, Status, Theme, WindowMatch, + WindowRule, parse_config, }, rules::{MatcherTemp, RuleMapper}, shortcuts::ModeState, @@ -28,7 +29,7 @@ use { client::Client, config_dir, exec::{Command, set_env, unset_env}, - get_workspace, + get_autotile, get_workspace, input::{ FocusFollowsMouseMode, InputDevice, Seat, SwitchEvent, capability::CAP_SWITCH, get_seat, input_devices, on_input_device_removed, on_new_input_device, @@ -41,11 +42,11 @@ use { on_devices_enumerated, on_idle, on_unload, quit, set_animation_cubic_bezier, set_animation_curve, set_animation_duration_ms, set_animation_style, set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius, - set_default_workspace_capture, - set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, - set_idle_grace_period, set_key_press_enables_dpms, set_middle_click_paste_enabled, - set_mouse_move_enables_dpms, set_show_bar, set_show_float_pin_icon, set_show_titles, - set_tab_title_align, set_ui_drag_enabled, set_ui_drag_threshold, + set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen, + set_floating_titles, set_idle, set_idle_grace_period, set_key_press_enables_dpms, + set_middle_click_paste_enabled, set_mouse_move_enables_dpms, set_show_bar, + set_show_float_pin_icon, set_show_titles, set_tab_title_align, set_ui_drag_enabled, + set_ui_drag_threshold, status::{set_i3bar_separator, set_status, set_status_command, unset_status_command}, switch_to_vt, tasks::{self, JoinHandle}, @@ -185,6 +186,9 @@ impl ActionExt for Action { SimpleCommand::Move(dir) => window_or_seat!(s, s.move_(dir)), SimpleCommand::ToggleFullscreen => window_or_seat!(s, s.toggle_fullscreen()), 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 => { @@ -280,12 +284,7 @@ impl ActionExt for Action { SimpleCommand::MoveTabLeft => b.new(move || s.move_tab(false)), SimpleCommand::MoveTabRight => b.new(move || s.move_tab(true)), SimpleCommand::SetAutotile(enabled) => b.new(move || set_autotile(enabled)), - SimpleCommand::ToggleAutotile => { - b.new(move || { - // Toggle not directly supported; set to true - set_autotile(true) - }) - } + SimpleCommand::ToggleAutotile => b.new(move || set_autotile(!get_autotile())), }, Action::Multi { actions } => { let actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect(); @@ -322,6 +321,9 @@ impl ActionExt for Action { let workspace = get_workspace(&name); window_or_seat!(s, s.set_workspace(workspace)) } + 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) { @@ -1526,6 +1528,46 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc Command { @@ -1822,6 +1867,9 @@ fn create_command(exec: &Exec) -> Command { for (k, v) in &exec.envs { command.env(k, v); } + if let Some(tag) = &exec.tag { + command.tag(tag); + } command } diff --git a/crates/toml-config/src/rules.rs b/crates/toml-config/src/rules.rs index f5d6d6d7..4b83eecd 100644 --- a/crates/toml-config/src/rules.rs +++ b/crates/toml-config/src/rules.rs @@ -127,6 +127,8 @@ impl Rule for ClientRule { value_ref!(CommRegex, comm_regex); value_ref!(Exe, exe); value_ref!(ExeRegex, exe_regex); + value_ref!(Tag, tag); + value_ref!(TagRegex, tag_regex); value!(Uid, uid); value!(Pid, pid); bool!(Sandboxed, sandboxed); diff --git a/crates/toml-spec/spec/spec.generated.json b/crates/toml-spec/spec/spec.generated.json index 182dde2f..61312cf6 100644 --- a/crates/toml-spec/spec/spec.generated.json +++ b/crates/toml-spec/spec/spec.generated.json @@ -162,6 +162,54 @@ "name" ] }, + { + "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", + "properties": { + "type": { + "const": "send-to-scratchpad" + }, + "name": { + "type": "string", + "description": "The name of the scratchpad." + } + }, + "required": [ + "type" + ] + }, + { + "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", + "properties": { + "type": { + "const": "toggle-scratchpad" + }, + "name": { + "type": "string", + "description": "The name of the scratchpad." + } + }, + "required": [ + "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", "type": "object", @@ -841,6 +889,14 @@ "exe-regex": { "type": "string", "description": "Matches the `/proc/pid/exe` of the client with a regular expression." + }, + "tag": { + "type": "string", + "description": "Matches the tag of the client verbatim." + }, + "tag-regex": { + "type": "string", + "description": "Matches the tag of the client with a regular expression." } }, "required": [] @@ -1157,6 +1213,10 @@ "type": "boolean", "description": "Configures whether middle-click pasting is enabled.\n\nChanging this has no effect on running applications.\n\nThe default is `true`.\n" }, + "autotile": { + "type": "boolean", + "description": "Configures whether autotiling is enabled by default.\n\nWhen enabled, newly mapped tiled windows alternate their split\norientation automatically. This can also be toggled at runtime via the\n`enable-autotile`, `disable-autotile`, and `toggle-autotile` actions.\n\nThe default is `false`.\n" + }, "modes": { "description": "Configures the input modes.\n\nModes can be used to define shortcuts that are only active when the mode is\nactive.\n\n- Example\n\n ```toml\n [modes.\"navigation\".shortcuts]\n w = \"focus-up\"\n a = \"focus-left\"\n s = \"focus-down\"\n d = \"focus-right\"\n r = \"focus-above\"\n f = \"focus-below\"\n q = \"focus-prev\"\n e = \"focus-next\"\n ```\n\nModes can be activated with the `push-mode` and `latch-mode` actions.\n", "type": "object", @@ -1184,6 +1244,14 @@ "egui": { "description": "Sets the egui settings of the compositor.\n", "$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": [] @@ -1413,6 +1481,10 @@ "type": "string", "description": "" } + }, + "tag": { + "type": "string", + "description": "Specifies a tag to apply to all spawned wayland connections.\n" } }, "required": [] @@ -1990,6 +2062,23 @@ }, "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": { "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", @@ -2008,9 +2097,15 @@ "make-group-tab", "change-group-opposite", "toggle-tab", + "enable-autotile", + "disable-autotile", + "toggle-autotile", "toggle-fullscreen", "enter-fullscreen", "exit-fullscreen", + "send-to-scratchpad", + "toggle-scratchpad", + "cycle-scratchpad", "focus-parent", "close", "disable-pointer-constraint", diff --git a/crates/toml-spec/spec/spec.generated.md b/crates/toml-spec/spec/spec.generated.md index e68ded70..27a25895 100644 --- a/crates/toml-spec/spec/spec.generated.md +++ b/crates/toml-spec/spec/spec.generated.md @@ -286,6 +286,76 @@ This table is a tagged union. The variant is determined by the `type` field. It The value of this field should be a string. +- `send-to-scratchpad`: + + Sends the currently focused window to a scratchpad and hides it. + + A scratchpad can hold any number of windows. If `name` is omitted, the + default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-shift-minus = { type = "send-to-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. + +- `toggle-scratchpad`: + + Toggles a scratchpad. + + 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. + 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: + + ```toml + [shortcuts] + alt-minus = { type = "toggle-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. + +- `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`: Moves a workspace to a different output. @@ -1409,6 +1479,18 @@ The table has the following fields: The value of this field should be a string. +- `tag` (optional): + + Matches the tag of the client verbatim. + + The value of this field should be a string. + +- `tag-regex` (optional): + + Matches the tag of the client with a regular expression. + + The value of this field should be a string. + ### `ClientMatchExactly` @@ -2354,6 +2436,18 @@ The table has the following fields: The value of this field should be a boolean. +- `autotile` (optional): + + Configures whether autotiling is enabled by default. + + When enabled, newly mapped tiled windows alternate their split + orientation automatically. This can also be toggled at runtime via the + `enable-autotile`, `disable-autotile`, and `toggle-autotile` actions. + + The default is `false`. + + The value of this field should be a boolean. + - `modes` (optional): Configures the input modes. @@ -2454,6 +2548,32 @@ The table has the following fields: 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). + ### `Connector` @@ -2933,6 +3053,12 @@ The table has the following fields: The value of this field should be a table whose values are strings. +- `tag` (optional): + + Specifies a tag to apply to all spawned wayland connections. + + The value of this field should be a string. + ### `FallbackOutputMode` @@ -4357,6 +4483,40 @@ The table has the following fields: The value of this field should be a string. + +### `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). + + ### `SimpleActionName` @@ -4448,6 +4608,18 @@ The string should have one of the following values: Toggles the current group between tabbed and split mode. +- `enable-autotile`: + + Enables alternating split orientation for newly tiled windows. + +- `disable-autotile`: + + Disables alternating split orientation for newly tiled windows. + +- `toggle-autotile`: + + Toggles alternating split orientation for newly tiled windows. + - `toggle-fullscreen`: Toggle the currently focused window between fullscreen and windowed. @@ -4460,6 +4632,18 @@ The string should have one of the following values: Makes the currently focused window windowed. +- `send-to-scratchpad`: + + Sends the currently focused window to the default scratchpad. + +- `toggle-scratchpad`: + + Toggles the default scratchpad. + +- `cycle-scratchpad`: + + Cycles through the windows of the default scratchpad. + - `focus-parent`: Focus the parent of the currently focused window. diff --git a/crates/toml-spec/spec/spec.yaml b/crates/toml-spec/spec/spec.yaml index ce83f243..6c3b96dc 100644 --- a/crates/toml-spec/spec/spec.yaml +++ b/crates/toml-spec/spec/spec.yaml @@ -345,6 +345,64 @@ Action: description: The name of the workspace. required: true kind: string + send-to-scratchpad: + description: | + Sends the currently focused window to a scratchpad and hides it. + + A scratchpad can hold any number of windows. If `name` is omitted, the + default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-shift-minus = { type = "send-to-scratchpad", name = "terminal" } + ``` + fields: + name: + description: The name of the scratchpad. + required: false + kind: string + toggle-scratchpad: + description: | + Toggles a scratchpad. + + 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. + 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: + + ```toml + [shortcuts] + alt-minus = { type = "toggle-scratchpad", name = "terminal" } + ``` + fields: + name: + description: The name of the scratchpad. + required: false + 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: description: | Moves a workspace to a different output. @@ -978,6 +1036,11 @@ Exec: values: kind: string description: The environment variables to pass to the executable. + tag: + kind: string + required: false + description: | + Specifies a tag to apply to all spawned wayland connections. SimpleActionName: @@ -1039,12 +1102,24 @@ SimpleActionName: description: Toggles the current group's direction. - value: toggle-tab description: Toggles the current group between tabbed and split mode. + - value: enable-autotile + description: Enables alternating split orientation for newly tiled windows. + - value: disable-autotile + description: Disables alternating split orientation for newly tiled windows. + - value: toggle-autotile + description: Toggles alternating split orientation for newly tiled windows. - value: toggle-fullscreen description: Toggle the currently focused window between fullscreen and windowed. - value: enter-fullscreen description: Makes the currently focused window fullscreen. - value: exit-fullscreen description: Makes the currently focused window windowed. + - value: send-to-scratchpad + description: Sends the currently focused window to the default scratchpad. + - value: toggle-scratchpad + description: Toggles the default scratchpad. + - value: cycle-scratchpad + description: Cycles through the windows of the default scratchpad. - value: focus-parent description: Focus the parent of the currently focused window. - value: close @@ -3102,10 +3177,21 @@ Config: required: false description: | Configures whether middle-click pasting is enabled. - + Changing this has no effect on running applications. The default is `true`. + autotile: + kind: boolean + required: false + description: | + Configures whether autotiling is enabled by default. + + When enabled, newly mapped tiled windows alternate their split + orientation automatically. This can also be toggled at runtime via the + `enable-autotile`, `disable-autotile`, and `toggle-autotile` actions. + + The default is `false`. modes: kind: map values: @@ -3202,6 +3288,61 @@ Config: required: false description: | 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: @@ -4110,6 +4251,14 @@ ClientMatch: kind: string required: false description: Matches the `/proc/pid/exe` of the client with a regular expression. + tag: + kind: string + required: false + description: Matches the tag of the client verbatim. + tag-regex: + kind: string + required: false + description: Matches the tag of the client with a regular expression. ClientMatchExactly: diff --git a/crates/wire-to-xml/src/main.rs b/crates/wire-to-xml/src/main.rs index 5341e137..6a73075e 100644 --- a/crates/wire-to-xml/src/main.rs +++ b/crates/wire-to-xml/src/main.rs @@ -21,7 +21,7 @@ use { std::{io, os::unix::ffi::OsStrExt, path::PathBuf}, }; -#[path = "../../build/wire/parser.rs"] +#[path = "../../../build/wire/parser.rs"] #[allow(dead_code)] mod parser; diff --git a/src/client.rs b/src/client.rs index fd3b7266..058d2373 100644 --- a/src/client.rs +++ b/src/client.rs @@ -80,6 +80,7 @@ pub struct ClientMetadata { pub sandbox_engine: Option, pub app_id: Option, pub instance_id: Option, + pub tag: Option, } impl Clients { diff --git a/src/compositor.rs b/src/compositor.rs index 783ca284..6520cadd 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -286,6 +286,7 @@ fn start_compositor2( display: Default::default(), }, acceptor: Default::default(), + tagged_acceptors: Default::default(), serial: Default::default(), idle_inhibitor_ids: Default::default(), run_toplevel, @@ -385,6 +386,7 @@ fn start_compositor2( bo_drop_queue: Rc::new(ObjectDropQueue::new(&ring)), virtual_outputs: Default::default(), clean_logs_older_than: Default::default(), + scratchpads: Default::default(), }); state.tracker.register(ClientId::from_raw(0)); create_dummy_output(&state); diff --git a/src/config/handler.rs b/src/config/handler.rs index 18f785ad..d50d54b4 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -8,7 +8,7 @@ use { }, client::{Client, ClientId}, cmm::cmm_eotf::Eotf, - compositor::MAX_EXTENTS, + compositor::{MAX_EXTENTS, WAYLAND_DISPLAY}, criteria::{ CritLiteralOrRegex, CritMgrExt, CritTarget, CritUpstreamNode, clm::ClmLeafMatcher, @@ -25,6 +25,7 @@ use { output_schedule::map_cursor_hz, scale::Scale, state::{ConnectorData, DeviceHandlerData, DrmDevData, OutputData, State}, + tagged_acceptor::TaggedAcceptorError, theme::{ThemeColor, ThemeSized}, tree::{ ContainerSplit, OutputNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier, @@ -406,6 +407,8 @@ enum CphError { UnknownFallbackOutputMode(FallbackOutputMode), #[error("Unknown tile state {0:?}")] UnknownTileState(ConfigTileState), + #[error("Could not create tagged acceptor")] + CreateTaggedAcceptor(#[source] TaggedAcceptorError), } trait WithRequestName { diff --git a/src/config/handler/dispatch.rs b/src/config/handler/dispatch.rs index 99efeeae..97936039 100644 --- a/src/config/handler/dispatch.rs +++ b/src/config/handler/dispatch.rs @@ -60,7 +60,7 @@ impl ConfigProxyHandler { ClientMessage::GetSeats => self.handle_get_seats(), ClientMessage::RemoveSeat { .. } => {} ClientMessage::Run { prog, args, env } => { - self.handle_run(prog, args, env, vec![]).wrn("run")? + self.handle_run(prog, args, env, vec![], None).wrn("run")? } ClientMessage::GrabKb { kb, grab } => self.handle_grab(kb, grab).wrn("grab")?, ClientMessage::SetColor { colorable, color } => { @@ -111,6 +111,15 @@ impl ConfigProxyHandler { ClientMessage::SetSeatWorkspace { seat, workspace } => self .handle_set_seat_workspace(seat, workspace) .wrn("set_seat_workspace")?, + ClientMessage::SeatSendToScratchpad { seat, name } => self + .handle_seat_send_to_scratchpad(seat, name) + .wrn("seat_send_to_scratchpad")?, + 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")? } @@ -268,7 +277,14 @@ impl ConfigProxyHandler { args, env, fds, - } => self.handle_run(prog, args, env, fds).wrn("run")?, + } => self.handle_run(prog, args, env, fds, None).wrn("run")?, + ClientMessage::Run3 { + prog, + args, + env, + fds, + tag, + } => self.handle_run(prog, args, env, fds, tag).wrn("run")?, ClientMessage::DisableDefaultSeat => self.state.create_default_seat.set(false), ClientMessage::DestroyKeymap { keymap } => self.handle_destroy_keymap(keymap), ClientMessage::GetConnectorName { connector } => self @@ -500,6 +516,9 @@ impl ConfigProxyHandler { ClientMessage::SetWindowWorkspace { window, workspace } => self .handle_set_window_workspace(window, workspace) .wrn("set_window_workspace")?, + ClientMessage::WindowSendToScratchpad { window, name } => self + .handle_window_send_to_scratchpad(window, name) + .wrn("window_send_to_scratchpad")?, ClientMessage::SetWindowFullscreen { window, fullscreen } => self .handle_set_window_fullscreen(window, fullscreen) .wrn("set_window_fullscreen")?, @@ -701,6 +720,11 @@ impl ConfigProxyHandler { ClientMessage::SetAutotile { enabled } => { self.state.theme.autotile_enabled.set(enabled); } + ClientMessage::GetAutotile => { + self.respond(Response::GetAutotile { + enabled: self.state.theme.autotile_enabled.get(), + }); + } ClientMessage::SeatToggleExpand { .. } => { // Removed feature; kept for binary protocol compatibility. } diff --git a/src/config/handler/matchers.rs b/src/config/handler/matchers.rs index cc00a346..ed9577d1 100644 --- a/src/config/handler/matchers.rs +++ b/src/config/handler/matchers.rs @@ -105,6 +105,7 @@ impl ConfigProxyHandler { } ClientCriterionStringField::Comm => mgr.comm(needle), ClientCriterionStringField::Exe => mgr.exe(needle), + ClientCriterionStringField::Tag => mgr.tag(needle), } } ClientCriterionPayload::Sandboxed => mgr.sandboxed(), diff --git a/src/config/handler/runtime.rs b/src/config/handler/runtime.rs index 2b730264..ebd286df 100644 --- a/src/config/handler/runtime.rs +++ b/src/config/handler/runtime.rs @@ -58,9 +58,18 @@ impl ConfigProxyHandler { &self, prog: &str, args: Vec, - env: Vec<(String, String)>, + mut env: Vec<(String, String)>, fds: Vec<(i32, i32)>, + tag: Option<&str>, ) -> Result<(), CphError> { + if let Some(tag) = tag { + let display = self + .state + .tagged_acceptors + .get(&self.state, tag) + .map_err(CphError::CreateTaggedAcceptor)?; + env.push((WAYLAND_DISPLAY.to_string(), display.to_string())); + } let fds: Vec<_> = fds .into_iter() .map(|(a, b)| (a, Rc::new(OwnedFd::new(b)))) diff --git a/src/config/handler/seats.rs b/src/config/handler/seats.rs index 3f52ee1b..3711a0af 100644 --- a/src/config/handler/seats.rs +++ b/src/config/handler/seats.rs @@ -120,6 +120,44 @@ impl ConfigProxyHandler { }) } + pub(super) fn handle_seat_send_to_scratchpad( + &self, + seat: Seat, + name: &str, + ) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + if let Some(toplevel) = seat.get_keyboard_node().node_toplevel() { + self.state.send_to_scratchpad(name, toplevel); + } + Ok(()) + }) + } + + pub(super) fn handle_seat_toggle_scratchpad( + &self, + seat: Seat, + name: &str, + ) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + self.state.toggle_scratchpad(&seat, name); + Ok(()) + }) + } + + pub(super) 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(()) + }) + } + pub(super) fn handle_get_repeat_rate(&self, seat: Seat) -> Result<(), CphError> { let seat = self.get_seat(seat)?; let (rate, delay) = seat.get_rate(); diff --git a/src/config/handler/windows.rs b/src/config/handler/windows.rs index faaf1b6e..829af1b8 100644 --- a/src/config/handler/windows.rs +++ b/src/config/handler/windows.rs @@ -124,6 +124,18 @@ impl ConfigProxyHandler { }) } + pub(super) fn handle_window_send_to_scratchpad( + &self, + window: Window, + name: &str, + ) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let window = self.get_window(window)?; + self.state.send_to_scratchpad(name, window); + Ok(()) + }) + } + pub(super) fn handle_window_exists(&self, window: Window) { self.respond(Response::WindowExists { exists: self.get_window(window).is_ok(), diff --git a/src/criteria/clm.rs b/src/criteria/clm.rs index 87f0a8db..a042d3f7 100644 --- a/src/criteria/clm.rs +++ b/src/criteria/clm.rs @@ -13,7 +13,7 @@ use { clmm_sandboxed::ClmMatchSandboxed, clmm_string::{ ClmMatchComm, ClmMatchExe, ClmMatchSandboxAppId, ClmMatchSandboxEngine, - ClmMatchSandboxInstanceId, + ClmMatchSandboxInstanceId, ClmMatchTag, }, clmm_uid::ClmMatchUid, }, @@ -62,6 +62,7 @@ pub struct RootMatchers { pid: ClmRootMatcherMap, comm: ClmRootMatcherMap, exe: ClmRootMatcherMap, + tag: ClmRootMatcherMap, id: ClmRootMatcherMap, } @@ -74,6 +75,7 @@ impl RootMatchers { self.pid.clear(); self.comm.clear(); self.exe.clear(); + self.tag.clear(); self.id.clear(); } } @@ -185,6 +187,7 @@ impl ClMatcherManager { unconditional!(pid); unconditional!(comm); unconditional!(exe); + unconditional!(tag); unconditional!(id); fixed!(sandboxed); fixed!(is_xwayland); @@ -228,6 +231,9 @@ impl ClMatcherManager { self.root(ClmMatchExe::new(string)) } + pub fn tag(&self, string: CritLiteralOrRegex) -> Rc { + self.root(ClmMatchTag::new(string)) + } } pub struct ClientTargetOwner { diff --git a/src/criteria/clm/clm_matchers/clmm_string.rs b/src/criteria/clm/clm_matchers/clmm_string.rs index aaf82198..367bdf0a 100644 --- a/src/criteria/clm/clm_matchers/clmm_string.rs +++ b/src/criteria/clm/clm_matchers/clmm_string.rs @@ -14,6 +14,7 @@ pub type ClmMatchString = CritMatchString; pub type ClmMatchSandboxEngine = ClmMatchString>; pub type ClmMatchSandboxAppId = ClmMatchString>; pub type ClmMatchSandboxInstanceId = ClmMatchString>; +pub type ClmMatchTag = ClmMatchString>; pub type ClmMatchComm = ClmMatchString; pub type ClmMatchExe = ClmMatchString; @@ -31,6 +32,7 @@ trait ClientMetadataField: Sized + 'static { pub struct SandboxEngineField; pub struct SandboxAppIdField; pub struct SandboxInstanceIdField; +pub struct TagField; impl StringAccess for ClientMetadataAccess where @@ -81,6 +83,18 @@ impl ClientMetadataField for SandboxInstanceIdField { } } +impl ClientMetadataField for TagField { + fn field(meta: &ClientMetadata) -> &Option { + &meta.tag + } + + fn nodes( + roots: &RootMatchers, + ) -> &ClmRootMatcherMap>> { + &roots.tag + } +} + impl StringAccess for CommAccess { fn with_string(data: &Client, f: impl FnOnce(&str) -> bool) -> bool { f(&data.pid_info.comm) diff --git a/src/ifs/wl_surface.rs b/src/ifs/wl_surface.rs index 2397925e..195f1e8c 100644 --- a/src/ifs/wl_surface.rs +++ b/src/ifs/wl_surface.rs @@ -1364,25 +1364,25 @@ impl WlSurface { let bounds = self.toplevel.get().and_then(|tl| tl.tl_render_bounds()); let pos = self.buffer_abs_pos.get(); let apply_damage = |pos: Rect| { - if pending.damage_full { - let mut damage = pos; + let clip_damage = |mut damage: Rect| { + damage = damage.intersect(pos); if let Some(bounds) = bounds { damage = damage.intersect(bounds); } - self.client.state.damage(damage); + damage + }; + if pending.damage_full { + self.client.state.damage(clip_damage(pos)); } else { let matrix = self.damage_matrix.get(); if let Some(buffer) = self.buffer.get() { for damage in &pending.buffer_damage { - let mut damage = matrix.apply( + let damage = matrix.apply( pos.x1(), pos.y1(), damage.intersect(buffer.buffer.buf.rect), ); - if let Some(bounds) = bounds { - damage = damage.intersect(bounds); - } - self.client.state.damage(damage); + self.client.state.damage(clip_damage(damage)); } } for damage in &pending.surface_damage { @@ -1394,8 +1394,7 @@ impl WlSurface { let y2 = (damage.y2() + scale - 1) / scale; damage = Rect::new_saturating(x1, y1, x2, y2); } - damage = damage.intersect(bounds.unwrap_or(pos)); - self.client.state.damage(damage); + self.client.state.damage(clip_damage(damage)); } } }; diff --git a/src/it/test_config.rs b/src/it/test_config.rs index 267e7833..a81ba905 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -272,6 +272,27 @@ impl TestConfig { }) } + pub fn send_to_scratchpad(&self, seat: SeatId, name: &str) -> TestResult { + self.send(ClientMessage::SeatSendToScratchpad { + seat: Seat(seat.raw() as _), + name, + }) + } + + pub fn toggle_scratchpad(&self, seat: SeatId, name: &str) -> TestResult { + self.send(ClientMessage::SeatToggleScratchpad { + seat: Seat(seat.raw() as _), + name, + }) + } + + 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() { @@ -319,6 +340,10 @@ impl TestConfig { pub fn set_show_titles(&self, show: bool) -> TestResult { self.send(ClientMessage::SetShowTitles { show }) } + + pub fn set_autotile(&self, enabled: bool) -> TestResult { + self.send(ClientMessage::SetAutotile { enabled }) + } } impl Drop for TestConfig { diff --git a/src/it/test_ifs/test_viewport.rs b/src/it/test_ifs/test_viewport.rs index b25105c8..e08266de 100644 --- a/src/it/test_ifs/test_viewport.rs +++ b/src/it/test_ifs/test_viewport.rs @@ -29,6 +29,17 @@ impl TestViewport { Ok(()) } + pub fn unset_source(&self) -> Result<(), TestError> { + self.tran.send(SetSource { + self_id: self.id, + x: Fixed::from_int(-1), + y: Fixed::from_int(-1), + width: Fixed::from_int(-1), + height: Fixed::from_int(-1), + })?; + Ok(()) + } + pub fn set_destination(&self, width: i32, height: i32) -> Result<(), TestError> { self.tran.send(SetDestination { self_id: self.id, @@ -37,6 +48,15 @@ impl TestViewport { })?; Ok(()) } + + pub fn unset_destination(&self) -> Result<(), TestError> { + self.tran.send(SetDestination { + self_id: self.id, + width: -1, + height: -1, + })?; + Ok(()) + } } impl Drop for TestViewport { diff --git a/src/it/tests.rs b/src/it/tests.rs index dc28888c..35b6be97 100644 --- a/src/it/tests.rs +++ b/src/it/tests.rs @@ -85,6 +85,8 @@ mod t0051_pointer_warp; mod t0052_bar; mod t0053_theme; mod t0054_subsurface_already_attached; +mod t0055_autotiling; +mod t0055_scratchpad; pub trait TestCase: Sync { fn name(&self) -> &'static str; @@ -158,5 +160,7 @@ pub fn tests() -> Vec<&'static dyn TestCase> { t0052_bar, t0053_theme, t0054_subsurface_already_attached, + t0055_autotiling, + t0055_scratchpad, } } diff --git a/src/it/tests/t0002_window.rs b/src/it/tests/t0002_window.rs index 84571c57..28ee359f 100644 --- a/src/it/tests/t0002_window.rs +++ b/src/it/tests/t0002_window.rs @@ -1,7 +1,6 @@ use { crate::{ it::{test_error::TestError, testrun::TestRun}, - rect::Rect, tree::Node, }, std::rc::Rc, @@ -11,29 +10,19 @@ testcase!(); /// Create and map a single surface async fn test(run: Rc) -> Result<(), TestError> { - run.backend.install_default()?; + let ds = run.create_default_setup().await?; let client = run.create_client().await?; let window = client.create_window().await?; window.map().await?; - tassert_eq!(window.tl.core.width.get(), 800); - tassert_eq!( - window.tl.core.height.get(), - 600 - 2 * run.state.theme.title_plus_underline_height() - ); + let workspace_rect = ds.output.workspace_rect.get(); - tassert_eq!( - window.tl.server.node_absolute_position(), - Rect::new_sized( - 0, - 2 * run.state.theme.title_plus_underline_height(), - window.tl.core.width.get(), - window.tl.core.height.get(), - ) - .unwrap() - ); + tassert_eq!(window.tl.core.width.get(), workspace_rect.width()); + tassert_eq!(window.tl.core.height.get(), workspace_rect.height()); + + tassert_eq!(window.tl.server.node_absolute_position(), workspace_rect); Ok(()) } diff --git a/src/it/tests/t0003_multi_window.rs b/src/it/tests/t0003_multi_window.rs index 3fbf599c..db726f90 100644 --- a/src/it/tests/t0003_multi_window.rs +++ b/src/it/tests/t0003_multi_window.rs @@ -11,7 +11,7 @@ testcase!(); /// Create and map two surfaces async fn test(run: Rc) -> Result<(), TestError> { - run.backend.install_default()?; + let ds = run.create_default_setup().await?; let client = run.create_client().await?; @@ -21,17 +21,30 @@ async fn test(run: Rc) -> Result<(), TestError> { let window2 = client.create_window().await?; window2.map().await?; - let otop = 2 * run.state.theme.title_plus_underline_height(); + let workspace_rect = ds.output.workspace_rect.get(); let bw = run.state.theme.sizes.border_width.get(); + let child_width = (workspace_rect.width() - bw) / 2; tassert_eq!( window.tl.server.node_absolute_position(), - Rect::new_sized(0, otop, (800 - bw) / 2, 600 - otop).unwrap() + Rect::new_sized( + workspace_rect.x1(), + workspace_rect.y1(), + child_width, + workspace_rect.height(), + ) + .unwrap() ); tassert_eq!( window2.tl.server.node_absolute_position(), - Rect::new_sized((800 - bw) / 2 + bw, otop, (800 - bw) / 2, 600 - otop).unwrap() + Rect::new_sized( + workspace_rect.x1() + child_width + bw, + workspace_rect.y1(), + child_width, + workspace_rect.height(), + ) + .unwrap() ); Ok(()) diff --git a/src/it/tests/t0007_subsurface/screenshot_1.qoi b/src/it/tests/t0007_subsurface/screenshot_1.qoi index 230c0408..b5954651 100644 Binary files a/src/it/tests/t0007_subsurface/screenshot_1.qoi and b/src/it/tests/t0007_subsurface/screenshot_1.qoi differ diff --git a/src/it/tests/t0007_subsurface/screenshot_2.qoi b/src/it/tests/t0007_subsurface/screenshot_2.qoi index 722271f6..718d5c29 100644 Binary files a/src/it/tests/t0007_subsurface/screenshot_2.qoi and b/src/it/tests/t0007_subsurface/screenshot_2.qoi differ diff --git a/src/it/tests/t0014_container_scroll_focus.rs b/src/it/tests/t0014_container_scroll_focus.rs index 0186cbaf..dccd1096 100644 --- a/src/it/tests/t0014_container_scroll_focus.rs +++ b/src/it/tests/t0014_container_scroll_focus.rs @@ -48,13 +48,18 @@ async fn test(run: Rc) -> TestResult { let mono_container = w_mono2.tl.container_parent()?; let container_pos = mono_container.tl_data().pos.get(); - let w_mono1_title = mono_container.render_data.borrow_mut().title_rects[0] - .move_(container_pos.x1(), container_pos.y1()); - ds.mouse.abs( - &ds.connector, - w_mono1_title.x1() as _, - w_mono1_title.y1() as _, - ); + let (tab_x, tab_y) = { + let tab_bar = mono_container.tab_bar.borrow(); + let Some(tab_bar) = tab_bar.as_ref() else { + bail!("no tab bar"); + }; + let w_mono1_title = &tab_bar.entries[0]; + ( + container_pos.x1() + w_mono1_title.x.get() + w_mono1_title.width.get() / 2, + container_pos.y1() + tab_bar.height / 2, + ) + }; + ds.mouse.abs(&ds.connector, tab_x as _, tab_y as _); client.sync().await; tassert!(enters.next().is_err()); diff --git a/src/it/tests/t0015_scroll_partial.rs b/src/it/tests/t0015_scroll_partial.rs index c6cf49b7..f5cb6e3c 100644 --- a/src/it/tests/t0015_scroll_partial.rs +++ b/src/it/tests/t0015_scroll_partial.rs @@ -26,12 +26,18 @@ async fn test(run: Rc) -> TestResult { let container = w_mono2.tl.container_parent()?; let pos = container.tl_data().pos.get(); - let w_mono1_title = container.render_data.borrow_mut().title_rects[0].move_(pos.x1(), pos.y1()); - ds.mouse.abs( - &ds.connector, - w_mono1_title.x1() as f64, - w_mono1_title.y1() as f64, - ); + let (tab_x, tab_y) = { + let tab_bar = container.tab_bar.borrow(); + let Some(tab_bar) = tab_bar.as_ref() else { + bail!("no tab bar"); + }; + let w_mono1_title = &tab_bar.entries[0]; + ( + pos.x1() + w_mono1_title.x.get() + w_mono1_title.width.get() / 2, + pos.y1() + tab_bar.height / 2, + ) + }; + ds.mouse.abs(&ds.connector, tab_x as f64, tab_y as f64); client.sync().await; let enters = dss.kb.enter.expect()?; diff --git a/src/it/tests/t0020_surface_offset/screenshot_1.qoi b/src/it/tests/t0020_surface_offset/screenshot_1.qoi index eef5f37a..4c826f86 100644 Binary files a/src/it/tests/t0020_surface_offset/screenshot_1.qoi and b/src/it/tests/t0020_surface_offset/screenshot_1.qoi differ diff --git a/src/it/tests/t0020_surface_offset/screenshot_2.qoi b/src/it/tests/t0020_surface_offset/screenshot_2.qoi index 7e8cf143..0fb763e2 100644 Binary files a/src/it/tests/t0020_surface_offset/screenshot_2.qoi and b/src/it/tests/t0020_surface_offset/screenshot_2.qoi differ diff --git a/src/it/tests/t0022_toplevel_suspended.rs b/src/it/tests/t0022_toplevel_suspended.rs index 1fdacb1a..524856e3 100644 --- a/src/it/tests/t0022_toplevel_suspended.rs +++ b/src/it/tests/t0022_toplevel_suspended.rs @@ -2,7 +2,7 @@ use { crate::{ ifs::wl_surface::xdg_surface::xdg_toplevel::STATE_SUSPENDED, it::{ - test_error::TestResult, + test_error::{TestErrorExt, TestResult}, test_utils::{ test_ouput_node_ext::TestOutputNodeExt, test_toplevel_node_ext::TestToplevelNodeExt, }, @@ -10,7 +10,7 @@ use { }, }, isnt::std_1::collections::IsntHashSetExt, - std::rc::Rc, + std::{rc::Rc, time::Duration}, }; testcase!(); @@ -19,6 +19,7 @@ async fn test(run: Rc) -> TestResult { let ds = run.create_default_setup().await?; let client = run.create_client().await?; + let default_seat = client.get_default_seat().await?; let win1 = client.create_window().await?; win1.set_color(255, 0, 0, 255); @@ -44,5 +45,23 @@ async fn test(run: Rc) -> TestResult { client.sync().await; tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); + let leaves = default_seat.kb.leave.expect()?; + let enters = default_seat.kb.enter.expect()?; + + run.cfg.set_idle(Duration::from_micros(100))?; + run.cfg.set_idle_grace_period(Duration::from_secs(0))?; + run.state.wheel.timeout(3).await?; + + client.sync().await; + tassert!(win2.tl.core.states.borrow().contains(&STATE_SUSPENDED)); + let leave = leaves.next().with_context(|| "no leave on suspend")?; + tassert_eq!(leave.surface, win2.surface.id); + + ds.mouse.rel(1.0, 1.0); + client.sync().await; + tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); + let enter = enters.next().with_context(|| "no enter on restore")?; + tassert_eq!(enter.surface, win2.surface.id); + Ok(()) } diff --git a/src/it/tests/t0023_xdg_activation/screenshot_1.qoi b/src/it/tests/t0023_xdg_activation/screenshot_1.qoi index 1fa8d204..960da20a 100644 Binary files a/src/it/tests/t0023_xdg_activation/screenshot_1.qoi and b/src/it/tests/t0023_xdg_activation/screenshot_1.qoi differ diff --git a/src/it/tests/t0026_output_transform/screenshot_1.qoi b/src/it/tests/t0026_output_transform/screenshot_1.qoi index 2206fc85..f11111bb 100644 Binary files a/src/it/tests/t0026_output_transform/screenshot_1.qoi and b/src/it/tests/t0026_output_transform/screenshot_1.qoi differ diff --git a/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi b/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi index f7bf53bf..9f5fca3c 100644 Binary files a/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi and b/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi differ diff --git a/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi b/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi index b454acd3..aaf1b108 100644 Binary files a/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi and b/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi differ diff --git a/src/it/tests/t0029_double_click_float/screenshot_1.qoi b/src/it/tests/t0029_double_click_float/screenshot_1.qoi index dd974ccf..e08dc525 100644 Binary files a/src/it/tests/t0029_double_click_float/screenshot_1.qoi and b/src/it/tests/t0029_double_click_float/screenshot_1.qoi differ diff --git a/src/it/tests/t0029_double_click_float/screenshot_2.qoi b/src/it/tests/t0029_double_click_float/screenshot_2.qoi index f49edd4d..e08dc525 100644 Binary files a/src/it/tests/t0029_double_click_float/screenshot_2.qoi and b/src/it/tests/t0029_double_click_float/screenshot_2.qoi differ diff --git a/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi b/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi index b9826001..36c68e4e 100644 Binary files a/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi and b/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi differ diff --git a/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi b/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi index 988bc767..e6f6db74 100644 Binary files a/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi and b/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi differ diff --git a/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi b/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi index a7509404..9abc8de3 100644 Binary files a/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi and b/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi differ diff --git a/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi b/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi index 8fe5d0b2..80a29c84 100644 Binary files a/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi and b/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi differ diff --git a/src/it/tests/t0039_alpha_modifier/screenshot_2.qoi b/src/it/tests/t0039_alpha_modifier/screenshot_2.qoi index 9874e2f5..735af290 100644 Binary files a/src/it/tests/t0039_alpha_modifier/screenshot_2.qoi and b/src/it/tests/t0039_alpha_modifier/screenshot_2.qoi differ diff --git a/src/it/tests/t0041_input_method/screenshot_1.qoi b/src/it/tests/t0041_input_method/screenshot_1.qoi index d25fcf64..cd07ecd4 100644 Binary files a/src/it/tests/t0041_input_method/screenshot_1.qoi and b/src/it/tests/t0041_input_method/screenshot_1.qoi differ diff --git a/src/it/tests/t0041_input_method/screenshot_2.qoi b/src/it/tests/t0041_input_method/screenshot_2.qoi index 7f93231a..d76ea9a0 100644 Binary files a/src/it/tests/t0041_input_method/screenshot_2.qoi and b/src/it/tests/t0041_input_method/screenshot_2.qoi differ diff --git a/src/it/tests/t0041_input_method/screenshot_3.qoi b/src/it/tests/t0041_input_method/screenshot_3.qoi index d25fcf64..cd07ecd4 100644 Binary files a/src/it/tests/t0041_input_method/screenshot_3.qoi and b/src/it/tests/t0041_input_method/screenshot_3.qoi differ diff --git a/src/it/tests/t0042_toplevel_select/screenshot_1.qoi b/src/it/tests/t0042_toplevel_select/screenshot_1.qoi index 6423ef6d..6d57d140 100644 Binary files a/src/it/tests/t0042_toplevel_select/screenshot_1.qoi and b/src/it/tests/t0042_toplevel_select/screenshot_1.qoi differ diff --git a/src/it/tests/t0042_toplevel_select/screenshot_2.qoi b/src/it/tests/t0042_toplevel_select/screenshot_2.qoi index 823fd750..478b3c43 100644 Binary files a/src/it/tests/t0042_toplevel_select/screenshot_2.qoi and b/src/it/tests/t0042_toplevel_select/screenshot_2.qoi differ diff --git a/src/it/tests/t0042_toplevel_select/screenshot_3.qoi b/src/it/tests/t0042_toplevel_select/screenshot_3.qoi index 823fd750..478b3c43 100644 Binary files a/src/it/tests/t0042_toplevel_select/screenshot_3.qoi and b/src/it/tests/t0042_toplevel_select/screenshot_3.qoi differ diff --git a/src/it/tests/t0042_toplevel_select/screenshot_4.qoi b/src/it/tests/t0042_toplevel_select/screenshot_4.qoi index 714222f1..07dd87fb 100644 Binary files a/src/it/tests/t0042_toplevel_select/screenshot_4.qoi and b/src/it/tests/t0042_toplevel_select/screenshot_4.qoi differ diff --git a/src/it/tests/t0047_surface_damage.rs b/src/it/tests/t0047_surface_damage.rs index d9760bc8..c2d0d6dd 100644 --- a/src/it/tests/t0047_surface_damage.rs +++ b/src/it/tests/t0047_surface_damage.rs @@ -308,9 +308,8 @@ async fn test(run: Rc) -> TestResult { let output_damage = connector_data.damage.borrow(); tassert!(!output_damage.is_empty()); - // Buffer damage is transformed by the damage matrix which includes the surface position - // The buffer damage (0,0,1,1) should be transformed to surface coordinates - let expected_buffer_damage = buffer_damage.move_(surface_pos.x1(), surface_pos.y1()); + // The test window maps its 1x1 buffer through a viewport to the full window size. + let expected_buffer_damage = surface_pos; // Find the exact output damage that matches our expected buffer damage let mut found_exact_buffer_damage = false; @@ -331,10 +330,12 @@ async fn test(run: Rc) -> TestResult { // Test 7: Check output damage from existing window's viewport (which already has scaling) connector_data.damage.borrow_mut().clear(); - // The existing window was created with create_surface_ext() which automatically creates a viewport - // Let's verify that the viewport's existing scaling affects buffer damage correctly - // First, let's modify the viewport scaling that already exists on the window - window.surface.viewport.set_destination(150, 100)?; // Change scaling to 150x100 + // The existing window was created with create_surface_ext() which automatically creates a viewport. + // Commit the viewport size change separately; that commit intentionally damages the old/new extents. + window.surface.viewport.set_destination(150, 100)?; + window.surface.commit()?; + client.sync().await; + connector_data.damage.borrow_mut().clear(); // Add buffer damage to test viewport scaling coordinate transformation window.surface.damage_buffer(0, 0, 1, 1)?; // Damage entire 1x1 buffer @@ -346,8 +347,8 @@ async fn test(run: Rc) -> TestResult { let output_damage = connector_data.damage.borrow(); tassert!(!output_damage.is_empty()); - // With viewporter scaling, the 1x1 buffer damage should scale to 150x100 - // and be moved by surface position (0, 36) to get output coordinates (0, 36, 150, 136) + // With viewporter scaling, the 1x1 buffer damage should scale to the viewport destination. + let surface_pos = window.surface.server.buffer_abs_pos.get(); let expected_scaled_damage = Rect::new_sized(0, 0, 150, 100).unwrap(); let expected_output_damage = expected_scaled_damage.move_(surface_pos.x1(), surface_pos.y1()); @@ -402,8 +403,9 @@ async fn test(run: Rc) -> TestResult { rotation_window.map().await?; client.sync().await; - // Disable viewporter by setting destination to 0x0 to rely purely on buffer dimensions - rotation_window.surface.viewport.set_destination(0, 0)?; // Disable viewporter + // Disable viewporter to rely purely on buffer dimensions. + rotation_window.surface.viewport.unset_source()?; + rotation_window.surface.viewport.unset_destination()?; // Use a rectangular buffer (4x2) so rotation has a visible geometric effect // Attach AFTER mapping to avoid being overwritten by map()'s single-pixel buffer diff --git a/src/it/tests/t0055_autotiling.rs b/src/it/tests/t0055_autotiling.rs new file mode 100644 index 00000000..4b3611c4 --- /dev/null +++ b/src/it/tests/t0055_autotiling.rs @@ -0,0 +1,58 @@ +use { + crate::{ + it::{test_error::TestResult, testrun::TestRun}, + tree::{ContainerSplit, Node, ToplevelNodeBase}, + }, + std::rc::Rc, +}; + +testcase!(); + +async fn test(run: Rc) -> TestResult { + run.backend.install_default()?; + run.cfg.set_autotile(true)?; + + let client = run.create_client().await?; + + let win1 = client.create_window().await?; + win1.map().await?; + let root = win1.tl.container_parent()?; + tassert_eq!(root.split.get(), ContainerSplit::Horizontal); + + let win2 = client.create_window().await?; + win2.map().await?; + client.sync().await; + + tassert_eq!(root.split.get(), ContainerSplit::Horizontal); + tassert_eq!(win1.tl.container_parent()?.node_id(), root.node_id()); + tassert_eq!(win2.tl.container_parent()?.node_id(), root.node_id()); + + let win3 = client.create_window().await?; + win3.map().await?; + client.sync().await; + + let v_group = win3.tl.container_parent()?; + tassert_eq!(root.split.get(), ContainerSplit::Horizontal); + tassert_eq!(v_group.split.get(), ContainerSplit::Vertical); + tassert_eq!(win2.tl.container_parent()?.node_id(), v_group.node_id()); + + let win4 = client.create_window().await?; + win4.map().await?; + client.sync().await; + + let h_group = win4.tl.container_parent()?; + tassert_eq!(h_group.split.get(), ContainerSplit::Horizontal); + tassert_eq!(win3.tl.container_parent()?.node_id(), h_group.node_id()); + let h_parent = match h_group + .tl_data() + .parent + .get() + .and_then(|p| p.node_into_container()) + { + Some(parent) => parent, + None => bail!("autotile group does not have a container parent"), + }; + tassert_eq!(h_parent.node_id(), v_group.node_id()); + + Ok(()) +} diff --git a/src/it/tests/t0055_scratchpad.rs b/src/it/tests/t0055_scratchpad.rs new file mode 100644 index 00000000..5abf2440 --- /dev/null +++ b/src/it/tests/t0055_scratchpad.rs @@ -0,0 +1,107 @@ +use { + crate::{ + it::{test_error::TestResult, testrun::TestRun}, + tree::{Node, ToplevelNodeBase}, + }, + std::rc::Rc, +}; + +testcase!(); + +async fn test(run: Rc) -> TestResult { + let ds = run.create_default_setup().await?; + + let client = run.create_client().await?; + let win1 = client.create_window().await?; + win1.map2().await?; + let win2 = client.create_window().await?; + win2.map2().await?; + + run.cfg.send_to_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win1.tl.server.node_visible()); + tassert!(!win2.tl.server.node_visible()); + + run.cfg.show_workspace(ds.seat.id(), "2")?; + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win2.tl.server.node_visible()); + tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "2"); + + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(!win2.tl.server.node_visible()); + + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win2.tl.server.node_visible()); + tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "2"); + + run.cfg.show_workspace(ds.seat.id(), "3")?; + client.sync().await; + tassert!(!win2.tl.server.node_visible()); + + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + 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/main.rs b/src/main.rs index e95b8c01..22c1f8bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,6 +99,7 @@ mod scale; mod screenshoter; mod sighand; mod state; +mod tagged_acceptor; mod tasks; mod text; mod theme; diff --git a/src/state.rs b/src/state.rs index f81ed745..7186c6c9 100644 --- a/src/state.rs +++ b/src/state.rs @@ -87,6 +87,7 @@ use { pr_caps::PrCapsThread, rect::Rect, scale::Scale, + tagged_acceptor::TaggedAcceptors, theme::Theme, time::Time, tree::{ @@ -185,6 +186,7 @@ pub struct State { pub run_args: RunArgs, pub xwayland: XWaylandState, pub acceptor: CloneCell>>, + pub tagged_acceptors: TaggedAcceptors, pub serial: NumCell, pub run_toplevel: Rc, pub config_dir: Option, @@ -283,6 +285,7 @@ pub struct State { pub bo_drop_queue: Rc>>, pub virtual_outputs: VirtualOutputs, pub clean_logs_older_than: Cell>, + pub scratchpads: RefCell>>>, } // impl Drop for State { @@ -302,6 +305,27 @@ pub struct ScreenlockState { pub lock: CloneCell>>, } +pub struct ScratchpadEntry { + node: Weak, + identifier: ToplevelIdentifier, + hidden: Cell, +} + +impl ScratchpadEntry { + fn alive(&self) -> bool { + self.node().is_some() + } + + fn node(&self) -> Option> { + let node = self.node.upgrade()?; + if node.tl_data().identifier.get() == self.identifier { + Some(node) + } else { + None + } + } +} + pub struct InputDeviceData { pub _handler: SpawnedFuture<()>, pub id: InputDeviceId, @@ -475,6 +499,7 @@ impl State { self.eng.clear(); self.ei_acceptor.take(); self.ei_acceptor_future.take(); + self.tagged_acceptors.clear(); self.ei_clients.clear(); self.slow_ei_clients.clear(); self.toplevels.clear(); @@ -484,6 +509,7 @@ impl State { self.node_at_tree.borrow_mut().clear(); self.position_hint_requests.clear(); self.pending_warp_mouse_to_focus.clear(); + self.scratchpads.borrow_mut().clear(); self.head_managers.clear(); self.head_managers_async.clear(); self.const_40hz_latch.clear(); diff --git a/src/state/animations.rs b/src/state/animations.rs index 98bc1995..39e712bb 100644 --- a/src/state/animations.rs +++ b/src/state/animations.rs @@ -859,4 +859,3 @@ mod tests { assert_eq!(merged[1].node_id, NodeId(2)); } } - diff --git a/src/state/tree_ops.rs b/src/state/tree_ops.rs index 8a8e8f99..5e0ed85e 100644 --- a/src/state/tree_ops.rs +++ b/src/state/tree_ops.rs @@ -5,13 +5,14 @@ use { tree::{ ContainerNode, ContainerSplit, Direction, FindTreeUsecase, FloatNode, FoundNode, Node, OutputNode, TileState, ToplevelData, ToplevelNode, ToplevelNodeBase, WorkspaceNode, - WsMoveConfig, generic_node_visitor, move_ws_to_output, + WsMoveConfig, generic_node_visitor, move_ws_to_output, toplevel_hide_for_scratchpad, + toplevel_restore_from_scratchpad, toplevel_set_workspace, }, }, - std::{ops::Deref, rc::Rc}, + std::{cell::Cell, ops::Deref, rc::Rc}, }; -use super::State; +use super::{ScratchpadEntry, State}; impl State { pub fn tree_changed(&self) { @@ -41,19 +42,39 @@ impl State { && node.tl_data().kind.is_app_window() && !node.tl_data().visible.get(); if animate_new_app_map { - self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone())); + self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone(), true)); } else { - self.do_map_tiled(seat.as_deref(), node.clone()); + self.do_map_tiled(seat.as_deref(), node.clone(), true); } self.focus_after_map(node, seat.as_deref()); } - fn do_map_tiled(self: &Rc, seat: Option<&Rc>, node: Rc) { + pub fn map_tiled_without_autotile(self: &Rc, node: Rc) { + let seat = self.seat_queue.last(); + self.do_map_tiled(seat.as_deref(), node.clone(), false); + self.focus_after_map(node, seat.as_deref()); + } + + fn do_map_tiled( + self: &Rc, + seat: Option<&Rc>, + node: Rc, + autotile: bool, + ) { let ws = self.ensure_map_workspace(seat); - self.map_tiled_on(node, &ws); + self.map_tiled_on_(node, &ws, autotile); } pub fn map_tiled_on(self: &Rc, node: Rc, ws: &Rc) { + self.map_tiled_on_(node, ws, false); + } + + fn map_tiled_on_( + self: &Rc, + node: Rc, + ws: &Rc, + autotile: bool, + ) { if let Some(c) = ws.container.get() { let la = c.clone().tl_last_active_child(); let lap = la @@ -62,7 +83,11 @@ impl State { .get() .and_then(|n| n.node_into_container()); if let Some(lap) = lap { - lap.add_child_after(&*la, node); + if autotile { + lap.add_tiled_child_after(&*la, node); + } else { + lap.add_child_after(&*la, node); + } } else { c.append_child(node); } @@ -115,6 +140,146 @@ impl State { float } + pub fn send_to_scratchpad(self: &Rc, name: &str, node: Rc) { + if node.node_is_placeholder() { + 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(true), + }); + { + let mut scratchpads = self.scratchpads.borrow_mut(); + for entries in scratchpads.values_mut() { + entries.retain(|entry| entry.alive() && entry.identifier != identifier); + } + scratchpads + .entry(name.to_string()) + .or_default() + .push(entry); + } + self.tree_changed(); + } + + pub fn toggle_scratchpad(self: &Rc, seat: &Rc, name: &str) { + let entry = { + let mut scratchpads = self.scratchpads.borrow_mut(); + let Some(entries) = scratchpads.get_mut(name) else { + 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()) + .or_else(|| entries.last()) + .cloned() + }; + let Some(entry) = entry else { + return; + }; + if entry.hidden.get() { + 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 { + self.hide_scratchpad_entry(&entry); + } + } + + /// 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 toplevel_hide_for_scratchpad(node) { + entry.hidden.set(true); + self.tree_changed(); + } + } + + fn show_scratchpad_entry( + self: &Rc, + seat: &Rc, + name: &str, + entry: &Rc, + ) { + if !entry.hidden.get() { + return; + } + let Some(node) = entry.node() 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); + entry.hidden.set(false); + node.node_do_focus(seat, Direction::Unspecified); + seat.maybe_schedule_warp_mouse_to_focus(); + self.tree_changed(); + } + + fn move_scratchpad_entry_to_current_workspace( + self: &Rc, + seat: &Rc, + entry: &Rc, + ) { + let Some(node) = entry.node() else { + return; + }; + let ws = seat.get_fallback_output().ensure_workspace(); + toplevel_set_workspace(self, node.clone(), &ws); + node.node_do_focus(seat, Direction::Unspecified); + seat.maybe_schedule_warp_mouse_to_focus(); + self.tree_changed(); + } + fn focus_after_map(&self, node: Rc, seat: Option<&Rc>) { if !node.node_visible() { return; diff --git a/src/tagged_acceptor.rs b/src/tagged_acceptor.rs new file mode 100644 index 00000000..9133f29a --- /dev/null +++ b/src/tagged_acceptor.rs @@ -0,0 +1,191 @@ +use { + crate::{ + async_engine::SpawnedFuture, + client::ClientMetadata, + state::State, + utils::{ + errorfmt::ErrorFmt, + numcell::NumCell, + oserror::{OsError, OsErrorExt, OsErrorExt2}, + xrd::xrd, + }, + }, + ahash::AHashMap, + std::{ + cell::{Cell, RefCell}, + rc::Rc, + }, + thiserror::Error, + uapi::{OwnedFd, Ustring, c, format_ustr}, +}; + +#[derive(Debug, Error)] +pub enum TaggedAcceptorError { + #[error("XDG_RUNTIME_DIR is not set")] + XrdNotSet, + #[error("XDG_RUNTIME_DIR ({0:?}) is too long to form a unix socket address")] + XrdTooLong(String), + #[error("Could not create a wayland socket")] + SocketFailed(#[source] OsError), + #[error("Could not stat the existing socket")] + SocketStat(#[source] OsError), + #[error("Could not start listening for incoming connections")] + ListenFailed(#[source] OsError), + #[error("Could not open the lock file")] + OpenLockFile(#[source] OsError), + #[error("Could not lock the lock file")] + LockLockFile(#[source] OsError), + #[error("Could not bind the socket to an address")] + BindFailed(#[source] OsError), +} + +#[derive(Default)] +pub struct TaggedAcceptors { + acceptors: RefCell>>, + next_name: NumCell, +} + +struct Acceptor { + socket: AllocatedSocket, + tag: String, + state: Rc, + metadata: Rc, + future: Cell>>, +} + +impl TaggedAcceptors { + pub fn clear(&self) { + let acceptors = self.acceptors.take(); + for (_, acceptor) in acceptors { + acceptor.kill(); + } + } + + pub fn get(&self, state: &Rc, tag: &str) -> Result, TaggedAcceptorError> { + let acceptors = &mut *self.acceptors.borrow_mut(); + if let Some(acceptor) = acceptors.get(tag) { + return Ok(acceptor.socket.name.clone()); + } + let acceptor = Rc::new(Acceptor { + socket: self.allocate_socket()?, + tag: tag.to_owned(), + state: state.clone(), + metadata: Rc::new(ClientMetadata { + tag: Some(tag.to_owned()), + ..Default::default() + }), + future: Default::default(), + }); + log::info!("Creating tagged acceptor `{tag}`"); + acceptor.future.set(Some( + state.eng.spawn("tagged accept", acceptor.clone().accept()), + )); + acceptors.insert(tag.to_owned(), acceptor.clone()); + Ok(acceptor.socket.name.clone()) + } + + fn allocate_socket(&self) -> Result { + let xrd = xrd().ok_or(TaggedAcceptorError::XrdNotSet)?; + let socket = uapi::socket(c::AF_UNIX, c::SOCK_STREAM | c::SOCK_CLOEXEC, 0) + .map(Rc::new) + .map_os_err(TaggedAcceptorError::SocketFailed)?; + loop { + let i = self.next_name.fetch_add(1) + 1000; + if let Some(s) = bind_socket(&socket, &xrd, i)? { + return Ok(s); + } + } + } +} + +impl Acceptor { + fn kill(&self) { + log::info!("Destroying tagged acceptor `{}`", self.tag); + self.future.take(); + self.state + .tagged_acceptors + .acceptors + .borrow_mut() + .remove(&self.tag); + } + + async fn accept(self: Rc) { + let state = &self.state; + loop { + let fd = match state.ring.accept(&self.socket.socket, c::SOCK_CLOEXEC).await { + Ok(fd) => fd, + Err(e) => { + log::error!("Could not accept a client: {}", ErrorFmt(e)); + break; + } + }; + let id = state.clients.id(); + if let Err(e) = state.clients.spawn(id, state, fd, &self.metadata) { + log::error!("Could not spawn a client: {}", ErrorFmt(e)); + break; + } + } + self.kill(); + } +} + +struct AllocatedSocket { + // wayland-x + name: Rc, + // /run/user/1000/wayland-x + path: Ustring, + socket: Rc, + // /run/user/1000/wayland-x.lock + lock_path: Ustring, + _lock_fd: OwnedFd, +} + +impl Drop for AllocatedSocket { + fn drop(&mut self) { + let _ = uapi::unlink(&self.path); + let _ = uapi::unlink(&self.lock_path); + } +} + +fn bind_socket( + fd: &Rc, + xrd: &str, + id: u64, +) -> Result, TaggedAcceptorError> { + let mut addr: c::sockaddr_un = uapi::pod_zeroed(); + addr.sun_family = c::AF_UNIX as _; + let name = Rc::new(format!("wayland-{}", id)); + let path = format_ustr!("{}/{}", xrd, name); + let lock_path = format_ustr!("{}.lock", path.display()); + if path.len() + 1 > addr.sun_path.len() { + return Err(TaggedAcceptorError::XrdTooLong(xrd.to_string())); + } + let lock_fd = uapi::open(&*lock_path, c::O_CREAT | c::O_CLOEXEC | c::O_RDWR, 0o644) + .map_os_err(TaggedAcceptorError::OpenLockFile)?; + if let Err(e) = uapi::flock(lock_fd.raw(), c::LOCK_EX | c::LOCK_NB).to_os_error() { + if e.0 == c::EWOULDBLOCK { + return Ok(None); + } + return Err(TaggedAcceptorError::LockLockFile(e)); + } + match uapi::lstat(&path).to_os_error() { + Ok(_) => { + log::info!("Unlinking {}", path.display()); + let _ = uapi::unlink(&path); + } + Err(OsError(c::ENOENT)) => {} + Err(e) => return Err(TaggedAcceptorError::SocketStat(e)), + } + let sun_path = uapi::as_bytes_mut(&mut addr.sun_path[..]); + sun_path[..path.len()].copy_from_slice(path.as_bytes()); + sun_path[path.len()] = 0; + uapi::bind(fd.raw(), &addr).map_os_err(TaggedAcceptorError::BindFailed)?; + uapi::listen(fd.raw(), 4096).map_os_err(TaggedAcceptorError::ListenFailed)?; + Ok(Some(AllocatedSocket { + name, + path, + socket: fd.clone(), + lock_path, + _lock_fd: lock_fd, + })) +} diff --git a/src/tree/container.rs b/src/tree/container.rs index 3fbda5a7..85b1e065 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -36,6 +36,7 @@ use { linkedlist::{LinkedList, LinkedNode, NodeRef}, numcell::NumCell, rc_eq::rc_eq, + scroller::Scroller, threshold_counter::ThresholdCounter, }, }, @@ -154,6 +155,7 @@ pub struct ContainerNode { pub child_removed: Rc, pub all_children_resized: Rc, pub tab_bar: RefCell>, + scroll: Scroller, pub update_tab_textures_scheduled: Cell, pub ephemeral: Cell, } @@ -253,6 +255,7 @@ impl ContainerNode { child_removed: state.lazy_event_sources.create_source(), all_children_resized: state.post_layout_event_sources.create_source(), tab_bar: RefCell::new(None), + scroll: Default::default(), update_tab_textures_scheduled: Cell::new(false), ephemeral: Cell::new(Ephemeral::Off), }); @@ -277,6 +280,47 @@ impl ContainerNode { self.add_child_x(prev, new, |prev, new| self.add_child_after_(prev, new)); } + pub fn add_tiled_child_after(self: &Rc, prev: &dyn Node, new: Rc) { + if !self.state.theme.autotile_enabled.get() + || self.mono_child.is_some() + || self.num_children.get() <= 1 + { + self.add_child_after(prev, new); + return; + } + let focused = self + .child_nodes + .borrow() + .get(&prev.node_id()) + .map(|n| n.to_ref()); + let Some(focused) = focused else { + log::error!( + "Tried to autotile a child into a container but the preceding node is not in the container" + ); + return; + }; + let focused_node = focused.node.clone(); + let focused_active = focused_node.tl_data().active(); + let sub = ContainerNode::new( + &self.state, + &self.workspace.get(), + focused_node.clone(), + self.split.get().other(), + ); + // Autotile-created groups are structural and collapse once only one + // child remains. Explicit make-group commands control their own + // grouping through the regular manual paths. + sub.ephemeral.set(Ephemeral::On); + sub.append_child(new); + let sub_id = sub.node_id(); + self.clone().cnode_replace_child(&*focused_node, sub); + if focused_active + && let Some(group) = self.child_nodes.borrow().get(&sub_id).map(|n| n.to_ref()) + { + self.update_child_active(&group, true, 1); + } + } + pub fn add_child_before(self: &Rc, prev: &dyn Node, new: Rc) { self.add_child_x(prev, new, |prev, new| self.add_child_before_(prev, new)); } @@ -527,6 +571,18 @@ impl ContainerNode { self.activate_child2(child, false); } + fn activate_child_from_input( + self: &Rc, + child: &NodeRef, + seat: &Rc, + ) { + self.activate_child(child); + child + .node + .clone() + .node_do_focus(seat, Direction::Unspecified); + } + fn activate_child2(self: &Rc, child: &NodeRef, preserve_focus: bool) { if let Some(mc) = self.mono_child.get() { if mc.node.node_id() == child.node.node_id() { @@ -1144,42 +1200,6 @@ impl ContainerNode { } pub fn insert_child(self: &Rc, node: Rc, direction: Direction) { - // Autotile: if the container would become too narrow/tall, wrap the - // focused child and new node in a perpendicular sub-container. - if self.state.theme.autotile_enabled.get() && self.mono_child.is_none() { - let (pw, ph) = self.predict_child_body_size(); - let opposite = match self.split.get() { - ContainerSplit::Horizontal if pw > 0 && ph > 0 && pw < ph => { - Some(ContainerSplit::Vertical) - } - ContainerSplit::Vertical if pw > 0 && ph > 0 && ph < pw => { - Some(ContainerSplit::Horizontal) - } - _ => None, - }; - if let Some(opp_split) = opposite { - if let Some(focused) = self.focus_history.last() { - if self.num_children.get() <= 1 { - // Single child, autotile not applicable. - } else { - let focused_node = focused.node.clone(); - let was_ephemeral = self.ephemeral.replace(Ephemeral::Off); - self.clone().cnode_remove_child2(&*focused_node, true); - self.ephemeral.set(was_ephemeral); - let sub = ContainerNode::new( - &self.state, - &self.workspace.get(), - focused_node, - opp_split, - ); - sub.ephemeral.set(Ephemeral::On); - sub.append_child(node); - self.append_child(sub); - return; - } - } - } - } let (split, right) = direction_to_split(direction); if split != self.split.get() || right { self.append_child(node); @@ -1289,7 +1309,7 @@ impl ContainerNode { fn button( self: Rc, id: CursorType, - _seat: &Rc, + seat: &Rc, _time_usec: u64, pressed: bool, button: u32, @@ -1319,7 +1339,7 @@ impl ContainerNode { if let Some(child) = children.get(&child_id) { let child_ref = child.to_ref(); drop(children); - self.activate_child(&child_ref); + self.activate_child_from_input(&child_ref, seat); } return; } @@ -1692,31 +1712,33 @@ impl Node for ContainerNode { self.button(id, seat, time_usec, state == ButtonState::Pressed, button); } - fn node_on_axis_event(self: Rc, _seat: &Rc, event: &PendingScroll) { + fn node_on_axis_event(self: Rc, seat: &Rc, event: &PendingScroll) { if self.mono_child.is_none() { return; } - // Use vertical scroll (index 1) to switch tabs. - let v = match event.v120[1].get() { - Some(v) if v != 0 => v, + let steps = match self.scroll.handle(event) { + Some(steps) => steps, _ => return, }; - let mono = match self.mono_child.get() { + let mut target = match self.mono_child.get() { Some(m) => m, None => return, }; - let next = if v > 0 { - // Scroll down → next tab. - mono.next().or_else(|| self.children.first()) - } else { - // Scroll up → previous tab. - mono.prev().or_else(|| self.children.last()) - }; - if let Some(next) = next { - if next.node.node_id() != mono.node.node_id() { - self.activate_child(&next); + let current_id = target.node.node_id(); + for _ in 0..steps.abs() { + let next = if steps > 0 { + target.next().or_else(|| self.children.first()) + } else { + target.prev().or_else(|| self.children.last()) + }; + match next { + Some(next) => target = next, + None => break, } } + if target.node.node_id() != current_id { + self.activate_child_from_input(&target, seat); + } } fn node_on_leave(&self, seat: &WlSeatGlobal) { diff --git a/src/tree/container/tasks.rs b/src/tree/container/tasks.rs index 4c90413e..977e47f0 100644 --- a/src/tree/container/tasks.rs +++ b/src/tree/container/tasks.rs @@ -154,4 +154,3 @@ impl ContainerNode { self.damage(); } } - diff --git a/src/tree/display.rs b/src/tree/display.rs index 440916bf..26b31a88 100644 --- a/src/tree/display.rs +++ b/src/tree/display.rs @@ -8,18 +8,25 @@ use { renderer::Renderer, state::State, tree::{ - FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink, NodeLocation, - OutputNode, StackedNode, TileDragDestination, WorkspaceDragDestination, + Direction, FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink, + NodeLocation, OutputNode, StackedNode, TileDragDestination, WorkspaceDragDestination, WorkspaceNodeId, walker::NodeVisitor, }, utils::{copyhashmap::CopyHashMap, linkedlist::LinkedList}, }, - std::{cell::Cell, ops::Deref, rc::Rc}, + std::{ + cell::{Cell, RefCell}, + mem, + ops::Deref, + rc::{Rc, Weak}, + }, }; pub struct DisplayNode { pub id: NodeId, pub extents: Cell, + visible: Cell, + suspend_restore_kb_foci: RefCell, Weak)>>, pub outputs: CopyHashMap>, pub stacked: Rc>>, pub stacked_above_layers: Rc>>, @@ -31,6 +38,8 @@ impl DisplayNode { let slf = Self { id, extents: Default::default(), + visible: Default::default(), + suspend_restore_kb_foci: Default::default(), outputs: Default::default(), stacked: Default::default(), stacked_above_layers: Default::default(), @@ -71,6 +80,17 @@ impl DisplayNode { pub fn update_visible(&self, state: &State) { let visible = state.root_visible(); + let was_visible = self.visible.replace(visible); + if !visible && was_visible { + let mut foci = self.suspend_restore_kb_foci.borrow_mut(); + foci.clear(); + for seat in state.globals.seats.lock().values() { + let node = seat.get_keyboard_node(); + if node.node_id() != self.id { + foci.push((seat.clone(), Rc::downgrade(&node))); + } + } + } for output in self.outputs.lock().values() { output.update_visible(); } @@ -82,6 +102,20 @@ impl DisplayNode { for seat in state.globals.seats.lock().values() { seat.set_visible(visible); } + if visible && !was_visible { + for (seat, node) in mem::take(&mut *self.suspend_restore_kb_foci.borrow_mut()) { + if seat.get_keyboard_node().node_id() == self.id { + if let Some(node) = node.upgrade() + && node.node_visible() + { + seat.focus_node(node); + } else { + seat.get_fallback_output() + .take_keyboard_navigation_focus(&seat, Direction::Unspecified); + } + } + } + } if visible { state.damage(self.extents.get()); } diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 2b06ec42..22f9ea9a 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -967,7 +967,7 @@ impl ToplevelData { } fd.workspace.remove_fullscreen_node(); if fd.placeholder.is_destroyed() { - state.map_tiled(node); + state.map_tiled_without_autotile(node); return; } let parent = fd.placeholder.tl_data().parent.take().unwrap(); @@ -1247,7 +1247,7 @@ pub fn toplevel_set_floating(state: &Rc, tl: Rc, floati }; if !floating { parent.cnode_remove_child2(&*tl, true); - state.map_tiled(tl); + state.map_tiled_without_autotile(tl); } else if let Some(ws) = data.workspace.get() { let node_id = data.node_id; let old_body = @@ -1308,3 +1308,54 @@ pub fn toplevel_set_workspace(state: &Rc, tl: Rc, ws: & tl.tl_set_fullscreen(true, Some(ws.clone())); } } + +/// 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 false; + } + let data = tl.tl_data(); + let workspace = data.workspace.get(); + if data.is_fullscreen.get() { + tl.clone().tl_set_fullscreen(false, None); + if data.is_fullscreen.get() { + return false; + } + } + 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(); + data.float.take(); + if data.parent_is_float.replace(false) { + data.property_changed(TL_CHANGED_FLOATING); + } + if data.workspace.take().is_some() { + data.property_changed(TL_CHANGED_WORKSPACE); + } + tl.tl_set_visible(false); + if let Some(workspace) = &workspace { + for seat in kb_foci { + workspace + .clone() + .node_do_focus(&seat, Direction::Unspecified); + } + } + 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, + tl: Rc, + ws: &Rc, +) { + let (width, height) = tl.tl_data().float_size(ws); + state.map_floating(tl.clone(), width, height, ws, None); +}