mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 11:46:52 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecfd080416 | ||
|
|
56080328ed |
No files matched your search
@@ -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, ¶ms).unwrap_or_else(|| vec![0.0; w * h]);
|
||||
let mask = match proxy_mask(app, &proxy, k, ¶ms) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user