The CPA package's general-journal.iif now holds every posted entry through as_of (closing entries left out, D-36), written as plain ASCII with CR LF, DOCNUM the entry id, QuickBooks Desktop's field limits and subaccount types honoured, and every character written differently listed in the export's warnings and the README (grouped by substitution, TINs masked). The 1099 worksheet stops at as_of too. PDFs print CJK, Arabic, Hebrew, Thai and Devanagari (Noto fallbacks, OFL), and so does the viewer. has_full_tin reads Unicode separators. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
338 lines
13 KiB
Rust
338 lines
13 KiB
Rust
//! Look and feel: the palette of the color scheme in force (schemes.rs),
|
|
//! 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),
|
|
],
|
|
};
|
|
|
|
/// Where the palette in force is kept, in the context's memory.
|
|
fn palette_id() -> egui::Id {
|
|
egui::Id::new("wanderbooks-palette")
|
|
}
|
|
|
|
/// The palette in force: the scheme's that `apply` set, else the
|
|
/// viewer's own for the current mode.
|
|
pub fn pal(ctx: &Context) -> Palette {
|
|
ctx.data(|d| d.get_temp::<Palette>(palette_id()))
|
|
.unwrap_or_else(|| crate::schemes::own(ctx.global_style().visuals.dark_mode).palette())
|
|
}
|
|
|
|
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 ([`font_definitions`]).
|
|
pub fn install_fonts(ctx: &Context) {
|
|
ctx.set_fonts(font_definitions());
|
|
}
|
|
|
|
/// The viewer's fonts: Inter with tabular figures frozen in, so every
|
|
/// number in the app lines up digit for digit; egui's own fonts and
|
|
/// Phosphor's icons as fallbacks in every family; then the PDFs' Noto
|
|
/// fallbacks (`ledger_import::fonts::FALLBACKS`: Chinese, Japanese,
|
|
/// Korean, Arabic, Hebrew, Thai, Devanagari), the very bytes the PDFs use,
|
|
/// so a name in any of those scripts shows as written, never as empty
|
|
/// boxes, and no font is in the binary twice. (egui shapes each word,
|
|
/// joining Arabic and setting it right to left, but doesn't reorder the
|
|
/// words of a right-to-left phrase: they read left to right.)
|
|
pub fn font_definitions() -> FontDefinitions {
|
|
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"));
|
|
for (name, bytes) in &ledger_import::fonts::FALLBACKS {
|
|
add(&mut fonts, name, bytes);
|
|
for list in fonts.families.values_mut() {
|
|
list.push((*name).to_owned());
|
|
}
|
|
}
|
|
fonts
|
|
}
|
|
|
|
/// Apply the viewer's own style for light or dark mode (tests).
|
|
#[cfg(test)]
|
|
pub fn apply(ctx: &Context, dark: bool) {
|
|
apply_scheme(ctx, crate::schemes::own(dark));
|
|
}
|
|
|
|
/// Apply a color scheme: its palette, everywhere.
|
|
pub fn apply_scheme(ctx: &Context, scheme: &crate::schemes::Scheme) {
|
|
let dark = scheme.dark;
|
|
let palette = scheme.palette();
|
|
ctx.data_mut(|d| d.insert_temp(palette_id(), palette));
|
|
let p = &palette;
|
|
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);
|
|
// egui's weak text (placeholders, hints) is the text at 60% alpha
|
|
// unless set: the palette's secondary text, which is kept readable.
|
|
visuals.weak_text_color = Some(p.muted);
|
|
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);
|
|
visuals.widgets.open.bg_fill = p.hover;
|
|
visuals.widgets.open.weak_bg_fill = p.hover;
|
|
visuals.widgets.open.bg_stroke = Stroke::new(1.0, p.border);
|
|
visuals.widgets.open.fg_stroke = Stroke::new(1.0, p.text);
|
|
// egui keeps a light and a dark style and paints with the one its
|
|
// theme picks, which follows the system unless told: both get the
|
|
// scheme's visuals, and the theme is pinned to the scheme's mode, so
|
|
// a system that turns light or dark later changes nothing (review of
|
|
// the color schemes, 2026-10-19: figures went unreadable).
|
|
ctx.set_theme(if dark {
|
|
egui::Theme::Dark
|
|
} else {
|
|
egui::Theme::Light
|
|
});
|
|
ctx.all_styles_mut(|style| style.visuals = visuals.clone());
|
|
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;
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// font_definitions: every family ends with the PDFs' Noto fallbacks,
|
|
/// in their order, after Inter, egui's fonts and the icons, each font
|
|
/// the very bytes ledger-import bundles (the same pointer: one copy in
|
|
/// the binary); and a name in each script shows with real glyphs in
|
|
/// every family the viewer uses.
|
|
#[test]
|
|
fn every_family_falls_back_to_the_noto_fonts() {
|
|
let defs = font_definitions();
|
|
let names: Vec<String> = ledger_import::fonts::FALLBACKS
|
|
.iter()
|
|
.map(|(n, _)| (*n).to_owned())
|
|
.collect();
|
|
for family in [
|
|
FontFamily::Proportional,
|
|
FontFamily::Monospace,
|
|
FontFamily::Name(SEMIBOLD.into()),
|
|
FontFamily::Name(BOLD.into()),
|
|
] {
|
|
let list = &defs.families[&family];
|
|
assert!(list.len() > names.len(), "{family:?}: {list:?}");
|
|
assert_eq!(list[list.len() - names.len()..], names[..], "{family:?}");
|
|
}
|
|
assert!(defs.families[&FontFamily::Proportional][0] == "InterTab-Regular");
|
|
for (name, bytes) in &ledger_import::fonts::FALLBACKS {
|
|
let data: &[u8] = &defs.font_data[*name].font;
|
|
assert!(
|
|
std::ptr::eq(data, *bytes),
|
|
"{name} is ledger-import's own bytes, not a copy"
|
|
);
|
|
}
|
|
let ctx = Context::default();
|
|
ctx.set_fonts(defs);
|
|
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
|
out.textures_delta.clear();
|
|
for text in [
|
|
"北京 東京 서울",
|
|
"مكتب الرياض",
|
|
"שלום עולם",
|
|
"สวัสดีครับ",
|
|
"नमस्ते",
|
|
] {
|
|
for id in [
|
|
regular(14.0),
|
|
semibold(14.0),
|
|
bold(14.0),
|
|
FontId::monospace(12.5),
|
|
] {
|
|
// Letters only: egui says a character the replacement
|
|
// glyph's own font draws is missing, the space in Hack.
|
|
let missing: Vec<char> = text
|
|
.chars()
|
|
.filter(|c| !c.is_whitespace() && !ctx.fonts_mut(|f| f.has_glyph(&id, *c)))
|
|
.collect();
|
|
assert!(missing.is_empty(), "{text} in {id:?}: {missing:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|