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

commit93869c4e740d9cb276b902637999fedf801c8899
parentb4feec934a
authorLucas Galante <[email protected]>
date2026-05-20 14:56
Update imports and view fn for origin-relative Column from clear-ui

- Import Column from clear_ui::layout (remove local column.rs)
- Column::new now takes (pc, ox, oy, cx, cy, cw) for parent-relative coords
- Use cy=28 (was cy+12) to fix bg label / header overlap

 Cargo.lock          |  20 -----
 Cargo.toml          |   3 +-
 src/app.rs          |  12 +++
 src/main.rs         | 174 ++++++++++++++++++++++++++++++++++---
 src/pages/layout.rs | 243 +++++++++++++++++++++++++---------------------------
 5 files changed, 290 insertions(+), 162 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 3ef71a9..37ca0db 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -391,7 +391,6 @@ dependencies = [
  "glyphon",
  "pollster",
  "serde",
- "taffy",
  "tokio",
  "wgpu",
  "winit",
@@ -405,7 +404,6 @@ dependencies = [
  "bytemuck",
  "glyphon",
  "pollster",
- "taffy",
  "tokio",
  "wgpu",
  "winit",
@@ -968,12 +966,6 @@ dependencies = [
  "bitflags 2.11.1",
 ]
 
-[[package]]
-name = "grid"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5"
-
 [[package]]
 name = "hashbrown"
 version = "0.15.5"
@@ -2244,18 +2236,6 @@ dependencies = [
  "libc",
 ]
 
-[[package]]
-name = "taffy"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859"
-dependencies = [
- "arrayvec",
- "grid",
- "serde",
- "slotmap",
-]
-
 [[package]]
 name = "tempfile"
 version = "3.27.0"
diff --git a/Cargo.toml b/Cargo.toml
index 54b66da..f996744 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,12 +4,11 @@ version = "0.1.0"
 edition = "2021"
 
 [dependencies]
-clear-ui = { path = "../ui-framework" }
+clear-ui = { path = "../clear-ui" }
 winit = "0.30"
 wgpu = "24"
 bytemuck = { version = "1", features = ["derive"] }
 pollster = "0.4"
-taffy = "0.10.1"
 glyphon = "0.8"
 tokio = { version = "1", features = ["full"] }
 zbus = "5"
diff --git a/src/app.rs b/src/app.rs
index b9b0107..9c3774d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,3 +1,5 @@
+use clear_ui::layout::RenderTarget;
+
 use crate::pages::audio;
 use crate::pages::display;
 use crate::pages::input;
@@ -92,3 +94,13 @@ impl PageContent {
         });
     }
 }
+
+impl RenderTarget for PageContent {
+    fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
+        self.rects.push((color, x, y, w, h));
+    }
+
+    fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
+        self.texts.push((content.to_string(), size, x, y, color));
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 0512950..47c2239 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,6 +6,7 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
 use winit::window::{Window, WindowAttributes};
 
 use clear_ui::color;
