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

commit8df70d5b22e9bffe31d3871e556f82f5cd2de814
parent93869c4e74
authorLucas Galante <[email protected]>
date2026-05-21 19:10
Implement Processors page and adopt clear-ui Section layout across pages

- Add new Processors page to display CPU model, usage, cores, and GPU info
- Update existing pages (Audio, Display, Input, Layout, Network, Power, Status, Storage, System Info) to use the new Section layout helper from clear-ui
- Integrate Spinbox widgets for volume control in Audio page
- Ignore .antigravitycli directory in .gitignore

 .gitignore               |   1 +
 src/app.rs               |   4 +
 src/main.rs              | 214 +++++++++++++++++++++++++++++++++++++++--------
 src/pages/audio.rs       | 127 ++++++++++++++--------------
 src/pages/display.rs     | 116 ++++++++++++-------------
 src/pages/input.rs       | 122 ++++++++++++---------------
 src/pages/layout.rs      |  51 +++++------
 src/pages/mod.rs         |   6 +-
 src/pages/network.rs     |  56 +++++++------
 src/pages/power.rs       |  77 +++++++++--------
 src/pages/processors.rs  |  89 ++++++++++++++++++++
 src/pages/status.rs      |  30 ++++---
 src/pages/storage.rs     |  39 +++++----
 src/pages/system_info.rs |  81 ++----------------
 14 files changed, 590 insertions(+), 423 deletions(-)

diff --git a/.gitignore b/.gitignore
index c4c6b78..d193af6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ target/
 *.swo
 *~
 .DS_Store
+.antigravitycli/
diff --git a/src/app.rs b/src/app.rs
index 9c3774d..4e98176 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -6,6 +6,7 @@ use crate::pages::input;
 use crate::pages::layout;
 use crate::pages::network;
 use crate::pages::power;
+use crate::pages::processors;
 use crate::pages::status;
 use crate::pages::storage;
 use crate::pages::system_info;
@@ -19,6 +20,7 @@ pub struct AppState {
     pub network: network::NetworkState,
     pub layout: layout::LayoutState,
     pub input: input::InputState,
+    pub processors: processors::ProcessorsState,
     pub system_info: system_info::SystemState,
     pub status: status::StatusState,
     pub storage: storage::StorageState,
@@ -34,6 +36,7 @@ impl Default for AppState {
             network: network::NetworkState::default(),
             layout: layout::LayoutState::default(),
             input: input::InputState::default(),
+            processors: processors::ProcessorsState::default(),
             system_info: system_info::SystemState::default(),
             status: status::StatusState::default(),
             storage: storage::StorageState::default(),
@@ -49,6 +52,7 @@ pub enum AppAction {
     Radios(network::NetworkMessage),
     Layout(layout::LayoutMessage),
     Input(input::InputMessage),
+    Processors(processors::ProcessorsMessage),
     SystemInfo(system_info::SystemMessage),
     Status(status::StatusMessage),
     Storage(storage::StorageMessage),
diff --git a/src/main.rs b/src/main.rs
index 47c2239..ceb52e1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,7 +6,7 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
 use winit::window::{Window, WindowAttributes};
 
 use clear_ui::color;
-use clear_ui::widget::Widget;
+use clear_ui::widget::{Spinbox, Widget};
 use glyphon::{
     Attrs, Buffer, Cache, FontSystem, Metrics, Resolution, SwashCache, TextArea, TextAtlas,
     TextBounds, TextRenderer, Viewport,
@@ -81,7 +81,7 @@ struct TextItem {
     color: glyphon::Color,
 }
 
-enum ColorPickerAction {
+enum ColorSelectorAction {
     Background([u8; 3]),
     Border([u8; 3]),
 }
@@ -121,11 +121,12 @@ struct SystemInterface {
     rx_network: std::sync::mpsc::Receiver<pages::network::NetworkState>,
     rx_layout: std::sync::mpsc::Receiver<pages::layout::LayoutState>,
     rx_input: std::sync::mpsc::Receiver<pages::input::InputState>,
+    rx_processors: std::sync::mpsc::Receiver<pages::processors::ProcessorsState>,
     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>,
+    tx_color_selector: std::sync::mpsc::Sender<ColorSelectorAction>,
+    rx_color_selector: std::sync::mpsc::Receiver<ColorSelectorAction>,
 
     scale_factor: f64,
     width: u32,
@@ -218,7 +219,7 @@ impl SystemInterface {
         });
 
         // ── Initial state (fetch all concurrently) ──
-        let (power, audio, display, network, system_info, status, storage) = tokio::join!(
+        let (power, audio, display, network, system_info, status, storage, processors) = tokio::join!(
             pages::power::fetch_power_state(),
             pages::audio::fetch_audio_state(),
             pages::display::fetch_display_state(),
@@ -226,19 +227,24 @@ impl SystemInterface {
             pages::system_info::fetch_system_state(),
             pages::status::fetch_status_state(),
             pages::storage::fetch_storage_state(),
+            pages::processors::fetch_processors_state(),
         );
-        let app = AppState {
+        let mut app = AppState {
             power,
             audio,
             display,
             network,
             layout: pages::layout::read_layout_config(),
             input: pages::input::read_input_config(),
+            processors,
             system_info,
             status,
             storage,
             current_page: Page::ALL[0],
         };
+        // Init spinbox vectors to match fetched sinks/sources
+        app.audio.sink_spinboxes.resize_with(app.audio.sinks.len(), || Spinbox::new(50, 0, 100, 1));
+        app.audio.source_spinboxes.resize_with(app.audio.sources.len(), || Spinbox::new(50, 0, 100, 1));
 
         // ── Background refresh channels ──
         fn spawn_bg<T, F>(period_secs: u64, f: fn() -> F) -> std::sync::mpsc::Receiver<T>
@@ -284,10 +290,11 @@ impl SystemInterface {
             rx
         };
         let rx_system = spawn_bg(5, || pages::system_info::fetch_system_state());
+        let rx_processors = spawn_bg(3, || pages::processors::fetch_processors_state());
         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 (tx_color_selector, rx_color_selector) = 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,
@@ -299,8 +306,8 @@ impl SystemInterface {
             cursor_x: 0.0, cursor_y: 0.0,
             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,
+            rx_processors, rx_system, rx_status, rx_storage,
+            tx_color_selector, rx_color_selector,
             width: size.width, height: size.height,
             needs_rebuild: true,
         };
@@ -405,9 +412,12 @@ impl SystemInterface {
                 hovering: false,
                 kind: WidgetKind::ActionButton(btn.action.clone()),
             });
+            let buf = make_text_buffer(&mut self.font_system, &btn.label, btn.label_size * s);
+            let tw = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0);
+            let lh = btn.label_size * s * 1.4;
             text_items.push(TextItem {
-                buffer: make_text_buffer(&mut self.font_system, &btn.label, btn.label_size * s),
-                x: btn.x * s + 10.0 * s, y: btn.y * s + 8.0 * s,
+                buffer: buf,
+                x: btn.x * s + (btn.w * s - tw) / 2.0, y: btn.y * s + (btn.h * s - lh) / 2.0,
                 color: glyphon::Color::rgb(
                     (btn.label_color[0] * 255.0) as u8,
                     (btn.label_color[1] * 255.0) as u8,
@@ -436,11 +446,12 @@ impl SystemInterface {
         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::Audio => audio::view(&mut self.app.audio, cx, cy, cw, ch),
+            Page::Display => display::view(&mut self.app.display, cx, cy, cw, ch),
             Page::Radios => network::view(&self.app.network, 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::Processors => processors::view(&self.app.processors, cx, cy, cw, ch),
+            Page::Input => input::view(&mut 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),
             Page::Storage => storage::view(&self.app.storage, cx, cy, cw, ch),
@@ -534,6 +545,10 @@ impl SystemInterface {
             system_info::update(&mut self.app.system_info, system_info::SystemMessage::Refreshed(s));
             self.needs_rebuild = true;
         }
+        while let Ok(s) = self.rx_processors.try_recv() {
+            processors::update(&mut self.app.processors, processors::ProcessorsMessage::Refreshed(s));
+            self.needs_rebuild = true;
+        }
         while let Ok(s) = self.rx_status.try_recv() {
             status::update(&mut self.app.status, status::StatusMessage::Refreshed(s));
             self.needs_rebuild = true;
@@ -542,12 +557,12 @@ 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() {
+        while let Ok(action) = self.rx_color_selector.try_recv() {
             match action {
-                ColorPickerAction::Background(rgb) => {
+                ColorSelectorAction::Background(rgb) => {
                     layout::update(&mut self.app.layout, layout::LayoutMessage::SetBackground(rgb));
                 }
-                ColorPickerAction::Border(rgb) => {
+                ColorSelectorAction::Border(rgb) => {
                     layout::update(&mut self.app.layout, layout::LayoutMessage::SetBorderColor(rgb));
                 }
             }
@@ -561,7 +576,7 @@ impl SystemInterface {
             AppAction::Layout(m) => match m {
                 layout::LayoutMessage::PickBackgroundColor => {
                     let color = self.app.layout.background_color;
-                    let tx = self.tx_color_picker.clone();
+                    let tx = self.tx_color_selector.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 {
@@ -570,14 +585,14 @@ impl SystemInterface {
                                 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]));
+                                let _ = tx.send(ColorSelectorAction::Background([r, g, b]));
                             }
                         }
                     });
                 }
                 layout::LayoutMessage::PickBorderColor => {
                     let color = self.app.layout.border_color;
-                    let tx = self.tx_color_picker.clone();
+                    let tx = self.tx_color_selector.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 {
@@ -586,7 +601,7 @@ impl SystemInterface {
                                 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]));
+                                let _ = tx.send(ColorSelectorAction::Border([r, g, b]));
                             }
                         }
                     });
@@ -599,6 +614,7 @@ impl SystemInterface {
             AppAction::Radios(m) => network::update(&mut self.app.network, 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::Processors(m) => processors::update(&mut self.app.processors, m.clone()),
             AppAction::Status(m) => status::update(&mut self.app.status, m.clone()),
             AppAction::Storage(m) => storage::update(&mut self.app.storage, m.clone()),
         }
@@ -626,12 +642,43 @@ impl SystemInterface {
                             changed = true;
                         }
                     }
-                    for cp in &mut self.app.layout.color_pickers {
+                    for cp in &mut self.app.layout.color_selectors {
                         if cp.cursor_moved(self.cursor_x / s, self.cursor_y / s) {
                             changed = true;
                         }
                     }
                 }
+                if self.app.current_page == Page::Input {
+                    let s = self.scale_factor as f32;
+                    if self.app.input.rate_spinbox.cursor_moved(self.cursor_x / s, self.cursor_y / s) {
+                        changed = true;
+                    }
+                    if self.app.input.delay_spinbox.cursor_moved(self.cursor_x / s, self.cursor_y / s) {
+                        changed = true;
+                    }
+                    if self.app.input.tap_toggle.cursor_moved(self.cursor_x / s, self.cursor_y / s) {
+                        changed = true;
+                    }
+                }
+                if self.app.current_page == Page::Audio {
+                    let s = self.scale_factor as f32;
+                    for sb in &mut self.app.audio.sink_spinboxes {
+                        if sb.cursor_moved(self.cursor_x / s, self.cursor_y / s) {
+                            changed = true;
+                        }
+                    }
+                    for sb in &mut self.app.audio.source_spinboxes {
+                        if sb.cursor_moved(self.cursor_x / s, self.cursor_y / s) {
+                            changed = true;
+                        }
+                    }
+                }
+                if self.app.current_page == Page::Display {
+                    let s = self.scale_factor as f32;
+                    if self.app.display.brightness_spinbox.cursor_moved(self.cursor_x / s, self.cursor_y / s) {
+                        changed = true;
+                    }
+                }
                 if changed { self.needs_rebuild = true; }
                 changed
             }
@@ -644,7 +691,7 @@ impl SystemInterface {
                             changed = true;
                         }
                     }
-                    for (i, cp) in self.app.layout.color_pickers.iter_mut().enumerate() {
+                    for (i, cp) in self.app.layout.color_selectors.iter_mut().enumerate() {
                         let old = cp.color;
                         if cp.keyboard_input(event) {
                             if cp.color != old {
@@ -664,6 +711,59 @@ impl SystemInterface {
                         return true;
                     }
                 }
+                if self.app.current_page == Page::Input {
+                    if self.app.input.rate_spinbox.keyboard_input(event) {
+                        self.handle_action(&AppAction::Input(pages::input::InputMessage::ApplyRepeat));
+                        self.needs_rebuild = true;
+                        return true;
+                    }
+                    if self.app.input.delay_spinbox.keyboard_input(event) {
+                        self.handle_action(&AppAction::Input(pages::input::InputMessage::ApplyRepeat));
+                        self.needs_rebuild = true;
+                        return true;
+                    }
+                }
+                if self.app.current_page == Page::Audio {
+                    let mut actions = Vec::new();
+                    for (i, sb) in self.app.audio.sink_spinboxes.iter_mut().enumerate() {
+                        let old = sb.value;
+                        if sb.keyboard_input(event) {
+                            if sb.value != old {
+                                let id = self.app.audio.sinks[i].id;
+                                actions.push(AppAction::Audio(pages::audio::AudioMessage::SinkVolume(id, sb.value as f32 / 100.0)));
+                            }
+                        }
+                    }
+                    for (i, sb) in self.app.audio.source_spinboxes.iter_mut().enumerate() {
+                        let old = sb.value;
+                        if sb.keyboard_input(event) {
+                            if sb.value != old {
+                                let id = self.app.audio.sources[i].id;
+                                actions.push(AppAction::Audio(pages::audio::AudioMessage::SourceVolume(id, sb.value as f32 / 100.0)));
+                            }
+                        }
+                    }
+                    for a in &actions {
+                        self.handle_action(a);
+                    }
+                    if !actions.is_empty() {
+                        self.needs_rebuild = true;
+                        return true;
+                    }
+                }
+                if self.app.current_page == Page::Display {
+                    let sb = &mut self.app.display.brightness_spinbox;
+                    let old = sb.value;
+                    if sb.keyboard_input(event) {
+                        let new_val = sb.value;
+                        drop(sb);
+                        if new_val != old {
+                            self.handle_action(&AppAction::Display(pages::display::DisplayMessage::BrightnessSet(new_val as u32)));
+                        }
+                        self.needs_rebuild = true;
+                        return true;
+                    }
+                }
                 false
             }
             WindowEvent::MouseInput { state, button, .. } => {
@@ -689,11 +789,11 @@ impl SystemInterface {
                         }
                     }
                 }
+                let s = self.scale_factor as f32;
+                let lx = self.cursor_x / s;
+                let ly = self.cursor_y / s;
+                let mut actions = Vec::new();
                 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;
@@ -706,7 +806,7 @@ impl SystemInterface {
                             ));
                         }
                     }
-                    for (i, cp) in self.app.layout.color_pickers.iter_mut().enumerate() {
+                    for (i, cp) in self.app.layout.color_selectors.iter_mut().enumerate() {
                         let old = cp.color;
                         if !cp.hit_test(lx, ly) { cp.unfocus(); }
                         cp.mouse_input(*button, *state, lx, ly);
@@ -723,14 +823,62 @@ impl SystemInterface {
                             }));
                         }
                     }
-                    for a in &actions {
-                        self.handle_action(a);
+                }
+                if *state == ElementState::Pressed && self.app.current_page == Page::Input {
+                    let sb = &mut self.app.input.rate_spinbox;
+                    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::Input(pages::input::InputMessage::ApplyRepeat));
                     }
-                    if !actions.is_empty() {
-                        self.needs_rebuild = true;
-                        return true;
+                    let sb = &mut self.app.input.delay_spinbox;
+                    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::Input(pages::input::InputMessage::ApplyRepeat));
+                    }
+                }
+                if self.app.current_page == Page::Input {
+                    let toggle = &mut self.app.input.tap_toggle;
+                    toggle.mouse_input(*button, *state, lx, ly);
+                    if toggle.take_click() {
+                        actions.push(AppAction::Input(pages::input::InputMessage::ToggleTapToClick));
+                    }
+                }
+                if *state == ElementState::Pressed && self.app.current_page == Page::Audio {
+                    for (i, sb) in self.app.audio.sink_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 {
+                            let id = self.app.audio.sinks[i].id;
+                            actions.push(AppAction::Audio(pages::audio::AudioMessage::SinkVolume(id, sb.value as f32 / 100.0)));
+                        }
+                    }
+                    for (i, sb) in self.app.audio.source_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 {
+                            let id = self.app.audio.sources[i].id;
+                            actions.push(AppAction::Audio(pages::audio::AudioMessage::SourceVolume(id, sb.value as f32 / 100.0)));
+                        }
                     }
                 }
