viewer: color schemes, twenty families and every variant, with a light/dark filter
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Owner, 2026-10-19: full color-scheme support for the viewer, the top Neovim-style schemes, about twenty families each with a flyout of all its variants, a light/dark/all filter, Ayu included, alphabetical, a whole row to click. - The sidebar's corner button (the light/dark toggle before) opens the menu: the filter, then, alphabetically and all on screen at once (in columns in a short window), Ayu, Catppuccin, Cyberdream, Dracula, Everforest, GitHub, Gruvbox, Kanagawa, Material, Melange, Monokai Pro, Night Owl, Nightfox, Nord, One Dark, Oxocarbon, Rosé Pine, Solarized, Sonokai, Tokyo Night and Wanderbooks (the viewer's own Light and Dark): 80 schemes, each variant a row with a strip of its colors. - Colors from each scheme's official source, checked value by value (docs/color-schemes.md; the table names the sources). - Every palette, the viewer's own included, keeps every text color (figures, gains and losses, warnings, links, secondary and faint text) at WCAG AA (4.5:1) on every ground it sits on, soft fills included; a color that falls short keeps its hue and only changes lightness. Text, secondary and faint text stay apart (each 1.2 times the next), gains, losses and text and the charts' series shown together stay plainly different (CIE76 ΔE 20). - The scheme sets both egui styles and pins its mode, so the system turning light or dark changes nothing (it made figures unreadable). - Preferences now live in the platform's place for app settings ($XDG_CONFIG_HOME/wanderbooks-viewer on Linux), not eframe's default, which was $XDG_DATA_HOME/wanderbooks: the data root's own place. The old file is moved once. - Tests: every page of thirteen painted in all 80 schemes, the same words in the same places, each text readable on what was painted under it; the menu's order, reach and whole-row clicks in a tall and a short window; the system flip; preferences restored. Two review rounds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
13 files changed
+3700
-87
No files matched your search
+275
-53
@@ -5,10 +5,9 @@ use crate::data::{Loader, Source, Update};
|
||||
use crate::nav::{Nav, Route, Section};
|
||||
use crate::pages;
|
||||
use crate::receipts::Receipts;
|
||||
use crate::schemes::{self, Filter, Scheme};
|
||||
use crate::theme::{self, pal};
|
||||
use egui::{
|
||||
Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, Sense, Stroke, Ui, vec2,
|
||||
};
|
||||
use egui::{Align, CornerRadius, Frame, Key, Layout, Margin, RichText, Sense, Stroke, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -42,13 +41,96 @@ impl Shell<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The height of a family's row in the color scheme menu, for laying the
|
||||
/// families out in columns.
|
||||
const FAMILY_ROW: f32 = 34.0;
|
||||
|
||||
/// The width of a variant's row in a family's flyout: room for its colors
|
||||
/// and the longest name.
|
||||
const SCHEME_ROW_WIDTH: f32 = 200.0;
|
||||
|
||||
/// Where the viewer keeps its color scheme ("Family/Variant").
|
||||
const SCHEME_KEY: &str = "scheme";
|
||||
/// Where it keeps its scheme menu's filter.
|
||||
const FILTER_KEY: &str = "scheme_filter";
|
||||
|
||||
/// One variant in the color scheme menu: a strip of its colors and its
|
||||
/// name, the whole row one button, selected when it is the one in force.
|
||||
fn scheme_row(ui: &mut Ui, scheme: &Scheme, current: bool) -> egui::Response {
|
||||
let p = pal(ui.ctx());
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(SCHEME_ROW_WIDTH, 28.0), Sense::click());
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::selected(
|
||||
egui::WidgetType::SelectableLabel,
|
||||
true,
|
||||
current,
|
||||
scheme.name,
|
||||
)
|
||||
});
|
||||
if ui.is_rect_visible(rect) {
|
||||
let painter = ui.painter();
|
||||
let fill = if current {
|
||||
Some(p.selected)
|
||||
} else if response.hovered() {
|
||||
Some(p.hover)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(fill) = fill {
|
||||
painter.rect_filled(rect, CornerRadius::same(6), fill);
|
||||
}
|
||||
let strip = egui::Rect::from_min_size(
|
||||
egui::pos2(rect.left() + 8.0, rect.center().y - 7.0),
|
||||
vec2(50.0, 14.0),
|
||||
);
|
||||
let w = strip.width() / 5.0;
|
||||
for (i, c) in scheme.swatch().into_iter().enumerate() {
|
||||
let r = egui::Rect::from_min_size(
|
||||
strip.min + vec2(w * i as f32, 0.0),
|
||||
vec2(w, strip.height()),
|
||||
);
|
||||
painter.rect_filled(r, 0.0, c);
|
||||
}
|
||||
painter.rect_stroke(
|
||||
strip,
|
||||
3.0,
|
||||
Stroke::new(1.0, p.border),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(strip.right() + 10.0, rect.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
scheme.name,
|
||||
theme::regular(14.0),
|
||||
p.text,
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// The scheme and menu filter kept from last time (`get` reads the
|
||||
/// viewer's preferences): the scheme kept; before schemes, the light or
|
||||
/// dark choice kept as "dark"; else the viewer's own for the system's
|
||||
/// mode (schemes INVARIANT 4).
|
||||
fn restored(get: impl Fn(&str) -> Option<String>, system_dark: bool) -> (&'static Scheme, Filter) {
|
||||
let dark = get("dark").map_or(system_dark, |v| v == "true");
|
||||
let scheme = get(SCHEME_KEY).map_or_else(|| schemes::own(dark), |k| Scheme::from_key(&k, dark));
|
||||
let filter = get(FILTER_KEY)
|
||||
.and_then(|f| Filter::from_name(&f))
|
||||
.unwrap_or_default();
|
||||
(scheme, filter)
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
loader: Option<Loader>,
|
||||
books: Option<Arc<Books>>,
|
||||
error: Option<String>,
|
||||
nav: Nav,
|
||||
receipts: Receipts,
|
||||
dark: bool,
|
||||
/// The color scheme in force (schemes.rs).
|
||||
scheme: &'static Scheme,
|
||||
/// Which schemes its menu shows.
|
||||
filter: Filter,
|
||||
state: PageState,
|
||||
palette: crate::palette::Palette,
|
||||
updated_at: f64,
|
||||
@@ -58,27 +140,29 @@ pub struct App {
|
||||
impl App {
|
||||
/// The live app over a books directory.
|
||||
pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self {
|
||||
let dark = cc
|
||||
.storage
|
||||
.and_then(|s| s.get_string("dark"))
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(cc.egui_ctx.global_style().visuals.dark_mode);
|
||||
let mut app = Self::bare(&cc.egui_ctx, dark);
|
||||
// The scheme kept from last time; before schemes, a light/dark
|
||||
// choice; else the system's mode (schemes INVARIANT 4).
|
||||
let (scheme, filter) = restored(
|
||||
|key| cc.storage.and_then(|s| s.get_string(key)),
|
||||
cc.egui_ctx.global_style().visuals.dark_mode,
|
||||
);
|
||||
let mut app = Self::bare(&cc.egui_ctx, scheme);
|
||||
app.filter = filter;
|
||||
app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone()));
|
||||
app
|
||||
}
|
||||
|
||||
/// An app showing fixed books (tests and screenshots).
|
||||
pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self {
|
||||
let mut app = Self::bare(ctx, dark);
|
||||
let mut app = Self::bare(ctx, schemes::own(dark));
|
||||
app.books = Some(Arc::new(books));
|
||||
app.nav = Nav::new(start);
|
||||
app
|
||||
}
|
||||
|
||||
fn bare(ctx: &egui::Context, dark: bool) -> Self {
|
||||
fn bare(ctx: &egui::Context, scheme: &'static Scheme) -> Self {
|
||||
theme::install_fonts(ctx);
|
||||
theme::apply(ctx, dark);
|
||||
theme::apply_scheme(ctx, scheme);
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
Self {
|
||||
loader: None,
|
||||
@@ -86,7 +170,8 @@ impl App {
|
||||
error: None,
|
||||
nav: Nav::new(Route::Overview),
|
||||
receipts: Receipts::new(ctx),
|
||||
dark,
|
||||
scheme,
|
||||
filter: Filter::default(),
|
||||
state: PageState::default(),
|
||||
palette: Default::default(),
|
||||
updated_at: -1e9,
|
||||
@@ -99,6 +184,115 @@ impl App {
|
||||
self.nav.go(r);
|
||||
}
|
||||
|
||||
/// Wear the color scheme kept as `key` ("Family/Variant"; tests).
|
||||
pub fn set_scheme(&mut self, ctx: &egui::Context, key: &str) {
|
||||
self.choose(ctx, Scheme::from_key(key, self.scheme.dark));
|
||||
}
|
||||
|
||||
/// The color scheme in force, as kept ("Family/Variant").
|
||||
pub fn scheme_key(&self) -> String {
|
||||
self.scheme.key()
|
||||
}
|
||||
|
||||
fn choose(&mut self, ctx: &egui::Context, scheme: &'static Scheme) {
|
||||
self.scheme = scheme;
|
||||
theme::apply_scheme(ctx, scheme);
|
||||
}
|
||||
|
||||
/// The color scheme button and its menu: a filter (all, light, dark),
|
||||
/// then each family with a flyout of its variants. `compact`: the
|
||||
/// narrow rail's icon alone.
|
||||
fn scheme_menu(&mut self, ui: &mut Ui, compact: bool) {
|
||||
let p = pal(ui.ctx());
|
||||
let text = if compact {
|
||||
RichText::new(icon::PALETTE).size(16.0).color(p.muted)
|
||||
} else {
|
||||
RichText::new(format!("{} {}", icon::PALETTE, self.scheme.label()))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted)
|
||||
};
|
||||
let mut button = egui::Button::new(text).frame(false);
|
||||
if compact {
|
||||
button = button.min_size(vec2(32.0, 32.0));
|
||||
}
|
||||
// Open while the filter changes; closed when a scheme is chosen.
|
||||
let config = egui::containers::menu::MenuConfig::new()
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside);
|
||||
let mut chosen = None;
|
||||
let (response, _) = egui::containers::menu::MenuButton::from_button(button)
|
||||
.config(config)
|
||||
.ui(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
ui.horizontal(|ui| {
|
||||
for f in [Filter::All, Filter::Light, Filter::Dark] {
|
||||
ui.selectable_value(&mut self.filter, f, f.name());
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
// Every family on screen at once, in as many columns as
|
||||
// the window's height needs: nothing hides below a scroll.
|
||||
let families = schemes::families(self.filter);
|
||||
let rows = (((ui.ctx().content_rect().height() - 160.0) / FAMILY_ROW).floor()
|
||||
as usize)
|
||||
.clamp(6, families.len().max(6));
|
||||
let font = theme::regular(14.0);
|
||||
let check = ui
|
||||
.painter()
|
||||
.layout_no_wrap(icon::CHECK.into(), font.clone(), p.accent)
|
||||
.size()
|
||||
.x;
|
||||
ui.horizontal_top(|ui| {
|
||||
for column in families.chunks(rows) {
|
||||
// Each family's button as wide as its column, its
|
||||
// flyout opening beside the column.
|
||||
ui.allocate_ui_with_layout(
|
||||
vec2(170.0, 0.0),
|
||||
Layout::top_down_justified(Align::LEFT),
|
||||
|ui| {
|
||||
ui.set_width(170.0);
|
||||
for (family, variants) in column {
|
||||
let here = *family == self.scheme.family
|
||||
&& self.filter.shows(self.scheme);
|
||||
// Only the family in force is checked; the
|
||||
// others are indented by the check's width,
|
||||
// so names line up and a screen reader hears
|
||||
// the check only where it is.
|
||||
let mut label = egui::text::LayoutJob::default();
|
||||
if here {
|
||||
label.append(
|
||||
icon::CHECK,
|
||||
0.0,
|
||||
egui::TextFormat::simple(font.clone(), p.accent),
|
||||
);
|
||||
}
|
||||
let indent = if here { 8.0 } else { check + 8.0 };
|
||||
label.append(
|
||||
family,
|
||||
indent,
|
||||
egui::TextFormat::simple(font.clone(), p.text),
|
||||
);
|
||||
ui.menu_button(label, |ui| {
|
||||
for v in variants {
|
||||
if scheme_row(ui, v, std::ptr::eq(*v, self.scheme))
|
||||
.clicked()
|
||||
{
|
||||
chosen = Some(*v);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
response.on_hover_text(format!("Color scheme: {}", self.scheme.label()));
|
||||
if let Some(v) = chosen {
|
||||
self.choose(ui.ctx(), v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Block until every receipt on screen has rendered (screenshots).
|
||||
pub fn settle(&mut self, ctx: &egui::Context) {
|
||||
self.receipts.settle(ctx);
|
||||
@@ -283,7 +477,7 @@ impl App {
|
||||
egui::Align2::CENTER_CENTER,
|
||||
initial,
|
||||
theme::bold(15.0),
|
||||
Color32::WHITE,
|
||||
schemes::on(p.accent),
|
||||
);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
@@ -374,25 +568,7 @@ impl App {
|
||||
}
|
||||
});
|
||||
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||
let label = if self.dark {
|
||||
format!("{} Light mode", icon::SUN)
|
||||
} else {
|
||||
format!("{} Dark mode", icon::MOON)
|
||||
};
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
RichText::new(label)
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.dark = !self.dark;
|
||||
theme::apply(ui.ctx(), self.dark);
|
||||
}
|
||||
self.scheme_menu(ui, false);
|
||||
ui.add_space(6.0);
|
||||
if let Some(b) = &books {
|
||||
live_status(
|
||||
@@ -433,7 +609,7 @@ impl App {
|
||||
egui::Align2::CENTER_CENTER,
|
||||
name.chars().next().unwrap_or('W'),
|
||||
theme::bold(15.0),
|
||||
Color32::WHITE,
|
||||
schemes::on(p.accent),
|
||||
);
|
||||
ui.interact(r, ui.id().with("logo"), Sense::hover())
|
||||
.on_hover_text(format!("{name} · Books"));
|
||||
@@ -487,23 +663,7 @@ impl App {
|
||||
}
|
||||
});
|
||||
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
|
||||
let (glyph, tip) = if self.dark {
|
||||
(icon::SUN, "Light mode")
|
||||
} else {
|
||||
(icon::MOON, "Dark mode")
|
||||
};
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(RichText::new(glyph).size(16.0).color(p.muted))
|
||||
.frame(false)
|
||||
.min_size(vec2(32.0, 32.0)),
|
||||
)
|
||||
.on_hover_text(tip)
|
||||
.clicked()
|
||||
{
|
||||
self.dark = !self.dark;
|
||||
theme::apply(ui.ctx(), self.dark);
|
||||
}
|
||||
self.scheme_menu(ui, true);
|
||||
ui.add_space(6.0);
|
||||
if let Some(b) = &books {
|
||||
let live = self.loader.is_some() || self.live_label;
|
||||
@@ -831,6 +991,68 @@ impl eframe::App for App {
|
||||
}
|
||||
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string("dark", self.dark.to_string());
|
||||
storage.set_string(SCHEME_KEY, self.scheme.key());
|
||||
storage.set_string(FILTER_KEY, self.filter.name().to_string());
|
||||
storage.set_string("dark", self.scheme.dark.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn kept(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let map: HashMap<String, String> = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
move |k| map.get(k).cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kept_scheme_and_filter_come_back() {
|
||||
let (s, f) = restored(
|
||||
kept(&[
|
||||
("scheme", "Ayu/Mirage"),
|
||||
("scheme_filter", "Dark"),
|
||||
("dark", "false"),
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key(), "Ayu/Mirage", "the scheme wins over the old choice");
|
||||
assert_eq!(f, Filter::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_schemes_the_light_or_dark_choice_is_kept() {
|
||||
let (s, f) = restored(kept(&[("dark", "true")]), false);
|
||||
assert!(std::ptr::eq(s, schemes::own(true)));
|
||||
assert_eq!(f, Filter::All);
|
||||
let (s, _) = restored(kept(&[("dark", "false")]), true);
|
||||
assert!(std::ptr::eq(s, schemes::own(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_nothing_kept_the_systems_mode_decides() {
|
||||
for dark in [false, true] {
|
||||
let (s, f) = restored(kept(&[]), dark);
|
||||
assert!(std::ptr::eq(s, schemes::own(dark)));
|
||||
assert_eq!(f, Filter::All);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lost_scheme_or_filter_falls_back() {
|
||||
let (s, f) = restored(
|
||||
kept(&[
|
||||
("scheme", "Gone/Away"),
|
||||
("scheme_filter", "Sepia"),
|
||||
("dark", "true"),
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert!(std::ptr::eq(s, schemes::own(true)));
|
||||
assert_eq!(f, Filter::All);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ pub mod fmt;
|
||||
pub mod nav;
|
||||
pub mod pages;
|
||||
pub mod palette;
|
||||
mod prefs;
|
||||
mod receipts;
|
||||
mod schemes;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod theme;
|
||||
@@ -129,6 +131,9 @@ pub fn run(
|
||||
// Small on purpose: tiling window managers hand out small slots
|
||||
// regardless, and every screen lays itself out to fit.
|
||||
.with_min_inner_size([360.0, 400.0]),
|
||||
// The platform's place for app settings, never the books' folder
|
||||
// (prefs.rs).
|
||||
persistence_path: prefs::prepare(),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(eframe::run_native(
|
||||
|
||||
@@ -97,10 +97,10 @@ fn change(
|
||||
prev: Decimal,
|
||||
up_is_good: bool,
|
||||
what: &str,
|
||||
p: &theme::Palette,
|
||||
) -> Option<(String, egui::Color32)> {
|
||||
let pct = fmt::pct_change(prev, now)?;
|
||||
let good = (pct >= 0.0) == up_is_good;
|
||||
let p = &theme::LIGHT;
|
||||
let arrow = if pct >= 0.0 { "▲" } else { "▼" };
|
||||
Some((
|
||||
format!("{arrow} {:.0}% vs {what}", pct.abs()),
|
||||
@@ -153,8 +153,7 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
Kpi {
|
||||
label: "Cash on hand",
|
||||
value: fmt::dollars(k.cash),
|
||||
sub: change(k.cash, k.cash_30d_ago, true, "30 days ago")
|
||||
.map(|(t, c)| (t, color_for(c, p))),
|
||||
sub: change(k.cash, k.cash_30d_ago, true, "30 days ago", &p),
|
||||
spark: Some(&cash_spark),
|
||||
how: "The balance of every cash account (bank, savings, cash on hand) at the end of today.",
|
||||
},
|
||||
@@ -179,8 +178,8 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
k.expenses_prev_month,
|
||||
false,
|
||||
"same days last month",
|
||||
)
|
||||
.map(|(t, c)| (t, color_for(c, p))),
|
||||
&p,
|
||||
),
|
||||
spark: None,
|
||||
how: "Expenses dated this month (accrual basis: when incurred, not when paid).",
|
||||
},
|
||||
@@ -195,8 +194,8 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
k.revenue_prev_month,
|
||||
true,
|
||||
"same days last month",
|
||||
)
|
||||
.map(|(t, c)| (t, color_for(c, p))),
|
||||
&p,
|
||||
),
|
||||
spark: None,
|
||||
how: "Revenue dated this month.",
|
||||
},
|
||||
@@ -241,18 +240,6 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `change` picks from the light palette; map to the current one.
|
||||
fn color_for(c: egui::Color32, p: &theme::Palette) -> egui::Color32 {
|
||||
let l = &theme::LIGHT;
|
||||
if c == l.positive {
|
||||
p.positive
|
||||
} else if c == l.negative {
|
||||
p.negative
|
||||
} else {
|
||||
p.muted
|
||||
}
|
||||
}
|
||||
|
||||
/// "Needs attention", full width: a summary of how many problems, to-dos,
|
||||
/// and notes there are, then each item as a row (its severity, the title
|
||||
/// that opens where to look, what's wrong, and the prompt for the agent at
|
||||
|
||||
@@ -530,7 +530,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
};
|
||||
let (out_t, out_c) = money(flow.paid, p.text);
|
||||
let (in_t, in_c) = money(flow.received, p.positive);
|
||||
let (net_t, net_c) = paid_cell(flow.net(), p);
|
||||
let (net_t, net_c) = paid_cell(flow.net(), &p);
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Where the viewer keeps its preferences (window size, color scheme and
|
||||
//! its filter): the platform's own place for an app's settings, in a
|
||||
//! folder of the viewer's own.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the viewer's preferences live where the platform keeps app
|
||||
//! settings, and never in the books' private folder.
|
||||
//! SOURCE: owner, 2026-10-19 (preferences "in the OS's platform-native
|
||||
//! location (like XDG on Linux)"); plan I-8 (the viewer writes
|
||||
//! nothing under the data root; only eframe preferences under
|
||||
//! $HOME). Found 2026-10-19: eframe's default, named after the app
|
||||
//! id "wanderbooks", is $XDG_DATA_HOME/wanderbooks, the data
|
||||
//! root's own default place.
|
||||
//! INVARIANTS:
|
||||
//! 1. Linux and the BSDs: $XDG_CONFIG_HOME/wanderbooks-viewer/viewer.ron
|
||||
//! ($XDG_CONFIG_HOME only if absolute, else ~/.config); macOS:
|
||||
//! ~/Library/Application Support/wanderbooks-viewer/viewer.ron;
|
||||
//! Windows: %APPDATA%\wanderbooks-viewer\viewer.ron.
|
||||
//! 2. Preferences eframe kept in its old place (app.ron in
|
||||
//! $XDG_DATA_HOME/wanderbooks and the like) are moved once, when the
|
||||
//! new place has none: only that one file, nothing else there is
|
||||
//! read or touched.
|
||||
//! ```
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The viewer's own folder for its preferences.
|
||||
pub const DIR: &str = "wanderbooks-viewer";
|
||||
/// Its preferences file.
|
||||
pub const FILE: &str = "viewer.ron";
|
||||
/// eframe's own file name, in its old place.
|
||||
const OLD_FILE: &str = "app.ron";
|
||||
|
||||
/// The preferences file for an account whose home is `home`, given
|
||||
/// `$XDG_CONFIG_HOME` (or `%APPDATA%` on Windows) (INVARIANT 1).
|
||||
pub fn path_in(home: &Path, config_home: Option<&Path>) -> PathBuf {
|
||||
let base = if cfg!(target_os = "macos") {
|
||||
home.join("Library").join("Application Support")
|
||||
} else if cfg!(windows) {
|
||||
config_home
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| home.join("AppData").join("Roaming"))
|
||||
} else {
|
||||
config_home
|
||||
.filter(|p| p.is_absolute())
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| home.join(".config"))
|
||||
};
|
||||
base.join(DIR).join(FILE)
|
||||
}
|
||||
|
||||
/// The preferences file for this account, if it has a home.
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
let home = std::env::home_dir()?;
|
||||
let var = if cfg!(windows) {
|
||||
"APPDATA"
|
||||
} else {
|
||||
"XDG_CONFIG_HOME"
|
||||
};
|
||||
let config = std::env::var_os(var).map(PathBuf::from);
|
||||
Some(path_in(&home, config.as_deref()))
|
||||
}
|
||||
|
||||
/// Move preferences from `old_dir/app.ron` to `new` if `new` doesn't
|
||||
/// exist yet (INVARIANT 2). Failures change nothing that matters: the
|
||||
/// viewer starts with its defaults.
|
||||
pub fn move_old(old_dir: &Path, new: &Path) {
|
||||
let old = old_dir.join(OLD_FILE);
|
||||
if new.exists() || !old.is_file() {
|
||||
return;
|
||||
}
|
||||
let Some(dir) = new.parent() else {
|
||||
return;
|
||||
};
|
||||
if std::fs::create_dir_all(dir).is_err() {
|
||||
return;
|
||||
}
|
||||
if std::fs::copy(&old, new).is_ok() {
|
||||
let _ = std::fs::remove_file(&old);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the viewer keeps its preferences, its old ones moved in
|
||||
/// (INVARIANT 2): None when the account has no home.
|
||||
pub fn prepare() -> Option<PathBuf> {
|
||||
let new = path()?;
|
||||
if let Some(old_dir) = eframe::storage_dir("wanderbooks") {
|
||||
move_old(&old_dir, &new);
|
||||
}
|
||||
Some(new)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn on_linux_it_is_the_xdg_config_folder_of_its_own() {
|
||||
let home = Path::new("/home/someone");
|
||||
assert_eq!(
|
||||
path_in(home, None),
|
||||
Path::new("/home/someone/.config/wanderbooks-viewer/viewer.ron")
|
||||
);
|
||||
assert_eq!(
|
||||
path_in(home, Some(Path::new("/cfg"))),
|
||||
Path::new("/cfg/wanderbooks-viewer/viewer.ron")
|
||||
);
|
||||
// A relative XDG_CONFIG_HOME is ignored (the spec says so).
|
||||
assert_eq!(
|
||||
path_in(home, Some(Path::new("cfg"))),
|
||||
Path::new("/home/someone/.config/wanderbooks-viewer/viewer.ron")
|
||||
);
|
||||
// Never the data root's place.
|
||||
assert!(!path_in(home, None).starts_with("/home/someone/.local/share/wanderbooks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_preferences_are_moved_once_and_nothing_else_is_touched() {
|
||||
let dir = crate::test_support::TempDir(
|
||||
std::env::temp_dir().join(format!("ledger-gui-prefs-{}", std::process::id())),
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir.0);
|
||||
std::fs::create_dir_all(&dir.0).unwrap();
|
||||
let old = dir.0.join("old");
|
||||
std::fs::create_dir(&old).unwrap();
|
||||
std::fs::write(old.join("app.ron"), "(scheme: \"Ayu/Mirage\")").unwrap();
|
||||
std::fs::write(old.join("other"), "kept").unwrap();
|
||||
let new = dir.0.join("cfg/wanderbooks-viewer/viewer.ron");
|
||||
move_old(&old, &new);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&new).unwrap(),
|
||||
"(scheme: \"Ayu/Mirage\")"
|
||||
);
|
||||
assert!(!old.join("app.ron").exists(), "moved, not copied");
|
||||
assert_eq!(std::fs::read_to_string(old.join("other")).unwrap(), "kept");
|
||||
// Once: new preferences are never overwritten by old ones.
|
||||
std::fs::write(old.join("app.ron"), "older").unwrap();
|
||||
std::fs::write(&new, "newer").unwrap();
|
||||
move_old(&old, &new);
|
||||
assert_eq!(std::fs::read_to_string(&new).unwrap(), "newer");
|
||||
assert!(old.join("app.ron").exists());
|
||||
// Nothing to move: nothing made.
|
||||
let none = dir.0.join("none/viewer.ron");
|
||||
move_old(&dir.0.join("missing"), &none);
|
||||
assert!(!none.exists() && !dir.0.join("none").exists());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -193,7 +193,7 @@ pub(crate) fn render(
|
||||
books: Books,
|
||||
route: Route,
|
||||
set: impl FnOnce(&mut PageState),
|
||||
) -> (Vec<Painted>, &'static crate::theme::Palette) {
|
||||
) -> (Vec<Painted>, crate::theme::Palette) {
|
||||
let (texts, _, p) = render_all(books, route, set);
|
||||
(texts, p)
|
||||
}
|
||||
@@ -206,7 +206,7 @@ pub(crate) fn render_all(
|
||||
) -> (
|
||||
Vec<Painted>,
|
||||
Vec<(egui::Rect, Color32)>,
|
||||
&'static crate::theme::Palette,
|
||||
crate::theme::Palette,
|
||||
) {
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1400.0, 2400.0))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Look and feel: one palette per mode, Inter with tabular figures, and
|
||||
//! the spacing the whole app shares.
|
||||
//! 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,
|
||||
@@ -90,13 +90,16 @@ pub const DARK: Palette = Palette {
|
||||
],
|
||||
};
|
||||
|
||||
/// The palette for the current mode.
|
||||
pub fn pal(ctx: &Context) -> &'static Palette {
|
||||
if ctx.global_style().visuals.dark_mode {
|
||||
&DARK
|
||||
} else {
|
||||
&LIGHT
|
||||
}
|
||||
/// 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";
|
||||
@@ -166,9 +169,18 @@ pub fn install_fonts(ctx: &Context) {
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
/// Apply the app's style for light or dark mode.
|
||||
/// Apply the viewer's own style for light or dark mode (tests).
|
||||
#[cfg(test)]
|
||||
pub fn apply(ctx: &Context, dark: bool) {
|
||||
let p = if dark { &DARK } else { &LIGHT };
|
||||
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 {
|
||||
@@ -179,6 +191,9 @@ pub fn apply(ctx: &Context, dark: bool) {
|
||||
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);
|
||||
@@ -207,7 +222,21 @@ pub fn apply(ctx: &Context, dark: bool) {
|
||||
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);
|
||||
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)),
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Color schemes (schemes.rs): chosen from the menu in the sidebar's
|
||||
//! corner, narrowed by the light/dark filter, kept with the viewer's
|
||||
//! preferences. That every scheme paints the same words and figures in
|
||||
//! the same places, each readable, is checked inside the crate
|
||||
//! (schemes.rs, `painted`), where what is painted can be read.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use ledger_gui::nav::Route;
|
||||
use ledger_gui::{App, data};
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn demo(tag: &str) -> (TestDir, data::Source) {
|
||||
let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("ledger-gui-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
ledger_gui::demo::build(&dir, today).unwrap();
|
||||
let source = data::Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: Some(dir.join("wanderbooks.toml")),
|
||||
today: Some(today),
|
||||
};
|
||||
(TestDir(dir), source)
|
||||
}
|
||||
|
||||
/// eframe's preferences, in memory.
|
||||
#[derive(Default)]
|
||||
struct Prefs(HashMap<String, String>);
|
||||
|
||||
impl eframe::Storage for Prefs {
|
||||
fn get_string(&self, key: &str) -> Option<String> {
|
||||
self.0.get(key).cloned()
|
||||
}
|
||||
fn set_string(&mut self, key: &str, value: String) {
|
||||
self.0.insert(key.to_string(), value);
|
||||
}
|
||||
fn remove_string(&mut self, key: &str) {
|
||||
self.0.remove(key);
|
||||
}
|
||||
fn flush(&mut self) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scheme_is_chosen_from_the_menu_and_kept() {
|
||||
let (_dir, source) = demo("schemes-menu");
|
||||
let books = data::load_once(&source).unwrap();
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 920.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().scheme_key(), "Wanderbooks/Light");
|
||||
// The button names the scheme in force; it opens the menu.
|
||||
h.get_by_label_contains("Wanderbooks Light").click();
|
||||
h.run_steps(2);
|
||||
// Narrowed to dark schemes: a light variant isn't offered. (The
|
||||
// filter's "Dark" comes first; the viewer's own family, under the
|
||||
// pointer, may have opened its flyout with its own "Dark".)
|
||||
h.query_all_by_label("Dark").next().unwrap().click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label_contains("Catppuccin").hover();
|
||||
h.run_steps(3);
|
||||
assert!(
|
||||
h.query_by_label("Latte").is_none(),
|
||||
"a light variant offered"
|
||||
);
|
||||
h.get_by_label("Mocha").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().scheme_key(), "Catppuccin/Mocha");
|
||||
// Worn: Mocha's own page color, and dark.
|
||||
let visuals = h.ctx.global_style().visuals.clone();
|
||||
assert!(visuals.dark_mode);
|
||||
assert_eq!(
|
||||
visuals.panel_fill,
|
||||
egui::Color32::from_rgb(0x18, 0x18, 0x25)
|
||||
);
|
||||
// Kept with the viewer's preferences, the filter too.
|
||||
let mut prefs = Prefs::default();
|
||||
eframe::App::save(h.state_mut(), &mut prefs);
|
||||
assert_eq!(prefs.0["scheme"], "Catppuccin/Mocha");
|
||||
assert_eq!(prefs.0["scheme_filter"], "Dark");
|
||||
// Opened again: only the family in force carries the check, and the
|
||||
// variant in force is selected under its name alone.
|
||||
let check = egui_phosphor::regular::CHECK;
|
||||
h.get_by_label_contains("Catppuccin Mocha").click();
|
||||
h.run_steps(2);
|
||||
// A family's row (not the scheme button, which names the scheme).
|
||||
let family = |h: &Harness<'_, App>, name: &str| -> String {
|
||||
h.query_all_by_label_contains(name)
|
||||
.map(|n| n.accesskit_node().label().unwrap_or_default().to_string())
|
||||
.find(|l| !l.contains(egui_phosphor::regular::PALETTE))
|
||||
.unwrap_or_else(|| panic!("no {name} row"))
|
||||
};
|
||||
let label = family(&h, "Catppuccin");
|
||||
assert!(label.contains(check), "{label:?}");
|
||||
let label = family(&h, "Gruvbox");
|
||||
assert!(!label.contains(check), "{label:?}");
|
||||
h.query_all_by_label_contains("Catppuccin")
|
||||
.find(|n| {
|
||||
!n.accesskit_node()
|
||||
.label()
|
||||
.unwrap_or_default()
|
||||
.contains(egui_phosphor::regular::PALETTE)
|
||||
})
|
||||
.unwrap()
|
||||
.hover();
|
||||
h.run_steps(3);
|
||||
let on = |name: &str| format!("{:?}", h.get_by_label(name).accesskit_node().toggled());
|
||||
assert_eq!(on("Mocha"), "Some(True)");
|
||||
assert_eq!(on("Macchiato"), "Some(False)");
|
||||
}
|
||||
|
||||
/// The families in the menu, alphabetically (owner, 2026-10-19).
|
||||
const FAMILIES: [&str; 21] = [
|
||||
"Ayu",
|
||||
"Catppuccin",
|
||||
"Cyberdream",
|
||||
"Dracula",
|
||||
"Everforest",
|
||||
"GitHub",
|
||||
"Gruvbox",
|
||||
"Kanagawa",
|
||||
"Material",
|
||||
"Melange",
|
||||
"Monokai Pro",
|
||||
"Night Owl",
|
||||
"Nightfox",
|
||||
"Nord",
|
||||
"One Dark",
|
||||
"Oxocarbon",
|
||||
"Rosé Pine",
|
||||
"Solarized",
|
||||
"Sonokai",
|
||||
"Tokyo Night",
|
||||
"Wanderbooks",
|
||||
];
|
||||
|
||||
/// A family's row in the open menu (not the scheme button, which names
|
||||
/// the scheme in force).
|
||||
fn family_row<'a>(h: &'a Harness<'_, App>, name: &'a str) -> egui_kittest::Node<'a> {
|
||||
h.query_all_by_label_contains(name)
|
||||
.find(|n| {
|
||||
let label = n.accesskit_node().label().unwrap_or_default().to_string();
|
||||
!label.contains(egui_phosphor::regular::PALETTE)
|
||||
// The check (in force) and the flyout's arrow aside.
|
||||
&& label.trim_matches(|c: char| !c.is_alphanumeric()) == name
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no {name} row"))
|
||||
}
|
||||
|
||||
/// Owner, 2026-10-19: Ayu was out of reach (the list scrolled, with no
|
||||
/// sign of it), the families weren't alphabetical, and a click on a
|
||||
/// variant's colors did nothing. Every family is on screen at once, in
|
||||
/// order, in a short window or a tall one, and a click anywhere on a
|
||||
/// variant's row chooses it.
|
||||
#[test]
|
||||
fn every_family_is_on_screen_in_order_and_a_whole_row_chooses() {
|
||||
let (_dir, source) = demo("schemes-reach");
|
||||
for (w, h_) in [(1440.0, 920.0), (1000.0, 480.0)] {
|
||||
let books = data::load_once(&source).unwrap();
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(w, h_))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview));
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("Wanderbooks Light").click();
|
||||
h.run_steps(3);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h_));
|
||||
let mut last: Option<egui::Rect> = None;
|
||||
for family in FAMILIES {
|
||||
let r = family_row(&h, family).rect();
|
||||
assert!(
|
||||
screen.contains_rect(r),
|
||||
"{w}x{h_}: {family} at {r:?} is off screen"
|
||||
);
|
||||
// In order: down a column, then on to the next one.
|
||||
if let Some(prev) = last {
|
||||
assert!(
|
||||
r.top() > prev.top() || r.left() > prev.left(),
|
||||
"{w}x{h_}: {family} comes before the family above it"
|
||||
);
|
||||
}
|
||||
last = Some(r);
|
||||
}
|
||||
// Ayu's flyout; a click on Mirage's colors, not its name.
|
||||
family_row(&h, "Ayu").hover();
|
||||
h.run_steps(3);
|
||||
let row = h.get_by_label("Mirage").rect();
|
||||
let on_colors = egui::pos2(row.left() + 20.0, row.center().y);
|
||||
h.hover_at(on_colors);
|
||||
h.run_steps(1);
|
||||
h.drag_at(on_colors);
|
||||
h.run_steps(1);
|
||||
h.drop_at(on_colors);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().scheme_key(), "Ayu/Mirage", "{w}x{h_}");
|
||||
assert!(row.width() >= 180.0, "the row is the button: {row:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pictures of the menu and of a few schemes, for a person to look at
|
||||
/// (`SHOT_DIR=… cargo test -p ledger-gui --test schemes -- --ignored`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn pictures() {
|
||||
let out = std::path::PathBuf::from(std::env::var("SHOT_DIR").expect("SHOT_DIR"));
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let (_dir, source) = demo("schemes-shots");
|
||||
let shot_at = |size: egui::Vec2, key: &str, menu: Option<&str>, name: &str| {
|
||||
let books = data::load_once(&source).unwrap();
|
||||
let mut h = Harness::builder()
|
||||
.with_size(size)
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview));
|
||||
h.run_steps(2);
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().set_scheme(&ctx, key);
|
||||
h.run_steps(4);
|
||||
if let Some(family) = menu {
|
||||
let label = h.state().scheme_key().replace('/', " ");
|
||||
h.get_by_label_contains(label.split(' ').next().unwrap())
|
||||
.click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains(family).hover();
|
||||
h.run_steps(4);
|
||||
}
|
||||
h.render()
|
||||
.unwrap()
|
||||
.save(out.join(format!("{name}.png")))
|
||||
.unwrap();
|
||||
};
|
||||
let shot = |key: &str, menu: Option<&str>, name: &str| {
|
||||
shot_at(egui::vec2(1440.0, 920.0), key, menu, name)
|
||||
};
|
||||
shot("Wanderbooks/Light", Some("Ayu"), "menu");
|
||||
shot_at(
|
||||
egui::vec2(1000.0, 480.0),
|
||||
"Wanderbooks/Light",
|
||||
Some("Melange"),
|
||||
"menu-short",
|
||||
);
|
||||
for key in [
|
||||
"Catppuccin/Mocha",
|
||||
"Catppuccin/Latte",
|
||||
"Tokyo Night/Night",
|
||||
"Gruvbox/Dark",
|
||||
"Ayu/Light",
|
||||
"Kanagawa/Lotus",
|
||||
"Nord/Nord",
|
||||
"Solarized/Light",
|
||||
] {
|
||||
shot(key, None, &key.replace(['/', ' '], "-"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user