gardesk/gardisplay / 7967ccf

Browse files

fix CRTC transform scaling and improve display change UX

- Fix scaling apply flow to match xrandr: apply CRTCs first, then
shrink screen (instead of disable-all → resize → apply which broke
on appledrm)
- Fix ensure_screen_size to use root window geometry instead of
GetScreenInfo discrete sizes
- Fix confirm overlay: remove black backdrop, center dialog properly
with shared layout computation for render and hit-testing
- Refit window after display changes using actual root geometry and
X server sync, preventing zoom/cutoff artifacts
- Fix transform ordering: set pending transform before SetCrtcConfig
- Track mode_width/mode_height separately from effective dimensions
- Generate virtual resolution entries for single-mode drivers
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
7967ccf6f9b0f4679efcd4331534fe3d6e694418
Parents
673c927
Tree
6cea5ad

7 changed files

StatusFile+-
M gardisplay/src/app.rs 144 60
M gardisplay/src/dpi.rs 126 43
M gardisplay/src/randr/manager.rs 182 24
M gardisplay/src/ui/confirm_overlay.rs 51 57
M gardisplay/src/ui/display_panel.rs 157 36
M gardisplay/src/ui/monitor_view.rs 34 3
M gardisplay/src/watchdog.rs 33 5
gardisplay/src/app.rsmodified
@@ -266,7 +266,7 @@ impl App {
266266
             return;
267267
         }
268268
 
269
-        // Update monitor positions from profile
269
+        // Update monitor positions and settings from profile
270270
         for state in view.monitors_mut() {
271271
             if let Some(config) = config_map.get(state.info.name.as_str()) {
272272
                 // Sanity check: don't apply positions that are clearly wrong
@@ -287,6 +287,27 @@ impl App {
287287
                         state.info.name
288288
                     );
289289
                 }
290
+
291
+                // Apply mode dimensions and scaling from profile
292
+                state.mode_width = config.width;
293
+                state.mode_height = config.height;
294
+                state.refresh = config.refresh;
295
+                state.rotation = config.rotation;
296
+                state.scale = config.scale;
297
+                state.enabled = config.enabled;
298
+
299
+                // Update visual rect to effective (scaled) dimensions
300
+                let (rot_w, rot_h) = match config.rotation {
301
+                    90 | 270 => (config.height, config.width),
302
+                    _ => (config.width, config.height),
303
+                };
304
+                if (config.scale - 1.0).abs() < 0.001 {
305
+                    state.info.rect.width = rot_w;
306
+                    state.info.rect.height = rot_h;
307
+                } else {
308
+                    state.info.rect.width = (rot_w as f64 / config.scale).round() as u32;
309
+                    state.info.rect.height = (rot_h as f64 / config.scale).round() as u32;
310
+                }
290311
             }
291312
         }
292313
 
@@ -581,6 +602,59 @@ impl App {
581602
         }
582603
     }
583604
 
605
+    /// Refit the window to the current screen after a display change.
606
+    /// Uses root window geometry (which reflects the effective screen size after
607
+    /// transforms/scaling) rather than monitor detection (which returns raw mode sizes).
608
+    fn refit_window(&mut self) {
609
+        // Sync to ensure screen resize has been processed by the X server
610
+        let _ = self.conn.sync();
611
+
612
+        // Query root window geometry for actual effective screen dimensions
613
+        let (scr_w, scr_h) = match self.conn.inner().get_geometry(self.conn.root()) {
614
+            Ok(cookie) => match cookie.reply() {
615
+                Ok(geom) => (geom.width as u32, geom.height as u32),
616
+                Err(_) => return,
617
+            },
618
+            Err(_) => return,
619
+        };
620
+
621
+        let win_w = WINDOW_WIDTH.min(scr_w.saturating_sub(40));
622
+        let win_h = WINDOW_HEIGHT.min(scr_h.saturating_sub(40));
623
+        let x = (scr_w as i32 - win_w as i32) / 2;
624
+        let y = (scr_h as i32 - win_h as i32) / 2;
625
+
626
+        tracing::info!(
627
+            "refit_window: screen={}x{}, requesting window {}x{} at ({}, {})",
628
+            scr_w, scr_h, win_w, win_h, x, y
629
+        );
630
+
631
+        if let Err(e) = self.window.set_geometry(Rect::new(x, y, win_w, win_h)) {
632
+            tracing::warn!("failed to refit window: {}", e);
633
+            return;
634
+        }
635
+
636
+        // Flush and sync so the WM processes the configure request
637
+        let _ = self.conn.flush();
638
+        let _ = self.conn.sync();
639
+
640
+        // Query ACTUAL window geometry (WM may have adjusted our request)
641
+        let (actual_w, actual_h) = match self.conn.inner().get_geometry(self.window.id()) {
642
+            Ok(cookie) => match cookie.reply() {
643
+                Ok(geom) => (geom.width as u32, geom.height as u32),
644
+                Err(_) => (win_w, win_h),
645
+            },
646
+            Err(_) => (win_w, win_h),
647
+        };
648
+
649
+        tracing::info!(
650
+            "refit_window: actual window size after WM = {}x{}",
651
+            actual_w, actual_h
652
+        );
653
+
654
+        // Update renderer and layout to match actual window size
655
+        self.handle_resize(Size::new(actual_w, actual_h));
656
+    }
657
+
584658
     /// Update the cursor based on monitor view state.
