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

commit82061ce507de2ae32b69194fe5b81cbe33cc7a30
parent5619b64abd
authorLucas Galante <[email protected]>
date2026-08-10 21:37
feat: Audio page restyle — one well, one row per device

The Output/Input wells with nested per-device child wells become a
single label-less well: accent zone headings split by a hairline, and
each device on one line (name, volume slider, spinbox, mute — or a dim
"inactive" note). Mute uses the shared quiet-red danger style. The
kv-row and divider helpers move to app.rs (section_kv_row /
section_divider) and Accounts now uses the shared copies.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/app.rs            |  28 ++++++
 src/pages/accounts.rs |  39 ++------
 src/pages/audio.rs    | 246 ++++++++++++++++++++++++--------------------------
 3 files changed, 152 insertions(+), 161 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 15bdc6e..2f7c7c2 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -304,6 +304,34 @@ impl RenderTarget for PageContent {
     }
 }
 
+
+/// A dim label / bright value pair on one line (the shared details idiom).
+pub fn section_kv_row(sc: &mut cce_ui::layout::SectionContext<'_, PageContent>, label: &str, value: &str, value_color: [f32; 4]) {
+    let mut y = sc.content_y;
+    if y > sc.content_start_y {
+        y += sc.row_gap;
+    }
+    let lx = sc.ax(12.0);
+    sc.pc.text(label, lx, y, 12.0, [0.53, 0.53, 0.60, 1.0]);
+    sc.pc.text(value, lx + 130.0, y, 12.0, value_color);
+    sc.content_y = y + 18.0;
+    for h in &mut sc.grid.col_heights {
+        *h = sc.content_y;
+    }
+}
+
+/// A hairline separating a well's zones.
+pub fn section_divider(sc: &mut cce_ui::layout::SectionContext<'_, PageContent>) {
+    let y = sc.content_y + sc.row_gap + 4.0;
+    let x = sc.ax(12.0);
+    let w = sc.cw - 2.0 * (sc.padding() + 12.0);
+    sc.pc.rect([1.0, 1.0, 1.0, 0.06], x, y, w, 1.0);
+    sc.content_y = y + 5.0;
+    for h in &mut sc.grid.col_heights {
+        *h = sc.content_y;
+    }
+}
+
 pub trait SectionContextExt {
     fn button(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], action: AppAction);
     fn button_left(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], action: AppAction);
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index 2b513bf..e1094e8 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -1,4 +1,4 @@
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{AppAction, PageContent, SectionContextExt, section_divider, section_kv_row};
 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy};
 use cce_ui::widget::{TextBox, WidgetHost};
 
@@ -343,33 +343,6 @@ pub async fn exchange_code_for_tokens(code: String, verifier: String, sender: ca
 
 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 
-/// A dim label / bright value pair on one line (the details block).
-fn kv_row(sc: &mut cce_ui::layout::SectionContext<'_, PageContent>, label: &str, value: &str) {
-    let mut y = sc.content_y;
-    if y > sc.content_start_y {
-        y += sc.row_gap;
-    }
-    let lx = sc.ax(12.0);
-    sc.pc.text(label, lx, y, 12.0, TEXT_DIM);
-    sc.pc.text(value, lx + 130.0, y, 12.0, [0.90, 0.90, 0.95, 1.0]);
-    sc.content_y = y + 18.0;
-    for h in &mut sc.grid.col_heights {
-        *h = sc.content_y;
-    }
-}
-
-/// A hairline separating the well's zones.
-fn divider(sc: &mut cce_ui::layout::SectionContext<'_, PageContent>) {
-    let y = sc.content_y + sc.row_gap + 4.0;
-    let x = sc.ax(12.0);
-    let w = sc.cw - 2.0 * (sc.padding() + 12.0);
-    sc.pc.rect([1.0, 1.0, 1.0, 0.06], x, y, w, 1.0);
-    sc.content_y = y + 5.0;
-    for h in &mut sc.grid.col_heights {
-        *h = sc.content_y;
-    }
-}
-
 // The calm palette: neutral chrome, one green primary, quiet red danger, and
 // the accent tint marking both the selected row and an active mode button.
 const BTN_NEUTRAL: ([f32; 4], [f32; 4]) = ([0.15, 0.15, 0.20, 1.0], [0.22, 0.22, 0.28, 1.0]);
@@ -444,7 +417,7 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, layou
             c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, TEXT_BTN, AppAction::Accounts(action));
         });
 