+use clear_ui::widget::Widget;
 use glyphon::{
     Attrs, Buffer, Cache, FontSystem, Metrics, Resolution, SwashCache, TextArea, TextAtlas,
     TextBounds, TextRenderer, Viewport,
@@ -80,6 +81,11 @@ struct TextItem {
     color: glyphon::Color,
 }
 
+enum ColorPickerAction {
+    Background([u8; 3]),
+    Border([u8; 3]),
+}
+
 struct SystemInterface {
     window: Arc<Window>,
     surface: wgpu::Surface<'static>,
@@ -118,6 +124,8 @@ struct SystemInterface {
     rx_system: std::sync::mpsc::Receiver<pages::system_info::SystemState>,
     rx_status: std::sync::mpsc::Receiver<pages::status::StatusState>,
     rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageState>,
+    tx_color_picker: std::sync::mpsc::Sender<ColorPickerAction>,
+    rx_color_picker: std::sync::mpsc::Receiver<ColorPickerAction>,
 
     scale_factor: f64,
     width: u32,
@@ -209,17 +217,26 @@ impl SystemInterface {
             mapped_at_creation: false,
         });
 
-        // ── Initial state ──
+        // ── Initial state (fetch all concurrently) ──
+        let (power, audio, display, network, system_info, status, storage) = tokio::join!(
+            pages::power::fetch_power_state(),
+            pages::audio::fetch_audio_state(),
+            pages::display::fetch_display_state(),
+            pages::network::fetch_network_state(),
+            pages::system_info::fetch_system_state(),
+            pages::status::fetch_status_state(),
+            pages::storage::fetch_storage_state(),
+        );
         let app = AppState {
-            power: pages::power::fetch_power_state().await,
-            audio: pages::audio::fetch_audio_state().await,
-            display: pages::display::fetch_display_state().await,
-            network: pages::network::fetch_network_state().await,
+            power,
+            audio,
+            display,
+            network,
             layout: pages::layout::read_layout_config(),
             input: pages::input::read_input_config(),
-            system_info: pages::system_info::fetch_system_state().await,
-            status: pages::status::fetch_status_state().await,
-            storage: pages::storage::fetch_storage_state().await,
+            system_info,
+            status,
+            storage,
             current_page: Page::ALL[0],
         };
 
@@ -270,6 +287,7 @@ impl SystemInterface {
         let rx_status = spawn_bg(10, || pages::status::fetch_status_state());
         let rx_storage = spawn_bg(10, || pages::storage::fetch_storage_state());
 
+        let (tx_color_picker, rx_color_picker) = std::sync::mpsc::channel();
         let scale_factor = (window.scale_factor() as f32).max(2.0) as f64;
         let mut this = Self {
             window, surface, device, queue, config, render_pipeline,
@@ -282,6 +300,7 @@ impl SystemInterface {
             scale_factor,
             rx_power, rx_audio, rx_display, rx_network, rx_layout, rx_input,
             rx_system, rx_status, rx_storage,
+            tx_color_picker, rx_color_picker,
             width: size.width, height: size.height,
             needs_rebuild: true,
         };
@@ -341,7 +360,7 @@ impl SystemInterface {
             text_items.push(TextItem {
                 buffer: make_text_buffer(&mut self.font_system, page.label(), 12.0 * s),
                 x: 16.0 * s, y: y + 9.0 * s,
-                color: glyphon::Color::rgb(0x99, 0x99, 0xaa),
+                color: glyphon::Color::rgb(0x55, 0x55, 0x77),
             });
         }
 
@@ -413,14 +432,14 @@ impl SystemInterface {
         self.needs_rebuild = false;
     }
 
-    fn render_page_content(&self, cx: f32, cy: f32, cw: f32, ch: f32) -> PageContent {
+    fn render_page_content(&mut self, cx: f32, cy: f32, cw: f32, ch: f32) -> PageContent {
         use pages::*;
         match self.app.current_page {
             Page::Power => power::view(&self.app.power, cx, cy, cw, ch),
             Page::Audio => audio::view(&self.app.audio, cx, cy, cw, ch),
             Page::Display => display::view(&self.app.display, cx, cy, cw, ch),
             Page::Radios => network::view(&self.app.network, cx, cy, cw, ch),
-            Page::Layout => layout::view(&self.app.layout, cx, cy, cw, ch),
+            Page::Layout => layout::view(&mut self.app.layout, cx, cy, cw, ch),
             Page::Input => input::view(&self.app.input, cx, cy, cw, ch),
             Page::System => system_info::view(&self.app.system_info, cx, cy, cw, ch),
             Page::Status => status::view(&self.app.status, cx, cy, cw, ch),
@@ -523,16 +542,61 @@ impl SystemInterface {
             storage::update(&mut self.app.storage, storage::StorageMessage::Refreshed(s));
             self.needs_rebuild = true;
         }
+        while let Ok(action) = self.rx_color_picker.try_recv() {
+            match action {
+                ColorPickerAction::Background(rgb) => {
+                    layout::update(&mut self.app.layout, layout::LayoutMessage::SetBackground(rgb));
+                }
+                ColorPickerAction::Border(rgb) => {
+                    layout::update(&mut self.app.layout, layout::LayoutMessage::SetBorderColor(rgb));
+                }
+            }
+            self.needs_rebuild = true;
+        }
     }
 
     fn handle_action(&mut self, action: &AppAction) {
         use pages::*;
         match action {
+            AppAction::Layout(m) => match m {
+                layout::LayoutMessage::PickBackgroundColor => {
+                    let color = self.app.layout.background_color;
+                    let tx = self.tx_color_picker.clone();
+                    tokio::spawn(async move {
+                        let hex = format!("#{:02X}{:02X}{:02X}", color[0], color[1], color[2]);
+                        if let Ok(output) = tokio::process::Command::new("clear-colors").arg(&hex).output().await {
+                            let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
+                            if s.starts_with('#') && s.len() >= 7 {
+                                let r = u8::from_str_radix(&s[1..3], 16).unwrap_or(color[0]);
+                                let g = u8::from_str_radix(&s[3..5], 16).unwrap_or(color[1]);
+                                let b = u8::from_str_radix(&s[5..7], 16).unwrap_or(color[2]);
+                                let _ = tx.send(ColorPickerAction::Background([r, g, b]));
+                            }
+                        }
+                    });
+                }
+                layout::LayoutMessage::PickBorderColor => {
+                    let color = self.app.layout.border_color;
+                    let tx = self.tx_color_picker.clone();
+                    tokio::spawn(async move {
+                        let hex = format!("#{:02X}{:02X}{:02X}", color[0], color[1], color[2]);
+                        if let Ok(output) = tokio::process::Command::new("clear-colors").arg(&hex).output().await {
+                            let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
+                            if s.starts_with('#') && s.len() >= 7 {
+                                let r = u8::from_str_radix(&s[1..3], 16).unwrap_or(color[0]);
+                                let g = u8::from_str_radix(&s[3..5], 16).unwrap_or(color[1]);
+                                let b = u8::from_str_radix(&s[5..7], 16).unwrap_or(color[2]);
+                                let _ = tx.send(ColorPickerAction::Border([r, g, b]));
+                            }
+                        }
+                    });
+                }
+                _ => layout::update(&mut self.app.layout, m.clone()),
+            },
             AppAction::Power(m) => power::update(&mut self.app.power, m.clone()),
             AppAction::Audio(m) => audio::update(&mut self.app.audio, m.clone()),
             AppAction::Display(m) => display::update(&mut self.app.display, m.clone()),
             AppAction::Radios(m) => network::update(&mut self.app.network, m.clone()),
-            AppAction::Layout(m) => layout::update(&mut self.app.layout, m.clone()),
             AppAction::Input(m) => input::update(&mut self.app.input, m.clone()),
             AppAction::SystemInfo(m) => system_info::update(&mut self.app.system_info, m.clone()),
             AppAction::Status(m) => status::update(&mut self.app.status, m.clone()),
@@ -555,9 +619,53 @@ impl SystemInterface {
                         changed = true;
                     }
                 }
+                if self.app.current_page == Page::Layout {
+                    let s = self.scale_factor as f32;
+                    for sb in &mut self.app.layout.spinboxes {
+                        if sb.cursor_moved(self.cursor_x / s, self.cursor_y / s) {
+                            changed = true;
+                        }
+                    }
+                    for cp in &mut self.app.layout.color_pickers {
+                        if cp.cursor_moved(self.cursor_x / s, self.cursor_y / s) {
+                            changed = true;
+                        }
+                    }
+                }
                 if changed { self.needs_rebuild = true; }
                 changed
             }
+            WindowEvent::KeyboardInput { event, .. } => {
+                if self.app.current_page == Page::Layout {
+                    let mut changed = false;
+                    let mut actions = Vec::new();
+                    for sb in &mut self.app.layout.spinboxes {
+                        if sb.keyboard_input(event) {
+                            changed = true;
+                        }
+                    }
+                    for (i, cp) in self.app.layout.color_pickers.iter_mut().enumerate() {
+                        let old = cp.color;
+                        if cp.keyboard_input(event) {
+                            if cp.color != old {
+                                actions.push(AppAction::Layout(match i {
+                                    0 => pages::layout::LayoutMessage::SetBackground(cp.color),
+                                    _ => pages::layout::LayoutMessage::SetBorderColor(cp.color),
+                                }));
+                            }
+                            changed = true;
+                        }
+                    }
+                    for a in &actions {
+                        self.handle_action(a);
+                    }
+                    if changed {
+                        self.needs_rebuild = true;
+                        return true;
+                    }
+                }
+                false
+            }
             WindowEvent::MouseInput { state, button, .. } => {
                 if *button != MouseButton::Left { return false; }
                 if *state == ElementState::Released {
@@ -581,6 +689,48 @@ impl SystemInterface {
                         }
                     }
                 }
+                if *state == ElementState::Pressed && self.app.current_page == Page::Layout {
+                    let s = self.scale_factor as f32;
+                    let lx = self.cursor_x / s;
+                    let ly = self.cursor_y / s;
+                    let mut actions = Vec::new();
+                    for (i, sb) in self.app.layout.spinboxes.iter_mut().enumerate() {
+                        if !sb.hit_test(lx, ly) { sb.unfocus(); }
+                        let old = sb.value;
+                        if sb.mouse_input(*button, *state, lx, ly) && sb.value != old {
+                            actions.push(AppAction::Layout(
+                                pages::layout::LayoutMessage::SetWidth(
+                                    pages::layout::WidthParam::ALL[i],
+                                    sb.value as u16,
+                                )
+                            ));
+                        }
+                    }
+                    for (i, cp) in self.app.layout.color_pickers.iter_mut().enumerate() {
+                        let old = cp.color;
+                        if !cp.hit_test(lx, ly) { cp.unfocus(); }
+                        cp.mouse_input(*button, *state, lx, ly);
+                        if cp.take_click() {
+                            actions.push(AppAction::Layout(match i {
+                                0 => pages::layout::LayoutMessage::PickBackgroundColor,
+                                _ => pages::layout::LayoutMessage::PickBorderColor,
+                            }));
+                        }
+                        if cp.color != old {
+                            actions.push(AppAction::Layout(match i {
+                                0 => pages::layout::LayoutMessage::SetBackground(cp.color),
+                                _ => pages::layout::LayoutMessage::SetBorderColor(cp.color),
+                            }));
+                        }
+                    }
+                    for a in &actions {
+                        self.handle_action(a);
+                    }
+                    if !actions.is_empty() {
+                        self.needs_rebuild = true;
+                        return true;
+                    }
+                }
                 self.needs_rebuild = true;
                 true
             }
diff --git a/src/pages/layout.rs b/src/pages/layout.rs
index 560e6a5..e940f50 100644
--- a/src/pages/layout.rs
+++ b/src/pages/layout.rs
@@ -1,11 +1,56 @@
 use std::fs;
 use std::io::Write;
 
-use crate::app::{AppAction, PageContent};
+use crate::app::PageContent;
+use clear_ui::layout::Column;
+use clear_ui::widget::{ColorPicker, Spinbox};
 
 const CONFIG_PATH: &str = "/home/lsgalante/.config/clearwm/config.toml";
 const CLEARWM_SOCK: &str = "/tmp/clearwm.sock";
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WidthParam {
+    Fullscreen, Cascade, Grid, Vsplit, Hsplit, Floating,
+}
+
+impl WidthParam {
+    pub const ALL: [WidthParam; 6] = [
+        WidthParam::Fullscreen, WidthParam::Cascade, WidthParam::Grid,
+        WidthParam::Vsplit, WidthParam::Hsplit, WidthParam::Floating,
+    ];
+    pub fn key(self) -> &'static str {
+        match self {
+            WidthParam::Fullscreen => "fullscreen_border_width",
+            WidthParam::Cascade => "cascade_border_width",
+            WidthParam::Grid => "grid_border_width",
+            WidthParam::Vsplit => "vsplit_border_width",
+            WidthParam::Hsplit => "hsplit_border_width",
+            WidthParam::Floating => "floating_border_width",
+        }
+    }
+    pub fn label(self) -> &'static str {
+        match self {
+            WidthParam::Fullscreen => "Fullscreen",
+            WidthParam::Cascade => "Cascade",
+            WidthParam::Grid => "Grid",
+            WidthParam::Vsplit => "Vsplit",
+            WidthParam::Hsplit => "Hsplit",
+            WidthParam::Floating => "Floating",
+        }
+    }
+}
+
+fn make_spinboxes(fs: u16, ca: u16, g: u16, v: u16, h: u16, fl: u16) -> Vec<Spinbox> {
+    vec![
+        Spinbox::new(fs as i32, 0, 100, 1),
+        Spinbox::new(ca as i32, 0, 100, 1),
+        Spinbox::new(g as i32, 0, 100, 1),
+        Spinbox::new(v as i32, 0, 100, 1),
+        Spinbox::new(h as i32, 0, 100, 1),
+        Spinbox::new(fl as i32, 0, 100, 1),
+    ]
+}
+
 #[derive(Debug, Clone)]
 pub struct LayoutState {
     pub background_color: [u8; 3],
@@ -17,6 +62,8 @@ pub struct LayoutState {
     pub hsplit_border_width: u16,
     pub floating_border_width: u16,
     pub color_options: Vec<(&'static str, [u8; 3])>,
+    pub spinboxes: Vec<Spinbox>,
+    pub color_pickers: Vec<ColorPicker>,
 }
 
 impl Default for LayoutState {
@@ -31,6 +78,11 @@ impl Default for LayoutState {
             hsplit_border_width: 6,
             floating_border_width: 6,
             color_options: preset_colors(),
+            spinboxes: make_spinboxes(0, 6, 6, 6, 6, 6),
+            color_pickers: vec![
+                ColorPicker::new([0x0a, 0x1a, 0x0e]).with_label("Desktop Background"),
+                ColorPicker::new([0x3e, 0x3e, 0x3e]).with_label("Border Color"),
+            ],
         }
     }
 }
@@ -39,12 +91,9 @@ impl Default for LayoutState {
 pub enum LayoutMessage {
     SetBackground([u8; 3]),
     SetBorderColor([u8; 3]),
-    FullscreenDown, FullscreenUp,
-    CascadeDown, CascadeUp,
-    GridDown, GridUp,
-    VsplitDown, VsplitUp,
-    HsplitDown, HsplitUp,
-    FloatingDown, FloatingUp,
+    PickBackgroundColor,
+    PickBorderColor,
+    SetWidth(WidthParam, u16),
     Refreshed(LayoutState),
 }
 
@@ -67,16 +116,29 @@ fn preset_colors() -> Vec<(&'static str, [u8; 3])> {
 
 pub fn read_layout_config() -> LayoutState {
     let content = fs::read_to_string(CONFIG_PATH).unwrap_or_default();
+    let fs = parse_u16_from(&content, "fullscreen_border_width", 0);
+    let ca = parse_u16_from(&content, "cascade_border_width", 6);
+    let g = parse_u16_from(&content, "grid_border_width", 6);
+    let v = parse_u16_from(&content, "vsplit_border_width", 6);
+    let h = parse_u16_from(&content, "hsplit_border_width", 6);
+    let fl = parse_u16_from(&content, "floating_border_width", 6);
     LayoutState {
         background_color: parse_color_from_key(&content, "background_color", [0x0a, 0x1a, 0x0e]),
         border_color: parse_color_from_key(&content, "border_color", [0x3e, 0x3e, 0x3e]),
-        fullscreen_border_width: parse_u16_from(&content, "fullscreen_border_width", 0),
-        cascade_border_width: parse_u16_from(&content, "cascade_border_width", 6),
-        grid_border_width: parse_u16_from(&content, "grid_border_width", 6),
-        vsplit_border_width: parse_u16_from(&content, "vsplit_border_width", 6),
-        hsplit_border_width: parse_u16_from(&content, "hsplit_border_width", 6),
-        floating_border_width: parse_u16_from(&content, "floating_border_width", 6),
+        fullscreen_border_width: fs,
+        cascade_border_width: ca,
+        grid_border_width: g,
+        vsplit_border_width: v,
+        hsplit_border_width: h,
+        floating_border_width: fl,
         color_options: preset_colors(),
+        spinboxes: make_spinboxes(fs, ca, g, v, h, fl),
+        color_pickers: vec![
+            ColorPicker::new(parse_color_from_key(&content, "background_color", [0x0a, 0x1a, 0x0e]))
+                .with_label("Desktop Background"),
+            ColorPicker::new(parse_color_from_key(&content, "border_color", [0x3e, 0x3e, 0x3e]))
+                .with_label("Border Color"),
+        ],
     }
 }
 
@@ -170,122 +232,57 @@ fn apply_all_widths(s: &LayoutState) {
     w("floating_border_width", s.floating_border_width);
 }
 
-fn rgb_float(c: [u8; 3]) -> [f32; 4] {
-    [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0]
-}
-
-const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
-const ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
-const BTN_ACTIVE: [f32; 4] = [0.20, 0.40, 0.22, 1.0];
-const BTN_INACTIVE: [f32; 4] = [0.13, 0.18, 0.14, 1.0];
-const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
-const SECTION_BORDER: [f32; 4] = [0.18, 0.18, 0.27, 1.0];
-const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
 
-pub fn view(state: &LayoutState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
+pub fn view(state: &mut LayoutState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
     let mut pc = PageContent::new();
-    let mut y = cy + 12.0;
+    let mut col = Column::new(&mut pc, cx, cy, 0.0, 28.0, cw);
 
-    // ── Background Color ──
-    pc.text("Desktop Background", cx + 12.0, y, 14.0, TEXT_FG);
-    y += 22.0;
-
-    let hex = format!("#{:02x}{:02x}{:02x}", state.background_color[0], state.background_color[1], state.background_color[2]);
-    pc.text(&format!("Current: {}", hex), cx + 14.0, y, 12.0, ACCENT);
-    y += 18.0;
-
-    // Color swatch grid
-    let swatch_w = 48.0;
-    let swatch_h = 24.0;
-    let gap = 6.0;
-    let cols = 4usize;
-    let total_w = cols as f32 * (swatch_w + gap);
-    let start_x = cx + (cw - total_w) / 2.0;
-
-    for (i, (name, rgb)) in state.color_options.iter().enumerate() {
-        let col = i % cols;
-        let row = i / cols;
-        let sx = start_x + col as f32 * (swatch_w + gap);
-        let sy = y + row as f32 * (swatch_h + 16.0);
-        let is_selected = *rgb == state.background_color;
-        let border = if is_selected { ACCENT } else { [0.24, 0.24, 0.24, 1.0] };
-
-        pc.rect(rgb_float(*rgb), sx, sy, swatch_w, swatch_h);
-        if is_selected {
-            pc.rect(border, sx - 1.0, sy - 1.0, swatch_w + 2.0, swatch_h + 2.0);
-        }
-        pc.text(name, sx + swatch_w / 2.0 - name.len() as f32 * 2.5, sy + swatch_h + 2.0, 8.0, TEXT_DIM);
-        // Clickable over the swatch
-        let action = AppAction::Layout(LayoutMessage::SetBackground(*rgb));
-        pc.button("", sx, sy, swatch_w, swatch_h,
-            [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], action);
+    state.color_pickers[0].color = state.background_color;
+    col.widget(&mut state.color_pickers[0], 12.0, 220.0, 22.0);
+    col.spacing(16.0);
+    col.separator();
+    col.spacing(12.0);
+    state.color_pickers[1].color = state.border_color;
+    col.widget(&mut state.color_pickers[1], 12.0, 220.0, 22.0);
+    col.separator();
+    col.header("Border Width", 12.0);
+    for (i, param) in WidthParam::ALL.iter().enumerate() {
+        col.row(30.0, |row| {
+            row.text(param.label(), 14.0, 6.0, 12.0, TEXT_DIM);
+            row.widget(&mut state.spinboxes[i], 110.0, 90.0, 26.0);
+        });
     }
-    let swatch_rows = (state.color_options.len() + cols - 1) / cols;
-    y += swatch_rows as f32 * (swatch_h + 16.0) + 8.0;
-
-    // ── Border Color ──
-    pc.rect(SECTION_BORDER, cx + 8.0, y, cw - 16.0, 1.0);
-    y += 8.0;
-    pc.text("Border Color", cx + 12.0, y, 14.0, TEXT_FG);
-    y += 22.0;
 
-    for (i, (name, rgb)) in state.color_options.iter().enumerate() {
-        let col = i % cols;
-        let row = i / cols;
-        let sx = start_x + col as f32 * (swatch_w + gap);
-        let sy = y + row as f32 * (swatch_h + 16.0);
-        let is_selected = *rgb == state.border_color;
-        let border = if is_selected { ACCENT } else { [0.24, 0.24, 0.24, 1.0] };
+    pc
+}
 
-        pc.rect(rgb_float(*rgb), sx, sy, swatch_w, swatch_h);
-        if is_selected {
-            pc.rect(border, sx - 1.0, sy - 1.0, swatch_w + 2.0, swatch_h + 2.0);
-        }
-        pc.text(name, sx + swatch_w / 2.0 - name.len() as f32 * 2.5, sy + swatch_h + 2.0, 8.0, TEXT_DIM);
-        let action = AppAction::Layout(LayoutMessage::SetBorderColor(*rgb));
-        pc.button("", sx, sy, swatch_w, swatch_h,
-            [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], action);
+fn set_width(state: &mut LayoutState, param: WidthParam, val: u16) {
+    let val = val.min(100);
+    match param {
+        WidthParam::Fullscreen => state.fullscreen_border_width = val,
+        WidthParam::Cascade => state.cascade_border_width = val,
+        WidthParam::Grid => state.grid_border_width = val,
+        WidthParam::Vsplit => state.vsplit_border_width = val,
+        WidthParam::Hsplit => state.hsplit_border_width = val,
+        WidthParam::Floating => state.floating_border_width = val,
     }
-    y += swatch_rows as f32 * (swatch_h + 16.0) + 8.0;
-
-    // ── Border Widths ──
-    pc.rect(SECTION_BORDER, cx + 8.0, y, cw - 16.0, 1.0);
-    y += 8.0;
-    pc.text("Border Width", cx + 12.0, y, 14.0, TEXT_FG);
-    y += 22.0;
-
-    let widths: [(&str, u16, AppAction, AppAction); 6] = [
-        ("Fullscreen", state.fullscreen_border_width,
-         AppAction::Layout(LayoutMessage::FullscreenDown), AppAction::Layout(LayoutMessage::FullscreenUp)),
-        ("Cascade", state.cascade_border_width,
-         AppAction::Layout(LayoutMessage::CascadeDown), AppAction::Layout(LayoutMessage::CascadeUp)),
-        ("Grid", state.grid_border_width,
-         AppAction::Layout(LayoutMessage::GridDown), AppAction::Layout(LayoutMessage::GridUp)),
-        ("Vsplit", state.vsplit_border_width,
-         AppAction::Layout(LayoutMessage::VsplitDown), AppAction::Layout(LayoutMessage::VsplitUp)),
-        ("Hsplit", state.hsplit_border_width,
-         AppAction::Layout(LayoutMessage::HsplitDown), AppAction::Layout(LayoutMessage::HsplitUp)),
-        ("Floating", state.floating_border_width,
-         AppAction::Layout(LayoutMessage::FloatingDown), AppAction::Layout(LayoutMessage::FloatingUp)),
-    ];
+    state.spinboxes[param_idx(param)].value = val as i32;
+    apply_all_widths(state);
+}
 
-    for (name, val, down, up) in &widths {
-        pc.text(name, cx + 14.0, y + 6.0, 12.0, TEXT_DIM);
-        pc.button("-1", cx + cw - 100.0, y, 30.0, 26.0,
-            BTN_INACTIVE, BTN_HOVER, WHITE, down.clone());
-        pc.text(&format!(" {}px ", val), cx + cw - 66.0, y + 6.0, 13.0, TEXT_FG);
-        pc.button("+1", cx + cw - 38.0, y, 30.0, 26.0,
-            BTN_ACTIVE, BTN_HOVER, WHITE, up.clone());
-        y += 30.0;
+fn param_idx(p: WidthParam) -> usize {
+    match p {
+        WidthParam::Fullscreen => 0,
+        WidthParam::Cascade => 1,
+        WidthParam::Grid => 2,
+        WidthParam::Vsplit => 3,
+        WidthParam::Hsplit => 4,
+        WidthParam::Floating => 5,
     }
-
-    pc
 }
 
 pub fn update(state: &mut LayoutState, msg: LayoutMessage) {
-    let dec = |v: &mut u16| { if *v > 0 { *v -= 1; } };
-    let inc = |v: &mut u16| { if *v < 100 { *v += 1; } };
     match msg {
         LayoutMessage::SetBackground(rgb) => {
             state.background_color = rgb;
@@ -295,18 +292,8 @@ pub fn update(state: &mut LayoutState, msg: LayoutMessage) {
             state.border_color = rgb;
             apply_border_color(rgb);
         }
-        LayoutMessage::FullscreenDown => { dec(&mut state.fullscreen_border_width); apply_all_widths(state); }
-        LayoutMessage::FullscreenUp => { inc(&mut state.fullscreen_border_width); apply_all_widths(state); }
-        LayoutMessage::CascadeDown => { dec(&mut state.cascade_border_width); apply_all_widths(state); }
-        LayoutMessage::CascadeUp => { inc(&mut state.cascade_border_width); apply_all_widths(state); }
-        LayoutMessage::GridDown => { dec(&mut state.grid_border_width); apply_all_widths(state); }
-        LayoutMessage::GridUp => { inc(&mut state.grid_border_width); apply_all_widths(state); }
-        LayoutMessage::VsplitDown => { dec(&mut state.vsplit_border_width); apply_all_widths(state); }
-        LayoutMessage::VsplitUp => { inc(&mut state.vsplit_border_width); apply_all_widths(state); }
-        LayoutMessage::HsplitDown => { dec(&mut state.hsplit_border_width); apply_all_widths(state); }
-        LayoutMessage::HsplitUp => { inc(&mut state.hsplit_border_width); apply_all_widths(state); }
-        LayoutMessage::FloatingDown => { dec(&mut state.floating_border_width); apply_all_widths(state); }
-        LayoutMessage::FloatingUp => { inc(&mut state.floating_border_width); apply_all_widths(state); }
+        LayoutMessage::PickBackgroundColor | LayoutMessage::PickBorderColor => {}
+        LayoutMessage::SetWidth(p, v) => set_width(state, p, v),
         LayoutMessage::Refreshed(new) => { *state = new; }
     }
 }