1
0
Fork 0
forked from wry/wry

Merge pull request #664 from ArthurHeymans/DirectionMoveWorkspace

feat(toml-config): Add directional output selection for move-to-output action
This commit is contained in:
mahkoh 2025-11-28 13:23:33 +01:00 committed by GitHub
commit 0e49b33a7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 348 additions and 25 deletions

View file

@ -1385,6 +1385,31 @@ impl ConfigClient {
workspaces 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( pub fn set_client_matcher_capabilities(
&self, &self,
matcher: ClientMatcher, matcher: ClientMatcher,

View file

@ -805,6 +805,13 @@ pub enum ClientMessage<'a> {
show: bool, show: bool,
}, },
GetShowTitles, GetShowTitles,
GetWorkspaceConnector {
workspace: Workspace,
},
GetConnectorInDirection {
connector: Connector,
direction: Direction,
},
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@ -1043,6 +1050,12 @@ pub enum Response {
GetShowTitles { GetShowTitles {
show: bool, show: bool,
}, },
GetWorkspaceConnector {
connector: Connector,
},
GetConnectorInDirection {
connector: Connector,
},
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]

View file

@ -186,6 +186,13 @@ impl Workspace {
pub fn window(self) -> Window { pub fn window(self) -> Window {
get!(Window(0)).get_workspace_window(self) 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. /// Returns the workspace with the given name.

View file

@ -3,7 +3,7 @@
use { use {
crate::{ crate::{
_private::WireMode, _private::WireMode,
PciId, Workspace, Direction, PciId, Workspace,
video::connector_type::{ video::connector_type::{
CON_9PIN_DIN, CON_COMPONENT, CON_COMPOSITE, CON_DISPLAY_PORT, CON_DPI, CON_DSI, 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, 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<Workspace> { pub fn workspaces(self) -> Vec<Workspace> {
get!().get_connector_workspaces(self) 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. /// Returns all available DRM devices.

View file

@ -1577,6 +1577,32 @@ impl ConfigProxyHandler {
Ok(()) 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> { fn handle_has_capability(&self, device: InputDevice, cap: Capability) -> Result<(), CphError> {
let dev = self.get_device_handler_data(device)?; let dev = self.get_device_handler_data(device)?;
let mut is_unknown = false; let mut is_unknown = false;
@ -3073,6 +3099,15 @@ impl ConfigProxyHandler {
ClientMessage::GetConnectorWorkspaces { connector } => self ClientMessage::GetConnectorWorkspaces { connector } => self
.handle_get_connector_workspaces(connector) .handle_get_connector_workspaces(connector)
.wrn("get_connector_workspaces")?, .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::GetClients => self.handle_get_clients(),
ClientMessage::ClientExists { client } => self.handle_client_exists(client), ClientMessage::ClientExists { client } => self.handle_client_exists(client),
ClientMessage::ClientIsXwayland { client } => self ClientMessage::ClientIsXwayland { client } => self

View file

@ -1560,6 +1560,69 @@ impl State {
} }
} }
pub fn find_connector_in_direction(
&self,
source_output: &OutputNode,
direction: Direction,
) -> Option<Rc<OutputNode>> {
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 { pub fn node_at(&self, x: i32, y: i32) -> FoundNode {
let mut found_tree = self.node_at_tree.borrow_mut(); let mut found_tree = self.node_at_tree.borrow_mut();
found_tree.push(FoundNode { found_tree.push(FoundNode {

View file

@ -160,7 +160,8 @@ pub enum Action {
}, },
MoveToOutput { MoveToOutput {
workspace: Option<Workspace>, workspace: Option<Workspace>,
output: OutputMatch, output: Option<OutputMatch>,
direction: Option<Direction>,
}, },
SetRepeatRate { SetRepeatRate {
rate: RepeatRate, rate: RepeatRate,

View file

@ -47,6 +47,10 @@ impl<'v> Extractor<'v> {
self.log_unused = false; self.log_unused = false;
} }
pub fn span(&self) -> Span {
self.span
}
pub fn extract<E: Extractable<'v>, U>(&mut self, e: E) -> Result<E::Output, Spanned<U>> pub fn extract<E: Extractable<'v>, U>(&mut self, e: E) -> Result<E::Output, Spanned<U>>
where where
ExtractorError: Into<U>, ExtractorError: Into<U>,

View file

@ -34,7 +34,7 @@ use {
indexmap::IndexMap, indexmap::IndexMap,
jay_config::{ jay_config::{
Axis::{Horizontal, Vertical}, Axis::{Horizontal, Vertical},
get_workspace, Direction, get_workspace,
input::{LayerDirection, Timeline}, input::{LayerDirection, Timeline},
}, },
thiserror::Error, thiserror::Error,
@ -90,6 +90,10 @@ pub enum ActionParserError {
CopyMark(#[source] MarkIdParserError), CopyMark(#[source] MarkIdParserError),
#[error("Could not parse a show-workspace action")] #[error("Could not parse a show-workspace action")]
ShowWorkspace(#[source] OutputMatchParserError), 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>); pub struct ActionParser<'a>(pub &'a Context<'a>);
@ -356,14 +360,40 @@ impl ActionParser<'_> {
Ok(Action::ConfigureDrmDevice { dev }) Ok(Action::ConfigureDrmDevice { dev })
} }
fn parse_direction(v: Spanned<&str>) -> Result<Direction, Spanned<ActionParserError>> {
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<Self> { fn parse_move_to_output(&mut self, ext: &mut Extractor<'_>) -> ParseResult<Self> {
let (ws, output) = ext.extract((opt(str("workspace")), val("output")))?; let (ws, output_val, direction_val) = ext.extract((
let output = output opt(str("workspace")),
.parse_map(&mut OutputMatchParser(self.0)) opt(val("output")),
.map_spanned_err(ActionParserError::MoveToOutput)?; 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 { Ok(Action::MoveToOutput {
workspace: ws.despan().map(get_workspace), workspace: ws.despan().map(get_workspace),
output, output,
direction,
}) })
} }

View file

@ -360,20 +360,52 @@ impl Action {
set_idle_grace_period(period) set_idle_grace_period(period)
} }
}), }),
Action::MoveToOutput { output, workspace } => { Action::MoveToOutput {
output,
workspace,
direction,
} => {
let state = state.clone(); let state = state.clone();
b.new(move || { b.new(move || {
let output = 'get_output: { let target_output = {
for connector in connectors() { // Handle directional output selection
if connector.connected() && output.matches(connector, &state) { if let Some(direction) = direction {
break 'get_output connector; // 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 { match workspace {
Some(ws) => ws.move_to_output(output), Some(ws) => ws.move_to_output(target_output),
None => s.move_to_output(output), None => s.move_to_output(target_output),
} }
}) })
} }

View file

@ -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", "type": "object",
"properties": { "properties": {
"type": { "type": {
@ -174,13 +174,16 @@
"description": "The name of the workspace.\n\nIf this is omitted, the currently active workspace is moved.\n" "description": "The name of the workspace.\n\nIf this is omitted, the currently active workspace is moved.\n"
}, },
"output": { "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" "$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": [ "required": [
"type", "type"
"output"
] ]
}, },
{ {
@ -1126,6 +1129,16 @@
} }
] ]
}, },
"Direction": {
"type": "string",
"description": "A directional value used for output selection.\n",
"enum": [
"left",
"right",
"up",
"down"
]
},
"DrmDevice": { "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", "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", "type": "object",

View file

@ -290,20 +290,30 @@ This table is a tagged union. The variant is determined by the `type` field. It
Moves a workspace to a different output. Moves a workspace to a different output.
- Example 1: - Example 1: Move a specific workspace to a named output
```toml ```toml
[shortcuts] [shortcuts]
alt-F1 = { type = "move-to-output", workspace = "1", output.name = "right" } alt-F1 = { type = "move-to-output", workspace = "1", output.name = "right" }
``` ```
- Example 2: - Example 2: Move the current workspace to a named output
```toml ```toml
[shortcuts] [shortcuts]
alt-F1 = { type = "move-to-output", output.name = "right" } 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: The table has the following fields:
- `workspace` (optional): - `workspace` (optional):
@ -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. The value of this field should be a string.
- `output` (required): - `output` (optional):
The output to move to. The output to move to.
If multiple outputs match, the workspace is moved to the first matching If multiple outputs match, the workspace is moved to the first matching
output. output.
Either `output` or `direction` must be specified, but not both.
The value of this field should be a [OutputMatch](#types-OutputMatch). 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`: - `configure-connector`:
Applies a configuration to connectors. 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). Each element of this array should be a [ContentTypeMask](#types-ContentTypeMask).
<a name="types-Direction"></a>
### `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.
<a name="types-DrmDevice"></a> <a name="types-DrmDevice"></a>
### `DrmDevice` ### `DrmDevice`

View file

@ -288,19 +288,29 @@ Action:
description: | description: |
Moves a workspace to a different output. Moves a workspace to a different output.
- Example 1: - Example 1: Move a specific workspace to a named output
```toml ```toml
[shortcuts] [shortcuts]
alt-F1 = { type = "move-to-output", workspace = "1", output.name = "right" } alt-F1 = { type = "move-to-output", workspace = "1", output.name = "right" }
``` ```
- Example 2: - Example 2: Move the current workspace to a named output
```toml ```toml
[shortcuts] [shortcuts]
alt-F1 = { type = "move-to-output", output.name = "right" } 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: fields:
workspace: workspace:
description: | description: |
@ -315,8 +325,21 @@ Action:
If multiple outputs match, the workspace is moved to the first matching If multiple outputs match, the workspace is moved to the first matching
output. output.
required: true
Either `output` or `direction` must be specified, but not both.
required: false
ref: OutputMatch 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: configure-connector:
description: | description: |
Applies a configuration to connectors. Applies a configuration to connectors.
@ -4193,6 +4216,21 @@ BlendSpace:
description: Linear color space. This is the physically correct blend space. 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: ClientCapabilities:
description: | description: |
A mask of client capabilities. A mask of client capabilities.