From a35d4372bfac43f930e08911d223734f43b0adfd Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 4 Oct 2025 20:52:50 -0500 Subject: [PATCH 1/2] randR method added for actual integration with X11 --- rewrite/Cargo.toml | 1 + rewrite/src/gamma_randr.rs | 269 ++++++++++++++++++++++++++++ rewrite/src/lib.rs | 1 + rewrite/src/main.rs | 6 +- rewrite/tests/gamma_randr_tests.rs | 276 +++++++++++++++++++++++++++++ rewrite/tests/gamma_tests.rs | 199 +++++++++++++++++++++ 6 files changed, 751 insertions(+), 1 deletion(-) create mode 100644 rewrite/src/gamma_randr.rs create mode 100644 rewrite/tests/gamma_randr_tests.rs create mode 100644 rewrite/tests/gamma_tests.rs diff --git a/rewrite/Cargo.toml b/rewrite/Cargo.toml index 563731c..d57dc35 100644 --- a/rewrite/Cargo.toml +++ b/rewrite/Cargo.toml @@ -7,3 +7,4 @@ edition = "2021" [dependencies] clap = { version = "4.5", features = ["derive"] } +x11rb = { version = "0.13", features = ["randr"] } diff --git a/rewrite/src/gamma_randr.rs b/rewrite/src/gamma_randr.rs new file mode 100644 index 0000000..ddccd73 --- /dev/null +++ b/rewrite/src/gamma_randr.rs @@ -0,0 +1,269 @@ +/// X11 RandR gamma adjustment method +/// Ported from legacy/src/gamma-randr.c + +use crate::colorramp::colorramp_fill; +use crate::gamma::GammaMethod; +use crate::types::ColorSetting; +use std::fmt; +use x11rb::connection::Connection; +use x11rb::protocol::randr; +use x11rb::protocol::xproto; +use x11rb::rust_connection::RustConnection; + +const RANDR_VERSION_MAJOR: u32 = 1; +const RANDR_VERSION_MINOR: u32 = 3; + +/// State for a single CRTC +struct CrtcState { + crtc: randr::Crtc, + ramp_size: u16, + saved_ramps: Vec, // R, G, B ramps concatenated (3 * ramp_size) +} + +/// X11 RandR gamma adjustment method +pub struct RandrGammaMethod { + conn: Option, + screen_num: Option, + preferred_screen: usize, + crtc_filter: Vec, // If non-empty, only adjust these CRTC indices + crtcs: Vec, +} + +impl RandrGammaMethod { + pub fn new() -> Self { + Self { + conn: None, + screen_num: None, + preferred_screen: 0, + crtc_filter: Vec::new(), + crtcs: Vec::new(), + } + } + + /// Set which screen to use (None = use default) + pub fn set_screen(&mut self, screen: i32) { + self.screen_num = Some(screen); + } + + /// Set which CRTCs to adjust (empty = all) + pub fn set_crtcs(&mut self, crtc_indices: Vec) { + self.crtc_filter = crtc_indices; + } + + fn get_screen_root(&self) -> Result { + let conn = self.conn.as_ref().ok_or("Not connected to X server")?; + + let screen_num = self.screen_num.unwrap_or(self.preferred_screen as i32); + let setup = conn.setup(); + let screen = setup + .roots + .get(screen_num as usize) + .ok_or_else(|| format!("Screen {} could not be found", screen_num))?; + + Ok(screen.root) + } + + fn set_temperature_for_crtc( + &self, + crtc_state: &CrtcState, + setting: &ColorSetting, + preserve: bool, + ) -> Result<(), String> { + let conn = self.conn.as_ref().ok_or("Not connected to X server")?; + let ramp_size = crtc_state.ramp_size as usize; + + /* Create new gamma ramps */ + let mut gamma_r = vec![0u16; ramp_size]; + let mut gamma_g = vec![0u16; ramp_size]; + let mut gamma_b = vec![0u16; ramp_size]; + + if preserve { + /* Initialize from saved state */ + gamma_r.copy_from_slice(&crtc_state.saved_ramps[0..ramp_size]); + gamma_g.copy_from_slice(&crtc_state.saved_ramps[ramp_size..2 * ramp_size]); + gamma_b.copy_from_slice(&crtc_state.saved_ramps[2 * ramp_size..3 * ramp_size]); + } else { + /* Initialize to linear (pure state) */ + for i in 0..ramp_size { + let value = ((i as f64 / ramp_size as f64) * 65536.0) as u16; + gamma_r[i] = value; + gamma_g[i] = value; + gamma_b[i] = value; + } + } + + /* Apply color temperature adjustment */ + colorramp_fill(&mut gamma_r, &mut gamma_g, &mut gamma_b, setting); + + /* Set gamma ramps */ + randr::set_crtc_gamma( + conn, + crtc_state.crtc, + &gamma_r, + &gamma_g, + &gamma_b, + ) + .map_err(|e| format!("Failed to set CRTC gamma: {}", e))? + .check() + .map_err(|e| format!("RANDR Set CRTC Gamma returned error: {:?}", e))?; + + Ok(()) + } +} + +impl Default for RandrGammaMethod { + fn default() -> Self { + Self::new() + } +} + +impl GammaMethod for RandrGammaMethod { + fn init(&mut self) -> Result<(), String> { + /* Open X server connection */ + let (conn, preferred_screen) = RustConnection::connect(None) + .map_err(|e| format!("Failed to connect to X server: {}", e))?; + + self.preferred_screen = preferred_screen; + + /* Query RandR version */ + let ver_reply = randr::query_version(&conn, RANDR_VERSION_MAJOR, RANDR_VERSION_MINOR) + .map_err(|e| format!("Failed to query RANDR version: {}", e))? + .reply() + .map_err(|e| format!("RANDR Query Version returned error: {}", e))?; + + if ver_reply.major_version != RANDR_VERSION_MAJOR + || ver_reply.minor_version < RANDR_VERSION_MINOR + { + return Err(format!( + "Unsupported RANDR version ({}.{})", + ver_reply.major_version, ver_reply.minor_version + )); + } + + self.conn = Some(conn); + Ok(()) + } + + fn start(&mut self) -> Result<(), String> { + let conn = self.conn.as_ref().ok_or("Not initialized")?; + let root = self.get_screen_root()?; + + /* Get screen resources (list of CRTCs) */ + let res_reply = randr::get_screen_resources_current(conn, root) + .map_err(|e| format!("Failed to get screen resources: {}", e))? + .reply() + .map_err(|e| format!("RANDR Get Screen Resources Current returned error: {}", e))?; + + let crtcs = res_reply.crtcs; + + /* Save CRTC state and gamma ramps */ + for crtc in crtcs { + /* Get gamma ramp size */ + let gamma_size_reply = randr::get_crtc_gamma_size(conn, crtc) + .map_err(|e| format!("Failed to get CRTC gamma size: {}", e))? + .reply() + .map_err(|e| format!("RANDR Get CRTC Gamma Size returned error: {}", e))?; + + let ramp_size = gamma_size_reply.size; + + if ramp_size == 0 { + eprintln!("Warning: CRTC has gamma ramp size 0, skipping"); + continue; + } + + /* Get current gamma ramps */ + let gamma_get_reply = randr::get_crtc_gamma(conn, crtc) + .map_err(|e| format!("Failed to get CRTC gamma: {}", e))? + .reply() + .map_err(|e| format!("RANDR Get CRTC Gamma returned error: {}", e))?; + + /* Save gamma ramps (R, G, B concatenated) */ + let mut saved_ramps = Vec::with_capacity(3 * ramp_size as usize); + saved_ramps.extend_from_slice(&gamma_get_reply.red); + saved_ramps.extend_from_slice(&gamma_get_reply.green); + saved_ramps.extend_from_slice(&gamma_get_reply.blue); + + self.crtcs.push(CrtcState { + crtc, + ramp_size, + saved_ramps, + }); + } + + if self.crtcs.is_empty() { + return Err("No usable CRTCs found".to_string()); + } + + Ok(()) + } + + fn set_temperature(&mut self, setting: &ColorSetting, preserve: bool) -> Result<(), String> { + /* If no CRTC filter is set, adjust all CRTCs */ + if self.crtc_filter.is_empty() { + for crtc_state in &self.crtcs { + self.set_temperature_for_crtc(crtc_state, setting, preserve)?; + } + } else { + /* Only adjust specified CRTCs */ + for &crtc_idx in &self.crtc_filter { + if crtc_idx >= self.crtcs.len() { + return Err(format!( + "CRTC {} does not exist. Valid CRTCs are [0-{}]", + crtc_idx, + self.crtcs.len() - 1 + )); + } + self.set_temperature_for_crtc(&self.crtcs[crtc_idx], setting, preserve)?; + } + } + + Ok(()) + } + + fn restore(&mut self) { + if let Some(conn) = &self.conn { + /* Restore original gamma ramps for all CRTCs */ + for crtc_state in &self.crtcs { + let ramp_size = crtc_state.ramp_size as usize; + let gamma_r = &crtc_state.saved_ramps[0..ramp_size]; + let gamma_g = &crtc_state.saved_ramps[ramp_size..2 * ramp_size]; + let gamma_b = &crtc_state.saved_ramps[2 * ramp_size..3 * ramp_size]; + + match randr::set_crtc_gamma(conn, crtc_state.crtc, gamma_r, gamma_g, gamma_b) { + Ok(cookie) => { + if let Err(e) = cookie.check() { + eprintln!("Warning: Failed to restore CRTC gamma: {:?}", e); + } + } + Err(e) => { + eprintln!("Warning: Failed to send restore CRTC gamma request: {:?}", e); + } + } + } + } + } + + fn name(&self) -> &str { + "randr" + } + + fn print_help(&self) { + println!("Adjust gamma ramps with the X RANDR extension."); + println!(); + println!(" screen=N X screen to apply adjustments to"); + println!(" crtc=N List of comma separated CRTCs to apply adjustments to"); + println!(); + } +} + +impl fmt::Display for RandrGammaMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "RandR") + } +} + +impl Drop for RandrGammaMethod { + fn drop(&mut self) { + self.restore(); + } +} diff --git a/rewrite/src/lib.rs b/rewrite/src/lib.rs index 258a376..e00fa64 100644 --- a/rewrite/src/lib.rs +++ b/rewrite/src/lib.rs @@ -1,5 +1,6 @@ pub mod colorramp; pub mod gamma; +pub mod gamma_randr; pub mod location; pub mod solar; pub mod types; diff --git a/rewrite/src/main.rs b/rewrite/src/main.rs index 3ef30f2..426f923 100644 --- a/rewrite/src/main.rs +++ b/rewrite/src/main.rs @@ -1,11 +1,13 @@ mod colorramp; mod gamma; +mod gamma_randr; mod location; mod solar; mod types; use clap::{Parser, ValueEnum}; use gamma::{DummyGammaMethod, GammaMethod}; +use gamma_randr::RandrGammaMethod; use location::{LocationProvider, ManualLocationProvider}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use types::*; @@ -19,6 +21,7 @@ const FADE_LENGTH: i32 = 40; #[derive(Debug, Clone, Copy, ValueEnum)] enum GammaMethodChoice { + Randr, Dummy, } @@ -31,7 +34,7 @@ struct Args { location: Option, /// Gamma adjustment method - #[arg(short = 'm', long, default_value = "dummy")] + #[arg(short = 'm', long, default_value = "randr")] method: GammaMethodChoice, /// One-shot mode (set temperature and exit) @@ -239,6 +242,7 @@ fn main() -> Result<(), Box> { /* Set up gamma method */ let mut gamma_method: Box = match args.method { + GammaMethodChoice::Randr => Box::new(RandrGammaMethod::new()), GammaMethodChoice::Dummy => Box::new(DummyGammaMethod::new()), }; diff --git a/rewrite/tests/gamma_randr_tests.rs b/rewrite/tests/gamma_randr_tests.rs new file mode 100644 index 0000000..fb4ae71 --- /dev/null +++ b/rewrite/tests/gamma_randr_tests.rs @@ -0,0 +1,276 @@ +use redshift_rebooted::gamma::GammaMethod; +use redshift_rebooted::gamma_randr::RandrGammaMethod; +use redshift_rebooted::types::*; + +#[test] +fn test_randr_gamma_method_creation() { + // Test that RandrGammaMethod can be created + let method = RandrGammaMethod::new(); + assert_eq!(method.name(), "randr", "RandrGammaMethod name should be 'randr'"); +} + +#[test] +fn test_randr_gamma_method_default() { + // Test that RandrGammaMethod can be created with Default trait + let method = RandrGammaMethod::default(); + assert_eq!(method.name(), "randr", "Default RandrGammaMethod name should be 'randr'"); +} + +#[test] +fn test_randr_gamma_method_display_trait() { + let method = RandrGammaMethod::new(); + let display_string = format!("{}", method); + assert_eq!(display_string, "RandR", "RandrGammaMethod should display as 'RandR'"); +} + +#[test] +fn test_randr_gamma_method_init_no_display() { + // Test init when DISPLAY is not set or X11 is not available + // This may fail gracefully or succeed depending on environment + let mut method = RandrGammaMethod::new(); + + // We don't assert success/failure here because it depends on environment + // Just verify it doesn't panic + let _ = method.init(); +} + +#[test] +fn test_randr_gamma_method_set_screen() { + // Test the set_screen configuration method + let mut method = RandrGammaMethod::new(); + method.set_screen(0); + // If we got here without panicking, the method works +} + +#[test] +fn test_randr_gamma_method_set_crtcs() { + // Test the set_crtcs configuration method + let mut method = RandrGammaMethod::new(); + method.set_crtcs(vec![0, 1]); + // If we got here without panicking, the method works +} + +#[test] +fn test_randr_gamma_method_restore_without_init() { + // Test that restore doesn't panic even if not initialized + let mut method = RandrGammaMethod::new(); + method.restore(); + // Should not panic +} + +// Integration test - only runs if X11 is available +#[test] +#[ignore] // Use `cargo test -- --ignored` to run this +fn test_randr_gamma_method_full_lifecycle_x11() { + // This test requires X11 to be available + let mut method = RandrGammaMethod::new(); + + // Try to initialize + if method.init().is_err() { + eprintln!("X11 not available, skipping integration test"); + return; + } + + // Try to start + if method.start().is_err() { + eprintln!("Could not start RandR method, skipping"); + return; + } + + // Try to set a temperature + let setting = ColorSetting { + temperature: 5000, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + if let Err(e) = method.set_temperature(&setting, false) { + eprintln!("Could not set temperature: {}", e); + } + + // Restore + method.restore(); +} + +// Integration test - test preserve flag +#[test] +#[ignore] +fn test_randr_gamma_method_preserve_flag_x11() { + let mut method = RandrGammaMethod::new(); + + if method.init().is_err() || method.start().is_err() { + eprintln!("X11 not available, skipping integration test"); + return; + } + + let setting = ColorSetting { + temperature: 4000, + gamma: [1.0, 1.0, 1.0], + brightness: 0.9, + }; + + // Test without preserve + if let Err(e) = method.set_temperature(&setting, false) { + eprintln!("Could not set temperature without preserve: {}", e); + } + + // Test with preserve + if let Err(e) = method.set_temperature(&setting, true) { + eprintln!("Could not set temperature with preserve: {}", e); + } + + method.restore(); +} + +// Integration test - test multiple temperature changes +#[test] +#[ignore] +fn test_randr_gamma_method_multiple_changes_x11() { + let mut method = RandrGammaMethod::new(); + + if method.init().is_err() || method.start().is_err() { + eprintln!("X11 not available, skipping integration test"); + return; + } + + let temperatures = [6500, 5000, 3500, 4500, 6500]; + + for temp in temperatures { + let setting = ColorSetting { + temperature: temp, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + if let Err(e) = method.set_temperature(&setting, false) { + eprintln!("Could not set temperature {}: {}", temp, e); + } + } + + method.restore(); +} + +// Integration test - test extreme temperatures +#[test] +#[ignore] +fn test_randr_gamma_method_extreme_temperatures_x11() { + let mut method = RandrGammaMethod::new(); + + if method.init().is_err() || method.start().is_err() { + eprintln!("X11 not available, skipping integration test"); + return; + } + + // Test minimum temperature + let min_setting = ColorSetting { + temperature: MIN_TEMP, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + if let Err(e) = method.set_temperature(&min_setting, false) { + eprintln!("Could not set minimum temperature: {}", e); + } + + // Test maximum temperature + let max_setting = ColorSetting { + temperature: MAX_TEMP, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + if let Err(e) = method.set_temperature(&max_setting, false) { + eprintln!("Could not set maximum temperature: {}", e); + } + + // Restore to neutral + let neutral_setting = ColorSetting { + temperature: NEUTRAL_TEMP, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + if let Err(e) = method.set_temperature(&neutral_setting, false) { + eprintln!("Could not restore neutral temperature: {}", e); + } + + method.restore(); +} + +// Integration test - test various gamma values +#[test] +#[ignore] +fn test_randr_gamma_method_gamma_values_x11() { + let mut method = RandrGammaMethod::new(); + + if method.init().is_err() || method.start().is_err() { + eprintln!("X11 not available, skipping integration test"); + return; + } + + let gamma_values = [ + [1.0, 1.0, 1.0], + [0.8, 0.8, 0.8], + [1.2, 1.2, 1.2], + [1.0, 0.9, 0.9], + ]; + + for gamma in gamma_values { + let setting = ColorSetting { + temperature: 6500, + gamma, + brightness: 1.0, + }; + + if let Err(e) = method.set_temperature(&setting, false) { + eprintln!("Could not set gamma {:?}: {}", gamma, e); + } + } + + method.restore(); +} + +// Integration test - test brightness values +#[test] +#[ignore] +fn test_randr_gamma_method_brightness_values_x11() { + let mut method = RandrGammaMethod::new(); + + if method.init().is_err() || method.start().is_err() { + eprintln!("X11 not available, skipping integration test"); + return; + } + + let brightness_values = [1.0, 0.8, 0.6, 0.9, 1.0]; + + for brightness in brightness_values { + let setting = ColorSetting { + temperature: 6500, + gamma: [1.0, 1.0, 1.0], + brightness, + }; + + if let Err(e) = method.set_temperature(&setting, false) { + eprintln!("Could not set brightness {}: {}", brightness, e); + } + } + + method.restore(); +} + +#[test] +fn test_randr_gamma_method_as_trait_object() { + // Test that RandrGammaMethod can be used as a trait object + let method: Box = Box::new(RandrGammaMethod::new()); + assert_eq!(method.name(), "randr"); +} + +#[test] +fn test_randr_gamma_method_drop() { + // Test that RandrGammaMethod's Drop implementation doesn't panic + { + let _method = RandrGammaMethod::new(); + // When _method goes out of scope, Drop should run + } + // If we got here, Drop didn't panic +} diff --git a/rewrite/tests/gamma_tests.rs b/rewrite/tests/gamma_tests.rs new file mode 100644 index 0000000..fad340e --- /dev/null +++ b/rewrite/tests/gamma_tests.rs @@ -0,0 +1,199 @@ +use redshift_rebooted::gamma::*; +use redshift_rebooted::types::*; + +#[test] +fn test_dummy_gamma_method_lifecycle() { + // Test that DummyGammaMethod can be initialized and started + let mut method = DummyGammaMethod::new(); + + // Init should succeed + assert!(method.init().is_ok(), "DummyGammaMethod init should succeed"); + + // Start should succeed + assert!(method.start().is_ok(), "DummyGammaMethod start should succeed"); + + // Name should be "dummy" + assert_eq!(method.name(), "dummy", "DummyGammaMethod name should be 'dummy'"); +} + +#[test] +fn test_dummy_gamma_method_set_temperature() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Create a color setting + let setting = ColorSetting { + temperature: 3500, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + // Set temperature should succeed (even though it's a no-op) + assert!( + method.set_temperature(&setting, false).is_ok(), + "DummyGammaMethod set_temperature should succeed" + ); + + // Test with preserve flag + assert!( + method.set_temperature(&setting, true).is_ok(), + "DummyGammaMethod set_temperature with preserve should succeed" + ); +} + +#[test] +fn test_dummy_gamma_method_restore() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Restore should not panic (it's a no-op for dummy) + method.restore(); +} + +#[test] +fn test_dummy_gamma_method_various_temperatures() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Test with various temperature values + let temperatures = [1000, 2500, 3500, 5000, 6500, 10000, 25000]; + + for temp in temperatures { + let setting = ColorSetting { + temperature: temp, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + assert!( + method.set_temperature(&setting, false).is_ok(), + "DummyGammaMethod should handle temperature {}K", + temp + ); + } +} + +#[test] +fn test_dummy_gamma_method_various_gamma_values() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Test with various gamma values + let gamma_values = [ + [0.5, 0.5, 0.5], + [1.0, 1.0, 1.0], + [1.5, 1.5, 1.5], + [1.0, 0.8, 0.8], + ]; + + for gamma in gamma_values { + let setting = ColorSetting { + temperature: 6500, + gamma, + brightness: 1.0, + }; + + assert!( + method.set_temperature(&setting, false).is_ok(), + "DummyGammaMethod should handle gamma {:?}", + gamma + ); + } +} + +#[test] +fn test_dummy_gamma_method_various_brightness_values() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Test with various brightness values + let brightness_values = [0.1, 0.5, 0.75, 1.0]; + + for brightness in brightness_values { + let setting = ColorSetting { + temperature: 6500, + gamma: [1.0, 1.0, 1.0], + brightness, + }; + + assert!( + method.set_temperature(&setting, false).is_ok(), + "DummyGammaMethod should handle brightness {}", + brightness + ); + } +} + +#[test] +fn test_dummy_gamma_method_multiple_calls() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Test calling set_temperature multiple times (simulating continual mode) + let setting1 = ColorSetting { + temperature: 6500, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + let setting2 = ColorSetting { + temperature: 3500, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + assert!(method.set_temperature(&setting1, false).is_ok()); + assert!(method.set_temperature(&setting2, false).is_ok()); + assert!(method.set_temperature(&setting1, false).is_ok()); + assert!(method.set_temperature(&setting2, false).is_ok()); +} + +#[test] +fn test_gamma_method_trait_object() { + // Test that GammaMethod can be used as a trait object + let mut method: Box = Box::new(DummyGammaMethod::new()); + + assert!(method.init().is_ok()); + assert!(method.start().is_ok()); + + let setting = ColorSetting { + temperature: 4500, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + assert!(method.set_temperature(&setting, false).is_ok()); + method.restore(); +} + +#[test] +fn test_gamma_method_default_color_setting() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Test with default ColorSetting + let setting = ColorSetting::default(); + + assert_eq!(setting.temperature, NEUTRAL_TEMP); + assert_eq!(setting.gamma, [1.0, 1.0, 1.0]); + assert_eq!(setting.brightness, 1.0); + + assert!( + method.set_temperature(&setting, false).is_ok(), + "DummyGammaMethod should handle default ColorSetting" + ); +} + +#[test] +fn test_dummy_gamma_method_display_trait() { + let method = DummyGammaMethod::new(); + let display_string = format!("{}", method); + assert_eq!(display_string, "Dummy", "DummyGammaMethod should display as 'Dummy'"); +} -- 2.47.3 From 709b516a9796626f4ee3dd576ccb5554d7fbed9f Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 4 Oct 2025 20:52:50 -0500 Subject: [PATCH 2/2] randR method added for actual integration with X11 --- rewrite/Cargo.toml | 1 + rewrite/src/gamma_randr.rs | 269 ++++++++++++++++++++++++++++ rewrite/src/lib.rs | 1 + rewrite/src/main.rs | 6 +- rewrite/tests/gamma_randr_tests.rs | 276 +++++++++++++++++++++++++++++ rewrite/tests/gamma_tests.rs | 199 +++++++++++++++++++++ 6 files changed, 751 insertions(+), 1 deletion(-) create mode 100644 rewrite/src/gamma_randr.rs create mode 100644 rewrite/tests/gamma_randr_tests.rs create mode 100644 rewrite/tests/gamma_tests.rs diff --git a/rewrite/Cargo.toml b/rewrite/Cargo.toml index 563731c..d57dc35 100644 --- a/rewrite/Cargo.toml +++ b/rewrite/Cargo.toml @@ -7,3 +7,4 @@ edition = "2021" [dependencies] clap = { version = "4.5", features = ["derive"] } +x11rb = { version = "0.13", features = ["randr"] } diff --git a/rewrite/src/gamma_randr.rs b/rewrite/src/gamma_randr.rs new file mode 100644 index 0000000..ddccd73 --- /dev/null +++ b/rewrite/src/gamma_randr.rs @@ -0,0 +1,269 @@ +/// X11 RandR gamma adjustment method +/// Ported from legacy/src/gamma-randr.c + +use crate::colorramp::colorramp_fill; +use crate::gamma::GammaMethod; +use crate::types::ColorSetting; +use std::fmt; +use x11rb::connection::Connection; +use x11rb::protocol::randr; +use x11rb::protocol::xproto; +use x11rb::rust_connection::RustConnection; + +const RANDR_VERSION_MAJOR: u32 = 1; +const RANDR_VERSION_MINOR: u32 = 3; + +/// State for a single CRTC +struct CrtcState { + crtc: randr::Crtc, + ramp_size: u16, + saved_ramps: Vec, // R, G, B ramps concatenated (3 * ramp_size) +} + +/// X11 RandR gamma adjustment method +pub struct RandrGammaMethod { + conn: Option, + screen_num: Option, + preferred_screen: usize, + crtc_filter: Vec, // If non-empty, only adjust these CRTC indices + crtcs: Vec, +} + +impl RandrGammaMethod { + pub fn new() -> Self { + Self { + conn: None, + screen_num: None, + preferred_screen: 0, + crtc_filter: Vec::new(), + crtcs: Vec::new(), + } + } + + /// Set which screen to use (None = use default) + pub fn set_screen(&mut self, screen: i32) { + self.screen_num = Some(screen); + } + + /// Set which CRTCs to adjust (empty = all) + pub fn set_crtcs(&mut self, crtc_indices: Vec) { + self.crtc_filter = crtc_indices; + } + + fn get_screen_root(&self) -> Result { + let conn = self.conn.as_ref().ok_or("Not connected to X server")?; + + let screen_num = self.screen_num.unwrap_or(self.preferred_screen as i32); + let setup = conn.setup(); + let screen = setup + .roots + .get(screen_num as usize) + .ok_or_else(|| format!("Screen {} could not be found", screen_num))?; + + Ok(screen.root) + } + + fn set_temperature_for_crtc( + &self, + crtc_state: &CrtcState, + setting: &ColorSetting, + preserve: bool, + ) -> Result<(), String> { + let conn = self.conn.as_ref().ok_or("Not connected to X server")?; + let ramp_size = crtc_state.ramp_size as usize; + + /* Create new gamma ramps */ + let mut gamma_r = vec![0u16; ramp_size]; + let mut gamma_g = vec![0u16; ramp_size]; + let mut gamma_b = vec![0u16; ramp_size]; + + if preserve { + /* Initialize from saved state */ + gamma_r.copy_from_slice(&crtc_state.saved_ramps[0..ramp_size]); + gamma_g.copy_from_slice(&crtc_state.saved_ramps[ramp_size..2 * ramp_size]); + gamma_b.copy_from_slice(&crtc_state.saved_ramps[2 * ramp_size..3 * ramp_size]); + } else { + /* Initialize to linear (pure state) */ + for i in 0..ramp_size { + let value = ((i as f64 / ramp_size as f64) * 65536.0) as u16; + gamma_r[i] = value; + gamma_g[i] = value; + gamma_b[i] = value; + } + } + + /* Apply color temperature adjustment */ + colorramp_fill(&mut gamma_r, &mut gamma_g, &mut gamma_b, setting); + + /* Set gamma ramps */ + randr::set_crtc_gamma( + conn, + crtc_state.crtc, + &gamma_r, + &gamma_g, + &gamma_b, + ) + .map_err(|e| format!("Failed to set CRTC gamma: {}", e))? + .check() + .map_err(|e| format!("RANDR Set CRTC Gamma returned error: {:?}", e))?; + + Ok(()) + } +} + +impl Default for RandrGammaMethod { + fn default() -> Self { + Self::new() + } +} + +impl GammaMethod for RandrGammaMethod { + fn init(&mut self) -> Result<(), String> { + /* Open X server connection */ + let (conn, preferred_screen) = RustConnection::connect(None) + .map_err(|e| format!("Failed to connect to X server: {}", e))?; + + self.preferred_screen = preferred_screen; + + /* Query RandR version */ + let ver_reply = randr::query_version(&conn, RANDR_VERSION_MAJOR, RANDR_VERSION_MINOR) + .map_err(|e| format!("Failed to query RANDR version: {}", e))? + .reply() + .map_err(|e| format!("RANDR Query Version returned error: {}", e))?; + + if ver_reply.major_version != RANDR_VERSION_MAJOR + || ver_reply.minor_version < RANDR_VERSION_MINOR + { + return Err(format!( + "Unsupported RANDR version ({}.{})", + ver_reply.major_version, ver_reply.minor_version + )); + } + + self.conn = Some(conn); + Ok(()) + } + + fn start(&mut self) -> Result<(), String> { + let conn = self.conn.as_ref().ok_or("Not initialized")?; + let root = self.get_screen_root()?; + + /* Get screen resources (list of CRTCs) */ + let res_reply = randr::get_screen_resources_current(conn, root) + .map_err(|e| format!("Failed to get screen resources: {}", e))? + .reply() + .map_err(|e| format!("RANDR Get Screen Resources Current returned error: {}", e))?; + + let crtcs = res_reply.crtcs; + + /* Save CRTC state and gamma ramps */ + for crtc in crtcs { + /* Get gamma ramp size */ + let gamma_size_reply = randr::get_crtc_gamma_size(conn, crtc) + .map_err(|e| format!("Failed to get CRTC gamma size: {}", e))? + .reply() + .map_err(|e| format!("RANDR Get CRTC Gamma Size returned error: {}", e))?; + + let ramp_size = gamma_size_reply.size; + + if ramp_size == 0 { + eprintln!("Warning: CRTC has gamma ramp size 0, skipping"); + continue; + } + + /* Get current gamma ramps */ + let gamma_get_reply = randr::get_crtc_gamma(conn, crtc) + .map_err(|e| format!("Failed to get CRTC gamma: {}", e))? + .reply() + .map_err(|e| format!("RANDR Get CRTC Gamma returned error: {}", e))?; + + /* Save gamma ramps (R, G, B concatenated) */ + let mut saved_ramps = Vec::with_capacity(3 * ramp_size as usize); + saved_ramps.extend_from_slice(&gamma_get_reply.red); + saved_ramps.extend_from_slice(&gamma_get_reply.green); + saved_ramps.extend_from_slice(&gamma_get_reply.blue); + + self.crtcs.push(CrtcState { + crtc, + ramp_size, + saved_ramps, + }); + } + + if self.crtcs.is_empty() { + return Err("No usable CRTCs found".to_string()); + } + + Ok(()) + } + + fn set_temperature(&mut self, setting: &ColorSetting, preserve: bool) -> Result<(), String> { + /* If no CRTC filter is set, adjust all CRTCs */ + if self.crtc_filter.is_empty() { + for crtc_state in &self.crtcs { + self.set_temperature_for_crtc(crtc_state, setting, preserve)?; + } + } else { + /* Only adjust specified CRTCs */ + for &crtc_idx in &self.crtc_filter { + if crtc_idx >= self.crtcs.len() { + return Err(format!( + "CRTC {} does not exist. Valid CRTCs are [0-{}]", + crtc_idx, + self.crtcs.len() - 1 + )); + } + self.set_temperature_for_crtc(&self.crtcs[crtc_idx], setting, preserve)?; + } + } + + Ok(()) + } + + fn restore(&mut self) { + if let Some(conn) = &self.conn { + /* Restore original gamma ramps for all CRTCs */ + for crtc_state in &self.crtcs { + let ramp_size = crtc_state.ramp_size as usize; + let gamma_r = &crtc_state.saved_ramps[0..ramp_size]; + let gamma_g = &crtc_state.saved_ramps[ramp_size..2 * ramp_size]; + let gamma_b = &crtc_state.saved_ramps[2 * ramp_size..3 * ramp_size]; + + match randr::set_crtc_gamma(conn, crtc_state.crtc, gamma_r, gamma_g, gamma_b) { + Ok(cookie) => { + if let Err(e) = cookie.check() { + eprintln!("Warning: Failed to restore CRTC gamma: {:?}", e); + } + } + Err(e) => { + eprintln!("Warning: Failed to send restore CRTC gamma request: {:?}", e); + } + } + } + } + } + + fn name(&self) -> &str { + "randr" + } + + fn print_help(&self) { + println!("Adjust gamma ramps with the X RANDR extension."); + println!(); + println!(" screen=N X screen to apply adjustments to"); + println!(" crtc=N List of comma separated CRTCs to apply adjustments to"); + println!(); + } +} + +impl fmt::Display for RandrGammaMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "RandR") + } +} + +impl Drop for RandrGammaMethod { + fn drop(&mut self) { + self.restore(); + } +} diff --git a/rewrite/src/lib.rs b/rewrite/src/lib.rs index 258a376..e00fa64 100644 --- a/rewrite/src/lib.rs +++ b/rewrite/src/lib.rs @@ -1,5 +1,6 @@ pub mod colorramp; pub mod gamma; +pub mod gamma_randr; pub mod location; pub mod solar; pub mod types; diff --git a/rewrite/src/main.rs b/rewrite/src/main.rs index 3ef30f2..426f923 100644 --- a/rewrite/src/main.rs +++ b/rewrite/src/main.rs @@ -1,11 +1,13 @@ mod colorramp; mod gamma; +mod gamma_randr; mod location; mod solar; mod types; use clap::{Parser, ValueEnum}; use gamma::{DummyGammaMethod, GammaMethod}; +use gamma_randr::RandrGammaMethod; use location::{LocationProvider, ManualLocationProvider}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use types::*; @@ -19,6 +21,7 @@ const FADE_LENGTH: i32 = 40; #[derive(Debug, Clone, Copy, ValueEnum)] enum GammaMethodChoice { + Randr, Dummy, } @@ -31,7 +34,7 @@ struct Args { location: Option, /// Gamma adjustment method - #[arg(short = 'm', long, default_value = "dummy")] + #[arg(short = 'm', long, default_value = "randr")] method: GammaMethodChoice, /// One-shot mode (set temperature and exit) @@ -239,6 +242,7 @@ fn main() -> Result<(), Box> { /* Set up gamma method */ let mut gamma_method: Box = match args.method { + GammaMethodChoice::Randr => Box::new(RandrGammaMethod::new()), GammaMethodChoice::Dummy => Box::new(DummyGammaMethod::new()), }; diff --git a/rewrite/tests/gamma_randr_tests.rs b/rewrite/tests/gamma_randr_tests.rs new file mode 100644 index 0000000..fb4ae71 --- /dev/null +++ b/rewrite/tests/gamma_randr_tests.rs @@ -0,0 +1,276 @@ +use redshift_rebooted::gamma::GammaMethod; +use redshift_rebooted::gamma_randr::RandrGammaMethod; +use redshift_rebooted::types::*; + +#[test] +fn test_randr_gamma_method_creation() { + // Test that RandrGammaMethod can be created + let method = RandrGammaMethod::new(); + assert_eq!(method.name(), "randr", "RandrGammaMethod name should be 'randr'"); +} + +#[test] +fn test_randr_gamma_method_default() { + // Test that RandrGammaMethod can be created with Default trait + let method = RandrGammaMethod::default(); + assert_eq!(method.name(), "randr", "Default RandrGammaMethod name should be 'randr'"); +} + +#[test] +fn test_randr_gamma_method_display_trait() { + let method = RandrGammaMethod::new(); + let display_string = format!("{}", method); + assert_eq!(display_string, "RandR", "RandrGammaMethod should display as 'RandR'"); +} + +#[test] +fn test_randr_gamma_method_init_no_display() { + // Test init when DISPLAY is not set or X11 is not available + // This may fail gracefully or succeed depending on environment + let mut method = RandrGammaMethod::new(); + + // We don't assert success/failure here because it depends on environment + // Just verify it doesn't panic + let _ = method.init(); +} + +#[test] +fn test_randr_gamma_method_set_screen() { + // Test the set_screen configuration method + let mut method = RandrGammaMethod::new(); + method.set_screen(0); + // If we got here without panicking, the method works +} + +#[test] +fn test_randr_gamma_method_set_crtcs() { + // Test the set_crtcs configuration method + let mut method = RandrGammaMethod::new(); + method.set_crtcs(vec![0, 1]); + // If we got here without panicking, the method works +} + +#[test] +fn test_randr_gamma_method_restore_without_init() { + // Test that restore doesn't panic even if not initialized + let mut method = RandrGammaMethod::new(); + method.restore(); + // Should not panic +} + +// Integration test - only runs if X11 is available +#[test] +#[ignore] // Use `cargo test -- --ignored` to run this +fn test_randr_gamma_method_full_lifecycle_x11() { + // This test requires X11 to be available + let mut method = RandrGammaMethod::new(); + + // Try to initialize + if method.init().is_err() { + eprintln!("X11 not available, skipping integration test"); + return; + } + + // Try to start + if method.start().is_err() { + eprintln!("Could not start RandR method, skipping"); + return; + } + + // Try to set a temperature + let setting = ColorSetting { + temperature: 5000, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + if let Err(e) = method.set_temperature(&setting, false) { + eprintln!("Could not set temperature: {}", e); + } + + // Restore + method.restore(); +} + +// Integration test - test preserve flag +#[test] +#[ignore] +fn test_randr_gamma_method_preserve_flag_x11() { + let mut method = RandrGammaMethod::new(); + + if method.init().is_err() || method.start().is_err() { + eprintln!("X11 not available, skipping integration test"); + return; + } + + let setting = ColorSetting { + temperature: 4000, + gamma: [1.0, 1.0, 1.0], + brightness: 0.9, + }; + + // Test without preserve + if let Err(e) = method.set_temperature(&setting, false) { + eprintln!("Could not set temperature without preserve: {}", e); + } + + // Test with preserve + if let Err(e) = method.set_temperature(&setting, true) { + eprintln!("Could not set temperature with preserve: {}", e); + } + + method.restore(); +} + +// Integration test - test multiple temperature changes +#[test] +#[ignore] +fn test_randr_gamma_method_multiple_changes_x11() { + let mut method = RandrGammaMethod::new(); + + if method.init().is_err() || method.start().is_err() { + eprintln!("X11 not available, skipping integration test"); + return; + } + + let temperatures = [6500, 5000, 3500, 4500, 6500]; + + for temp in temperatures { + let setting = ColorSetting { + temperature: temp, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + if let Err(e) = method.set_temperature(&setting, false) { + eprintln!("Could not set temperature {}: {}", temp, e); + } + } + + method.restore(); +} + +// Integration test - test extreme temperatures +#[test] +#[ignore] +fn test_randr_gamma_method_extreme_temperatures_x11() { + let mut method = RandrGammaMethod::new(); + + if method.init().is_err() || method.start().is_err() { + eprintln!("X11 not available, skipping integration test"); + return; + } + + // Test minimum temperature + let min_setting = ColorSetting { + temperature: MIN_TEMP, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + if let Err(e) = method.set_temperature(&min_setting, false) { + eprintln!("Could not set minimum temperature: {}", e); + } + + // Test maximum temperature + let max_setting = ColorSetting { + temperature: MAX_TEMP, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + if let Err(e) = method.set_temperature(&max_setting, false) { + eprintln!("Could not set maximum temperature: {}", e); + } + + // Restore to neutral + let neutral_setting = ColorSetting { + temperature: NEUTRAL_TEMP, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + if let Err(e) = method.set_temperature(&neutral_setting, false) { + eprintln!("Could not restore neutral temperature: {}", e); + } + + method.restore(); +} + +// Integration test - test various gamma values +#[test] +#[ignore] +fn test_randr_gamma_method_gamma_values_x11() { + let mut method = RandrGammaMethod::new(); + + if method.init().is_err() || method.start().is_err() { + eprintln!("X11 not available, skipping integration test"); + return; + } + + let gamma_values = [ + [1.0, 1.0, 1.0], + [0.8, 0.8, 0.8], + [1.2, 1.2, 1.2], + [1.0, 0.9, 0.9], + ]; + + for gamma in gamma_values { + let setting = ColorSetting { + temperature: 6500, + gamma, + brightness: 1.0, + }; + + if let Err(e) = method.set_temperature(&setting, false) { + eprintln!("Could not set gamma {:?}: {}", gamma, e); + } + } + + method.restore(); +} + +// Integration test - test brightness values +#[test] +#[ignore] +fn test_randr_gamma_method_brightness_values_x11() { + let mut method = RandrGammaMethod::new(); + + if method.init().is_err() || method.start().is_err() { + eprintln!("X11 not available, skipping integration test"); + return; + } + + let brightness_values = [1.0, 0.8, 0.6, 0.9, 1.0]; + + for brightness in brightness_values { + let setting = ColorSetting { + temperature: 6500, + gamma: [1.0, 1.0, 1.0], + brightness, + }; + + if let Err(e) = method.set_temperature(&setting, false) { + eprintln!("Could not set brightness {}: {}", brightness, e); + } + } + + method.restore(); +} + +#[test] +fn test_randr_gamma_method_as_trait_object() { + // Test that RandrGammaMethod can be used as a trait object + let method: Box = Box::new(RandrGammaMethod::new()); + assert_eq!(method.name(), "randr"); +} + +#[test] +fn test_randr_gamma_method_drop() { + // Test that RandrGammaMethod's Drop implementation doesn't panic + { + let _method = RandrGammaMethod::new(); + // When _method goes out of scope, Drop should run + } + // If we got here, Drop didn't panic +} diff --git a/rewrite/tests/gamma_tests.rs b/rewrite/tests/gamma_tests.rs new file mode 100644 index 0000000..fad340e --- /dev/null +++ b/rewrite/tests/gamma_tests.rs @@ -0,0 +1,199 @@ +use redshift_rebooted::gamma::*; +use redshift_rebooted::types::*; + +#[test] +fn test_dummy_gamma_method_lifecycle() { + // Test that DummyGammaMethod can be initialized and started + let mut method = DummyGammaMethod::new(); + + // Init should succeed + assert!(method.init().is_ok(), "DummyGammaMethod init should succeed"); + + // Start should succeed + assert!(method.start().is_ok(), "DummyGammaMethod start should succeed"); + + // Name should be "dummy" + assert_eq!(method.name(), "dummy", "DummyGammaMethod name should be 'dummy'"); +} + +#[test] +fn test_dummy_gamma_method_set_temperature() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Create a color setting + let setting = ColorSetting { + temperature: 3500, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + // Set temperature should succeed (even though it's a no-op) + assert!( + method.set_temperature(&setting, false).is_ok(), + "DummyGammaMethod set_temperature should succeed" + ); + + // Test with preserve flag + assert!( + method.set_temperature(&setting, true).is_ok(), + "DummyGammaMethod set_temperature with preserve should succeed" + ); +} + +#[test] +fn test_dummy_gamma_method_restore() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Restore should not panic (it's a no-op for dummy) + method.restore(); +} + +#[test] +fn test_dummy_gamma_method_various_temperatures() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Test with various temperature values + let temperatures = [1000, 2500, 3500, 5000, 6500, 10000, 25000]; + + for temp in temperatures { + let setting = ColorSetting { + temperature: temp, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + assert!( + method.set_temperature(&setting, false).is_ok(), + "DummyGammaMethod should handle temperature {}K", + temp + ); + } +} + +#[test] +fn test_dummy_gamma_method_various_gamma_values() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Test with various gamma values + let gamma_values = [ + [0.5, 0.5, 0.5], + [1.0, 1.0, 1.0], + [1.5, 1.5, 1.5], + [1.0, 0.8, 0.8], + ]; + + for gamma in gamma_values { + let setting = ColorSetting { + temperature: 6500, + gamma, + brightness: 1.0, + }; + + assert!( + method.set_temperature(&setting, false).is_ok(), + "DummyGammaMethod should handle gamma {:?}", + gamma + ); + } +} + +#[test] +fn test_dummy_gamma_method_various_brightness_values() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Test with various brightness values + let brightness_values = [0.1, 0.5, 0.75, 1.0]; + + for brightness in brightness_values { + let setting = ColorSetting { + temperature: 6500, + gamma: [1.0, 1.0, 1.0], + brightness, + }; + + assert!( + method.set_temperature(&setting, false).is_ok(), + "DummyGammaMethod should handle brightness {}", + brightness + ); + } +} + +#[test] +fn test_dummy_gamma_method_multiple_calls() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Test calling set_temperature multiple times (simulating continual mode) + let setting1 = ColorSetting { + temperature: 6500, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + let setting2 = ColorSetting { + temperature: 3500, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + assert!(method.set_temperature(&setting1, false).is_ok()); + assert!(method.set_temperature(&setting2, false).is_ok()); + assert!(method.set_temperature(&setting1, false).is_ok()); + assert!(method.set_temperature(&setting2, false).is_ok()); +} + +#[test] +fn test_gamma_method_trait_object() { + // Test that GammaMethod can be used as a trait object + let mut method: Box = Box::new(DummyGammaMethod::new()); + + assert!(method.init().is_ok()); + assert!(method.start().is_ok()); + + let setting = ColorSetting { + temperature: 4500, + gamma: [1.0, 1.0, 1.0], + brightness: 1.0, + }; + + assert!(method.set_temperature(&setting, false).is_ok()); + method.restore(); +} + +#[test] +fn test_gamma_method_default_color_setting() { + let mut method = DummyGammaMethod::new(); + method.init().unwrap(); + method.start().unwrap(); + + // Test with default ColorSetting + let setting = ColorSetting::default(); + + assert_eq!(setting.temperature, NEUTRAL_TEMP); + assert_eq!(setting.gamma, [1.0, 1.0, 1.0]); + assert_eq!(setting.brightness, 1.0); + + assert!( + method.set_temperature(&setting, false).is_ok(), + "DummyGammaMethod should handle default ColorSetting" + ); +} + +#[test] +fn test_dummy_gamma_method_display_trait() { + let method = DummyGammaMethod::new(); + let display_string = format!("{}", method); + assert_eq!(display_string, "Dummy", "DummyGammaMethod should display as 'Dummy'"); +} -- 2.47.3