From 4a91998a4423ca32e40343c376a53f1da4703f28 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:56:13 -0400 Subject: [PATCH] goblin: settings polish (wrap fix, 5-dot censor, nav reset) Follow-ups on the Settings redesign, all in the goblin view: - Truncation sweep: settings_row_toggle now reserves room for the switch and bounds the text column, so a long label/subtitle WRAPS to another line instead of running under the switch and clipping. Fixes the Advanced Privacy notification descriptions (worst in longer locales); applies uniformly to every toggle row. The Username and Advanced Privacy blurbs are full-width labels that already wrap. No description on the new surfaces can truncate: wrapping is width-bounded, not length-tuned. - Anonymous-mode censor is a FIXED count of dots (five), computed by a pure censored_amount_dots() that ignores the real amount, so the balance magnitude never leaks. Used for both the home balance hero and censored activity amounts. Unit-tested to never vary with size. - Settings navigation resets to the root whenever the user is off the Settings (Me) tab, so leaving and re-entering always lands on the top-level page instead of the last sub-page. One reset at the leave/enter boundary via a pure settings_page_after(); deep links that open a sub-page set the tab to Me in the same frame, so they survive. Unit-tested. --- src/gui/views/goblin/mod.rs | 109 ++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 6 deletions(-) diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index a1a11675..ec485673 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -201,8 +201,21 @@ impl IdentityCueCtx { } } +/// The settings sub-page for the coming frame: reset to the root once the user +/// is off the Settings (Me) tab, otherwise keep the current page. Pure so the +/// leave/enter boundary rule is unit-testable without an egui context. Deep +/// links open a sub-page while setting the tab to Me in the same frame, so they +/// are preserved. +fn settings_page_after(tab: Tab, page: SettingsPage) -> SettingsPage { + if tab == Tab::Me { + page + } else { + SettingsPage::Main + } +} + /// Sub-pages of the Settings tab. -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] enum SettingsPage { Main, Node, @@ -820,6 +833,13 @@ impl GoblinWalletView { self.login_toast = None; } + // Settings navigation resets to its ROOT whenever the user is not on the + // Settings (Me) tab, so leaving Settings and coming back always lands on + // the top-level page instead of the last sub-page. One reset at the + // leave/enter boundary — deep links that open a sub-page set the tab to Me + // in the same frame, so they are unaffected (the tab is Me by next frame). + self.settings_page = settings_page_after(self.tab, self.settings_page); + // Transient login-outcome toast (drawn as a Foreground area, so it rides // above whatever surface or overlay is showing). self.login_toast_ui(ui.ctx()); @@ -1851,7 +1871,7 @@ impl GoblinWalletView { // fiat lookup is skipped entirely while censored (fiat_line is what // kicks the rate fetch) and only fires once revealed. if crate::AppConfig::anonymous_mode() && !self.balance_revealed { - if censored_balance_hero(ui) { + if censored_balance_hero(ui, total) { self.balance_revealed = true; } } else { @@ -2929,7 +2949,9 @@ impl GoblinWalletView { // the "reveal" the spec calls for. let anon = crate::AppConfig::anonymous_mode(); let amount = if anon { - "•••".to_string() + // Fixed dot count, never digit-matched, so a censored row can't leak + // the amount's magnitude. + censored_amount_dots(item.amount, false) } else { format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU) }; @@ -8388,7 +8410,13 @@ fn settings_row_toggle(ui: &mut egui::Ui, label: &str, sub: &str, on: bool) -> O let t = theme::tokens(); let mut toggled = None; ui.horizontal(|ui| { + // Reserve room for the switch and bound the text column, so a long + // label/subtitle WRAPS onto another line instead of running under the + // switch and clipping (longer locales clipped worst). 46px switch + gap. + let toggle_w = 58.0; + let text_w = (ui.available_width() - toggle_w).max(0.0); ui.vertical(|ui| { + ui.set_width(text_w); ui.label( RichText::new(label) .font(FontId::new(15.0, fonts::medium())) @@ -8707,10 +8735,24 @@ fn fiat_line(data: &Option) -> Option { }) } +/// Number of dots a censored money value renders as. FIXED (never the real +/// digit count) so anonymous mode can't leak the balance magnitude. +const CENSOR_DOT_COUNT: usize = 5; + +/// The anonymous-mode censor for a money value: always [`CENSOR_DOT_COUNT`] +/// dots, deliberately ignoring the real amount so its magnitude never leaks. +/// `spaced` widens the dots for the balance hero; activity amounts pass false. +fn censored_amount_dots(_atomic: u64, spaced: bool) -> String { + let sep = if spaced { " " } else { "" }; + ["•"; CENSOR_DOT_COUNT].join(sep) +} + /// The anonymous-mode balance: a centered row of dots standing in for the /// number, tappable to reveal. Returns true on the frame it is tapped. No fiat -/// line is drawn (and no rate fetch is triggered) while censored. -fn censored_balance_hero(ui: &mut egui::Ui) -> bool { +/// line is drawn (and no rate fetch is triggered) while censored. `total` is +/// passed only so the censor is computed the same way everywhere; it is ignored +/// (the dot count is fixed) so the balance size never leaks. +fn censored_balance_hero(ui: &mut egui::Ui, total: u64) -> bool { let t = theme::tokens(); let mut clicked = false; ui.vertical_centered(|ui| { @@ -8718,7 +8760,7 @@ fn censored_balance_hero(ui: &mut egui::Ui) -> bool { ui.add_space(6.0); let resp = ui.add( egui::Label::new( - RichText::new("• • • •") + RichText::new(censored_amount_dots(total, true)) .font(FontId::new(56.0, fonts::bold())) .color(t.text), ) @@ -8811,3 +8853,58 @@ fn fit_news_title_pt(ui: &egui::Ui, text: &str, avail: f32) -> f32 { } FLOOR } + +#[cfg(test)] +mod anon_censor_tests { + use super::{CENSOR_DOT_COUNT, SettingsPage, Tab, censored_amount_dots, settings_page_after}; + + /// The censored money display must be a fixed number of dots that never + /// reflects the real amount — otherwise anonymous mode leaks the magnitude + /// (a bigger balance would show more/longer digits). + #[test] + fn censored_amount_is_fixed_width_regardless_of_size() { + for spaced in [false, true] { + let zero = censored_amount_dots(0, spaced); + let small = censored_amount_dots(1, spaced); + let huge = censored_amount_dots(u64::MAX, spaced); + assert_eq!(zero, small, "censor must not vary with amount"); + assert_eq!(small, huge, "censor must not vary with amount"); + assert_eq!( + zero.chars().filter(|c| *c == '•').count(), + CENSOR_DOT_COUNT, + "censor must always show exactly {CENSOR_DOT_COUNT} dots" + ); + } + } + + /// Leaving the Settings tab resets the sub-page to the root, so re-entering + /// always lands on the top-level Settings page. Staying on the Settings tab + /// preserves the current sub-page (so deep links into a sub-page survive). + #[test] + fn settings_nav_resets_to_root_off_settings_tab() { + // Off the Settings tab: any sub-page collapses to the root. + for tab in [Tab::Home, Tab::Pay, Tab::Activity, Tab::Receive] { + assert_eq!( + settings_page_after(tab, SettingsPage::AdvancedPrivacy), + SettingsPage::Main + ); + assert_eq!( + settings_page_after(tab, SettingsPage::Username), + SettingsPage::Main + ); + } + // On the Settings tab: the current sub-page is preserved. + assert_eq!( + settings_page_after(Tab::Me, SettingsPage::Username), + SettingsPage::Username + ); + assert_eq!( + settings_page_after(Tab::Me, SettingsPage::AdvancedPrivacy), + SettingsPage::AdvancedPrivacy + ); + assert_eq!( + settings_page_after(Tab::Me, SettingsPage::Main), + SettingsPage::Main + ); + } +}