diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 05cfeaad..a021091f 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -595,6 +595,52 @@ 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) + } + + #[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 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 +2154,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 80329780..94ce77b6 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -968,6 +968,37 @@ 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, + }, + SetWindowPosition { + window: Window, + x1: Option, + y1: Option, + x2: Option, + y2: Option, + width: Option, + height: Option, + }, + GetWorkspacePosition { + workspace: Workspace, + }, + GetWorkspaceSize { + workspace: Workspace, + }, } #[derive(Serialize, Deserialize, Debug)] @@ -1239,6 +1270,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 f87f0748..3026a976 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -713,6 +713,21 @@ impl Seat { self.window().resize(dx1, dy1, dx2, dy2); } + /// Sets the position and/or size of the focused window. + /// + /// 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 /// 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 85f4ebbc..3c93e504 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 b5434855..023915a1 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 662cda44..3262ebe7 100644 --- a/jay-config/src/window.rs +++ b/jay-config/src/window.rs @@ -241,6 +241,43 @@ 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) + } + + /// Sets the position and/or size of the window. + /// + /// This only has an effect if the window is floating. + /// + /// 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); + } } /// A window matcher. @@ -354,6 +391,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 +442,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 eebe1537..7ed1b850 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 6b5072d4..fb192a0f 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,69 @@ 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_position( + &self, + window: Window, + x1: Option, + y1: Option, + x2: Option, + y2: Option, + width: Option, + height: Option, + ) -> Result<(), CphError> { + 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)); + } + tl.tl_set_geometry(x1, y1, x2, y2); + 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 +3842,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 +3992,29 @@ 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::SetWindowPosition { + window, + x1, + y1, + x2, + y2, + width, + height, + } => self + .handle_set_window_position(window, x1, y1, x2, y2, width, height) + .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 +4114,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()?; @@ -4017,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)] @@ -4047,6 +4245,10 @@ 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("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/ifs/wl_surface/x_surface/xwindow.rs b/src/ifs/wl_surface/x_surface/xwindow.rs index a57a5974..3e4ca8eb 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 4d479418..5cdd51f7 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 d31fa3fa..1819c78d 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 91b28945..98afcbd7 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -79,6 +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_geometry(&self, x1: i32, y1: i32, x2: i32, y2: i32); } impl ToplevelNode for T { @@ -304,6 +305,13 @@ 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_geometry(&self, x1: i32, y1: i32, x2: i32, y2: i32) { + let Some(parent) = self.tl_data().parent.get() else { + return; + }; + parent.cnode_resize_child(self, Some(x1), Some(y1), Some(x2), Some(y2)); + } } pub trait ToplevelNodeBase: Node { diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 5cc5a774..3f287767 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -214,6 +214,14 @@ pub enum Action { HideOverlay { ws: Rc, }, + SetPosition { + x1: Option, + y1: Option, + x2: Option, + y2: Option, + width: Option, + height: Option, + }, } #[derive(Debug)] @@ -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 708e28c9..8db3bbdf 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 b46f7724..32f1c057 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,6 +623,25 @@ impl ActionParser<'_, '_> { }) } + fn parse_set_position(&mut self, ext: &mut Extractor<'_, '_>) -> ParseResult { + 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 { + x1: x1.despan().flatten(), + y1: y1.despan().flatten(), + x2: x2.despan().flatten(), + y2: y2.despan().flatten(), + width: width.despan().flatten(), + height: height.despan().flatten(), + }) + } + 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 +715,7 @@ 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-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 00000000..3d8babf1 --- /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 1f866055..c43eeaf5 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 6f84a02b..be55bfa9 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -518,6 +518,14 @@ impl Action { Action::Resize { dx1, dy1, dx2, dy2 } => { window_or_seat!(s, s.resize(dx1, dy1, dx2, dy2)) } + 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-config/src/rules.rs b/toml-config/src/rules.rs index b67a2cca..4d70f437 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 581f0af8..359c3910 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -711,6 +711,42 @@ "type" ] }, + { + "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-position" + }, + "x1": { + "description": "The x coordinate of the left edge.", + "$ref": "#/$defs/Coordinate" + }, + "y1": { + "description": "The y coordinate of the top edge.", + "$ref": "#/$defs/Coordinate" + }, + "x2": { + "description": "The x coordinate of the right edge.", + "$ref": "#/$defs/Coordinate" + }, + "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" + ] + }, { "description": "Hides an overlay if it is visible.", "type": "object", @@ -1377,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.", @@ -2566,6 +2615,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 +2827,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 f23531ff..665111eb 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -1050,6 +1050,84 @@ This table is a tagged union. The variant is determined by the `type` field. It The numbers should be integers. +- `set-position`: + + Sets the position and/or size of the focused window to an absolute value. + + This only has an effect if the window is floating. + + 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. + + 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-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: + + - `x1` (optional): + + The x coordinate of the left edge. + + The value of this field should be a [Coordinate](#types-Coordinate). + + - `y1` (optional): + + The y coordinate of the top edge. + + The value of this field should be a [Coordinate](#types-Coordinate). + + - `x2` (optional): + + The x coordinate of the right edge. + + The value of this field should be a [Coordinate](#types-Coordinate). + + - `y2` (optional): + + The y coordinate of the bottom edge. + + The value of this field should be a [Coordinate](#types-Coordinate). + + - `width` (optional): + + The width of the window. + + The value of this field should be a [Coordinate](#types-Coordinate). + + - `height` (optional): + + The height of the window. + + The value of this field should be a [Coordinate](#types-Coordinate). + - `hide-overlay`: Hides an overlay if it is visible. @@ -2789,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` @@ -5769,6 +5878,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 +6266,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 3aab51b0..68695741 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -981,6 +981,70 @@ Action: required: false kind: number integer_only: true + set-position: + description: | + Sets the position and/or size of the focused window to an absolute value. + + This only has an effect if the window is floating. + + 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. + + 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-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 } + ``` + fields: + 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: @@ -990,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. @@ -4440,6 +4530,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: