Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) (#5091)

As Photoshop's menus on Windows: a tap on Alt (or F10) puts the keyboard
on the in-window menu bar, with File lit; ← and → move along it, ↓, ↩
and Space open the lit menu with its first row lit, Esc or a click
leave. Alt+letter opens the menu with that mnemonic, open or not, and
in an open menu a letter runs the row with that mnemonic or opens its
submenu.

Mnemonics are assigned per menu level from the labels as shown (so
translated), unique within the level: Photoshop's F E I L Y S T V W H
for the top menus where the label has the letter, else the first unused
letter, word starts first; only ASCII letters and digits, since those
are the keys the keyboard reports. They are underlined while Alt is
held, the bar is focused or the menu was opened from the keyboard; a
menu opened with the mouse shows none until Alt, as on Windows. The
underline is a layout job with the placeholder colour, so disabled rows
stay dim; without underlines the text path is unchanged.

The shortcuts defer to the focused bar as they do to an open menu, so E
opens Edit instead of picking the Eraser and the arrows don't nudge the
layer. Rows carry their mnemonic in the navigation state. macOS keeps
the native menu bar, where this doesn't run.

Tests: two unit tests (assignment, key letters) and seven harness tests
(Alt focus and arrows, second Alt / Esc / click, Alt+letter and Enter,
letters in an open menu, underline conditions, the layout job, and the
whole app's shortcut deferral).

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Fabio LissiandBrandon Thomas authored and GitHub committed 2026-10-14 15:16:09 -04:00
1 parent 9b34c49128
commit 4cc0518169
8 files changed
+628 -21

No files matched your search

+1
View File
@@ -127,6 +127,7 @@ mod mask_props_ui;
pub mod mask_thumbs_ui;
pub mod mask_trash_prompt;
pub mod menu_catalog;
pub mod menu_keys;
pub mod menu_nav;
pub mod menus;
pub mod monitor_status;
+303
View File
@@ -0,0 +1,303 @@
//! The menu bar from the keyboard (#4988), as Photoshop's on Windows: a tap on Alt (or F10)
//! puts the keyboard on the bar (File lit; ← / → move along it, ↓ / ↩ / Space open the lit menu,
//! Esc or a click leaves), Alt+letter opens the menu with that mnemonic straight away, and in an
//! open menu a letter runs the row with that mnemonic (or opens its submenu).
//!
//! Mnemonics are underlined while Alt is held, the bar is focused or a menu was opened from the
//! keyboard; a menu opened with the mouse shows none until Alt is pressed, as Windows does.
//! They are assigned per menu level from the labels as shown (so translated), unique within
//! the level: Photoshop's letters for the top menus where the label has them (F E I L Y S T V
//! W H), else the first unused letter, word starts first. Only ASCII letters and digits are keys
//! the keyboard reports as such, so a label without one (Japanese, Chinese…) has no mnemonic.
//!
//! The state is view state in egui memory, like [`crate::menu_nav::Nav`]. On macOS the native
//! menu bar replaces the in-window one and this never runs.
use egui::{Color32, Event, Id, Key, Modifiers, Stroke, Ui};
use crate::menu_nav::Nav;
/// Photoshop's mnemonics for the top menus (Windows), used where the shown label has the letter.
pub const TOP_PREFERRED: [(&str, char); 10] = [
("File", 'F'),
("Edit", 'E'),
("Image", 'I'),
("Layer", 'L'),
("Type", 'Y'),
("Select", 'S'),
("Filter", 'T'),
("View", 'V'),
("Window", 'W'),
("Help", 'H'),
];
/// Keyboard state of the menu bar between frames.
#[derive(Clone, Debug, Default)]
pub struct Keys {
/// The title the keyboard is on with no menu open, in screen order (Alt tap, F10).
pub focus: Option<usize>,
/// Underline the mnemonics: the bar is focused or a menu was opened from the keyboard.
pub underline: bool,
/// Alt is down (from its key events, whatever the platform reports as modifiers).
alt_down: bool,
/// Alt went down and nothing else was pressed since: releasing it is a tap.
alt_tap: bool,
/// A menu was just opened from the keyboard: light its first row once it is drawn.
first_row: bool,
}
fn keys_id() -> Id {
Id::new("pc-menu-bar-keys")
}
fn focus_id() -> Id {
Id::new("pc-menu-bar-focus")
}
/// Is the keyboard on the menu bar (no menu open yet)? The shortcuts leave the keys to it then,
/// as they do for an open menu: ← / → / ↓ / ↩ / Esc and the letters drive the bar.
pub fn is_focused(ctx: &egui::Context) -> bool {
let pass = ctx.cumulative_pass_nr();
ctx.data(|d| d.get_temp::<u64>(focus_id())).is_some_and(|seen| seen + 2 >= pass)
}
/// The mnemonic a key press stands for: the letter or digit of a plain key.
fn key_char(key: Key) -> Option<char> {
let name = key.name();
let mut chars = name.chars();
match (chars.next(), chars.next()) {
(Some(c), None) if c.is_ascii_alphanumeric() => Some(c.to_ascii_uppercase()),
_ => None,
}
}
/// Mnemonics for `labels`, unique within the group: the preferred letter when the label has it,
/// else the first unused ASCII letter or digit, trying the first character of each word before
/// the others. Compared case-insensitively; the returned letter is upper case.
pub fn assign(labels: &[&str], preferred: &[Option<char>]) -> Vec<Option<char>> {
let mut used: Vec<char> = Vec::new();
let mut out = Vec::with_capacity(labels.len());
for (i, label) in labels.iter().enumerate() {
let upper: Vec<char> = label.chars().map(|c| c.to_ascii_uppercase()).collect();
let has = |c: char| upper.contains(&c.to_ascii_uppercase());
let mut pick = preferred.get(i).copied().flatten().map(|c| c.to_ascii_uppercase()).filter(|&c| has(c) && !used.contains(&c));
if pick.is_none() {
let starts = label.split(|c: char| !c.is_alphanumeric()).filter_map(|w| w.chars().next());
let rest = label.chars();
pick = starts.chain(rest).filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()).find(|c| !used.contains(c));
}
if let Some(c) = pick {
used.push(c);
}
out.push(pick);
}
out
}
/// `prefix + label` as menu text with the mnemonic's first occurrence in `label` underlined
/// (none when `mnemonic` is None). `color` is the text colour; None takes the widget's.
pub fn mnemonic_text(ui: &Ui, prefix: &str, label: &str, mnemonic: Option<char>, color: Option<Color32>) -> egui::WidgetText {
let color = color.unwrap_or(Color32::PLACEHOLDER);
let font = egui::TextStyle::Button.resolve(ui.style());
let plain = egui::TextFormat { font_id: font.clone(), color, ..Default::default() };
let at = mnemonic.and_then(|m| label.char_indices().find(|(_, c)| c.to_ascii_uppercase() == m).map(|(i, c)| (i, c.len_utf8())));
let Some((i, len)) = at else {
return egui::RichText::new(format!("{prefix}{label}")).color(color).into();
};
let mut job = egui::text::LayoutJob::default();
job.wrap.max_width = f32::INFINITY;
let underlined = egui::TextFormat { underline: Stroke::new(1.0, color), ..plain.clone() };
let before = format!("{prefix}{}", &label[..i]);
if !before.is_empty() {
job.append(&before, 0.0, plain.clone());
}
job.append(&label[i..i + len], 0.0, underlined);
if i + len < label.len() {
job.append(&label[i + len..], 0.0, plain);
}
job.into()
}
impl Keys {
/// Last frame's state.
pub fn load(ctx: &egui::Context) -> Keys {
ctx.data(|d| d.get_temp(keys_id())).unwrap_or_default()
}
/// The state as the last frame left it (tests, inspection).
pub fn current(ctx: &egui::Context) -> Keys {
Self::load(ctx)
}
pub fn store(self, ctx: &egui::Context) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| {
if self.focus.is_some() {
d.insert_temp(focus_id(), pass);
} else {
d.remove::<u64>(focus_id());
}
d.insert_temp(keys_id(), self);
});
}
/// Underline the mnemonics this frame?
pub fn show_underlines(&self, ctx: &egui::Context) -> bool {
self.underline || self.focus.is_some() || self.alt_down || ctx.input(|i| i.modifiers.alt)
}
/// A menu just opened from the keyboard: light its first row (once, when it is drawn).
pub fn take_first_row(&mut self, nav: &mut Nav) {
if self.first_row && nav.top.is_some() {
nav.highlight_first(0);
self.first_row = false;
}
}
/// Handle this frame's keys, after the menus were drawn. `tops` are the titles' popup ids
/// and `mnemonics` their letters, both in screen order; `nav` is the open menu's state. A
/// command a mnemonic ran lands in `clicked`.
pub fn keys(&mut self, ctx: &egui::Context, tops: &[Id], mnemonics: &[Option<char>], nav: &mut Nav, clicked: &mut Option<String>) {
if tops.is_empty() {
return;
}
let events = ctx.input(|i| i.events.clone());
let mut tap = false;
for e in &events {
match e {
Event::Key { key: Key::AltLeft | Key::AltRight, pressed: true, .. } => {
self.alt_down = true;
self.alt_tap = true;
}
Event::Key { key: Key::AltLeft | Key::AltRight, pressed: false, .. } => {
self.alt_down = false;
tap |= std::mem::take(&mut self.alt_tap);
}
Event::Key { pressed: true, .. } | Event::Text(_) | Event::PointerButton { pressed: true, .. } => self.alt_tap = false,
_ => {}
}
}
if events.iter().any(|e| matches!(e, Event::PointerButton { pressed: true, .. })) {
self.focus = None;
self.underline = false;
}
let open = nav.top.is_some();
let n = tops.len();
let open_top = |i: usize, keys: &mut Keys| {
if let Some(&id) = tops.get(i) {
egui::Popup::open_id(ctx, id);
keys.focus = None;
keys.underline = true;
keys.first_row = true;
ctx.request_repaint();
}
};
let consume = |m: Modifiers, k: Key| ctx.input_mut(|i| i.consume_key(m, k));
// Alt+letter opens that menu, open or not (an open one switches), as on Windows.
let alt_letter = events.iter().find_map(|e| match e {
Event::Key { key, pressed: true, modifiers, .. } if modifiers.alt && !modifiers.command && !modifiers.ctrl => key_char(*key).map(|c| (*key, c)),
_ => None,
});
if let Some((key, c)) = alt_letter
&& let Some(i) = mnemonics.iter().position(|m| *m == Some(c))
{
consume(Modifiers::ALT, key);
self.alt_tap = false;
open_top(i, self);
return;
}
if open {
// A letter runs the row with that mnemonic in the level the arrows are in.
let letter = events.iter().find_map(|e| match e {
Event::Key { key, pressed: true, modifiers, .. } if !modifiers.command && !modifiers.ctrl && !modifiers.alt => {
key_char(*key).map(|c| (*key, c))
}
_ => None,
});
if let Some((key, c)) = letter
&& let Some(row) = nav.row_with_mnemonic(c)
{
consume(Modifiers::NONE, key);
self.underline = true;
if let Some(cmd) = row.command.clone() {
*clicked = Some(cmd);
egui::Popup::close_all(ctx);
} else {
nav.open_sub_row(ctx, row.widget);
}
ctx.request_repaint();
}
if tap {
egui::Popup::close_all(ctx);
self.underline = false;
ctx.request_repaint();
}
return;
}
// No menu open: Alt (tapped) or F10 puts the keyboard on the bar or takes it off again.
let f10 = consume(Modifiers::NONE, Key::F10);
if tap || f10 {
self.focus = if self.focus.is_some() { None } else { Some(0) };
self.underline = false;
ctx.request_repaint();
return;
}
let Some(focus) = self.focus else { return };
let press = |k: Key| consume(Modifiers::NONE, k);
if press(Key::Escape) {
self.focus = None;
} else if press(Key::ArrowRight) {
self.focus = Some((focus + 1) % n);
} else if press(Key::ArrowLeft) {
self.focus = Some((focus + n - 1) % n);
} else if press(Key::ArrowDown) || press(Key::Enter) || press(Key::Space) {
open_top(focus, self);
} else if let Some((key, c)) = events.iter().find_map(|e| match e {
Event::Key { key, pressed: true, modifiers, .. } if modifiers.is_none() => key_char(*key).map(|c| (*key, c)),
_ => None,
}) {
if let Some(i) = mnemonics.iter().position(|m| *m == Some(c)) {
consume(Modifiers::NONE, key);
open_top(i, self);
}
} else {
return;
}
ctx.request_repaint();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn letters_are_unique_and_prefer_photoshop_s() {
let labels = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"];
let preferred: Vec<Option<char>> = TOP_PREFERRED.iter().map(|(_, c)| Some(*c)).collect();
assert_eq!(assign(&labels, &preferred), "FEILYSTVWH".chars().map(Some).collect::<Vec<_>>());
// Items: first letters first, then the next free letter of the word starts, then any.
assert_eq!(
assign(&["New…", "Open…", "Open Recent", "Close", "Close All", "Save", "Save As…"], &[]),
[Some('N'), Some('O'), Some('R'), Some('C'), Some('A'), Some('S'), Some('V'),]
);
// A preferred letter the label lacks (a translation) falls back; a taken one too.
assert_eq!(assign(&["Datei", "Bearbeiten"], &[Some('F'), Some('E')]), [Some('D'), Some('E')]);
assert_eq!(assign(&["Aa", "Ab", "Ba"], &[Some('A'), Some('A'), Some('A')]), [Some('A'), Some('B'), None]);
// Only ASCII letters and digits are keys: no mnemonic without one.
assert_eq!(assign(&["ファイル", "Zoom 100%", "✔ Rulers"], &[]), [None, Some('Z'), Some('R')]);
}
#[test]
fn key_chars_are_plain_letters_and_digits() {
assert_eq!(key_char(Key::A), Some('A'));
assert_eq!(key_char(Key::Num7), Some('7'));
assert_eq!(key_char(Key::F10), None);
assert_eq!(key_char(Key::ArrowDown), None);
assert_eq!(key_char(Key::Space), None);
}
}
#[cfg(test)]
#[path = "menu_keys_tests.rs"]
mod harness_tests;
+212
View File
@@ -0,0 +1,212 @@
//! The menu bar from the keyboard (#4988): Alt / F10 focus the bar, Alt+letter opens a menu,
//! letters run rows, and the shortcuts wait while the bar has the keyboard.
use crate::PhotocraftApp;
use crate::menu_keys::Keys;
use crate::menu_nav::{Nav, is_open};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
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
}
/// The menu bar alone, as `menu_nav_tests` drives it.
fn bar() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1280.0, 720.0)).build_ui_state(
|ui, app| {
crate::menus::menu_bar(app, ui);
},
app(),
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(3);
h
}
fn key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) {
h.key_press(k);
h.run_steps(3);
}
fn alt_key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) {
h.event_modifiers(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::ALT }, egui::Modifiers::ALT);
h.run_steps(3);
}
/// A tap on Alt: down, then up with nothing else pressed between.
fn alt_tap(h: &mut Harness<'static, PhotocraftApp>) {
h.key_down(egui::Key::AltLeft);
h.run_steps(2);
h.key_up(egui::Key::AltLeft);
h.run_steps(3);
}
fn rows(h: &Harness<'static, PhotocraftApp>, level: usize) -> Vec<crate::menu_nav::Row> {
Nav::current(&h.ctx).rows.get(level).cloned().unwrap_or_default()
}
#[test]
fn alt_focuses_the_bar_arrows_move_and_down_opens_the_lit_menu() {
let mut h = bar();
assert_eq!(Keys::current(&h.ctx).focus, None);
alt_tap(&mut h);
assert_eq!(Keys::current(&h.ctx).focus, Some(0), "Alt puts the keyboard on File");
assert!(crate::menu_keys::is_focused(&h.ctx), "the shortcuts wait for the bar");
assert!(!is_open(&h.ctx), "no menu opens yet");
key(&mut h, egui::Key::ArrowRight);
assert_eq!(Keys::current(&h.ctx).focus, Some(1), "→ moves to Edit");
key(&mut h, egui::Key::ArrowLeft);
key(&mut h, egui::Key::ArrowLeft);
assert_eq!(Keys::current(&h.ctx).focus, Some(9), "← wraps round to Help");
key(&mut h, egui::Key::ArrowRight);
key(&mut h, egui::Key::ArrowRight);
assert_eq!(Keys::current(&h.ctx).focus, Some(1));
key(&mut h, egui::Key::ArrowDown);
h.run_steps(2);
assert!(is_open(&h.ctx), "↓ opens the lit menu");
assert!(h.query_by_label_contains("Undo").is_some(), "Edit is open");
assert_eq!(Keys::current(&h.ctx).focus, None, "the focus moves into the menu");
let first = rows(&h, 0).iter().position(|r| r.enabled).unwrap();
assert_eq!(Nav::current(&h.ctx).highlighted(0), Some(first), "its first row is lit, as on Windows");
assert!(Keys::current(&h.ctx).underline, "a menu opened from the keyboard shows its mnemonics");
key(&mut h, egui::Key::Escape);
assert!(!is_open(&h.ctx) && Keys::current(&h.ctx).focus.is_none(), "Esc leaves the menu and the bar");
}
#[test]
fn a_second_alt_or_esc_or_a_click_takes_the_keyboard_off_the_bar() {
let mut h = bar();
alt_tap(&mut h);
assert_eq!(Keys::current(&h.ctx).focus, Some(0));
alt_tap(&mut h);
assert_eq!(Keys::current(&h.ctx).focus, None, "a second tap leaves");
key(&mut h, egui::Key::F10);
assert_eq!(Keys::current(&h.ctx).focus, Some(0), "F10 focuses the bar too");
key(&mut h, egui::Key::Escape);
assert_eq!(Keys::current(&h.ctx).focus, None);
assert!(!crate::menu_keys::is_focused(&h.ctx));
key(&mut h, egui::Key::F10);
let at = egui::pos2(600.0, 300.0);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.run_steps(2);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
assert_eq!(Keys::current(&h.ctx).focus, None, "a click anywhere leaves");
}
#[test]
fn alt_with_a_letter_opens_that_menu_and_enter_opens_the_lit_one() {
let mut h = bar();
alt_key(&mut h, egui::Key::E);
h.run_steps(2);
assert!(is_open(&h.ctx) && h.query_by_label_contains("Undo").is_some(), "Alt+E opens Edit");
alt_key(&mut h, egui::Key::F);
h.run_steps(2);
assert!(h.query_by_label_contains("Open…").is_some(), "Alt+F switches to File while a menu is open");
key(&mut h, egui::Key::Escape);
assert!(!is_open(&h.ctx));
key(&mut h, egui::Key::F10);
key(&mut h, egui::Key::Enter);
h.run_steps(2);
assert!(h.query_by_label_contains("Open…").is_some(), "Enter opens the lit File menu");
key(&mut h, egui::Key::Escape);
key(&mut h, egui::Key::F10);
key(&mut h, egui::Key::V);
h.run_steps(2);
assert!(is_open(&h.ctx) && h.query_by_label_contains("Zoom In").is_some(), "a letter on the focused bar opens that menu (V = View)");
}
#[test]
fn a_letter_in_an_open_menu_runs_the_row_with_that_mnemonic_or_opens_its_submenu() {
let mut h = bar();
alt_key(&mut h, egui::Key::V);
h.run_steps(2);
assert!(h.query_by_label_contains("Zoom In").is_some(), "View is open");
let level = rows(&h, 0);
let zoom_in = level.iter().find(|r| r.command.as_deref() == Some("view.zoomIn")).expect("Zoom In row");
let letter = zoom_in.mnemonic.expect("Zoom In has a mnemonic");
assert!(level.iter().filter(|r| r.mnemonic == Some(letter)).count() == 1, "mnemonics are unique within a level");
let zoom = h.state().ui.views[0].zoom;
key(&mut h, egui::Key::from_name(&letter.to_string()).unwrap());
h.run_steps(3);
assert!(h.state().ui.views[0].zoom > zoom, "the letter ran Zoom In ({letter})");
assert!(!is_open(&h.ctx), "and closed the menu");
// A submenu's letter opens it, with its first row lit.
alt_key(&mut h, egui::Key::V);
h.run_steps(2);
let level = rows(&h, 0);
let sub = level.iter().find(|r| r.command.is_none() && r.enabled && r.mnemonic.is_some()).expect("an enabled submenu with a mnemonic");
let letter = sub.mnemonic.unwrap();
key(&mut h, egui::Key::from_name(&letter.to_string()).unwrap());
h.run_steps(3);
assert!(!rows(&h, 1).is_empty(), "the submenu opened ({letter})");
assert_eq!(Nav::current(&h.ctx).level, 1);
}
#[test]
fn mnemonics_are_underlined_only_with_alt_or_a_keyboard_opened_menu() {
let mut h = bar();
h.get_by_label("File").click();
h.run_steps(4);
assert!(is_open(&h.ctx));
assert!(!Nav::current(&h.ctx).underline, "a menu opened with the mouse shows no underlines");
h.key_down(egui::Key::AltLeft);
h.run_steps(3);
assert!(Nav::current(&h.ctx).underline, "while Alt is held they show");
h.key_up(egui::Key::AltLeft);
h.run_steps(3);
// The tap closed the menu (as on Windows); opening from the keyboard underlines.
assert!(!is_open(&h.ctx), "an Alt tap closes an open menu");
alt_key(&mut h, egui::Key::F);
h.run_steps(2);
assert!(is_open(&h.ctx) && Nav::current(&h.ctx).underline);
assert!(rows(&h, 0).iter().any(|r| r.mnemonic.is_some()), "rows carry their letters");
}
#[test]
fn underlined_text_marks_the_mnemonic_once_and_keeps_the_prefix() {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(Default::default(), |ui| {
let t = crate::menu_keys::mnemonic_text(ui, "✔ ", "Save As…", Some('A'), None);
let egui::WidgetText::LayoutJob(job) = t else { panic!("a mnemonic makes a layout job") };
assert_eq!(job.text, "✔ Save As…");
let underlined: Vec<&str> =
job.sections.iter().filter(|s| s.format.underline != egui::Stroke::NONE).map(|s| &job.text[s.byte_range.start.0..s.byte_range.end.0]).collect();
assert_eq!(underlined, ["a"], "the first occurrence, case-insensitively");
let t = crate::menu_keys::mnemonic_text(ui, "", "Edit", None, None);
assert!(matches!(t, egui::WidgetText::RichText(_)), "without a mnemonic, plain text");
let t = crate::menu_keys::mnemonic_text(ui, "", "Édition", Some('D'), None);
let egui::WidgetText::LayoutJob(job) = t else { panic!() };
assert_eq!(job.sections.len(), 3, "multi-byte text before the letter is kept whole");
});
out.textures_delta.clear();
}
/// The whole app: with the bar focused a letter opens a menu instead of picking a tool, and the
/// arrows don't nudge the layer.
#[test]
fn the_shortcuts_wait_while_the_keyboard_is_on_the_bar() {
let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = app();
app.services.native_menu = None;
app
});
h.run_steps(4);
let tool = h.state().ui.tool;
key(&mut h, egui::Key::F10);
assert_eq!(Keys::current(&h.ctx).focus, Some(0), "F10 focuses the bar");
key(&mut h, egui::Key::E);
h.run_steps(2);
assert_eq!(h.state().ui.tool, tool, "E did not pick the Eraser");
assert!(is_open(&h.ctx) && h.query_by_label_contains("Undo").is_some(), "E opened Edit");
key(&mut h, egui::Key::Escape);
h.run_steps(2);
assert!(!is_open(&h.ctx));
h.key_press(egui::Key::E);
h.run_steps(3);
assert_ne!(h.state().ui.tool, tool, "off the bar, E picks the Eraser again");
}
+33 -3
View File
@@ -50,13 +50,17 @@ pub struct Row {
pub command: Option<String>,
/// Where it was drawn (scrolled out of view when outside its level's `views` rect).
pub rect: Rect,
/// The letter that runs it from the keyboard ([`crate::menu_keys`]), upper case.
pub mnemonic: Option<char>,
}
/// 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>,
pub(crate) top: Option<usize>,
/// Underline the rows' mnemonics this frame ([`crate::menu_keys`]).
pub underline: bool,
/// 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).
@@ -146,7 +150,15 @@ impl Nav {
/// 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. A row
/// without a `command` opens a submenu.
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 {
pub fn row<R>(
&mut self,
ui: &mut Ui,
level: usize,
enabled: bool,
command: Option<&str>,
mnemonic: Option<char>,
add: impl FnOnce(&mut Ui, &mut Nav) -> (egui::Response, R),
) -> R {
let i = self.rows.get(level).map_or(usize::MAX, Vec::len);
// While a submenu is kept open for the pointer heading to it, its row stays lit and the
// rows crossed on the way don't light up.
@@ -166,7 +178,7 @@ impl Nav {
self.aim(level, i, root, open, &r);
}
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 });
rows.push(Row { widget: r.id, enabled, command: command.map(str::to_string), rect: r.rect, mnemonic });
if let Some(index) = held {
self.set_hi(level, Some(index));
} else {
@@ -278,6 +290,24 @@ impl Nav {
}
/// Open the highlighted submenu of `level` and move the keyboard into it.
/// Light the first enabled row of `level` once it has been drawn (a menu opened from the
/// keyboard, as on Windows).
pub(crate) fn highlight_first(&mut self, level: usize) {
self.first_at = Some(level);
}
/// The enabled row of the level the arrows are in whose mnemonic is `c`.
pub(crate) fn row_with_mnemonic(&self, c: char) -> Option<Row> {
let level = self.level.min(self.rows.len().saturating_sub(1));
self.rows.get(level)?.iter().find(|r| r.enabled && r.mnemonic == Some(c)).cloned()
}
/// Open the submenu of row `widget` of the level the arrows are in.
pub(crate) fn open_sub_row(&mut self, ctx: &egui::Context, widget: Id) {
let level = self.level.min(self.rows.len().saturating_sub(1));
self.open_sub(ctx, level, widget);
}
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);
+71 -13
View File
@@ -1187,6 +1187,13 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
let mut clicked: Option<String> = None;
let t = crate::theme::Tokens::get(ui.ctx());
let mut nav = crate::menu_nav::Nav::load(ui.ctx());
// The keyboard on the bar (#4988): Alt / F10 focus, Alt+letter, mnemonics.
let mut keys = crate::menu_keys::Keys::load(ui.ctx());
keys.take_first_row(&mut nav);
nav.underline = keys.show_underlines(ui.ctx());
let top_labels: Vec<String> = TOP_MENUS.iter().map(|top| crate::i18n::tr(lang, top).to_string()).collect();
let preferred: Vec<Option<char>> = TOP_MENUS.iter().map(|top| crate::menu_keys::TOP_PREFERRED.iter().find(|(t, _)| t == top).map(|(_, c)| *c)).collect();
let top_mnemonics = crate::menu_keys::assign(&top_labels.iter().map(String::as_str).collect::<Vec<_>>(), &preferred);
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let v = &mut ui.style_mut().visuals;
@@ -1201,10 +1208,21 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
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 {
for (k, top) in TOP_MENUS.iter().enumerate() {
// egui's menu_button toggles on release; these titles open on the press (one
// gesture can press, drag to an item and release), so each drives its popup.
let title = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(lang, top)).color(t.text_dim)));
// The title the keyboard is on (Alt / F10) is lit like a hovered one.
let focused = keys.focus == Some(if rtl { TOP_MENUS.len() - 1 - k } else { k });
let label = if nav.underline {
crate::menu_keys::mnemonic_text(ui, "", &top_labels[k], top_mnemonics[k], Some(t.text_dim))
} else {
egui::RichText::new(top_labels[k].as_str()).color(t.text_dim).into()
};
let mut button = egui::Button::new(label);
if focused {
button = button.fill(ui.visuals().widgets.hovered.weak_bg_fill);
}
let title = ui.add(button);
// The bar's close behaviour and style, as a menu (not bar) config so submenus inside
// render as submenus.
let bar = egui::containers::menu::MenuConfig::find(ui);
@@ -1215,7 +1233,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
let opening = title.clicked() && ui.ctx().data(|d| d.get_temp::<bool>(press_gesture_id())).unwrap_or(false);
let close = if opening {
egui::PopupCloseBehavior::IgnoreClicks
} else if top == "Help" {
} else if *top == "Help" {
// Menus close on any click, inside them too; a click in Help's search field
// must keep it open. Its items close it themselves when chosen.
egui::PopupCloseBehavior::CloseOnClickOutside
@@ -1237,12 +1255,12 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
ItemCache { state, items }
})
.items;
let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect();
let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(*top)).collect();
ui.set_min_width(220.0);
if mine.is_empty() {
ui.weak(crate::i18n::tr(lang, "(coming soon)"));
}
if top == "Help" {
if *top == "Help" {
// The search field scrolls with the rows, as part of the menu's content.
crate::menu_nav::level(ui, 1, &mut nav, |ui, nav| {
help_search(ui, items, opening, &mut clicked, nav);
@@ -1262,8 +1280,14 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
tops.reverse();
}
nav.keys(ui.ctx(), &tops, &mut clicked);
let mut mnemonics = top_mnemonics.clone();
if rtl {
mnemonics.reverse();
}
keys.keys(ui.ctx(), &tops, &mnemonics, &mut nav, &mut clicked);
});
});
keys.store(ui.ctx());
nav.store(ui.ctx());
// No menu open this frame: nothing is kept.
app.menu_cache = built.into_inner();
@@ -1418,7 +1442,7 @@ fn help_search(ui: &mut egui::Ui, items: &[MenuItem], opening: bool, clicked: &m
if let Some(sc) = &it.shortcut {
b = b.shortcut_text(crate::shortcuts::pretty(sc));
}
let hit = nav.row(ui, 0, it.enabled, Some(&it.id), |ui, _| {
let hit = nav.row(ui, 0, it.enabled, Some(&it.id), None, |ui, _| {
let r = ui.add_enabled(it.enabled, b);
let hit = r.clicked() || released_on(ui, &r);
(r, hit)
@@ -1453,6 +1477,27 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
crate::widgets::style_spectrum_popup_menu(ui);
// Walk in Photoshop order: leaves and separators at this depth; a submenu appears at the position
// of its first child.
// Each row's mnemonic (#4988), unique within this level, in display order: the leaves' labels
// and the submenus' names as shown.
let shown_labels: Vec<String> = {
let mut subs: Vec<&str> = Vec::new();
items
.iter()
.filter_map(|it| {
if it.path.len() == depth {
(it.label != "---").then(|| translated_menu_label(lang, it))
} else {
let name = it.path.get(depth).map(String::as_str)?;
(!subs.contains(&name)).then(|| {
subs.push(name);
crate::i18n::tr(lang, name).to_string()
})
}
})
.collect()
};
let mnemonics = crate::menu_keys::assign(&shown_labels.iter().map(String::as_str).collect::<Vec<_>>(), &[]);
let mut next_row = 0usize;
let mut shown_subs: Vec<&str> = Vec::new();
let mut last_was_sep = true;
for (i, it) in items.iter().enumerate() {
@@ -1464,10 +1509,16 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
}
continue;
}
let mut text = translated_menu_label(lang, it);
if let Some(c) = it.checked {
text = format!("{} {}", if c { "✔" } else { " " }, text);
}
let label = translated_menu_label(lang, it);
let prefix = match it.checked {
Some(true) => "✔ ",
Some(false) => " ",
None => "",
};
let mnemonic = mnemonics.get(next_row).copied().flatten();
next_row += 1;
let text: egui::WidgetText =
if nav.underline { crate::menu_keys::mnemonic_text(ui, prefix, &label, mnemonic, None) } else { format!("{prefix}{label}").into() };
let mut b = egui::Button::new(text);
if let Some(tint) = it.color.as_deref().and_then(menu_tint) {
b = b.fill(tint.gamma_multiply(0.55));
@@ -1475,7 +1526,7 @@ 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));
}
let hit = nav.row(ui, depth - 1, it.enabled, Some(&it.id), |ui, _| {
let hit = nav.row(ui, depth - 1, it.enabled, Some(&it.id), mnemonic, |ui, _| {
let r = ui.add_enabled(it.enabled, b);
let r = match it.id.as_str() {
"image.mode.bits8" | "image.mode.bits16" => r.on_hover_text(crate::i18n::tr(lang, "Integer")),
@@ -1498,9 +1549,16 @@ 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 enabled = submenu_enabled(&child);
let mnemonic = mnemonics.get(next_row).copied().flatten();
next_row += 1;
let title: egui::WidgetText = if nav.underline {
crate::menu_keys::mnemonic_text(ui, "", crate::i18n::tr(lang, name), mnemonic, None)
} else {
crate::i18n::tr(lang, name).into()
};
ui.add_enabled_ui(enabled, |ui| {
nav.row(ui, depth - 1, enabled, None, |ui, nav| {
let r = ui.menu_button(crate::i18n::tr(lang, name), |ui| render_level(ui, &child, depth + 1, clicked, nav));
nav.row(ui, depth - 1, enabled, None, mnemonic, |ui, nav| {
let r = ui.menu_button(title, |ui| render_level(ui, &child, depth + 1, clicked, nav));
if r.inner.is_some() {
// Where the submenu hangs from, to keep it below the menu bar (#319).
nav.set_anchor(depth, r.response.rect);
+4 -3
View File
@@ -334,9 +334,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
if app.camera_raw.is_some() {
return;
}
// An open menu owns the keyboard (arrows, ↩, Esc), like a native menu; so does an open
// dropdown list, whose letters pick an option rather than a tool (#1481).
if crate::menu_nav::is_open(ctx) || crate::widgets::dropdown_list_open(ctx) {
// An open menu owns the keyboard (arrows, ↩, Esc), like a native menu; so does the menu bar
// once Alt or F10 put the keyboard on it (#4988), and an open dropdown list, whose letters
// pick an option rather than a tool (#1481).
if crate::menu_nav::is_open(ctx) || crate::menu_keys::is_focused(ctx) || crate::widgets::dropdown_list_open(ctx) {
return;
}
// ⌘Z in a filter or adjustment dialog steps the dialog's settings, not the document (#4697).
+1 -1
View File
@@ -76,7 +76,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape.
- Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it.
- `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`. `{"key": "AltLeft"}` is a tap on Alt, which puts the keyboard on the in-window menu bar (then `ArrowRight`, `ArrowDown`, `Enter`, `Escape` or a menu's letter drive it); `{"key": "F", "alt": true}` opens the menu with that mnemonic (#4988)
- `ui.pointer`, `ui.click`, `ui.move`, `ui.key` and `ui.screenshot` validate every parameter before acting, as `ui.set` does: an unknown field (top-level, under `modifiers` or in an event), a value of the wrong JSON type (`"x": "abc"`, `"ctrl": "yes"`), a `button`, `kind`, `tool` or `key` outside the accepted set, or a number outside its range (`pressure`, tilt, `rotation`, `count`) is an error naming the parameter, and nothing is queued or applied. A `null` field counts as omitted and takes its default
- Engine commands (`engine.execute`, `photocraft-cli run`, MCP `command_run`) reject a param key their `params` doc does not name for the commands listed in `STRICT` (`crates/engine/src/param_check.rs`, today `layer.hideLayers` and `layer.showLayers`): `{"ids":[4]}` is the error `unknown parameter \`ids\` (expected: layer)`, and nothing runs. Other commands still ignore unknown keys until their `params` doc is complete and they are added to that list (`coalesce` and `target` are always accepted).
- `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing)
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
@@ -208,6 +208,7 @@ Paragraph; the rest open as in-app floating windows.
| Color panel wheel and RGB/HSB/CMYK/Lab sliders | missing | UI-217-17, #2569 |
| Window size and dock width remembered | done | UI-217-2 |
| Command palette with recents | done | UI-217-7 |
| Menu bar from the keyboard: Alt / F10 focus the bar, ← → ↓ ↩ drive it, Alt+letter opens a menu, underlined mnemonics run items | done | `menu_keys.rs`, #4988; macOS uses the native bar |
| Every preference takes effect | partial | 46 of 149 do nothing (scorecard) |
| UI scale presets | missing | UI-217-9, #2492 |
| History snapshots: camera button, snapshot rows (click returns and selects, rename, delete; the trash deletes the selected snapshot, and a snapshot or state dragged onto it, #5324), `history.*Snapshot` commands | partial | `history_cmds.rs`, `snapshot_rows` in `panels.rs`; New Snapshot dialog, first snapshot and History Options to come (#2966) |
@@ -223,6 +224,7 @@ Paragraph; the rest open as in-app floating windows.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) |
| 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial |
| 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed |
| 2026-10-13 | minor | Smart object perspective (non-affine) corrected to done: #426 landed four-corner placements with PSD round trip; scorecard FILE-215-6 flipped |