viewer: a scheme hovered in the menu is shown live
check / check (push) Canceled after 0s

Owner, 2026-10-19: hovering a scheme for a moment switches to it live;
moving off goes to the next one hovered or back to the one in force, to
sample schemes without re-opening the menu.

A variant's row rested on for 50 ms is worn; off every row for 50 ms,
the scheme in force again; the menu closed without a click, the one in
force at once. Only a click chooses, and a preview is never kept.
Tested with frames 10 ms apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-19 12:51:59 -06:00
1 parent a1198a9c59
commit 8022a66866
5 files changed
+167 -9

No files matched your search

+75 -6
View File
@@ -45,6 +45,10 @@ impl Shell<'_> {
/// families out in columns.
const FAMILY_ROW: f32 = 34.0;
/// How long the pointer rests on a scheme's row (or off every row) before
/// that scheme (or the one in force) is worn, seconds.
const PREVIEW_AFTER: f64 = 0.05;
/// 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;
@@ -129,6 +133,12 @@ pub struct App {
receipts: Receipts,
/// The color scheme in force (schemes.rs).
scheme: &'static Scheme,
/// The scheme being worn: the one in force, or one previewed while
/// its row in the menu is hovered.
shown: &'static Scheme,
/// The scheme the pointer asks for, and since when: worn once it has
/// asked for PREVIEW_AFTER.
wanted: Option<(&'static Scheme, f64)>,
/// Which schemes its menu shows.
filter: Filter,
state: PageState,
@@ -171,6 +181,8 @@ impl App {
nav: Nav::new(Route::Overview),
receipts: Receipts::new(ctx),
scheme,
shown: scheme,
wanted: None,
filter: Filter::default(),
state: PageState::default(),
palette: Default::default(),
@@ -194,11 +206,63 @@ impl App {
self.scheme.key()
}
/// The scheme being worn ("Family/Variant"): a previewed one while
/// its row is hovered (tests).
pub fn shown_scheme_key(&self) -> String {
self.shown.key()
}
fn choose(&mut self, ctx: &egui::Context, scheme: &'static Scheme) {
self.scheme = scheme;
self.wear(ctx, scheme);
}
/// Wear `scheme`, in force or previewed.
fn wear(&mut self, ctx: &egui::Context, scheme: &'static Scheme) {
self.shown = scheme;
self.wanted = None;
theme::apply_scheme(ctx, scheme);
}
/// The preview (owner, 2026-10-19: "when we hover over a scheme for
/// some time switch to it live; when we switch out of it go to either
/// the new thing we're hovering or back to the current one"). While
/// the menu is open, the scheme whose row is hovered, else the one in
/// force, is worn once the pointer has asked for it PREVIEW_AFTER
/// without change; the menu closed, the one in force is worn at once.
/// A preview is never kept: only a click chooses.
fn preview(&mut self, ctx: &egui::Context, open: bool, hovered: Option<&'static Scheme>) {
let want = match (open, hovered) {
(true, Some(s)) => s,
_ => self.scheme,
};
if std::ptr::eq(want, self.shown) {
self.wanted = None;
return;
}
if !open {
self.wear(ctx, want);
return;
}
let now = ctx.input(|i| i.time);
match self.wanted {
Some((s, since)) if std::ptr::eq(s, want) => {
let waited = now - since;
if waited >= PREVIEW_AFTER {
self.wear(ctx, want);
} else {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(
PREVIEW_AFTER - waited,
));
}
}
_ => {
self.wanted = Some((want, now));
ctx.request_repaint_after(std::time::Duration::from_secs_f64(PREVIEW_AFTER));
}
}
}
/// 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.
@@ -219,7 +283,8 @@ impl App {
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)
let mut hovered = None;
let (response, open) = egui::containers::menu::MenuButton::from_button(button)
.config(config)
.ui(ui, |ui| {
ui.set_min_width(200.0);
@@ -273,9 +338,12 @@ impl App {
);
ui.menu_button(label, |ui| {
for v in variants {
if scheme_row(ui, v, std::ptr::eq(*v, self.scheme))
.clicked()
{
let row =
scheme_row(ui, v, std::ptr::eq(*v, self.scheme));
if row.hovered() {
hovered = Some(*v);
}
if row.clicked() {
chosen = Some(*v);
ui.close();
}
@@ -288,8 +356,9 @@ impl App {
});
});
response.on_hover_text(format!("Color scheme: {}", self.scheme.label()));
if let Some(v) = chosen {
self.choose(ui.ctx(), v);
match chosen {
Some(v) => self.choose(ui.ctx(), v),
None => self.preview(ui.ctx(), open.is_some(), hovered),
}
}
+81
View File
@@ -204,6 +204,87 @@ fn every_family_is_on_screen_in_order_and_a_whole_row_chooses() {
}
}
/// Owner, 2026-10-19: a scheme hovered in the menu for a moment is worn
/// live; off its row, the next one hovered, or the one in force, is worn
/// again; nothing is chosen until a click.
#[test]
fn a_hovered_scheme_is_shown_live_and_only_a_click_keeps_it() {
let (_dir, source) = demo("schemes-preview");
let books = data::load_once(&source).unwrap();
// Frames 10 ms apart, to see the 50 ms wait.
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 920.0))
.with_step_dt(0.01)
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview));
h.run_steps(3);
let page = |h: &Harness<'_, App>| h.ctx.global_style().visuals.panel_fill;
let light = page(&h);
h.get_by_label_contains("Wanderbooks Light").click();
h.run_steps(3);
family_row(&h, "Ayu").hover();
h.run_steps(3);
// A variant's row (the filter has a "Dark" too: the flyout's is last).
let rest_on = |h: &mut Harness<'_, App>, name: &str| {
let r = h.query_all_by_label(name).last().unwrap().rect();
h.hover_at(egui::pos2(r.left() + 20.0, r.center().y));
};
rest_on(&mut h, "Mirage");
h.run_steps(2);
assert_eq!(
h.state().shown_scheme_key(),
"Wanderbooks/Light",
"not before 50 ms"
);
h.run_steps(8);
assert_eq!(h.state().shown_scheme_key(), "Ayu/Mirage");
assert_eq!(
h.state().scheme_key(),
"Wanderbooks/Light",
"only shown, not chosen"
);
let mirage = page(&h);
assert_ne!(mirage, light);
// On to the next one hovered.
rest_on(&mut h, "Dark");
h.run_steps(10);
assert_eq!(h.state().shown_scheme_key(), "Ayu/Dark");
// Off every row: back to the one in force.
let filter = h.query_all_by_label("All").next().unwrap().rect();
h.hover_at(filter.center());
h.run_steps(10);
assert_eq!(h.state().shown_scheme_key(), "Wanderbooks/Light");
assert_eq!(page(&h), light);
// Closed while one is shown: the one in force, at once; nothing kept.
family_row(&h, "Ayu").hover();
h.run_steps(3);
rest_on(&mut h, "Mirage");
h.run_steps(10);
assert_eq!(h.state().shown_scheme_key(), "Ayu/Mirage");
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert_eq!(h.state().shown_scheme_key(), "Wanderbooks/Light");
assert_eq!(h.state().scheme_key(), "Wanderbooks/Light");
assert_eq!(page(&h), light);
let mut prefs = Prefs::default();
eframe::App::save(h.state_mut(), &mut prefs);
assert_eq!(
prefs.0["scheme"], "Wanderbooks/Light",
"a preview is never kept"
);
// A click keeps it.
h.get_by_label_contains("Wanderbooks Light").click();
h.run_steps(3);
family_row(&h, "Ayu").hover();
h.run_steps(3);
rest_on(&mut h, "Mirage");
h.run_steps(10);
h.get_by_label("Mirage").click();
h.run_steps(3);
assert_eq!(h.state().scheme_key(), "Ayu/Mirage");
assert_eq!(h.state().shown_scheme_key(), "Ayu/Mirage");
assert_eq!(page(&h), mirage);
}
/// 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]