-        divider(stack.context);
+        section_divider(stack.context);
 
         // ── Context zone: add form / OAuth form / selected details ──
         if state.adding_new {
@@ -493,11 +466,11 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, layou
             if selected_idx < state.accounts.len() {
                 let acc = state.accounts[selected_idx].clone();
 
-                kv_row(stack.context, "Email", &acc.email);
+                section_kv_row(stack.context, "Email", &acc.email, TEXT_BTN);
                 let auth_type = if acc.is_oauth { "OAuth2 (Google)" } else { "Password" };
-                kv_row(stack.context, "Authentication", auth_type);
-                kv_row(stack.context, "IMAP", &acc.imap);
-                kv_row(stack.context, "SMTP", &acc.smtp);
+                section_kv_row(stack.context, "Authentication", auth_type, TEXT_BTN);
+                section_kv_row(stack.context, "IMAP", &acc.imap, TEXT_BTN);
+                section_kv_row(stack.context, "SMTP", &acc.smtp, TEXT_BTN);
 
                 stack.context.spacing(6.0);
 
diff --git a/src/pages/audio.rs b/src/pages/audio.rs
index 25c2645..91b492d 100644
--- a/src/pages/audio.rs
+++ b/src/pages/audio.rs
@@ -1,4 +1,4 @@
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{AppAction, PageContent, SectionContextExt, section_divider};
 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy};
 use cce_ui::widget::{Spinbox, Slider, WidgetHost};
 
@@ -221,142 +221,131 @@ const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
 #[allow(dead_code)]
 const RED: [f32; 4] = [1.0, 0.33, 0.33, 1.0];
 
+const HEADING: [f32; 4] = [0.35, 0.65, 0.90, 1.0];
+const BTN_NEUTRAL: ([f32; 4], [f32; 4]) = ([0.15, 0.15, 0.20, 1.0], [0.22, 0.22, 0.28, 1.0]);
+const BTN_DANGER: ([f32; 4], [f32; 4]) = ([0.25, 0.14, 0.14, 1.0], [0.40, 0.20, 0.20, 1.0]);
+const TEXT_BTN: [f32; 4] = [0.90, 0.90, 0.95, 1.0];
+const TEXT_DANGER: [f32; 4] = [0.95, 0.55, 0.55, 1.0];
+
+/// One device on one line: name, volume slider, spinbox, mute — or a dim
+/// "inactive" note. The widgets stay index-aligned with the device vecs.
+#[allow(clippy::too_many_arguments)]
+fn device_row(
+    stack: &mut cce_ui::layout::VStack<'_, '_, PageContent>,
+    name: &str,
+    active: bool,
+    muted: bool,
+    volume: f32,
+    slider: &mut cce_ui::widget::Adapted<Slider>,
+    spin: &mut cce_ui::widget::Adapted<Spinbox>,
+    mute_action: AppAction,
+    ctx: &mut cce_ui::context::UiContext,
+) {
+    if !active {
+        let sc = &mut *stack.context;
+        let mut y = sc.content_y;
+        if y > sc.content_start_y {
+            y += sc.row_gap;
+        }
+        let lx = sc.ax(12.0);
+        sc.pc.text(name, lx, y, 12.0, TEXT_DIM);
+        sc.pc.text("inactive", lx + 150.0, y, 12.0, TEXT_DIM);
+        sc.content_y = y + 18.0;
+        for h in &mut sc.grid.col_heights {
+            *h = sc.content_y;
+        }
+        return;
+    }
+
+    slider.set_value(volume);
+    spin.value = (volume * 100.0).round() as i32;
+
+    let sb_h = cce_ui::layout::spinbox_height();
+    let sl_h = cce_ui::layout::slider_height();
+    let row_h = sb_h.max(sl_h).max(22.0);
+    let (mute_label, colors, mute_text) = if muted {
+        ("Unmute", BTN_DANGER, TEXT_DANGER)
+    } else {
+        ("Mute", BTN_NEUTRAL, TEXT_BTN)
+    };
+
+    stack.add_row(1, 0.0, row_h, |c, _, x, w| {
+        let name_w = 150.0;
+        let spin_w = 90.0;
+        let mute_w = 80.0;
+        let gap = 8.0;
+        let slider_w = (w - name_w - spin_w - mute_w - 2.0 * gap).max(60.0);
+        let y = c.ay();
+
+        c.pc.text(name, x, y + (row_h - 14.0) / 2.0, 12.0, TEXT_FG);
+        render_widget(c.pc, slider, x + name_w, y + (row_h - sl_h) / 2.0, slider_w, sl_h, ctx);
+        let spin_x = x + name_w + slider_w + gap;
+        spin.set_row_rect(spin_x, spin_w);
+        render_widget(c.pc, spin, spin_x, y + (row_h - sb_h) / 2.0, spin_w, sb_h, ctx);
+        c.button(
+            mute_label,
+            spin_x + spin_w + gap,
+            y + (row_h - 22.0) / 2.0,
+            mute_w,
+            22.0,
+            colors.0,
+            colors.1,
+            mute_text,
+            mute_action.clone(),
+        );
+    });
+}
+
 pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focused: &[bool], layout: &mut dyn LayoutStrategy, ctx: &mut cce_ui::context::UiContext) -> PageContent {
     let mut final_pc = PageContent::new();
     let sec_w = 320.0f32;
-    let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(2);
+    let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 
-    // ── Output section ──
-    builder.add_section(&mut final_pc, "Output", sec_focused.first().copied().unwrap_or(false), |sec| {
+    builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
         if !state.loaded {
-            sec.text("Loading output devices...", 12.0, 0.0, 12.0, TEXT_DIM);
-        } else if state.sinks.is_empty() {
-            sec.text("No output devices found", 12.0, 0.0, 12.0, TEXT_DIM);
+            sec.text("Loading audio devices...", 12.0, 0.0, 12.0, TEXT_DIM);
+            return;
         }
+        let mut stack = sec.vstack(8.0);
 
-        if state.loaded {
-            for (idx, sink) in state.sinks.iter().enumerate() {
-                let sec_title = if sink.active {
-                    sink.name.clone()
-                } else {
-                    format!("{} (inactive)", sink.name)
-                };
-
-                sec.add_section(&sec_title, false, |subsec| {
-                    if !sink.active {
-                        subsec.text("Device is inactive.", 12.0, 0.0, 12.0, TEXT_DIM);
-                    } else {
-                        let label = if sink.muted {
-                            format!("Volume: {:.0}%  (muted)", sink.volume * 100.0)
-                        } else {
-                            format!("Volume: {:.0}%", sink.volume * 100.0)
-                        };
-                        state.sink_sliders[idx].set_label(&label);
-                        state.sink_sliders[idx].set_value(sink.volume);
-
-                        let mut stack = subsec.vstack(8.0);
-
-                        let label_h = cce_ui::widget::label_offset(&*state.sink_sliders[idx]);
-                        let slider_h = cce_ui::layout::slider_height() + label_h;
-                        stack.add_widget(&mut *state.sink_sliders[idx], stack.context.cw - 28.0, slider_h, ctx);
-
-                        let sb_h = cce_ui::layout::spinbox_height();
-                        let gap = 8.0;
-
-                        state.sink_spinboxes[idx].value = (sink.volume * 100.0).round() as i32;
-
-                        let mute_label = if sink.muted { "Unmute" } else { "Mute" };
-                        let mute_col = if sink.muted { MUTED_BG } else { BTN_INACTIVE };
-
-                        stack.add_row(2, gap, sb_h, |sec_ctx, i, x, w| {
-                            if i == 0 {
-                                state.sink_spinboxes[idx].set_row_rect(x, w);
-                                let y = sec_ctx.ay();
-                                render_widget(sec_ctx.pc, &mut *state.sink_spinboxes[idx], x, y, w, sb_h, ctx);
-                            } else {
-                                sec_ctx.button(
-                                    mute_label,
-                                    x,
-                                    sec_ctx.ay(),
-                                    w,
-                                    sb_h,
-                                    mute_col,
-                                    BTN_HOVER,
-                                    WHITE,
-                                    AppAction::Audio(AudioMessage::SinkMute(sink.id)),
-                                );
-                            }
-                        });
-                    }
-                });
-            }
+        stack.context.text("Output", 12.0, 0.0, 14.0, HEADING);
+        if state.sinks.is_empty() {
+            stack.context.text("No output devices found", 12.0, 0.0, 12.0, TEXT_DIM);
         }
-    });
-
-    // ── Input section ──
-    builder.add_section(&mut final_pc, "Input", sec_focused.get(1).copied().unwrap_or(false), |sec| {
-        if !state.loaded {
-            sec.text("Loading input devices...", 12.0, 0.0, 12.0, TEXT_DIM);
-        } else if state.sources.is_empty() {
-            sec.text("No input devices found", 12.0, 0.0, 12.0, TEXT_DIM);
+        let sinks = state.sinks.clone();
+        for (idx, sink) in sinks.iter().enumerate() {
+            device_row(
+                &mut stack,
+                &sink.name,
+                sink.active,
+                sink.muted,
+                sink.volume,
+                &mut state.sink_sliders[idx],
+                &mut state.sink_spinboxes[idx],
+                AppAction::Audio(AudioMessage::SinkMute(sink.id)),
+                ctx,
+            );
         }
 
-        if state.loaded {
-            for (idx, src) in state.sources.iter().enumerate() {
-                let sec_title = if src.active {
-                    src.name.clone()
-                } else {
-                    format!("{} (inactive)", src.name)
-                };
-
-                sec.add_section(&sec_title, false, |subsec| {
-                    if !src.active {
-                        subsec.text("Device is inactive.", 12.0, 0.0, 12.0, TEXT_DIM);
-                    } else {
-                        let label = if src.muted {
-                            format!("Volume: {:.0}%  (muted)", src.volume * 100.0)
-                        } else {
-                            format!("Volume: {:.0}%", src.volume * 100.0)
-                        };
-                        state.source_sliders[idx].set_label(&label);
-                        state.source_sliders[idx].set_value(src.volume);
-
-                        let mut stack = subsec.vstack(8.0);
-
-                        let label_h = cce_ui::widget::label_offset(&*state.source_sliders[idx]);
-                        let slider_h = cce_ui::layout::slider_height() + label_h;
-                        stack.add_widget(&mut *state.source_sliders[idx], stack.context.cw - 28.0, slider_h, ctx);
-
-                        let sb_h = cce_ui::layout::spinbox_height();
-                        let gap = 8.0;
-
-                        state.source_spinboxes[idx].value = (src.volume * 100.0).round() as i32;
-
-                        let mute_label = if src.muted { "Unmute" } else { "Mute" };
-                        let mute_col = if src.muted { MUTED_BG } else { BTN_INACTIVE };
-
-                        stack.add_row(2, gap, sb_h, |sec_ctx, i, x, w| {
-                            if i == 0 {
-                                state.source_spinboxes[idx].set_row_rect(x, w);
-                                let y = sec_ctx.ay();
-                                render_widget(sec_ctx.pc, &mut *state.source_spinboxes[idx], x, y, w, sb_h, ctx);
-                            } else {
-                                sec_ctx.button(
-                                    mute_label,
-                                    x,
-                                    sec_ctx.ay(),
-                                    w,
-                                    sb_h,
-                                    mute_col,
-                                    BTN_HOVER,
-                                    WHITE,
-                                    AppAction::Audio(AudioMessage::SourceMute(src.id)),
-                                );
-                            }
-                        });
-                    }
-                });
-            }
+        section_divider(stack.context);
+
+        stack.context.text("Input", 12.0, 0.0, 14.0, HEADING);
+        if state.sources.is_empty() {
+            stack.context.text("No input devices found", 12.0, 0.0, 12.0, TEXT_DIM);
+        }
+        let sources = state.sources.clone();
+        for (idx, src) in sources.iter().enumerate() {
+            device_row(
+                &mut stack,
+                &src.name,
+                src.active,
+                src.muted,
+                src.volume,
+                &mut state.source_sliders[idx],
+                &mut state.source_spinboxes[idx],
+                AppAction::Audio(AudioMessage::SourceMute(src.id)),
+                ctx,
+            );
         }
     });
 
@@ -428,7 +417,8 @@ impl crate::pages::AppPage for AudioState {
                 }
             }
         }
-        vec![output, input]
+        output.extend(input);
+        vec![output]
     }
 
     fn view(