Files
wanderbooks/crates/ledger-gui/src/theme.rs
T
swaitsandClaude Opus 5.5 105fd52694 feat: ledger-gui — a read-only, live desktop view of the books
egui/wgpu app for owners and their accountant: Overview, Cash, Profit,
Transactions (with rendered receipts and journal history), Reports
(statements with interval columns, comparisons, drill-down), Accounts,
Documents, Company, Journal; Ctrl+K palette; back/forward; light/dark.
Follows the journal live (~30 ms from write to screen). --demo opens a
deterministic demo company, also used for screenshot tests.

Engine: ledger-store Snapshot (read-only, incremental, tolerant of a
half-written tail); ledger-core analysis (intervals, balance series,
P&L by interval, cash flows); ledger-config extracted from ledger-mcp
with the full company profile and cash_accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 16:20:14 -07:00

229 lines
8.1 KiB
Rust

//! Look and feel: one palette per mode, Inter with tabular figures, and
//! the spacing the whole app shares.
use egui::{
Color32, Context, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Margin, Stroke,
TextStyle, Visuals,
};
use std::sync::Arc;
/// Colors for one mode. Every color in the app comes from here.
#[derive(Clone, Copy, Debug)]
pub struct Palette {
pub bg: Color32,
pub surface: Color32,
pub sidebar: Color32,
pub hover: Color32,
pub selected: Color32,
pub border: Color32,
pub text: Color32,
pub muted: Color32,
pub faint: Color32,
pub accent: Color32,
pub accent_soft: Color32,
pub positive: Color32,
pub negative: Color32,
pub warning: Color32,
pub warning_soft: Color32,
pub negative_soft: Color32,
pub positive_soft: Color32,
pub new_highlight: Color32,
/// Chart series, in order.
pub series: [Color32; 6],
}
pub const LIGHT: Palette = Palette {
bg: Color32::from_rgb(0xF6, 0xF6, 0xF4),
surface: Color32::from_rgb(0xFF, 0xFF, 0xFF),
sidebar: Color32::from_rgb(0xEF, 0xEF, 0xEC),
hover: Color32::from_rgb(0xF2, 0xF2, 0xF0),
selected: Color32::from_rgb(0xE8, 0xE9, 0xFB),
border: Color32::from_rgb(0xE4, 0xE4, 0xE0),
text: Color32::from_rgb(0x1B, 0x1B, 0x1F),
muted: Color32::from_rgb(0x67, 0x67, 0x70),
faint: Color32::from_rgb(0x9C, 0x9C, 0xA4),
accent: Color32::from_rgb(0x4F, 0x46, 0xE5),
accent_soft: Color32::from_rgb(0xEE, 0xEE, 0xFD),
positive: Color32::from_rgb(0x15, 0x80, 0x3D),
negative: Color32::from_rgb(0xB9, 0x1C, 0x1C),
warning: Color32::from_rgb(0xB4, 0x53, 0x09),
warning_soft: Color32::from_rgb(0xFE, 0xF3, 0xE2),
negative_soft: Color32::from_rgb(0xFD, 0xEC, 0xEC),
positive_soft: Color32::from_rgb(0xE8, 0xF5, 0xEC),
new_highlight: Color32::from_rgb(0xFF, 0xF7, 0xD6),
series: [
Color32::from_rgb(0x4F, 0x46, 0xE5),
Color32::from_rgb(0x0D, 0x94, 0x88),
Color32::from_rgb(0xD9, 0x77, 0x06),
Color32::from_rgb(0xDB, 0x27, 0x77),
Color32::from_rgb(0x25, 0x63, 0xEB),
Color32::from_rgb(0x64, 0x74, 0x8B),
],
};
pub const DARK: Palette = Palette {
bg: Color32::from_rgb(0x11, 0x12, 0x14),
surface: Color32::from_rgb(0x19, 0x1A, 0x1D),
sidebar: Color32::from_rgb(0x15, 0x16, 0x18),
hover: Color32::from_rgb(0x22, 0x23, 0x27),
selected: Color32::from_rgb(0x26, 0x27, 0x45),
border: Color32::from_rgb(0x2A, 0x2B, 0x30),
text: Color32::from_rgb(0xEC, 0xEC, 0xEE),
muted: Color32::from_rgb(0xA3, 0xA3, 0xAC),
faint: Color32::from_rgb(0x6E, 0x6E, 0x78),
accent: Color32::from_rgb(0x8B, 0x93, 0xFA),
accent_soft: Color32::from_rgb(0x24, 0x25, 0x45),
positive: Color32::from_rgb(0x4A, 0xDE, 0x80),
negative: Color32::from_rgb(0xF8, 0x71, 0x71),
warning: Color32::from_rgb(0xFB, 0xBF, 0x24),
warning_soft: Color32::from_rgb(0x33, 0x29, 0x16),
negative_soft: Color32::from_rgb(0x3A, 0x1C, 0x1E),
positive_soft: Color32::from_rgb(0x16, 0x30, 0x22),
new_highlight: Color32::from_rgb(0x36, 0x31, 0x18),
series: [
Color32::from_rgb(0x8B, 0x93, 0xFA),
Color32::from_rgb(0x2D, 0xD4, 0xBF),
Color32::from_rgb(0xFB, 0xBF, 0x24),
Color32::from_rgb(0xF4, 0x72, 0xB6),
Color32::from_rgb(0x60, 0xA5, 0xFA),
Color32::from_rgb(0x94, 0xA3, 0xB8),
],
};
/// The palette for the current mode.
pub fn pal(ctx: &Context) -> &'static Palette {
if ctx.global_style().visuals.dark_mode {
&DARK
} else {
&LIGHT
}
}
pub const SEMIBOLD: &str = "semibold";
pub const BOLD: &str = "bold";
pub fn regular(size: f32) -> FontId {
FontId::new(size, FontFamily::Proportional)
}
pub fn semibold(size: f32) -> FontId {
FontId::new(size, FontFamily::Name(SEMIBOLD.into()))
}
pub fn bold(size: f32) -> FontId {
FontId::new(size, FontFamily::Name(BOLD.into()))
}
/// Install fonts: Inter with tabular figures frozen in, so every number in
/// the app lines up digit for digit. Phosphor icons ride along as a
/// fallback in every family.
pub fn install_fonts(ctx: &Context) {
let mut fonts = FontDefinitions::default();
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
fonts
.font_data
.insert(name.to_owned(), Arc::new(FontData::from_static(bytes)));
};
add(
&mut fonts,
"InterTab-Regular",
include_bytes!("../assets/fonts/InterTab-Regular.ttf"),
);
add(
&mut fonts,
"InterTab-SemiBold",
include_bytes!("../assets/fonts/InterTab-SemiBold.ttf"),
);
add(
&mut fonts,
"InterTab-Bold",
include_bytes!("../assets/fonts/InterTab-Bold.ttf"),
);
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
let fallbacks: Vec<String> = fonts
.families
.get(&FontFamily::Proportional)
.cloned()
.unwrap_or_default();
// Text first, icons as a fallback. The bundled Inter copies have their
// private-use glyphs removed, so Phosphor's icons (which live there)
// are never shadowed.
let family = |primary: &str| {
let mut v = vec![primary.to_owned()];
v.extend(fallbacks.iter().cloned());
v
};
fonts
.families
.insert(FontFamily::Proportional, family("InterTab-Regular"));
fonts.families.insert(
FontFamily::Name(SEMIBOLD.into()),
family("InterTab-SemiBold"),
);
fonts
.families
.insert(FontFamily::Name(BOLD.into()), family("InterTab-Bold"));
ctx.set_fonts(fonts);
}
/// Apply the app's style for light or dark mode.
pub fn apply(ctx: &Context, dark: bool) {
let p = if dark { &DARK } else { &LIGHT };
let mut visuals = if dark {
Visuals::dark()
} else {
Visuals::light()
};
visuals.panel_fill = p.bg;
visuals.window_fill = p.surface;
visuals.extreme_bg_color = p.surface;
visuals.faint_bg_color = p.hover;
visuals.override_text_color = Some(p.text);
visuals.hyperlink_color = p.accent;
visuals.selection.bg_fill = p.selected;
visuals.selection.stroke = Stroke::new(1.0, p.accent);
visuals.window_stroke = Stroke::new(1.0, p.border);
visuals.window_corner_radius = CornerRadius::same(12);
visuals.menu_corner_radius = CornerRadius::same(10);
for w in [
&mut visuals.widgets.noninteractive,
&mut visuals.widgets.inactive,
&mut visuals.widgets.hovered,
&mut visuals.widgets.active,
&mut visuals.widgets.open,
] {
w.corner_radius = CornerRadius::same(8);
}
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, p.border);
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, p.text);
visuals.widgets.inactive.bg_fill = p.surface;
visuals.widgets.inactive.weak_bg_fill = p.surface;
visuals.widgets.inactive.bg_stroke = Stroke::new(1.0, p.border);
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, p.text);
visuals.widgets.hovered.bg_fill = p.hover;
visuals.widgets.hovered.weak_bg_fill = p.hover;
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, p.border);
visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, p.text);
visuals.widgets.active.bg_fill = p.selected;
visuals.widgets.active.weak_bg_fill = p.selected;
visuals.widgets.active.fg_stroke = Stroke::new(1.0, p.text);
ctx.set_visuals(visuals);
ctx.all_styles_mut(|style| {
style.text_styles = [
(TextStyle::Heading, semibold(20.0)),
(TextStyle::Body, regular(14.0)),
(TextStyle::Button, regular(14.0)),
(TextStyle::Small, regular(12.0)),
(TextStyle::Monospace, FontId::monospace(12.5)),
]
.into();
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
style.spacing.button_padding = egui::vec2(10.0, 5.0);
style.spacing.interact_size.y = 28.0;
style.spacing.window_margin = Margin::same(16);
style.spacing.scroll.bar_width = 8.0;
style.spacing.scroll.floating = true;
style.interaction.selectable_labels = false;
});
}