system settings
git clone https://git.lucas.co/cce-system-interface.git
feat: Accounts page restyle — one well, list rows, calm compact actions
Single label-less well replacing the Accounts/Modify Accounts pair:
frameless left-aligned account rows (selection tinted, default marked),
a three-button action row with width-responsive labels, a hairline
divider, aligned label/value detail rows, and per-account actions on a
third-width grid with Delete quiet-red instead of a full-width bar.
Button labels everywhere now clip to their own (clip-clamped) box so a
long label truncates instead of spilling over the neighbor.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/pages/accounts.rs | 440 ++++++++++++++++++--------------------------------
src/renderer.rs | 17 +-
2 files changed, 168 insertions(+), 289 deletions(-)
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index 4e0671c..2b513bf 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -343,306 +343,186 @@ 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]);
+const BTN_PRIMARY: ([f32; 4], [f32; 4]) = ([0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 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 ACCENT_BG: [f32; 4] = [0.20, 0.40, 0.65, 0.35];
+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];
+
pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, 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);
- let lm = cce_ui::layout::label_margin();
let row_h = cce_ui::layout::spinbox_height();
let widget_h = cce_ui::layout::spinbox_height();
- let btn_gap = lm * 1.0;
+ let btn_h = 26.0;
- // ── Accounts Section ──
- builder.add_section(&mut final_pc, "Accounts", false, |sec_accounts| {
+ builder.add_section_spanned(&mut final_pc, "", 1, false, |sec| {
if !state.loaded {
- sec_accounts.text("Loading online accounts...", 12.0, 0.0, 12.0, TEXT_DIM);
- } else {
-
-
- let mut stack = sec_accounts.vstack(8.0);
-
- if state.accounts.is_empty() {
- stack.context.text("No accounts configured.", 12.0, 0.0, 12.0, TEXT_DIM);
- } else {
- for (idx, acc) in state.accounts.iter().enumerate() {
- let label = if acc.is_default {
- format!("{} [Default]", acc.email)
- } else {
- acc.email.clone()
- };
- let is_selected = state.selected_idx == Some(idx) && !state.adding_new && !state.editing_oauth_creds;
- let bg_col = if is_selected { [0.20, 0.40, 0.65, 0.4] } else { [0.10, 0.10, 0.16, 0.3] };
-
- stack.add_row(1, 0.0, row_h, |ctx, _, x, w| {
- ctx.button(
- &label,
- x,
- ctx.ay(),
- w,
- row_h,
- bg_col,
- [0.20, 0.20, 0.25, 0.15],
- [0.90, 0.90, 0.95, 1.0],
- AppAction::Accounts(AccountsMessage::SelectAccount(idx)),
- );
- });
- }
- }
-
- stack.context.spacing(4.0);
-
- let add_bg = if state.adding_new { [0.20, 0.40, 0.65, 0.4] } else { [0.13, 0.18, 0.14, 1.0] };
+ sec.text("Loading online accounts...", 12.0, 0.0, 12.0, TEXT_DIM);
+ return;
+ }
+ let item_w = sec.cw - 2.0 * (sec.padding() + 12.0);
+ let rx = sec.left;
+ let mut stack = sec.vstack(8.0);
- stack.add_row(2, btn_gap, row_h, |ctx, i, x, w| {
- if i == 0 {
- ctx.button(
- "Add Account",
- x,
- ctx.ay(),
- w,
- row_h,
- add_bg,
- [0.25, 0.30, 0.26, 1.0],
- [1.0, 1.0, 1.0, 1.0],
- AppAction::Accounts(AccountsMessage::AddAccountStart),
- );
+ // ── Account list: frameless rows, selection tinted, default marked ──
+ if state.accounts.is_empty() {
+ stack.context.text("No accounts configured.", 12.0, 0.0, 12.0, TEXT_DIM);
+ } else {
+ for (idx, acc) in state.accounts.iter().enumerate() {
+ let label = if acc.is_default {
+ format!("{} \u{2022} default", acc.email)
+ } else {
+ acc.email.clone()
+ };
+ let is_selected = state.selected_idx == Some(idx) && !state.adding_new && !state.editing_oauth_creds;
+ let (bg, hover) = if is_selected {
+ (ACCENT_BG, [0.22, 0.44, 0.70, 0.45])
} else {
- ctx.button(
- "Sign in with Google",
+ ([1.0, 1.0, 1.0, 0.04], [1.0, 1.0, 1.0, 0.10])
+ };
+ stack.add_row(1, 0.0, row_h, |c, _, x, w| {
+ c.button_left(
+ &label,
x,
- ctx.ay(),
+ c.ay(),
w,
row_h,
- [0.15, 0.15, 0.25, 1.0],
- [0.25, 0.25, 0.35, 1.0],
- [1.0, 1.0, 1.0, 1.0],
- AppAction::Accounts(AccountsMessage::GoogleLoginInit),
+ bg,
+ hover,
+ TEXT_BTN,
+ AppAction::Accounts(AccountsMessage::SelectAccount(idx)),
);
- }
- });
-
- let oauth_bg = if state.editing_oauth_creds { [0.20, 0.40, 0.65, 0.4] } else { [0.15, 0.15, 0.20, 1.0] };
- stack.add_row(1, 0.0, row_h, |ctx, _, x, w| {
- ctx.button(
- "Google API Settings",
- x,
- ctx.ay(),
- w,
- row_h,
- oauth_bg,
- [0.25, 0.25, 0.30, 1.0],
- [1.0, 1.0, 1.0, 1.0],
- AppAction::Accounts(AccountsMessage::EditOAuthCredsStart),
- );
- });
-
- if let Some(selected_idx) = state.selected_idx {
- if selected_idx < state.accounts.len() && !state.adding_new && !state.editing_oauth_creds {
- let acc = &state.accounts[selected_idx];
- if !acc.is_default {
- stack.add_row(1, 0.0, row_h, |ctx, _, x, w| {
- ctx.button(
- "Make Default",
- x,
- ctx.ay(),
- w,
- row_h,
- [0.15, 0.15, 0.25, 1.0],
- [0.25, 0.25, 0.35, 1.0],
- [1.0, 1.0, 1.0, 1.0],
- AppAction::Accounts(AccountsMessage::MakeDefault(selected_idx)),
- );
- });
- }
- stack.add_row(1, 0.0, row_h, |ctx, _, x, w| {
- ctx.button(
- "Delete Account",
- x,
- ctx.ay(),
- w,
- row_h,
- [0.33, 0.20, 0.20, 1.0],
- [0.45, 0.25, 0.25, 1.0],
- [1.0, 0.33, 0.33, 1.0],
- AppAction::Accounts(AccountsMessage::DeleteAccount(selected_idx)),
- );
- });
- }
+ });
}
}
- });
- // ── Modify Accounts Section ──
- builder.add_section(&mut final_pc, "Modify Accounts", false, |sec_modify| {
- let item_w = sec_modify.cw - 2.0 * (sec_modify.padding() + 12.0);
- let rx = sec_modify.left;
-
- if state.loaded {
- let mut stack = sec_modify.vstack(8.0);
-
- if state.adding_new {
- stack.context.text("Add New Account", 12.0, 0.0, 14.0, [0.35, 0.65, 0.90, 1.0]);
-
- stack.context.text("Note: Gmail uses Google Login. iCloud requires App PW.", 12.0, 0.0, 11.0, TEXT_DIM);
-
- // Email Address textbox
- state.email_box.set_row_rect(rx + 12.0, item_w);
- stack.add_widget(&mut state.email_box, item_w, widget_h, ctx);
-
- // Password textbox
- state.password_box.set_row_rect(rx + 12.0, item_w);
- stack.add_widget(&mut state.password_box, item_w, widget_h, ctx);
-
- // IMAP Server textbox
- state.imap_box.set_row_rect(rx + 12.0, item_w);
- stack.add_widget(&mut state.imap_box, item_w, widget_h, ctx);
-
- // SMTP Server textbox
- state.smtp_box.set_row_rect(rx + 12.0, item_w);
- stack.add_widget(&mut state.smtp_box, item_w, widget_h, ctx);
-
- stack.context.spacing(4.0);
-
-
- stack.add_row(2, btn_gap, row_h, |ctx, i, x, w| {
- if i == 0 {
- ctx.button(
- "Login (Google)",
- x,
- ctx.ay(),
- w,
- row_h,
- [0.15, 0.15, 0.25, 1.0],
- [0.25, 0.25, 0.35, 1.0],
- [1.0, 1.0, 1.0, 1.0],
- AppAction::Accounts(AccountsMessage::GoogleLoginInit),
- );
- } else {
- ctx.button(
- "Login (iCloud)",
- x,
- ctx.ay(),
- w,
- row_h,
- [0.15, 0.15, 0.25, 1.0],
- [0.25, 0.25, 0.35, 1.0],
- [1.0, 1.0, 1.0, 1.0],
- AppAction::Accounts(AccountsMessage::ICloudLoginHelp),
- );
- }
- });
+ stack.context.spacing(6.0);
+
+ // ── Global actions: one compact row ──
+ let add_bg = if state.adding_new { (ACCENT_BG, ACCENT_BG) } else { BTN_PRIMARY };
+ let oauth_bg = if state.editing_oauth_creds { (ACCENT_BG, ACCENT_BG) } else { BTN_NEUTRAL };
+ let narrow = item_w < 520.0;
+ stack.add_row(3, 8.0, btn_h, |c, i, x, w| {
+ let (label, colors, action) = match i {
+ 0 => (if narrow { "Add" } else { "Add Account" }, add_bg, AccountsMessage::AddAccountStart),
+ 1 => (if narrow { "Google Login" } else { "Sign in with Google" }, BTN_NEUTRAL, AccountsMessage::GoogleLoginInit),
+ _ => (if narrow { "Google API" } else { "Google API Settings" }, oauth_bg, AccountsMessage::EditOAuthCredsStart),
+ };
+ c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, TEXT_BTN, AppAction::Accounts(action));
+ });
- stack.add_row(2, btn_gap, row_h, |ctx, i, x, w| {
- if i == 0 {
- ctx.button(
- "Save Account",
- x,
- ctx.ay(),
- w,
- row_h,
- [0.13, 0.18, 0.14, 1.0],
- [0.25, 0.30, 0.26, 1.0],
- [1.0, 1.0, 1.0, 1.0],
- AppAction::Accounts(AccountsMessage::AddAccountSave),
- );
- } else {
- ctx.button(
- "Cancel",
- x,
- ctx.ay(),
- w,
- row_h,
- [0.33, 0.20, 0.20, 1.0],
- [0.45, 0.25, 0.25, 1.0],
- [1.0, 1.0, 1.0, 1.0],
- AppAction::Accounts(AccountsMessage::AddAccountCancel),
- );
- }
- });
- } else if state.editing_oauth_creds {
- stack.context.text("Google OAuth Credentials", 12.0, 0.0, 14.0, [0.35, 0.65, 0.90, 1.0]);
-
- stack.context.text("Client ID & secret from your Google Cloud Console.", 12.0, 0.0, 11.0, TEXT_DIM);
- stack.context.text("Use a 'Desktop app' OAuth client with the Gmail API enabled —", 12.0, 0.0, 11.0, TEXT_DIM);
- stack.context.text("loopback redirects (http://localhost:*) are then allowed automatically.", 12.0, 0.0, 11.0, TEXT_DIM);
-
- // Client ID textbox
- state.oauth_client_id_box.set_row_rect(rx + 12.0, item_w);
- stack.add_widget(&mut state.oauth_client_id_box, item_w, widget_h, ctx);
-
- // Client Secret textbox
- state.oauth_client_secret_box.set_row_rect(rx + 12.0, item_w);
- stack.add_widget(&mut state.oauth_client_secret_box, item_w, widget_h, ctx);
-
- stack.context.spacing(4.0);
-
-
- stack.add_row(2, btn_gap, row_h, |ctx, i, x, w| {
- if i == 0 {
- ctx.button(
- "Save Credentials",
- x,
- ctx.ay(),
- w,
- row_h,
- [0.13, 0.18, 0.14, 1.0],
- [0.25, 0.30, 0.26, 1.0],
- [1.0, 1.0, 1.0, 1.0],
- AppAction::Accounts(AccountsMessage::EditOAuthCredsSave),
- );
- } else {
- ctx.button(
- "Cancel",
- x,
- ctx.ay(),
- w,
- row_h,
- [0.33, 0.20, 0.20, 1.0],
- [0.45, 0.25, 0.25, 1.0],
- [1.0, 1.0, 1.0, 1.0],
- AppAction::Accounts(AccountsMessage::EditOAuthCredsCancel),
- );
+ divider(stack.context);
+
+ // ── Context zone: add form / OAuth form / selected details ──
+ if state.adding_new {
+ stack.context.text("Add New Account", 12.0, 0.0, 14.0, [0.35, 0.65, 0.90, 1.0]);
+ stack.context.text("Gmail uses Google Login; iCloud requires an App Password.", 12.0, 0.0, 11.0, TEXT_DIM);
+
+ state.email_box.set_row_rect(rx + 12.0, item_w);
+ stack.add_widget(&mut state.email_box, item_w, widget_h, ctx);
+ state.password_box.set_row_rect(rx + 12.0, item_w);
+ stack.add_widget(&mut state.password_box, item_w, widget_h, ctx);
+ state.imap_box.set_row_rect(rx + 12.0, item_w);
+ stack.add_widget(&mut state.imap_box, item_w, widget_h, ctx);
+ state.smtp_box.set_row_rect(rx + 12.0, item_w);
+ stack.add_widget(&mut state.smtp_box, item_w, widget_h, ctx);
+
+ stack.context.spacing(4.0);
+ stack.add_row(4, 8.0, btn_h, |c, i, x, w| {
+ let (label, colors, text_col, action) = match i {
+ 0 => ("Save", BTN_PRIMARY, TEXT_BTN, AccountsMessage::AddAccountSave),
+ 1 => ("Cancel", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::AddAccountCancel),
+ 2 => ("Login (Google)", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::GoogleLoginInit),
+ _ => ("Login (iCloud)", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::ICloudLoginHelp),
+ };
+ c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, text_col, AppAction::Accounts(action));
+ });
+ } else if state.editing_oauth_creds {
+ stack.context.text("Google OAuth Credentials", 12.0, 0.0, 14.0, [0.35, 0.65, 0.90, 1.0]);
+ stack.context.text("Client ID & secret from your Google Cloud Console.", 12.0, 0.0, 11.0, TEXT_DIM);
+ stack.context.text("Use a 'Desktop app' OAuth client with the Gmail API enabled \u{2014}", 12.0, 0.0, 11.0, TEXT_DIM);
+ stack.context.text("loopback redirects (http://localhost:*) are then allowed automatically.", 12.0, 0.0, 11.0, TEXT_DIM);
+
+ state.oauth_client_id_box.set_row_rect(rx + 12.0, item_w);
+ stack.add_widget(&mut state.oauth_client_id_box, item_w, widget_h, ctx);
+ state.oauth_client_secret_box.set_row_rect(rx + 12.0, item_w);
+ stack.add_widget(&mut state.oauth_client_secret_box, item_w, widget_h, ctx);
+
+ stack.context.spacing(4.0);
+ stack.add_row(2, 8.0, btn_h, |c, i, x, w| {
+ let (label, colors, action) = match i {
+ 0 => ("Save Credentials", BTN_PRIMARY, AccountsMessage::EditOAuthCredsSave),
+ _ => ("Cancel", BTN_NEUTRAL, AccountsMessage::EditOAuthCredsCancel),
+ };
+ c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, TEXT_BTN, AppAction::Accounts(action));
+ });
+ } else if let Some(selected_idx) = state.selected_idx {
+ if selected_idx < state.accounts.len() {
+ let acc = state.accounts[selected_idx].clone();
+
+ kv_row(stack.context, "Email", &acc.email);
+ 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);
+
+ stack.context.spacing(6.0);
+
+ // Per-account actions, compact; Delete quiet-red, at the end.
+ let mut actions: Vec<(&str, ([f32; 4], [f32; 4]), [f32; 4], AccountsMessage)> = Vec::new();
+ if !acc.is_default {
+ actions.push(("Make Default", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::MakeDefault(selected_idx)));
+ }
+ if acc.is_oauth {
+ actions.push(("Re-login (Browser)", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::GoogleLoginInit));
+ }
+ actions.push(("Delete", BTN_DANGER, TEXT_DANGER, AccountsMessage::DeleteAccount(selected_idx)));
+ stack.add_row(3, 8.0, btn_h, |c, i, x, w| {
+ if let Some((label, colors, text_col, action)) = actions.get(i).cloned() {
+ c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, text_col, AppAction::Accounts(action));
}
});
- } else if let Some(selected_idx) = state.selected_idx {
- if selected_idx < state.accounts.len() {
- let acc = &state.accounts[selected_idx];
-
- stack.context.text("Account Details", 12.0, 0.0, 14.0, [0.35, 0.65, 0.90, 1.0]);
-
- stack.context.text(&format!("Email Address: {}", acc.email), 12.0, 0.0, 12.0, [0.90, 0.90, 0.95, 1.0]);
-
- let auth_type = if acc.is_oauth { "OAuth2 (Google)" } else { "Password-based" };
- stack.context.text(&format!("Authentication: {}", auth_type), 12.0, 0.0, 12.0, [0.83, 0.83, 0.83, 1.0]);
-
- stack.context.text(&format!("IMAP Server: {}", acc.imap), 12.0, 0.0, 12.0, [0.83, 0.83, 0.83, 1.0]);
-
- stack.context.text(&format!("SMTP Server: {}", acc.smtp), 12.0, 0.0, 12.0, [0.83, 0.83, 0.83, 1.0]);
-
- if acc.is_oauth {
- stack.add_row(1, 0.0, row_h, |ctx, _, x, w| {
- ctx.button(
- "Click to Login (Browser)",
- x,
- ctx.ay(),
- w,
- row_h,
- [0.15, 0.15, 0.25, 1.0],
- [0.25, 0.25, 0.35, 1.0],
- [1.0, 1.0, 1.0, 1.0],
- AppAction::Accounts(AccountsMessage::GoogleLoginInit),
- );
- });
- }
- }
- } else {
- stack.context.text("Select an account to view details, or click Add Account.", 12.0, 0.0, 12.0, TEXT_DIM);
}
+ } else {
+ stack.context.text("Select an account to view details, or add one.", 12.0, 0.0, 12.0, TEXT_DIM);
+ }
- if let Some(ref msg) = state.status_msg {
- sec_modify.text(msg, 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]);
- }
+ if let Some(ref msg) = state.status_msg {
+ stack.context.spacing(6.0);
+ stack.context.text(msg, 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]);
}
});
final_pc
@@ -847,7 +727,7 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
}
impl crate::pages::AppPage for AccountsState {
- // Sections: [Accounts, Modify Accounts] — the modify group depends on the mode.
+ // Sections: [the one well] — the group depends on the mode.
fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
let modify: Vec<cce_ui::widget::WidgetId> = if self.editing_oauth_creds {
vec![
@@ -864,7 +744,7 @@ impl crate::pages::AppPage for AccountsState {
} else {
Vec::new()
};
- vec![Vec::new(), modify]
+ vec![modify]
}
fn view(
diff --git a/src/renderer.rs b/src/renderer.rs
index ab2f54b..283dea4 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -513,15 +513,14 @@ impl SystemInterface {
};
let label_color = btn.label_color.unwrap_or([0.83, 0.83, 0.83, 1.0]);
- let button_bounds = Some(match clip {
- Some(c) => [
- c[0],
- (c[1] - scroll_offset_y).max(0.0),
- (c[0] + c[2]).min(logical_sw),
- (c[1] + c[3] - scroll_offset_y).min(viewport_bottom),
- ],
- None => [0.0, 0.0, logical_sw, viewport_bottom],
- });
+ // Label bounds: the button's own (clip-clamped) box — a label longer
+ // than its button truncates instead of spilling over the neighbor.
+ let button_bounds = Some([
+ wx,
+ wy.max(0.0),
+ (wx + ww).min(logical_sw),
+ (wy + wh).min(viewport_bottom),
+ ]);
texts.push((
label.to_string(),
label_size,