linux: Use `randr` as fallback for scale factor in X11 (#34265)

Smit Barmase created

Closes #14537

- Adds server-side scale factor detection via `randr` when client-side
detection fails using `xrdb/Xft.dpi`.
- Adds the `GPUI_X11_SCALE_FACTOR` flag to force a scale factor, which
can be a positive number for custom scaling or `randr` for server-side
scale factor detection.

Release Notes:

- Fixed an issue where the scale factor was not detected correctly on
X11 systems when `Xft.dpi` is not defined (mostly in cases involving
window managers).

Change summary

crates/gpui/src/platform/linux/x11/client.rs | 259 +++++++++++++++++++++
1 file changed, 253 insertions(+), 6 deletions(-)

Detailed changes

crates/gpui/src/platform/linux/x11/client.rs 🔗

@@ -77,6 +77,8 @@ pub(crate) const XINPUT_ALL_DEVICES: xinput::DeviceId = 0;
 /// terminology is both archaic and unclear.
 pub(crate) const XINPUT_ALL_DEVICE_GROUPS: xinput::DeviceId = 1;
 
+const GPUI_X11_SCALE_FACTOR_ENV: &str = "GPUI_X11_SCALE_FACTOR";
+
 pub(crate) struct WindowRef {
     window: X11WindowStatePtr,
     refresh_state: Option<RefreshState>,
@@ -424,12 +426,7 @@ impl X11Client {
 
         let resource_database = x11rb::resource_manager::new_from_default(&xcb_connection)
             .context("Failed to create resource database")?;
-        let scale_factor = resource_database
-            .get_value("Xft.dpi", "Xft.dpi")
-            .ok()
-            .flatten()
-            .map(|dpi: f32| dpi / 96.0)
-            .unwrap_or(1.0);
+        let scale_factor = get_scale_factor(&xcb_connection, &resource_database, x_root_index);
         let cursor_handle = cursor::Handle::new(&xcb_connection, x_root_index, &resource_database)
             .context("Failed to initialize cursor theme handler")?
             .reply()
@@ -2272,3 +2269,253 @@ fn create_invisible_cursor(
     xcb_flush(connection);
     Ok(cursor)
 }
+
+enum DpiMode {
+    Randr,
+    Scale(f32),
+    NotSet,
+}
+
+fn get_scale_factor(
+    connection: &XCBConnection,
+    resource_database: &Database,
+    screen_index: usize,
+) -> f32 {
+    let env_dpi = std::env::var(GPUI_X11_SCALE_FACTOR_ENV)
+        .ok()
+        .map(|var| {
+            if var.to_lowercase() == "randr" {
+                DpiMode::Randr
+            } else if let Ok(scale) = var.parse::<f32>() {
+                if valid_scale_factor(scale) {
+                    DpiMode::Scale(scale)
+                } else {
+                    panic!(
+                        "`{}` must be a positive normal number or `randr`. Got `{}`",
+                        GPUI_X11_SCALE_FACTOR_ENV, var
+                    );
+                }
+            } else if var.is_empty() {
+                DpiMode::NotSet
+            } else {
+                panic!(
+                    "`{}` must be a positive number or `randr`. Got `{}`",
+                    GPUI_X11_SCALE_FACTOR_ENV, var
+                );
+            }
+        })
+        .unwrap_or(DpiMode::NotSet);
+
+    match env_dpi {
+        DpiMode::Scale(scale) => {
+            log::info!(
+                "Using scale factor from {}: {}",
+                GPUI_X11_SCALE_FACTOR_ENV,
+                scale
+            );
+            return scale;
+        }
+        DpiMode::Randr => {
+            if let Some(scale) = get_randr_scale_factor(connection, screen_index) {
+                log::info!(
+                    "Using RandR scale factor from {}=randr: {}",
+                    GPUI_X11_SCALE_FACTOR_ENV,
+                    scale
+                );
+                return scale;
+            }
+            log::warn!("Failed to calculate RandR scale factor, falling back to default");
+            return 1.0;
+        }
+        DpiMode::NotSet => {}
+    }
+
+    // TODO: Use scale factor from XSettings here
+
+    if let Some(dpi) = resource_database
+        .get_value::<f32>("Xft.dpi", "Xft.dpi")
+        .ok()
+        .flatten()
+    {
+        let scale = dpi / 96.0; // base dpi
+        log::info!("Using scale factor from Xft.dpi: {}", scale);
+        return scale;
+    }
+
+    if let Some(scale) = get_randr_scale_factor(connection, screen_index) {
+        log::info!("Using RandR scale factor: {}", scale);
+        return scale;
+    }
+
+    log::info!("Using default scale factor: 1.0");
+    1.0
+}
+
+fn get_randr_scale_factor(connection: &XCBConnection, screen_index: usize) -> Option<f32> {
+    let root = connection.setup().roots.get(screen_index)?.root;
+
+    let version_cookie = connection.randr_query_version(1, 6).ok()?;
+    let version_reply = version_cookie.reply().ok()?;
+    if version_reply.major_version < 1
+        || (version_reply.major_version == 1 && version_reply.minor_version < 5)
+    {
+        return legacy_get_randr_scale_factor(connection, root); // for randr <1.5
+    }
+
+    let monitors_cookie = connection.randr_get_monitors(root, true).ok()?; // true for active only
+    let monitors_reply = monitors_cookie.reply().ok()?;
+
+    let mut fallback_scale: Option<f32> = None;
+    for monitor in monitors_reply.monitors {
+        if monitor.width_in_millimeters == 0 || monitor.height_in_millimeters == 0 {
+            continue;
+        }
+        let scale_factor = get_dpi_factor(
+            (monitor.width as u32, monitor.height as u32),
+            (
+                monitor.width_in_millimeters as u64,
+                monitor.height_in_millimeters as u64,
+            ),
+        );
+        if monitor.primary {
+            return Some(scale_factor);
+        } else if fallback_scale.is_none() {
+            fallback_scale = Some(scale_factor);
+        }
+    }
+
+    fallback_scale
+}
+
+fn legacy_get_randr_scale_factor(connection: &XCBConnection, root: u32) -> Option<f32> {
+    let primary_cookie = connection.randr_get_output_primary(root).ok()?;
+    let primary_reply = primary_cookie.reply().ok()?;
+    let primary_output = primary_reply.output;
+
+    let primary_output_cookie = connection
+        .randr_get_output_info(primary_output, x11rb::CURRENT_TIME)
+        .ok()?;
+    let primary_output_info = primary_output_cookie.reply().ok()?;
+
+    // try primary
+    if primary_output_info.connection == randr::Connection::CONNECTED
+        && primary_output_info.mm_width > 0
+        && primary_output_info.mm_height > 0
+        && primary_output_info.crtc != 0
+    {
+        let crtc_cookie = connection
+            .randr_get_crtc_info(primary_output_info.crtc, x11rb::CURRENT_TIME)
+            .ok()?;
+        let crtc_info = crtc_cookie.reply().ok()?;
+
+        if crtc_info.width > 0 && crtc_info.height > 0 {
+            let scale_factor = get_dpi_factor(
+                (crtc_info.width as u32, crtc_info.height as u32),
+                (
+                    primary_output_info.mm_width as u64,
+                    primary_output_info.mm_height as u64,
+                ),
+            );
+            return Some(scale_factor);
+        }
+    }
+
+    // fallback: full scan
+    let resources_cookie = connection.randr_get_screen_resources_current(root).ok()?;
+    let screen_resources = resources_cookie.reply().ok()?;
+
+    let mut crtc_cookies = Vec::with_capacity(screen_resources.crtcs.len());
+    for &crtc in &screen_resources.crtcs {
+        if let Ok(cookie) = connection.randr_get_crtc_info(crtc, x11rb::CURRENT_TIME) {
+            crtc_cookies.push((crtc, cookie));
+        }
+    }
+
+    let mut crtc_infos: HashMap<randr::Crtc, randr::GetCrtcInfoReply> = HashMap::default();
+    let mut valid_outputs: HashSet<randr::Output> = HashSet::new();
+    for (crtc, cookie) in crtc_cookies {
+        if let Ok(reply) = cookie.reply() {
+            if reply.width > 0 && reply.height > 0 && !reply.outputs.is_empty() {
+                crtc_infos.insert(crtc, reply.clone());
+                valid_outputs.extend(&reply.outputs);
+            }
+        }
+    }
+
+    if valid_outputs.is_empty() {
+        return None;
+    }
+
+    let mut output_cookies = Vec::with_capacity(valid_outputs.len());
+    for &output in &valid_outputs {
+        if let Ok(cookie) = connection.randr_get_output_info(output, x11rb::CURRENT_TIME) {
+            output_cookies.push((output, cookie));
+        }
+    }
+    let mut output_infos: HashMap<randr::Output, randr::GetOutputInfoReply> = HashMap::default();
+    for (output, cookie) in output_cookies {
+        if let Ok(reply) = cookie.reply() {
+            output_infos.insert(output, reply);
+        }
+    }
+
+    let mut fallback_scale: Option<f32> = None;
+    for crtc_info in crtc_infos.values() {
+        for &output in &crtc_info.outputs {
+            if let Some(output_info) = output_infos.get(&output) {
+                if output_info.connection != randr::Connection::CONNECTED {
+                    continue;
+                }
+
+                if output_info.mm_width == 0 || output_info.mm_height == 0 {
+                    continue;
+                }
+
+                let scale_factor = get_dpi_factor(
+                    (crtc_info.width as u32, crtc_info.height as u32),
+                    (output_info.mm_width as u64, output_info.mm_height as u64),
+                );
+
+                if output != primary_output && fallback_scale.is_none() {
+                    fallback_scale = Some(scale_factor);
+                }
+            }
+        }
+    }
+
+    fallback_scale
+}
+
+fn get_dpi_factor((width_px, height_px): (u32, u32), (width_mm, height_mm): (u64, u64)) -> f32 {
+    let ppmm = ((width_px as f64 * height_px as f64) / (width_mm as f64 * height_mm as f64)).sqrt(); // pixels per mm
+
+    const MM_PER_INCH: f64 = 25.4;
+    const BASE_DPI: f64 = 96.0;
+    const QUANTIZE_STEP: f64 = 12.0; // e.g. 1.25 = 15/12, 1.5 = 18/12, 1.75 = 21/12, 2.0 = 24/12
+    const MIN_SCALE: f64 = 1.0;
+    const MAX_SCALE: f64 = 20.0;
+
+    let dpi_factor =
+        ((ppmm * (QUANTIZE_STEP * MM_PER_INCH / BASE_DPI)).round() / QUANTIZE_STEP).max(MIN_SCALE);
+
+    let validated_factor = if dpi_factor <= MAX_SCALE {
+        dpi_factor
+    } else {
+        MIN_SCALE
+    };
+
+    if valid_scale_factor(validated_factor as f32) {
+        validated_factor as f32
+    } else {
+        log::warn!(
+            "Calculated DPI factor {} is invalid, using 1.0",
+            validated_factor
+        );
+        1.0
+    }
+}
+
+#[inline]
+fn valid_scale_factor(scale_factor: f32) -> bool {
+    scale_factor.is_sign_positive() && scale_factor.is_normal()
+}