From 906fee9c71d02da984d8d2dca02c997e4849e5b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 02:33:32 -0400 Subject: [PATCH] P8: desktop sidebar (shell B), theme + density pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wide desktop (≥720px) now renders a left sidebar nav (Wallet/Activity/Send/ Receive/Settings) with a profile card instead of the bottom tab bar; narrow screens keep the bottom tabs. Both drive the same Tab state and screens. - Settings gains an Appearance group: tap to cycle theme (Dark/Light/Yellow, re-applies egui visuals live) and density (Comfy/Regular/Compact). Co-Authored-By: Claude Fable 5 --- src/gui/views/goblin/mod.rs | 213 +++++++++++++++++++++++++++++++++--- 1 file changed, 196 insertions(+), 17 deletions(-) diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 62a25892..12a0d02b 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -135,23 +135,45 @@ impl GoblinWalletView { return; } - // Bottom tab bar. - let bottom_inset = View::get_bottom_inset(); - egui::TopBottomPanel::bottom("goblin_tabs") - .frame(egui::Frame { - fill: t.bg, - inner_margin: Margin { - left: 8, - right: 8, - top: 8, - bottom: (4.0 + bottom_inset) as i8, - }, - stroke: Stroke::new(1.0, t.line), - ..Default::default() - }) - .show_inside(ui, |ui| { - self.tab_bar_ui(ui, wallet); - }); + // Desktop (wide) shows a left sidebar (shell B); narrow/mobile shows a + // bottom tab bar (shell A). Both drive the same Tab state and screens. + let wide_desktop = View::is_desktop() && ui.available_width() >= 720.0; + if wide_desktop { + egui::SidePanel::left("goblin_sidebar") + .resizable(false) + .exact_width(244.0) + .frame(egui::Frame { + fill: t.bg, + stroke: Stroke::new(1.0, t.line), + inner_margin: Margin { + left: (View::far_left_inset_margin(ui) + 16.0) as i8, + right: 16, + top: (View::get_top_inset() + 28.0) as i8, + bottom: 20, + }, + ..Default::default() + }) + .show_inside(ui, |ui| { + self.sidebar_ui(ui, wallet); + }); + } else { + let bottom_inset = View::get_bottom_inset(); + egui::TopBottomPanel::bottom("goblin_tabs") + .frame(egui::Frame { + fill: t.bg, + inner_margin: Margin { + left: 8, + right: 8, + top: 8, + bottom: (4.0 + bottom_inset) as i8, + }, + stroke: Stroke::new(1.0, t.line), + ..Default::default() + }) + .show_inside(ui, |ui| { + self.tab_bar_ui(ui, wallet); + }); + } // Central content. egui::CentralPanel::default() @@ -218,6 +240,118 @@ impl GoblinWalletView { }); } + /// Desktop left sidebar: wordmark, nav items, profile card. + fn sidebar_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + // Wordmark. + ui.horizontal(|ui| { + widgets_logo(ui); + ui.add_space(8.0); + ui.label( + RichText::new("goblin") + .font(FontId::new(20.0, fonts::bold())) + .color(t.text), + ); + }); + ui.add_space(28.0); + + let has_requests = wallet + .nostr_service() + .map(|s| !s.store.pending_requests().is_empty()) + .unwrap_or(false); + // (tab, icon, label, is_send_action, badge) + let items = [ + (Some(Tab::Home), WALLET, "Wallet", false), + (Some(Tab::Activity), CLOCK, "Activity", has_requests), + (None, crate::gui::icons::ARROW_UP, "Send", false), + (Some(Tab::Receive), ARROW_DOWN, "Receive", false), + (Some(Tab::Me), USER_CIRCLE, "Settings", false), + ]; + for (tab, icon, label, badge) in items { + let active = tab.map(|x| x == self.tab).unwrap_or(false); + let (rect, resp) = + ui.allocate_exact_size(Vec2::new(ui.available_width(), 44.0), Sense::click()); + if active || resp.hovered() { + ui.painter().rect_filled( + rect, + eframe::epaint::CornerRadius::same(12), + if active { t.surface2 } else { t.hover }, + ); + } + let color = if active { t.text } else { t.text_dim }; + ui.painter().text( + rect.left_center() + Vec2::new(14.0, 0.0), + egui::Align2::LEFT_CENTER, + icon, + FontId::new(20.0, fonts::regular()), + color, + ); + ui.painter().text( + rect.left_center() + Vec2::new(44.0, 0.0), + egui::Align2::LEFT_CENTER, + label, + FontId::new( + 15.0, + if active { + fonts::semibold() + } else { + fonts::medium() + }, + ), + color, + ); + if badge { + ui.painter() + .circle_filled(rect.right_center() - Vec2::new(14.0, 0.0), 4.0, t.neg); + } + if resp.clicked() { + match tab { + Some(x) => self.tab = x, + None => self.send = Some(SendFlow::default()), + } + } + ui.add_space(4.0); + } + + // Profile card pinned to the bottom. + ui.with_layout(Layout::bottom_up(Align::Min), |ui| { + let (handle, connected) = wallet + .nostr_service() + .map(|s| { + let id = s.identity.read(); + let h = id + .nip05 + .clone() + .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) + .unwrap_or_else(|| data::short_npub(&hex_of(&id.npub))); + (h, s.is_connected()) + }) + .unwrap_or_else(|| ("Anonymous".to_string(), false)); + w::card(ui, |ui| { + ui.horizontal(|ui| { + w::avatar(ui, &handle, 36.0, 6); + ui.add_space(10.0); + ui.vertical(|ui| { + ui.label( + RichText::new(&handle) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.text), + ); + ui.label( + RichText::new(if connected { + "synced · Tor" + } else { + "connecting…" + }) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); + }); + }); + }); + }); + } + fn home_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let data = wallet.get_data(); ScrollArea::vertical() @@ -629,6 +763,28 @@ impl GoblinWalletView { self.claim_ui(ui, wallet); } + ui.add_space(16.0); + w::kicker(ui, "Appearance"); + ui.add_space(8.0); + w::card(ui, |ui| { + let theme_label = match crate::AppConfig::theme() { + crate::gui::theme::ThemeKind::Light => "Light", + crate::gui::theme::ThemeKind::Dark => "Dark", + crate::gui::theme::ThemeKind::Yellow => "Yellow", + }; + if settings_row_btn(ui, "Theme", theme_label) { + cycle_theme(ui.ctx()); + } + let density_label = match crate::AppConfig::density() { + crate::gui::theme::DensityKind::Compact => "Compact", + crate::gui::theme::DensityKind::Regular => "Regular", + crate::gui::theme::DensityKind::Comfy => "Comfy", + }; + if settings_row_btn(ui, "Density", density_label) { + cycle_density(); + } + }); + ui.add_space(16.0); settings_group(ui, "Wallet", |ui| { settings_row(ui, "Display unit", "ツ (grin)"); @@ -933,6 +1089,29 @@ fn accept_policy_label(wallet: &Wallet) -> &'static str { .unwrap_or("Anyone") } +/// Cycle the color theme Dark → Light → Yellow → Dark and re-apply visuals. +fn cycle_theme(ctx: &egui::Context) { + use crate::gui::theme::ThemeKind; + let next = match crate::AppConfig::theme() { + ThemeKind::Dark => ThemeKind::Light, + ThemeKind::Light => ThemeKind::Yellow, + ThemeKind::Yellow => ThemeKind::Dark, + }; + crate::AppConfig::set_theme(next); + crate::setup_visuals(ctx); +} + +/// Cycle the density Comfy → Regular → Compact → Comfy. +fn cycle_density() { + use crate::gui::theme::DensityKind; + let next = match crate::AppConfig::density() { + DensityKind::Comfy => DensityKind::Regular, + DensityKind::Regular => DensityKind::Compact, + DensityKind::Compact => DensityKind::Comfy, + }; + crate::AppConfig::set_density(next); +} + /// Cycle the incoming-payment accept policy Anyone → Contacts → Ask → Anyone. fn cycle_accept_policy(wallet: &Wallet) { use crate::nostr::config::AcceptPolicy;