| 1 | //! Monitor layout management |
| 2 | //! |
| 3 | //! Tracks monitor geometry and calculates adjacency relationships. |
| 4 | |
| 5 | use std::collections::HashMap; |
| 6 | |
| 7 | use hyprkvm_common::Direction; |
| 8 | |
| 9 | use super::ipc::{HyprlandClient, HyprlandError, Monitor}; |
| 10 | |
| 11 | /// Manages the monitor layout |
| 12 | pub struct MonitorLayout { |
| 13 | monitors: HashMap<String, MonitorInfo>, |
| 14 | } |
| 15 | |
| 16 | /// Monitor information with computed adjacency |
| 17 | #[derive(Debug, Clone)] |
| 18 | pub struct MonitorInfo { |
| 19 | pub name: String, |
| 20 | pub x: i32, |
| 21 | pub y: i32, |
| 22 | pub width: u32, |
| 23 | pub height: u32, |
| 24 | pub scale: f32, |
| 25 | pub active_workspace_id: i32, |
| 26 | pub active_workspace_name: String, |
| 27 | pub focused: bool, |
| 28 | pub adjacency: Adjacency, |
| 29 | } |
| 30 | |
| 31 | /// Adjacent monitors in each direction |
| 32 | #[derive(Debug, Clone, Default)] |
| 33 | pub struct Adjacency { |
| 34 | pub left: Option<String>, |
| 35 | pub right: Option<String>, |
| 36 | pub up: Option<String>, |
| 37 | pub down: Option<String>, |
| 38 | } |
| 39 | |
| 40 | impl MonitorLayout { |
| 41 | /// Build layout from current Hyprland state |
| 42 | pub async fn from_hyprland(client: &HyprlandClient) -> Result<Self, HyprlandError> { |
| 43 | let monitors = client.monitors().await?; |
| 44 | Ok(Self::from_monitors(&monitors)) |
| 45 | } |
| 46 | |
| 47 | /// Build layout from monitor list |
| 48 | pub fn from_monitors(monitors: &[Monitor]) -> Self { |
| 49 | let mut layout = Self { |
| 50 | monitors: HashMap::new(), |
| 51 | }; |
| 52 | |
| 53 | // First pass: add all monitors |
| 54 | for mon in monitors { |
| 55 | layout.monitors.insert( |
| 56 | mon.name.clone(), |
| 57 | MonitorInfo { |
| 58 | name: mon.name.clone(), |
| 59 | x: mon.x, |
| 60 | y: mon.y, |
| 61 | width: mon.width, |
| 62 | height: mon.height, |
| 63 | scale: mon.scale, |
| 64 | active_workspace_id: mon.active_workspace.id, |
| 65 | active_workspace_name: mon.active_workspace.name.clone(), |
| 66 | focused: mon.focused, |
| 67 | adjacency: Adjacency::default(), |
| 68 | }, |
| 69 | ); |
| 70 | } |
| 71 | |
| 72 | // Second pass: compute adjacency |
| 73 | let names: Vec<String> = layout.monitors.keys().cloned().collect(); |
| 74 | for name in &names { |
| 75 | let adjacency = layout.compute_adjacency(name); |
| 76 | if let Some(mon) = layout.monitors.get_mut(name) { |
| 77 | mon.adjacency = adjacency; |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | layout |
| 82 | } |
| 83 | |
| 84 | /// Compute adjacency for a monitor |
| 85 | fn compute_adjacency(&self, name: &str) -> Adjacency { |
| 86 | let Some(current) = self.monitors.get(name) else { |
| 87 | return Adjacency::default(); |
| 88 | }; |
| 89 | |
| 90 | let mut adjacency = Adjacency::default(); |
| 91 | |
| 92 | for (other_name, other) in &self.monitors { |
| 93 | if other_name == name { |
| 94 | continue; |
| 95 | } |
| 96 | |
| 97 | // Check if monitors are vertically aligned (overlap in Y axis) |
| 98 | let v_overlap = current.y < other.y + other.height as i32 |
| 99 | && current.y + current.height as i32 > other.y; |
| 100 | |
| 101 | // Check if monitors are horizontally aligned (overlap in X axis) |
| 102 | let h_overlap = current.x < other.x + other.width as i32 |
| 103 | && current.x + current.width as i32 > other.x; |
| 104 | |
| 105 | // Left neighbor: other is to the left and vertically aligned |
| 106 | if v_overlap && other.x + other.width as i32 <= current.x { |
| 107 | if adjacency.left.is_none() |
| 108 | || self.is_closer_left(current, other, &adjacency.left) |
| 109 | { |
| 110 | adjacency.left = Some(other_name.clone()); |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // Right neighbor: other is to the right and vertically aligned |
| 115 | if v_overlap && other.x >= current.x + current.width as i32 { |
| 116 | if adjacency.right.is_none() |
| 117 | || self.is_closer_right(current, other, &adjacency.right) |
| 118 | { |
| 119 | adjacency.right = Some(other_name.clone()); |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | // Up neighbor: other is above and horizontally aligned |
| 124 | if h_overlap && other.y + other.height as i32 <= current.y { |
| 125 | if adjacency.up.is_none() |
| 126 | || self.is_closer_up(current, other, &adjacency.up) |
| 127 | { |
| 128 | adjacency.up = Some(other_name.clone()); |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // Down neighbor: other is below and horizontally aligned |
| 133 | if h_overlap && other.y >= current.y + current.height as i32 { |
| 134 | if adjacency.down.is_none() |
| 135 | || self.is_closer_down(current, other, &adjacency.down) |
| 136 | { |
| 137 | adjacency.down = Some(other_name.clone()); |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | adjacency |
| 143 | } |
| 144 | |
| 145 | // Helper functions to find closest neighbor |
| 146 | fn is_closer_left(&self, _current: &MonitorInfo, other: &MonitorInfo, existing: &Option<String>) -> bool { |
| 147 | let Some(existing_name) = existing else { return true }; |
| 148 | let Some(existing_mon) = self.monitors.get(existing_name) else { return true }; |
| 149 | other.x + other.width as i32 > existing_mon.x + existing_mon.width as i32 |
| 150 | } |
| 151 | |
| 152 | fn is_closer_right(&self, _current: &MonitorInfo, other: &MonitorInfo, existing: &Option<String>) -> bool { |
| 153 | let Some(existing_name) = existing else { return true }; |
| 154 | let Some(existing_mon) = self.monitors.get(existing_name) else { return true }; |
| 155 | other.x < existing_mon.x |
| 156 | } |
| 157 | |
| 158 | fn is_closer_up(&self, _current: &MonitorInfo, other: &MonitorInfo, existing: &Option<String>) -> bool { |
| 159 | let Some(existing_name) = existing else { return true }; |
| 160 | let Some(existing_mon) = self.monitors.get(existing_name) else { return true }; |
| 161 | other.y + other.height as i32 > existing_mon.y + existing_mon.height as i32 |
| 162 | } |
| 163 | |
| 164 | fn is_closer_down(&self, _current: &MonitorInfo, other: &MonitorInfo, existing: &Option<String>) -> bool { |
| 165 | let Some(existing_name) = existing else { return true }; |
| 166 | let Some(existing_mon) = self.monitors.get(existing_name) else { return true }; |
| 167 | other.y < existing_mon.y |
| 168 | } |
| 169 | |
| 170 | /// Get the focused monitor |
| 171 | pub fn focused(&self) -> Option<&MonitorInfo> { |
| 172 | self.monitors.values().find(|m| m.focused) |
| 173 | } |
| 174 | |
| 175 | /// Get a monitor by name |
| 176 | pub fn get(&self, name: &str) -> Option<&MonitorInfo> { |
| 177 | self.monitors.get(name) |
| 178 | } |
| 179 | |
| 180 | /// Get monitor in given direction from focused monitor |
| 181 | pub fn neighbor(&self, direction: Direction) -> Option<&MonitorInfo> { |
| 182 | let focused = self.focused()?; |
| 183 | let neighbor_name = match direction { |
| 184 | Direction::Left => focused.adjacency.left.as_ref()?, |
| 185 | Direction::Right => focused.adjacency.right.as_ref()?, |
| 186 | Direction::Up => focused.adjacency.up.as_ref()?, |
| 187 | Direction::Down => focused.adjacency.down.as_ref()?, |
| 188 | }; |
| 189 | self.monitors.get(neighbor_name) |
| 190 | } |
| 191 | |
| 192 | /// Check if we're at an edge (no local monitor in that direction) |
| 193 | pub fn is_at_edge(&self, direction: Direction) -> bool { |
| 194 | self.neighbor(direction).is_none() |
| 195 | } |
| 196 | |
| 197 | /// Get screen bounds (bounding box of all monitors) |
| 198 | pub fn bounds(&self) -> (i32, i32, i32, i32) { |
| 199 | let mut min_x = i32::MAX; |
| 200 | let mut min_y = i32::MAX; |
| 201 | let mut max_x = i32::MIN; |
| 202 | let mut max_y = i32::MIN; |
| 203 | |
| 204 | for mon in self.monitors.values() { |
| 205 | min_x = min_x.min(mon.x); |
| 206 | min_y = min_y.min(mon.y); |
| 207 | max_x = max_x.max(mon.x + mon.width as i32); |
| 208 | max_y = max_y.max(mon.y + mon.height as i32); |
| 209 | } |
| 210 | |
| 211 | (min_x, min_y, max_x, max_y) |
| 212 | } |
| 213 | |
| 214 | /// Check if a cursor position is at a screen edge |
| 215 | pub fn cursor_at_edge(&self, x: i32, y: i32, threshold: i32) -> Option<Direction> { |
| 216 | let (min_x, min_y, max_x, max_y) = self.bounds(); |
| 217 | |
| 218 | if x <= min_x + threshold { |
| 219 | Some(Direction::Left) |
| 220 | } else if x >= max_x - threshold { |
| 221 | Some(Direction::Right) |
| 222 | } else if y <= min_y + threshold { |
| 223 | Some(Direction::Up) |
| 224 | } else if y >= max_y - threshold { |
| 225 | Some(Direction::Down) |
| 226 | } else { |
| 227 | None |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | /// Get all monitor names |
| 232 | pub fn monitor_names(&self) -> impl Iterator<Item = &String> { |
| 233 | self.monitors.keys() |
| 234 | } |
| 235 | |
| 236 | /// Get number of monitors |
| 237 | pub fn len(&self) -> usize { |
| 238 | self.monitors.len() |
| 239 | } |
| 240 | |
| 241 | /// Check if empty |
| 242 | pub fn is_empty(&self) -> bool { |
| 243 | self.monitors.is_empty() |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | #[cfg(test)] |
| 248 | mod tests { |
| 249 | use super::*; |
| 250 | use crate::hyprland::ipc::WorkspaceRef; |
| 251 | |
| 252 | fn make_monitor(name: &str, x: i32, y: i32, width: u32, height: u32, focused: bool) -> Monitor { |
| 253 | Monitor { |
| 254 | id: 0, |
| 255 | name: name.to_string(), |
| 256 | description: String::new(), |
| 257 | x, |
| 258 | y, |
| 259 | width, |
| 260 | height, |
| 261 | scale: 1.0, |
| 262 | active_workspace: WorkspaceRef { |
| 263 | id: 1, |
| 264 | name: "1".to_string(), |
| 265 | }, |
| 266 | focused, |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | #[test] |
| 271 | fn test_horizontal_layout() { |
| 272 | let monitors = vec![ |
| 273 | make_monitor("DP-1", 0, 0, 1920, 1080, true), |
| 274 | make_monitor("DP-2", 1920, 0, 1920, 1080, false), |
| 275 | ]; |
| 276 | |
| 277 | let layout = MonitorLayout::from_monitors(&monitors); |
| 278 | |
| 279 | let dp1 = layout.get("DP-1").unwrap(); |
| 280 | assert!(dp1.adjacency.left.is_none()); |
| 281 | assert_eq!(dp1.adjacency.right, Some("DP-2".to_string())); |
| 282 | |
| 283 | let dp2 = layout.get("DP-2").unwrap(); |
| 284 | assert_eq!(dp2.adjacency.left, Some("DP-1".to_string())); |
| 285 | assert!(dp2.adjacency.right.is_none()); |
| 286 | } |
| 287 | |
| 288 | #[test] |
| 289 | fn test_edge_detection() { |
| 290 | let monitors = vec![ |
| 291 | make_monitor("DP-1", 0, 0, 1920, 1080, true), |
| 292 | ]; |
| 293 | |
| 294 | let layout = MonitorLayout::from_monitors(&monitors); |
| 295 | |
| 296 | assert!(layout.is_at_edge(Direction::Left)); |
| 297 | assert!(layout.is_at_edge(Direction::Right)); |
| 298 | assert!(layout.is_at_edge(Direction::Up)); |
| 299 | assert!(layout.is_at_edge(Direction::Down)); |
| 300 | } |
| 301 | } |
| 302 |