585659
     fn update_cursor(&mut self) {
586660
         let shape = self.monitor_view.cursor_shape();
@@ -635,11 +709,13 @@ impl App {
635709
                 output.map(|o| o.modes.len()).unwrap_or(0)
636710
             );
637711
 
712
+            // Pass mode_width/mode_height (raw resolution) to display panel so
713
+            // the resolution dropdown shows actual hardware modes, not scaled values.
638714
             self.display_panel.set_selected_monitor(
639715
                 Some(&state.info.name),
640716
                 output,
641
-                state.info.rect.width,
642
-                state.info.rect.height,
717
+                state.mode_width,
718
+                state.mode_height,
643719
                 state.refresh,
644720
                 state.rotation,
645721
                 state.scale,
@@ -656,9 +732,6 @@ impl App {
656732
         let randr = self.randr.as_ref()?;
657733
         let outputs = randr.get_outputs().ok()?;
658734
 
659
-        // Capture current DPI scale
660
-        let current_scale = dpi::get_current_scale();
661
-
662735
         Some(
663736
             outputs
664737
                 .iter()
@@ -666,6 +739,11 @@ impl App {
666739
                 .map(|o| {
667740
                     let mode = o.current_mode.as_ref().unwrap();
668741
                     let pos = o.position.unwrap_or((0, 0));
742
+                    // Read per-monitor scale from CRTC transform
743
+                    let scale = o
744
+                        .crtc
745
+                        .map(|c| randr.get_crtc_scale(c))
746
+                        .unwrap_or(1.0);
669747
                     MonitorConfig {
670748
                         name: o.name.clone(),
671749
                         enabled: true,
@@ -674,8 +752,8 @@ impl App {
674752
                         width: mode.width as u32,
675753
                         height: mode.height as u32,
676754
                         refresh: mode.refresh,
677
-                        scale: current_scale, // Capture current DPI scale
678
-                        rotation: 0,          // TODO: capture actual rotation
755
+                        scale,
756
+                        rotation: 0, // TODO: capture actual rotation
679757
                     }
680758
                 })
681759
                 .collect(),
@@ -698,6 +776,8 @@ impl App {
698776
         self.pre_change_config = self.capture_current_randr_state();
699777
 
700778
         // Build MonitorConfig from current view state
779
+        // Use mode_width/mode_height (raw hardware resolution) for the RandR mode,
780
+        // not info.rect which holds effective (scaled) dimensions.
701781
         let primary_name = self.monitor_view.primary_name().map(|s| s.to_string());
702782
         let configs: Vec<MonitorConfig> = self
703783
             .monitor_view
@@ -708,22 +788,21 @@ impl App {
708788
                 enabled: state.enabled,
709789
                 x: state.real_position.x,
710790
                 y: state.real_position.y,
711
-                width: state.info.rect.width,
712
-                height: state.info.rect.height,
791
+                width: state.mode_width,
792
+                height: state.mode_height,
713793
                 refresh: state.refresh,
714794
                 scale: state.scale,
715795
                 rotation: state.rotation,
716796
             })
717797
             .collect();
718798
 
719
-        // IMPORTANT: Prepare the screen size BEFORE applying any configurations
720
-        // This is essential for rotation changes which may require a larger virtual screen
721
-        if let Err(e) = randr.prepare_screen_for_configs(&configs) {
722
-            tracing::error!("failed to prepare screen size: {}", e);
723
-            self.set_status(&format!("Failed to prepare screen: {}", e));
724
-            return;
725
-        }
726
-
799
+        // Apply CRTCs using the same approach as xrandr:
800
+        // 1. Apply each CRTC (ensure_screen_size inside apply_monitor grows if needed)
801
+        // 2. Shrink the screen to fit effective dimensions after all CRTCs are applied
802
+        //
803
+        // When scaling down (e.g., 2x), the CRTC transform reduces the scanout size
804
+        // (2880x1800 mode at 2x → 1440x900 scanout), so it always fits within the
805
+        // current screen. The screen is then shrunk to match after all CRTCs are set.
727806
         let mut success_count = 0;
728807
         let mut error_count = 0;
729808
 
@@ -737,6 +816,13 @@ impl App {
737816
             }
738817
         }
739818
 
819
+        // Shrink the screen to fit the effective (post-transform) dimensions.
820
+        // This must happen AFTER all CRTCs are applied so the server knows the
821
+        // actual scanout sizes. This matches xrandr's behavior.
822
+        if let Err(e) = randr.shrink_screen_to_fit() {
823
+            tracing::error!("failed to shrink screen: {}", e);
824
+        }
825
+
740826
         // Set primary
741827
         if let Some(ref name) = primary_name {
742828
             if let Err(e) = randr.set_primary(name) {
@@ -748,13 +834,7 @@ impl App {
748834
             tracing::error!("failed to flush: {}", e);
749835
         }
750836
 
751
-        // Try to shrink screen to fit (non-fatal if it fails)
752
-        if let Err(e) = randr.shrink_screen_to_fit() {
753
-            tracing::debug!("shrink_screen_to_fit failed (non-fatal): {}", e);
754
-        }
755
-
756837
         // Apply DPI scaling if any monitor has non-1.0 scale
757
-        // Use the primary monitor's scale, or the first enabled monitor
758838
         let scale = configs
759839
             .iter()
760840
             .find(|c| c.enabled && primary_name.as_ref() == Some(&c.name))
@@ -764,7 +844,6 @@ impl App {
764844
 
765845
         if let Err(e) = dpi::apply_dpi_scale(scale) {
766846
             tracing::error!("failed to apply DPI scale: {}", e);
767
-            // Non-fatal - continue with the rest
768847
         }
769848
 
770849
         if error_count > 0 {
@@ -793,7 +872,10 @@ impl App {
793872
             }
794873
         }
795874
 
796
-        // Show confirmation overlay
875
+        // Refit window to the new screen dimensions before showing overlay
876
+        self.refit_window();
877
+
878
+        // Show confirmation overlay (uses current window size after refit)
797879
         let size = self.window.size();
798880
         let window_rect = Rect::new(0, 0, size.width, size.height);
799881
         self.confirm_overlay = Some(ConfirmOverlay::new(window_rect));
@@ -809,7 +891,10 @@ impl App {
809891
         watchdog::cancel_watchdog();
810892
         self.watchdog_child = None;
811893
 
812
-        // Update original_monitors to current state
894
+        // Update original_monitors to current state.
895
+        // Use mode_width/mode_height (raw hardware resolution) so that revert_layout
896
+        // can find the correct RandR mode. info.rect holds effective (scaled) dimensions
897
+        // which may not correspond to an actual mode.
813898
         let primary_name = self.monitor_view.primary_name().map(|s| s.to_string());
814899
         self.original_monitors = self
815900
             .monitor_view
@@ -820,8 +905,8 @@ impl App {
820905
                 rect: Rect::new(
821906
                     state.real_position.x,
822907
                     state.real_position.y,
823
-                    state.info.rect.width,
824
-                    state.info.rect.height,
908
+                    state.mode_width,
909
+                    state.mode_height,
825910
                 ),
826911
                 primary: primary_name.as_ref() == Some(&state.info.name),
827912
                 width_mm: state.info.width_mm,
@@ -829,6 +914,7 @@ impl App {
829914
             })
830915
             .collect();
831916
 
917
+        self.refit_window();
832918
         self.set_status("Display settings confirmed");
833919
         self.monitor_view.clear_dirty();
834920
     }
@@ -843,25 +929,15 @@ impl App {
843929
 
844930
         if let Some(ref configs) = self.pre_change_config.take() {
845931
             if let Some(ref randr) = self.randr {
846
-                // IMPORTANT: Prepare screen size BEFORE reverting
847
-                // The pre-change config may have different dimensions than current
848
-                if let Err(e) = randr.prepare_screen_for_configs(configs) {
849
-                    tracing::error!("failed to prepare screen for revert: {}", e);
850
-                    // Continue anyway - we still want to try to revert
851
-                }
852
-
932
+                // Apply CRTCs (ensure_screen_size inside grows if needed), then shrink
853933
                 for config in configs {
854934
                     if let Err(e) = randr.apply_monitor(config) {
855935
                         tracing::error!("failed to revert {}: {}", config.name, e);
856936
                     }
857937
                 }
938
+                let _ = randr.shrink_screen_to_fit();
858939
                 let _ = randr.flush();
859940
 
860
-                // Try to shrink screen to fit
861
-                if let Err(e) = randr.shrink_screen_to_fit() {
862
-                    tracing::debug!("shrink_screen_to_fit failed during revert: {}", e);
863
-                }
864
-
865941
                 // Restore DPI scale from pre-change config
866942
                 let scale = configs
867943
                     .iter()
@@ -876,6 +952,7 @@ impl App {
876952
 
877953
         // Reset view to original monitors
878954
         self.monitor_view.set_monitors(self.original_monitors.clone());
955
+        self.refit_window();
879956
         self.set_status("Display settings reverted");
880957
     }
881958
 
@@ -891,10 +968,12 @@ impl App {
891968
         // Restore original monitors in view
892969
         self.monitor_view.set_monitors(self.original_monitors.clone());
893970
 
894
-        // Apply via RandR
971
+        // Apply via RandR using disable→resize→apply pattern
895972
         if let Some(ref randr) = self.randr {
896
-            for m in &self.original_monitors {
897
-                let config = MonitorConfig {
973
+            let configs: Vec<MonitorConfig> = self
974
+                .original_monitors
975
+                .iter()
976
+                .map(|m| MonitorConfig {
898977
                     name: m.name.clone(),
899978
                     enabled: true,
900979
                     x: m.rect.x,
@@ -904,12 +983,16 @@ impl App {
904983
                     refresh: 60.0,
905984
                     scale: 1.0,
906985
                     rotation: 0,
907
-                };
986
+                })
987
+                .collect();
908988
 
909
-                if let Err(e) = randr.apply_monitor(&config) {
910
-                    tracing::error!("failed to revert {}: {}", m.name, e);
989
+            // Apply CRTCs (ensure_screen_size inside grows if needed), then shrink
990
+            for config in &configs {
991
+                if let Err(e) = randr.apply_monitor(config) {
992
+                    tracing::error!("failed to revert {}: {}", config.name, e);
911993
                 }
912994
             }
995
+            let _ = randr.shrink_screen_to_fit();
913996
 
914997
             // Restore primary
915998
             if let Some(m) = self.original_monitors.iter().find(|m| m.primary) {
@@ -921,6 +1004,7 @@ impl App {
9211004
             let _ = randr.flush();
9221005
         }
9231006
 
1007
+        self.refit_window();
9241008
         self.set_status("Reverted to original layout");
9251009
     }
9261010
 
@@ -934,21 +1018,21 @@ impl App {
9341018
     fn save_profile(&mut self) {
9351019
         let primary_name = self.monitor_view.primary_name().map(|s| s.to_string());
9361020
 
937
-        // Build profile from current layout
1021
+        // Build profile from current layout using raw mode dimensions
9381022
         let monitors: Vec<MonitorConfig> = self
9391023
             .monitor_view
9401024
             .monitors()
9411025
             .iter()
9421026
             .map(|state| MonitorConfig {
9431027
                 name: state.info.name.clone(),
944
-                enabled: true,
1028
+                enabled: state.enabled,
9451029
                 x: state.real_position.x,
9461030
                 y: state.real_position.y,
947
-                width: state.info.rect.width,
948
-                height: state.info.rect.height,
949
-                refresh: 60.0,
950
-                scale: 1.0,
951
-                rotation: 0,
1031
+                width: state.mode_width,
1032
+                height: state.mode_height,
1033
+                refresh: state.refresh,
1034
+                scale: state.scale,
1035
+                rotation: state.rotation,
9521036
             })
9531037
             .collect();
9541038
 
@@ -984,14 +1068,14 @@ impl App {
9841068
             .iter()
9851069
             .map(|state| MonitorConfig {
9861070
                 name: state.info.name.clone(),
987
-                enabled: true,
1071
+                enabled: state.enabled,
9881072
                 x: state.real_position.x,
9891073
                 y: state.real_position.y,
990
-                width: state.info.rect.width,
991
-                height: state.info.rect.height,
992
-                refresh: 60.0,
993
-                scale: 1.0,
994
-                rotation: 0,
1074
+                width: state.mode_width,
1075
+                height: state.mode_height,
1076
+                refresh: state.refresh,
1077
+                scale: state.scale,
1078
+                rotation: state.rotation,
9951079
             })
9961080
             .collect();
9971081
 
gardisplay/src/dpi.rsmodified
@@ -1,73 +1,152 @@
1
-//! DPI scaling for X11.
1
+//! DPI and UI scaling for X11.
22
 //!
3
-//! X11 doesn't have native HiDPI scaling like Wayland. Instead, scaling is achieved by:
4
-//! 1. Setting Xft.dpi via X resources (affects fonts and DPI-aware apps)
5
-//! 2. Environment variables (GDK_SCALE, QT_SCALE_FACTOR) for toolkit-specific scaling
3
+//! X11 doesn't have native HiDPI scaling like Wayland. Scaling is achieved by:
4
+//! 1. xsettingsd for GTK applications (Gdk/WindowScalingFactor)
5
+//! 2. Xft.dpi via X resources (affects fonts and DPI-aware apps)
66
 //!
7
-//! Note: DPI changes typically require applications to be restarted to take effect.
7
+//! IMPORTANT: On X11, scaling changes typically require apps to be restarted
8
+//! (or logout/login) to fully take effect. This is a fundamental X11 limitation.
89
 
10
+use std::fs;
11
+use std::io::Write;
12
+use std::path::PathBuf;
913
 use std::process::Command;
1014
 
1115
 /// Base DPI (96 is the X11 standard).
1216
 const BASE_DPI: u32 = 96;
1317
 
14
-/// Apply DPI scaling via xrdb.
15
-/// Scale of 1.0 = 96 DPI, scale of 2.0 = 192 DPI, etc.
16
-pub fn apply_dpi_scale(scale: f64) -> std::io::Result<()> {
17
-    let dpi = (BASE_DPI as f64 * scale).round() as u32;
18
+/// Get the xsettingsd config file path.
19
+fn xsettingsd_config_path() -> PathBuf {
20
+    let runtime_dir =
21
+        std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
22
+    PathBuf::from(runtime_dir).join("gardisplay-xsettingsd.conf")
23
+}
1824
 
19
-    tracing::info!("setting Xft.dpi to {} (scale={})", dpi, scale);
25
+/// Write the xsettingsd configuration file.
26
+fn write_xsettingsd_config(scale: f64) -> std::io::Result<PathBuf> {
27
+    let config_path = xsettingsd_config_path();
2028
 
21
-    // Build the xrdb resource string
22
-    let resource = format!("Xft.dpi: {}\n", dpi);
29
+    let scale_factor = scale.round() as u32;
30
+    let dpi = (BASE_DPI as f64 * scale).round() as u32;
2331
 
24
-    // Apply via xrdb -merge
25
-    let output = Command::new("xrdb")
26
-        .arg("-merge")
27
-        .stdin(std::process::Stdio::piped())
28
-        .stdout(std::process::Stdio::piped())
29
-        .stderr(std::process::Stdio::piped())
30
-        .spawn()?
31
-        .wait_with_output()?;
32
+    // xsettingsd uses fixed-point format: value * 1024
33
+    let xft_dpi_fixed = dpi * 1024;
34
+    let unscaled_dpi_fixed = BASE_DPI * 1024;
35
+
36
+    let config = format!(
37
+        r#"# Generated by gardisplay
38
+Gdk/WindowScalingFactor {scale_factor}
39
+Gdk/UnscaledDPI {unscaled_dpi_fixed}
40
+Xft/DPI {xft_dpi_fixed}
41
+Xft/Antialias 1
42
+Xft/Hinting 1
43
+Xft/HintStyle "hintslight"
44
+Xft/RGBA "rgb"
45
+"#
46
+    );
47
+
48
+    let mut file = fs::File::create(&config_path)?;
49
+    file.write_all(config.as_bytes())?;
50
+    file.sync_all()?;
51
+
52
+    tracing::debug!("wrote xsettingsd config to {:?}", config_path);
53
+    Ok(config_path)
54
+}
55
+
56
+/// Check if xsettingsd is running and return its PID.
57
+fn get_xsettingsd_pid() -> Option<u32> {
58
+    let output = Command::new("pgrep")
59
+        .arg("-x")
60
+        .arg("xsettingsd")
61
+        .output()
62
+        .ok()?;
3263
 
3364
     if !output.status.success() {
34
-        let stderr = String::from_utf8_lossy(&output.stderr);
35
-        tracing::error!("xrdb failed: {}", stderr);
36
-        return Err(std::io::Error::new(
37
-            std::io::ErrorKind::Other,
38
-            format!("xrdb failed: {}", stderr),
39
-        ));
65
+        return None;
4066
     }
4167
 
42
-    // Also write to stdin
68
+    let stdout = String::from_utf8_lossy(&output.stdout);
69
+    stdout.trim().parse().ok()
70
+}
71
+
72
+/// Start xsettingsd if not running, or send SIGHUP to reload config.
73
+fn start_or_reload_xsettingsd(config_path: &PathBuf) -> std::io::Result<()> {
74
+    if let Some(pid) = get_xsettingsd_pid() {
75
+        // Send SIGHUP to reload config (gentler than restart)
76
+        tracing::info!("sending SIGHUP to xsettingsd (PID {})", pid);
77
+        let _ = Command::new("kill")
78
+            .arg("-HUP")
79
+            .arg(pid.to_string())
80
+            .status();
81
+    } else {
82
+        // Start xsettingsd
83
+        tracing::info!("starting xsettingsd with config {:?}", config_path);
84
+        Command::new("xsettingsd")
85
+            .arg("-c")
86
+            .arg(config_path)
87
+            .stdin(std::process::Stdio::null())
88
+            .stdout(std::process::Stdio::null())
89
+            .stderr(std::process::Stdio::null())
90
+            .spawn()?;
91
+    }
92
+
93
+    Ok(())
94
+}
95
+
96
+/// Apply DPI scaling via xrdb (for legacy X11 apps).
97
+fn apply_xrdb_dpi(dpi: u32) -> std::io::Result<()> {
98
+    let resource = format!("Xft.dpi: {}\n", dpi);
99
+
43100
     let mut child = Command::new("xrdb")
44101
         .arg("-merge")
45102
         .stdin(std::process::Stdio::piped())
46103
         .spawn()?;
47104
 
48105
     if let Some(mut stdin) = child.stdin.take() {
49
-        use std::io::Write;
50106
         stdin.write_all(resource.as_bytes())?;
51107
     }
52108
 
53
-    let status = child.wait()?;
54
-    if !status.success() {
55
-        return Err(std::io::Error::new(
56
-            std::io::ErrorKind::Other,
57
-            "xrdb -merge failed",
58
-        ));
109
+    child.wait()?;
110
+    Ok(())
111
+}
112
+
113
+/// Apply DPI scaling for the desktop.
114
+/// This sets up both xsettingsd (for GTK apps) and xrdb (for legacy apps).
115
+/// Scale of 1.0 = 96 DPI, scale of 2.0 = 192 DPI, etc.
116
+///
117
+/// NOTE: Existing applications may not fully update until restarted.
118
+/// This is a fundamental X11 limitation.
119
+pub fn apply_dpi_scale(scale: f64) -> std::io::Result<()> {
120
+    let dpi = (BASE_DPI as f64 * scale).round() as u32;
121
+    let scale_factor = scale.round() as u32;
122
+
123
+    tracing::info!(
124
+        "applying scale {} (DPI={}, WindowScalingFactor={})",
125
+        scale,
126
+        dpi,
127
+        scale_factor
128
+    );
129
+
130
+    // 1. Write xsettingsd config and reload
131
+    if let Ok(config_path) = write_xsettingsd_config(scale) {
132
+        if let Err(e) = start_or_reload_xsettingsd(&config_path) {
133
+            tracing::warn!("failed to start/reload xsettingsd: {}", e);
134
+        }
135
+    }
136
+
137
+    // 2. Apply xrdb DPI for legacy apps
138
+    if let Err(e) = apply_xrdb_dpi(dpi) {
139
+        tracing::warn!("failed to set xrdb DPI: {}", e);
59140
     }
60141
 
61
-    tracing::info!("DPI set to {} - apps may need restart to reflect changes", dpi);
142
+    tracing::info!("scaling applied - new apps will use scale {}", scale);
62143
     Ok(())
63144
 }
64145
 
65
-/// Get the current DPI setting.
146
+/// Get the current DPI setting from xrdb.
147
+#[allow(dead_code)] // Available for supplemental DPI queries
66148
 pub fn get_current_dpi() -> Option<u32> {
67
-    let output = Command::new("xrdb")
68
-        .arg("-query")
69
-        .output()
70
-        .ok()?;
149
+    let output = Command::new("xrdb").arg("-query").output().ok()?;
71150
 
72151
     if !output.status.success() {
73152
         return None;
@@ -87,6 +166,7 @@ pub fn get_current_dpi() -> Option<u32> {
87166
 }
88167
 
89168
 /// Get the current scale factor based on DPI.
169
+#[allow(dead_code)] // Available for supplemental DPI queries
90170
 pub fn get_current_scale() -> f64 {
91171
     get_current_dpi()
92172
         .map(|dpi| dpi as f64 / BASE_DPI as f64)
@@ -99,11 +179,14 @@ mod tests {
99179
 
100180
     #[test]
101181
     fn test_dpi_calculation() {
102
-        // scale 1.0 = 96 DPI
103182
         assert_eq!((BASE_DPI as f64 * 1.0).round() as u32, 96);
104
-        // scale 1.5 = 144 DPI
105183
         assert_eq!((BASE_DPI as f64 * 1.5).round() as u32, 144);
106
-        // scale 2.0 = 192 DPI
107184
         assert_eq!((BASE_DPI as f64 * 2.0).round() as u32, 192);
108185
     }
186
+
187
+    #[test]
188
+    fn test_xsettingsd_config_path() {
189
+        let path = xsettingsd_config_path();
190
+        assert!(path.to_string_lossy().contains("gardisplay-xsettingsd"));
191
+    }
109192
 }
gardisplay/src/randr/manager.rsmodified
@@ -3,11 +3,51 @@
33
 use gartk_x11::Connection;
44
 use x11rb::connection::Connection as X11Connection;
55
 use x11rb::protocol::randr::{self, ConnectionExt as RandrExt};
6
+use x11rb::protocol::render;
7
+use x11rb::protocol::xproto::ConnectionExt as XprotoExt;
68
 
79
 use super::error::{RandrError, Result};
810
 use super::types::{ModeInfo, OutputInfo};
911
 use crate::config::MonitorConfig;
1012
 
13
+/// Convert a floating-point value to X11 Fixed (16.16 fixed-point).
14
+fn float_to_fixed(value: f64) -> render::Fixed {
15
+    (value * 65536.0) as i32
16
+}
17
+
18
+/// Create an identity transform matrix (no transformation).
19
+fn identity_transform() -> render::Transform {
20
+    render::Transform {
21
+        matrix11: float_to_fixed(1.0),
22
+        matrix12: float_to_fixed(0.0),
23
+        matrix13: float_to_fixed(0.0),
24
+        matrix21: float_to_fixed(0.0),
25
+        matrix22: float_to_fixed(1.0),
26
+        matrix23: float_to_fixed(0.0),
27
+        matrix31: float_to_fixed(0.0),
28
+        matrix32: float_to_fixed(0.0),
29
+        matrix33: float_to_fixed(1.0),
30
+    }
31
+}
32
+
33
+/// Create a scaling transform matrix.
34
+/// For RandR CRTC transforms, `scale` is the UI scale factor (e.g., 2.0 = "2x bigger").
35
+/// The transform uses the inverse: 1/scale on the diagonal.
36
+fn scale_transform(scale: f64) -> render::Transform {
37
+    let factor = 1.0 / scale;
38
+    render::Transform {
39
+        matrix11: float_to_fixed(factor),
40
+        matrix12: float_to_fixed(0.0),
41
+        matrix13: float_to_fixed(0.0),
42
+        matrix21: float_to_fixed(0.0),
43
+        matrix22: float_to_fixed(factor),
44
+        matrix23: float_to_fixed(0.0),
45
+        matrix31: float_to_fixed(0.0),
46
+        matrix32: float_to_fixed(0.0),
47
+        matrix33: float_to_fixed(1.0),
48
+    }
49
+}
50
+
1151
 /// Manager for RandR operations.
1252
 pub struct RandrManager {
1353
     conn: Connection,
@@ -190,20 +230,17 @@ impl RandrManager {
190230
     /// Ensure the virtual screen is large enough to contain the given bounds.
191231
     /// This must be called before applying configurations that might exceed the current screen size.
192232
     pub fn ensure_screen_size(&self, required_width: u32, required_height: u32) -> Result<()> {
193
-        let _resources = self.get_resources()?;
194
-
195
-        // Get current screen info
196
-        let screen_info = self
233
+        // Use root window geometry to get the actual current virtual screen size.
234
+        // GetScreenInfo returns discrete "advertised" sizes which may not reflect
235
+        // the current virtual size set by RRSetScreenSize.
236
+        let geom = self
197237
             .conn
198238
             .inner()
199
-            .randr_get_screen_info(self.root)?
200
-            .reply()?;
239
+            .get_geometry(self.root)?
240
+            .reply()
241
+            .map_err(|e| RandrError::ConfigFailed(format!("get_geometry: {}", e)))?;
201242
 
202
-        let current_size = screen_info
203
-            .sizes
204
-            .get(screen_info.size_id as usize)
205
-            .map(|s| (s.width as u32, s.height as u32))
206
-            .unwrap_or((0, 0));
243
+        let current_size = (geom.width as u32, geom.height as u32);
207244
 
208245
         tracing::debug!(
209246
             "ensure_screen_size: current={}x{}, required={}x{}",
@@ -253,13 +290,19 @@ impl RandrManager {
253290
         }
254291
     }
255292
 
256
-    /// Calculate the effective desktop dimensions after rotation.
257
-    /// Note: Scale is handled by the transform, not by changing mode resolution.
258
-    /// The framebuffer stays at the mode resolution; the transform scales output.
259
-    fn effective_dimensions(width: u32, height: u32, rotation: u32, _scale: f64) -> (u32, u32) {
260
-        // Scale doesn't affect framebuffer size - it's handled by the CRTC transform
261
-        // The mode resolution determines the framebuffer size
262
-        Self::rotated_dimensions(width, height, rotation)
293
+    /// Calculate the effective desktop dimensions after rotation and scaling.
294
+    /// The CRTC transform scales the output, so the framebuffer (what apps see)
295
+    /// is the mode resolution divided by the scale factor.
296
+    /// e.g., 2880x1800 at scale 2.0 → effective 1440x900 framebuffer.
297
+    fn effective_dimensions(width: u32, height: u32, rotation: u32, scale: f64) -> (u32, u32) {
298
+        let (rot_w, rot_h) = Self::rotated_dimensions(width, height, rotation);
299
+        if (scale - 1.0).abs() < 0.001 {
300
+            (rot_w, rot_h)
301
+        } else {
302
+            let eff_w = (rot_w as f64 / scale).round() as u32;
303
+            let eff_h = (rot_h as f64 / scale).round() as u32;
304
+            (eff_w.max(1), eff_h.max(1))
305
+        }
263306
     }
264307
 
265308
     /// Calculate the required screen size to contain all given monitor configurations.
@@ -293,6 +336,7 @@ impl RandrManager {
293336
 
294337
     /// Prepare the screen for a set of monitor configurations.
295338
     /// This should be called before applying any configurations to ensure the screen is large enough.
339
+    #[allow(dead_code)] // Available but apply_layout now uses disable→resize→apply pattern
296340
     pub fn prepare_screen_for_configs(&self, configs: &[MonitorConfig]) -> Result<()> {
297341
         let (required_width, required_height) = Self::calculate_required_screen_size(configs);
298342
         tracing::info!(
@@ -304,8 +348,33 @@ impl RandrManager {
304348
         self.ensure_screen_size(required_width, required_height)
305349
     }
306350
 
351
+    /// Read the current CRTC transform scale factor.
352
+    /// Returns 1.0 if no transform or identity transform.
353
+    pub fn get_crtc_scale(&self, crtc: randr::Crtc) -> f64 {
354
+        let Ok(cookie) = self.conn.inner().randr_get_crtc_transform(crtc) else {
355
+            return 1.0;
356
+        };
357
+        let Ok(reply) = cookie.reply() else {
358
+            return 1.0;
359
+        };
360
+
361
+        // The current transform matrix11 is 1/scale in 16.16 fixed-point
362
+        let matrix11 = reply.current_transform.matrix11;
363
+        if matrix11 <= 0 {
364
+            return 1.0;
365
+        }
366
+
367
+        let factor = matrix11 as f64 / 65536.0;
368
+        if (factor - 1.0).abs() < 0.001 {
369
+            1.0
370
+        } else {
371
+            1.0 / factor // Convert back to UI scale
372
+        }
373
+    }
374
+
307375
     /// Shrink the screen to the minimum size required for the current outputs.
308376
     /// Call this after applying all configurations to clean up excess virtual screen space.
377
+    /// Accounts for CRTC transforms (scaling) when computing effective dimensions.
309378
     pub fn shrink_screen_to_fit(&self) -> Result<()> {
310379
         let outputs = self.get_outputs()?;
311380
 
@@ -317,8 +386,12 @@ impl RandrManager {
317386
                 continue;
318387
             }
319388
             if let (Some(mode), Some(pos)) = (&output.current_mode, output.position) {
320
-                let right = pos.0.max(0) as u32 + mode.width as u32;
321
-                let bottom = pos.1.max(0) as u32 + mode.height as u32;
389
+                // Check if this output has a scale transform
390
+                let scale = output.crtc.map(|c| self.get_crtc_scale(c)).unwrap_or(1.0);
391
+                let (eff_w, eff_h) =
392
+                    Self::effective_dimensions(mode.width as u32, mode.height as u32, 0, scale);
393
+                let right = pos.0.max(0) as u32 + eff_w;
394
+                let bottom = pos.1.max(0) as u32 + eff_h;
322395
                 max_x = max_x.max(right);
323396
                 max_y = max_y.max(bottom);
324397
             }
@@ -412,7 +485,34 @@ impl RandrManager {
412485
             _ => randr::Rotation::ROTATE0,
413486
         };
414487
 
415
-        // Apply configuration
488
+        // Set CRTC transform BEFORE set_crtc_config.
489
+        // Per the RandR spec, SetCrtcTransform stores a "pending" transform.
490
+        // The next SetCrtcConfig call activates it. So we must set the transform first.
491
+        if (config.scale - 1.0).abs() > 0.001 {
492
+            let transform = scale_transform(config.scale);
493
+            let filter = if (config.scale.round() - config.scale).abs() < 0.001 {
494
+                b"nearest".as_slice() // Integer scale: pixel-perfect
495
+            } else {
496
+                b"bilinear".as_slice() // Fractional scale: smooth interpolation
497
+            };
498
+            self.conn
499
+                .inner()
500
+                .randr_set_crtc_transform(crtc, transform, filter, &[])?;
501
+            tracing::info!(
502
+                "set pending CRTC transform {:.2}x for {} (filter={})",
503
+                config.scale,
504
+                config.name,
505
+                String::from_utf8_lossy(filter)
506
+            );
507
+        } else {
508
+            // Reset to identity transform
509
+            let transform = identity_transform();
510
+            self.conn
511
+                .inner()
512
+                .randr_set_crtc_transform(crtc, transform, b"nearest", &[])?;
513
+        }
514
+
515
+        // Apply configuration — this activates the pending transform
416516
         let result = self
417517
             .conn
418518
             .inner()
@@ -435,9 +535,6 @@ impl RandrManager {
435535
             )));
436536
         }
437537
 
438
-        // Note: Scale is handled via DPI settings, not RandR transforms
439
-        // RandR transforms don't work well for HiDPI scaling on X11
440
-
441538
         tracing::info!(
442539
             "applied config for {}: {}x{} rot={} scale={} at ({}, {})",
443540
             config.name,
@@ -570,6 +667,67 @@ impl RandrManager {
570667
         Err(RandrError::NoCrtcAvailable(name))
571668
     }
572669
 
670
+    /// Set the screen to an exact size.
671
+    /// All CRTCs should be disabled first to avoid validation failures.
672
+    #[allow(dead_code)] // Available for direct screen resize
673
+    pub fn resize_screen(&self, width: u32, height: u32) -> Result<()> {
674
+        let mm_width = (width as f64 * 25.4 / 96.0) as u32;
675
+        let mm_height = (height as f64 * 25.4 / 96.0) as u32;
676
+
677
+        tracing::info!(
678
+            "resizing screen to {}x{} ({}x{}mm)",
679
+            width,
680
+            height,
681
+            mm_width,
682
+            mm_height
683
+        );
684
+
685
+        self.conn.inner().randr_set_screen_size(
686
+            self.root,
687
+            width as u16,
688
+            height as u16,
689
+            mm_width,
690
+            mm_height,
691
+        )?;
692
+        self.conn.inner().flush()?;
693
+        Ok(())
694
+    }
695
+
696
+    /// Disable all connected outputs (set CRTCs to mode 0).
697
+    #[allow(dead_code)] // Available for screen resize operations
698
+    pub fn disable_all_crtcs(&self) -> Result<()> {
699
+        let resources = self.get_resources()?;
700
+
701
+        for &crtc in &resources.crtcs {
702
+            let crtc_info = self
703
+                .conn
704
+                .inner()
705
+                .randr_get_crtc_info(crtc, resources.config_timestamp)?
706
+                .reply()?;
707
+
708
+            // Only disable active CRTCs (those with a mode set)
709
+            if crtc_info.mode != 0 {
710
+                self.conn
711
+                    .inner()
712
+                    .randr_set_crtc_config(
713
+                        crtc,
714
+                        resources.timestamp,
715
+                        resources.config_timestamp,
716
+                        0,
717
+                        0,
718
+                        0, // mode 0 = disable
719
+                        randr::Rotation::ROTATE0,
720
+                        &[],
721
+                    )?
722
+                    .reply()?;
723
+            }
724
+        }
725
+
726
+        self.conn.inner().flush()?;
727
+        tracing::debug!("disabled all active CRTCs");
728
+        Ok(())
729
+    }
730
+
573731
     /// Flush pending X11 requests.
574732
     pub fn flush(&self) -> Result<()> {
575733
         self.conn.inner().flush()?;
gardisplay/src/ui/confirm_overlay.rsmodified
@@ -8,6 +8,12 @@ use gartk_render::{Renderer, TextAlign, TextStyle};
88
 /// Default timeout for confirmation (15 seconds).
99
 pub const CONFIRM_TIMEOUT_SECS: u64 = 15;
1010
 
11
+const DIALOG_WIDTH: i32 = 400;
12
+const DIALOG_HEIGHT: i32 = 150;
13
+const BTN_WIDTH: i32 = 120;
14
+const BTN_HEIGHT: u32 = 36;
15
+const BTN_SPACING: i32 = 20;
16
+
1117
 /// Result of handling an overlay event.
1218
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1319
 pub enum ConfirmResult {
@@ -23,12 +29,12 @@ pub enum ConfirmResult {
2329
 
2430
 /// Confirmation overlay that appears after applying display changes.
2531
 pub struct ConfirmOverlay {
26
-    /// Full window rect for the overlay.
27
-    rect: Rect,
2832
     /// When the confirmation started.
2933
     start_time: Instant,
3034
     /// Timeout duration.
3135
     timeout: Duration,
36
+    /// Dialog rect (centered in window).
37
+    dialog_rect: Rect,
3238
     /// Keep button rect.
3339
     keep_btn: Rect,
3440
     /// Revert button rect.
@@ -39,32 +45,40 @@ pub struct ConfirmOverlay {
3945
     revert_hovered: bool,
4046
 }
4147
 
48
+/// Compute dialog and button rects centered in the given window rect.
49
+fn compute_layout(window_rect: Rect) -> (Rect, Rect, Rect) {
50
+    let dialog_x = window_rect.x + (window_rect.width as i32 - DIALOG_WIDTH) / 2;
51
+    let dialog_y = window_rect.y + (window_rect.height as i32 - DIALOG_HEIGHT) / 2;
52
+    let dialog_rect = Rect::new(dialog_x, dialog_y, DIALOG_WIDTH as u32, DIALOG_HEIGHT as u32);
53
+
54
+    let center_x = dialog_x + DIALOG_WIDTH / 2;
55
+    let btn_y = dialog_y + 95;
56
+
57
+    let keep_btn = Rect::new(
58
+        center_x - BTN_WIDTH - BTN_SPACING / 2,
59
+        btn_y,
60
+        BTN_WIDTH as u32,
61
+        BTN_HEIGHT,
62
+    );
63
+    let revert_btn = Rect::new(
64
+        center_x + BTN_SPACING / 2,
65
+        btn_y,
66
+        BTN_WIDTH as u32,
67
+        BTN_HEIGHT,
68
+    );
69
+
70
+    (dialog_rect, keep_btn, revert_btn)
71
+}
72
+
4273
 impl ConfirmOverlay {
4374
     /// Create a new confirmation overlay.
4475
     pub fn new(window_rect: Rect) -> Self {
45
-        let btn_width = 120;
46
-        let btn_height = 36;
47
-        let btn_spacing = 20;
48
-        let center_x = window_rect.x + window_rect.width as i32 / 2;
49
-        let center_y = window_rect.y + window_rect.height as i32 / 2;
50
-
51
-        let keep_btn = Rect::new(
52
-            center_x - btn_width - btn_spacing / 2,
53
-            center_y + 30,
54
-            btn_width as u32,
55
-            btn_height,
56
-        );
57
-        let revert_btn = Rect::new(
58
-            center_x + btn_spacing / 2,
59
-            center_y + 30,
60
-            btn_width as u32,
61
-            btn_height,
62
-        );
76
+        let (dialog_rect, keep_btn, revert_btn) = compute_layout(window_rect);
6377
 
6478
         Self {
65
-            rect: window_rect,
6679
             start_time: Instant::now(),
6780
             timeout: Duration::from_secs(CONFIRM_TIMEOUT_SECS),
81
+            dialog_rect,
6882
             keep_btn,
6983
             revert_btn,
7084
             keep_hovered: false,
@@ -89,25 +103,10 @@ impl ConfirmOverlay {
89103
 
90104
     /// Update the overlay rect (e.g., on window resize).
91105
     pub fn set_rect(&mut self, rect: Rect) {
92
-        self.rect = rect;
93
-        let btn_width = 120;
94
-        let btn_height = 36;
95
-        let btn_spacing = 20;
96
-        let center_x = rect.x + rect.width as i32 / 2;
97
-        let center_y = rect.y + rect.height as i32 / 2;
98
-
99
-        self.keep_btn = Rect::new(
100
-            center_x - btn_width - btn_spacing / 2,
101
-            center_y + 30,
102
-            btn_width as u32,
103
-            btn_height,
104
-        );
105
-        self.revert_btn = Rect::new(
106
-            center_x + btn_spacing / 2,
107
-            center_y + 30,
108
-            btn_width as u32,
109
-            btn_height,
110
-        );
106
+        let (dialog_rect, keep_btn, revert_btn) = compute_layout(rect);
107
+        self.dialog_rect = dialog_rect;
108
+        self.keep_btn = keep_btn;
109
+        self.revert_btn = revert_btn;
111110
     }
112111
 
113112
     /// Handle an input event.
@@ -164,7 +163,6 @@ impl ConfirmOverlay {
164163
                 }
165164
             }
166165
             InputEvent::Idle => {
167
-                // Check timeout on idle (already checked above, but log for debugging)
168166
                 let remaining = self.remaining_secs();
169167
                 if remaining <= 3 {
170168
                     tracing::debug!("confirm overlay: {}s remaining", remaining);
@@ -177,20 +175,12 @@ impl ConfirmOverlay {
177175
 
178176
     /// Render the overlay.
179177
     pub fn render(&self, renderer: &Renderer, theme: &Theme) -> anyhow::Result<()> {
180
-        // Semi-transparent dark overlay
181
-        let overlay_color = Color::new(0.0, 0.0, 0.0, 0.7);
182
-        renderer.fill_rect(self.rect, overlay_color)?;
183
-
184
-        // Center dialog box
185
-        let dialog_width = 400;
186
-        let dialog_height = 150;
187
-        let dialog_x = self.rect.x + (self.rect.width as i32 - dialog_width) / 2;
188
-        let dialog_y = self.rect.y + (self.rect.height as i32 - dialog_height) / 2;
189
-        let dialog_rect = Rect::new(dialog_x, dialog_y, dialog_width as u32, dialog_height as u32);
190
-
191178
         // Dialog background
192
-        renderer.fill_rounded_rect(dialog_rect, 12.0, theme.background)?;
193
-        renderer.stroke_rounded_rect(dialog_rect, 12.0, theme.border, 2.0)?;
179
+        renderer.fill_rounded_rect(self.dialog_rect, 12.0, theme.background)?;
180
+        renderer.stroke_rounded_rect(self.dialog_rect, 12.0, theme.border, 2.0)?;
181
+
182
+        let dx = self.dialog_rect.x;
183
+        let dy = self.dialog_rect.y;
194184
 
195185
         // Title
196186
         let title_style = TextStyle::new()
@@ -199,19 +189,23 @@ impl ConfirmOverlay {
199189
             .color(theme.foreground)
200190
             .align(TextAlign::Center);
201191
 
202
-        let title_rect = Rect::new(dialog_x, dialog_y + 20, dialog_width as u32, 30);
192
+        let title_rect = Rect::new(dx, dy + 20, DIALOG_WIDTH as u32, 30);
203193
         renderer.text_in_rect("Keep these display settings?", title_rect, &title_style)?;
204194
 
205195
         // Countdown
206196
         let remaining = self.remaining_secs();
207
-        let countdown_text = format!("Reverting in {} second{}...", remaining, if remaining == 1 { "" } else { "s" });
197
+        let countdown_text = format!(
198
+            "Reverting in {} second{}...",
199
+            remaining,
200
+            if remaining == 1 { "" } else { "s" }
201
+        );
208202
         let countdown_style = TextStyle::new()
209203
             .font_family(&theme.font_family)
210204
             .font_size(theme.font_size)
211205
             .color(theme.item_description)
212206
             .align(TextAlign::Center);
213207
 
214
-        let countdown_rect = Rect::new(dialog_x, dialog_y + 55, dialog_width as u32, 24);
208
+        let countdown_rect = Rect::new(dx, dy + 55, DIALOG_WIDTH as u32, 24);
215209
         renderer.text_in_rect(&countdown_text, countdown_rect, &countdown_style)?;
216210
 
217211
         // Keep button
gardisplay/src/ui/display_panel.rsmodified
@@ -30,6 +30,24 @@ pub enum DisplayPanelResult {
3030
     ConfigChanged(DisplayPanelConfig),
3131
 }
3232
 
33
+/// A virtual resolution entry: native mode + scale factor.
34
+#[derive(Debug, Clone)]
35
+struct VirtualResolution {
36
+    /// Effective width after scaling.
37
+    eff_width: u32,
38
+    /// Effective height after scaling.
39
+    eff_height: u32,
40
+    /// Scale factor that produces this effective resolution.
41
+    scale: f64,
42
+    /// The native mode width.
43
+    native_width: u32,
44
+    /// The native mode height.
45
+    native_height: u32,
46
+}
47
+
48
+/// Common scale factors for generating virtual resolutions.
49
+const VIRTUAL_SCALE_FACTORS: &[f64] = &[1.0, 1.25, 1.5, 2.0];
50
+
3351
 /// Display settings panel for the selected monitor.
3452
 pub struct DisplayPanel {
3553
     rect: Rect,
@@ -42,6 +60,8 @@ pub struct DisplayPanel {
4260
     // State
4361
     selected_output: Option<String>,
4462
     available_modes: Vec<ModeInfo>,
63
+    /// Virtual resolution entries (populated when driver has limited modes).
64
+    virtual_resolutions: Vec<VirtualResolution>,
4565
     // Current values
4666
     current_width: u32,
4767
     current_height: u32,
@@ -83,6 +103,7 @@ impl DisplayPanel {
83103
             enabled_toggle,
84104
             selected_output: None,
85105
             available_modes: Vec::new(),
106
+            virtual_resolutions: Vec::new(),
86107
             current_width: 0,
87108
             current_height: 0,
88109
             current_refresh: 60.0,
@@ -122,41 +143,100 @@ impl DisplayPanel {
122143
             if let Some(output) = output {
123144
                 self.available_modes = output.modes.clone();
124145
 
125
-                // Populate resolution dropdown with unique resolutions
126
-                let resolutions: Vec<String> = output
146
+                // Collect unique hardware resolutions
147
+                let unique_resolutions: HashSet<String> = output
127148
                     .modes
128149
                     .iter()
129150
                     .map(|m| format!("{}x{}", m.width, m.height))
130
-                    .collect::<HashSet<_>>()
131
-                    .into_iter()
132151
                     .collect();
133
-                let mut sorted_resolutions: Vec<String> = resolutions;
134
-                sorted_resolutions.sort_by(|a, b| {
135
-                    let parse_res = |s: &str| -> u64 {
136
-                        let parts: Vec<&str> = s.split('x').collect();
137
-                        if parts.len() == 2 {
138
-                            parts[0].parse::<u64>().unwrap_or(0)
139
-                                * parts[1].parse::<u64>().unwrap_or(0)
140
-                        } else {
141
-                            0
142
-                        }
143
-                    };
144
-                    parse_res(b).cmp(&parse_res(a))
145
-                });
146
-                self.resolution_dropdown.set_items(sorted_resolutions);
152
+
153
+                // If the driver only has 1 unique resolution (e.g., appledrm on Apple Silicon),
154
+                // generate virtual resolutions using CRTC transform scale factors.
155
+                // This mimics macOS's "scaled resolutions" list.
156
+                if unique_resolutions.len() <= 1 {
157
+                    if let Some(native) = output.modes.first() {
158
+                        self.virtual_resolutions = VIRTUAL_SCALE_FACTORS
159
+                            .iter()
160
+                            .map(|&s| {
161
+                                let ew = (native.width as f64 / s).round() as u32;
162
+                                let eh = (native.height as f64 / s).round() as u32;
163
+                                VirtualResolution {
164
+                                    eff_width: ew,
165
+                                    eff_height: eh,
166
+                                    scale: s,
167
+                                    native_width: native.width as u32,
168
+                                    native_height: native.height as u32,
169
+                                }
170
+                            })
171
+                            .collect();
172
+
173
+                        let mut sorted: Vec<String> = self
174
+                            .virtual_resolutions
175
+                            .iter()
176
+                            .map(|vr| format!("{}x{}", vr.eff_width, vr.eff_height))
177
+                            .collect();
178
+                        sorted.sort_by(|a, b| {
179
+                            let parse_res = |s: &str| -> u64 {
180
+                                // Parse "WxH" or "WxH (Sx)" — grab just the WxH part
181
+                                let wxh = s.split_whitespace().next().unwrap_or(s);
182
+                                let parts: Vec<&str> = wxh.split('x').collect();
183
+                                if parts.len() == 2 {
184
+                                    parts[0].parse::<u64>().unwrap_or(0)
185
+                                        * parts[1].parse::<u64>().unwrap_or(0)
186
+                                } else {
187
+                                    0
188
+                                }
189
+                            };
190
+                            parse_res(b).cmp(&parse_res(a))
191
+                        });
192
+                        self.resolution_dropdown.set_items(sorted);
193
+                    }
194
+                } else {
195
+                    // Multiple hardware modes available — use them directly
196
+                    self.virtual_resolutions.clear();
197
+                    let mut sorted_resolutions: Vec<String> =
198
+                        unique_resolutions.into_iter().collect();
199
+                    sorted_resolutions.sort_by(|a, b| {
200
+                        let parse_res = |s: &str| -> u64 {
201
+                            let parts: Vec<&str> = s.split('x').collect();
202
+                            if parts.len() == 2 {
203
+                                parts[0].parse::<u64>().unwrap_or(0)
204
+                                    * parts[1].parse::<u64>().unwrap_or(0)
205
+                            } else {
206
+                                0
207
+                            }
208
+                        };
209
+                        parse_res(b).cmp(&parse_res(a))
210
+                    });
211
+                    self.resolution_dropdown.set_items(sorted_resolutions);
212
+                }
147213
 
148214
                 // Populate refresh rates for current resolution
149215
                 self.update_refresh_dropdown(width, height);
150216
             } else {
151217
                 // Demo mode: just show current resolution/refresh
152218
                 self.available_modes.clear();
219
+                self.virtual_resolutions.clear();
153220
                 self.resolution_dropdown.set_items(vec![format!("{}x{}", width, height)]);
154221
                 self.refresh_dropdown.set_items(vec![format!("{:.0}Hz", refresh)]);
155222
             }
156223
 
157
-            // Set current resolution
158
-            let current_res = format!("{}x{}", width, height);
159
-            self.resolution_dropdown.set_selected_by_name(&current_res);
224
+            // Set current resolution in dropdown.
225
+            if !self.virtual_resolutions.is_empty() {
226
+                // Find the virtual resolution matching the current scale
227
+                let target = self
228
+                    .virtual_resolutions
229
+                    .iter()
230
+                    .find(|vr| (vr.scale - scale).abs() < 0.01)
231
+                    .or_else(|| self.virtual_resolutions.first());
232
+                if let Some(vr) = target {
233
+                    let label = format!("{}x{}", vr.eff_width, vr.eff_height);
234
+                    self.resolution_dropdown.set_selected_by_name(&label);
235
+                }
236
+            } else {
237
+                let current_res = format!("{}x{}", width, height);
238
+                self.resolution_dropdown.set_selected_by_name(&current_res);
239
+            }
160240
 
161241
             // Set current refresh
162242
             let current_refresh_str = format!("{:.0}Hz", refresh);
@@ -209,6 +289,24 @@ impl DisplayPanel {
209289
         self.refresh_dropdown.set_items(sorted_refreshes);
210290
     }
211291
 
292
+    /// Find a virtual resolution entry matching a dropdown label.
293
+    /// Labels are either "WxH" (for scale 1x) or "WxH (Sx)" (for other scales).
294
+    fn find_virtual_resolution(&self, label: &str) -> Option<VirtualResolution> {
295
+        // Parse the effective dimensions from the label
296
+        let wxh = label.split_whitespace().next().unwrap_or(label);
297
+        let parts: Vec<&str> = wxh.split('x').collect();
298
+        if parts.len() != 2 {
299
+            return None;
300
+        }
301
+        let w: u32 = parts[0].parse().ok()?;
302
+        let h: u32 = parts[1].parse().ok()?;
303
+
304
+        self.virtual_resolutions
305
+            .iter()
306
+            .find(|vr| vr.eff_width == w && vr.eff_height == h)
307
+            .cloned()
308
+    }
309
+
212310
     /// Get the current configuration.
213311
     pub fn get_config(&self) -> Option<DisplayPanelConfig> {
214312
         if self.selected_output.is_some() {
@@ -267,22 +365,36 @@ impl DisplayPanel {
267365
         if let Some(action) = self.resolution_dropdown.handle_event(event) {
268366
             if let crate::ui::widgets::DropdownAction::Select(_) = action {
269367
                 if let Some(res) = self.resolution_dropdown.selected_item() {
270
-                    // Parse resolution
271
-                    let parts: Vec<&str> = res.split('x').collect();
272
-                    if parts.len() == 2 {
273
-                        if let (Ok(w), Ok(h)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
274
-                            self.current_width = w;
275
-                            self.current_height = h;
276
-                            // Update refresh rates for new resolution
277
-                            self.update_refresh_dropdown(w, h);
278
-                            // Select first available refresh rate
279
-                            if let Some(first_refresh) = self.refresh_dropdown.selected_item() {
280
-                                let hz_str = first_refresh.trim_end_matches("Hz");
281
-                                if let Ok(hz) = hz_str.parse::<f64>() {
282
-                                    self.current_refresh = hz;
283
-                                }
368
+                    // Check if this is a virtual resolution (has scale annotation like "(2x)")
369
+                    if let Some(vr) = self.find_virtual_resolution(res) {
370
+                        // Virtual resolution: use native mode + scale
371
+                        self.current_width = vr.native_width;
372
+                        self.current_height = vr.native_height;
373
+                        self.current_scale = vr.scale;
374
+                        // Sync the scale dropdown
375
+                        let scale_str = format!("{}x", vr.scale);
376
+                        self.scale_dropdown.set_selected_by_name(&scale_str);
377
+                        self.update_refresh_dropdown(vr.native_width, vr.native_height);
378
+                        config_changed = true;
379
+                    } else {
380
+                        // Real hardware resolution: parse WxH
381
+                        let parts: Vec<&str> = res.split('x').collect();
382
+                        if parts.len() == 2 {
383
+                            if let (Ok(w), Ok(h)) =
384
+                                (parts[0].parse::<u32>(), parts[1].parse::<u32>())
385
+                            {
386
+                                self.current_width = w;
387
+                                self.current_height = h;
388
+                                self.update_refresh_dropdown(w, h);
389
+                                config_changed = true;
284390
                             }
285
-                            config_changed = true;
391
+                        }
392
+                    }
393
+                    // Select first available refresh rate
394
+                    if let Some(first_refresh) = self.refresh_dropdown.selected_item() {
395
+                        let hz_str = first_refresh.trim_end_matches("Hz");
396
+                        if let Ok(hz) = hz_str.parse::<f64>() {
397
+                            self.current_refresh = hz;
286398
                         }
287399
                     }
288400
                 }
@@ -336,6 +448,15 @@ impl DisplayPanel {
336448
                     let scale_str = scale.trim_end_matches('x');
337449
                     if let Ok(s) = scale_str.parse::<f64>() {
338450
                         self.current_scale = s;
451
+                        // Sync virtual resolution dropdown if active
452
+                        if let Some(vr) = self
453
+                            .virtual_resolutions
454
+                            .iter()
455
+                            .find(|vr| (vr.scale - s).abs() < 0.01)
456
+                        {
457
+                            let label = format!("{}x{}", vr.eff_width, vr.eff_height);
458
+                            self.resolution_dropdown.set_selected_by_name(&label);
459
+                        }
339460
                         config_changed = true;
340461
                     }
341462
                 }
gardisplay/src/ui/monitor_view.rsmodified
@@ -16,7 +16,7 @@ const DOUBLE_CLICK_MS: u128 = 400;
1616
 /// Visual representation of a monitor in the layout.
1717
 #[derive(Debug, Clone)]
1818
 pub struct MonitorState {
19
-    /// Monitor info from X11.
19
+    /// Monitor info from X11. rect.width/height hold effective (scaled) dimensions.
2020
     pub info: Monitor,
2121
     /// Scaled rectangle for display (updated during drag).
2222
     pub scaled_rect: Rect,
@@ -30,6 +30,10 @@ pub struct MonitorState {
3030
     pub rotation: u32,
3131
     /// Scale factor.
3232
     pub scale: f64,
33
+    /// Raw mode width (hardware resolution, before scaling).
34
+    pub mode_width: u32,
35
+    /// Raw mode height (hardware resolution, before scaling).
36
+    pub mode_height: u32,
3337
 }
3438
 
3539
 /// State for an active drag operation.
@@ -100,6 +104,8 @@ impl MonitorView {
100104
             .map(|info| {
101105
                 let scaled_rect = self.scale_rect(&info.rect);
102106
                 let real_position = Point::new(info.rect.x, info.rect.y);
107
+                let mode_width = info.rect.width;
108
+                let mode_height = info.rect.height;
103109
                 MonitorState {
104110
                     info,
105111
                     scaled_rect,
@@ -108,6 +114,8 @@ impl MonitorView {
108114
                     refresh: 60.0, // Default, will be updated from RandR
109115
                     rotation: 0,
110116
                     scale: 1.0,
117
+                    mode_width,
118
+                    mode_height,
111119
                 }
112120
             })
113121
             .collect();
@@ -742,17 +750,40 @@ impl MonitorView {
742750
         enabled: bool,
743751
     ) {
744752
         if let Some(state) = self.monitors.iter_mut().find(|m| m.info.name == name) {
745
-            state.info.rect.width = width;
746
-            state.info.rect.height = height;
753
+            // Store raw mode resolution
754
+            state.mode_width = width;
755
+            state.mode_height = height;
747756
             state.refresh = refresh;
748757
             state.rotation = rotation;
749758
             state.scale = scale;
750759
             state.enabled = enabled;
760
+
761
+            // Set info.rect to effective (scaled) dimensions for visual layout
762
+            let (eff_w, eff_h) = Self::effective_dimensions(width, height, rotation, scale);
763
+            state.info.rect.width = eff_w;
764
+            state.info.rect.height = eff_h;
765
+
751766
             self.dirty = true;
752767
             self.recalculate_layout();
753768
         }
754769
     }
755770
 
771
+    /// Calculate effective dimensions after rotation and scaling.
772
+    /// Mirrors the logic in RandrManager::effective_dimensions.
773
+    fn effective_dimensions(width: u32, height: u32, rotation: u32, scale: f64) -> (u32, u32) {
774
+        let (rot_w, rot_h) = match rotation {
775
+            90 | 270 => (height, width),
776
+            _ => (width, height),
777
+        };
778
+        if (scale - 1.0).abs() < 0.001 {
779
+            (rot_w, rot_h)
780
+        } else {
781
+            let eff_w = (rot_w as f64 / scale).round() as u32;
782
+            let eff_h = (rot_h as f64 / scale).round() as u32;
783
+            (eff_w.max(1), eff_h.max(1))
784
+        }
785
+    }
786
+
756787
     /// Get mutable monitors.
757788
     pub fn monitors_mut(&mut self) -> &mut [MonitorState] {
758789
         &mut self.monitors
gardisplay/src/watchdog.rsmodified
@@ -111,15 +111,22 @@ fn generate_xrandr_commands(configs: &[MonitorConfig]) -> String {
111111
             _ => "normal",
112112
         };
113113
 
114
-        // Reset any RandR transforms to identity (scale 1x1)
114
+        // Apply RandR transform for scaling (xrandr --scale uses inverse of UI scale)
115
+        let xrandr_scale = if (config.scale - 1.0).abs() > 0.001 {
116
+            let s = 1.0 / config.scale;
117
+            format!("{:.4}x{:.4}", s, s)
118
+        } else {
119
+            "1x1".to_string()
120
+        };
115121
         commands.push(format!(
116
-            "xrandr --output {} --mode {}x{} --pos {}x{} --rotate {} --scale 1x1",
122
+            "xrandr --output {} --mode {}x{} --pos {}x{} --rotate {} --scale {}",
117123
             config.name,
118124
             config.width,
119125
             config.height,
120126
             config.x,
121127
             config.y,
122
-            rotation
128
+            rotation,
129
+            xrandr_scale
123130
         ));
124131
     }
125132
 
@@ -168,7 +175,28 @@ mod tests {
168175
         ];
169176
 
170177
         let commands = generate_xrandr_commands(&configs);
171
-        assert!(commands.contains("xrandr --output eDP-1 --mode 2880x1800 --pos 0x0 --rotate normal"));
172
-        assert!(commands.contains("xrandr --output HDMI-1 --mode 1920x1080 --pos 2880x0 --rotate left"));
178
+        assert!(commands.contains("xrandr --output eDP-1 --mode 2880x1800 --pos 0x0 --rotate normal --scale 1x1"));
179
+        assert!(commands.contains("xrandr --output HDMI-1 --mode 1920x1080 --pos 2880x0 --rotate left --scale 1x1"));
180
+    }
181
+
182
+    #[test]
183
+    fn test_generate_xrandr_commands_scaled() {
184
+        let configs = vec![MonitorConfig {
185
+            name: "eDP-1".to_string(),
186
+            enabled: true,
187
+            x: 0,
188
+            y: 0,
189
+            width: 2880,
190
+            height: 1800,
191
+            refresh: 60.0,
192
+            scale: 2.0,
193
+            rotation: 0,
194
+        }];
195
+
196
+        let commands = generate_xrandr_commands(&configs);
197
+        // scale 2.0 → xrandr --scale 0.5x0.5
198
+        assert!(commands.contains("--scale 0.5000x0.5000"));
199
+        // DPI should be 192 for scale 2.0
200
+        assert!(commands.contains("Xft.dpi: 192"));
173201
     }
174202
 }