@@ -0,0 +1,357 @@ |
| 1 | +//! RandR manager for querying and applying display configurations. |
| 2 | + |
| 3 | +use gartk_x11::Connection; |
| 4 | +use x11rb::connection::Connection as X11Connection; |
| 5 | +use x11rb::protocol::randr::{self, ConnectionExt as RandrExt}; |
| 6 | + |
| 7 | +use super::error::{RandrError, Result}; |
| 8 | +use super::types::{ModeInfo, OutputInfo}; |
| 9 | +use crate::config::MonitorConfig; |
| 10 | + |
| 11 | +/// Manager for RandR operations. |
| 12 | +pub struct RandrManager { |
| 13 | + conn: Connection, |
| 14 | + root: u32, |
| 15 | +} |
| 16 | + |
| 17 | +impl RandrManager { |
| 18 | + /// Create a new RandR manager. |
| 19 | + pub fn new(conn: Connection) -> Result<Self> { |
| 20 | + let root = conn.root(); |
| 21 | + |
| 22 | + // Verify RandR is available |
| 23 | + conn.inner() |
| 24 | + .randr_query_version(1, 5)? |
| 25 | + .reply()?; |
| 26 | + |
| 27 | + Ok(Self { conn, root }) |
| 28 | + } |
| 29 | + |
| 30 | + /// Get screen resources (outputs, CRTCs, modes). |
| 31 | + fn get_resources(&self) -> Result<randr::GetScreenResourcesCurrentReply> { |
| 32 | + Ok(self |
| 33 | + .conn |
| 34 | + .inner() |
| 35 | + .randr_get_screen_resources_current(self.root)? |
| 36 | + .reply()?) |
| 37 | + } |
| 38 | + |
| 39 | + /// Get information about all outputs. |
| 40 | + #[allow(dead_code)] // Used for mode selection UI |
| 41 | + pub fn get_outputs(&self) -> Result<Vec<OutputInfo>> { |
| 42 | + let resources = self.get_resources()?; |
| 43 | + let mut outputs = Vec::new(); |
| 44 | + |
| 45 | + for &output in &resources.outputs { |
| 46 | + let info = self |
| 47 | + .conn |
| 48 | + .inner() |
| 49 | + .randr_get_output_info(output, resources.config_timestamp)? |
| 50 | + .reply()?; |
| 51 | + |
| 52 | + let name = String::from_utf8_lossy(&info.name).to_string(); |
| 53 | + let connected = info.connection == randr::Connection::CONNECTED; |
| 54 | + |
| 55 | + // Get available modes |
| 56 | + let modes: Vec<ModeInfo> = info |
| 57 | + .modes |
| 58 | + .iter() |
| 59 | + .filter_map(|&mode_id| self.get_mode_info(&resources, mode_id)) |
| 60 | + .collect(); |
| 61 | + |
| 62 | + // Get current mode and position if CRTC is set |
| 63 | + let (crtc, current_mode, position) = if info.crtc != 0 { |
| 64 | + let crtc_info = self |
| 65 | + .conn |
| 66 | + .inner() |
| 67 | + .randr_get_crtc_info(info.crtc, resources.config_timestamp)? |
| 68 | + .reply()?; |
| 69 | + |
| 70 | + let current = if crtc_info.mode != 0 { |
| 71 | + self.get_mode_info(&resources, crtc_info.mode) |
| 72 | + } else { |
| 73 | + None |
| 74 | + }; |
| 75 | + |
| 76 | + ( |
| 77 | + Some(info.crtc), |
| 78 | + current, |
| 79 | + Some((crtc_info.x, crtc_info.y)), |
| 80 | + ) |
| 81 | + } else { |
| 82 | + (None, None, None) |
| 83 | + }; |
| 84 | + |
| 85 | + outputs.push(OutputInfo { |
| 86 | + name, |
| 87 | + output, |
| 88 | + crtc, |
| 89 | + connected, |
| 90 | + modes, |
| 91 | + current_mode, |
| 92 | + position, |
| 93 | + width_mm: info.mm_width, |
| 94 | + height_mm: info.mm_height, |
| 95 | + }); |
| 96 | + } |
| 97 | + |
| 98 | + Ok(outputs) |
| 99 | + } |
| 100 | + |
| 101 | + /// Get mode information by ID. |
| 102 | + fn get_mode_info( |
| 103 | + &self, |
| 104 | + resources: &randr::GetScreenResourcesCurrentReply, |
| 105 | + mode_id: randr::Mode, |
| 106 | + ) -> Option<ModeInfo> { |
| 107 | + resources.modes.iter().find(|m| m.id == mode_id).map(|m| { |
| 108 | + let refresh = if m.htotal > 0 && m.vtotal > 0 { |
| 109 | + (m.dot_clock as f64) / (m.htotal as f64 * m.vtotal as f64) |
| 110 | + } else { |
| 111 | + 0.0 |
| 112 | + }; |
| 113 | + |
| 114 | + ModeInfo { |
| 115 | + id: m.id, |
| 116 | + width: m.width, |
| 117 | + height: m.height, |
| 118 | + refresh, |
| 119 | + } |
| 120 | + }) |
| 121 | + } |
| 122 | + |
| 123 | + /// Get the name of the primary output. |
| 124 | + #[allow(dead_code)] // Used for mode selection UI |
| 125 | + pub fn get_primary_name(&self) -> Result<Option<String>> { |
| 126 | + let reply = self.conn.inner().randr_get_output_primary(self.root)?.reply()?; |
| 127 | + |
| 128 | + if reply.output == 0 { |
| 129 | + return Ok(None); |
| 130 | + } |
| 131 | + |
| 132 | + let resources = self.get_resources()?; |
| 133 | + let info = self |
| 134 | + .conn |
| 135 | + .inner() |
| 136 | + .randr_get_output_info(reply.output, resources.config_timestamp)? |
| 137 | + .reply()?; |
| 138 | + |
| 139 | + Ok(Some(String::from_utf8_lossy(&info.name).to_string())) |
| 140 | + } |
| 141 | + |
| 142 | + /// Set the primary output by name. |
| 143 | + pub fn set_primary(&self, name: &str) -> Result<()> { |
| 144 | + let output = self.find_output_by_name(name)?; |
| 145 | + self.conn.inner().randr_set_output_primary(self.root, output)?; |
| 146 | + tracing::info!("set primary output to {}", name); |
| 147 | + Ok(()) |
| 148 | + } |
| 149 | + |
| 150 | + /// Find an output by name. |
| 151 | + fn find_output_by_name(&self, name: &str) -> Result<randr::Output> { |
| 152 | + let resources = self.get_resources()?; |
| 153 | + |
| 154 | + for &output in &resources.outputs { |
| 155 | + let info = self |
| 156 | + .conn |
| 157 | + .inner() |
| 158 | + .randr_get_output_info(output, resources.config_timestamp)? |
| 159 | + .reply()?; |
| 160 | + |
| 161 | + let output_name = String::from_utf8_lossy(&info.name); |
| 162 | + if output_name == name { |
| 163 | + return Ok(output); |
| 164 | + } |
| 165 | + } |
| 166 | + |
| 167 | + Err(RandrError::OutputNotFound(name.to_string())) |
| 168 | + } |
| 169 | + |
| 170 | + /// Apply a monitor configuration. |
| 171 | + pub fn apply_monitor(&self, config: &MonitorConfig) -> Result<()> { |
| 172 | + if !config.enabled { |
| 173 | + return self.disable_output(&config.name); |
| 174 | + } |
| 175 | + |
| 176 | + let resources = self.get_resources()?; |
| 177 | + let output = self.find_output_by_name(&config.name)?; |
| 178 | + |
| 179 | + let output_info = self |
| 180 | + .conn |
| 181 | + .inner() |
| 182 | + .randr_get_output_info(output, resources.config_timestamp)? |
| 183 | + .reply()?; |
| 184 | + |
| 185 | + // Find matching mode |
| 186 | + let mode_id = self.find_mode_id(&resources, &output_info.modes, config)?; |
| 187 | + |
| 188 | + // Find available CRTC |
| 189 | + let crtc = self.find_available_crtc(&resources, &output_info, output)?; |
| 190 | + |
| 191 | + // Convert rotation |
| 192 | + let rotation = match config.rotation { |
| 193 | + 90 => randr::Rotation::ROTATE90, |
| 194 | + 180 => randr::Rotation::ROTATE180, |
| 195 | + 270 => randr::Rotation::ROTATE270, |
| 196 | + _ => randr::Rotation::ROTATE0, |
| 197 | + }; |
| 198 | + |
| 199 | + // Apply configuration |
| 200 | + let result = self |
| 201 | + .conn |
| 202 | + .inner() |
| 203 | + .randr_set_crtc_config( |
| 204 | + crtc, |
| 205 | + resources.timestamp, |
| 206 | + resources.config_timestamp, |
| 207 | + config.x as i16, |
| 208 | + config.y as i16, |
| 209 | + mode_id, |
| 210 | + rotation, |
| 211 | + &[output], |
| 212 | + )? |
| 213 | + .reply()?; |
| 214 | + |
| 215 | + if result.status != randr::SetConfig::SUCCESS { |
| 216 | + return Err(RandrError::ConfigFailed(format!( |
| 217 | + "set_crtc_config returned {:?}", |
| 218 | + result.status |
| 219 | + ))); |
| 220 | + } |
| 221 | + |
| 222 | + tracing::info!( |
| 223 | + "applied config for {}: {}x{} at ({}, {})", |
| 224 | + config.name, |
| 225 | + config.width, |
| 226 | + config.height, |
| 227 | + config.x, |
| 228 | + config.y |
| 229 | + ); |
| 230 | + |
| 231 | + Ok(()) |
| 232 | + } |
| 233 | + |
| 234 | + /// Disable an output. |
| 235 | + pub fn disable_output(&self, name: &str) -> Result<()> { |
| 236 | + let resources = self.get_resources()?; |
| 237 | + let output = self.find_output_by_name(name)?; |
| 238 | + |
| 239 | + let output_info = self |
| 240 | + .conn |
| 241 | + .inner() |
| 242 | + .randr_get_output_info(output, resources.config_timestamp)? |
| 243 | + .reply()?; |
| 244 | + |
| 245 | + if output_info.crtc == 0 { |
| 246 | + // Already disabled |
| 247 | + return Ok(()); |
| 248 | + } |
| 249 | + |
| 250 | + // Disable by setting mode to 0 |
| 251 | + self.conn |
| 252 | + .inner() |
| 253 | + .randr_set_crtc_config( |
| 254 | + output_info.crtc, |
| 255 | + resources.timestamp, |
| 256 | + resources.config_timestamp, |
| 257 | + 0, |
| 258 | + 0, |
| 259 | + 0, // mode 0 = disable |
| 260 | + randr::Rotation::ROTATE0, |
| 261 | + &[], |
| 262 | + )? |
| 263 | + .reply()?; |
| 264 | + |
| 265 | + tracing::info!("disabled output {}", name); |
| 266 | + Ok(()) |
| 267 | + } |
| 268 | + |
| 269 | + /// Find a matching mode ID for the given config. |
| 270 | + fn find_mode_id( |
| 271 | + &self, |
| 272 | + resources: &randr::GetScreenResourcesCurrentReply, |
| 273 | + output_modes: &[randr::Mode], |
| 274 | + config: &MonitorConfig, |
| 275 | + ) -> Result<randr::Mode> { |
| 276 | + for &mode_id in output_modes { |
| 277 | + if let Some(mode) = self.get_mode_info(resources, mode_id) { |
| 278 | + if mode.width as u32 == config.width |
| 279 | + && mode.height as u32 == config.height |
| 280 | + && (mode.refresh - config.refresh).abs() < 1.0 |
| 281 | + { |
| 282 | + return Ok(mode_id); |
| 283 | + } |
| 284 | + } |
| 285 | + } |
| 286 | + |
| 287 | + // Fallback: find mode with matching resolution (any refresh) |
| 288 | + for &mode_id in output_modes { |
| 289 | + if let Some(mode) = self.get_mode_info(resources, mode_id) { |
| 290 | + if mode.width as u32 == config.width && mode.height as u32 == config.height { |
| 291 | + tracing::warn!( |
| 292 | + "exact refresh rate not found, using {}x{}@{:.1}Hz", |
| 293 | + mode.width, |
| 294 | + mode.height, |
| 295 | + mode.refresh |
| 296 | + ); |
| 297 | + return Ok(mode_id); |
| 298 | + } |
| 299 | + } |
| 300 | + } |
| 301 | + |
| 302 | + Err(RandrError::ModeNotFound { |
| 303 | + width: config.width, |
| 304 | + height: config.height, |
| 305 | + refresh: config.refresh, |
| 306 | + }) |
| 307 | + } |
| 308 | + |
| 309 | + /// Find an available CRTC for the output. |
| 310 | + fn find_available_crtc( |
| 311 | + &self, |
| 312 | + resources: &randr::GetScreenResourcesCurrentReply, |
| 313 | + output_info: &randr::GetOutputInfoReply, |
| 314 | + output: randr::Output, |
| 315 | + ) -> Result<randr::Crtc> { |
| 316 | + // First, check if output already has a CRTC |
| 317 | + if output_info.crtc != 0 { |
| 318 | + return Ok(output_info.crtc); |
| 319 | + } |
| 320 | + |
| 321 | + // Find a free CRTC that the output can use |
| 322 | + for &crtc in &output_info.crtcs { |
| 323 | + let crtc_info = self |
| 324 | + .conn |
| 325 | + .inner() |
| 326 | + .randr_get_crtc_info(crtc, resources.config_timestamp)? |
| 327 | + .reply()?; |
| 328 | + |
| 329 | + // CRTC is free if it has no outputs |
| 330 | + if crtc_info.outputs.is_empty() { |
| 331 | + return Ok(crtc); |
| 332 | + } |
| 333 | + } |
| 334 | + |
| 335 | + // Try to find any CRTC we can use |
| 336 | + for &crtc in &resources.crtcs { |
| 337 | + let crtc_info = self |
| 338 | + .conn |
| 339 | + .inner() |
| 340 | + .randr_get_crtc_info(crtc, resources.config_timestamp)? |
| 341 | + .reply()?; |
| 342 | + |
| 343 | + if crtc_info.outputs.is_empty() && crtc_info.possible.contains(&output) { |
| 344 | + return Ok(crtc); |
| 345 | + } |
| 346 | + } |
| 347 | + |
| 348 | + let name = String::from_utf8_lossy(&output_info.name).to_string(); |
| 349 | + Err(RandrError::NoCrtcAvailable(name)) |
| 350 | + } |
| 351 | + |
| 352 | + /// Flush pending X11 requests. |
| 353 | + pub fn flush(&self) -> Result<()> { |
| 354 | + self.conn.inner().flush()?; |
| 355 | + Ok(()) |
| 356 | + } |
| 357 | +} |