git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commit98a05c63bbe4401196612bdb316a0d3001333f6d
parent52902bf8b8
authorLucas Galante <[email protected]>
date2026-06-27 21:46
feat(display): add a display scale spinbox to the display page

 src/pages/display.rs | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 70 insertions(+), 2 deletions(-)

diff --git a/src/pages/display.rs b/src/pages/display.rs
index c5dd167..2d453a7 100644
--- a/src/pages/display.rs
+++ b/src/pages/display.rs
@@ -14,10 +14,12 @@ pub struct DisplayOutput {
     pub name_label: Label,
     pub resolution_label: Label,
     pub scale_label: Option<Label>,
+    pub scale_spinbox: Spinbox,
 }
 
 impl DisplayOutput {
     pub fn update_labels(&mut self) {
+        self.scale_spinbox.value = (self.scale * 100.0).round() as i32;
         if self.connected {
             self.name_label.set_text(&self.name);
             self.resolution_label.set_text(&format!("{} @ {}Hz", self.resolution, self.refresh));
@@ -101,6 +103,7 @@ impl Default for DisplayState {
 pub enum DisplayMessage {
     Refreshed(DisplayState),
     BrightnessSet(u32),
+    ScaleSet(String, f32),
     ToggleScreensaverEnable,
     ToggleScreensaverLockScreen,
     SetScreensaverTimeout(i32),
@@ -215,12 +218,16 @@ async fn fetch_outputs() -> Vec<DisplayOutput> {
             let name = trimmed.split_whitespace().next().unwrap_or("").to_string();
             let name_label = Label::new(&name).with_font_size(12.0).with_color([212, 212, 212]);
             let resolution_label = Label::new("").with_font_size(12.0).with_color([212, 212, 212]);
+            let scale_spinbox = Spinbox::new(100, 100, 300, 25)
+                .with_label("Scale")
+                .with_decimals(2);
             current = Some(DisplayOutput {
                 name, resolution: String::new(), refresh: String::new(),
                 scale: 1.0, connected: true,
                 name_label,
                 resolution_label,
                 scale_label: None,
+                scale_spinbox,
             });
             continue;
         }
@@ -260,6 +267,11 @@ fn spawn_brightness(pct: u32) {
         .args(["set", &format!("{}%", pct), "-n"]).spawn();
 }
 
+fn spawn_scale(output_name: &str, scale: f32) {
+    let _ = tokio::process::Command::new("wlr-randr")
+        .args(["--output", output_name, "--scale", &format!("{:.2}", scale)]).spawn();
+}
+
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 #[allow(dead_code)]
 const BLANK_BAR: [f32; 4] = [0.15, 0.15, 0.24, 1.0];
@@ -317,6 +329,9 @@ pub fn view(state: &mut DisplayState, cx: f32, cy: f32, cw: f32, ch: f32, layout
                     if let Some(ref mut scale_lbl) = out.scale_label {
                         subsec.widget_full(scale_lbl, 20.0, ctx);
                     }
+                    if out.connected {
+                        subsec.widget_full(&mut out.scale_spinbox, cce_ui::layout::spinbox_height(), ctx);
+                    }
                 });
             }
         }
@@ -367,7 +382,8 @@ pub fn update(state: &mut DisplayState, msg: DisplayMessage) {
                 hovers.insert(out.name.clone(), (
                     out.name_label.hovered(),
                     out.resolution_label.hovered(),
-                    out.scale_label.as_ref().map(|l| l.hovered()).unwrap_or(false)
+                    out.scale_label.as_ref().map(|l| l.hovered()).unwrap_or(false),
+                    out.scale_spinbox.hovered()
                 ));
             }
 
@@ -387,12 +403,13 @@ pub fn update(state: &mut DisplayState, msg: DisplayMessage) {
             state.screensaver_style_menu.set_hovered(style_hover);
 
             for out in &mut state.outputs {
-                if let Some(&(name_h, res_h, scale_h)) = hovers.get(&out.name) {
+                if let Some(&(name_h, res_h, scale_h, spin_h)) = hovers.get(&out.name) {
                     out.name_label.set_hovered(name_h);
                     out.resolution_label.set_hovered(res_h);
                     if let Some(ref mut scale_lbl) = out.scale_label {
                         scale_lbl.set_hovered(scale_h);
                     }
+                    out.scale_spinbox.set_hovered(spin_h);
                 }
             }
         }
@@ -403,6 +420,14 @@ pub fn update(state: &mut DisplayState, msg: DisplayMessage) {
             state.brightness_spinbox.value = pct as i32;
             state.brightness_slider.set_value(pct as f32 / 100.0);
         }
+        DisplayMessage::ScaleSet(name, scale) => {
+            if let Some(out) = state.outputs.iter_mut().find(|o| o.name == name) {
+                out.scale = scale;
+                out.scale_spinbox.value = (scale * 100.0).round() as i32;
+                out.update_labels();
+                spawn_scale(&name, scale);
+            }
+        }
         DisplayMessage::ToggleScreensaverEnable => {
             state.screensaver_enable = !state.screensaver_enable;
             write_config_value("enable", &state.screensaver_enable.to_string());
@@ -471,6 +496,8 @@ impl crate::pages::AppPage for DisplayState {
                 scale_lbl.clear_children(ctx);
                 scale_lbl.set_parent(None, ctx);
             }
+            out.scale_spinbox.clear_children(ctx);
+            out.scale_spinbox.set_parent(None, ctx);
         }
     }
 
@@ -525,6 +552,9 @@ impl crate::pages::AppPage for DisplayState {
             if let Some(ref mut scale_lbl) = out.scale_label {
                 cce_ui::widget::link_parent_child(&mut sec_containers[2], scale_lbl, ctx);
             }
+            if out.connected {
+                cce_ui::widget::link_parent_child(&mut sec_containers[2], &mut out.scale_spinbox, ctx);
+            }
         }
 
         cce_ui::widget::link_parent_child(&mut sec_containers[3], &mut self.screensaver_enable_toggle, ctx);
@@ -566,6 +596,14 @@ impl crate::pages::AppPage for DisplayState {
         if self.screensaver_timeout_spinbox.take_change() {
             actions.push(AppAction::Display(DisplayMessage::SetScreensaverTimeout(self.screensaver_timeout_spinbox.value)));
         }
+        for out in &mut self.outputs {
+            if out.connected && out.scale_spinbox.take_change() {
+                actions.push(AppAction::Display(DisplayMessage::ScaleSet(
+                    out.name.clone(),
+                    out.scale_spinbox.value as f32 / 100.0,
+                )));
+            }
+        }
     }
 
     fn handle_pointer_move(
@@ -607,6 +645,7 @@ impl crate::pages::AppPage for DisplayState {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::pages::AppPage;
 
     #[test]
     fn test_view_layout_grid() {
@@ -615,5 +654,34 @@ mod tests {
         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &mut layout, &mut cce_ui::context::UiContext::new());
         assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
     }
+
+    #[test]
+    fn test_scale_spinbox_propagation() {
+        let mut state = DisplayState::default();
+        let mut out = DisplayOutput {
+            name: "eDP-1".to_string(),
+            resolution: "1920x1080".to_string(),
+            refresh: "60".to_string(),
+            scale: 1.0,
+            connected: true,
+            name_label: Label::new("eDP-1"),
+            resolution_label: Label::new("1920x1080 @ 60Hz"),
+            scale_label: None,
+            scale_spinbox: Spinbox::new(100, 100, 300, 25).with_label("Scale").with_decimals(2),
+        };
+        out.scale_spinbox.value = 125;
+        out.scale_spinbox.just_changed = true;
+        state.outputs.push(out);
+
+        let mut actions = Vec::new();
+        state.propagate_widget_changes(&mut actions);
+        assert_eq!(actions.len(), 1);
+        if let AppAction::Display(DisplayMessage::ScaleSet(name, scale)) = &actions[0] {
+            assert_eq!(name, "eDP-1");
+            assert_eq!(*scale, 1.25);
+        } else {
+            panic!("Expected AppAction::Display(DisplayMessage::ScaleSet)");
+        }
+    }
 }