diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 21acd641..cce4acc1 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -1385,6 +1385,31 @@ impl ConfigClient { workspaces } + /// Returns the connector that the workspace is currently on. + /// Returns `Connector(0)` (invalid connector) if the workspace doesn't exist or + /// isn't assigned to any connector. + pub fn get_workspace_connector(&self, workspace: Workspace) -> Connector { + let res = self.send_with_response(&ClientMessage::GetWorkspaceConnector { workspace }); + get_response!(res, Connector(0), GetWorkspaceConnector { connector }); + connector + } + + /// Finds the connector in the specified direction from the given connector. + /// Returns `Connector(0)` (invalid connector) if no connector exists in that direction + /// or if the source connector is invalid. + pub fn get_connector_in_direction( + &self, + connector: Connector, + direction: Direction, + ) -> Connector { + let res = self.send_with_response(&ClientMessage::GetConnectorInDirection { + connector, + direction, + }); + get_response!(res, Connector(0), GetConnectorInDirection { connector }); + connector + } + pub fn set_client_matcher_capabilities( &self, matcher: ClientMatcher, diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 43146868..bd1e3213 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -805,6 +805,13 @@ pub enum ClientMessage<'a> { show: bool, }, GetShowTitles, + GetWorkspaceConnector { + workspace: Workspace, + }, + GetConnectorInDirection { + connector: Connector, + direction: Direction, + }, } #[derive(Serialize, Deserialize, Debug)] @@ -1043,6 +1050,12 @@ pub enum Response { GetShowTitles { show: bool, }, + GetWorkspaceConnector { + connector: Connector, + }, + GetConnectorInDirection { + connector: Connector, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index efb92a1e..aa975250 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -186,6 +186,13 @@ impl Workspace { pub fn window(self) -> Window { get!(Window(0)).get_workspace_window(self) } + + /// Returns the connector that contains this workspace. + /// + /// If no such connector exists, [`Connector::exists`] returns false. + pub fn connector(self) -> Connector { + get!(Connector(0)).get_workspace_connector(self) + } } /// Returns the workspace with the given name. diff --git a/jay-config/src/video.rs b/jay-config/src/video.rs index 05d6ae0d..f9f9cb6f 100644 --- a/jay-config/src/video.rs +++ b/jay-config/src/video.rs @@ -3,7 +3,7 @@ use { crate::{ _private::WireMode, - PciId, Workspace, + Direction, PciId, Workspace, video::connector_type::{ CON_9PIN_DIN, CON_COMPONENT, CON_COMPOSITE, CON_DISPLAY_PORT, CON_DPI, CON_DSI, CON_DVIA, CON_DVID, CON_DVII, CON_EDP, CON_EMBEDDED_WINDOW, CON_HDMIA, CON_HDMIB, @@ -327,6 +327,17 @@ impl Connector { pub fn workspaces(self) -> Vec { get!().get_connector_workspaces(self) } + + /// Find the closest connector in the given direction. + /// + /// Uses center-to-center distance calculation and prefers outputs better aligned + /// with the movement axis. + /// + /// If no connector exists in the given direction, returns a connector whose + /// `exists()` returns false. + pub fn connector_in_direction(self, direction: Direction) -> Connector { + get!(Connector(0)).get_connector_in_direction(self, direction) + } } /// Returns all available DRM devices. diff --git a/src/config/handler.rs b/src/config/handler.rs index 6b5eb6ce..fbf7eedd 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -1577,6 +1577,32 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_get_workspace_connector(&self, workspace: Workspace) -> Result<(), CphError> { + let connector = self + .get_existing_workspace(workspace)? + .map(|ws| ws.output.get()) + .filter(|o| !o.is_dummy) + .map(|o| Connector(o.global.connector.id.raw() as _)) + .unwrap_or(Connector(0)); + self.respond(Response::GetWorkspaceConnector { connector }); + Ok(()) + } + + fn handle_get_connector_in_direction( + &self, + connector: Connector, + direction: Direction, + ) -> Result<(), CphError> { + let source_output = self.get_output_node(connector)?; + let connector = self + .state + .find_connector_in_direction(&source_output, direction.into()) + .map(|o| Connector(o.global.connector.id.raw() as u64)) + .unwrap_or(Connector(0)); + self.respond(Response::GetConnectorInDirection { connector }); + Ok(()) + } + fn handle_has_capability(&self, device: InputDevice, cap: Capability) -> Result<(), CphError> { let dev = self.get_device_handler_data(device)?; let mut is_unknown = false; @@ -3073,6 +3099,15 @@ impl ConfigProxyHandler { ClientMessage::GetConnectorWorkspaces { connector } => self .handle_get_connector_workspaces(connector) .wrn("get_connector_workspaces")?, + ClientMessage::GetWorkspaceConnector { workspace } => self + .handle_get_workspace_connector(workspace) + .wrn("get_workspace_connector")?, + ClientMessage::GetConnectorInDirection { + connector, + direction, + } => self + .handle_get_connector_in_direction(connector, direction) + .wrn("get_connector_in_direction")?, ClientMessage::GetClients => self.handle_get_clients(), ClientMessage::ClientExists { client } => self.handle_client_exists(client), ClientMessage::ClientIsXwayland { client } => self diff --git a/src/state.rs b/src/state.rs index 21642e45..23ccbd18 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1560,6 +1560,69 @@ impl State { } } + pub fn find_connector_in_direction( + &self, + source_output: &OutputNode, + direction: Direction, + ) -> Option> { + let outputs = self.root.outputs.lock(); + + let ref_box = source_output.global.pos.get(); + let ref_x1 = ref_box.x1(); + let ref_y1 = ref_box.y1(); + let ref_x2 = ref_box.x2(); + let ref_y2 = ref_box.y2(); + + // Use the center of the source output as the reference point (like wlroots) + let (ref_lx, ref_ly) = ref_box.center(); + + // Find the closest output in the given direction using wlroots-style algorithm + let mut min_distance = i64::MAX; + let mut closest_output = None; + + for output in outputs.values() { + if output.id == source_output.id { + continue; + } + + let box_pos = output.global.pos.get(); + let box_x1 = box_pos.x1(); + let box_y1 = box_pos.y1(); + let box_x2 = box_pos.x2(); + let box_y2 = box_pos.y2(); + + // Edge-based direction check (like wlroots) + // Test to make sure this output is in the given direction + let is_in_direction = match direction { + Direction::Left => box_x2 <= ref_x1, + Direction::Right => box_x1 >= ref_x2, + Direction::Up => box_y2 <= ref_y1, + Direction::Down => box_y1 >= ref_y2, + Direction::Unspecified => false, + }; + + if !is_in_direction { + continue; + } + + // Calculate distance from reference point to closest point on this output + // This mimics wlr_box_closest_point + squared Euclidean distance + let closest_x = ref_lx.clamp(box_x1, box_x2); + let closest_y = ref_ly.clamp(box_y1, box_y2); + + let dx = (closest_x - ref_lx) as i64; + let dy = (closest_y - ref_ly) as i64; + let distance = dx * dx + dy * dy; + + if distance < min_distance { + min_distance = distance; + closest_output = Some(output); + } + } + + closest_output.cloned() + } + pub fn node_at(&self, x: i32, y: i32) -> FoundNode { let mut found_tree = self.node_at_tree.borrow_mut(); found_tree.push(FoundNode { diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 2e770263..c7594c11 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -160,7 +160,8 @@ pub enum Action { }, MoveToOutput { workspace: Option, - output: OutputMatch, + output: Option, + direction: Option, }, SetRepeatRate { rate: RepeatRate, diff --git a/toml-config/src/config/extractor.rs b/toml-config/src/config/extractor.rs index 4744a10f..09e5d29c 100644 --- a/toml-config/src/config/extractor.rs +++ b/toml-config/src/config/extractor.rs @@ -47,6 +47,10 @@ impl<'v> Extractor<'v> { self.log_unused = false; } + pub fn span(&self) -> Span { + self.span + } + pub fn extract, U>(&mut self, e: E) -> Result> where ExtractorError: Into, diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 1f4057ef..7ea8fec4 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -34,7 +34,7 @@ use { indexmap::IndexMap, jay_config::{ Axis::{Horizontal, Vertical}, - get_workspace, + Direction, get_workspace, input::{LayerDirection, Timeline}, }, thiserror::Error, @@ -90,6 +90,10 @@ pub enum ActionParserError { CopyMark(#[source] MarkIdParserError), #[error("Could not parse a show-workspace action")] ShowWorkspace(#[source] OutputMatchParserError), + #[error("Unknown direction {0}")] + UnknownDirection(String), + #[error("Exactly one of `output` or `direction` must be specified")] + OutputAndDirectionMutuallyExclusive, } pub struct ActionParser<'a>(pub &'a Context<'a>); @@ -356,14 +360,40 @@ impl ActionParser<'_> { Ok(Action::ConfigureDrmDevice { dev }) } + fn parse_direction(v: Spanned<&str>) -> Result> { + use Direction::*; + match v.value { + "left" => Ok(Left), + "right" => Ok(Right), + "up" => Ok(Up), + "down" => Ok(Down), + _ => Err(ActionParserError::UnknownDirection(v.value.to_string()).spanned(v.span)), + } + } + fn parse_move_to_output(&mut self, ext: &mut Extractor<'_>) -> ParseResult { - let (ws, output) = ext.extract((opt(str("workspace")), val("output")))?; - let output = output - .parse_map(&mut OutputMatchParser(self.0)) - .map_spanned_err(ActionParserError::MoveToOutput)?; + let (ws, output_val, direction_val) = ext.extract(( + opt(str("workspace")), + opt(val("output")), + opt(str("direction")), + ))?; + + // Validate that exactly one of output or direction is specified + if output_val.is_some() == direction_val.is_some() { + return Err(ActionParserError::OutputAndDirectionMutuallyExclusive.spanned(ext.span())); + } + + let output = output_val + .map(|v| { + v.parse(&mut OutputMatchParser(self.0)) + .map_spanned_err(ActionParserError::MoveToOutput) + }) + .transpose()?; + let direction = direction_val.map(Self::parse_direction).transpose()?; Ok(Action::MoveToOutput { workspace: ws.despan().map(get_workspace), output, + direction, }) } diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index a1063721..3cf8e035 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -360,20 +360,52 @@ impl Action { set_idle_grace_period(period) } }), - Action::MoveToOutput { output, workspace } => { + Action::MoveToOutput { + output, + workspace, + direction, + } => { let state = state.clone(); b.new(move || { - let output = 'get_output: { - for connector in connectors() { - if connector.connected() && output.matches(connector, &state) { - break 'get_output connector; + let target_output = { + // Handle directional output selection + if let Some(direction) = direction { + // Get the current workspace to determine the source output + let current_ws = match workspace { + Some(ws) => ws, + None => s.get_workspace(), + }; + if !current_ws.exists() { + return; } + // Get the connector that currently has this workspace + let source_connector = current_ws.connector(); + if !source_connector.exists() { + return; + } + // Find the connector in the given direction + let target = source_connector.connector_in_direction(direction); + if !target.exists() { + return; + } + target + } else if let Some(output) = &output { + // Handle normal output matching + 'match_output: { + for connector in connectors() { + if connector.connected() && output.matches(connector, &state) { + break 'match_output connector; + } + } + return; + } + } else { + return; } - return; }; match workspace { - Some(ws) => ws.move_to_output(output), - None => s.move_to_output(output), + Some(ws) => ws.move_to_output(target_output), + None => s.move_to_output(target_output), } }) } diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 5fcd911c..1f0a45f5 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -163,7 +163,7 @@ ] }, { - "description": "Moves a workspace to a different output.\n\n- Example 1:\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", workspace = \"1\", output.name = \"right\" }\n ```\n\n- Example 2:\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", output.name = \"right\" }\n ```\n", + "description": "Moves a workspace to a different output.\n\n- Example 1: Move a specific workspace to a named output\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", workspace = \"1\", output.name = \"right\" }\n ```\n\n- Example 2: Move the current workspace to a named output\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", output.name = \"right\" }\n ```\n\n- Example 3: Move the current workspace to the output on the right (directional)\n\n ```toml\n [shortcuts]\n \"logo+ctrl+shift+Right\" = { type = \"move-to-output\", direction = \"right\" }\n \"logo+ctrl+shift+Left\" = { type = \"move-to-output\", direction = \"left\" }\n \"logo+ctrl+shift+Up\" = { type = \"move-to-output\", direction = \"up\" }\n \"logo+ctrl+shift+Down\" = { type = \"move-to-output\", direction = \"down\" }\n ```\n", "type": "object", "properties": { "type": { @@ -174,13 +174,16 @@ "description": "The name of the workspace.\n\nIf this is omitted, the currently active workspace is moved.\n" }, "output": { - "description": "The output to move to.\n\nIf multiple outputs match, the workspace is moved to the first matching\noutput.\n", + "description": "The output to move to.\n\nIf multiple outputs match, the workspace is moved to the first matching\noutput.\n\nEither `output` or `direction` must be specified, but not both.\n", "$ref": "#/$defs/OutputMatch" + }, + "direction": { + "description": "The direction to search for the next output.\n\nFinds the closest output in the specified direction based on\ncenter-to-center distance, with preference for outputs better aligned\nwith the movement axis.\n\nEither `output` or `direction` must be specified, but not both.\n", + "$ref": "#/$defs/Direction" } }, "required": [ - "type", - "output" + "type" ] }, { @@ -1126,6 +1129,16 @@ } ] }, + "Direction": { + "type": "string", + "description": "A directional value used for output selection.\n", + "enum": [ + "left", + "right", + "up", + "down" + ] + }, "DrmDevice": { "description": "Describes configuration to apply to a DRM device (graphics card).\n\n- Example: To disable direct scanout on a device:\n\n ```toml\n [[drm-devices]]\n match = { pci-vendor = 0x1002, pci-model = 0x73ff }\n direct-scanout = false\n ```\n", "type": "object", diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index 0809f804..4c168f60 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -290,19 +290,29 @@ This table is a tagged union. The variant is determined by the `type` field. It Moves a workspace to a different output. - - Example 1: + - Example 1: Move a specific workspace to a named output ```toml [shortcuts] alt-F1 = { type = "move-to-output", workspace = "1", output.name = "right" } ``` - - Example 2: + - Example 2: Move the current workspace to a named output ```toml [shortcuts] alt-F1 = { type = "move-to-output", output.name = "right" } ``` + + - Example 3: Move the current workspace to the output on the right (directional) + + ```toml + [shortcuts] + "logo+ctrl+shift+Right" = { type = "move-to-output", direction = "right" } + "logo+ctrl+shift+Left" = { type = "move-to-output", direction = "left" } + "logo+ctrl+shift+Up" = { type = "move-to-output", direction = "up" } + "logo+ctrl+shift+Down" = { type = "move-to-output", direction = "down" } + ``` The table has the following fields: @@ -314,15 +324,29 @@ This table is a tagged union. The variant is determined by the `type` field. It The value of this field should be a string. - - `output` (required): + - `output` (optional): The output to move to. If multiple outputs match, the workspace is moved to the first matching output. + + Either `output` or `direction` must be specified, but not both. The value of this field should be a [OutputMatch](#types-OutputMatch). + - `direction` (optional): + + The direction to search for the next output. + + Finds the closest output in the specified direction based on + center-to-center distance, with preference for outputs better aligned + with the movement axis. + + Either `output` or `direction` must be specified, but not both. + + The value of this field should be a [Direction](#types-Direction). + - `configure-connector`: Applies a configuration to connectors. @@ -2285,6 +2309,33 @@ An array of masks that are OR'd. Each element of this array should be a [ContentTypeMask](#types-ContentTypeMask). + +### `Direction` + +A directional value used for output selection. + +Values of this type should be strings. + +The string should have one of the following values: + +- `left`: + + The left direction. + +- `right`: + + The right direction. + +- `up`: + + The up direction. + +- `down`: + + The down direction. + + + ### `DrmDevice` diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index b45806a4..bf571ba7 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -288,19 +288,29 @@ Action: description: | Moves a workspace to a different output. - - Example 1: + - Example 1: Move a specific workspace to a named output ```toml [shortcuts] alt-F1 = { type = "move-to-output", workspace = "1", output.name = "right" } ``` - - Example 2: + - Example 2: Move the current workspace to a named output ```toml [shortcuts] alt-F1 = { type = "move-to-output", output.name = "right" } ``` + + - Example 3: Move the current workspace to the output on the right (directional) + + ```toml + [shortcuts] + "logo+ctrl+shift+Right" = { type = "move-to-output", direction = "right" } + "logo+ctrl+shift+Left" = { type = "move-to-output", direction = "left" } + "logo+ctrl+shift+Up" = { type = "move-to-output", direction = "up" } + "logo+ctrl+shift+Down" = { type = "move-to-output", direction = "down" } + ``` fields: workspace: description: | @@ -315,8 +325,21 @@ Action: If multiple outputs match, the workspace is moved to the first matching output. - required: true + + Either `output` or `direction` must be specified, but not both. + required: false ref: OutputMatch + direction: + description: | + The direction to search for the next output. + + Finds the closest output in the specified direction based on + center-to-center distance, with preference for outputs better aligned + with the movement axis. + + Either `output` or `direction` must be specified, but not both. + required: false + ref: Direction configure-connector: description: | Applies a configuration to connectors. @@ -4193,6 +4216,21 @@ BlendSpace: description: Linear color space. This is the physically correct blend space. +Direction: + kind: string + description: | + A directional value used for output selection. + values: + - value: left + description: The left direction. + - value: right + description: The right direction. + - value: up + description: The up direction. + - value: down + description: The down direction. + + ClientCapabilities: description: | A mask of client capabilities.