system settings
git clone https://git.lucas.co/cce-system-interface.git
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(