+                if *state == ElementState::Pressed && self.app.current_page == Page::Display {
+                    let sb = &mut self.app.display.brightness_spinbox;
+                    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::Display(pages::display::DisplayMessage::BrightnessSet(sb.value as u32)));
+                    }
+                }
+                for a in &actions {
+                    self.handle_action(a);
+                }
+                if !actions.is_empty() {
+                    self.needs_rebuild = true;
+                    return true;
+                }
+                self.needs_rebuild = true;
                 self.needs_rebuild = true;
                 true
             }
diff --git a/src/pages/audio.rs b/src/pages/audio.rs
index 6608676..4af8283 100644
--- a/src/pages/audio.rs
+++ b/src/pages/audio.rs
@@ -1,4 +1,6 @@
 use crate::app::{AppAction, PageContent};
+use clear_ui::layout::{render_widget, Section};
+use clear_ui::widget::Spinbox;
 
 #[derive(Debug, Clone)]
 pub struct AudioSink {
@@ -22,6 +24,8 @@ pub struct AudioSource {
 pub struct AudioState {
     pub sinks: Vec<AudioSink>,
     pub sources: Vec<AudioSource>,
+    pub sink_spinboxes: Vec<Spinbox>,
+    pub source_spinboxes: Vec<Spinbox>,
 }
 
 #[derive(Debug, Clone)]
@@ -113,7 +117,7 @@ pub async fn fetch_audio_state() -> AudioState {
     let connected = drm_connected_ports();
     let sinks = fetch_sinks(&connected).await;
     let sources = fetch_sources(&connected).await;
-    AudioState { sinks, sources }
+    AudioState { sinks, sources, sink_spinboxes: Vec::new(), source_spinboxes: Vec::new() }
 }
 
 async fn fetch_sinks(connected_ports: &[String]) -> Vec<AudioSink> {
@@ -195,29 +199,26 @@ async fn fetch_sources(connected_ports: &[String]) -> Vec<AudioSource> {
 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 MUTED_BG: [f32; 4] = [0.33, 0.20, 0.20, 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 BLANK_BAR: [f32; 4] = [0.15, 0.15, 0.24, 1.0];
 const FILL_BAR: [f32; 4] = [0.30, 0.50, 0.32, 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];
 const RED: [f32; 4] = [1.0, 0.33, 0.33, 1.0];
 
-pub fn view(state: &AudioState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
+pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
     let mut pc = PageContent::new();
     let mut y = cy + 12.0;
 
     // ── Output section ──
-    pc.text("Output", cx + 12.0, y, 14.0, TEXT_FG);
-    y += 22.0;
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Output");
 
     if state.sinks.is_empty() {
-        pc.text("No output devices found", cx + 12.0, y, 12.0, TEXT_DIM);
-        y += 18.0;
+        sec.text(&mut pc, "No output devices found", 12.0, 0.0, 12.0, TEXT_DIM);
+        sec.spacing(18.0);
     }
 
-    for sink in &state.sinks {
+    for (idx, sink) in state.sinks.iter().enumerate() {
         let label = if !sink.active {
             format!("{}  (inactive)", sink.name)
         } else if sink.muted {
@@ -226,57 +227,49 @@ pub fn view(state: &AudioState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCont
             format!("{}  {:.0}%", sink.name, sink.volume * 100.0)
         };
         let lc = if sink.muted { RED } else { TEXT_FG };
-        pc.text(&label, cx + 14.0, y, 13.0, lc);
-        y += 18.0;
+        sec.text(&mut pc, &label, 14.0, 0.0, 13.0, lc);
+        sec.spacing(18.0);
 
         if sink.active {
-            // Volume bar (visual only — we use +/- buttons)
             let bar_w = cw - 100.0;
-            let bar_x = cx + 14.0;
-            pc.rect(BLANK_BAR, bar_x, y, bar_w, 8.0);
-            pc.rect(FILL_BAR, bar_x, y, bar_w * sink.volume, 8.0);
-            pc.text(&format!("{:.0}%", sink.volume * 100.0), bar_x + bar_w + 8.0, y - 2.0, 11.0, TEXT_DIM);
+            let bar_x = 14.0;
+            let yt = sec.ay();
+            pc.rect(BLANK_BAR, sec.ax(bar_x), yt, bar_w, 8.0);
+            pc.rect(FILL_BAR, sec.ax(bar_x), yt, bar_w * sink.volume, 8.0);
+            sec.text(&mut pc, &format!("{:.0}%", sink.volume * 100.0), bar_x + bar_w + 8.0, -2.0, 11.0, TEXT_DIM);
 
-            // Buttons
-            let btn_h = 28.0;
-            let btn_y = y + 12.0;
-            let mut bx = cx + 14.0;
+            let row_y = sec.ay() + 12.0;
+            let sb_w = 100.0;
+            let sb_h = 26.0;
+            let mute_w = 60.0;
+            let gap = 8.0;
 
-            pc.button("-10", bx, btn_y, 36.0, btn_h,
-                BTN_INACTIVE, BTN_HOVER, WHITE,
-                AppAction::Audio(AudioMessage::SinkVolume(sink.id, (sink.volume - 0.10).max(0.0))));
-            bx += 40.0;
+            state.sink_spinboxes[idx].value = (sink.volume * 100.0).round() as i32;
+            render_widget(&mut pc, &mut state.sink_spinboxes[idx], sec.ax(bar_x), row_y, sb_w, sb_h);
 
             let mute_label = if sink.muted { "Unmute" } else { "Mute" };
-            let mute_bg = if sink.muted { MUTED_BG } else { BTN_INACTIVE };
-            pc.button(mute_label, bx, btn_y, 52.0, btn_h,
-                mute_bg, BTN_HOVER, WHITE,
+            let mute_col = if sink.muted { MUTED_BG } else { BTN_INACTIVE };
+            pc.button(mute_label, sec.ax(bar_x) + sb_w + gap, row_y, mute_w, sb_h,
+                mute_col, BTN_HOVER, WHITE,
                 AppAction::Audio(AudioMessage::SinkMute(sink.id)));
-            bx += 56.0;
 
-            pc.button("+10", bx, btn_y, 36.0, btn_h,
-                BTN_ACTIVE, BTN_HOVER, WHITE,
-                AppAction::Audio(AudioMessage::SinkVolume(sink.id, (sink.volume + 0.10).min(1.0))));
-
-            y = btn_y + btn_h + 6.0;
+            sec.content_y += 12.0 + sb_h + 6.0;
         } else {
-            y += 6.0;
+            sec.content_y += 6.0;
         }
     }
 
+    y = sec.finish(&mut pc);
+
     // ── Input section ──
-    y += 8.0;
-    pc.rect(SECTION_BORDER, cx + 8.0, y, cw - 16.0, 1.0);
-    y += 8.0;
-    pc.text("Input", cx + 12.0, y, 14.0, TEXT_FG);
-    y += 22.0;
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Input");
 
     if state.sources.is_empty() {
-        pc.text("No input devices found", cx + 12.0, y, 12.0, TEXT_DIM);
-        y += 18.0;
+        sec.text(&mut pc, "No input devices found", 12.0, 0.0, 12.0, TEXT_DIM);
+        sec.spacing(18.0);
     }
 
-    for src in &state.sources {
+    for (idx, src) in state.sources.iter().enumerate() {
         let label = if !src.active {
             format!("{}  (inactive)", src.name)
         } else if src.muted {
@@ -285,48 +278,50 @@ pub fn view(state: &AudioState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCont
             format!("{}  {:.0}%", src.name, src.volume * 100.0)
         };
         let lc = if src.muted { RED } else { TEXT_FG };
-        pc.text(&label, cx + 14.0, y, 13.0, lc);
-        y += 18.0;
+        sec.text(&mut pc, &label, 14.0, 0.0, 13.0, lc);
+        sec.spacing(18.0);
 
         if src.active {
             let bar_w = cw - 100.0;
-            let bar_x = cx + 14.0;
-            pc.rect(BLANK_BAR, bar_x, y, bar_w, 8.0);
-            pc.rect(FILL_BAR, bar_x, y, bar_w * src.volume, 8.0);
-            pc.text(&format!("{:.0}%", src.volume * 100.0), bar_x + bar_w + 8.0, y - 2.0, 11.0, TEXT_DIM);
+            let bar_x = 14.0;
+            let yt = sec.ay();
+            pc.rect(BLANK_BAR, sec.ax(bar_x), yt, bar_w, 8.0);
+            pc.rect(FILL_BAR, sec.ax(bar_x), yt, bar_w * src.volume, 8.0);
+            sec.text(&mut pc, &format!("{:.0}%", src.volume * 100.0), bar_x + bar_w + 8.0, -2.0, 11.0, TEXT_DIM);
 
-            let btn_h = 28.0;
-            let btn_y = y + 12.0;
-            let mut bx = cx + 14.0;
+            let row_y = sec.ay() + 12.0;
+            let sb_w = 100.0;
+            let sb_h = 26.0;
+            let mute_w = 60.0;
+            let gap = 8.0;
 
-            pc.button("-10", bx, btn_y, 36.0, btn_h,
-                BTN_INACTIVE, BTN_HOVER, WHITE,
-                AppAction::Audio(AudioMessage::SourceVolume(src.id, (src.volume - 0.10).max(0.0))));
-            bx += 40.0;
+            state.source_spinboxes[idx].value = (src.volume * 100.0).round() as i32;
+            render_widget(&mut pc, &mut state.source_spinboxes[idx], sec.ax(bar_x), row_y, sb_w, sb_h);
 
             let mute_label = if src.muted { "Unmute" } else { "Mute" };
-            let mute_bg = if src.muted { MUTED_BG } else { BTN_INACTIVE };
-            pc.button(mute_label, bx, btn_y, 52.0, btn_h,
-                mute_bg, BTN_HOVER, WHITE,
+            let mute_col = if src.muted { MUTED_BG } else { BTN_INACTIVE };
+            pc.button(mute_label, sec.ax(bar_x) + sb_w + gap, row_y, mute_w, sb_h,
+                mute_col, BTN_HOVER, WHITE,
                 AppAction::Audio(AudioMessage::SourceMute(src.id)));
-            bx += 56.0;
-
-            pc.button("+10", bx, btn_y, 36.0, btn_h,
-                BTN_ACTIVE, BTN_HOVER, WHITE,
-                AppAction::Audio(AudioMessage::SourceVolume(src.id, (src.volume + 0.10).min(1.0))));
 
-            y = btn_y + btn_h + 6.0;
+            sec.content_y += 12.0 + sb_h + 6.0;
         } else {
-            y += 6.0;
+            sec.content_y += 6.0;
         }
     }
 
+    sec.finish(&mut pc);
+
     pc
 }
 
 pub fn update(state: &mut AudioState, msg: AudioMessage) {
     match msg {
-        AudioMessage::Refreshed(new) => { *state = new; }
+        AudioMessage::Refreshed(new) => {
+            *state = new;
+            state.sink_spinboxes.resize_with(state.sinks.len(), || Spinbox::new(50, 0, 100, 1));
+            state.source_spinboxes.resize_with(state.sources.len(), || Spinbox::new(50, 0, 100, 1));
+        }
         AudioMessage::SinkVolume(id, vol) => {
             if let Some(sink) = state.sinks.iter_mut().find(|s| s.id == id) {
                 sink.volume = vol;
diff --git a/src/pages/display.rs b/src/pages/display.rs
index a23cb0f..cf47cdd 100644
--- a/src/pages/display.rs
+++ b/src/pages/display.rs
@@ -1,4 +1,6 @@
-use crate::app::{AppAction, PageContent};
+use crate::app::PageContent;
+use clear_ui::layout::{render_widget, Section};
+use clear_ui::widget::Spinbox;
 
 #[derive(Debug, Clone)]
 pub struct DisplayOutput {
@@ -9,26 +11,44 @@ pub struct DisplayOutput {
     pub connected: bool,
 }
 
-#[derive(Debug, Clone, Default)]
+#[derive(Debug, Clone)]
 pub struct DisplayState {
     pub brightness: f32,
     pub max_brightness: f32,
     pub outputs: Vec<DisplayOutput>,
     pub night_light: bool,
+    pub brightness_spinbox: Spinbox,
+}
+
+impl Default for DisplayState {
+    fn default() -> Self {
+        Self {
+            brightness: 0.0,
+            max_brightness: 0.0,
+            outputs: Vec::new(),
+            night_light: false,
+            brightness_spinbox: Spinbox::new(50, 0, 100, 5),
+        }
+    }
 }
 
 #[derive(Debug, Clone)]
 pub enum DisplayMessage {
     Refreshed(DisplayState),
-    BrightnessDecrement,
-    BrightnessIncrement,
+    BrightnessSet(u32),
 }
 
 pub async fn fetch_display_state() -> DisplayState {
     let (brightness, max_brightness) = fetch_brightness().await;
     let outputs = fetch_outputs().await;
     let night_light = is_night_light_on().await;
-    DisplayState { brightness, max_brightness, outputs, night_light }
+    let pct = if max_brightness > 0.0 {
+        (brightness / max_brightness * 100.0).round() as i32
+    } else { 50 };
+    DisplayState {
+        brightness, max_brightness, outputs, night_light,
+        brightness_spinbox: Spinbox::new(pct, 0, 100, 5),
+    }
 }
 
 async fn fetch_brightness() -> (f32, f32) {
@@ -98,56 +118,43 @@ fn spawn_brightness(pct: u32) {
 
 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 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 BLANK_BAR: [f32; 4] = [0.15, 0.15, 0.24, 1.0];
 const FILL_BAR: [f32; 4] = [0.30, 0.50, 0.32, 1.0];
-const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
 
-pub fn view(state: &DisplayState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
+pub fn view(state: &mut DisplayState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
     let mut pc = PageContent::new();
     let mut y = cy + 12.0;
 
     // ── Brightness ──
-    pc.text("Brightness", cx + 12.0, y, 14.0, TEXT_FG);
-    y += 22.0;
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Brightness");
 
     let bright_pct = if state.max_brightness > 0.0 {
-        (state.brightness / state.max_brightness * 100.0) as u32
+        (state.brightness / state.max_brightness * 100.0).round() as i32
     } else { 0 };
 
     let bar_w = cw - 100.0;
-    pc.rect(BLANK_BAR, cx + 12.0, y, bar_w, 8.0);
-    pc.rect(FILL_BAR, cx + 12.0, y, bar_w * bright_pct as f32 / 100.0, 8.0);
-    pc.text(&format!("{}%", bright_pct), cx + 16.0 + bar_w, y - 2.0, 11.0, TEXT_DIM);
-    y += 14.0;
-
-    let btn_h = 28.0;
-    let btn_y = y;
-    pc.button("-10", cx + 12.0, btn_y, 36.0, btn_h,
-        BTN_INACTIVE, BTN_HOVER, WHITE,
-        AppAction::Display(DisplayMessage::BrightnessDecrement));
-    pc.text("brightnessctl set", cx + 56.0, btn_y + 8.0, 10.0, TEXT_DIM);
-    pc.button("+10", cx + 12.0 + bar_w - 36.0, btn_y, 36.0, btn_h,
-        BTN_ACTIVE, BTN_HOVER, WHITE,
-        AppAction::Display(DisplayMessage::BrightnessIncrement));
-
-    y = btn_y + btn_h + 12.0;
+    let yt = sec.ay();
+    pc.rect(BLANK_BAR, sec.ax(12.0), yt, bar_w, 8.0);
+    pc.rect(FILL_BAR, sec.ax(12.0), yt, bar_w * bright_pct as f32 / 100.0, 8.0);
+    pc.text(&format!("{}%", bright_pct), sec.ax(16.0 + bar_w), yt - 2.0, 11.0, TEXT_DIM);
+    sec.content_y += 14.0;
+
+    let yt = sec.ay();
+    let sb_w = 100.0;
+    let sb_h = 26.0;
+    state.brightness_spinbox.value = bright_pct;
+    render_widget(&mut pc, &mut state.brightness_spinbox, sec.ax(12.0), yt, sb_w, sb_h);
+    sec.content_y += sb_h + 12.0;
+    y = sec.finish(&mut pc);
 
     // ── Night Light ──
-    pc.rect(SECTION_BORDER, cx + 8.0, y, cw - 16.0, 1.0);
-    y += 8.0;
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Night Light");
     let nl_label = if state.night_light { "Night Light: ON" } else { "Night Light: OFF" };
-    pc.text(nl_label, cx + 12.0, y, 13.0, TEXT_FG);
-    y += 24.0;
+    sec.text(&mut pc, nl_label, 12.0, 0.0, 13.0, TEXT_FG);
+    y = sec.finish(&mut pc);
 
     // ── Outputs ──
-    pc.rect(SECTION_BORDER, cx + 8.0, y, cw - 16.0, 1.0);
-    y += 8.0;
-    pc.text("Outputs", cx + 12.0, y, 14.0, TEXT_FG);
-    y += 22.0;
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Outputs");
 
     for out in &state.outputs {
         if out.connected {
@@ -158,13 +165,14 @@ pub fn view(state: &DisplayState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCo
                     } else { format!("  scale {:.0}x", out.scale) }
                 } else { String::new() }
             } else { String::new() };
-            pc.text(&format!("{}  {} @ {}Hz{}", out.name, out.resolution, out.refresh, scale_info),
-                cx + 14.0, y, 12.0, TEXT_FG);
+            sec.text(&mut pc, &format!("{}  {} @ {}Hz{}", out.name, out.resolution, out.refresh, scale_info),
+                14.0, 0.0, 12.0, TEXT_FG);
         } else {
-            pc.text(&format!("{}  (disconnected)", out.name), cx + 14.0, y, 12.0, TEXT_DIM);
+            sec.text(&mut pc, &format!("{}  (disconnected)", out.name), 14.0, 0.0, 12.0, TEXT_DIM);
         }
-        y += 18.0;
+        sec.spacing(18.0);
     }
+    sec.finish(&mut pc);
 
     pc
 }
@@ -172,25 +180,11 @@ pub fn view(state: &DisplayState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCo
 pub fn update(state: &mut DisplayState, msg: DisplayMessage) {
     match msg {
         DisplayMessage::Refreshed(new) => { *state = new; }
-        DisplayMessage::BrightnessDecrement => {
-            let pct = if state.max_brightness > 0.0 {
-                (state.brightness / state.max_brightness * 100.0) as u32
-            } else { 0 };
-            if pct > 0 {
-                let new_pct = pct.saturating_sub(10).max(0);
-                state.brightness = new_pct as f32 / 100.0 * state.max_brightness;
-                spawn_brightness(new_pct);
-            }
-        }
-        DisplayMessage::BrightnessIncrement => {
-            let pct = if state.max_brightness > 0.0 {
-                (state.brightness / state.max_brightness * 100.0) as u32
-            } else { 0 };
-            if pct < 100 {
-                let new_pct = (pct + 10).min(100);
-                state.brightness = new_pct as f32 / 100.0 * state.max_brightness;
-                spawn_brightness(new_pct);
-            }
+        DisplayMessage::BrightnessSet(pct) => {
+            let pct = pct.clamp(0, 100);
+            state.brightness = pct as f32 / 100.0 * state.max_brightness;
+            spawn_brightness(pct);
+            state.brightness_spinbox.value = pct as i32;
         }
     }
 }
diff --git a/src/pages/input.rs b/src/pages/input.rs
index dafef86..116c73d 100644
--- a/src/pages/input.rs
+++ b/src/pages/input.rs
@@ -2,6 +2,8 @@ use std::fs;
 use std::io::Write;
 
 use crate::app::{AppAction, PageContent};
+use clear_ui::layout::{render_widget, Section};
+use clear_ui::widget::{Spinbox, Toggle};
 
 const CONFIG_PATH: &str = "/home/lsgalante/.config/clearwm/config.toml";
 const CLEARWM_SOCK: &str = "/tmp/clearwm.sock";
@@ -19,31 +21,45 @@ pub struct InputState {
     pub tap_to_click: bool,
     pub repeat_rate: u16,
     pub repeat_delay: u16,
+    pub rate_spinbox: Spinbox,
+    pub delay_spinbox: Spinbox,
+    pub tap_toggle: Toggle,
     pub keybinds: Vec<Keybind>,
 }
 
 impl Default for InputState {
     fn default() -> Self {
-        Self { tap_to_click: false, repeat_rate: 50, repeat_delay: 300, keybinds: Vec::new() }
+        Self {
+            tap_to_click: false,
+            repeat_rate: 50,
+            repeat_delay: 300,
+            rate_spinbox: Spinbox::new(50, 1, 100, 1).with_label("Repeat Rate"),
+            delay_spinbox: Spinbox::new(300, 100, 2000, 10).with_label("Repeat Delay"),
+        tap_toggle: Toggle::new().with_label("Tap to Click"),
+            keybinds: Vec::new(),
+        }
     }
 }
 
 #[derive(Debug, Clone)]
 pub enum InputMessage {
     ToggleTapToClick,
-    RepeatRateDown,
-    RepeatRateUp,
-    RepeatDelayDown,
-    RepeatDelayUp,
+    ApplyRepeat,
     Refreshed(InputState),
 }
 
 pub fn read_input_config() -> InputState {
     let content = fs::read_to_string(CONFIG_PATH).unwrap_or_default();
+    let rate = parse_u16_key(&content, "rate", 50);
+    let delay = parse_u16_key(&content, "delay", 300);
+    let tap = parse_bool_from(&content, "tap_to_click");
     InputState {
-        tap_to_click: parse_bool_from(&content, "tap_to_click"),
-        repeat_rate: parse_u16_key(&content, "rate", 50),
-        repeat_delay: parse_u16_key(&content, "delay", 300),
+        tap_to_click: tap,
+        repeat_rate: rate,
+        repeat_delay: delay,
+        rate_spinbox: Spinbox::new(rate as i32, 1, 100, 1).with_label("Repeat Rate"),
+        delay_spinbox: Spinbox::new(delay as i32, 100, 2000, 10).with_label("Repeat Delay"),
+        tap_toggle: Toggle::new().with_label("Tap to Click"),
         keybinds: parse_keybinds(&content),
     }
 }
@@ -147,62 +163,38 @@ fn apply_repeat_config(rate: u16, delay: u16) {
 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 TOGGLE_ON: [f32; 4] = [0.16, 0.41, 0.18, 1.0];
 const TOGGLE_OFF: [f32; 4] = [0.16, 0.16, 0.24, 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: &InputState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
+pub fn view(state: &mut InputState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
     let mut pc = PageContent::new();
     let mut y = cy + 12.0;
 
     // ── Touchpad ──
-    pc.text("Touchpad", cx + 12.0, y, 14.0, TEXT_FG);
-    y += 22.0;
-
-    let tap_bg = if state.tap_to_click { TOGGLE_ON } else { TOGGLE_OFF };
-    let tap_label = if state.tap_to_click { "Tap to Click: ON" } else { "Tap to Click: OFF" };
-    pc.text(tap_label, cx + 14.0, y + 6.0, 13.0, if state.tap_to_click { ACCENT } else { TEXT_DIM });
-    let btn_w = (cw - 32.0).min(100.0);
-    pc.button(if state.tap_to_click { "ON" } else { "OFF" }, cx + cw - btn_w - 14.0, y, btn_w, 28.0,
-        tap_bg, BTN_HOVER, WHITE,
-        AppAction::Input(InputMessage::ToggleTapToClick));
-    y += 36.0;
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Touchpad");
+
+    let yt = sec.ay();
+    let toggle_w = 48.0;
+    let toggle_h = 24.0;
+    state.tap_toggle.set_toggled(state.tap_to_click);
+    render_widget(&mut pc, &mut state.tap_toggle, sec.ax(100.0), yt, toggle_w, toggle_h);
+    sec.content_y += toggle_h + 12.0;
+    y = sec.finish(&mut pc);
 
     // ── Keyboard ──
-    pc.rect(SECTION_BORDER, cx + 8.0, y, cw - 16.0, 1.0);
-    y += 8.0;
-    pc.text("Keyboard", cx + 12.0, y, 14.0, TEXT_FG);
-    y += 22.0;
-
-    // Repeat Rate
-    pc.text(&format!("Repeat Rate: {} /sec", state.repeat_rate), cx + 14.0, y, 12.0, TEXT_DIM);
-    y += 18.0;
-    pc.button("-1", cx + 14.0, y, 36.0, 28.0, BTN_INACTIVE, BTN_HOVER, WHITE,
-        AppAction::Input(InputMessage::RepeatRateDown));
-    pc.text(&format!(" {} ", state.repeat_rate), cx + 58.0, y + 7.0, 13.0, TEXT_FG);
-    pc.button("+1", cx + 14.0 + 36.0 + 8.0, y, 36.0, 28.0, BTN_ACTIVE, BTN_HOVER, WHITE,
-        AppAction::Input(InputMessage::RepeatRateUp));
-    y += 34.0;
-
-    // Repeat Delay
-    pc.text(&format!("Repeat Delay: {}ms", state.repeat_delay), cx + 14.0, y, 12.0, TEXT_DIM);
-    y += 18.0;
-    pc.button("-10", cx + 14.0, y, 36.0, 28.0, BTN_INACTIVE, BTN_HOVER, WHITE,
-        AppAction::Input(InputMessage::RepeatDelayDown));
-    pc.text(&format!(" {}ms ", state.repeat_delay), cx + 58.0, y + 7.0, 13.0, TEXT_FG);
-    pc.button("+10", cx + 14.0 + 36.0 + 8.0, y, 36.0, 28.0, BTN_ACTIVE, BTN_HOVER, WHITE,
-        AppAction::Input(InputMessage::RepeatDelayUp));
-    y += 40.0;
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Keyboard");
+
+    sec.widget(&mut pc, &mut state.rate_spinbox, 14.0, 200.0, 26.0);
+    sec.spacing(8.0);
+
+    sec.widget(&mut pc, &mut state.delay_spinbox, 14.0, 200.0, 26.0);
+    sec.spacing(8.0);
+    y = sec.finish(&mut pc);
 
     // ── Keybindings ──
-    pc.rect(SECTION_BORDER, cx + 8.0, y, cw - 16.0, 1.0);
-    y += 8.0;
-    pc.text("Keyboard Bindings", cx + 12.0, y, 14.0, TEXT_FG);
-    y += 22.0;
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Keyboard Bindings");
 
     for kb in &state.keybinds {
         let binding = if kb.mods.is_empty() {
@@ -215,11 +207,12 @@ pub fn view(state: &InputState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCont
         } else {
             format!("{}: {}", kb.action, kb.command)
         };
-        pc.text(&binding, cx + 14.0, y, 12.0, TEXT_FG);
+        sec.text(&mut pc, &binding, 14.0, 0.0, 12.0, TEXT_FG);
         let label_w = cw - 200.0;
-        pc.text(&action_label, cx + 14.0 + label_w.min(180.0), y, 12.0, TEXT_DIM);
-        y += 18.0;
+        sec.text(&mut pc, &action_label, 14.0 + label_w.min(180.0), 0.0, 12.0, TEXT_DIM);
+        sec.spacing(18.0);
     }
+    sec.finish(&mut pc);
 
     pc
 }
@@ -230,21 +223,12 @@ pub fn update(state: &mut InputState, msg: InputMessage) {
             state.tap_to_click = !state.tap_to_click;
             write_tap_to_click(state.tap_to_click);
         }
-        InputMessage::RepeatRateDown => {
-            if state.repeat_rate > 1 { state.repeat_rate -= 1; }
-            apply_repeat_config(state.repeat_rate, state.repeat_delay);
-        }
-        InputMessage::RepeatRateUp => {
-            if state.repeat_rate < 100 { state.repeat_rate += 1; }
-            apply_repeat_config(state.repeat_rate, state.repeat_delay);
-        }
-        InputMessage::RepeatDelayDown => {
-            if state.repeat_delay > 100 { state.repeat_delay -= 10; }
-            apply_repeat_config(state.repeat_rate, state.repeat_delay);
-        }
-        InputMessage::RepeatDelayUp => {
-            if state.repeat_delay < 2000 { state.repeat_delay += 10; }
-            apply_repeat_config(state.repeat_rate, state.repeat_delay);
+        InputMessage::ApplyRepeat => {
+            let rate = state.rate_spinbox.value.max(1).min(100) as u16;
+            let delay = state.delay_spinbox.value.max(100).min(2000) as u16;
+            state.repeat_rate = rate;
+            state.repeat_delay = delay;
+            apply_repeat_config(rate, delay);
         }
         InputMessage::Refreshed(new) => { *state = new; }
     }
diff --git a/src/pages/layout.rs b/src/pages/layout.rs
index e940f50..bf1c279 100644
--- a/src/pages/layout.rs
+++ b/src/pages/layout.rs
@@ -2,8 +2,8 @@ use std::fs;
 use std::io::Write;
 
 use crate::app::PageContent;
-use clear_ui::layout::Column;
-use clear_ui::widget::{ColorPicker, Spinbox};
+use clear_ui::layout::Section;
+use clear_ui::widget::{ColorSelector, Spinbox};
 
 const CONFIG_PATH: &str = "/home/lsgalante/.config/clearwm/config.toml";
 const CLEARWM_SOCK: &str = "/tmp/clearwm.sock";
@@ -63,7 +63,7 @@ pub struct LayoutState {
     pub floating_border_width: u16,
     pub color_options: Vec<(&'static str, [u8; 3])>,
     pub spinboxes: Vec<Spinbox>,
-    pub color_pickers: Vec<ColorPicker>,
+    pub color_selectors: Vec<ColorSelector>,
 }
 
 impl Default for LayoutState {
@@ -79,9 +79,9 @@ impl Default for LayoutState {
             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"),
+            color_selectors: vec![
+                ColorSelector::new([0x0a, 0x1a, 0x0e]).with_label("Desktop Background"),
+                ColorSelector::new([0x3e, 0x3e, 0x3e]).with_label("Border Color"),
             ],
         }
     }
@@ -133,10 +133,10 @@ pub fn read_layout_config() -> LayoutState {
         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]))
+        color_selectors: vec![
+            ColorSelector::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]))
+            ColorSelector::new(parse_color_from_key(&content, "border_color", [0x3e, 0x3e, 0x3e]))
                 .with_label("Border Color"),
         ],
     }
@@ -232,27 +232,28 @@ fn apply_all_widths(s: &LayoutState) {
     w("floating_border_width", s.floating_border_width);
 }
 
-const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
-
 pub fn view(state: &mut LayoutState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
     let mut pc = PageContent::new();
-    let mut col = Column::new(&mut pc, cx, cy, 0.0, 28.0, cw);
+    let mut y = cy + 12.0;
+
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Desktop Background");
+    state.color_selectors[0].color = state.background_color;
+    sec.widget(&mut pc, &mut state.color_selectors[0], 12.0, 220.0, 22.0);
+    y = sec.finish(&mut pc);
+
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Border Color");
+    state.color_selectors[1].color = state.border_color;
+    sec.widget(&mut pc, &mut state.color_selectors[1], 12.0, 220.0, 22.0);
+    y = sec.finish(&mut pc);
 
-    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);
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Border Width");
+    sec.spacing(8.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);
-        });
+        state.spinboxes[i].set_label(param.label());
+        sec.widget(&mut pc, &mut state.spinboxes[i], 14.0, 200.0, 26.0);
+        sec.spacing(8.0);
     }
+    sec.finish(&mut pc);
 
     pc
 }
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 74d0cfd..bc0be2c 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -8,6 +8,7 @@ pub mod system_info;
 pub mod keybindings;
 pub mod input;
 pub mod status;
+pub mod processors;
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum Page {
@@ -18,16 +19,18 @@ pub enum Page {
     Display,
     Layout,
     System,
+    Processors,
     Input,
     Status,
 }
 
 impl Page {
-    pub const ALL: [Page; 9] = [
+    pub const ALL: [Page; 10] = [
         Page::Audio,
         Page::Display,
         Page::Input,
         Page::Layout,
+        Page::Processors,
         Page::Power,
         Page::Radios,
         Page::Status,
@@ -44,6 +47,7 @@ impl Page {
             Page::Display => "Display",
             Page::Layout => "Layout",
             Page::System => "System",
+            Page::Processors => "Processors",
             Page::Input => "Input",
             Page::Status => "Status",
         }
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 54df5e3..8e8d31a 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -1,4 +1,5 @@
 use crate::app::{AppAction, PageContent};
+use clear_ui::layout::Section;
 
 #[derive(Debug, Clone)]
 pub struct WifiNetwork {
@@ -200,7 +201,6 @@ const TOGGLE_OFF: [f32; 4] = [0.16, 0.16, 0.24, 1.0];
 const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
 const NET_BTN: [f32; 4] = [0.13, 0.20, 0.27, 1.0];
 const ACT_BTN: [f32; 4] = [0.16, 0.29, 0.18, 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: &NetworkState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
@@ -208,22 +208,24 @@ pub fn view(state: &NetworkState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCo
     let mut y = cy + 12.0;
 
     // ── WiFi ──
-    pc.text("WiFi", cx + 12.0, y, 14.0, TEXT_FG);
+    let mut sec = Section::new(&mut pc, cx, y, cw, "WiFi");
+
+    let yt = sec.ay();
     pc.button(if state.wifi_enabled { "ON" } else { "OFF" },
-        cx + cw - 80.0, y, 60.0, 28.0,
+        sec.ax(cw - 80.0), yt, 60.0, 28.0,
         if state.wifi_enabled { TOGGLE_ON } else { TOGGLE_OFF }, BTN_HOVER, WHITE,
         AppAction::Radios(NetworkMessage::ToggleWifi));
-    y += 34.0;
+    sec.content_y += 34.0;
 
     if !state.connected_ssid.is_empty() {
-        pc.text(&format!("Connected: {}", state.connected_ssid), cx + 14.0, y, 13.0, ACCENT);
-        y += 18.0;
-        pc.text(&format!("Signal: {}%  IP: {}", state.signal_strength, state.ip_address),
-            cx + 14.0, y, 12.0, TEXT_DIM);
-        y += 16.0;
+        sec.text(&mut pc, &format!("Connected: {}", state.connected_ssid), 14.0, 0.0, 13.0, ACCENT);
+        sec.spacing(18.0);
+        sec.text(&mut pc, &format!("Signal: {}%  IP: {}", state.signal_strength, state.ip_address),
+            14.0, 0.0, 12.0, TEXT_DIM);
+        sec.spacing(16.0);
     } else if state.wifi_enabled {
-        pc.text("Not connected", cx + 14.0, y, 12.0, TEXT_DIM);
-        y += 16.0;
+        sec.text(&mut pc, "Not connected", 14.0, 0.0, 12.0, TEXT_DIM);
+        sec.spacing(16.0);
     }
 
     if state.wifi_enabled && !state.available.is_empty() {
@@ -231,52 +233,52 @@ pub fn view(state: &NetworkState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCo
             let prefix = if net.in_use { ">" } else { " " };
             let label = format!("{}  {}  ({}%)", prefix, net.ssid, net.signal);
             let active = net.in_use;
-            pc.button(&label, cx + 14.0, y, cw - 28.0, 26.0,
+            let yt = sec.ay();
+            pc.button(&label, sec.ax(14.0), yt, cw - 28.0, 26.0,
                 if active { ACT_BTN } else { NET_BTN }, BTN_HOVER,
                 if active { ACCENT } else { TEXT_FG },
                 AppAction::Radios(NetworkMessage::ConnectWifi(net.ssid.clone())));
-            y += 30.0;
+            sec.content_y += 30.0;
         }
     }
 
+    y = sec.finish(&mut pc);
+
     // ── Bluetooth ──
-    y += 4.0;
-    pc.rect(SECTION_BORDER, cx + 8.0, y, cw - 16.0, 1.0);
-    y += 8.0;
-    pc.text("Bluetooth", cx + 12.0, y, 14.0, TEXT_FG);
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Bluetooth");
 
-    // BT toggle
+    let yt = sec.ay();
     pc.button(if state.bt_enabled { "ON" } else { "OFF" },
-        cx + cw - 140.0, y, 60.0, 28.0,
+        sec.ax(cw - 140.0), yt, 60.0, 28.0,
         if state.bt_enabled { TOGGLE_ON } else { TOGGLE_OFF }, BTN_HOVER, WHITE,
         AppAction::Radios(NetworkMessage::ToggleBluetooth));
-
-    // Scan button
-    pc.button("Scan", cx + cw - 72.0, y, 52.0, 28.0,
+    pc.button("Scan", sec.ax(cw - 72.0), yt, 52.0, 28.0,
         TOGGLE_OFF, BTN_HOVER, WHITE,
         AppAction::Radios(NetworkMessage::BtScan));
-    y += 34.0;
+    sec.content_y += 34.0;
 
     if state.bt_devices.is_empty() {
         if state.bt_enabled {
-            pc.text("No paired devices found", cx + 14.0, y, 12.0, TEXT_DIM);
+            sec.text(&mut pc, "No paired devices found", 14.0, 0.0, 12.0, TEXT_DIM);
         }
     } else {
         for dev in &state.bt_devices {
             let status = if dev.connected { ">" } else { " " };
             let label = format!("{} {} ({})", status, dev.name, dev.mac);
             let action_label = if dev.connected { "Disconnect" } else { "Connect" };
-            pc.text(&label, cx + 14.0, y, 12.0, if dev.connected { ACCENT } else { TEXT_FG });
-            pc.button(action_label, cx + cw - 90.0, y - 2.0, 70.0, 22.0,
+            let yt = sec.ay();
+            sec.text(&mut pc, &label, 14.0, 0.0, 12.0, if dev.connected { ACCENT } else { TEXT_FG });
+            pc.button(action_label, sec.ax(cw - 90.0), yt - 2.0, 70.0, 22.0,
                 if dev.connected { TOGGLE_OFF } else { TOGGLE_ON }, BTN_HOVER, WHITE,
                 if dev.connected {
                     AppAction::Radios(NetworkMessage::BtDisconnect(dev.mac.clone()))
                 } else {
                     AppAction::Radios(NetworkMessage::BtConnect(dev.mac.clone()))
                 });
-            y += 24.0;
+            sec.content_y += 24.0;
         }
     }
+    sec.finish(&mut pc);
 
     pc
 }
diff --git a/src/pages/power.rs b/src/pages/power.rs
index bf16277..9dcab48 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -1,4 +1,5 @@
 use crate::app::{AppAction, PageContent};
+use clear_ui::layout::Section;
 
 #[derive(Debug, Clone, Default)]
 pub struct BatteryInfo {
@@ -159,7 +160,6 @@ 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 DANGER_BG: [f32; 4] = [0.67, 0.20, 0.20, 1.0];
 const SAFE_BG: [f32; 4] = [0.20, 0.33, 0.22, 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];
 const RED: [f32; 4] = [1.0, 0.33, 0.33, 1.0];
 const ORANGE: [f32; 4] = [1.0, 0.73, 0.20, 1.0];
@@ -169,6 +169,8 @@ pub fn view(state: &PowerState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCont
     let mut y = cy + 12.0;
 
     // ── Battery section ──
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Battery");
+
     let bat = &state.battery;
     let bat_icon = match bat.state.as_str() {
         "charging" => "+",
@@ -181,13 +183,13 @@ pub fn view(state: &PowerState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCont
         else { ACCENT };
 
     let pct_str = format!("{} {:.0}%", bat_icon, bat.percentage);
-    pc.text(&pct_str, cx + 12.0, y, 24.0, pct_color);
-    y += 30.0;
+    sec.text(&mut pc, &pct_str, 12.0, 0.0, 24.0, pct_color);
+    sec.spacing(30.0);
 
     let state_str = format!("{}  •  {:.1}W  •  {:.1}/{:.1} Wh",
         bat.state, bat.energy_rate, bat.energy, bat.energy_full);
-    pc.text(&state_str, cx + 12.0, y, 12.0, TEXT_DIM);
-    y += 18.0;
+    sec.text(&mut pc, &state_str, 12.0, 0.0, 12.0, TEXT_DIM);
+    sec.spacing(18.0);
 
     let time_str = if bat.time_to_empty > 0 {
         format!("Time remaining: {}", format_duration(bat.time_to_empty))
@@ -195,27 +197,25 @@ pub fn view(state: &PowerState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCont
         format!("Time to full: {}", format_duration(bat.time_to_full))
     } else { String::new() };
     if !time_str.is_empty() {
-        pc.text(&time_str, cx + 12.0, y, 12.0, TEXT_DIM);
-        y += 18.0;
+        sec.text(&mut pc, &time_str, 12.0, 0.0, 12.0, TEXT_DIM);
+        sec.spacing(18.0);
     }
 
     let detail_str = format!("{}  {}", bat.vendor, bat.model);
-    pc.text(&detail_str, cx + 12.0, y, 11.0, TEXT_DIM);
-    y += 20.0;
+    sec.text(&mut pc, &detail_str, 12.0, 0.0, 11.0, TEXT_DIM);
+    sec.spacing(20.0);
 
     let ac_str = if state.on_ac { "On AC Power" } else { "On Battery" };
-    pc.text(ac_str, cx + 12.0, y, 14.0, TEXT_FG);
-    y += 24.0;
+    sec.text(&mut pc, ac_str, 12.0, 0.0, 14.0, TEXT_FG);
+
+    y = sec.finish(&mut pc);
 
     // ── CPU Governor section ──
-    y += 8.0;
-    pc.rect(SECTION_BORDER, cx + 8.0, y, cw - 16.0, 1.0);
-    y += 8.0;
-    pc.text("CPU Governor", cx + 12.0, y, 12.0, TEXT_DIM);
-    y += 18.0;
+    let mut sec = Section::new(&mut pc, cx, y, cw, "CPU Governor");
 
     let btn_w = (cw - 40.0) / 2.0;
     let btn_h = 44.0;
+    let yt = sec.ay();
 
     let perf_active = !state.cpu_powersave;
     let (perf_bg, perf_desc, perf_desc_color) = if perf_active {
@@ -224,10 +224,10 @@ pub fn view(state: &PowerState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCont
         (BTN_INACTIVE, "Switch to performance governor", TEXT_DIM)
     };
 
-    pc.button("Performance", cx + 12.0, y, btn_w, btn_h,
+    pc.button("Performance", sec.ax(12.0), yt, btn_w, btn_h,
         perf_bg, BTN_HOVER, WHITE,
         AppAction::Power(PowerMessage::SetCpuPerformance));
-    pc.text(perf_desc, cx + 16.0, y + 26.0, 10.0, perf_desc_color);
+    sec.text(&mut pc, perf_desc, 16.0, 26.0, 10.0, perf_desc_color);
 
     let (save_bg, save_desc, save_desc_color) = if state.cpu_powersave {
         (BTN_ACTIVE, "Governor set to powersave — lower power, slower burst", ACCENT)
@@ -235,19 +235,18 @@ pub fn view(state: &PowerState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCont
         (BTN_INACTIVE, "Switch to powersave governor (requires auth)", TEXT_DIM)
     };
 
-    let save_x = cx + 16.0 + btn_w;
-    pc.button("Powersave", save_x, y, btn_w, btn_h,
+    let save_x = 16.0 + btn_w;
+    pc.button("Powersave", sec.ax(save_x), yt, btn_w, btn_h,
         save_bg, BTN_HOVER, WHITE,
         AppAction::Power(PowerMessage::SetCpuPowersave));
-    pc.text(save_desc, save_x + 4.0, y + 26.0, 10.0, save_desc_color);
-    y += btn_h + 12.0;
+    sec.text(&mut pc, save_desc, save_x + 4.0, 26.0, 10.0, save_desc_color);
+    sec.content_y += btn_h + 12.0;
+    y = sec.finish(&mut pc);
 
     // ── GPU Power section ──
-    pc.rect(SECTION_BORDER, cx + 8.0, y, cw - 16.0, 1.0);
-    y += 8.0;
-    pc.text("GPU Power", cx + 12.0, y, 12.0, TEXT_DIM);
-    y += 18.0;
+    let mut sec = Section::new(&mut pc, cx, y, cw, "GPU Power");
 
+    let yt = sec.ay();
     let gpu_def_active = !state.gpu_powersave;
     let (gpu_def_bg, gpu_def_desc, gpu_def_desc_c) = if gpu_def_active {
         (BTN_ACTIVE, "NVIDIA running at default power limit", ACCENT)
@@ -255,10 +254,10 @@ pub fn view(state: &PowerState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCont
         (BTN_INACTIVE, "Restore default power limit (requires auth)", TEXT_DIM)
     };
 
-    pc.button("80W Default", cx + 12.0, y, btn_w, btn_h,
+    pc.button("80W Default", sec.ax(12.0), yt, btn_w, btn_h,
         gpu_def_bg, BTN_HOVER, WHITE,
         AppAction::Power(PowerMessage::SetGpuDefault));
-    pc.text(gpu_def_desc, cx + 16.0, y + 26.0, 10.0, gpu_def_desc_c);
+    sec.text(&mut pc, gpu_def_desc, 16.0, 26.0, 10.0, gpu_def_desc_c);
 
     let (gpu_cap_bg, gpu_cap_desc, gpu_cap_desc_c) = if state.gpu_powersave {
         (BTN_ACTIVE, "NVIDIA power limit capped at 5W — minimal draw", ACCENT)
@@ -266,29 +265,29 @@ pub fn view(state: &PowerState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCont
         (BTN_INACTIVE, "Cap NVIDIA to 5W power limit (requires auth)", TEXT_DIM)
     };
 
-    pc.button("5W Cap", save_x, y, btn_w, btn_h,
+    pc.button("5W Cap", sec.ax(save_x), yt, btn_w, btn_h,
         gpu_cap_bg, BTN_HOVER, WHITE,
         AppAction::Power(PowerMessage::SetGpuPowersave));
-    pc.text(gpu_cap_desc, save_x + 4.0, y + 26.0, 10.0, gpu_cap_desc_c);
-    y += btn_h + 12.0;
+    sec.text(&mut pc, gpu_cap_desc, save_x + 4.0, 26.0, 10.0, gpu_cap_desc_c);
+    sec.content_y += btn_h + 12.0;
+    y = sec.finish(&mut pc);
 
     // ── System Actions section ──
-    pc.rect(SECTION_BORDER, cx + 8.0, y, cw - 16.0, 1.0);
-    y += 8.0;
-    pc.text("System Actions", cx + 12.0, y, 12.0, TEXT_DIM);
-    y += 18.0;
+    let mut sec = Section::new(&mut pc, cx, y, cw, "System Actions");
 
+    let yt = sec.ay();
     let act_btn_w = (cw - 48.0) / 4.0;
     let act_btn_h = 32.0;
 
-    pc.button("Suspend", cx + 12.0, y, act_btn_w, act_btn_h,
+    pc.button("Suspend", sec.ax(12.0), yt, act_btn_w, act_btn_h,
         SAFE_BG, BTN_HOVER, WHITE, AppAction::Power(PowerMessage::Suspend));
-    pc.button("Hibernate", cx + 16.0 + act_btn_w, y, act_btn_w, act_btn_h,
+    pc.button("Hibernate", sec.ax(16.0 + act_btn_w), yt, act_btn_w, act_btn_h,
         SAFE_BG, BTN_HOVER, WHITE, AppAction::Power(PowerMessage::Hibernate));
-    pc.button("Reboot", cx + 20.0 + 2.0 * act_btn_w, y, act_btn_w, act_btn_h,
+    pc.button("Reboot", sec.ax(20.0 + 2.0 * act_btn_w), yt, act_btn_w, act_btn_h,
         DANGER_BG, BTN_HOVER, WHITE, AppAction::Power(PowerMessage::Reboot));
-    pc.button("Power Off", cx + 24.0 + 3.0 * act_btn_w, y, act_btn_w, act_btn_h,
+    pc.button("Power Off", sec.ax(24.0 + 3.0 * act_btn_w), yt, act_btn_w, act_btn_h,
         DANGER_BG, BTN_HOVER, WHITE, AppAction::Power(PowerMessage::PowerOff));
+    sec.finish(&mut pc);
 
     pc
 }
diff --git a/src/pages/processors.rs b/src/pages/processors.rs
new file mode 100644
index 0000000..ec5963f
--- /dev/null
+++ b/src/pages/processors.rs
@@ -0,0 +1,89 @@
+use crate::app::PageContent;
+use clear_ui::layout::Section;
+
+#[derive(Debug, Clone, Default)]
+pub struct ProcessorsState {
+    pub cpu_model: String,
+    pub cpu_usage: f32,
+    pub cpu_cores: u32,
+    pub gpu: String,
+}
+
+#[derive(Debug, Clone)]
+pub enum ProcessorsMessage {
+    Refreshed(ProcessorsState),
+}
+
+pub async fn fetch_processors_state() -> ProcessorsState {
+    let (cpu_model, cpu_cores) = {
+        let lscpu = tokio::process::Command::new("lscpu")
+            .output().await.ok()
+            .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
+            .unwrap_or_default();
+        let model = lscpu.lines()
+            .find(|l| l.contains("Model name"))
+            .and_then(|l| l.split(':').nth(1))
+            .map(|s| s.trim().to_string())
+            .unwrap_or_default();
+        let cores = lscpu.lines()
+            .find(|l| l.contains("CPU(s)"))
+            .and_then(|l| {
+                let rest = l.split(':').nth(1).unwrap_or("").trim();
+                rest.split_whitespace().next().and_then(|n| n.parse::<u32>().ok())
+            })
+            .unwrap_or(0);
+        (model, cores)
+    };
+
+    let cpu_usage = {
+        let read_stat = || -> Option<(u64, u64)> {
+            let stat = std::fs::read_to_string("/proc/stat").ok()?;
+            let first = stat.lines().next()?;
+            let vals: Vec<u64> = first.split_whitespace().skip(1).filter_map(|v| v.parse().ok()).collect();
+            if vals.len() < 3 { return None; }
+            let total: u64 = vals.iter().sum();
+            let idle = vals.get(3).copied().unwrap_or(0);
+            Some((idle, total))
+        };
+        let (idle1, total1) = read_stat().unwrap_or((0, 1));
+        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+        let (idle2, total2) = read_stat().unwrap_or((0, 1));
+        let d_idle = idle2.saturating_sub(idle1);
+        let d_total = total2.saturating_sub(total1);
+        if d_total > 0 {
+            (1.0 - d_idle as f64 / d_total as f64) * 100.0
+        } else { 0.0 }
+    } as f32;
+
+    let gpu = tokio::process::Command::new("lspci")
+        .output().await.ok()
+        .and_then(|o| {
+            String::from_utf8_lossy(&o.stdout).lines()
+                .find(|l| l.contains("VGA") || l.contains("3D"))
+                .and_then(|l| l.split(':').nth(2))
+                .map(|s| s.trim().to_string())
+        })
+        .unwrap_or_default();
+
+    ProcessorsState { cpu_model, cpu_usage, cpu_cores, gpu }
+}
+
+const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
+
+pub fn view(state: &ProcessorsState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
+    let mut pc = PageContent::new();
+    let y = cy + 12.0;
+
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Processors");
+    sec.text(&mut pc,
+        &format!("CPU  {}  ({} cores)  —  {:.0}%", state.cpu_model, state.cpu_cores, state.cpu_usage),
+        12.0, 0.0, 12.0, TEXT_FG,
+    );
+    sec.spacing(10.0);
+    sec.text(&mut pc, &format!("GPU  {}", state.gpu), 12.0, 0.0, 12.0, TEXT_FG);
+    sec.finish(&mut pc);
+
+    pc
+}
+
+pub fn update(_state: &mut ProcessorsState, _msg: ProcessorsMessage) {}
diff --git a/src/pages/status.rs b/src/pages/status.rs
index f3d247c..117c1de 100644
--- a/src/pages/status.rs
+++ b/src/pages/status.rs
@@ -1,4 +1,5 @@
 use crate::app::{AppAction, PageContent};
+use clear_ui::layout::Section;
 
 #[derive(Debug, Clone, Default)]
 pub struct StatusState {
@@ -69,34 +70,39 @@ const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
 
 pub fn view(state: &StatusState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
     let mut pc = PageContent::new();
-    let mut y = cy + 12.0;
+    let y = cy + 12.0;
 
-    // Waybar status header
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Waybar");
+
+    // Status
     let status_color = if state.running { ACCENT } else { [0.67, 0.20, 0.20, 1.0] };
     let status_text = if state.running { "Running" } else { "Stopped" };
-    pc.text("Waybar", cx + 12.0, y, 14.0, TEXT_FG);
-    pc.text(status_text, cx + 80.0, y, 14.0, status_color);
-    y += 22.0;
+    sec.text(&mut pc, "Waybar", 12.0, 0.0, 14.0, TEXT_FG);
+    sec.text(&mut pc, status_text, 80.0, 0.0, 14.0, status_color);
+    sec.spacing(22.0);
 
     // Font size
-    pc.text(&format!("Font size: {}px", state.font_size), cx + 12.0, y, 13.0, TEXT_FG);
-    y += 20.0;
+    sec.text(&mut pc, &format!("Font size: {}px", state.font_size), 12.0, 0.0, 13.0, TEXT_FG);
+    sec.spacing(20.0);
 
     let btn_h = 28.0;
-    pc.button("-1", cx + 12.0, y, 36.0, btn_h,
+    let yt = sec.ay();
+    pc.button("-1", sec.ax(12.0), yt, 36.0, btn_h,
         BTN_INACTIVE, BTN_HOVER, WHITE,
         AppAction::Status(StatusMessage::FontSizeDown));
-    pc.text(&format!(" {}px ", state.font_size), cx + 56.0, y + 7.0, 13.0, TEXT_FG);
-    pc.button("+1", cx + 12.0 + 36.0 + 8.0, y, 36.0, btn_h,
+    pc.text(&format!(" {}px ", state.font_size), sec.ax(56.0), yt + 7.0, 13.0, TEXT_FG);
+    pc.button("+1", sec.ax(12.0 + 36.0 + 8.0), yt, 36.0, btn_h,
         BTN_ACTIVE, BTN_HOVER, WHITE,
         AppAction::Status(StatusMessage::FontSizeUp));
-    y += btn_h + 12.0;
+    sec.content_y += btn_h + 12.0;
 
     // Reload button
+    let yt = sec.ay();
     let btn_w = (cw - 24.0).min(200.0);
-    pc.button("Reload Waybar", cx + cw / 2.0 - btn_w / 2.0, y, btn_w, 32.0,
+    pc.button("Reload Waybar", cx + cw / 2.0 - btn_w / 2.0, yt, btn_w, 32.0,
         BTN_INACTIVE, BTN_HOVER, WHITE,
         AppAction::Status(StatusMessage::ReloadWaybar));
+    sec.finish(&mut pc);
 
     pc
 }
diff --git a/src/pages/storage.rs b/src/pages/storage.rs
index 74df39f..604caf8 100644
--- a/src/pages/storage.rs
+++ b/src/pages/storage.rs
@@ -1,4 +1,5 @@
 use crate::app::PageContent;
+use clear_ui::layout::Section;
 
 #[derive(Debug, Clone, Default)]
 pub struct StorageState {
@@ -60,12 +61,11 @@ fn parse_mem(info: &str) -> (f64, f64) {
 const LABEL_FG: [f32; 4] = [0.56, 0.83, 0.56, 1.0];
 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
 
-pub fn view(state: &StorageState, cx: f32, cy: f32, _cw: f32, _ch: f32) -> PageContent {
+pub fn view(state: &StorageState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
     let mut pc = PageContent::new();
-    let mut y = cy + 12.0;
+    let y = cy + 12.0;
 
-    pc.text("Local Storage", cx + 12.0, y, 16.0, LABEL_FG);
-    y += 26.0;
+    let mut sec = Section::new(&mut pc, cx, y, cw, "Local Storage");
 
     let disk_pct = if state.disk_total > 0.0 {
         state.disk_used / state.disk_total * 100.0
@@ -73,19 +73,20 @@ pub fn view(state: &StorageState, cx: f32, cy: f32, _cw: f32, _ch: f32) -> PageC
         0.0
     };
 
-    pc.text("Disk", cx + 12.0, y, 12.0, LABEL_FG);
-    pc.text(
+    sec.text(&mut pc, "Disk", 12.0, 0.0, 12.0, LABEL_FG);
+    sec.text(&mut pc,
         &format!("{:.0} / {:.0} GiB  ({:.0}%)", state.disk_used, state.disk_total, disk_pct),
-        cx + 100.0, y, 12.0, TEXT_FG,
+        100.0, 0.0, 12.0, TEXT_FG,
     );
-    y += 18.0;
+    sec.spacing(18.0);
 
-    let bar_w = _cw - 24.0;
-    pc.rect([0.15, 0.15, 0.25, 1.0], cx + 12.0, y, bar_w, 8.0);
+    let bar_w = cw - 24.0;
+    let yt = sec.ay();
+    pc.rect([0.15, 0.15, 0.25, 1.0], sec.ax(12.0), yt, bar_w, 8.0);
     if disk_pct > 0.0 {
-        pc.rect([0.36, 0.60, 0.36, 1.0], cx + 12.0, y, bar_w * (disk_pct as f32 / 100.0).min(1.0), 8.0);
+        pc.rect([0.36, 0.60, 0.36, 1.0], sec.ax(12.0), yt, bar_w * (disk_pct as f32 / 100.0).min(1.0), 8.0);
     }
-    y += 20.0;
+    sec.content_y += 20.0;
 
     let ram_pct = if state.ram_total > 0.0 {
         state.ram_used / state.ram_total * 100.0
@@ -93,17 +94,19 @@ pub fn view(state: &StorageState, cx: f32, cy: f32, _cw: f32, _ch: f32) -> PageC
         0.0
     };
 
-    pc.text("RAM", cx + 12.0, y, 12.0, LABEL_FG);
-    pc.text(
+    sec.text(&mut pc, "RAM", 12.0, 0.0, 12.0, LABEL_FG);
+    sec.text(&mut pc,
         &format!("{:.1} / {:.1} GiB  ({:.0}%)", state.ram_used, state.ram_total, ram_pct),
-        cx + 100.0, y, 12.0, TEXT_FG,
+        100.0, 0.0, 12.0, TEXT_FG,
     );
-    y += 18.0;
+    sec.spacing(18.0);
 
-    pc.rect([0.15, 0.15, 0.25, 1.0], cx + 12.0, y, bar_w, 8.0);
+    let yt = sec.ay();
+    pc.rect([0.15, 0.15, 0.25, 1.0], sec.ax(12.0), yt, bar_w, 8.0);
     if ram_pct > 0.0 {
-        pc.rect([0.50, 0.50, 0.65, 1.0], cx + 12.0, y, bar_w * (ram_pct as f32 / 100.0).min(1.0), 8.0);
+        pc.rect([0.50, 0.50, 0.65, 1.0], sec.ax(12.0), yt, bar_w * (ram_pct as f32 / 100.0).min(1.0), 8.0);
     }
+    sec.finish(&mut pc);
 
     pc
 }
diff --git a/src/pages/system_info.rs b/src/pages/system_info.rs
index e18a4dd..eb20492 100644
--- a/src/pages/system_info.rs
+++ b/src/pages/system_info.rs
@@ -1,14 +1,11 @@
 use crate::app::PageContent;
+use clear_ui::layout::Section;
 
 #[derive(Debug, Clone, Default)]
 pub struct SystemState {
     pub hostname: String,
     pub kernel: String,
-    pub cpu_model: String,
-    pub cpu_usage: f32,
-    pub cpu_cores: u32,
     pub uptime: String,
-    pub gpu: String,
 }
 
 #[derive(Debug, Clone)]
@@ -27,86 +24,26 @@ pub async fn fetch_system_state() -> SystemState {
         .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
         .unwrap_or_default();
 
-    let (cpu_model, cpu_cores) = {
-        let lscpu = tokio::process::Command::new("lscpu")
-            .output().await.ok()
-            .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
-            .unwrap_or_default();
-        let model = lscpu.lines()
-            .find(|l| l.contains("Model name"))
-            .and_then(|l| l.split(':').nth(1))
-            .map(|s| s.trim().to_string())
-            .unwrap_or_default();
-        let cores = lscpu.lines()
-            .find(|l| l.contains("CPU(s)"))
-            .and_then(|l| {
-                let rest = l.split(':').nth(1).unwrap_or("").trim();
-                rest.split_whitespace().next().and_then(|n| n.parse::<u32>().ok())
-            })
-            .unwrap_or(0);
-        (model, cores)
-    };
-
-    let cpu_usage = {
-        let read_stat = || -> Option<(u64, u64)> {
-            let stat = std::fs::read_to_string("/proc/stat").ok()?;
-            let first = stat.lines().next()?;
-            let vals: Vec<u64> = first.split_whitespace().skip(1).filter_map(|v| v.parse().ok()).collect();
-            if vals.len() < 3 { return None; }
-            let total: u64 = vals.iter().sum();
-            let idle = vals.get(3).copied().unwrap_or(0);
-            Some((idle, total))
-        };
-        let (idle1, total1) = read_stat().unwrap_or((0, 1));
-        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
-        let (idle2, total2) = read_stat().unwrap_or((0, 1));
-        let d_idle = idle2.saturating_sub(idle1);
-        let d_total = total2.saturating_sub(total1);
-        if d_total > 0 {
-            (1.0 - d_idle as f64 / d_total as f64) * 100.0
-        } else { 0.0 }
-    } as f32;
-
     let uptime = tokio::process::Command::new("uptime")
         .args(["-p"]).output().await.ok()
         .map(|o| String::from_utf8_lossy(&o.stdout).trim().trim_start_matches("up ").to_string())
         .unwrap_or_default();
 
-    let gpu = tokio::process::Command::new("lspci")
-        .output().await.ok()
-        .and_then(|o| {
-            String::from_utf8_lossy(&o.stdout).lines()
-                .find(|l| l.contains("VGA") || l.contains("3D"))
-                .and_then(|l| l.split(':').nth(2))
-                .map(|s| s.trim().to_string())
-        })
-        .unwrap_or_default();
-
-    SystemState { hostname, kernel, cpu_model, cpu_usage, cpu_cores, uptime, gpu }
+    SystemState { hostname, kernel, uptime }
 }
 
 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];
 
-pub fn view(state: &SystemState, cx: f32, cy: f32, _cw: f32, _ch: f32) -> PageContent {
+pub fn view(state: &SystemState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
     let mut pc = PageContent::new();
-    let mut y = cy + 12.0;
-
-    // Hostname / kernel
-    pc.text(&format!("{}  —  Linux {}", state.hostname, state.kernel), cx + 12.0, y, 14.0, TEXT_FG);
-    y += 22.0;
-
-    // Uptime
-    pc.text(&format!("Uptime: {}", state.uptime), cx + 12.0, y, 12.0, TEXT_DIM);
-    y += 18.0;
-
-    // CPU
-    pc.text(&format!("CPU  {}  ({} cores)  —  {:.0}%", state.cpu_model, state.cpu_cores, state.cpu_usage),
-        cx + 12.0, y, 12.0, TEXT_FG);
-    y += 18.0;
+    let y = cy + 12.0;
 
-    // GPU
-    pc.text(&format!("GPU  {}", state.gpu), cx + 12.0, y, 12.0, TEXT_FG);
+    let mut sec = Section::new(&mut pc, cx, y, cw, "System");
+    sec.text(&mut pc, &format!("{}  —  Linux {}", state.hostname, state.kernel), 12.0, 0.0, 14.0, TEXT_FG);
+    sec.spacing(10.0);
+    sec.text(&mut pc, &format!("Uptime: {}", state.uptime), 12.0, 0.0, 12.0, TEXT_DIM);
+    sec.finish(&mut pc);
 
     pc
 }