Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ecfd080416 Color Range: test the dialog in the full app without a GPU; show preview failures (#145)
The dialog opens from Select › Color Range… in the whole app on the CPU
canvas (no wgpu render state, as on a Linux adapter that can't run the
GPU canvas) at 1024×600, on screen, with both previews drawn, and OK
applies. v0.1.1 and earlier had no Color Range dialog (it shipped in
0.2.0 with #80): the menu item ran select.colorRange directly with the
foreground colour, which matches the report (a selection appears, no
window).

A preview that can't be computed used to stay silently blank; it now
logs the reason once and shows "Preview unavailable: <reason>" in the
preview (also in its accessibility label).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 06:08:15 -04:00
Brandon ThomasandClaude Opus 5.5 56080328ed Menus on small displays: scroll arrows, keyboard navigation, reachability tests (#138)
Builds on #160 (scrollable menu levels). Each menu level is now bounded
from the menu bar down (so a top-level menu no longer slides over the
bar), shows native-style ▲/▼ scroll arrows when it overflows (resting on
one scrolls; the wheel works over them too), and the keyboard drives an
open menu: ↑/↓ move a highlight that is kept in view, → opens a submenu
(highlighting its first row) or the next menu, ← closes it or opens the
previous menu, ↩/Space run the item, Esc closes. Keyboard shortcuts
wait while a menu is open; a resting pointer no longer undoes ←/→.

Tests: every enabled row of Filter, Layer, Image and Edit is reachable
and visible at 1280×720 and 1024×600 at 1× and 1.5×, by keyboard, wheel
and the scroll arrows; submenus open/close by keyboard; ↩ runs a command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 06:08:15 -04:00
6 changed files with 668 additions and 24 deletions

No files matched your search

+94 -9
View File
@@ -162,6 +162,8 @@ pub struct Preview {
/// Hash of the params the mask texture shows.
mask_key: u64,
mask: Option<egui::TextureHandle>,
/// Why the preview couldn't be drawn (shown in the dialog and logged, never silent: #145).
error: Option<String>,
}
fn hash(text: &str) -> u64 {
@@ -170,13 +172,13 @@ fn hash(text: &str) -> u64 {
/// The selection mask `params` would make, on the proxy: the engine command run on a scratch
/// session (Out Of Gamut uses the document's own proof setup).
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Option<Vec<f32>> {
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Result<Vec<f32>, String> {
let area = proxy.bounds();
if params.get("select").and_then(Value::as_str) == Some("outOfGamut") {
let pv = app.session.color.proof(proxy.id);
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).ok()?;
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).map_err(|e| e.to_string())?;
let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false);
return Some(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
return Ok(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
}
let mut p = params.clone();
// Eyedropper points are in document pixels; the proxy is 1/k the size.
@@ -203,9 +205,20 @@ fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) ->
if let Some(id) = app.session.active().and_then(|d| d.active_layer) {
let _ = s.select_layer(id);
}
s.execute(COMMAND, p).ok()?;
let d = s.active()?;
Some(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
s.execute(COMMAND, p).map_err(|e| e.to_string())?;
let d = s.active().ok_or("no preview document")?;
Ok(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
}
/// Record why the preview failed (logged once per new reason), or clear it.
fn report(app: &mut PhotocraftApp, error: Option<String>) {
let Some(p) = app.color_range.as_mut() else { return };
if p.error != error {
if let Some(e) = &error {
log::warn!("Color Range preview: {e}");
}
p.error = error;
}
}
/// Refresh the cached proxy / textures for the active document and the dialog's params. Returns
@@ -219,7 +232,7 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
let side = doc.size.width.max(doc.size.height).max(1);
let k = side.div_ceil(PREVIEW).max(1);
let proxy = Arc::new(crate::proxy::proxy_document(&doc, k));
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None });
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None, error: None });
}
let image_view = s(f, "__view", "selection") == "image";
let params = params(f);
@@ -234,6 +247,7 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
let thumb = photocraft_compose::thumbnail(&proxy, proxy.size.width.max(proxy.size.height));
let size = [thumb.width as usize, thumb.height as usize];
if size != [w, h] || thumb.pixels.len() != w * h * 4 {
report(app, Some(format!("the image thumbnail is {}×{}, expected {w}×{h}", thumb.width, thumb.height)));
return None;
}
let img = egui::ColorImage::from_rgba_unmultiplied(size, &thumb.pixels);
@@ -245,8 +259,18 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
return app.color_range.as_ref()?.image.as_ref().map(|t| (t.id(), [w, h], k));
}
if app.color_range.as_ref().is_some_and(|p| p.mask_key != key || p.mask.is_none()) {
let mask = proxy_mask(app, &proxy, k, &params).unwrap_or_else(|| vec![0.0; w * h]);
let mask = match proxy_mask(app, &proxy, k, &params) {
Ok(m) => {
report(app, None);
m
}
Err(e) => {
report(app, Some(e));
vec![0.0; w * h]
}
};
if mask.len() != w * h {
report(app, Some(format!("the selection preview has {} pixels, expected {}", mask.len(), w * h)));
return None;
}
let px: Vec<Color32> = mask.iter().map(|v| Color32::from_gray((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)).collect();
@@ -368,8 +392,22 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
pick(app, f, at, ui.input(|i| i.modifiers));
}
}
// A preview that can't be drawn says why (#145) instead of staying blank.
if let Some(e) = app.color_range.as_ref().and_then(|p| p.error.clone()) {
ui.painter().rect_filled(frame, 0.0, t.canvas);
let msg = format!(
"Preview unavailable:
{e}"
);
let g = ui.painter().layout(msg, egui::FontId::proportional(11.0), t.text_faint, frame.width() - 16.0);
ui.painter().galley(frame.center() - g.size() / 2.0, g, t.text_faint);
}
ui.painter().rect_stroke(frame, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), "Color Range preview"));
let label = match app.color_range.as_ref().and_then(|p| p.error.as_deref()) {
Some(e) => format!("Color Range preview unavailable: {e}"),
None => "Color Range preview".to_string(),
};
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), &label));
ui.add_space(10.0);
ui.vertical(|ui| {
ui.add_enabled_ui(c.sampling, |ui| {
@@ -704,4 +742,51 @@ mod tests {
app.run(COMMAND, params(&f)).unwrap();
}
}
/// #145: the whole app without a GPU (no wgpu render state: the CPU canvas, as on a Linux
/// machine whose adapter can't run the GPU canvas), on a small window. Select › Color Range…
/// from the menu opens the dialog on screen and draws both previews.
#[test]
fn opens_and_previews_in_the_full_app_without_a_gpu() {
let mut h = Harness::builder().with_size(egui::vec2(1024.0, 600.0)).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
assert!(cc.wgpu_render_state.is_none(), "this test covers the no-GPU path");
app_with_doc()
});
h.run_steps(6);
assert!(h.state().gpu.is_none(), "the CPU canvas");
h.get_all_by_label("Select").next().expect("the Select menu").click();
h.run_steps(3);
h.get_by_label("Color Range…").click();
h.run_steps(6);
let id = dialog_id(h.state());
let area = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).expect("the dialog is drawn");
assert!(h.ctx.content_rect().contains_rect(area), "the dialog is on screen: {area:?}");
assert!(h.query_by_label("Color Range preview").is_some());
let p = h.state().color_range.as_ref().expect("the preview is cached");
assert!(p.mask.is_some() && p.error.is_none(), "the selection preview is drawn: {:?}", p.error);
set(&mut h, "__view", json!("image"));
let p = h.state().color_range.as_ref().unwrap();
assert!(p.image.is_some() && p.error.is_none(), "the image preview is drawn: {:?}", p.error);
set(&mut h, "select", json!("reds"));
ok(&mut h);
h.run_steps(3);
assert_eq!(coverage(h.state(), 5, 5), 1.0);
}
#[test]
fn a_preview_failure_is_shown_not_silent() {
let mut h = harness(app_with_doc());
open(h.state_mut());
h.run_steps(3);
// The engine refuses these params: the preview says so instead of staying blank.
let id = dialog_id(h.state());
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("select".into(), json!("noSuchMode"));
h.run_steps(3);
let err = h.state().color_range.as_ref().and_then(|p| p.error.clone());
assert!(err.is_some(), "the failure is recorded");
assert!(h.query_by_label_contains("Color Range preview unavailable").is_some(), "and shown");
set(&mut h, "select", json!("reds"));
assert!(h.state().color_range.as_ref().unwrap().error.is_none(), "and cleared once the preview works again");
}
}
+1
View File
@@ -50,6 +50,7 @@ pub mod layer_tree_ui;
pub mod links;
pub mod liquify_ui;
pub mod menu_catalog;
pub mod menu_nav;
pub mod menus;
pub mod new_doc_ui;
pub mod notices;
+327
View File
@@ -0,0 +1,327 @@
//! Native-menu behaviour for the menu bar's menus (#138): a menu or submenu taller than the
//! window scrolls (mouse wheel / trackpad, and the ▲ / ▼ arrows at its top and bottom that scroll
//! while the pointer rests on them, like macOS and Windows menus on a small display), and the
//! keyboard drives an open menu: ↑ / ↓ move the highlight (and keep it in view), → opens a
//! submenu or the next menu, ← closes the submenu or opens the previous menu, ↩ / Space run the
//! highlighted item, Esc closes.
//!
//! The highlight is view state only (egui memory), per top-level menu; it follows the pointer too,
//! so the mouse and the keyboard share one highlighted row like a native menu.
use egui::containers::menu::{MenuState, SubMenu, find_menu_root};
use egui::{Id, Key, Modifiers, Rect, Sense, Ui, pos2, vec2};
/// Height of a scroll-arrow strip.
pub const ARROW: f32 = 16.0;
/// Gap kept between a menu and the window edge.
const EDGE: f32 = 6.0;
/// How fast resting on a scroll arrow scrolls, in points per second.
const SPEED: f32 = 600.0;
/// One actionable row of a menu level: a command or a submenu.
#[derive(Clone, Debug)]
pub struct Row {
/// The row's widget id (a submenu's popup id derives from it).
pub widget: Id,
pub enabled: bool,
/// The command it runs; `None` for a submenu.
pub command: Option<String>,
/// Where it was drawn (scrolled out of view when outside its level's `views` rect).
pub rect: Rect,
}
/// Keyboard / highlight state of the open menu.
#[derive(Clone, Debug, Default)]
pub struct Nav {
/// The open top-level menu (index into the menu bar).
top: Option<usize>,
/// Popup ids of the top-level menus, from the last frame.
tops: Vec<Id>,
/// The level the arrow keys move in (0 = the top-level menu).
pub(crate) level: usize,
/// Highlighted row per level.
hi: Vec<Option<usize>>,
/// This frame's rows per level (deeper levels are dropped when their parent redraws).
pub(crate) rows: Vec<Vec<Row>>,
/// Menu-root id per level (the owner of egui's `MenuState` for that level).
roots: Vec<Id>,
/// Bottom of the menu bar: where top-level menus hang from.
pub bar_bottom: Option<f32>,
/// The visible (scrolled) part of each level.
pub(crate) views: Vec<Rect>,
/// Scroll the highlighted row of `level` into view when it is next drawn.
reveal: bool,
/// Highlight the first enabled row of this level once it has been drawn (a keyboard-opened
/// submenu).
first_at: Option<usize>,
}
fn nav_id() -> Id {
Id::new("pc-menu-nav")
}
fn open_id() -> Id {
Id::new("pc-menu-open")
}
/// Is one of the menu bar's menus open? (Shortcuts leave the keys to it meanwhile.)
pub fn is_open(ctx: &egui::Context) -> bool {
// Only while the menu bar is still drawn (Full Screen Mode hides it with a menu open).
let pass = ctx.cumulative_pass_nr();
ctx.data(|d| d.get_temp::<u64>(open_id())).is_some_and(|seen| seen + 2 >= pass)
}
impl Nav {
/// Last frame's state, reset when another top-level menu (or none) is open now.
pub fn load(ctx: &egui::Context) -> Nav {
let mut nav: Nav = ctx.data(|d| d.get_temp(nav_id())).unwrap_or_default();
let top = nav.tops.iter().position(|id| egui::Popup::is_id_open(ctx, *id));
if top != nav.top {
nav = Nav { top, tops: std::mem::take(&mut nav.tops), ..Default::default() };
}
nav
}
/// The state as the last frame left it (tests, inspection).
pub fn current(ctx: &egui::Context) -> Nav {
ctx.data(|d| d.get_temp(nav_id())).unwrap_or_default()
}
/// The highlighted row of `level`, if any.
pub fn highlighted(&self, level: usize) -> Option<usize> {
self.hi.get(level).copied().flatten()
}
fn set_hi(&mut self, level: usize, row: Option<usize>) {
if self.hi.len() <= level {
self.hi.resize(level + 1, None);
}
self.hi[level] = row;
}
/// Start drawing `level`: forget its rows from before, and every deeper level's.
fn begin_level(&mut self, level: usize, root: Id) {
self.rows.truncate(level);
self.rows.resize(level + 1, Vec::new());
self.roots.truncate(level);
self.roots.resize(level + 1, root);
self.views.truncate(level);
self.views.resize(level + 1, Rect::NOTHING);
}
/// Draw a row of `level` with `add`, highlighted like a hovered row when the keyboard (or the
/// pointer) is on it, and register it for the keys. `add` returns the row's response.
pub fn row<R>(&mut self, ui: &mut Ui, level: usize, enabled: bool, command: Option<&str>, add: impl FnOnce(&mut Ui, &mut Nav) -> (egui::Response, R)) -> R {
let i = self.rows.get(level).map_or(usize::MAX, Vec::len);
let lit = enabled && self.highlighted(level) == Some(i);
let saved = ui.visuals().widgets.inactive;
if lit {
ui.visuals_mut().widgets.inactive = ui.visuals().widgets.hovered;
}
let (r, out) = add(ui, self);
ui.visuals_mut().widgets.inactive = saved;
if let Some(rows) = self.rows.get_mut(level) {
rows.push(Row { widget: r.id, enabled, command: command.map(str::to_string), rect: r.rect });
self.after_row(level, i, lit, &r);
}
out
}
/// After a row is drawn: the pointer moving onto it highlights it; a keyboard move scrolls
/// the highlighted row into view.
fn after_row(&mut self, level: usize, i: usize, lit: bool, r: &egui::Response) {
let moved = r.ctx.input(|inp| inp.pointer.delta() != egui::Vec2::ZERO);
if moved && r.hovered() && !lit {
self.set_hi(level, Some(i));
self.level = level;
r.ctx.request_repaint();
}
if lit && self.reveal && level == self.level {
r.scroll_to_me(None);
self.reveal = false;
}
}
fn enabled(&self, level: usize) -> Vec<usize> {
self.rows.get(level).map(|rows| rows.iter().enumerate().filter(|(_, r)| r.enabled).map(|(i, _)| i).collect()).unwrap_or_default()
}
/// ↑ / ↓: the next enabled row (wrapping), or the first / last one.
fn step(&mut self, level: usize, down: bool) {
let on = self.enabled(level);
let (Some(&first), Some(&last)) = (on.first(), on.last()) else { return };
let next = match self.highlighted(level) {
None => {
if down {
first
} else {
last
}
}
Some(cur) if down => on.iter().copied().find(|&i| i > cur).unwrap_or(first),
Some(cur) => on.iter().rev().copied().find(|&i| i < cur).unwrap_or(last),
};
self.set_hi(level, Some(next));
self.hi.truncate(level + 1);
self.level = level;
self.reveal = true;
}
/// Open the highlighted submenu of `level` and move the keyboard into it.
fn open_sub(&mut self, ctx: &egui::Context, level: usize, widget: Id) {
let Some(&root) = self.roots.get(level) else { return };
let sub = SubMenu::id_from_widget_id(widget);
// egui drops an open item it has never drawn: mark it shown so it survives to the next frame.
MenuState::mark_shown(ctx, sub);
MenuState::from_id(ctx, root, |s| s.open_item = Some(sub));
self.level = level + 1;
self.hi.truncate(level + 1);
self.first_at = Some(level + 1);
}
/// Handle this frame's navigation keys (after the menus were drawn). `tops` are the menu
/// bar's popup ids; an activated command lands in `clicked`.
pub fn keys(&mut self, ctx: &egui::Context, tops: &[Id], clicked: &mut Option<String>) {
self.tops = tops.to_vec();
let Some(top) = self.top.filter(|_| !tops.is_empty()) else { return };
let level = self.level.min(self.rows.len().saturating_sub(1));
self.level = level;
let press = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned();
let switch = |by: usize| {
let n = tops.len();
if let Some(&id) = tops.get((top + by) % n) {
egui::Popup::open_id(ctx, id);
}
};
if press(Key::Escape) {
egui::Popup::close_all(ctx);
} else if press(Key::ArrowDown) {
self.step(level, true);
} else if press(Key::ArrowUp) {
self.step(level, false);
} else if press(Key::ArrowRight) {
match current {
Some(Row { widget, enabled: true, command: None, .. }) => self.open_sub(ctx, level, widget),
_ => switch(1),
}
} else if press(Key::ArrowLeft) {
if level > 0 {
if let Some(&root) = self.roots.get(level - 1) {
MenuState::from_id(ctx, root, |s| s.open_item = None);
}
self.hi.truncate(level);
self.level = level - 1;
} else {
switch(tops.len() - 1);
}
} else if press(Key::Enter) || press(Key::Space) {
match current {
Some(Row { enabled: true, command: Some(c), .. }) => {
*clicked = Some(c);
egui::Popup::close_all(ctx);
}
Some(Row { widget, enabled: true, command: None, .. }) => self.open_sub(ctx, level, widget),
_ => {}
}
} else {
return;
}
ctx.request_repaint();
}
pub fn store(self, ctx: &egui::Context) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| {
if self.top.is_some() {
d.insert_temp(open_id(), pass);
} else {
d.remove::<u64>(open_id());
}
d.insert_temp(nav_id(), self);
});
}
}
/// Is the pointer over `rect` on this ui's layer (not under a submenu drawn on top)?
fn pointer_on(ui: &Ui, rect: Rect) -> bool {
ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id()))
}
/// Draw one level of a menu, bounded by the window: when its rows don't fit they scroll, with
/// scroll arrows at the top and bottom. `depth` is 1 for a top-level menu.
pub fn level(ui: &mut Ui, depth: usize, nav: &mut Nav, rows: impl FnOnce(&mut Ui, &mut Nav)) {
let ctx = ui.ctx().clone();
let level = depth.saturating_sub(1);
nav.begin_level(level, find_menu_root(ui).id);
let screen = ctx.content_rect();
// The menu frame's margin and stroke around the rows.
let frame = ui.spacing().menu_margin.sum().y + 2.0;
// A top-level menu hangs below the menu bar; a submenu may slide up to fit the window.
// (From the menu bar, not from where the popup is now: egui slides a popup that doesn't fit.)
let top = nav.bar_bottom.unwrap_or(screen.top()) + EDGE;
let room = if depth <= 1 { screen.bottom() - top - EDGE } else { screen.height() - 2.0 * EDGE } - frame;
let room = room.max(4.0 * ARROW);
let key = ui.id().with(("pc-menu-level", depth));
// Rows' height from the last frame: does this level overflow?
let content: Option<f32> = ctx.data(|d| d.get_temp(key));
let over = content.is_some_and(|h| h > room + 0.5);
let up = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
// Wheel, scroll bar and dragging the rows all scroll (#160).
let out = egui::ScrollArea::vertical()
.id_salt(("menu-level", depth))
.max_height(if over { room - 2.0 * ARROW } else { room })
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
.show(ui, |ui| rows(ui, nav));
let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
ctx.data_mut(|d| d.insert_temp(key, out.content_size.y));
if let Some(v) = nav.views.get_mut(level) {
*v = out.inner_rect;
}
if nav.first_at == Some(level) {
nav.first_at = None;
nav.step(level, true);
ctx.request_repaint();
}
let (Some(up), Some(down)) = (up, down) else { return };
let t = crate::theme::Tokens::get(&ctx);
let x = ui.min_rect().x_range();
let max = (out.content_size.y - out.inner_rect.height()).max(0.0);
let offset = out.state.offset.y;
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
let mut target = offset;
for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] {
let strip = Rect::from_x_y_ranges(x, strip.y_range());
let c = strip.center();
let (h, w) = (4.5, 6.0);
let tri = if dir < 0.0 {
vec![pos2(c.x - w, c.y + h * 0.5), pos2(c.x + w, c.y + h * 0.5), pos2(c.x, c.y - h * 0.5)]
} else {
vec![pos2(c.x - w, c.y - h * 0.5), pos2(c.x + w, c.y - h * 0.5), pos2(c.x, c.y + h * 0.5)]
};
ui.painter().add(egui::Shape::convex_polygon(tri, if live { t.text } else { t.text_faint.gamma_multiply(0.5) }, egui::Stroke::NONE));
let id = key.with(dir < 0.0);
let r = ui.interact(strip, id, Sense::hover());
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, live, if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }));
if live && pointer_on(ui, strip) {
target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max);
ctx.request_repaint();
}
// The wheel scrolls over the arrows too.
if pointer_on(ui, strip) {
let wheel = ctx.input(|i| i.smooth_scroll_delta.y);
if wheel != 0.0 {
target = (target - wheel).clamp(0.0, max);
}
}
}
if target != offset {
let mut st = out.state;
st.offset.y = target;
st.store(&ctx, out.id);
}
}
#[cfg(test)]
#[path = "menu_nav_tests.rs"]
mod tests;
+218
View File
@@ -0,0 +1,218 @@
//! Menus on small displays (#138): every row of the longest menus is reachable by keyboard, wheel
//! and the scroll arrows, and the keyboard drives submenus and runs commands.
use super::*;
use crate::PhotocraftApp;
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
/// Window size in pixels and UI scale: the small displays of #138.
const DISPLAYS: [(f32, f32, f32); 4] = [(1280.0, 720.0, 1.0), (1024.0, 600.0, 1.0), (1280.0, 720.0, 1.5), (1024.0, 600.0, 1.5)];
/// The tallest menus.
const LONGEST: [&str; 4] = ["Filter", "Layer", "Image", "Edit"];
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
app.sync_views();
app
}
fn harness((w, h, scale): (f32, f32, f32)) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder()
.with_size(egui::vec2(w / scale, h / scale))
.with_pixels_per_point(scale)
.build_ui_state(|ui, app| crate::menus::menu_bar(app, ui), app());
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.ctx.all_styles_mut(|s| s.scroll_animation = egui::style::ScrollAnimation::none());
h.run_steps(3);
h
}
fn open(h: &mut Harness<'static, PhotocraftApp>, top: &str) {
h.get_by_label(top).click();
h.run_steps(4);
assert!(Nav::current(&h.ctx).rows.first().is_some_and(|r| !r.is_empty()), "{top} did not open");
}
/// Is row `i` of `level` entirely inside its level's visible (scrolled) area, on screen?
fn visible(h: &Harness<'static, PhotocraftApp>, level: usize, i: usize) -> bool {
let nav = Nav::current(&h.ctx);
let (Some(row), Some(view)) = (nav.rows.get(level).and_then(|r| r.get(i)), nav.views.get(level)) else { return false };
view.expand(0.5).contains_rect(row.rect) && h.ctx.content_rect().expand(0.5).contains_rect(*view)
}
fn rows(h: &Harness<'static, PhotocraftApp>, level: usize) -> Vec<Row> {
Nav::current(&h.ctx).rows.get(level).cloned().unwrap_or_default()
}
fn key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) {
h.key_press(k);
h.run_steps(3);
}
#[test]
fn every_row_of_the_longest_menus_is_reachable_with_the_arrow_keys() {
for display in DISPLAYS {
for top in LONGEST {
let mut h = harness(display);
open(&mut h, top);
let all = rows(&h, 0);
let enabled: Vec<usize> = all.iter().enumerate().filter(|(_, r)| r.enabled).map(|(i, _)| i).collect();
assert!(enabled.len() > 5, "{top}: {} enabled rows", enabled.len());
let mut seen = Vec::new();
for _ in 0..enabled.len() {
key(&mut h, egui::Key::ArrowDown);
let i = Nav::current(&h.ctx).highlighted(0).expect("a highlighted row");
assert!(
visible(&h, 0, i),
"{display:?} {top}: highlighted row {i} ({:?}) is scrolled out of view",
rows(&h, 0).get(i).map(|r| r.command.clone())
);
seen.push(i);
}
seen.sort_unstable();
assert_eq!(seen, enabled, "{display:?} {top}: ↓ visits every enabled row once");
// ↓ wraps to the first row (scrolling back up); ↑ wraps to the last.
key(&mut h, egui::Key::ArrowDown);
assert_eq!(Nav::current(&h.ctx).highlighted(0), enabled.first().copied());
assert!(visible(&h, 0, enabled[0]));
key(&mut h, egui::Key::ArrowUp);
let last = *enabled.last().unwrap();
assert_eq!(Nav::current(&h.ctx).highlighted(0), Some(last));
assert!(visible(&h, 0, last), "{display:?} {top}: the last row is reachable");
}
}
}
#[test]
fn small_displays_overflow_and_the_menu_stays_in_the_window() {
// 1024 × 600 at 150 %: 683 × 400 points, far shorter than the Filter menu.
let mut h = harness((1024.0, 600.0, 1.5));
open(&mut h, "Filter");
let all = rows(&h, 0);
let last = all.len() - 1;
assert!(!visible(&h, 0, last), "the Filter menu must overflow at 683 × 400 pt for this test to mean anything");
let nav = Nav::current(&h.ctx);
let screen = h.ctx.content_rect();
assert!(nav.views[0].bottom() <= screen.bottom() && nav.views[0].top() >= screen.top());
let bar = h.get_by_label("Filter").rect();
assert!(nav.views[0].top() >= bar.bottom(), "the menu hangs below the menu bar instead of sliding over it: {:?} vs {bar:?}", nav.views[0]);
// Scroll arrows show when the rows overflow.
assert!(h.query_by_label("Scroll menu down").is_some() && h.query_by_label("Scroll menu up").is_some());
}
#[test]
fn the_mouse_wheel_scrolls_a_long_menu() {
for display in DISPLAYS {
let mut h = harness(display);
open(&mut h, "Filter");
let last = rows(&h, 0).len() - 1;
let view = Nav::current(&h.ctx).views[0];
h.hover_at(view.center());
h.run_steps(2);
for _ in 0..60 {
if visible(&h, 0, last) {
break;
}
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -60.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.run_steps(2);
}
assert!(visible(&h, 0, last), "{display:?}: the wheel reaches the last Filter row");
assert!(rows(&h, 0).len() == last + 1, "the menu stays open while scrolling");
}
}
#[test]
fn resting_on_the_scroll_arrows_scrolls() {
let mut h = harness((1024.0, 600.0, 1.5));
open(&mut h, "Filter");
let last = rows(&h, 0).len() - 1;
assert!(visible(&h, 0, 0) && !visible(&h, 0, last));
let view = Nav::current(&h.ctx).views[0];
h.hover_at(egui::pos2(view.center().x, view.bottom() + ARROW / 2.0));
for _ in 0..240 {
h.run_steps(1);
if visible(&h, 0, last) {
break;
}
}
assert!(visible(&h, 0, last), "resting on ▼ scrolls to the end");
assert!(!visible(&h, 0, 0));
let view = Nav::current(&h.ctx).views[0];
h.hover_at(egui::pos2(view.center().x, view.top() - ARROW / 2.0));
for _ in 0..240 {
h.run_steps(1);
if visible(&h, 0, 0) {
break;
}
}
assert!(visible(&h, 0, 0), "resting on ▲ scrolls back to the top");
}
#[test]
fn arrows_open_and_close_submenus_and_enter_runs_a_command() {
let mut h = harness((1024.0, 600.0, 1.5));
open(&mut h, "Filter");
// Walk down to the first submenu (Blur, Distort, …) and open it with →.
for _ in 0..rows(&h, 0).len() {
key(&mut h, egui::Key::ArrowDown);
let nav = Nav::current(&h.ctx);
if nav.highlighted(0).and_then(|i| nav.rows[0].get(i)).is_some_and(|r| r.command.is_none()) {
break;
}
}
key(&mut h, egui::Key::ArrowRight);
h.run_steps(2);
let nav = Nav::current(&h.ctx);
assert_eq!(nav.level, 1, "→ moves into the submenu");
assert!(nav.rows.get(1).is_some_and(|r| !r.is_empty()), "the submenu is open");
let first = nav.rows[1].iter().position(|r| r.enabled);
assert_eq!(nav.highlighted(1), first, "its first enabled row is highlighted");
assert!(visible(&h, 1, first.unwrap()));
key(&mut h, egui::Key::ArrowDown);
assert_eq!(Nav::current(&h.ctx).level, 1);
// ← closes it again.
key(&mut h, egui::Key::ArrowLeft);
h.run_steps(2);
let nav = Nav::current(&h.ctx);
assert_eq!((nav.level, nav.rows.len()), (0, 1), "← closes the submenu");
// ↩ runs the highlighted command and closes the menu: Select › All.
key(&mut h, egui::Key::Escape);
open(&mut h, "Select");
for _ in 0..rows(&h, 0).len() {
let nav = Nav::current(&h.ctx);
if nav.highlighted(0).and_then(|i| nav.rows[0].get(i)).is_some_and(|r| r.command.as_deref() == Some("select.all")) {
break;
}
key(&mut h, egui::Key::ArrowDown);
}
assert!(h.state().session.active().unwrap().doc.selection.is_none());
key(&mut h, egui::Key::Enter);
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "↩ ran Select › All");
assert!(!is_open(&h.ctx), "and closed the menu");
}
#[test]
fn left_and_right_switch_menus_and_shortcuts_wait_while_one_is_open() {
let mut h = harness((1280.0, 720.0, 1.0));
assert!(!is_open(&h.ctx));
open(&mut h, "File");
assert!(is_open(&h.ctx), "shortcuts leave the keys to an open menu");
key(&mut h, egui::Key::ArrowRight);
h.run_steps(2);
assert!(h.query_by_label_contains("Open…").is_none(), "→ leaves File");
assert!(h.query_by_label_contains("Undo").is_some(), "→ opens Edit");
key(&mut h, egui::Key::ArrowLeft);
h.run_steps(2);
assert!(h.query_by_label_contains("Open…").is_some(), "← back to File");
key(&mut h, egui::Key::Escape);
h.run_steps(2);
assert!(h.query_by_label_contains("Open…").is_none(), "Esc closes the menu");
assert!(!is_open(&h.ctx), "shortcuts work again");
}
+24 -15
View File
@@ -698,6 +698,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let items = menu_items(app);
let mut clicked: Option<String> = None;
let t = crate::theme::Tokens::get(ui.ctx());
let mut nav = crate::menu_nav::Nav::load(ui.ctx());
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let v = &mut ui.style_mut().visuals;
@@ -707,6 +708,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
v.widgets.hovered.bg_stroke = egui::Stroke::NONE;
egui::MenuBar::new().ui(ui, |ui| {
ui.spacing_mut().button_padding = egui::vec2(6.0, 3.0);
nav.bar_bottom = Some(ui.max_rect().bottom());
let mut buttons = Vec::with_capacity(TOP_MENUS.len());
for top in TOP_MENUS {
let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect();
@@ -715,13 +717,16 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if mine.is_empty() {
ui.weak("(coming soon)");
}
render_level(ui, &mine, 1, &mut clicked);
render_level(ui, &mine, 1, &mut clicked, &mut nav);
});
buttons.push(r.response);
}
switch_on_hover(ui.ctx(), &buttons);
let tops: Vec<egui::Id> = buttons.iter().map(egui::Popup::default_response_id).collect();
nav.keys(ui.ctx(), &tops, &mut clicked);
});
});
nav.store(ui.ctx());
if let Some(id) = clicked {
let ctx = ui.ctx().clone();
let _ = invoke(app, &ctx, &id, json!({}));
@@ -743,6 +748,10 @@ fn switch_on_hover(ctx: &egui::Context, buttons: &[egui::Response]) {
// `Response::hovered` is false while the menu's popup layer is open. Hit-test the title
// rect manually, but only when its own layer is top-most at the pointer.
let Some(p) = ctx.pointer_hover_pos() else { return };
// Only a moving pointer switches: a pointer resting on a title must not undo ← / → .
if ctx.input(|i| i.pointer.delta() == egui::Vec2::ZERO) {
return;
}
if let Some(i) = buttons.iter().position(|b| pointer_reaches_title(ctx, b, p))
&& i != open
{
@@ -751,19 +760,13 @@ fn switch_on_hover(ctx: &egui::Context, buttons: &[egui::Response]) {
}
}
fn render_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>) {
// On short displays leave room for the menu bar and popup frame so the ScrollArea becomes
// active before the popup reaches the bottom edge. Keep wheel/scrollbar input and also allow
// direct dragging of the menu contents.
let max_height = (ui.ctx().content_rect().height() - 56.0).max(96.0);
egui::ScrollArea::vertical()
.id_salt(("menu-level", depth))
.max_height(max_height)
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
.show(ui, |ui| render_level_rows(ui, items, depth, clicked));
fn render_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>, nav: &mut crate::menu_nav::Nav) {
// Menu popups can be taller than the window: each level stays on screen and scrolls (wheel,
// scroll arrows, keyboard), like a native menu on a small display.
crate::menu_nav::level(ui, depth, nav, |ui, nav| render_level_rows(ui, items, depth, clicked, nav));
}
fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>) {
fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>, nav: &mut crate::menu_nav::Nav) {
let t = crate::theme::Tokens::get(ui.ctx());
if t.pro {
// Spectrum/macOS menus: blue highlight row with white text.
@@ -798,7 +801,12 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
if let Some(sc) = &it.shortcut {
b = b.shortcut_text(crate::shortcuts::pretty(sc));
}
if ui.add_enabled(it.enabled, b).clicked() {
let hit = nav.row(ui, depth - 1, it.enabled, Some(&it.id), |ui, _| {
let r = ui.add_enabled(it.enabled, b);
let hit = r.clicked();
(r, hit)
});
if hit {
*clicked = Some(it.id.clone());
ui.close();
}
@@ -811,8 +819,9 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
shown_subs.push(name);
let child: Vec<&MenuItem> = items.iter().copied().filter(|c| c.path.len() > depth && c.path[depth] == name).collect();
let any_enabled = child.iter().any(|c| c.enabled && c.label != "---");
ui.add_enabled_ui(any_enabled || !child.is_empty(), |ui| {
ui.menu_button(name, |ui| render_level(ui, &child, depth + 1, clicked));
let enabled = any_enabled || !child.is_empty();
ui.add_enabled_ui(enabled, |ui| {
nav.row(ui, depth - 1, enabled, None, |ui, nav| (ui.menu_button(name, |ui| render_level(ui, &child, depth + 1, clicked, nav)).response, ()));
});
last_was_sep = false;
}
+4
View File
@@ -156,6 +156,10 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu
}
pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
// An open menu owns the keyboard (arrows, ↩, Esc), like a native menu.
if crate::menu_nav::is_open(ctx) {
return;
}
// Liquify is a full-window custom dialog with focusable sliders. egui can therefore claim
// keyboard input before the distortion-mode handler below runs. Give Liquify's local
// shortcuts first refusal (Undo, brush size, tool keys), but never steal keys from text edits.