From 50cb14936e2e095dbddb2f4f4eb42b00753eccc1 Mon Sep 17 00:00:00 2001 From: Nicolaus Jacobsen Date: Thu, 9 Jul 2026 06:59:52 +0200 Subject: [PATCH 1/2] config: add absolute size / position getters / setters and window rules --- jay-config/src/_private/client.rs | 58 +++++++ jay-config/src/_private/ipc.rs | 48 ++++++ jay-config/src/input.rs | 12 ++ jay-config/src/lib.rs | 10 ++ jay-config/src/video.rs | 10 ++ jay-config/src/window.rs | 57 ++++++ src/config.rs | 10 ++ src/config/handler.rs | 162 +++++++++++++++++- src/ifs/wl_surface/x_surface/xwindow.rs | 11 +- .../wl_surface/xdg_surface/xdg_toplevel.rs | 6 +- src/state.rs | 8 + src/tree/toplevel.rs | 19 ++ toml-config/src/config.rs | 10 ++ toml-config/src/config/parsers.rs | 1 + toml-config/src/config/parsers/action.rs | 18 ++ .../parsers/window_floating_geometry.rs | 67 ++++++++ toml-config/src/config/parsers/window_rule.rs | 55 +++++- toml-config/src/lib.rs | 6 + toml-config/src/rules.rs | 6 + toml-spec/spec/spec.generated.json | 88 ++++++++++ toml-spec/spec/spec.generated.md | 147 ++++++++++++++++ toml-spec/spec/spec.yaml | 111 ++++++++++++ 22 files changed, 905 insertions(+), 15 deletions(-) create mode 100644 toml-config/src/config/parsers/window_floating_geometry.rs diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 05cfeaade..8b4413b53 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -595,6 +595,42 @@ impl ConfigClient { workspace } + pub fn get_window_position(&self, window: Window) -> (i32, i32) { + let res = self.send_with_response(&ClientMessage::GetWindowPosition { window }); + get_response!(res, (0, 0), GetWindowPosition { x, y }); + (x, y) + } + + pub fn get_window_size(&self, window: Window) -> (i32, i32) { + let res = self.send_with_response(&ClientMessage::GetWindowSize { window }); + get_response!(res, (0, 0), GetWindowSize { width, height }); + (width, height) + } + + pub fn set_window_size(&self, window: Window, width: i32, height: i32) { + self.send(&ClientMessage::SetWindowSize { + window, + width, + height, + }); + } + + pub fn set_window_position(&self, window: Window, x: i32, y: i32) { + self.send(&ClientMessage::SetWindowPosition { window, x, y }); + } + + pub fn get_workspace_position(&self, workspace: Workspace) -> (i32, i32) { + let res = self.send_with_response(&ClientMessage::GetWorkspacePosition { workspace }); + get_response!(res, (0, 0), GetWorkspacePosition { x, y }); + (x, y) + } + + pub fn get_workspace_size(&self, workspace: Workspace) -> (i32, i32) { + let res = self.send_with_response(&ClientMessage::GetWorkspaceSize { workspace }); + get_response!(res, (0, 0), GetWorkspaceSize { width, height }); + (width, height) + } + pub fn get_seat_keyboard_workspace(&self, seat: Seat) -> Workspace { let res = self.send_with_response(&ClientMessage::GetSeatKeyboardWorkspace { seat }); get_response!(res, Workspace(0), GetSeatKeyboardWorkspace { workspace }); @@ -2108,6 +2144,28 @@ impl ConfigClient { }); } + pub fn set_window_matcher_initial_floating_size( + &self, + matcher: WindowMatcher, + width: i32, + height: i32, + ) { + self.send(&ClientMessage::SetWindowMatcherInitialFloatingSize { + matcher, + width, + height, + }); + } + + pub fn set_window_matcher_initial_floating_position( + &self, + matcher: WindowMatcher, + x: i32, + y: i32, + ) { + self.send(&ClientMessage::SetWindowMatcherInitialFloatingPosition { matcher, x, y }); + } + pub fn set_window_matcher_latch_handler( &self, matcher: WindowMatcher, diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 803297803..015d2e7a8 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -968,6 +968,38 @@ pub enum ClientMessage<'a> { connector: Connector, scaling_filter: ScalingFilter, }, + SetWindowMatcherInitialFloatingSize { + matcher: WindowMatcher, + width: i32, + height: i32, + }, + SetWindowMatcherInitialFloatingPosition { + matcher: WindowMatcher, + x: i32, + y: i32, + }, + GetWindowPosition { + window: Window, + }, + GetWindowSize { + window: Window, + }, + SetWindowSize { + window: Window, + width: i32, + height: i32, + }, + SetWindowPosition { + window: Window, + x: i32, + y: i32, + }, + GetWorkspacePosition { + workspace: Workspace, + }, + GetWorkspaceSize { + workspace: Workspace, + }, } #[derive(Serialize, Deserialize, Debug)] @@ -1239,6 +1271,22 @@ pub enum Response { GetContainerBorders { borders: ContainerBorders, }, + GetWindowPosition { + x: i32, + y: i32, + }, + GetWindowSize { + width: i32, + height: i32, + }, + GetWorkspacePosition { + x: i32, + y: i32, + }, + GetWorkspaceSize { + width: i32, + height: i32, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index f87f07487..5339acb2a 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -713,6 +713,18 @@ impl Seat { self.window().resize(dx1, dy1, dx2, dy2); } + /// Resizes the focused window to an absolute size. + pub fn set_size(self, width: i32, height: i32) { + self.window().set_size(width, height); + } + + /// Sets the position of the focused window to an absolute value. + /// + /// This only has an effect if the window is floating. + pub fn set_position(self, x: i32, y: i32) { + self.window().set_position(x, y); + } + /// Sets whether the cursor should automatically move to the center of a window /// when focus changes via keyboard commands (move-left, focus-right, show-workspace, etc.). /// diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index 85f4ebbcf..3c93e504d 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -232,6 +232,16 @@ impl Workspace { pub fn set_initial_connector(self, connector: Option) { get!().set_workspace_initial_connector(self, connector); } + + /// Returns the position of the workspace in the global compositor space. + pub fn position(self) -> (i32, i32) { + get!((0, 0)).get_workspace_position(self) + } + + /// Returns the size of the workspace. + pub fn size(self) -> (i32, i32) { + get!((0, 0)).get_workspace_size(self) + } } /// Returns the workspace with the given name. diff --git a/jay-config/src/video.rs b/jay-config/src/video.rs index b54348558..023915a11 100644 --- a/jay-config/src/video.rs +++ b/jay-config/src/video.rs @@ -184,6 +184,16 @@ impl Connector { get!().connector_size(self).1 } + /// Returns the logical size of the connector. + /// + /// This is a shortcut for `(width(), height())` that only performs a single round-trip. + pub fn size(self) -> (i32, i32) { + if !self.exists() { + return (0, 0); + } + get!((0, 0)).connector_size(self) + } + /// Returns the refresh rate in mhz of the current mode of the connector. /// /// This is a shortcut for `mode().refresh_rate()`. diff --git a/jay-config/src/window.rs b/jay-config/src/window.rs index 662cda44c..bdd9f76db 100644 --- a/jay-config/src/window.rs +++ b/jay-config/src/window.rs @@ -241,6 +241,31 @@ impl Window { pub fn resize(self, dx1: i32, dy1: i32, dx2: i32, dy2: i32) { get!().resize_window(self, dx1, dy1, dx2, dy2); } + + /// Returns the position of the window in the global compositor space. + pub fn position(self) -> (i32, i32) { + get!((0, 0)).get_window_position(self) + } + + /// Returns the size of the window. + pub fn size(self) -> (i32, i32) { + get!((0, 0)).get_window_size(self) + } + + /// Resizes the window to an absolute size. + /// + /// This has the same effect as [`resize`](Self::resize) but takes the target size + /// instead of a delta. + pub fn set_size(self, width: i32, height: i32) { + get!().set_window_size(self, width, height); + } + + /// Sets the position of the window in the global compositor space. + /// + /// This only has an effect if the window is floating. + pub fn set_position(self, x: i32, y: i32) { + get!().set_window_position(self, x, y); + } } /// A window matcher. @@ -354,6 +379,24 @@ impl WindowCriterion<'_> { pub fn set_initial_tile_state(self, tile_state: TileState) { self.to_matcher().set_initial_tile_state(tile_state); } + + /// Sets the initial size of matched windows while they are floating. + /// + /// If multiple such window matchers match a window, the used size is unspecified. + /// + /// This leaks the matcher. + pub fn set_initial_floating_size(self, width: i32, height: i32) { + self.to_matcher().set_initial_floating_size(width, height); + } + + /// Sets the initial position of matched windows while they are floating. + /// + /// If multiple such window matchers match a window, the used position is unspecified. + /// + /// This leaks the matcher. + pub fn set_initial_floating_position(self, x: i32, y: i32) { + self.to_matcher().set_initial_floating_position(x, y); + } } impl WindowMatcher { @@ -387,6 +430,20 @@ impl WindowMatcher { pub fn set_initial_tile_state(self, tile_state: TileState) { get!().set_window_matcher_initial_tile_state(self, tile_state); } + + /// Sets the initial size of matched windows while they are floating. + /// + /// If multiple such window matchers match a window, the used size is unspecified. + pub fn set_initial_floating_size(self, width: i32, height: i32) { + get!().set_window_matcher_initial_floating_size(self, width, height); + } + + /// Sets the initial position of matched windows while they are floating. + /// + /// If multiple such window matchers match a window, the used position is unspecified. + pub fn set_initial_floating_position(self, x: i32, y: i32) { + get!().set_window_matcher_initial_floating_position(self, x, y); + } } impl MatchedWindow { diff --git a/src/config.rs b/src/config.rs index eebe15375..7ed1b850c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -201,6 +201,14 @@ impl ConfigProxy { self.handler.get()?.initial_tile_state(data) } + pub fn initial_floating_size(&self, data: &ToplevelData) -> Option<(i32, i32)> { + self.handler.get()?.initial_floating_size(data) + } + + pub fn initial_floating_position(&self, data: &ToplevelData) -> Option<(i32, i32)> { + self.handler.get()?.initial_floating_position(data) + } + pub fn initial_output_for_workspace(&self, name: &str) -> Option>> { self.handler.get()?.initial_output_for_workspace(name) } @@ -281,6 +289,8 @@ impl ConfigProxy { window_matcher_std_kinds: state.tl_matcher_manager.kind(window::CLIENT_WINDOW), window_matcher_no_auto_focus: Default::default(), window_matcher_initial_tile_state: Default::default(), + window_matcher_initial_floating_size: Default::default(), + window_matcher_initial_floating_position: Default::default(), }); let init_msg = bincode_ops() .serialize(&InitMessage::V1(V1InitMessage {})) diff --git a/src/config/handler.rs b/src/config/handler.rs index 6b5072d43..ba317ffc3 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -30,7 +30,7 @@ use { tagged_acceptor::TaggedAcceptorError, theme::{ThemeColored, ThemeSized}, tree::{ - ContainerSplit, OutputNode, OutputNodeOrPersistent, TearingMode, TileState, + ContainerSplit, NodeBase, OutputNode, OutputNodeOrPersistent, TearingMode, TileState, ToplevelData, ToplevelIdentifier, ToplevelNode, TreeTimeline::LiveTL, VrrMode, WorkspaceNode, WorkspaceType, WsMoveConfig, move_ws_to_output, toplevel_create_split, toplevel_parent_container, toplevel_set_floating, toplevel_set_workspace, @@ -165,6 +165,20 @@ pub(super) struct ConfigProxyHandler { TileState, ), >, + pub window_matcher_initial_floating_size: CopyHashMap< + WindowMatcher, + ( + Rc>, + (i32, i32), + ), + >, + pub window_matcher_initial_floating_position: CopyHashMap< + WindowMatcher, + ( + Rc>, + (i32, i32), + ), + >, } pub struct ConfigWorkspace { @@ -2590,6 +2604,9 @@ impl ConfigProxyHandler { self.window_matcher_leafs.remove(&matcher); self.window_matcher_no_auto_focus.remove(&matcher); self.window_matcher_initial_tile_state.remove(&matcher); + self.window_matcher_initial_floating_size.remove(&matcher); + self.window_matcher_initial_floating_position + .remove(&matcher); } fn handle_enable_window_matcher_events( @@ -2647,6 +2664,33 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_set_window_matcher_initial_floating_size( + &self, + matcher: WindowMatcher, + width: i32, + height: i32, + ) -> Result<(), CphError> { + if width <= 0 || height <= 0 { + return Err(CphError::InvalidWindowSize(width, height)); + } + let m = self.get_window_matcher(matcher)?; + self.window_matcher_initial_floating_size + .set(matcher, (m, (width, height))); + Ok(()) + } + + fn handle_set_window_matcher_initial_floating_position( + &self, + matcher: WindowMatcher, + x: i32, + y: i32, + ) -> Result<(), CphError> { + let m = self.get_window_matcher(matcher)?; + self.window_matcher_initial_floating_position + .set(matcher, (m, (x, y))); + Ok(()) + } + fn handle_set_pointer_revert_key(&self, seat: Seat, key: KeySym) -> Result<(), CphError> { self.get_seat(seat)?.set_pointer_revert_key(key); Ok(()) @@ -2984,6 +3028,66 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_get_window_position(&self, window: Window) -> Result<(), CphError> { + let pos = self.get_window(window)?.node_absolute_position(LiveTL); + self.respond(Response::GetWindowPosition { + x: pos.x1(), + y: pos.y1(), + }); + Ok(()) + } + + fn handle_get_window_size(&self, window: Window) -> Result<(), CphError> { + let pos = self.get_window(window)?.node_absolute_position(LiveTL); + self.respond(Response::GetWindowSize { + width: pos.width(), + height: pos.height(), + }); + Ok(()) + } + + fn handle_set_window_size( + &self, + window: Window, + width: i32, + height: i32, + ) -> Result<(), CphError> { + if width <= 0 || height <= 0 { + return Err(CphError::InvalidWindowSize(width, height)); + } + self.get_window(window)?.tl_set_size(width, height); + Ok(()) + } + + fn handle_set_window_position(&self, window: Window, x: i32, y: i32) -> Result<(), CphError> { + self.get_window(window)?.tl_set_position(x, y); + Ok(()) + } + + fn handle_get_workspace_position(&self, workspace: Workspace) -> Result<(), CphError> { + let pos = self + .get_existing_workspace(workspace)? + .map(|ws| ws.node_absolute_position(LiveTL)) + .unwrap_or_default(); + self.respond(Response::GetWorkspacePosition { + x: pos.x1(), + y: pos.y1(), + }); + Ok(()) + } + + fn handle_get_workspace_size(&self, workspace: Workspace) -> Result<(), CphError> { + let pos = self + .get_existing_workspace(workspace)? + .map(|ws| ws.node_absolute_position(LiveTL)) + .unwrap_or_default(); + self.respond(Response::GetWorkspaceSize { + width: pos.width(), + height: pos.height(), + }); + Ok(()) + } + fn handle_window_exists(&self, window: Window) { self.respond(Response::WindowExists { exists: self.get_window(window).is_ok(), @@ -3735,6 +3839,16 @@ impl ConfigProxyHandler { } => self .handle_set_window_matcher_initial_tile_state(matcher, tile_state) .wrn("set_window_matcher_initial_tile_state")?, + ClientMessage::SetWindowMatcherInitialFloatingSize { + matcher, + width, + height, + } => self + .handle_set_window_matcher_initial_floating_size(matcher, width, height) + .wrn("set_window_matcher_initial_floating_size")?, + ClientMessage::SetWindowMatcherInitialFloatingPosition { matcher, x, y } => self + .handle_set_window_matcher_initial_floating_position(matcher, x, y) + .wrn("set_window_matcher_initial_floating_position")?, ClientMessage::SetPointerRevertKey { seat, key } => self .handle_set_pointer_revert_key(seat, key) .wrn("set_pointer_revert_key")?, @@ -3875,6 +3989,28 @@ impl ConfigProxyHandler { } => self .handle_window_resize(window, dx1, dy1, dx2, dy2) .wrn("window_resize")?, + ClientMessage::GetWindowPosition { window } => self + .handle_get_window_position(window) + .wrn("get_window_position")?, + ClientMessage::GetWindowSize { window } => { + self.handle_get_window_size(window).wrn("get_window_size")? + } + ClientMessage::SetWindowSize { + window, + width, + height, + } => self + .handle_set_window_size(window, width, height) + .wrn("set_window_size")?, + ClientMessage::SetWindowPosition { window, x, y } => self + .handle_set_window_position(window, x, y) + .wrn("set_window_position")?, + ClientMessage::GetWorkspacePosition { workspace } => self + .handle_get_workspace_position(workspace) + .wrn("get_workspace_position")?, + ClientMessage::GetWorkspaceSize { workspace } => self + .handle_get_workspace_size(workspace) + .wrn("get_workspace_size")?, ClientMessage::SetSessionManagementEnabled { enabled } => { self.handle_set_session_management_enabled(enabled) } @@ -3974,6 +4110,28 @@ impl ConfigProxyHandler { None } + pub fn initial_floating_size(&self, data: &ToplevelData) -> Option<(i32, i32)> { + for (matcher, size) in self.window_matcher_initial_floating_size.lock().values() { + if matcher.node.pull(data) { + return Some(*size); + } + } + None + } + + pub fn initial_floating_position(&self, data: &ToplevelData) -> Option<(i32, i32)> { + for (matcher, pos) in self + .window_matcher_initial_floating_position + .lock() + .values() + { + if matcher.node.pull(data) { + return Some(*pos); + } + } + None + } + pub fn initial_output_for_workspace(&self, name: &str) -> Option>> { let ws = self.workspaces_by_name.get(name)?; let connector = ws.initial_connector.get()?; @@ -4047,6 +4205,8 @@ enum CphError { OutputIsNotDesktop(Connector), #[error("{0}x{1} is not a valid connector position")] InvalidConnectorPosition(i32, i32), + #[error("{0}x{1} is not a valid window size")] + InvalidWindowSize(i32, i32), #[error("Keymap {0:?} does not exist")] KeymapDoesNotExist(Keymap), #[error("Seat {0:?} does not exist")] diff --git a/src/ifs/wl_surface/x_surface/xwindow.rs b/src/ifs/wl_surface/x_surface/xwindow.rs index a57a59745..3e4ca8eb6 100644 --- a/src/ifs/wl_surface/x_surface/xwindow.rs +++ b/src/ifs/wl_surface/x_surface/xwindow.rs @@ -306,11 +306,14 @@ impl Xwindow { self.data.state.tree_changed(); } Change::Map if map_floating => { - let ws = self.data.state.float_map_ws(); let ext = self.data.info.pending_extents.get(); - self.data - .state - .map_floating(self.clone(), ext.width(), ext.height(), &ws, None); + let state = &self.data.state; + let ws = state.float_map_ws(); + let (width, height) = state + .initial_floating_size(&self.toplevel_data) + .unwrap_or_else(|| (ext.width(), ext.height())); + let abs_pos = state.initial_floating_position(&self.toplevel_data); + state.map_floating(self.clone(), width, height, &ws, abs_pos); self.data.title_changed(); } Change::Map => { diff --git a/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs b/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs index 4d479418c..5cdd51f70 100644 --- a/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs +++ b/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs @@ -416,7 +416,11 @@ impl XdgToplevel { } fn map_floating(self: &Rc, workspace: &Rc, abs_pos: Option<(i32, i32)>) { - let (width, height) = self.toplevel_data.float_size(workspace); + let (width, height) = self + .state + .initial_floating_size(&self.toplevel_data) + .unwrap_or_else(|| self.toplevel_data.float_size(workspace)); + let abs_pos = abs_pos.or_else(|| self.state.initial_floating_position(&self.toplevel_data)); self.state .map_floating(self.clone(), width, height, workspace, abs_pos); } diff --git a/src/state.rs b/src/state.rs index d31fa3fa9..1819c78d3 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1876,6 +1876,14 @@ impl State { self.config.get()?.initial_tile_state(data) } + pub fn initial_floating_size(&self, data: &ToplevelData) -> Option<(i32, i32)> { + self.config.get()?.initial_floating_size(data) + } + + pub fn initial_floating_position(&self, data: &ToplevelData) -> Option<(i32, i32)> { + self.config.get()?.initial_floating_position(data) + } + pub fn update_capabilities( &self, data: &Rc, diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 91b289458..e3d34c91b 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -79,6 +79,8 @@ pub trait ToplevelNode: ToplevelNodeBase { fn tl_mark_ancestor_fullscreen(&self, fullscreen: bool); fn tl_mark_fullscreen(&self, connector: Option<&Rc>); fn tl_resize(&self, dx1: i32, dy1: i32, dx2: i32, dy2: i32); + fn tl_set_size(&self, width: i32, height: i32); + fn tl_set_position(&self, x: i32, y: i32); } impl ToplevelNode for T { @@ -304,6 +306,23 @@ impl ToplevelNode for T { let y2 = (dy2 != 0).then_some(pos.y2().saturating_add(dy2)); parent.cnode_resize_child(self, x1, y1, x2, y2); } + + fn tl_set_size(&self, width: i32, height: i32) { + let Some(parent) = self.tl_data().parent.get() else { + return; + }; + let pos = self.node_absolute_position(LiveTL); + let x2 = pos.x1().saturating_add(width); + let y2 = pos.y1().saturating_add(height); + parent.cnode_resize_child(self, None, None, Some(x2), Some(y2)); + } + + fn tl_set_position(&self, x: i32, y: i32) { + let Some(parent) = self.tl_data().parent.get() else { + return; + }; + parent.cnode_set_child_position(self, x, y); + } } pub trait ToplevelNodeBase: Node { diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 5cc5a7746..e035e3ab9 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -211,6 +211,14 @@ pub enum Action { dx2: i32, dy2: i32, }, + SetSize { + width: i32, + height: i32, + }, + SetPosition { + x: i32, + y: i32, + }, HideOverlay { ws: Rc, }, @@ -340,6 +348,8 @@ pub struct WindowRule { pub latch: Option, pub auto_focus: Option, pub initial_tile_state: Option, + pub initial_floating_size: Option<(i32, i32)>, + pub initial_floating_position: Option<(i32, i32)>, } #[derive(Default, Debug, Clone)] diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index 708e28c95..8db3bbdf3 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -51,6 +51,7 @@ mod tile_state; pub mod transactions; mod ui_drag; mod vrr; +mod window_floating_geometry; mod window_match; mod window_rule; mod window_type; diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index b46f77247..290fcb813 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -601,6 +601,22 @@ impl ActionParser<'_, '_> { }) } + fn parse_set_size(&mut self, ext: &mut Extractor<'_, '_>) -> ParseResult { + let (width, height) = ext.extract((s32("width"), s32("height")))?; + Ok(Action::SetSize { + width: width.value, + height: height.value, + }) + } + + fn parse_set_position(&mut self, ext: &mut Extractor<'_, '_>) -> ParseResult { + let (x, y) = ext.extract((s32("x"), s32("y")))?; + Ok(Action::SetPosition { + x: x.value, + y: y.value, + }) + } + fn parse_hide_overlay(&mut self, ext: &mut Extractor<'_, '_>) -> ParseResult { let (name,) = ext.extract((str("name"),))?; let ws = self.0.get_workspace_slot(name.value); @@ -674,6 +690,8 @@ impl Parser for ActionParser<'_, '_> { "create-virtual-output" => self.parse_create_virtual_output(&mut ext), "remove-virtual-output" => self.parse_remove_virtual_output(&mut ext), "resize" => self.parse_resize(&mut ext), + "set-size" => self.parse_set_size(&mut ext), + "set-position" => self.parse_set_position(&mut ext), "hide-overlay" => self.parse_hide_overlay(&mut ext), "show-overlay" => self.parse_show_overlay(&mut ext), "toggle-overlay" => self.parse_toggle_overlay(&mut ext), diff --git a/toml-config/src/config/parsers/window_floating_geometry.rs b/toml-config/src/config/parsers/window_floating_geometry.rs new file mode 100644 index 000000000..3d8babf16 --- /dev/null +++ b/toml-config/src/config/parsers/window_floating_geometry.rs @@ -0,0 +1,67 @@ +use { + crate::{ + config::{ + context::Context, + extractor::{Extractor, ExtractorError, s32}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + }, + toml::{ + toml_span::{Span, Spanned}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum FloatingSizeParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), +} + +pub struct FloatingSizeParser<'a, 'b>(pub &'a Context<'b>); + +impl Parser for FloatingSizeParser<'_, '_> { + type Value = (i32, i32); + type Error = FloatingSizeParserError; + 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 (width, height) = ext.extract((s32("width"), s32("height")))?; + Ok((width.value, height.value)) + } +} + +#[derive(Debug, Error)] +pub enum FloatingPositionParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), +} + +pub struct FloatingPositionParser<'a, 'b>(pub &'a Context<'b>); + +impl Parser for FloatingPositionParser<'_, '_> { + type Value = (i32, i32); + type Error = FloatingPositionParserError; + 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 (x, y) = ext.extract((s32("x"), s32("y")))?; + Ok((x.value, y.value)) + } +} diff --git a/toml-config/src/config/parsers/window_rule.rs b/toml-config/src/config/parsers/window_rule.rs index 1f8660558..c43eeaf55 100644 --- a/toml-config/src/config/parsers/window_rule.rs +++ b/toml-config/src/config/parsers/window_rule.rs @@ -8,6 +8,7 @@ use { parsers::{ action::{ActionParser, ActionParserError}, tile_state::TileStateParser, + window_floating_geometry::{FloatingPositionParser, FloatingSizeParser}, window_match::{WindowMatchParser, WindowMatchParserError}, }, spanned::SpannedErrorExt, @@ -48,15 +49,25 @@ impl Parser for WindowRuleParser<'_, '_> { table: &IndexMap, Spanned>, ) -> ParseResult { let mut ext = Extractor::new(self.0, span, table); - let (name, match_val, action_val, latch_val, auto_focus, initial_tile_state_val) = ext - .extract(( - opt(str("name")), - opt(val("match")), - opt(val("action")), - opt(val("latch")), - recover(opt(bol("auto-focus"))), - opt(val("initial-tile-state")), - ))?; + let ( + name, + match_val, + action_val, + latch_val, + auto_focus, + initial_tile_state_val, + initial_floating_size_val, + initial_floating_position_val, + ) = ext.extract(( + opt(str("name")), + opt(val("match")), + opt(val("action")), + opt(val("latch")), + recover(opt(bol("auto-focus"))), + opt(val("initial-tile-state")), + opt(val("initial-floating-size")), + opt(val("initial-floating-position")), + ))?; let mut action = None; if let Some(value) = action_val { action = Some( @@ -85,6 +96,30 @@ impl Parser for WindowRuleParser<'_, '_> { } } } + let mut initial_floating_size = None; + if let Some(value) = initial_floating_size_val { + match value.parse(&mut FloatingSizeParser(self.0)) { + Ok(v) => initial_floating_size = Some(v), + Err(e) => { + log::warn!( + "Could not parse the initial floating size: {}", + self.0.error(e) + ); + } + } + } + let mut initial_floating_position = None; + if let Some(value) = initial_floating_position_val { + match value.parse(&mut FloatingPositionParser(self.0)) { + Ok(v) => initial_floating_position = Some(v), + Err(e) => { + log::warn!( + "Could not parse the initial floating position: {}", + self.0.error(e) + ); + } + } + } let match_ = match match_val { None => WindowMatch::default(), Some(m) => m.parse_map(&mut WindowMatchParser(self.0))?, @@ -96,6 +131,8 @@ impl Parser for WindowRuleParser<'_, '_> { latch, auto_focus: auto_focus.despan(), initial_tile_state, + initial_floating_size, + initial_floating_position, }) } } diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 6f84a02b6..2994035fb 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -518,6 +518,12 @@ impl Action { Action::Resize { dx1, dy1, dx2, dy2 } => { window_or_seat!(s, s.resize(dx1, dy1, dx2, dy2)) } + Action::SetSize { width, height } => { + window_or_seat!(s, s.set_size(width, height)) + } + Action::SetPosition { x, y } => { + window_or_seat!(s, s.set_position(x, y)) + } Action::HideOverlay { ws } => { let workspace = ws.ws.get(); b.new(move || workspace.hide()) diff --git a/toml-config/src/rules.rs b/toml-config/src/rules.rs index b67a2cca6..4d70f437b 100644 --- a/toml-config/src/rules.rs +++ b/toml-config/src/rules.rs @@ -350,6 +350,12 @@ impl Rule for WindowRule { if let Some(tile_state) = self.initial_tile_state { matcher.set_initial_tile_state(tile_state); } + if let Some((width, height)) = self.initial_floating_size { + matcher.set_initial_floating_size(width, height); + } + if let Some((x, y)) = self.initial_floating_position { + matcher.set_initial_floating_position(x, y); + } } fn gen_matcher(m: Self::Matcher) -> Self::Criterion<'static> { diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 581f0af84..4ea841381 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -711,6 +711,50 @@ "type" ] }, + { + "description": "Resizes the focused window to an absolute size.\n\nThis has the same effect as `resize` but takes the target size instead of a\ndelta.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-x = { type = \"set-size\", width = 800, height = 600 }\n ```\n", + "type": "object", + "properties": { + "type": { + "const": "set-size" + }, + "width": { + "type": "integer", + "description": "The new width of the window." + }, + "height": { + "type": "integer", + "description": "The new height of the window." + } + }, + "required": [ + "type", + "width", + "height" + ] + }, + { + "description": "Sets the position of the focused window to an absolute value.\n\nThis only has an effect if the window is floating.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-x = { type = \"set-position\", x = 100, y = 100 }\n ```\n", + "type": "object", + "properties": { + "type": { + "const": "set-position" + }, + "x": { + "type": "integer", + "description": "The new x coordinate of the window." + }, + "y": { + "type": "integer", + "description": "The new y coordinate of the window." + } + }, + "required": [ + "type", + "x", + "y" + ] + }, { "description": "Hides an overlay if it is visible.", "type": "object", @@ -2566,6 +2610,42 @@ "variant3" ] }, + "WindowFloatingPosition": { + "description": "The initial position of a floating window.\n\n- Example:\n\n ```toml\n [[windows]]\n match.app-id = \"mpv\"\n initial-floating-position = { x = 100, y = 100 }\n ```\n", + "type": "object", + "properties": { + "x": { + "type": "integer", + "description": "The x coordinate of the window." + }, + "y": { + "type": "integer", + "description": "The y coordinate of the window." + } + }, + "required": [ + "x", + "y" + ] + }, + "WindowFloatingSize": { + "description": "The initial size of a floating window.\n\n- Example:\n\n ```toml\n [[windows]]\n match.app-id = \"mpv\"\n initial-floating-size = { width = 800, height = 600 }\n ```\n", + "type": "object", + "properties": { + "width": { + "type": "integer", + "description": "The width of the window." + }, + "height": { + "type": "integer", + "description": "The height of the window." + } + }, + "required": [ + "width", + "height" + ] + }, "WindowMatch": { "description": "Criteria for matching windows.\n\nIf no fields are set, all windows are matched. If multiple fields are set, all fields\nmust match the window.\n", "type": "object", @@ -2742,6 +2822,14 @@ "initial-tile-state": { "description": "Specifies if the window is initially mapped tiled or floating.", "$ref": "#/$defs/TileState" + }, + "initial-floating-size": { + "description": "Specifies the initial size of the window while it is floating.\n\nIf multiple matching rules specify this field, the used size is unspecified.\n", + "$ref": "#/$defs/WindowFloatingSize" + }, + "initial-floating-position": { + "description": "Specifies the initial position of the window while it is floating.\n\nIf multiple matching rules specify this field, the used position is unspecified.\n", + "$ref": "#/$defs/WindowFloatingPosition" } }, "required": [] diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index f23531ffe..dc39cbaf5 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -1050,6 +1050,69 @@ This table is a tagged union. The variant is determined by the `type` field. It The numbers should be integers. +- `set-size`: + + Resizes the focused window to an absolute size. + + This has the same effect as `resize` but takes the target size instead of a + delta. + + - Example: + + ```toml + [shortcuts] + alt-x = { type = "set-size", width = 800, height = 600 } + ``` + + The table has the following fields: + + - `width` (required): + + The new width of the window. + + The value of this field should be a number. + + The numbers should be integers. + + - `height` (required): + + The new height of the window. + + The value of this field should be a number. + + The numbers should be integers. + +- `set-position`: + + Sets the position of the focused window to an absolute value. + + This only has an effect if the window is floating. + + - Example: + + ```toml + [shortcuts] + alt-x = { type = "set-position", x = 100, y = 100 } + ``` + + The table has the following fields: + + - `x` (required): + + The new x coordinate of the window. + + The value of this field should be a number. + + The numbers should be integers. + + - `y` (required): + + The new y coordinate of the window. + + The value of this field should be a number. + + The numbers should be integers. + - `hide-overlay`: Hides an overlay if it is visible. @@ -5769,6 +5832,74 @@ The string should have one of the following values: + +### `WindowFloatingPosition` + +The initial position of a floating window. + +- Example: + + ```toml + [[windows]] + match.app-id = "mpv" + initial-floating-position = { x = 100, y = 100 } + ``` + +Values of this type should be tables. + +The table has the following fields: + +- `x` (required): + + The x coordinate of the window. + + The value of this field should be a number. + + The numbers should be integers. + +- `y` (required): + + The y coordinate of the window. + + The value of this field should be a number. + + The numbers should be integers. + + + +### `WindowFloatingSize` + +The initial size of a floating window. + +- Example: + + ```toml + [[windows]] + match.app-id = "mpv" + initial-floating-size = { width = 800, height = 600 } + ``` + +Values of this type should be tables. + +The table has the following fields: + +- `width` (required): + + The width of the window. + + The value of this field should be a number. + + The numbers should be integers. + +- `height` (required): + + The height of the window. + + The value of this field should be a number. + + The numbers should be integers. + + ### `WindowMatch` @@ -6089,6 +6220,22 @@ The table has the following fields: The value of this field should be a [TileState](#types-TileState). +- `initial-floating-size` (optional): + + Specifies the initial size of the window while it is floating. + + If multiple matching rules specify this field, the used size is unspecified. + + The value of this field should be a [WindowFloatingSize](#types-WindowFloatingSize). + +- `initial-floating-position` (optional): + + Specifies the initial position of the window while it is floating. + + If multiple matching rules specify this field, the used position is unspecified. + + The value of this field should be a [WindowFloatingPosition](#types-WindowFloatingPosition). + ### `WindowTypeMask` diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 3aab51b07..ab4a58643 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -981,6 +981,53 @@ Action: required: false kind: number integer_only: true + set-size: + description: | + Resizes the focused window to an absolute size. + + This has the same effect as `resize` but takes the target size instead of a + delta. + + - Example: + + ```toml + [shortcuts] + alt-x = { type = "set-size", width = 800, height = 600 } + ``` + fields: + width: + description: The new width of the window. + required: true + kind: number + integer_only: true + height: + description: The new height of the window. + required: true + kind: number + integer_only: true + set-position: + description: | + Sets the position of the focused window to an absolute value. + + This only has an effect if the window is floating. + + - Example: + + ```toml + [shortcuts] + alt-x = { type = "set-position", x = 100, y = 100 } + ``` + fields: + x: + description: The new x coordinate of the window. + required: true + kind: number + integer_only: true + y: + description: The new y coordinate of the window. + required: true + kind: number + integer_only: true hide-overlay: description: Hides an overlay if it is visible. fields: @@ -4440,6 +4487,70 @@ WindowRule: ref: TileState required: false description: Specifies if the window is initially mapped tiled or floating. + initial-floating-size: + ref: WindowFloatingSize + required: false + description: | + Specifies the initial size of the window while it is floating. + + If multiple matching rules specify this field, the used size is unspecified. + initial-floating-position: + ref: WindowFloatingPosition + required: false + description: | + Specifies the initial position of the window while it is floating. + + If multiple matching rules specify this field, the used position is unspecified. + + +WindowFloatingSize: + kind: table + description: | + The initial size of a floating window. + + - Example: + + ```toml + [[windows]] + match.app-id = "mpv" + initial-floating-size = { width = 800, height = 600 } + ``` + fields: + width: + kind: number + integer_only: true + required: true + description: The width of the window. + height: + kind: number + integer_only: true + required: true + description: The height of the window. + + +WindowFloatingPosition: + kind: table + description: | + The initial position of a floating window. + + - Example: + + ```toml + [[windows]] + match.app-id = "mpv" + initial-floating-position = { x = 100, y = 100 } + ``` + fields: + x: + kind: number + integer_only: true + required: true + description: The x coordinate of the window. + y: + kind: number + integer_only: true + required: true + description: The y coordinate of the window. WindowMatch: From 560737dd3c79701d753f6f3ba10314c65f7e4811 Mon Sep 17 00:00:00 2001 From: Nicolaus Jacobsen Date: Fri, 10 Jul 2026 10:56:09 +0200 Subject: [PATCH 2/2] merge set-size and set-position --- jay-config/src/_private/client.rs | 22 +++-- jay-config/src/_private/ipc.rs | 13 ++- jay-config/src/input.rs | 21 ++-- jay-config/src/window.rs | 34 ++++--- src/config/handler.rs | 74 +++++++++++---- src/tree/toplevel.rs | 17 +--- toml-config/src/config.rs | 16 ++-- toml-config/src/config/parsers/action.rs | 48 +++++++--- toml-config/src/lib.rs | 14 +-- toml-spec/spec/spec.generated.json | 67 +++++++------ toml-spec/spec/spec.generated.md | 116 ++++++++++++++++------- toml-spec/spec/spec.yaml | 109 ++++++++++++++------- 12 files changed, 363 insertions(+), 188 deletions(-) diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 8b4413b53..a021091fc 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -607,18 +607,28 @@ impl ConfigClient { (width, height) } - pub fn set_window_size(&self, window: Window, width: i32, height: i32) { - self.send(&ClientMessage::SetWindowSize { + #[expect(clippy::too_many_arguments)] + pub fn set_window_position( + &self, + window: Window, + x1: Option, + y1: Option, + x2: Option, + y2: Option, + width: Option, + height: Option, + ) { + self.send(&ClientMessage::SetWindowPosition { window, + x1, + y1, + x2, + y2, width, height, }); } - pub fn set_window_position(&self, window: Window, x: i32, y: i32) { - self.send(&ClientMessage::SetWindowPosition { window, x, y }); - } - pub fn get_workspace_position(&self, workspace: Workspace) -> (i32, i32) { let res = self.send_with_response(&ClientMessage::GetWorkspacePosition { workspace }); get_response!(res, (0, 0), GetWorkspacePosition { x, y }); diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 015d2e7a8..94ce77b6a 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -984,15 +984,14 @@ pub enum ClientMessage<'a> { GetWindowSize { window: Window, }, - SetWindowSize { - window: Window, - width: i32, - height: i32, - }, SetWindowPosition { window: Window, - x: i32, - y: i32, + x1: Option, + y1: Option, + x2: Option, + y2: Option, + width: Option, + height: Option, }, GetWorkspacePosition { workspace: Workspace, diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index 5339acb2a..3026a9764 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -713,16 +713,19 @@ impl Seat { self.window().resize(dx1, dy1, dx2, dy2); } - /// Resizes the focused window to an absolute size. - pub fn set_size(self, width: i32, height: i32) { - self.window().set_size(width, height); - } - - /// Sets the position of the focused window to an absolute value. + /// Sets the position and/or size of the focused window. /// - /// This only has an effect if the window is floating. - pub fn set_position(self, x: i32, y: i32) { - self.window().set_position(x, y); + /// See [`Window::set_position`](crate::window::Window::set_position) for details. + pub fn set_position( + self, + x1: Option, + y1: Option, + x2: Option, + y2: Option, + width: Option, + height: Option, + ) { + self.window().set_position(x1, y1, x2, y2, width, height); } /// Sets whether the cursor should automatically move to the center of a window diff --git a/jay-config/src/window.rs b/jay-config/src/window.rs index bdd9f76db..3262ebe7f 100644 --- a/jay-config/src/window.rs +++ b/jay-config/src/window.rs @@ -252,19 +252,31 @@ impl Window { get!((0, 0)).get_window_size(self) } - /// Resizes the window to an absolute size. - /// - /// This has the same effect as [`resize`](Self::resize) but takes the target size - /// instead of a delta. - pub fn set_size(self, width: i32, height: i32) { - get!().set_window_size(self, width, height); - } - - /// Sets the position of the window in the global compositor space. + /// Sets the position and/or size of the window. /// /// This only has an effect if the window is floating. - pub fn set_position(self, x: i32, y: i32) { - get!().set_window_position(self, x, y); + /// + /// Every argument that is `None` is left unconstrained by this call. Every argument that + /// is `Some(_)` is constrained to that value. + /// + /// Since `x2 = x1 + width` (and analogously for the y axis), not every combination of + /// constraints is satisfiable. If `x1`, `x2`, and `width` are all constrained but not + /// self-consistent (and analogously for `y1`, `y2`, `height`), this call has no effect. + /// + /// If fewer than two of `x1`, `x2`, `width` are constrained (and analogously for `y1`, + /// `y2`, `height`), the missing values default to preserving the window's current size, + /// e.g. setting only `width` keeps `x1` fixed and moves `x2`, while setting only `x1` keeps + /// the width fixed and moves `x2` along with it. + pub fn set_position( + self, + x1: Option, + y1: Option, + x2: Option, + y2: Option, + width: Option, + height: Option, + ) { + get!().set_window_position(self, x1, y1, x2, y2, width, height); } } diff --git a/src/config/handler.rs b/src/config/handler.rs index ba317ffc3..fb192a0f0 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -3046,21 +3046,24 @@ impl ConfigProxyHandler { Ok(()) } - fn handle_set_window_size( + fn handle_set_window_position( &self, window: Window, - width: i32, - height: i32, + x1: Option, + y1: Option, + x2: Option, + y2: Option, + width: Option, + height: Option, ) -> Result<(), CphError> { - if width <= 0 || height <= 0 { - return Err(CphError::InvalidWindowSize(width, height)); + let tl = self.get_window(window)?; + let pos = tl.node_absolute_position(LiveTL); + let (x1, x2) = resolve_geometry_axis(x1, x2, width, pos.x1(), pos.x2())?; + let (y1, y2) = resolve_geometry_axis(y1, y2, height, pos.y1(), pos.y2())?; + if x2 - x1 <= 0 || y2 - y1 <= 0 { + return Err(CphError::InvalidWindowSize(x2 - x1, y2 - y1)); } - self.get_window(window)?.tl_set_size(width, height); - Ok(()) - } - - fn handle_set_window_position(&self, window: Window, x: i32, y: i32) -> Result<(), CphError> { - self.get_window(window)?.tl_set_position(x, y); + tl.tl_set_geometry(x1, y1, x2, y2); Ok(()) } @@ -3995,15 +3998,16 @@ impl ConfigProxyHandler { ClientMessage::GetWindowSize { window } => { self.handle_get_window_size(window).wrn("get_window_size")? } - ClientMessage::SetWindowSize { + ClientMessage::SetWindowPosition { window, + x1, + y1, + x2, + y2, width, height, } => self - .handle_set_window_size(window, width, height) - .wrn("set_window_size")?, - ClientMessage::SetWindowPosition { window, x, y } => self - .handle_set_window_position(window, x, y) + .handle_set_window_position(window, x1, y1, x2, y2, width, height) .wrn("set_window_position")?, ClientMessage::GetWorkspacePosition { workspace } => self .handle_get_workspace_position(workspace) @@ -4175,6 +4179,42 @@ impl ConfigProxyHandler { } } +/// Resolves the constraints on a single axis (x1/x2/width or y1/y2/height) of +/// [`ConfigProxyHandler::handle_set_window_position`] into a concrete `(c1, c2)` pair. +/// +/// `c1`, `c2` and `size` are constrained to their contained value if they are `Some(_)` and +/// are otherwise unconstrained. +/// +/// If two of the three are constrained, the third is derived from them. If only one is +/// constrained, the window keeps its current size (if `c1` or `c2` is constrained) or its +/// current position (if only `size` is constrained). If none are constrained, the axis is +/// left unchanged. If all three are constrained but not self-consistent, an error is returned. +fn resolve_geometry_axis( + c1: Option, + c2: Option, + size: Option, + cur_c1: i32, + cur_c2: i32, +) -> Result<(i32, i32), CphError> { + let cur_size = cur_c2.saturating_sub(cur_c1); + let res = match (c1, c2, size) { + (Some(c1), Some(c2), Some(size)) => { + if c2.saturating_sub(c1) != size { + return Err(CphError::InconsistentWindowGeometry); + } + (c1, c2) + } + (Some(c1), Some(c2), None) => (c1, c2), + (Some(c1), None, Some(size)) => (c1, c1.saturating_add(size)), + (None, Some(c2), Some(size)) => (c2.saturating_sub(size), c2), + (Some(c1), None, None) => (c1, c1.saturating_add(cur_size)), + (None, Some(c2), None) => (c2.saturating_sub(cur_size), c2), + (None, None, Some(size)) => (cur_c1, cur_c1.saturating_add(size)), + (None, None, None) => (cur_c1, cur_c2), + }; + Ok(res) +} + #[derive(Debug, Error)] enum CphError { #[error("Tried to set an unknown accel profile: {}", (.0).0)] @@ -4207,6 +4247,8 @@ enum CphError { InvalidConnectorPosition(i32, i32), #[error("{0}x{1} is not a valid window size")] InvalidWindowSize(i32, i32), + #[error("The given x1/x2/width or y1/y2/height constraints are not self-consistent")] + InconsistentWindowGeometry, #[error("Keymap {0:?} does not exist")] KeymapDoesNotExist(Keymap), #[error("Seat {0:?} does not exist")] diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index e3d34c91b..98afcbd71 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -79,8 +79,7 @@ pub trait ToplevelNode: ToplevelNodeBase { fn tl_mark_ancestor_fullscreen(&self, fullscreen: bool); fn tl_mark_fullscreen(&self, connector: Option<&Rc>); fn tl_resize(&self, dx1: i32, dy1: i32, dx2: i32, dy2: i32); - fn tl_set_size(&self, width: i32, height: i32); - fn tl_set_position(&self, x: i32, y: i32); + fn tl_set_geometry(&self, x1: i32, y1: i32, x2: i32, y2: i32); } impl ToplevelNode for T { @@ -307,21 +306,11 @@ impl ToplevelNode for T { parent.cnode_resize_child(self, x1, y1, x2, y2); } - fn tl_set_size(&self, width: i32, height: i32) { + fn tl_set_geometry(&self, x1: i32, y1: i32, x2: i32, y2: i32) { let Some(parent) = self.tl_data().parent.get() else { return; }; - let pos = self.node_absolute_position(LiveTL); - let x2 = pos.x1().saturating_add(width); - let y2 = pos.y1().saturating_add(height); - parent.cnode_resize_child(self, None, None, Some(x2), Some(y2)); - } - - fn tl_set_position(&self, x: i32, y: i32) { - let Some(parent) = self.tl_data().parent.get() else { - return; - }; - parent.cnode_set_child_position(self, x, y); + parent.cnode_resize_child(self, Some(x1), Some(y1), Some(x2), Some(y2)); } } diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index e035e3ab9..3f2877670 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -211,17 +211,17 @@ pub enum Action { dx2: i32, dy2: i32, }, - SetSize { - width: i32, - height: i32, - }, - SetPosition { - x: i32, - y: i32, - }, HideOverlay { ws: Rc, }, + SetPosition { + x1: Option, + y1: Option, + x2: Option, + y2: Option, + width: Option, + height: Option, + }, } #[derive(Debug)] diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 290fcb813..32f1c057d 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -111,6 +111,28 @@ pub enum ShowWorkspaceError { FallbackOutputModeParser(FallbackOutputModeParserError), } +/// Extracts a field that should either be an integer or the string `"keep"`. +/// +/// `"keep"` is represented as `None`, an integer `n` is represented as `Some(n)`. +fn coordinate( + name: &'static str, +) -> impl for<'v, 'w> FnOnce( + &mut Extractor<'v, 'w>, +) -> Result>, Spanned> { + move |extractor: &mut Extractor| { + val(name)(extractor).and_then(|v| match v.value { + Value::String(s) if s.as_str() == "keep" => Ok(None.spanned(v.span)), + Value::Integer(i) => match i32::try_from(*i) { + Ok(n) => Ok(Some(n).spanned(v.span)), + Err(_) => Err(ExtractorError::I32.spanned(v.span)), + }, + _ => Err( + ExtractorError::Expected("an integer or \"keep\"", v.value.name()).spanned(v.span), + ), + }) + } +} + pub struct ActionParser<'a, 'b>(pub &'a Context<'b>); impl ActionParser<'_, '_> { @@ -601,19 +623,22 @@ impl ActionParser<'_, '_> { }) } - fn parse_set_size(&mut self, ext: &mut Extractor<'_, '_>) -> ParseResult { - let (width, height) = ext.extract((s32("width"), s32("height")))?; - Ok(Action::SetSize { - width: width.value, - height: height.value, - }) - } - fn parse_set_position(&mut self, ext: &mut Extractor<'_, '_>) -> ParseResult { - let (x, y) = ext.extract((s32("x"), s32("y")))?; + let (x1, y1, x2, y2, width, height) = ext.extract(( + opt(coordinate("x1")), + opt(coordinate("y1")), + opt(coordinate("x2")), + opt(coordinate("y2")), + opt(coordinate("width")), + opt(coordinate("height")), + ))?; Ok(Action::SetPosition { - x: x.value, - y: y.value, + x1: x1.despan().flatten(), + y1: y1.despan().flatten(), + x2: x2.despan().flatten(), + y2: y2.despan().flatten(), + width: width.despan().flatten(), + height: height.despan().flatten(), }) } @@ -690,7 +715,6 @@ impl Parser for ActionParser<'_, '_> { "create-virtual-output" => self.parse_create_virtual_output(&mut ext), "remove-virtual-output" => self.parse_remove_virtual_output(&mut ext), "resize" => self.parse_resize(&mut ext), - "set-size" => self.parse_set_size(&mut ext), "set-position" => self.parse_set_position(&mut ext), "hide-overlay" => self.parse_hide_overlay(&mut ext), "show-overlay" => self.parse_show_overlay(&mut ext), diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 2994035fb..be55bfa9d 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -518,12 +518,14 @@ impl Action { Action::Resize { dx1, dy1, dx2, dy2 } => { window_or_seat!(s, s.resize(dx1, dy1, dx2, dy2)) } - Action::SetSize { width, height } => { - window_or_seat!(s, s.set_size(width, height)) - } - Action::SetPosition { x, y } => { - window_or_seat!(s, s.set_position(x, y)) - } + Action::SetPosition { + x1, + y1, + x2, + y2, + width, + height, + } => window_or_seat!(s, s.set_position(x1, y1, x2, y2, width, height)), Action::HideOverlay { ws } => { let workspace = ws.ws.get(); b.new(move || workspace.hide()) diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 4ea841381..359c39101 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -712,47 +712,39 @@ ] }, { - "description": "Resizes the focused window to an absolute size.\n\nThis has the same effect as `resize` but takes the target size instead of a\ndelta.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-x = { type = \"set-size\", width = 800, height = 600 }\n ```\n", + "description": "Sets the position and/or size of the focused window to an absolute value.\n\nThis only has an effect if the window is floating.\n\nEvery field is optional and can be omitted or set to the string `\"keep\"` to leave\nit unconstrained by this action. Fields that are set to a number are constrained\nto that value.\n\nSince `x2 = x1 + width` (and analogously for the y axis), not every combination\nof constraints is satisfiable. For example, setting `x1 = 0`, `x2 = 100`, and\n`width = 50` is not satisfiable and has no effect.\n\nIf fewer than two of `x1`, `x2`, `width` are constrained (and analogously for\n`y1`, `y2`, `height`), the missing values default to preserving the window's\ncurrent size, e.g. setting only `width` keeps `x1` fixed and moves `x2`, while\nsetting only `x1` keeps the width fixed and moves `x2` along with it.\n\n- Example 1 (Resizing the window while keeping its top-left corner fixed):\n\n ```toml\n [shortcuts]\n alt-x = { type = \"set-position\", width = 800, height = 600 }\n ```\n\n- Example 2 (Moving the window while keeping its size):\n\n ```toml\n [shortcuts]\n alt-x = { type = \"set-position\", x1 = 100, y1 = 100 }\n ```\n\n- Example 3 (Placing the window at an exact rectangle):\n\n ```toml\n [shortcuts]\n alt-x = { type = \"set-position\", x1 = 0, y1 = 0, x2 = 800, y2 = 600 }\n ```\n", "type": "object", "properties": { "type": { - "const": "set-size" + "const": "set-position" }, - "width": { - "type": "integer", - "description": "The new width of the window." + "x1": { + "description": "The x coordinate of the left edge.", + "$ref": "#/$defs/Coordinate" }, - "height": { - "type": "integer", - "description": "The new height of the window." - } - }, - "required": [ - "type", - "width", - "height" - ] - }, - { - "description": "Sets the position of the focused window to an absolute value.\n\nThis only has an effect if the window is floating.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-x = { type = \"set-position\", x = 100, y = 100 }\n ```\n", - "type": "object", - "properties": { - "type": { - "const": "set-position" + "y1": { + "description": "The y coordinate of the top edge.", + "$ref": "#/$defs/Coordinate" }, - "x": { - "type": "integer", - "description": "The new x coordinate of the window." + "x2": { + "description": "The x coordinate of the right edge.", + "$ref": "#/$defs/Coordinate" }, - "y": { - "type": "integer", - "description": "The new y coordinate of the window." + "y2": { + "description": "The y coordinate of the bottom edge.", + "$ref": "#/$defs/Coordinate" + }, + "width": { + "description": "The width of the window.", + "$ref": "#/$defs/Coordinate" + }, + "height": { + "description": "The height of the window.", + "$ref": "#/$defs/Coordinate" } }, "required": [ - "type", - "x", - "y" + "type" ] }, { @@ -1421,6 +1413,19 @@ } ] }, + "Coordinate": { + "description": "A coordinate or size constraint used by the `set-position` action.\n\n- Example 1:\n\n ```toml\n x1 = 100\n ```\n\n- Example 2:\n\n ```toml\n x1 = \"keep\"\n ```\n", + "anyOf": [ + { + "type": "string", + "description": "The string `keep` can be used to explicitly leave this field unconstrained. This has\nthe same effect as omitting the field.\n" + }, + { + "type": "integer", + "description": "The value that this field should be constrained to." + } + ] + }, "DeviceConfigFilter": { "type": "string", "description": "A filter to apply before device configuration.", diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index dc39cbaf5..665111eb9 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -1050,68 +1050,83 @@ This table is a tagged union. The variant is determined by the `type` field. It The numbers should be integers. -- `set-size`: +- `set-position`: - Resizes the focused window to an absolute size. + Sets the position and/or size of the focused window to an absolute value. + + This only has an effect if the window is floating. - This has the same effect as `resize` but takes the target size instead of a - delta. + Every field is optional and can be omitted or set to the string `"keep"` to leave + it unconstrained by this action. Fields that are set to a number are constrained + to that value. - - Example: + Since `x2 = x1 + width` (and analogously for the y axis), not every combination + of constraints is satisfiable. For example, setting `x1 = 0`, `x2 = 100`, and + `width = 50` is not satisfiable and has no effect. + + If fewer than two of `x1`, `x2`, `width` are constrained (and analogously for + `y1`, `y2`, `height`), the missing values default to preserving the window's + current size, e.g. setting only `width` keeps `x1` fixed and moves `x2`, while + setting only `x1` keeps the width fixed and moves `x2` along with it. + + - Example 1 (Resizing the window while keeping its top-left corner fixed): ```toml [shortcuts] - alt-x = { type = "set-size", width = 800, height = 600 } + alt-x = { type = "set-position", width = 800, height = 600 } + ``` + + - Example 2 (Moving the window while keeping its size): + + ```toml + [shortcuts] + alt-x = { type = "set-position", x1 = 100, y1 = 100 } + ``` + + - Example 3 (Placing the window at an exact rectangle): + + ```toml + [shortcuts] + alt-x = { type = "set-position", x1 = 0, y1 = 0, x2 = 800, y2 = 600 } ``` The table has the following fields: - - `width` (required): + - `x1` (optional): - The new width of the window. + The x coordinate of the left edge. - The value of this field should be a number. + The value of this field should be a [Coordinate](#types-Coordinate). - The numbers should be integers. + - `y1` (optional): - - `height` (required): + The y coordinate of the top edge. - The new height of the window. + The value of this field should be a [Coordinate](#types-Coordinate). - The value of this field should be a number. + - `x2` (optional): - The numbers should be integers. - -- `set-position`: + The x coordinate of the right edge. - Sets the position of the focused window to an absolute value. - - This only has an effect if the window is floating. - - - Example: - - ```toml - [shortcuts] - alt-x = { type = "set-position", x = 100, y = 100 } - ``` + The value of this field should be a [Coordinate](#types-Coordinate). - The table has the following fields: + - `y2` (optional): - - `x` (required): + The y coordinate of the bottom edge. - The new x coordinate of the window. + The value of this field should be a [Coordinate](#types-Coordinate). - The value of this field should be a number. + - `width` (optional): - The numbers should be integers. + The width of the window. - - `y` (required): + The value of this field should be a [Coordinate](#types-Coordinate). - The new y coordinate of the window. + - `height` (optional): - The value of this field should be a number. + The height of the window. - The numbers should be integers. + The value of this field should be a [Coordinate](#types-Coordinate). - `hide-overlay`: @@ -2852,6 +2867,37 @@ An array of masks that are OR'd. Each element of this array should be a [ContentTypeMask](#types-ContentTypeMask). + +### `Coordinate` + +A coordinate or size constraint used by the `set-position` action. + +- Example 1: + + ```toml + x1 = 100 + ``` + +- Example 2: + + ```toml + x1 = "keep" + ``` + +Values of this type should have one of the following forms: + +#### A string + +The string `keep` can be used to explicitly leave this field unconstrained. This has +the same effect as omitting the field. + +#### A number + +The value that this field should be constrained to. + +The numbers should be integers. + + ### `DeviceConfigFilter` diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index ab4a58643..68695741a 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -981,53 +981,70 @@ Action: required: false kind: number integer_only: true - set-size: + set-position: description: | - Resizes the focused window to an absolute size. + Sets the position and/or size of the focused window to an absolute value. + + This only has an effect if the window is floating. - This has the same effect as `resize` but takes the target size instead of a - delta. + Every field is optional and can be omitted or set to the string `"keep"` to leave + it unconstrained by this action. Fields that are set to a number are constrained + to that value. - - Example: + Since `x2 = x1 + width` (and analogously for the y axis), not every combination + of constraints is satisfiable. For example, setting `x1 = 0`, `x2 = 100`, and + `width = 50` is not satisfiable and has no effect. + + If fewer than two of `x1`, `x2`, `width` are constrained (and analogously for + `y1`, `y2`, `height`), the missing values default to preserving the window's + current size, e.g. setting only `width` keeps `x1` fixed and moves `x2`, while + setting only `x1` keeps the width fixed and moves `x2` along with it. + + - Example 1 (Resizing the window while keeping its top-left corner fixed): ```toml [shortcuts] - alt-x = { type = "set-size", width = 800, height = 600 } + alt-x = { type = "set-position", width = 800, height = 600 } ``` - fields: - width: - description: The new width of the window. - required: true - kind: number - integer_only: true - height: - description: The new height of the window. - required: true - kind: number - integer_only: true - set-position: - description: | - Sets the position of the focused window to an absolute value. - This only has an effect if the window is floating. + - Example 2 (Moving the window while keeping its size): - - Example: + ```toml + [shortcuts] + alt-x = { type = "set-position", x1 = 100, y1 = 100 } + ``` + + - Example 3 (Placing the window at an exact rectangle): ```toml [shortcuts] - alt-x = { type = "set-position", x = 100, y = 100 } + alt-x = { type = "set-position", x1 = 0, y1 = 0, x2 = 800, y2 = 600 } ``` fields: - x: - description: The new x coordinate of the window. - required: true - kind: number - integer_only: true - y: - description: The new y coordinate of the window. - required: true - kind: number - integer_only: true + x1: + ref: Coordinate + description: The x coordinate of the left edge. + required: false + y1: + ref: Coordinate + description: The y coordinate of the top edge. + required: false + x2: + ref: Coordinate + description: The x coordinate of the right edge. + required: false + y2: + ref: Coordinate + description: The y coordinate of the bottom edge. + required: false + width: + ref: Coordinate + description: The width of the window. + required: false + height: + ref: Coordinate + description: The height of the window. + required: false hide-overlay: description: Hides an overlay if it is visible. fields: @@ -1037,6 +1054,32 @@ Action: kind: string +Coordinate: + description: | + A coordinate or size constraint used by the `set-position` action. + + - Example 1: + + ```toml + x1 = 100 + ``` + + - Example 2: + + ```toml + x1 = "keep" + ``` + kind: variable + variants: + - kind: string + description: | + The string `keep` can be used to explicitly leave this field unconstrained. This has + the same effect as omitting the field. + - kind: number + integer_only: true + description: The value that this field should be constrained to. + + Exec: description: | Describes how to execute a program.