effectcraft/crates/ui-egui/src/menu_keys.rs

332 lines
13 KiB
Rust

//! The keyboard in the in-window menu bar (#279), as in After Effects on Windows: Alt pressed and
//! released on its own focuses the menu bar (and leaves it again); Left / Right move between its
//! menus, Down, Enter or Space opens one. In an open menu Up / Down move the highlight through
//! the entries (skipping disabled ones, wrapping around), Right opens the highlighted submenu and
//! Left closes it again (in the top menu they open the next / previous menu), Enter or Space
//! chooses the highlighted entry and Escape closes the menus. The highlight is egui's keyboard
//! focus, so Enter and Space are egui's own clicks on it.
//!
//! While a menu is open or the bar has the keyboard, keys go to the menus only: the app's
//! shortcuts and the panels don't see them (Space must not start a preview).
use egui::containers::menu::{MenuState, SubMenu, find_menu_root};
use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Response, Ui};
/// An entry of an open menu as drawn this frame.
#[derive(Clone, Copy)]
struct Row {
id: Id,
enabled: bool,
submenu: bool,
}
/// An open menu (or submenu) as drawn this frame.
#[derive(Default)]
struct Level {
/// The menu's root `Ui` (where egui keeps which submenu is open).
menu: Option<Id>,
/// The submenu entry it opened from (none for a top-level menu).
owner: Option<Id>,
/// Drawn only to measure it (its first frame): its entries can't take the highlight yet.
sizing: bool,
rows: Vec<Row>,
}
/// The menu bar's buttons and open menus, collected while they are drawn.
#[derive(Default)]
pub struct Nav {
/// The bar's menu buttons and their menus' popups.
tops: Vec<(Id, Id)>,
/// Open menus by depth: the top-level menu, then each open submenu.
levels: Vec<Level>,
}
/// What to highlight once a menu opened from the keyboard has been drawn: the first entry of the
/// menu at this depth.
#[derive(Clone, Copy)]
struct FirstEntry(usize);
/// Submenus opened from the keyboard, with where the pointer was: egui closes an open submenu
/// while the pointer rests on another entry of its menu, so they stay open until it moves.
#[derive(Clone, Default)]
struct KeyOpened {
submenus: Vec<(Id, Id)>,
pointer: Option<Pos2>,
}
fn pending_id() -> Id {
Id::new("menu-keys-first")
}
fn opened_id() -> Id {
Id::new("menu-keys-opened")
}
fn active_id() -> Id {
Id::new("menu-keys-active")
}
fn alt_id() -> Id {
Id::new("menu-keys-alt")
}
impl Nav {
/// A top-level menu button of the bar.
pub fn top(&mut self, button: &Response) {
self.tops.push((button.id, Popup::default_response_id(button)));
}
/// An entry drawn at `depth` (0: a top-level menu) in the menu `ui`. Hovering an entry moves
/// the keyboard highlight to it, and a highlighted entry scrolls into view.
pub fn entry(&mut self, ui: &Ui, depth: usize, r: &Response, submenu: bool) {
while self.levels.len() <= depth {
self.levels.push(Level::default());
}
let Some(level) = self.levels.get_mut(depth) else { return };
if level.menu.is_none() {
level.menu = Some(find_menu_root(ui).id);
level.sizing = ui.is_sizing_pass();
}
level.rows.push(Row { id: r.id, enabled: r.enabled(), submenu });
if r.gained_focus() {
r.scroll_to_me(None);
}
let ctx = ui.ctx();
let keyboard = active(ctx) && ctx.memory(|m| m.focused()).is_some();
if keyboard && r.enabled() && !r.has_focus() && r.hovered() && ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO) {
r.request_focus();
}
}
/// Submenu entry `button` at `depth` showed its submenu this frame (drawn at `depth + 1`).
pub fn opened(&mut self, depth: usize, button: &Response) {
if let Some(level) = self.levels.get_mut(depth + 1) {
level.owner = Some(button.id);
}
}
/// The keyboard highlight: (depth, index) of the focused entry.
fn focused(&self, focus: Option<Id>) -> Option<(usize, usize)> {
let f = focus?;
self.levels.iter().enumerate().rev().find_map(|(d, l)| l.rows.iter().position(|r| r.id == f).map(|i| (d, i)))
}
}
/// Whether the menu bar had the keyboard last frame (a menu open or the bar focused).
pub fn active(ctx: &Context) -> bool {
ctx.data(|d| d.get_temp(active_id())).unwrap_or(false)
}
/// Alt pressed and released with nothing else in between (no other modifier, key, click or wheel),
/// off macOS. Alt only counts when it went down with no other modifier held: Windows switches the
/// keyboard layout with Alt+Shift, and releasing Shift first leaves Alt held alone, which must not
/// take the keyboard from the field being typed in (#394).
fn alt_tapped(ctx: &Context) -> bool {
if cfg!(target_os = "macos") {
return false;
}
// (armed, the modifiers held after the last change)
let (mut armed, mut held): (bool, Modifiers) = ctx.data(|d| d.get_temp(alt_id())).unwrap_or_default();
let mut tapped = false;
ctx.input(|i| {
for e in &i.events {
match e {
Event::ModifiersChanged(m) => {
tapped |= armed && *m == Modifiers::NONE;
armed = *m == Modifiers::ALT && held == Modifiers::NONE;
held = *m;
}
Event::PointerMoved(_) | Event::MouseMoved(_) | Event::Zoom(_) => {}
_ => armed = false,
}
}
});
ctx.data_mut(|d| d.insert_temp(alt_id(), (armed, held)));
tapped
}
/// The next enabled entry of `rows` after `from` (`step` 1) or before it (-1), wrapping around;
/// from none: the first (or last) one.
fn step(rows: &[Row], from: Option<usize>, step: isize) -> Option<Id> {
let n = rows.len() as isize;
let start = from.map_or(if step > 0 { -1 } else { n }, |i| i as isize);
(1..=n).map(|k| (start + step * k).rem_euclid(n.max(1))).filter_map(|i| rows.get(i as usize)).find(|r| r.enabled).map(|r| r.id)
}
fn focus(ctx: &Context, id: Id) {
ctx.memory_mut(|m| m.request_focus(id));
}
/// The bar's menu `i`, wrapping around.
fn nth_top(nav: &Nav, i: isize) -> Option<(Id, Id)> {
nav.tops.get(i.rem_euclid((nav.tops.len() as isize).max(1)) as usize).copied()
}
/// Move the bar's keyboard focus to its menu `i` (wrapping).
fn focus_top(ctx: &Context, nav: &Nav, i: isize) {
if let Some((button, _)) = nth_top(nav, i) {
focus(ctx, button);
}
}
/// Open the top-level menu `i` (wrapping) with its first entry highlighted once drawn.
fn open_top(ctx: &Context, nav: &Nav, i: isize) {
let Some((button, popup)) = nth_top(nav, i) else { return };
Popup::open_id(ctx, popup);
focus(ctx, button);
ctx.data_mut(|d| {
d.insert_temp(pending_id(), FirstEntry(0));
d.remove::<KeyOpened>(opened_id());
});
}
/// Open the submenu of entry `row` in the menu at `depth`, its first entry highlighted once drawn.
fn open_submenu(ctx: &Context, nav: &Nav, depth: usize, row: Id) {
let Some(menu) = nav.levels.get(depth).and_then(|l| l.menu) else { return };
let sub = SubMenu::id_from_widget_id(row);
show_submenu(ctx, menu, sub);
let pointer = ctx.pointer_hover_pos();
ctx.data_mut(|d| {
let mut o: KeyOpened = d.get_temp(opened_id()).unwrap_or_default();
o.submenus.retain(|(m, _)| *m != menu);
o.submenus.push((menu, sub));
o.pointer = pointer;
d.insert_temp(opened_id(), o);
d.insert_temp(pending_id(), FirstEntry(depth + 1));
});
}
/// Handle this frame's keys once the bar and its open menus are drawn (`nav`).
pub fn handle(ctx: &Context, nav: &Nav) {
let was_active = active(ctx);
let open = nav.tops.iter().position(|(_, p)| Popup::is_id_open(ctx, *p));
let focus_id = ctx.memory(|m| m.focused());
let bar = focus_id.and_then(|f| nav.tops.iter().position(|(b, _)| *b == f));
if alt_tapped(ctx) {
if open.is_some() || bar.is_some() {
Popup::close_all(ctx);
if let Some(f) = focus_id {
ctx.memory_mut(|m| m.surrender_focus(f));
}
} else if let Some((first, _)) = nav.tops.first() {
focus(ctx, *first);
}
ctx.data_mut(|d| d.insert_temp(active_id(), true));
strip_keys(ctx);
return;
}
let now_active = open.is_some() || bar.is_some();
ctx.data_mut(|d| d.insert_temp(active_id(), now_active));
if !now_active {
ctx.data_mut(|d| {
d.remove::<FirstEntry>(pending_id());
d.remove::<KeyOpened>(opened_id());
});
if was_active {
// (The key that closed the menus, e.g. Enter on an entry, stays with them.)
strip_keys(ctx);
}
return;
}
keep_key_opened(ctx);
// A menu opened from the keyboard and now shown: highlight its first entry (if it has an
// enabled one).
if let Some(FirstEntry(depth)) = ctx.data(|d| d.get_temp(pending_id()))
&& let Some(level) = nav.levels.get(depth).filter(|l| !l.sizing)
{
if let Some(id) = step(&level.rows, None, 1) {
focus(ctx, id);
}
ctx.data_mut(|d| d.remove::<FirstEntry>(pending_id()));
}
let pressed =
|k: Key| ctx.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key, pressed: true, modifiers, .. } if *key == k && !modifiers.any())));
let cur = nav.focused(focus_id);
let row = cur.and_then(|(d, i)| nav.levels.get(d)?.rows.get(i).copied());
for (key, dir) in [(Key::ArrowDown, 1), (Key::ArrowUp, -1)] {
if !pressed(key) {
continue;
}
match (open, cur) {
(Some(_), Some((d, i))) => {
if let Some(id) = nav.levels.get(d).and_then(|l| step(&l.rows, Some(i), dir)) {
focus(ctx, id);
}
}
// Opened with the pointer: the deepest open menu's first (last) entry.
(Some(_), None) => {
if let Some(id) = nav.levels.last().and_then(|l| step(&l.rows, None, dir)) {
focus(ctx, id);
}
}
(None, _) if dir > 0 => open_top(ctx, nav, bar.unwrap_or(0) as isize),
(None, _) => {}
}
}
let top = open.or(bar).unwrap_or(0) as isize;
if pressed(Key::ArrowRight) {
match (cur, row) {
(Some((d, _)), Some(r)) if r.submenu => open_submenu(ctx, nav, d, r.id),
_ if open.is_some() => open_top(ctx, nav, top + 1),
_ => focus_top(ctx, nav, top + 1),
}
}
if pressed(Key::ArrowLeft) {
match cur {
Some((d, _)) if d > 0 => {
let (menu, owner) = (nav.levels.get(d - 1).and_then(|l| l.menu), nav.levels.get(d).and_then(|l| l.owner));
if let Some(menu) = menu {
MenuState::from_id(ctx, menu, |s| s.open_item = None);
ctx.data_mut(|data| {
if let Some(mut o) = data.get_temp::<KeyOpened>(opened_id()) {
o.submenus.retain(|(m, _)| *m != menu);
data.insert_temp(opened_id(), o);
}
});
}
if let Some(owner) = owner {
focus(ctx, owner);
}
}
_ if open.is_some() => open_top(ctx, nav, top - 1),
_ => focus_top(ctx, nav, top - 1),
}
}
if pressed(Key::Enter) || pressed(Key::Space) {
match (cur, row) {
// egui toggles a submenu on Enter: it opens (again), highlighting its first entry.
(Some((d, _)), Some(r)) if r.submenu => open_submenu(ctx, nav, d, r.id),
// A top-level menu that the press opened.
(None, _) if bar.is_some() && open.is_some() => ctx.data_mut(|d| {
d.insert_temp(pending_id(), FirstEntry(0));
}),
_ => {}
}
}
// The arrows move the highlight here, not egui's spatial focus search.
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
strip_keys(ctx);
ctx.request_repaint();
}
/// Keep the submenus opened from the keyboard open while the pointer hasn't moved.
fn keep_key_opened(ctx: &Context) {
let Some(o) = ctx.data(|d| d.get_temp::<KeyOpened>(opened_id())) else { return };
if ctx.pointer_hover_pos() != o.pointer {
ctx.data_mut(|d| d.remove::<KeyOpened>(opened_id()));
return;
}
for (menu, sub) in o.submenus {
show_submenu(ctx, menu, sub);
}
}
/// Make `sub` the open submenu of `menu`. (egui forgets an open submenu that wasn't shown last
/// frame, so it counts as shown now.)
fn show_submenu(ctx: &Context, menu: Id, sub: Id) {
MenuState::mark_shown(ctx, sub);
MenuState::from_id(ctx, menu, |s| s.open_item = Some(sub));
}
/// The rest of the frame (shortcuts, panels) doesn't see this frame's keys.
fn strip_keys(ctx: &Context) {
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, Event::Key { .. } | Event::Text(_))));
}