M8: accessibility: screen reader names, keyboard navigation mode (#96)

* M8: accessibility: screen reader names, keyboard navigation mode

- Screen readers: the custom-painted controls report a role, name, value and
  state to AccessKit (number and text fields, dropdowns, segmented and toggle
  buttons, tools with their keys, layer rows as tree items, pages, tabs, menu
  items, the canvas describing the selection), from the shared widget helpers
  and a `Tip` trait for ad-hoc icon buttons. Regions are named containers.
- Keyboard navigation: F6 / Shift+F6 (view.nextRegion / view.previousRegion)
  cycle the toolbar, layers, canvas and properties; Tab / Shift+Tab move within
  a region and wrap; Enter or Space activate; arrows move along the toolbar and
  through the layer rows; menus and flyouts opened from the keyboard take it and
  give it back; Escape returns to the canvas. A focus ring (new `focus` token)
  shows until the next pointer press. Tab on the canvas selects the next layer
  again instead of moving egui's focus to the tab strip; Enter that starts a
  field edit no longer ends it.
- Reduced motion: pref.reduceMotion and the system setting (Windows
  UISettings, macOS, GNOME, prefers-reduced-motion) turn egui's animations off.
- Contrast: light secondary and accent text meet WCAG AA.
- ui.inspect reports focus {region, keyboard, control} and reduceMotion;
  ui.set {focusRegion}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: parity report command count after merging main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: accessibility: regression test against nested egui context locks

A debug build's temporary logging called the context inside a context closure and hung; the shipped code never does. Document the rule in a11y.rs and run every accessibility path with the AccessKit tree on, so a nested call fails the test (debug builds panic after 10 s) instead of hanging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 23:02:50 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c662e56de4
commit c49a72ea88
45 changed files with 2002 additions and 213 deletions
Generated
+1
View File
@@ -1328,6 +1328,7 @@ dependencies = [
"rfd",
"serde_json",
"webbrowser",
"windows",
"winresource",
]
+1
View File
@@ -137,6 +137,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Persistent data:** the document lives in an immutable ordered map, so undo snapshots are cheap and layout, component sync and text measurement only revisit what changed.
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
- **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images.
+15 -3
View File
@@ -259,8 +259,19 @@ dimension measured side by side.
main's layer used ~900 times 188/226 → 4/18 ms, adding a layer to it 430 → 21 ms, a label style
in all 72 button variants (1,800 copies) 450 → 57 ms, switching a variant, reset, detach, push
and paste ~450 → under 3 ms, accepting a library update 460 → 64 ms; the Assets panel with 2,000
assets 3.4 → 1.3 ms a frame. *Left:* a real-world file corpus, Windows/Linux/browser QA,
accessibility. 30–50 h.
assets 3.4 → 1.3 ms a frame. Accessibility: the custom-painted
controls name themselves for screen readers (AccessKit on desktop: role, name, value and state
for tools, layer rows with their type, level, selection and hidden/locked state, fields,
dropdowns, tabs, menu items, and the canvas, which describes the selection); keyboard navigation:
F6 / Shift+F6 move between the toolbar, layers, canvas and properties, Tab / Shift+Tab move
within a region with a focus ring, Enter or Space activate, the arrows go along the toolbar and
through the layer rows, menus and tool flyouts opened from the keyboard take it and give it back,
Escape returns to the canvas (where Tab selects the next layer again instead of wandering into
the tab strip); reduced motion follows the system's setting or a preference; secondary and
accent text meet WCAG AA in both themes. *Left:* a real-world file corpus, Windows/Linux/browser
QA, runs with real screen readers (NVDA, Narrator, VoiceOver, Orca), the AccessKit tree in the
browser (eframe's web backend doesn't send it yet), keyboard box selection, keyboard rename and
context menus in the layers panel. 28–46 h.
### Where we're going
@@ -320,7 +331,8 @@ dimension measured side by side.
code where components take their properties as parameters and instances are calls), colour picker
(HSB square, hue/alpha, hex, gradient stops, image fills), main menu generated from the catalog,
context menu, Actions palette (⌘K), keyboard shortcuts from one keymap per platform (a tabbed,
searchable Keyboard shortcuts panel), presentation player with
searchable Keyboard shortcuts panel), keyboard navigation (F6 between regions, Tab within them, a
focus ring) and screen-reader names for the custom controls (AccessKit), presentation player with
transitions, light and dark themes, home screen, About/Preferences/shortcuts/variables dialogs.
- **Automation:** desktop control channel (`--control <port>`: execute commands, inspect document/UI,
synthetic pointer/keys/typing, menu invocation, screenshots, renders).
+1 -1
View File
@@ -24,4 +24,4 @@ log = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList"] }
+9 -2
View File
@@ -34,7 +34,8 @@ pub fn start() {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("figcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
let fig = FigApp::new(Session::new(), Box::new(WebServices));
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
let fig = FigApp::new(Session::new(), Box::new(WebServices { reduce_motion }));
Ok(Box::new(WebShell { fig }))
}),
)
@@ -65,7 +66,10 @@ impl eframe::App for WebShell {
/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard
/// goes through egui's own web clipboard handling.
struct WebServices;
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
}
impl Services for WebServices {
fn open_url(&mut self, url: &str) {
@@ -74,4 +78,7 @@ impl Services for WebServices {
log::error!("couldn't open {url}: {e:?}");
}
}
fn reduce_motion(&self) -> bool {
self.reduce_motion.as_ref().is_some_and(web_sys::MediaQueryList::matches)
}
}
+4
View File
@@ -24,5 +24,9 @@ webbrowser = { workspace = true }
image = { workspace = true }
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
# The system's reduce-motion setting (UISettings.AnimationsEnabled).
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement"] }
[build-dependencies]
winresource = "0.1"
+49 -1
View File
@@ -1,6 +1,8 @@
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences and recents.
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use figcraft_engine::{Prefs, Session};
use figcraft_ui_egui::{FigApp, Services};
@@ -10,6 +12,8 @@ pub struct Desktop {
clipboard: Option<arboard::Clipboard>,
recents: Vec<String>,
pub prefs: Option<Prefs>,
/// The system's reduce-motion setting (read in the background at startup).
reduce_motion: Arc<AtomicBool>,
}
fn config_dir() -> Option<PathBuf> {
@@ -34,10 +38,51 @@ impl Desktop {
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
.unwrap_or_default();
let prefs = state.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok());
Self { clipboard: arboard::Clipboard::new().ok(), recents, prefs }
let reduce_motion = Arc::new(AtomicBool::new(false));
let flag = reduce_motion.clone();
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
Self { clipboard: arboard::Clipboard::new().ok(), recents, prefs, reduce_motion }
}
}
/// Whether the system's accessibility settings ask for reduced motion: Windows' animation
/// effects (`UISettings.AnimationsEnabled`), macOS's Reduce motion, GNOME's animations.
fn system_reduce_motion() -> bool {
#[cfg(target_os = "windows")]
{
match windows::UI::ViewManagement::UISettings::new().and_then(|s| s.AnimationsEnabled()) {
Ok(on) => !on,
Err(e) => {
log::info!("reduce motion: can't read the animation setting: {e}");
false
}
}
}
#[cfg(target_os = "macos")]
{
command_says("defaults", &["read", "com.apple.universalaccess", "reduceMotion"], "1")
}
#[cfg(all(unix, not(target_os = "macos")))]
{
command_says("gsettings", &["get", "org.gnome.desktop.interface", "enable-animations"], "false")
}
#[cfg(not(any(windows, unix)))]
{
false
}
}
/// Whether `program args` runs and prints `want` (trimmed).
#[cfg(unix)]
fn command_says(program: &str, args: &[&str], want: &str) -> bool {
std::process::Command::new(program)
.args(args)
.output()
.ok()
.filter(|o| o.status.success())
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
}
pub fn save_state(app: &FigApp) {
let Some(dir) = config_dir() else { return };
let _ = std::fs::create_dir_all(&dir);
@@ -110,6 +155,9 @@ impl Services for Desktop {
self.recents.insert(0, path.to_string());
self.recents.truncate(20);
}
fn reduce_motion(&self) -> bool {
self.reduce_motion.load(Ordering::Relaxed)
}
}
/// The window icon from the bundled app icon (if present).
+57
View File
@@ -0,0 +1,57 @@
//! Accessibility keys and preferences: F6 / Shift+F6 move the keyboard between the window's regions
//! (the UI does the moving; the commands ask it to), live while editing text too, listed in the
//! shortcuts panel; Reduce motion is a preference.
use serde_json::json;
use crate::Session;
use crate::keymap::{Context, GESTURES, Group, KEYS, Mods, Platform, display, lookup};
#[test]
fn f6_and_shift_f6_cycle_the_regions_on_every_platform_and_while_editing_text() {
let shift = Mods { shift: true, ..Mods::default() };
for p in [Platform::Mac, Platform::Other] {
for c in [Context::Canvas, Context::Text] {
assert_eq!(lookup(p, c, "F6", Mods::default()).map(|b| b.id), Some("view.nextRegion"), "{p:?} {c:?}");
assert_eq!(lookup(p, c, "F6", shift).map(|b| b.id), Some("view.previousRegion"), "{p:?} {c:?}");
}
}
assert_eq!(display("F6", Platform::Other), "F6");
assert_eq!(display("Shift+F6", Platform::Mac), "⇧F6");
assert_eq!(display("Shift+F6", Platform::Other), "Shift+F6");
// Listed with the view keys, next to the keyboard-mode keys they lead to.
for id in ["view.nextRegion", "view.previousRegion"] {
let b = KEYS.iter().find(|b| b.id == id).unwrap();
assert_eq!(b.group, Group::View);
assert!(b.title().starts_with("Focus the"), "{}", b.title());
}
assert!(GESTURES.iter().any(|g| g.0 == Group::View && g.3.contains("Tab")));
}
#[test]
fn region_commands_ask_the_ui_to_move_the_keyboard() {
let mut s = Session::new();
for (cmd, step) in [("view.nextRegion", 1), ("view.previousRegion", -1)] {
s.ui_requests.clear();
s.execute(cmd, &json!({})).unwrap();
let r = s.ui_requests.iter().find(|r| r.kind == "focus.region").unwrap_or_else(|| panic!("{cmd}"));
assert_eq!(r.data["step"], step, "{cmd}");
// Nothing to undo: the document didn't change.
assert!(!s.ds().unwrap().is_dirty());
}
}
#[test]
fn reduce_motion_is_a_preference_that_persists() {
let mut s = Session::new();
assert!(!s.prefs.reduce_motion);
assert_eq!(s.execute("pref.reduceMotion", &json!({})).unwrap()["on"], true);
assert!(s.prefs.reduce_motion);
assert_eq!(s.execute("pref.reduceMotion", &json!({"on": false})).unwrap()["on"], false);
s.prefs.reduce_motion = true;
// Saved with the other preferences; older settings files without it read as off.
let v = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(v["reduce_motion"], true);
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert!(!old.reduce_motion);
}
+9
View File
@@ -112,6 +112,12 @@ fn ui(s: &mut Session, kind: &str) -> Result<Value> {
ok()
}
/// Move the keyboard `step` regions on (toolbar, layers, canvas, properties): the UI does it.
fn region(s: &mut Session, step: i64) -> Result<Value> {
s.request_ui("focus.region", json!({"step": step}));
ok()
}
fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Result<Value> {
let slot = f(&mut s.prefs);
*slot = boolean(p, "on").unwrap_or(!*slot);
@@ -196,6 +202,8 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("view.designPrototype", "Switch between Design and Prototype", "{}", false, |s, _| ui(s, "panel.designPrototype")),
spec!("view.devMode", "Dev Mode", "{}", false, |s, _| ui(s, "panel.devMode")),
spec!("view.leftSidebar", "Show/Hide left sidebar", "{}", false, |s, _| ui(s, "panel.leftSidebar")),
spec!("view.nextRegion", "Focus next region", "{}", false, |s, _| region(s, 1)),
spec!("view.previousRegion", "Focus previous region", "{}", false, |s, _| region(s, -1)),
spec!("view.themeLight", "Light theme", "{}", false, |s, _| {
s.prefs.theme = "light".into();
ok()
@@ -213,6 +221,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("pref.renameDuplicates", "Rename duplicated layers", "{on?}", false, |s, p| pref(s, p, |x| &mut x.rename_duplicates)),
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
+4
View File
@@ -204,6 +204,8 @@ pub static KEYS: &[Binding] = &[
k(View, "view.designPrototype", &["Shift+E"]),
k(View, "view.devMode", &["Shift+D"]).label("Dev Mode on/off"),
k(View, "view.leftSidebar", &["Shift+Cmd+\\"]),
k(View, "view.nextRegion", &["F6"]).label("Focus the next region (toolbar, layers, canvas, properties)").both(),
k(View, "view.previousRegion", &["Shift+F6"]).label("Focus the previous region").both(),
k(View, "layers.collapseAll", &["Alt+L"]),
k(View, "doc.nextTab", &["Ctrl+Tab"]),
k(View, "doc.previousTab", &["Ctrl+Shift+Tab"]),
@@ -324,6 +326,8 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
(Transform, "Resize by the nudge amount", "⌘ arrows", "Ctrl+arrows"),
(Transform, "Opacity 10%…90%, 0: 100%", "1…9, 0", "1…9, 0"),
(Transform, "Exact opacity (4 then 5: 45%)", "Two digits", "Two digits"),
(View, "Next or previous control in the focused region", "⇥ / ⇧⇥", "Tab / Shift+Tab"),
(View, "Back to the canvas from a focused region", "Esc", "Esc"),
];
/// Modifier keys held (as pressed: on Windows and Linux Ctrl may come as `cmd`, `ctrl` or both).
+6
View File
@@ -426,6 +426,9 @@ pub struct Prefs {
pub theme: String,
/// The name comments are signed with (and whose comments count as yours).
pub user_name: String,
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
/// too). Prototype transitions still play.
pub reduce_motion: bool,
}
impl Default for Prefs {
@@ -440,6 +443,7 @@ impl Default for Prefs {
show_dimensions: true,
theme: "light".into(),
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
reduce_motion: false,
}
}
}
@@ -1011,6 +1015,8 @@ fn mark_dev_changes(doc: &mut Document, before: &Document, touched: &BTreeSet<No
}
}
#[cfg(test)]
mod a11y_keys_tests;
#[cfg(test)]
mod al_drag_tests;
#[cfg(test)]
+945
View File
@@ -0,0 +1,945 @@
//! Accessibility: names, roles and states for the custom-painted widgets (the AccessKit tree a
//! screen reader reads), and the keyboard navigation mode.
//!
//! Keyboard navigation: F6 / Shift+F6 move the keyboard between the regions of the window (the
//! toolbar, the layers panel, the canvas and the properties panel); Tab / Shift+Tab move between
//! the controls of a region (wrapping at its ends); Enter or Space activate the control; the
//! arrows move along the toolbar and through the layer rows; a menu or flyout opened from the
//! keyboard takes it, and gives it back to its button when it closes; Escape goes back to the
//! canvas, where every shortcut works as usual. A focus ring shows the control with the keyboard
//! until the next pointer press.
//!
//! The regions' controls come from what the last frame drew (egui keeps every widget's rectangle
//! and sense), so panels don't register their controls. Widget names are only built while a screen
//! reader asks for the tree (egui runs the node writers only then).
//!
//! Locking: egui runs the node writers (`accesskit_node_builder`) and the `ctx.data`, `ctx.input`,
//! `ctx.memory` and `ctx.viewport` closures with its context locked. Nothing in them may call the
//! context again (a nested call deadlocks; debug builds panic after 10 s): read what they need
//! first, as everything here does (`a11y_tests` runs every path with the tree on).
use egui::accesskit::{self, Role, Toggled};
use egui::{Context, CornerRadius, Event, FocusDirection, Id, Key, LayerId, Modifiers, Order, Pos2, Rect, Response, Stroke, StrokeKind, Ui};
use figcraft_engine::keymap;
use crate::FigApp;
use crate::theme::Tokens;
/// The commands F6 and Shift+F6 are bound to.
pub const NEXT_REGION: &str = "view.nextRegion";
pub const PREVIOUS_REGION: &str = "view.previousRegion";
/// A part of the window F6 moves the keyboard to.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Region {
Toolbar,
Layers,
#[default]
Canvas,
Properties,
}
impl Region {
/// In F6 order.
pub const ALL: [Region; 4] = [Region::Toolbar, Region::Layers, Region::Canvas, Region::Properties];
pub fn id(self) -> &'static str {
match self {
Region::Toolbar => "toolbar",
Region::Layers => "layers",
Region::Canvas => "canvas",
Region::Properties => "properties",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|r| r.id().eq_ignore_ascii_case(s))
}
fn index(self) -> usize {
self as usize
}
/// This region's entry of a per-region array.
fn of<T>(self, a: &[T; 4]) -> &T {
let [t, l, c, p] = a;
match self {
Region::Toolbar => t,
Region::Layers => l,
Region::Canvas => c,
Region::Properties => p,
}
}
fn of_mut<T>(self, a: &mut [T; 4]) -> &mut T {
let [t, l, c, p] = a;
match self {
Region::Toolbar => t,
Region::Layers => l,
Region::Canvas => c,
Region::Properties => p,
}
}
}
/// What a frame drew that the next frame's keyboard navigation needs.
#[derive(Clone, Copy, Default)]
struct Drawn {
/// Where each region is (None: not shown).
rects: [Option<Rect>; 4],
/// The control F6 lands on in each region (the active tool, the selected layer's row).
preferred: [Option<Id>; 4],
/// The canvas widget.
canvas: Option<Id>,
/// An open popup the app draws itself: (the control that opened it, the popup's area id).
popup: Option<(Id, Id)>,
/// A layer row had the keyboard.
row_focused: bool,
}
/// Where the keyboard goes on the next frame.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Move {
/// By this many regions (F6, Shift+F6).
Step(i32),
/// To this region.
To(Region),
}
/// Keyboard navigation state (in [`crate::UiState`]).
#[derive(Default)]
pub struct Nav {
/// The region with the keyboard.
pub region: Region,
/// Keyboard mode: the keyboard moved the focus since the last pointer press (the ring shows).
pub keyboard: bool,
last: Drawn,
next: Drawn,
pending: Option<Move>,
/// Frames left to give the selected layer's row the keyboard (the arrows moved the selection,
/// and its row may only be laid out a frame later).
refocus: u8,
/// The control that opened the popup the keyboard went into, and the popup's layer.
opener: Option<(Id, LayerId)>,
/// A control the keyboard moved to: the properties panel scrolls it into view.
scroll_to: Option<Id>,
}
impl Nav {
/// Move the keyboard `step` regions on (`view.nextRegion` / `view.previousRegion`).
pub fn step(&mut self, step: i32) {
self.pending = Some(match self.pending {
Some(Move::Step(s)) => Move::Step(s.saturating_add(step)),
_ => Move::Step(step),
});
}
/// Give `region` the keyboard (control channel `ui.set {focusRegion}`).
pub fn go(&mut self, region: Region) {
self.pending = Some(Move::To(region));
}
/// The region the point `p` is in (the toolbar floats over the canvas; the side panels scroll,
/// so a control scrolled below a panel's bottom still belongs to it).
fn region_at(&self, p: Pos2) -> Option<Region> {
let rect = |r: Region| *r.of(&self.last.rects);
if rect(Region::Toolbar).is_some_and(|r| r.contains(p)) {
return Some(Region::Toolbar);
}
for r in [Region::Layers, Region::Properties] {
if rect(r).is_some_and(|rect| rect.x_range().contains(p.x) && p.y >= rect.min.y) {
return Some(r);
}
}
rect(Region::Canvas).filter(|r| r.contains(p)).map(|_| Region::Canvas)
}
fn shown(&self, r: Region) -> bool {
r == Region::Canvas || r.of(&self.last.rects).is_some()
}
}
// ---------------------------------------------------------------------------------------------
// What the panels tell navigation while they draw.
/// `ui` draws (part of) `region` this frame; it is also a container named `label` for screen
/// readers. Call it before the region's widgets.
pub fn region(nav: &mut Nav, ui: &Ui, region: Region, role: Role, label: &str) {
area(nav, region, ui.max_rect());
container(ui, role, label);
}
/// `region` covers `rect` this frame (added to what it already covers).
pub fn area(nav: &mut Nav, region: Region, rect: Rect) {
let slot = region.of_mut(&mut nav.next.rects);
*slot = Some(slot.map_or(rect, |s| s.union(rect)));
}
/// A container node named `label` holding `ui`'s widgets (call before them).
pub fn container(ui: &Ui, role: Role, label: &str) {
let r = ui.max_rect();
ui.ctx().accesskit_node_builder(ui.unique_id(), |n| {
n.set_role(role);
n.set_label(label);
n.set_bounds(bounds(r));
});
}
/// `ui`'s container node covers `rect` (a container smaller than its `Ui`: the toolbar).
pub fn container_bounds(ui: &Ui, rect: Rect) {
ui.ctx().accesskit_node_builder(ui.unique_id(), |n| n.set_bounds(bounds(rect)));
}
/// F6 into `region` lands on `id` (the active tool, the selected layer's row).
pub fn prefer(nav: &mut Nav, region: Region, id: Id) {
*region.of_mut(&mut nav.next.preferred) = Some(id);
}
/// The canvas widget: F6 and Escape give it the keyboard (shortcuts keep working while it has it);
/// screen readers read it as "Canvas" with `describe()` (what is selected) as its description.
pub fn canvas(nav: &mut Nav, resp: &Response, describe: impl FnOnce() -> String) {
nav.next.canvas = Some(resp.id);
area(nav, Region::Canvas, resp.rect);
shared(&resp.ctx, |s| s.canvas = Some(resp.id));
node(resp, "Canvas", |n| {
n.set_role(Role::Group);
n.set_description(describe());
});
}
/// An open popup drawn by the app itself (egui `Area` `area`), opened from `opener`: a keyboard
/// user who opened it moves into it.
pub fn popup(nav: &mut Nav, opener: Id, area: Id) {
nav.next.popup = Some((opener, area));
}
/// A layer row: while it has the keyboard, the arrows move through the layers.
pub fn layer_row(nav: &mut Nav, resp: &Response) {
if resp.has_focus() {
nav.next.row_focused = true;
}
}
/// The properties panel's scroll area: scroll the control the keyboard just moved to into view.
/// Call at the end of the scroll area's contents.
pub fn scroll_focus(nav: &mut Nav, ui: &Ui) {
if let Some(id) = nav.scroll_to.take()
&& let Some(r) = ui.ctx().read_response(id)
&& nav.region_at(r.rect.center()) == Some(Region::Properties)
&& !ui.clip_rect().contains_rect(r.rect)
{
r.scroll_to_me(None);
}
}
// ---------------------------------------------------------------------------------------------
// State the widget helpers read from the context (they only see a `Ui` or a `Response`).
#[derive(Clone, Default)]
struct Shared {
canvas: Option<Id>,
keyboard: bool,
/// The focused widget showed its focus itself this frame (no ring needed).
ring: Option<Id>,
/// The focused widget's ring goes round this rectangle (clipped to the second) instead of the
/// widget: a control made of several widgets, a row whose visible part is inset.
ring_rect: Option<(Id, Rect, Rect)>,
/// The focused control's name (`ui.inspect` › `focus.control`).
name: String,
}
fn shared<R>(ctx: &Context, f: impl FnOnce(&mut Shared) -> R) -> R {
ctx.data_mut(|d| f(d.get_temp_mut_or_default::<Shared>(Id::new("figcraft_a11y"))))
}
/// Whether a widget other than the canvas has the keyboard (a field, a focused button): the
/// canvas shortcuts stay off then.
pub fn field_focused(ctx: &Context) -> bool {
let canvas = shared(ctx, |s| s.canvas);
ctx.memory(|m| m.focused()).is_some_and(|f| Some(f) != canvas)
}
/// The name of the control with the keyboard (empty when unnamed or nothing has it).
pub fn focused_name(ctx: &Context) -> String {
if ctx.memory(|m| m.focused()).is_none() {
return String::new();
}
shared(ctx, |s| s.name.clone())
}
/// Whether a screen reader reads the tree this frame: spelled-out values (colours, zoom) are only
/// built then.
pub fn active(ctx: &Context) -> bool {
ctx.viewport(|v| v.this_pass.accesskit_state.is_some())
}
/// Keyboard mode is on (the focus ring shows).
pub fn keyboard(ctx: &Context) -> bool {
shared(ctx, |s| s.keyboard)
}
// ---------------------------------------------------------------------------------------------
// Frame hooks.
/// Before the panels draw: pointer presses, the focused control's region, and the navigation
/// keys (F6, Tab, arrows, Escape), which are taken out of the input when they move the keyboard.
pub fn begin(app: &mut FigApp, ctx: &Context) {
app.ui.nav.last = std::mem::take(&mut app.ui.nav.next);
keys(app, ctx);
let keyboard = app.ui.nav.keyboard;
shared(ctx, |s| {
s.keyboard = keyboard;
s.ring = None;
s.ring_rect = None;
// Written again by the focused control as it draws.
s.name.clear();
});
}
fn keys(app: &mut FigApp, ctx: &Context) {
// A press hides the ring, and the keyboard goes to the region pressed in.
let press = ctx.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten());
if let Some(p) = press {
let nav = &mut app.ui.nav;
nav.keyboard = false;
nav.opener = None;
nav.refocus = 0;
if ctx.layer_id_at(p).is_none_or(|l| l == LayerId::background())
&& let Some(r) = nav.region_at(p)
{
nav.region = r;
}
}
let focused = ctx.memory(|m| m.focused());
let widget = focused.and_then(|id| last_widget(ctx, id));
let on_background = widget.is_none_or(|w| w.layer_id == LayerId::background());
// The control with the keyboard decides the region (a click into a field, a Tab in a panel);
// a press elsewhere takes the keyboard from it later this frame.
if let Some(w) = widget.filter(|_| on_background && press.is_none())
&& let Some(r) = app.ui.nav.region_at(w.rect.center())
{
app.ui.nav.region = r;
}
// Dialogs, the Actions palette and the presenter keep egui's own keyboard handling.
if blocked(app) {
app.ui.nav.pending = None;
return;
}
// F6 / Shift+F6 (and the commands they run).
let platform = crate::shortcuts::platform();
let region_key = ctx.input(|i| {
i.events.iter().find_map(|e| match e {
Event::Key { key, pressed: true, modifiers, .. } => {
keymap::lookup(platform, keymap::Context::Canvas, key.name(), crate::shortcuts::mods(*modifiers))
.and_then(|b| match b.id {
NEXT_REGION => Some(1),
PREVIOUS_REGION => Some(-1),
_ => None,
})
.map(|step| (step, *key, *modifiers))
}
_ => None,
})
});
if let Some((step, key, m)) = region_key {
ctx.input_mut(|i| i.consume_key(m, key));
app.ui.nav.step(step);
}
if let Some(mv) = app.ui.nav.pending.take() {
let to = match mv {
Move::To(r) => r,
Move::Step(s) => next_region(&app.ui.nav, s),
};
// egui's own Tab and arrow moves would undo the move.
ctx.memory_mut(|m| m.move_focus(FocusDirection::None));
enter(app, ctx, to);
return;
}
popups(app, ctx, focused, widget);
if !on_background {
return;
}
let region = app.ui.nav.region;
// The modifiers each key was pressed with (not the ones held when the frame began).
let pressed = |k: Key, m: Modifiers| {
ctx.input(|i| {
i.events.iter().any(|e| matches!(e, Event::Key { key, pressed: true, modifiers, .. } if *key == k && modifiers.matches_exact(m)))
})
};
// Tab moves between the region's controls (on the canvas it stays the select-next shortcut).
// A layer rename takes Tab itself (on to the next row's name).
let tab = if pressed(Key::Tab, Modifiers::NONE) {
Some(1)
} else if pressed(Key::Tab, Modifiers::SHIFT) {
Some(-1)
} else {
None
};
if let Some(step) = tab
&& app.ui.rename.is_none()
{
ctx.memory_mut(|m| m.move_focus(FocusDirection::None));
if region != Region::Canvas {
ctx.input_mut(|i| {
i.consume_key(Modifiers::SHIFT, Key::Tab);
i.consume_key(Modifiers::NONE, Key::Tab);
});
app.ui.nav.keyboard = true;
step_within(app, ctx, region, step);
}
}
// egui moves the focus with the arrows too; here they belong to fields (steps), the toolbar
// and the layer rows.
let arrows = [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown];
if focused.is_some() && arrows.iter().any(|k| ctx.input(|i| i.key_pressed(*k))) {
ctx.memory_mut(|m| m.move_focus(FocusDirection::None));
}
let in_region =
focused.is_some_and(|f| widget.is_some_and(|w| app.ui.nav.region_at(w.rect.center()) == Some(region)) && Some(f) != app.ui.nav.last.canvas);
match region {
Region::Toolbar if in_region => {
for (k, step) in [(Key::ArrowLeft, -1), (Key::ArrowRight, 1)] {
if pressed(k, Modifiers::NONE) {
ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
app.ui.nav.keyboard = true;
step_within(app, ctx, region, step);
}
}
for (k, last) in [(Key::Home, false), (Key::End, true)] {
if pressed(k, Modifiers::NONE) {
ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
app.ui.nav.keyboard = true;
let list = focusables(ctx, &app.ui.nav, region);
if let Some(id) = if last { list.last() } else { list.first() } {
focus(app, ctx, id.0);
}
}
}
}
Region::Layers if in_region && app.ui.nav.last.row_focused => {
for k in arrows {
let shift = matches!(k, Key::ArrowUp | Key::ArrowDown) && pressed(k, Modifiers::SHIFT);
if (pressed(k, Modifiers::NONE) || shift) && crate::layers::key_nav(app, k, shift) {
ctx.input_mut(|i| i.consume_key(if shift { Modifiers::SHIFT } else { Modifiers::NONE }, k));
app.ui.nav.keyboard = true;
app.ui.nav.refocus = 3;
}
}
}
_ => {}
}
// Escape gives the keyboard back to the canvas (a popup it closes keeps it where it was).
if region != Region::Canvas
&& pressed(Key::Escape, Modifiers::NONE)
&& !egui::Popup::is_any_open(ctx)
&& app.ui.nav.opener.is_none()
&& !menus_open(app)
{
// From the keyboard the canvas takes it; after the pointer (Escape in a field) only the
// region changes.
if app.ui.nav.keyboard {
enter(app, ctx, Region::Canvas);
} else {
app.ui.nav.region = Region::Canvas;
}
return;
}
// In keyboard mode the canvas keeps the keyboard (Escape on it deselects and lets egui drop it).
if app.ui.nav.keyboard
&& region == Region::Canvas
&& ctx.memory(|m| m.focused()).is_none()
&& let Some(c) = app.ui.nav.last.canvas
{
ctx.memory_mut(|m| m.request_focus(c));
}
}
/// After everything drew: the layer row the arrows moved to takes the keyboard, and the focus ring.
pub fn end(app: &mut FigApp, ctx: &Context) {
// A popup registered before it closed later in the frame (Escape) isn't open any more.
if !menus_open(app) {
app.ui.nav.next.popup = None;
}
let nav = &mut app.ui.nav;
if nav.refocus > 0 {
nav.refocus -= 1;
if let Some(id) = *Region::Layers.of(&nav.next.preferred) {
if ctx.memory(|m| m.focused()) != Some(id) {
ctx.memory_mut(|m| m.request_focus(id));
}
nav.refocus = 0;
}
ctx.request_repaint();
}
if nav.keyboard {
ring(ctx);
}
}
/// Dialogs, the palette, the presenter and the home screen have no regions.
fn blocked(app: &FigApp) -> bool {
let u = &app.ui;
u.palette.is_some()
|| u.about
|| u.preferences
|| u.variables.is_some()
|| u.export_preview
|| u.color_profile.is_some()
|| u.present.is_some()
|| u.home
|| u.find.is_some()
|| app.session.docs.is_empty()
}
/// Menus the app draws itself (Escape closes them, and the keyboard stays put).
fn menus_open(app: &FigApp) -> bool {
app.ui.main_menu || app.ui.tool_flyout.is_some() || app.ui.context_menu.is_some()
}
/// The region `step` regions on from the current one, skipping regions not shown.
fn next_region(nav: &Nav, step: i32) -> Region {
// A full turn at most each way.
let step = step.clamp(-4, 4);
let n = Region::ALL.len() as i32;
let cur = nav.region.index() as i32;
let dir = step.signum();
let mut i = cur;
let mut left = step.abs();
for _ in 0..(n * step.abs().max(1)) {
if left == 0 {
break;
}
i = (i + dir).rem_euclid(n);
if Region::ALL.get(i as usize).is_some_and(|r| nav.shown(*r)) {
left -= 1;
}
}
Region::ALL.get(i as usize).copied().unwrap_or_default()
}
/// Give `region` the keyboard: the canvas, or the region's preferred control, else its first.
/// Open menus close.
fn enter(app: &mut FigApp, ctx: &Context, region: Region) {
egui::Popup::close_all(ctx);
app.ui.main_menu = false;
app.ui.tool_flyout = None;
app.ui.context_menu = None;
let nav = &mut app.ui.nav;
nav.region = region;
nav.keyboard = true;
nav.opener = None;
nav.refocus = 0;
if region == Region::Canvas {
match nav.last.canvas {
Some(c) => ctx.memory_mut(|m| m.request_focus(c)),
None => drop_focus(ctx),
}
ctx.request_repaint();
return;
}
let list = focusables(ctx, nav, region);
let target = region.of(&nav.last.preferred).filter(|p| list.iter().any(|(id, _)| id == p)).or_else(|| list.first().map(|w| w.0));
match target {
Some(id) => focus(app, ctx, id),
None => drop_focus(ctx),
}
}
fn drop_focus(ctx: &Context) {
ctx.memory_mut(|m| {
if let Some(f) = m.focused() {
m.surrender_focus(f);
}
});
}
fn focus(app: &mut FigApp, ctx: &Context, id: Id) {
ctx.memory_mut(|m| m.request_focus(id));
if app.ui.nav.region == Region::Properties {
app.ui.nav.scroll_to = Some(id);
}
ctx.request_repaint();
}
/// Tab / Shift+Tab (and the toolbar's arrows): the next or previous control in the region,
/// wrapping. Going back from a field being edited skips the field it is part of.
fn step_within(app: &mut FigApp, ctx: &Context, region: Region, step: i32) {
let list = focusables(ctx, &app.ui.nav, region);
let n = list.len() as i32;
if n == 0 {
return;
}
let focused = ctx.memory(|m| m.focused());
let cur = focused.and_then(|f| list.iter().position(|(id, _)| *id == f));
let Some(cur) = cur else {
let first = if step > 0 { list.first() } else { list.last() };
if let Some((id, _)) = first {
focus(app, ctx, *id);
}
return;
};
let here = list.get(cur).map(|w| w.1.center());
let mut i = cur as i32;
for _ in 0..n {
i = (i + step.signum()).rem_euclid(n);
let Some((id, rect)) = list.get(i as usize) else { break };
// The control a focused field editor sits in.
if here.is_some_and(|c| rect.contains(c)) && i as usize != cur {
continue;
}
focus(app, ctx, *id);
return;
}
}
/// The controls of `region` the last frame drew, in drawing order (the panels' edges, which resize
/// them, are left out).
fn focusables(ctx: &Context, nav: &Nav, region: Region) -> Vec<(Id, Rect)> {
let canvas = nav.last.canvas;
let edges = [Id::new("left_panel").with("__resize"), Id::new("right_panel").with("__resize")];
ctx.viewport(|v| {
v.prev_pass
.widgets
.get_layer(LayerId::background())
.filter(|w| {
w.enabled
&& w.sense.is_focusable()
&& w.rect.is_positive()
&& w.rect.is_finite()
&& Some(w.id) != canvas
&& !edges.contains(&w.id)
&& nav.region_at(w.rect.center()) == Some(region)
})
.map(|w| (w.id, w.rect))
.collect()
})
}
fn last_widget(ctx: &Context, id: Id) -> Option<egui::WidgetRect> {
ctx.viewport(|v| v.prev_pass.widgets.get(id).copied())
}
/// The first control of `layer` the last frame drew (not the area itself: egui's `Area` senses
/// clicks on its background as `<area id>.with("move")`).
fn first_in(ctx: &Context, layer: LayerId) -> Option<Id> {
let background = layer.id.with("move");
ctx.viewport(|v| {
v.prev_pass.widgets.get_layer(layer).find(|w| w.enabled && w.sense.is_focusable() && w.rect.is_positive() && w.id != background).map(|w| w.id)
})
}
/// A menu opened from the keyboard takes it (its first item); when the keyboard leaves the menu
/// (Escape, or a pick that closes it), the control that opened it gets it back.
fn popups(app: &mut FigApp, ctx: &Context, focused: Option<Id>, widget: Option<egui::WidgetRect>) {
let nav = &mut app.ui.nav;
if let Some((opener, layer)) = nav.opener {
let in_menu = widget.is_some_and(|w| w.layer_id == layer);
if focused.is_none() {
nav.opener = None;
if last_widget(ctx, opener).is_some() {
ctx.memory_mut(|m| m.request_focus(opener));
ctx.request_repaint();
}
} else if !in_menu && first_in(ctx, layer).is_none() {
nav.opener = None;
}
return;
}
if !nav.keyboard {
return;
}
let Some(f) = focused.filter(|_| widget.is_some_and(|w| w.layer_id == LayerId::background())) else { return };
let layer = match nav.last.popup {
Some((opener, area)) if opener == f => LayerId::new(Order::Foreground, area),
_ => {
// egui's `Popup::default_response_id` of the control (the menus `menu_popup` opens).
let id = f.with("popup");
if !egui::Popup::is_id_open(ctx, id) {
return;
}
LayerId::new(Order::Foreground, id)
}
};
if let Some(first) = first_in(ctx, layer) {
ctx.memory_mut(|m| m.request_focus(first));
nav.opener = Some((f, layer));
ctx.request_repaint();
}
}
/// The focus ring around the focused control (unless it showed its focus itself).
fn ring(ctx: &Context) {
let Some(id) = ctx.memory(|m| m.focused()) else { return };
let (own, canvas, custom) = shared(ctx, |s| (s.ring == Some(id), s.canvas == Some(id), s.ring_rect.filter(|r| r.0 == id)));
if own {
return;
}
let Some(w) = ctx.viewport(|v| v.this_pass.widgets.get(id).copied()) else { return };
let painter = ctx.layer_painter(w.layer_id);
let (rect, painter) = match custom {
Some((_, rect, clip)) => (rect.expand(1.0), painter.with_clip_rect(clip)),
None if canvas => (w.rect.shrink(3.0), painter),
None => (w.interact_rect.expand(1.0), painter),
};
if rect.is_positive() {
paint_ring(&painter, rect);
}
}
fn paint_ring(painter: &egui::Painter, rect: Rect) {
let t = Tokens::get(painter.ctx());
painter.rect_stroke(rect, CornerRadius::same(6), Stroke::new(2.0, t.focus), StrokeKind::Outside);
}
/// The focus ring goes round `rect` (inside `ui`'s clip) while `resp` has the keyboard: a control
/// made of several widgets (a number field's label and value), a row whose visible part is inset.
pub fn ring_around(ui: &Ui, rect: Rect, resp: &Response) {
if resp.has_focus() {
let clip = ui.clip_rect().expand(4.0);
shared(&resp.ctx, |s| s.ring_rect = Some((resp.id, rect, clip)));
}
}
/// `resp` shows its focus itself (a highlighted menu row): no ring around it.
pub fn own_focus(resp: &Response) {
if resp.has_focus() {
shared(&resp.ctx, |s| s.ring = Some(resp.id));
}
}
// ---------------------------------------------------------------------------------------------
// Names, roles and states.
fn bounds(r: Rect) -> accesskit::Rect {
accesskit::Rect { x0: r.min.x.into(), y0: r.min.y.into(), x1: r.max.x.into(), y1: r.max.y.into() }
}
fn toggled(on: bool) -> Toggled {
if on { Toggled::True } else { Toggled::False }
}
/// Write `resp`'s node: its bounds, `label`, then `fill`. Also remembers the focused control's name.
fn node(resp: &Response, label: &str, fill: impl FnOnce(&mut accesskit::Node)) {
remember(resp, label);
let r = resp.rect;
resp.ctx.accesskit_node_builder(resp.id, |n| {
n.set_bounds(bounds(r));
if !label.is_empty() {
n.set_label(label);
}
fill(n);
});
}
/// Keep the focused control's name for `ui.inspect`.
fn remember(resp: &Response, name: &str) {
if resp.has_focus() && !name.is_empty() {
shared(&resp.ctx, |s| {
if s.name != name {
s.name.clear();
s.name.push_str(name);
}
});
}
}
/// A tooltip's name and key: "Rectangle R" is the name "Rectangle" and the key "R" (tooltips
/// put two spaces before the key).
pub fn split_tip(tip: &str) -> (&str, Option<&str>) {
match tip.split_once(" ") {
Some((name, key)) if !key.trim().is_empty() => (name.trim_end(), Some(key.trim())),
_ => (tip, None),
}
}
/// A button named by its tooltip ("Align left ⌥A": the name and the key).
pub fn button(resp: &Response, tip: &str) {
let (name, key) = split_tip(tip);
node(resp, name, |n| {
n.set_role(Role::Button);
if let Some(k) = key {
n.set_keyboard_shortcut(k);
}
});
}
/// A button opening a menu or flyout, `expanded` while it is open.
pub fn menu_button(resp: &Response, label: &str, expanded: bool) {
node(resp, label, |n| {
n.set_role(Role::Button);
n.set_has_popup(accesskit::HasPopup::Menu);
n.set_expanded(expanded);
});
}
/// A toggle button (a tool, a segment), pressed when `on`.
pub fn toggle(resp: &Response, tip: &str, on: bool) {
let (name, key) = split_tip(tip);
node(resp, name, |n| {
n.set_role(Role::Button);
n.set_toggled(toggled(on));
if let Some(k) = key {
n.set_keyboard_shortcut(k);
}
});
}
/// A tab, selected when it's the one shown.
pub fn tab(resp: &Response, label: &str, selected: bool) {
node(resp, label, |n| {
n.set_role(Role::Tab);
n.set_selected(selected);
});
}
pub fn checkbox(resp: &Response, label: &str, on: bool) {
node(resp, label, |n| {
n.set_role(Role::CheckBox);
n.set_toggled(toggled(on));
});
}
pub fn radio(resp: &Response, label: &str, on: bool) {
node(resp, label, |n| {
n.set_role(Role::RadioButton);
n.set_toggled(toggled(on));
});
}
/// A number field: its value (`suffix` after it), or `empty` ("Mixed", "Auto") without one.
pub fn number(resp: &Response, label: &str, value: Option<f64>, suffix: &str, empty: &str) {
node(resp, label, |n| {
n.set_role(Role::SpinButton);
match value {
Some(v) => {
n.set_numeric_value(v);
n.set_value(format!("{}{suffix}", crate::widgets::fmt_num(v)));
}
None => n.set_value(empty),
}
});
}
/// A text field showing `value`.
pub fn text(resp: &Response, label: &str, value: &str) {
node(resp, label, |n| {
n.set_role(Role::TextInput);
n.set_value(value);
});
}
/// A dropdown showing `value`, open when `expanded`.
pub fn combo(resp: &Response, label: &str, value: &str, expanded: bool) {
// An unlabelled dropdown goes by what it shows.
if label.is_empty() {
remember(resp, value);
}
node(resp, label, |n| {
n.set_role(Role::ComboBox);
n.set_value(value);
n.set_expanded(expanded);
});
}
/// Whether the menu `resp` opens ([`crate::right::menu_popup`]) is open.
pub fn menu_open(resp: &Response) -> bool {
egui::Popup::is_id_open(&resp.ctx, egui::Popup::default_response_id(resp))
}
/// A menu item, a checkable one when `checked` is set. `trail` is what the row shows at its right:
/// a key (`Shift+Cmd+K`, said the platform's way) or other text (a size), its description.
pub fn menu_item(resp: &Response, label: &str, trail: &str, checked: Option<bool>) {
node(resp, label, |n| {
match checked {
Some(on) => {
n.set_role(Role::MenuItemCheckBox);
n.set_toggled(toggled(on));
}
None => n.set_role(Role::MenuItem),
}
if keymap::parse(trail).is_some() {
n.set_keyboard_shortcut(keymap::display(trail, crate::shortcuts::platform()));
} else if !trail.is_empty() {
n.set_description(trail);
}
});
}
/// A menu item opening a submenu.
pub fn submenu(resp: &Response, label: &str) {
node(resp, label, |n| {
n.set_role(Role::MenuItem);
n.set_has_popup(accesskit::HasPopup::Menu);
});
}
/// `resp` can't be used now (a menu item whose command doesn't apply).
pub fn disabled(resp: &Response) {
node(resp, "", |n| n.set_disabled());
}
/// What a layer row says besides its name.
pub struct RowInfo<'a> {
/// The layer's type ("Frame", "Text").
pub kind: &'a str,
/// Nesting depth (0: on the page).
pub depth: usize,
pub selected: bool,
/// Open or closed when the layer has children.
pub expanded: Option<bool>,
pub hidden: bool,
pub locked: bool,
}
/// A layer row in the layers tree.
pub fn tree_item(resp: &Response, name: &str, info: &RowInfo) {
node(resp, name, |n| {
n.set_role(Role::TreeItem);
n.set_level(info.depth + 1);
n.set_selected(info.selected);
if let Some(open) = info.expanded {
n.set_expanded(open);
}
let mut d = info.kind.to_string();
for (on, word) in [(info.hidden, "hidden"), (info.locked, "locked")] {
if on {
d.push_str(", ");
d.push_str(word);
}
}
n.set_description(d);
});
}
/// An item of a list with one current item (a page).
pub fn list_item(resp: &Response, label: &str, selected: bool) {
node(resp, label, |n| {
n.set_role(Role::ListBoxOption);
n.set_selected(selected);
});
}
/// A colour swatch showing `value` ("#FF0000", "Linear gradient").
pub fn color(resp: &Response, label: &str, value: &str) {
node(resp, label, |n| {
n.set_role(Role::ColorWell);
n.set_value(value);
});
}
/// Tooltips that also name the control for screen readers.
pub trait Tip {
/// The tooltip, and for a clickable control its name (and key: "Align left ⌥A").
fn tip(self, tip: impl AsRef<str>) -> Self;
}
impl Tip for Response {
fn tip(self, tip: impl AsRef<str>) -> Self {
if self.sense.senses_click() {
button(&self, tip.as_ref());
}
self.on_hover_ui(move |ui| {
ui.label(tip.as_ref());
})
}
}
+390
View File
@@ -0,0 +1,390 @@
//! Accessibility, driven through egui headless: the AccessKit tree names the custom-painted widgets
//! (tools, layer rows, number fields, tabs, the canvas), and keyboard navigation: F6 cycles the
//! regions, Tab moves within one, arrows and Enter pick a tool, the arrows walk the layer rows,
//! a menu opened from the keyboard takes it and Escape gives it back, Escape returns to the
//! canvas where shortcuts work again, and a pointer press ends keyboard mode.
use egui::accesskit::{Node, Role, Toggled};
use egui::{Event, Key, Modifiers};
use figcraft_doc::NodeId;
use figcraft_engine::{Session, ToolId};
use serde_json::json;
use crate::a11y::{Region, focused_name};
use crate::layers_drag_tests::Ui;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn press(u: &mut Ui, key: Key, modifiers: Modifiers) {
u.frame(vec![
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
]);
}
fn f6(u: &mut Ui, back: bool) {
press(u, Key::F6, if back { Modifiers::SHIFT } else { Modifiers::NONE });
}
fn name(u: &Ui) -> String {
focused_name(&u.ctx)
}
fn region(u: &Ui) -> Region {
u.app.ui.nav.region
}
/// Two rectangles, "Rect A" and "Rect B" (listed above A: it's on top), A selected.
fn setup() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap());
let b = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 30, "width": 20, "height": 20})).unwrap());
s.execute("object.rename", &json!({"ids": [a], "name": "Rect A"})).unwrap();
s.execute("object.rename", &json!({"ids": [b], "name": "Rect B"})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = Ui::new(s);
u.frame(vec![]);
(u, a, b)
}
/// The nodes of the AccessKit tree a screen reader would get now.
fn tree(u: &mut Ui) -> Vec<Node> {
u.ctx.enable_accesskit();
u.frame(vec![]);
let out = u.frame_output(vec![]);
out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect()
}
fn find<'a>(nodes: &'a [Node], role: Role, label: &str) -> &'a Node {
nodes.iter().find(|n| n.role() == role && n.label() == Some(label)).unwrap_or_else(|| panic!("no {role:?} named {label:?}"))
}
#[test]
fn the_screen_reader_tree_names_tools_layers_fields_tabs_and_the_canvas() {
let (mut u, ..) = setup();
let nodes = tree(&mut u);
// Regions are named containers.
find(&nodes, Role::Toolbar, "Tools");
find(&nodes, Role::Pane, "Layers");
find(&nodes, Role::Pane, "Design");
// Tools: toggle buttons with their key, the current one pressed.
let mv = find(&nodes, Role::Button, "Move");
assert_eq!(mv.toggled(), Some(Toggled::True));
assert_eq!(mv.keyboard_shortcut(), Some("V"));
assert_eq!(find(&nodes, Role::Button, "Rectangle").toggled(), Some(Toggled::False));
assert_eq!(find(&nodes, Role::Button, "Shape tools").is_expanded(), Some(false));
// Layer rows: tree items with their type, level and selection.
let a = find(&nodes, Role::TreeItem, "Rect A");
assert_eq!((a.level(), a.is_selected(), a.description()), (Some(1), Some(true), Some("Rectangle")));
assert_eq!(find(&nodes, Role::TreeItem, "Rect B").is_selected(), Some(false));
// Number fields: name and value.
let w = find(&nodes, Role::SpinButton, "Width");
assert_eq!((w.value(), w.numeric_value()), (Some("40"), Some(40.0)));
assert_eq!(find(&nodes, Role::SpinButton, "Rotation").value(), Some("0°"));
// Tabs and dropdowns.
assert_eq!(find(&nodes, Role::Tab, "Design").is_selected(), Some(true));
assert_eq!(find(&nodes, Role::Tab, "Prototype").is_selected(), Some(false));
assert_eq!(find(&nodes, Role::ComboBox, "Zoom").is_expanded(), Some(false));
// Icon buttons are named by their tooltip.
find(&nodes, Role::Button, "Add new page");
// The canvas describes the selection.
let canvas = find(&nodes, Role::Group, "Canvas");
assert_eq!(canvas.description(), Some("Rect A, Rectangle, 40 by 30, selected"));
}
#[test]
fn f6_cycles_the_regions_and_lands_on_the_active_tool_and_the_selected_row() {
let (mut u, ..) = setup();
assert_eq!(region(&u), Region::Canvas);
assert!(!u.app.ui.nav.keyboard);
f6(&mut u, false);
assert_eq!(region(&u), Region::Properties);
assert!(u.app.ui.nav.keyboard, "keyboard mode: the focus ring shows");
assert!(!name(&u).is_empty(), "a control in the properties panel has the keyboard");
f6(&mut u, false);
assert_eq!((region(&u), name(&u)), (Region::Toolbar, "Move".to_string()));
f6(&mut u, false);
assert_eq!((region(&u), name(&u)), (Region::Layers, "Rect A".to_string()));
f6(&mut u, false);
assert_eq!(region(&u), Region::Canvas);
assert_eq!(name(&u), "Canvas");
// Shift+F6 goes back.
f6(&mut u, true);
assert_eq!(region(&u), Region::Layers);
// The commands do the same (menus, the control channel, MCP).
u.app.run("view.previousRegion", json!({}));
u.frame(vec![]);
assert_eq!(region(&u), Region::Toolbar);
// Hidden panels are skipped.
u.app.run("view.toggleUi", json!({"on": true}));
u.frame(vec![]);
f6(&mut u, false);
assert_eq!(region(&u), Region::Canvas);
f6(&mut u, false);
assert_eq!(region(&u), Region::Canvas, "only the canvas is shown");
}
#[test]
fn tab_moves_between_the_properties_controls_and_wraps_without_leaving_the_panel() {
let (mut u, a, _) = setup();
f6(&mut u, false);
let first = name(&u);
let mut seen = vec![first.clone()];
for _ in 0..200 {
press(&mut u, Key::Tab, Modifiers::NONE);
assert_eq!(region(&u), Region::Properties);
if name(&u) == first {
break;
}
seen.push(name(&u));
}
assert_eq!(name(&u), first, "Tab wraps round to the panel's first control");
for want in ["Width", "Height", "Rotation", "Opacity"] {
assert!(seen.iter().any(|n| n == want), "{want} not reached by Tab: {seen:?}");
}
// Shift+Tab steps back.
press(&mut u, Key::Tab, Modifiers::SHIFT);
assert_eq!(Some(&name(&u)), seen.last());
// Tab moved the keyboard, not the selection.
assert_eq!(u.app.session.selection(), vec![a]);
}
#[test]
fn enter_edits_a_focused_field_up_steps_it_and_tab_commits_and_moves_on() {
let (mut u, a, _) = setup();
f6(&mut u, false);
for _ in 0..100 {
if name(&u) == "Width" {
break;
}
press(&mut u, Key::Tab, Modifiers::NONE);
}
assert_eq!(name(&u), "Width");
press(&mut u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
// Typing replaces the value; Up steps it and the field keeps the keyboard (egui's own arrow
// focus moves are off on the panels).
u.frame(vec![Event::Text("64".into())]);
press(&mut u, Key::ArrowUp, Modifiers::NONE);
u.frame(vec![]);
assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().width, 65.0);
assert!(crate::a11y::field_focused(&u.ctx), "still editing");
press(&mut u, Key::Tab, Modifiers::NONE);
u.frame(vec![]);
assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().width, 65.0, "the edited width is kept");
assert_eq!(name(&u), "Height");
assert_eq!(region(&u), Region::Properties);
}
#[test]
fn arrows_and_enter_pick_a_toolbar_tool() {
let (mut u, ..) = setup();
f6(&mut u, true);
f6(&mut u, true);
assert_eq!((region(&u), name(&u)), (Region::Toolbar, "Move".to_string()));
// Move, its flyout button, Frame, its flyout button, Rectangle.
for _ in 0..4 {
press(&mut u, Key::ArrowRight, Modifiers::NONE);
}
assert_eq!(name(&u), "Rectangle");
press(&mut u, Key::Enter, Modifiers::NONE);
assert_eq!(u.app.session.tool.tool, ToolId::Rectangle);
// Left wraps past the first control to the last; Home goes back to the first.
press(&mut u, Key::Home, Modifiers::NONE);
assert_eq!(name(&u), "Move");
press(&mut u, Key::ArrowLeft, Modifiers::NONE);
assert_ne!(name(&u), "Move");
assert_eq!(region(&u), Region::Toolbar);
}
#[test]
fn escape_returns_to_the_canvas_where_shortcuts_and_tab_work_again() {
let (mut u, a, b) = setup();
f6(&mut u, true);
f6(&mut u, true);
assert_eq!(region(&u), Region::Toolbar);
// A shortcut key while a toolbar button has the keyboard does nothing.
press(&mut u, Key::R, Modifiers::NONE);
assert_eq!(u.app.session.tool.tool, ToolId::Move);
press(&mut u, Key::Escape, Modifiers::NONE);
assert_eq!((region(&u), name(&u)), (Region::Canvas, "Canvas".to_string()));
assert_eq!(u.app.session.selection(), vec![a], "leaving the toolbar doesn't deselect");
// Tab on the canvas selects the next layer and keeps the keyboard there.
press(&mut u, Key::Tab, Modifiers::NONE);
assert_eq!(u.app.session.selection(), vec![b]);
assert_eq!(name(&u), "Canvas");
press(&mut u, Key::R, Modifiers::NONE);
assert_eq!(u.app.session.tool.tool, ToolId::Rectangle);
}
#[test]
fn tab_on_the_canvas_selects_the_next_layer_without_keyboard_mode() {
let (mut u, _, b) = setup();
press(&mut u, Key::Tab, Modifiers::NONE);
u.frame(vec![]);
assert_eq!(u.app.session.selection(), vec![b]);
assert!(u.ctx.memory(|m| m.focused()).is_none(), "Tab doesn't wander into the tab bar");
}
#[test]
fn arrows_walk_the_layer_rows_and_the_keyboard_follows_the_selection() {
let (mut u, a, b) = setup();
f6(&mut u, true);
assert_eq!((region(&u), name(&u)), (Region::Layers, "Rect A".to_string()));
// B is listed above A.
press(&mut u, Key::ArrowUp, Modifiers::NONE);
u.frame(vec![]);
u.frame(vec![]);
assert_eq!(u.app.session.selection(), vec![b]);
assert_eq!(name(&u), "Rect B");
press(&mut u, Key::ArrowDown, Modifiers::SHIFT);
u.frame(vec![]);
let mut sel = u.app.session.selection();
sel.sort();
assert_eq!(sel, vec![a, b], "Shift extends");
// Nothing moved on the canvas.
assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().transform.translation().x, 0.0);
}
#[test]
fn a_menu_opened_from_the_keyboard_takes_it_and_escape_gives_it_back() {
let (mut u, ..) = setup();
f6(&mut u, false);
for _ in 0..100 {
if name(&u) == "Zoom" {
break;
}
press(&mut u, Key::Tab, Modifiers::NONE);
}
assert_eq!(name(&u), "Zoom");
press(&mut u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
u.frame(vec![]);
assert_eq!(name(&u), "Zoom in", "the menu's first item has the keyboard");
press(&mut u, Key::Escape, Modifiers::NONE);
u.frame(vec![]);
assert_eq!(name(&u), "Zoom");
assert_eq!(region(&u), Region::Properties, "closing the menu stays in the panel");
}
#[test]
fn a_pointer_press_ends_keyboard_mode() {
let (mut u, ..) = setup();
f6(&mut u, false);
assert!(u.app.ui.nav.keyboard);
let p = u.app.canvas.rect.center();
u.button(p, true);
u.button(p, false);
assert!(!u.app.ui.nav.keyboard);
assert_eq!(region(&u), Region::Canvas);
}
#[test]
fn focus_region_from_the_control_channel() {
let (mut u, ..) = setup();
let ctx = u.ctx.clone();
crate::control::handle(&mut u.app, &ctx, "ui.set", &json!({"focusRegion": "layers"})).unwrap();
u.frame(vec![]);
let v = crate::control::handle(&mut u.app, &ctx, "ui.inspect", &json!({})).unwrap();
assert_eq!(v["focus"]["region"], "layers");
assert_eq!(v["focus"]["keyboard"], true);
assert_eq!(v["focus"]["control"], "Rect A");
assert!(crate::control::handle(&mut u.app, &ctx, "ui.set", &json!({"focusRegion": "nowhere"})).is_err());
}
#[test]
fn reduce_motion_turns_egui_animations_off() {
let (mut u, ..) = setup();
assert!(u.ctx.global_style().animation_time > 0.0);
u.app.run("pref.reduceMotion", json!({"on": true}));
u.frame(vec![]);
assert_eq!(u.ctx.global_style().animation_time, 0.0);
assert_eq!(u.app.inspect()["reduceMotion"], true);
}
#[test]
fn a_tool_flyout_opened_from_the_keyboard_picks_a_tool() {
let (mut u, ..) = setup();
f6(&mut u, true);
f6(&mut u, true);
// Move, its flyout button, Frame, its flyout button, Rectangle, the shape tools' button.
for _ in 0..5 {
press(&mut u, Key::ArrowRight, Modifiers::NONE);
}
assert_eq!(name(&u), "Shape tools");
press(&mut u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
u.frame(vec![]);
assert_eq!(u.app.ui.tool_flyout, Some("shape"));
assert_eq!(name(&u), "Rectangle", "the flyout's first tool has the keyboard");
// egui moves the focus between a menu's items at the end of the frame.
press(&mut u, Key::ArrowDown, Modifiers::NONE);
u.frame(vec![]);
assert_eq!(name(&u), "Line");
press(&mut u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
u.frame(vec![]);
assert_eq!(u.app.session.tool.tool, ToolId::Line);
assert_eq!(u.app.ui.tool_flyout, None);
assert_eq!((region(&u), name(&u)), (Region::Toolbar, "Shape tools".to_string()), "back on the flyout's button");
// Escape closes an open flyout and stays in the toolbar.
press(&mut u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
u.frame(vec![]);
press(&mut u, Key::Escape, Modifiers::NONE);
u.frame(vec![]);
assert_eq!(u.app.ui.tool_flyout, None);
assert_eq!((region(&u), name(&u)), (Region::Toolbar, "Shape tools".to_string()));
}
/// Every keyboard and naming path with the screen reader tree on: the node writers and the shared
/// state run under egui's context lock, and a nested context call there would deadlock (debug
/// builds turn it into a panic after 10 s, so this test fails instead of hanging).
#[test]
fn every_accessibility_path_runs_with_the_tree_on_without_nested_context_locks() {
let (mut u, ..) = setup();
u.ctx.enable_accesskit();
u.app.run("pref.reduceMotion", json!({"on": true}));
for _ in 0..5 {
f6(&mut u, false);
let out = u.frame_output(vec![]);
assert!(out.platform_output.accesskit_update.is_some());
}
// Properties: Tab, a field edit, the zoom menu from the keyboard and back.
while region(&u) != Region::Properties {
f6(&mut u, false);
}
for _ in 0..100 {
if name(&u) == "Zoom" {
break;
}
press(&mut u, Key::Tab, Modifiers::NONE);
}
press(&mut u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
u.frame(vec![]);
assert_eq!(name(&u), "Zoom in");
press(&mut u, Key::ArrowDown, Modifiers::NONE);
press(&mut u, Key::Escape, Modifiers::NONE);
u.frame(vec![]);
// Toolbar: a flyout; layers: the arrows; the canvas; a pointer press.
while region(&u) != Region::Toolbar {
f6(&mut u, false);
}
press(&mut u, Key::End, Modifiers::NONE);
press(&mut u, Key::Home, Modifiers::NONE);
while region(&u) != Region::Layers {
f6(&mut u, false);
}
press(&mut u, Key::ArrowUp, Modifiers::NONE);
u.frame(vec![]);
press(&mut u, Key::Escape, Modifiers::NONE);
assert_eq!(region(&u), Region::Canvas);
let p = u.app.canvas.rect.center();
u.button(p, true);
u.button(p, false);
let out = u.frame_output(vec![]);
assert!(out.platform_output.accesskit_update.is_some());
}
+4 -3
View File
@@ -18,6 +18,7 @@ use figcraft_engine::library;
use serde_json::{Value, json};
use crate::FigApp;
use crate::a11y::Tip;
use crate::icons::{self, Icon};
use crate::theme::{COMPONENT, Tokens, regular, semibold};
@@ -253,7 +254,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String {
}
ui.data_mut(|d| d.insert_temp(filter_id, filter.clone()));
let br = Rect::from_center_size(pos2(fr.max.x - 26.0, field.center().y), vec2(24.0, 24.0));
let bresp = ui.interact(br, Id::new("asset_libraries_btn"), Sense::click()).on_hover_text("Libraries");
let bresp = ui.interact(br, Id::new("asset_libraries_btn"), Sense::click()).tip("Libraries");
if bresp.hovered() {
ui.painter().rect_filled(br, CornerRadius::same(5), t.hover);
}
@@ -680,7 +681,7 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) {
let w = ui.available_width();
let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
let back = Rect::from_center_size(pos2(hr.min.x + 24.0, hr.center().y), vec2(24.0, 24.0));
let bresp = ui.interact(back, Id::new("asset_libraries_back"), Sense::click()).on_hover_text("Back to assets");
let bresp = ui.interact(back, Id::new("asset_libraries_back"), Sense::click()).tip("Back to assets");
if bresp.hovered() {
ui.painter().rect_filled(back, CornerRadius::same(5), t.hover);
}
@@ -819,7 +820,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
ui.painter().with_clip_rect(clip).galley(pos2(r.min.x + PAD, r.min.y + 23.0), line, t.text_secondary);
ui.interact(clip, Id::new(("asset_lib_row", &l.id)), Sense::hover()).on_hover_text(&l.path);
let x = Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0));
let xr = ui.interact(x, Id::new(("asset_lib_remove", &l.id)), Sense::click()).on_hover_text("Remove library from this file");
let xr = ui.interact(x, Id::new(("asset_lib_remove", &l.id)), Sense::click()).tip("Remove library from this file");
if xr.hovered() {
ui.painter().rect_filled(x, CornerRadius::same(5), t.hover);
}
+21 -1
View File
@@ -125,6 +125,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
let ctx = ui.ctx().clone();
let ppp = ctx.pixels_per_point();
let resp = ui.interact(rect, ui.id().with("canvas"), Sense::click_and_drag());
crate::a11y::canvas(&mut app.ui.nav, &resp, || describe(&app.session));
if let Ok(ds) = app.session.ds_mut()
&& ((ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5)
{
@@ -225,6 +226,25 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
}
}
/// What the canvas says to screen readers: the selected layer ("Rectangle 1, Rectangle, 120 by 80,
/// selected"), how many are selected, or the page with nothing selected.
pub fn describe(s: &figcraft_engine::Session) -> String {
let Ok(ds) = s.ds() else { return String::new() };
match ds.selection.as_slice() {
[] => format!("{}, nothing selected", ds.doc.node(ds.page).map(|p| p.name.as_str()).unwrap_or("Page")),
[id] => match ds.doc.node(*id) {
Some(n) => {
let kind = n.kind.label();
let name = if n.name.is_empty() { kind } else { n.name.as_str() };
let (w, h) = (crate::widgets::fmt_num(n.width), crate::widgets::fmt_num(n.height));
format!("{name}, {kind}, {w} by {h}, selected")
}
None => String::new(),
},
many => format!("{} layers selected", many.len()),
}
}
/// Pixel preview's resolution (rendered pixels per page pixel) when it's on and zoomed in past it.
fn pixel_preview(ds: &figcraft_engine::DocState) -> Option<f64> {
let k = f64::from(ds.view.pixel_scale.clamp(1, 2));
@@ -303,7 +323,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) {
let (mods, scroll, zoom_delta, hover_pos, space) =
ctx.input(|i| (i.modifiers, i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space)));
let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
let typing_elsewhere = ctx.memory(|m| m.focused().is_some());
let typing_elsewhere = crate::a11y::field_focused(&ctx);
app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
// Dropped files import onto the canvas.
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
+3 -2
View File
@@ -7,6 +7,7 @@ use figcraft_doc::{NodeId, NodeKind};
use serde_json::json;
use crate::FigApp;
use crate::a11y::Tip;
use crate::icons::{self, Icon};
use crate::theme::{Tokens, doc_c32, regular, semibold};
use crate::widgets;
@@ -721,7 +722,7 @@ fn saved_preset_row(ui: &mut Ui, name: &str) -> Option<PresetAction> {
let w = ui.available_width().max(180.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
let del = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(20.0, 20.0));
let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).on_hover_text("Delete preset");
let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).tip("Delete preset");
let hot = resp.hovered() || del_resp.hovered();
if hot {
ui.painter().rect_filled(r, CornerRadius::same(5), crate::theme::ACCENT);
@@ -790,7 +791,7 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m
ui.horizontal(|ui| {
ui.add_space(16.0);
let r = ui.add(egui::TextEdit::singleline(name).font(regular(11.0)).desired_width(136.0));
if !r.has_focus() && ui.memory(|m| m.focused().is_none()) {
if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) {
r.request_focus();
}
save = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
+3 -3
View File
@@ -551,7 +551,7 @@ fn composer(ui: &mut Ui, id: Id, text: &mut String, hint: &str, people: &[String
.desired_width(ui.available_width() - 30.0)
.show(ui);
let resp = out.response.response;
if !resp.has_focus() && ui.memory(|m| m.focused().is_none()) {
if !resp.has_focus() && !crate::a11y::field_focused(ui.ctx()) {
resp.request_focus();
}
if resp.has_focus() {
@@ -574,7 +574,7 @@ fn composer(ui: &mut Ui, id: Id, text: &mut String, hint: &str, people: &[String
// name takes it from the field for a moment).
let found = cursor.and_then(|c| mention_query(text, c)).filter(|r| pick.dismissed != Some((id, r.0)));
let names = found.map(|r| mention_matches(people, text.get(r.0 + 1..r.1).unwrap_or(""), me)).unwrap_or_default();
let elsewhere = cursor.is_none() && ui.memory(|m| m.focused().is_some());
let elsewhere = cursor.is_none() && crate::a11y::field_focused(ui.ctx());
match found {
Some(r) if !names.is_empty() => {
if pick.field != Some(id) || pick.range.0 != r.0 {
@@ -1005,7 +1005,7 @@ pub fn keys(app: &mut FigApp, ctx: &egui::Context) {
if app.session.tool.tool != ToolId::Comment || cu.draft.is_some() || cu.editing.is_some() || cu.mention.active() {
return;
}
let free = match ctx.memory(|m| m.focused()) {
let free = match ctx.memory(|m| m.focused()).filter(|_| crate::a11y::field_focused(ctx)) {
None => true,
Some(f) => cu.open.is_some_and(|o| f == reply_id(o)) && cu.reply.is_empty(),
};
+13 -2
View File
@@ -14,7 +14,7 @@
//! window (panels, popovers); `hold` keeps modifier keys down for the frames that follow (all
//! false: released)
//! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog)
//! - `ui.set {rightTab?, leftTab?, theme?, home?, palette?, preview?, presentScroll?, …}`
//! - `ui.set {rightTab?, leftTab?, theme?, home?, palette?, focusRegion?, preview?, presentScroll?, …}`
//! - `ui.screenshot {path}` — PNG of the window (needs a visible window)
//! - `ui.render {path, scale?, ids?}` — PNG of the document (no UI chrome; works without a window)
//! - `app.quit {}`
@@ -124,7 +124,14 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1) as usize;
app.session.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string())
}
"ui.inspect" => Ok(app.inspect()),
"ui.inspect" => {
let mut v = app.inspect();
// The name of the control with the keyboard (what a screen reader announces).
if let Some(f) = v.get_mut("focus") {
f["control"] = json!(crate::a11y::focused_name(ctx));
}
Ok(v)
}
"ui.pointer" => pointer(app, p),
"ui.key" => {
let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?;
@@ -271,6 +278,10 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
if let Some(h) = p.get("mainMenu").and_then(Value::as_bool) {
app.ui.main_menu = h;
}
// Give a region the keyboard as F6 would: toolbar, layers, canvas or properties.
if let Some(r) = p.get("focusRegion").and_then(Value::as_str) {
app.ui.nav.go(crate::a11y::Region::parse(r).ok_or_else(|| format!("unknown region `{r}`"))?);
}
// A right-click on the canvas at screen [sx, sy] (null closes the menu);
// `contextSelectLayer` opens its Select layer submenu.
match p.get("contextMenu") {
+3 -2
View File
@@ -9,6 +9,7 @@ use figcraft_engine::codegen::{CodeLang, code_for};
use figcraft_engine::devinspect::{self, DevItem, Prop};
use serde_json::json;
use crate::a11y::Tip;
use crate::icons::{self, Icon};
use crate::right::{menu_check, menu_popup, prop_row, row};
use crate::theme::{ACCENT, Tokens, regular, semibold};
@@ -178,7 +179,7 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
ui.painter().text(pos2(r.min.x + PAD, r.center().y), Align2::LEFT_CENTER, format!("Note {}", k + 1), semibold(10.0), t.text_secondary);
let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0));
let resp = ui.interact(del, Id::new(("annot_del", id, k)), Sense::click()).on_hover_text("Remove annotation");
let resp = ui.interact(del, Id::new(("annot_del", id, k)), Sense::click()).tip("Remove annotation");
if resp.hovered() {
ui.painter().rect_filled(del, CornerRadius::same(5), t.hover);
}
@@ -276,7 +277,7 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) {
let mut go = None;
for item in &app.ui.dev.ready {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
let resp = resp.on_hover_text(format!("Go to {}", item.name));
let resp = resp.tip(format!("Go to {}", item.name));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover);
}
+4
View File
@@ -118,11 +118,15 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
("Rename duplicated layers", prefs.rename_duplicates, "pref.renameDuplicates"),
("Show dimensions on objects", prefs.show_dimensions, "pref.showDimensions"),
("Use number keys for opacity", prefs.number_keys_opacity, "pref.numberKeysOpacity"),
("Reduce motion", prefs.reduce_motion, "pref.reduceMotion"),
] {
if crate::widgets::checkbox(ui, val, label) {
toggles.push(cmd);
}
}
if app.services.reduce_motion() {
crate::widgets::label(ui, "Your system asks for reduced motion: animations stay off.", t.text_secondary);
}
for c in toggles {
app.run(c, json!({}));
}
+9 -1
View File
@@ -995,7 +995,15 @@ pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, active: bool, tooltip: &
// The glyph at its native 16 px in the usual buttons, smaller in compact ones.
let glyph = if size >= 22.0 { 16.0 } else { 12.0 };
paint(ui.painter(), Rect::from_center_size(rect.center(), Vec2::splat(glyph)), icon, col);
if tooltip.is_empty() { resp } else { resp.on_hover_text(tooltip) }
if tooltip.is_empty() {
return resp;
}
let resp = crate::a11y::Tip::tip(resp, tooltip);
// Shown pressed (an open option): a pressed toggle to screen readers.
if active {
crate::a11y::toggle(&resp, tooltip, true);
}
resp
}
/// Every icon at the sizes the UI uses, labelled: for reviewing the set (`ui.set {iconGallery}`).
+35 -7
View File
@@ -7,6 +7,7 @@ use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, Ui, pos2, vec
use figcraft_doc::{Document, NodeId, NodeKind};
use serde_json::json;
use crate::a11y::{self, Region, RowInfo, Tip};
use crate::icons::{self, Icon};
use crate::theme::{ACCENT, COMPONENT, Tokens, regular, semibold};
use crate::{FigApp, LeftTab};
@@ -70,7 +71,7 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) {
let mut y = r.min.y + 8.0;
// Main menu (logo).
let lr = Rect::from_center_size(pos2(r.center().x, y + 16.0), vec2(36.0, 32.0));
let lresp = ui.interact(lr, ui.id().with("rail_menu"), Sense::click()).on_hover_text("Main menu");
let lresp = ui.interact(lr, ui.id().with("rail_menu"), Sense::click()).tip("Main menu");
if lresp.hovered() || app.ui.main_menu {
ui.painter().rect_filled(lr, CornerRadius::same(6), t.hover);
}
@@ -78,6 +79,9 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) {
if lresp.clicked() {
app.ui.main_menu = !app.ui.main_menu;
}
if app.ui.main_menu {
a11y::popup(&mut app.ui.nav, lresp.id, crate::menus::main_menu_id());
}
y += 44.0;
ui.painter().line_segment([pos2(r.min.x + 18.0, y), pos2(r.max.x - 18.0, y)], Stroke::new(1.0, t.border));
y += 12.0;
@@ -85,8 +89,11 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) {
for (icon, label, tab) in items {
let ir = Rect::from_center_size(pos2(r.center().x, y + 14.0), vec2(36.0, 28.0));
let hit = Rect::from_min_max(pos2(r.min.x, y), pos2(r.max.x, y + 48.0));
let resp = ui.interact(hit, ui.id().with(("rail", label)), Sense::click()).on_hover_text(label);
let resp = ui.interact(hit, ui.id().with(("rail", label)), Sense::click()).tip(label);
let active = tab.is_some_and(|tb| tb == app.ui.left_tab && !app.ui.left_collapsed && !app.ui.versions.open);
if tab.is_some() {
a11y::tab(&resp, label, active);
}
if active {
ui.painter().rect_filled(ir, CornerRadius::same(6), t.selected_row);
} else if resp.hovered() {
@@ -135,7 +142,7 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) {
ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 46.0), Align2::LEFT_CENTER, "Drafts", regular(11.0), t.text_secondary);
}
let hr = Rect::from_center_size(pos2(head.max.x - 24.0, head.min.y + 24.0), vec2(24.0, 24.0));
let hresp = ui.interact(hr, ui.id().with("hideui"), Sense::click()).on_hover_text("Collapse panel");
let hresp = ui.interact(hr, ui.id().with("hideui"), Sense::click()).tip("Collapse panel");
if hresp.hovered() {
ui.painter().rect_filled(hr, CornerRadius::same(5), t.hover);
}
@@ -158,12 +165,13 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
let w = ui.available_width();
let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
let tresp = ui.interact(Rect::from_min_size(hr.min, vec2(w - 40.0, 40.0)), ui.id().with("pages_toggle"), Sense::click());
a11y::toggle(&tresp, "Pages", app.ui.pages_open);
ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Pages", semibold(11.0), t.text);
if tresp.clicked() {
app.ui.pages_open = !app.ui.pages_open;
}
let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0));
let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).on_hover_text(crate::shortcuts::tip("Find", "edit.findReplace"));
let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(crate::shortcuts::tip("Find", "edit.findReplace"));
if sresp.hovered() {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
@@ -172,7 +180,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
app.ui.find = Some((String::new(), String::new()));
}
let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0));
let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).on_hover_text("Add new page");
let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).tip("Add new page");
if aresp.hovered() {
ui.painter().rect_filled(ar, CornerRadius::same(5), t.hover);
}
@@ -195,7 +203,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
let mut move_to: Option<(NodeId, String)> = None;
for (id, name) in pages {
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click());
a11y::list_item(&resp, &name, id == cur);
let r = r.shrink2(vec2(8.0, 0.0));
a11y::ring_around(ui, r, &resp);
if resp.hovered() || id == cur {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
@@ -527,7 +537,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Layers", semibold(11.0), t.text);
let sr = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0));
let sresp = ui.interact(sr, ui.id().with("layer_search_btn"), Sense::click()).on_hover_text("Search layers");
let sresp = ui.interact(sr, ui.id().with("layer_search_btn"), Sense::click()).tip("Search layers");
if sresp.hovered() || app.ui.layer_search {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
@@ -645,9 +655,23 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
let Some(n) = doc.node(row.id) else { continue };
let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag());
let selected = sel.contains(&row.id);
a11y::layer_row(&mut app.ui.nav, &resp);
if sel.last() == Some(&row.id) {
a11y::prefer(&mut app.ui.nav, Region::Layers, resp.id);
}
let info = RowInfo {
kind: n.kind.label(),
depth: row.depth,
selected,
expanded: row.has_kids.then(|| app.ui.expanded.contains(&row.id)),
hidden: !n.visible,
locked: n.locked,
};
a11y::tree_item(&resp, if n.name.is_empty() { n.kind.label() } else { &n.name }, &info);
let in_selected = !selected && doc.ancestors(row.id).iter().any(|a| sel.contains(a));
let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. });
let rr = r.shrink2(vec2(8.0, 0.0));
a11y::ring_around(ui, rr, &resp);
if selected {
ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row });
} else if in_selected {
@@ -669,6 +693,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
let tri = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0));
let tresp = ui.interact(tri, ui.id().with(("tri", row.id)), Sense::click());
let open = app.ui.expanded.contains(&row.id);
a11y::button(&tresp, if open { "Collapse" } else { "Expand" });
icons::paint(ui.painter(), tri.shrink(1.0), if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary);
if tresp.clicked() {
if ui.input(|i| i.modifiers.alt) {
@@ -720,6 +745,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
let lr = Rect::from_center_size(pos2(rr.max.x - 36.0, r.center().y), vec2(20.0, 20.0));
if show_eye {
let eresp = ui.interact(er, ui.id().with(("eye", row.id)), Sense::click());
a11y::toggle(&eresp, "Hidden", !n.visible);
icons::paint(ui.painter(), er.shrink(3.0), if n.visible { Icon::Eye } else { Icon::EyeOff }, t.icon_secondary);
if eresp.clicked() {
vis = Some(row.id);
@@ -727,6 +753,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
}
if show_lock {
let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click());
a11y::toggle(&lresp, "Locked", n.locked);
icons::paint(ui.painter(), lr.shrink(3.0), if n.locked { Icon::Lock } else { Icon::Unlock }, t.icon_secondary);
if lresp.clicked() {
lock = Some(row.id);
@@ -806,7 +833,8 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
}
// Empty space below clears selection.
let rest = Rect::from_x_y_ranges(band.x_range(), (top + total)..=(top + total.max(viewport.max.y)));
if rest.height() > 0.0 && ui.interact(rest, Id::new("layers_empty_space"), Sense::click()).clicked() {
// A click target for the pointer only (no stop for the keyboard).
if rest.height() > 0.0 && ui.interact(rest, Id::new("layers_empty_space"), Sense::CLICK).clicked() {
app.run("select.none", json!({}));
}
});
+8 -1
View File
@@ -25,11 +25,18 @@ impl Ui {
}
pub(crate) fn frame(&mut self, events: Vec<Event>) {
self.frame_output(events);
}
/// [`Self::frame`], returning what egui output (the AccessKit tree when it's enabled).
pub(crate) fn frame_output(&mut self, events: Vec<Event>) -> egui::FullOutput {
self.time += 1.0 / 60.0;
let input =
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() };
let app = &mut self.app;
self.ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear();
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
out.textures_delta.clear();
out
}
/// The middle of `id`'s row in the layers panel (found by hovering down the panel).
+18 -7
View File
@@ -8,6 +8,7 @@ use figcraft_doc::*;
use serde_json::json;
use crate::FigApp;
use crate::a11y::Tip;
use crate::icons::{self, Icon};
use crate::right::{block, common, menu_item, menu_popup, row};
use crate::theme::{ACCENT, Tokens, regular};
@@ -226,7 +227,7 @@ fn limit_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], hori
fn add_slot(ui: &mut Ui, label: &str, width: f32) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::click());
let resp = resp.on_hover_text(format!("Add {}", label.to_lowercase()));
let resp = resp.tip(format!("Add {}", label.to_lowercase()));
let border = if resp.hovered() { t.field_hover_border } else { t.border };
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, border), StrokeKind::Inside);
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(10.0, 10.0)), Icon::Plus, t.icon_secondary);
@@ -279,7 +280,17 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
ui.vertical(|ui| {
if grid_mode {
// Grid: gap between columns and between rows.
let e = crate::variable_ui::number_field(app, ui, ids, "columnGap", Id::new("al_gap"), Lead::Icon(Icon::Gap), Some(l.gap), "", 96.0);
let e = crate::variable_ui::number_field(
app,
ui,
ids,
"columnGap",
Id::new("al_gap"),
Lead::Icon(Icon::Gap, "Column gap"),
Some(l.gap),
"",
96.0,
);
app.field(ids, "columnGap", e, |v| json!(v.max(0.0)));
ui.add_space(8.0);
let e = crate::variable_ui::number_field(
@@ -288,7 +299,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
ids,
"rowGap",
Id::new("al_rowgap"),
Lead::Icon(Icon::DistributeV),
Lead::Icon(Icon::DistributeV, "Row gap"),
Some(l.cross_gap),
"",
96.0,
@@ -304,7 +315,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
ids,
"gap",
Id::new("al_gap"),
Lead::Icon(gap_icon),
Lead::Icon(gap_icon, "Gap between items"),
if auto { None } else { Some(l.gap) },
"",
96.0,
@@ -345,7 +356,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
ids,
"paddingHorizontal",
Id::new("al_padh"),
Lead::Icon(Icon::PaddingH),
Lead::Icon(Icon::PaddingH, "Horizontal padding"),
(pl == pr).then_some(pl),
"",
col,
@@ -357,7 +368,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
ids,
"paddingVertical",
Id::new("al_padv"),
Lead::Icon(Icon::PaddingV),
Lead::Icon(Icon::PaddingV, "Vertical padding"),
(pt == pb).then_some(pt),
"",
col,
@@ -374,7 +385,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
ids,
"crossGap",
Id::new("al_crossgap"),
Lead::Icon(Icon::DistributeV),
Lead::Icon(Icon::DistributeV, "Gap between rows"),
Some(l.cross_gap),
"",
col,
+50 -7
View File
@@ -5,6 +5,7 @@
//! below this crate knows about egui, so the frontend can be replaced.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod a11y;
pub mod assets_panel;
pub mod bidi;
pub mod canvas;
@@ -49,6 +50,8 @@ pub mod vector_panel;
pub mod versions;
pub mod widgets;
#[cfg(test)]
mod a11y_tests;
#[cfg(test)]
mod canvas_drag_tests;
#[cfg(test)]
@@ -118,6 +121,10 @@ pub trait Services {
Vec::new()
}
fn add_recent(&mut self, _path: &str) {}
/// The system asks for reduced motion (its accessibility setting).
fn reduce_motion(&self) -> bool {
false
}
}
/// No-op services (tests, headless screenshots).
@@ -242,6 +249,8 @@ pub struct UiState {
pub next_autosave: f64,
/// The Assets panel shows the libraries view (publish this file, enabled libraries).
pub libraries: bool,
/// Keyboard navigation: the region with the keyboard, keyboard mode (F6, Tab).
pub nav: a11y::Nav,
}
impl Default for UiState {
@@ -306,6 +315,7 @@ impl Default for UiState {
last_copy: None,
next_autosave: 0.0,
libraries: false,
nav: a11y::Nav::default(),
}
}
}
@@ -422,6 +432,7 @@ impl FigApp {
self.ui.right_tab = if self.ui.right_tab == RightTab::Inspect { RightTab::Design } else { RightTab::Inspect };
}
"panel.leftSidebar" => self.ui.left_collapsed = !self.ui.left_collapsed,
"focus.region" => self.ui.nav.step(r.data.get("step").and_then(Value::as_i64).unwrap_or(1).clamp(-4, 4) as i32),
"panel.layers" => self.ui.left_tab = LeftTab::File,
"panel.assets" => self.ui.left_tab = LeftTab::Assets,
"panel.versionHistory" => versions::open(self),
@@ -532,6 +543,11 @@ impl FigApp {
}
}
/// Reduced motion: the preference or the system's setting.
pub fn reduce_motion(&self) -> bool {
self.session.prefs.reduce_motion || self.services.reduce_motion()
}
/// Per-frame logic that doesn't draw (control requests, theme).
pub fn logic(&mut self, ctx: &egui::Context) {
if !self.fonts_installed {
@@ -548,7 +564,7 @@ impl FigApp {
_ => false,
};
self.ui.dark = dark;
theme::apply(ctx, if dark { theme::Tokens::dark() } else { theme::Tokens::light() });
theme::apply_with(ctx, if dark { theme::Tokens::dark() } else { theme::Tokens::light() }, self.reduce_motion());
if self.worker.is_none() {
self.worker = Some(render_worker::Worker::new(ctx.clone()));
}
@@ -588,9 +604,11 @@ impl FigApp {
// A field that had the keyboard when the frame began keeps its keys from the shortcuts
// even if it lets go during the frame (Enter commits it), or as it began (egui drops the
// focus on Escape before the frame, which cancels the field's edit and nothing else).
let typing = std::mem::take(&mut self.field_focus) || ctx.memory(|m| m.focused().is_some());
let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx);
// Control requests run first so their results reflect this frame.
control::drain(self, &ctx);
// Before any widget: the navigation keys (F6, Tab, arrows, Escape) move the keyboard.
a11y::begin(self, &ctx);
if self.ui.title_bar == TitleBar::Custom {
tabs::resize_edges(&ctx);
}
@@ -617,7 +635,10 @@ impl FigApp {
.exact_size(56.0)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border)))
.show(ui, |ui| layers::rail(self, ui));
.show(ui, |ui| {
a11y::region(&mut self.ui.nav, ui, a11y::Region::Layers, egui::accesskit::Role::Navigation, "Sidebar");
layers::rail(self, ui)
});
}
if !hidden && !self.ui.left_collapsed {
egui::Panel::left("left_panel")
@@ -625,7 +646,15 @@ impl FigApp {
.default_size(self.ui.left_width)
.size_range(200.0..=480.0)
.frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border)))
.show(ui, |ui| layers::left_panel(self, ui));
.show(ui, |ui| {
let label = match self.ui.left_tab {
_ if self.ui.versions.open => "Version history",
LeftTab::File => "Layers",
LeftTab::Assets => "Assets",
};
a11y::region(&mut self.ui.nav, ui, a11y::Region::Layers, egui::accesskit::Role::Pane, label);
layers::left_panel(self, ui)
});
}
if !hidden {
egui::Panel::right("right_panel")
@@ -633,7 +662,15 @@ impl FigApp {
.default_size(self.ui.right_width)
.size_range(240.0..=480.0)
.frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border)))
.show(ui, |ui| right::right_panel(self, ui));
.show(ui, |ui| {
let label = match self.ui.right_tab {
RightTab::Design => "Design",
RightTab::Prototype => "Prototype",
RightTab::Inspect => "Dev Mode",
};
a11y::region(&mut self.ui.nav, ui, a11y::Region::Properties, egui::accesskit::Role::Pane, label);
right::right_panel(self, ui)
});
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| {
let worker = self.worker.take();
@@ -641,7 +678,10 @@ impl FigApp {
canvas::show(self, ui, &mut worker);
self.worker = Some(worker);
if !hidden {
toolbar::show(self, ui);
ui.scope_builder(egui::UiBuilder::new().id_salt("toolbar"), |ui| {
a11y::container(ui, egui::accesskit::Role::Toolbar, "Tools");
toolbar::show(self, ui);
});
}
});
// Before the shortcuts: keys the hovered pane's prototype uses don't reach the editor, and
@@ -660,7 +700,8 @@ impl FigApp {
if self.ui.rename.is_some_and(|r| r.start) && !hidden && !self.ui.left_collapsed && self.ui.left_tab == LeftTab::File {
ctx.request_repaint();
}
self.field_focus = ctx.memory(|m| m.focused().is_some());
a11y::end(self, &ctx);
self.field_focus = a11y::field_focused(&ctx);
#[cfg(not(target_arch = "wasm32"))]
{
self.ui.frame_ms = t0.elapsed().as_secs_f64() * 1000.0;
@@ -700,6 +741,8 @@ impl FigApp {
"leftTab": self.ui.left_tab,
"home": self.ui.home,
"dark": self.ui.dark,
"focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard},
"reduceMotion": self.reduce_motion(),
"presenting": self.ui.present.is_some(),
"presenter": self.ui.present.as_ref().map(present::Presenter::inspect),
"presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect},
+52 -43
View File
@@ -6,6 +6,7 @@ use figcraft_engine::catalog::{MENU, SEP};
use serde_json::json;
use crate::FigApp;
use crate::a11y;
use crate::icons::{self, Icon};
use crate::theme::{ACCENT, Tokens, regular};
use crate::toolbar::pretty_shortcut;
@@ -182,7 +183,7 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
last_sep = false;
let full = format!("{prefix}/{sub}");
let resp = submenu_item(ui, &sub, &t);
if resp.hovered() {
if resp.hovered() || resp.has_focus() {
ui.data_mut(|d| d.insert_temp(Id::new(("submenu_open", prefix)), full.clone()));
}
let open: Option<String> = ui.data(|d| d.get_temp(Id::new(("submenu_open", prefix))));
@@ -237,7 +238,13 @@ fn item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool,
fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, indent: f32, t: &Tokens) -> bool {
let w = ui.available_width();
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
let hov = resp.hovered() && enabled;
a11y::menu_item(&resp, label, shortcut, checked.then_some(true));
if !enabled {
a11y::disabled(&resp);
}
// The keyboard's item is highlighted like the hovered one.
a11y::own_focus(&resp);
let hov = (resp.hovered() || resp.has_focus()) && enabled;
if hov {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
@@ -262,7 +269,9 @@ fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checke
fn submenu_item(ui: &mut Ui, label: &str, t: &Tokens) -> egui::Response {
let w = ui.available_width();
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
if resp.hovered() {
a11y::submenu(&resp, label);
a11y::own_focus(&resp);
if resp.hovered() || resp.has_focus() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
ui.painter().text(pos2(r.min.x + 28.0, r.center().y), Align2::LEFT_CENTER, label, regular(12.0), t.menu_text);
@@ -270,6 +279,11 @@ fn submenu_item(ui: &mut Ui, label: &str, t: &Tokens) -> egui::Response {
resp
}
/// The main menu's area.
pub fn main_menu_id() -> Id {
Id::new("main_menu")
}
/// Whether the pointer is over a menu (any foreground area).
fn over_menu(ctx: &egui::Context) -> bool {
ctx.input(|i| i.pointer.interact_pos()).and_then(|p| ctx.layer_id_at(p)).is_some_and(|l| l.order == egui::Order::Foreground)
@@ -282,50 +296,45 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
// Main menu.
if app.ui.main_menu {
let mut close = false;
let area =
egui::Area::new(Id::new("main_menu")).fixed_pos(pos2(8.0, crate::tabs::HEIGHT + 48.0)).order(egui::Order::Foreground).show(ctx, |ui| {
menu_frame(ui, &t, |ui| {
// Quick actions search row.
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18));
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)),
Icon::Search,
t.menu_dim,
);
ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, "Actions…", regular(12.0), t.menu_dim);
ui.painter().text(
pos2(r.max.x - 10.0, r.center().y),
Align2::RIGHT_CENTER,
pretty_shortcut(crate::shortcuts::shortcut("app.actions").unwrap_or("")),
regular(12.0),
t.menu_dim,
);
if resp.clicked() {
app.ui.palette = Some(String::new());
close = true;
let area = egui::Area::new(main_menu_id()).fixed_pos(pos2(8.0, crate::tabs::HEIGHT + 48.0)).order(egui::Order::Foreground).show(ctx, |ui| {
menu_frame(ui, &t, |ui| {
// Quick actions search row.
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
a11y::menu_item(&resp, "Actions…", crate::shortcuts::shortcut("app.actions").unwrap_or(""), None);
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18));
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), Icon::Search, t.menu_dim);
ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, "Actions…", regular(12.0), t.menu_dim);
ui.painter().text(
pos2(r.max.x - 10.0, r.center().y),
Align2::RIGHT_CENTER,
pretty_shortcut(crate::shortcuts::shortcut("app.actions").unwrap_or("")),
regular(12.0),
t.menu_dim,
);
if resp.clicked() {
app.ui.palette = Some(String::new());
close = true;
}
ui.add_space(6.0);
for top in TOP_MENUS {
let resp = submenu_item(ui, top, &t);
if resp.hovered() || resp.has_focus() {
ui.data_mut(|d| d.insert_temp(Id::new("main_open"), top.to_string()));
}
ui.add_space(6.0);
for top in TOP_MENUS {
let resp = submenu_item(ui, top, &t);
if resp.hovered() {
ui.data_mut(|d| d.insert_temp(Id::new("main_open"), top.to_string()));
}
let open: Option<String> = ui.data(|d| d.get_temp(Id::new("main_open")));
if open.as_deref() == Some(*top) {
let pos = pos2(resp.rect.max.x + 12.0, resp.rect.min.y - 8.0);
egui::Area::new(Id::new(("menu", *top))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
menu_frame(ui, &t, |ui| {
if menu_level(app, ui, top) {
close = true;
}
});
let open: Option<String> = ui.data(|d| d.get_temp(Id::new("main_open")));
if open.as_deref() == Some(*top) {
let pos = pos2(resp.rect.max.x + 12.0, resp.rect.min.y - 8.0);
egui::Area::new(Id::new(("menu", *top))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
menu_frame(ui, &t, |ui| {
if menu_level(app, ui, top) {
close = true;
}
});
}
});
}
});
}
});
});
let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !over_menu(ctx) || ctx.input(|i| i.key_pressed(egui::Key::Escape));
if close || (clicked_outside && !area.response.hovered()) {
app.ui.main_menu = false;
+6 -5
View File
@@ -32,6 +32,7 @@ use figcraft_geom::{Affine, Point};
use serde_json::{Value, json};
use crate::FigApp;
use crate::a11y::Tip;
use crate::icons::{self, Icon};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::widgets;
@@ -1596,7 +1597,7 @@ pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let canvas = app.canvas.rect;
let style = ctx.global_style();
let typing = ds.text_edit.is_some() || ctx.memory(|m| m.focused().is_some());
let typing = ds.text_edit.is_some() || crate::a11y::field_focused(ctx);
let chrome = Chrome { pane: true, bar_h: 36.0, pad: 12.0, round: style.visuals.window_corner_radius.nw, keys: !typing };
let mut action = None;
egui::Window::new("Prototype preview")
@@ -1889,7 +1890,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
x = br.min.x - if chrome.pane { 2.0 } else { 4.0 };
let mut resp = ui.interact(br, ui.id().with(("pbar", *label)), Sense::click());
if chrome.pane {
resp = resp.on_hover_text(*label);
resp = resp.tip(*label);
}
let on = (*act == BarAction::Comments && p.view.comments) || (*act == BarAction::Inspect && p.view.inspect);
if on {
@@ -1898,10 +1899,10 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
ui.painter().rect_filled(br, CornerRadius::same(5), hover);
}
if *act == BarAction::Comments {
resp = resp.on_hover_text("Show comments (Shift+C)");
resp = resp.tip("Show comments (Shift+C)");
}
if *act == BarAction::Inspect {
resp = resp.on_hover_text("Inspect the layer under the pointer: size, colours, typography");
resp = resp.tip("Inspect the layer under the pointer: size, colours, typography");
}
if chrome.pane {
icons::paint(ui.painter(), br.shrink(5.0), *icon, t.icon);
@@ -1926,7 +1927,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
let tip = playing.map(|f| f.description.trim()).filter(|d| !d.is_empty()).unwrap_or("Flows").to_string();
let galley = left.layout_no_wrap(label, semibold(11.5), text);
let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0));
let resp = ui.interact(fr.intersect(left.clip_rect()), ui.id().with("pflows"), Sense::click()).on_hover_text(tip);
let resp = ui.interact(fr.intersect(left.clip_rect()), ui.id().with("pflows"), Sense::click()).tip(tip);
if resp.hovered() {
left.rect_filled(fr, CornerRadius::same(5), hover);
}
+2 -1
View File
@@ -11,6 +11,7 @@ use figcraft_engine::cmd::proto_actions::{action_at, start_value};
use serde_json::{Value, json};
use crate::FigApp;
use crate::a11y::Tip;
use crate::icons::{self, Icon};
use crate::list_drag::ListDrag;
use crate::proto_panel::{action_fields, action_of, action_summary, targets_for, value_json};
@@ -228,7 +229,7 @@ fn action_item(app: &mut FigApp, ui: &mut Ui, cx: &Cx, a: &Action, path: Vec<usi
let header = row_rect(ui, 32.0, |ui| {
if !single {
let (r, resp) = ui.allocate_exact_size(vec2(12.0, FIELD_H), Sense::click());
let resp = resp.on_hover_text(if open { "Close" } else { "Open" });
let resp = resp.tip(if open { "Close" } else { "Open" });
icons::paint(
ui.painter(),
Rect::from_center_size(r.center(), vec2(12.0, 12.0)),
+3 -2
View File
@@ -9,6 +9,7 @@ use figcraft_doc::*;
use serde_json::{Value, json};
use crate::FigApp;
use crate::a11y::Tip;
use crate::icons::{self, Icon};
use crate::proto_editor::{Cx, Slot, set_action};
use crate::right::{menu_item, menu_popup, row};
@@ -301,7 +302,7 @@ fn flows_of(doc: &Document, page: NodeId) -> Vec<proto::Flow> {
/// A square icon button painted at `rect`; true when clicked.
fn icon_button(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, id, Sense::click()).on_hover_text(tip);
let resp = ui.interact(rect, id, Sense::click()).tip(tip);
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover);
}
@@ -592,7 +593,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N
let (hr, _) = ui.allocate_exact_size(vec2(width, 40.0), Sense::hover());
ui.painter().text(pos2(hr.min.x + PAD, hr.center().y), Align2::LEFT_CENTER, "Interaction", semibold(11.0), t.text);
let x = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0));
let xr = ui.interact(x, ui.id().with("proto_editor_close"), Sense::click()).on_hover_text("Close");
let xr = ui.interact(x, ui.id().with("proto_editor_close"), Sense::click()).tip("Close");
if xr.hovered() {
ui.painter().rect_filled(x, CornerRadius::same(5), t.hover);
}
+46 -21
View File
@@ -6,6 +6,7 @@ use figcraft_doc::*;
use figcraft_engine::cmd::lists::moved_index;
use serde_json::{Value, json};
use crate::a11y::{self, Tip};
use crate::icons::{self, Icon};
use crate::list_drag::{ListDrag, move_flags};
use crate::style_picker::{self, StyleSlot};
@@ -64,7 +65,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
ui.painter().text(pos2(top.min.x + 26.0, top.center().y), Align2::CENTER_CENTER, "Y", semibold(12.0), Color32::WHITE);
icons::paint(ui.painter(), Rect::from_center_size(pos2(top.min.x + 48.0, top.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary);
let share = Rect::from_min_size(pos2(top.max.x - 70.0, top.center().y - 16.0), vec2(56.0, 32.0));
let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).on_hover_text("Export and share");
let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).tip("Export and share");
ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { ACCENT });
ui.painter().text(share.center(), Align2::CENTER_CENTER, "Share", semibold(11.5), Color32::WHITE);
if sresp.clicked() {
@@ -75,8 +76,8 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
let (tri_r, chev_r) =
(Rect::from_min_max(play.min, pos2(play.max.x - 16.0, play.max.y)), Rect::from_min_max(pos2(play.max.x - 16.0, play.min.y), play.max));
let present_keys = crate::toolbar::pretty_shortcut(crate::shortcuts::shortcut("proto.present").unwrap_or(""));
let presp = ui.interact(tri_r, ui.id().with("play"), Sense::click()).on_hover_text(format!("Present {present_keys}"));
let mresp = ui.interact(chev_r, ui.id().with("play_menu"), Sense::click()).on_hover_text("Present or preview");
let presp = ui.interact(tri_r, ui.id().with("play"), Sense::click()).tip(format!("Present {present_keys}"));
let mresp = ui.interact(chev_r, ui.id().with("play_menu"), Sense::click()).tip("Present or preview");
if presp.hovered() || mresp.hovered() {
ui.painter().rect_filled(play, CornerRadius::same(5), t.hover);
}
@@ -107,6 +108,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
let r = Rect::from_min_size(pos2(x, tabs.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0));
let resp = ui.interact(r, ui.id().with(("rtab", label)), Sense::click());
let active = app.ui.right_tab == tab;
a11y::tab(&resp, label, active);
if active {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
@@ -120,6 +122,9 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0);
let zr = Rect::from_min_size(pos2(tabs.max.x - 64.0, tabs.center().y - 12.0), vec2(56.0, 24.0));
let zresp = ui.interact(zr, ui.id().with("zoommenu"), Sense::click());
// The value is only spelled out while a screen reader reads the tree.
let value = if a11y::active(&zresp.ctx) { format!("{}%", (zoom * 100.0).round()) } else { String::new() };
a11y::combo(&zresp, "Zoom", &value, a11y::menu_open(&zresp));
if zresp.hovered() {
ui.painter().rect_filled(zr, CornerRadius::same(5), t.hover);
}
@@ -179,6 +184,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
RightTab::Inspect => inspect(app, ui),
}
ui.add_space(80.0);
a11y::scroll_focus(&mut app.ui.nav, ui);
});
}
@@ -234,7 +240,10 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl
let trail = keys.as_ref().map_or(0.0, |k| k.size().x + MENU_PAD).max(art_w);
let w = ui.available_width().max(menu_row_width(text.size().x, trail).ceil());
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
let hovered = resp.hovered();
a11y::menu_item(&resp, label, shortcut, None);
// The keyboard's item is highlighted like the hovered one.
a11y::own_focus(&resp);
let hovered = resp.hovered() || resp.has_focus();
if hovered {
ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT);
}
@@ -250,7 +259,9 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl
/// A menu item with a check mark when `on` (the unchecked ones line up with it).
pub fn menu_check(ui: &mut Ui, on: bool, label: &str, shortcut: &str) -> bool {
menu_item(ui, &format!("{}{label}", if on { "✓ " } else { " " }), shortcut)
let resp = menu_row(ui, &format!("{}{label}", if on { "✓ " } else { " " }), shortcut, 0.0, |_, _, _| {});
a11y::menu_item(&resp, label, shortcut, Some(on));
resp.clicked()
}
pub(crate) fn row(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) {
@@ -427,7 +438,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
t.icon_secondary,
);
let sr = Rect::from_min_size(pos2(lr.max.x + 4.0, r.center().y - 12.0), vec2(24.0, 24.0));
let sresp = ui.interact(sr, ui.id().with("swap_orient"), Sense::click()).on_hover_text("Swap orientation");
let sresp = ui.interact(sr, ui.id().with("swap_orient"), Sense::click()).tip("Swap orientation");
if sresp.hovered() {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
@@ -462,7 +473,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
for (icon, label, cmd) in actions.into_iter().rev() {
let br = Rect::from_min_size(pos2(x - 24.0, r.center().y - 12.0), vec2(24.0, 24.0));
let tip = if cmd == "bool.union" { label } else { crate::shortcuts::tip(label, cmd) };
let bresp = ui.interact(br, ui.id().with(("qa", cmd)), Sense::click()).on_hover_text(tip);
let bresp = ui.interact(br, ui.id().with(("qa", cmd)), Sense::click()).tip(tip);
if bresp.hovered() {
ui.painter().rect_filled(br, CornerRadius::same(5), t.hover);
}
@@ -552,7 +563,7 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
});
widgets::sublabels(ui, "Rotation", None);
row(ui, 32.0, |ui| {
let e = widgets::number_field(ui, Id::new("pos_rot"), Lead::Icon(Icon::CornerRadius), rot, "°", col, 1.0);
let e = widgets::number_field(ui, Id::new("pos_rot"), Lead::Icon(Icon::CornerRadius, "Rotation"), rot, "°", col, 1.0);
app.field(&ids, "rotation", e, |v| json!(v));
let items = [
(Icon::Rotate, "Rotate 90° right"),
@@ -629,7 +640,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
ui.data_mut(|d| d.insert_temp(Id::new("blend_open"), !d.get_temp::<bool>(Id::new("blend_open")).unwrap_or(false)));
}
let col = widgets::col_width(ui.available_width());
crate::variable_ui::bound_row(app, ui, &ids, "visible", VariableType::Boolean, Lead::Icon(Icon::Eye), col * 2.0 + 8.0);
crate::variable_ui::bound_row(app, ui, &ids, "visible", VariableType::Boolean, Lead::Icon(Icon::Eye, "Visible"), col * 2.0 + 8.0);
// Masks: how they mask.
if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.is_mask)) {
let mt = common(doc, sel, |n| n.mask_type);
@@ -651,7 +662,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0));
widgets::sublabels(ui, "Opacity", radius_ok.then_some("Corner radius"));
row(ui, 32.0, |ui| {
let e = crate::variable_ui::number_field(app, ui, &ids, "opacity", Id::new("ap_op"), Lead::Icon(Icon::Opacity), op, "%", col);
let e = crate::variable_ui::number_field(app, ui, &ids, "opacity", Id::new("ap_op"), Lead::Icon(Icon::Opacity, "Opacity"), op, "%", col);
app.field(&ids, "opacity", e, |v| json!((v / 100.0).clamp(0.0, 1.0)));
if radius_ok {
let e = crate::variable_ui::number_field(
@@ -660,7 +671,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
&ids,
"cornerRadius",
Id::new("ap_rad"),
Lead::Icon(Icon::CornerRadius),
Lead::Icon(Icon::CornerRadius, "Corner radius"),
r.flatten(),
"",
col,
@@ -674,16 +685,21 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
});
if radius_ok && ui.data(|d| d.get_temp::<bool>(Id::new("indep_corners"))).unwrap_or(false) {
let radii = common(doc, sel, |n| n.corners.radii);
let keys = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"];
let keys = [
("topLeftRadius", "Top left radius"),
("topRightRadius", "Top right radius"),
("bottomRightRadius", "Bottom right radius"),
("bottomLeftRadius", "Bottom left radius"),
];
row(ui, 32.0, |ui| {
for (k, key) in keys.iter().enumerate().take(2) {
for (k, (key, name)) in keys.iter().enumerate().take(2) {
let e = crate::variable_ui::number_field(
app,
ui,
&ids,
key,
Id::new(("corner", k)),
Lead::Icon(Icon::CornerRadius),
Lead::Icon(Icon::CornerRadius, name),
radii.map(|r| r[k]),
"",
col,
@@ -692,14 +708,14 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
}
});
row(ui, 32.0, |ui| {
for (k, key) in keys.iter().enumerate().skip(2) {
for (k, (key, name)) in keys.iter().enumerate().skip(2) {
let e = crate::variable_ui::number_field(
app,
ui,
&ids,
key,
Id::new(("corner", k)),
Lead::Icon(Icon::CornerRadius),
Lead::Icon(Icon::CornerRadius, name),
radii.map(|r| r[k]),
"",
col,
@@ -950,8 +966,17 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
});
let sided = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle)));
let weight_w = half - if sided { 60.0 } else { 32.0 };
let e =
crate::variable_ui::number_field(app, ui, &ids, "strokeWeight", Id::new("stroke_w"), Lead::Icon(Icon::Menu), weight, "", weight_w);
let e = crate::variable_ui::number_field(
app,
ui,
&ids,
"strokeWeight",
Id::new("stroke_w"),
Lead::Icon(Icon::Menu, "Stroke weight"),
weight,
"",
weight_w,
);
app.field(&ids, "strokeWeight", e, |v| json!(v.max(0.0)));
if sided {
let sides_btn = icons::button(ui, Icon::IndependentCorners, 24.0, false, "Individual strokes");
@@ -1236,7 +1261,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
);
let _ = ui.interact(chip, Id::new(("bound_paint", fills, i)), Sense::hover()).on_hover_text(&name);
let dr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0));
let dresp = ui.interact(dr, Id::new(("detach_paint", fills, i)), Sense::click()).on_hover_text("Detach variable");
let dresp = ui.interact(dr, Id::new(("detach_paint", fills, i)), Sense::click()).tip("Detach variable");
icons::paint(ui.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary);
if dresp.clicked() {
app.run("variable.bind", json!({"ids": ids, "field": key, "variable": null}));
@@ -1765,7 +1790,7 @@ pub(crate) fn inspect_section(app: &mut FigApp, ui: &mut Ui, s: &figcraft_engine
pub(crate) fn prop_row(app: &mut FigApp, ui: &mut Ui, p: &figcraft_engine::devinspect::Prop) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
let resp = resp.on_hover_text(format!("Copy {}", p.value));
let resp = resp.tip(format!("Copy {}", p.value));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover);
}
@@ -1811,7 +1836,7 @@ fn inspect_assets(app: &mut FigApp, ui: &mut Ui, assets: &[figcraft_engine::devi
let mut export: Option<Vec<NodeId>> = all.then(|| assets.iter().map(|a| a.id).collect());
for a in assets {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
let resp = resp.on_hover_text(format!("Export {}", a.name));
let resp = resp.tip(format!("Export {}", a.name));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover);
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - PAD - 6.0, r.center().y), vec2(12.0, 12.0)), Icon::Export, t.icon);
+1
View File
@@ -75,6 +75,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let on = current.as_ref().and_then(|c| c.as_ref()).is_some_and(|h| h.eq_ignore_ascii_case(hex.trim_start_matches('#')));
let stroke = if on { Stroke::new(2.0, crate::theme::ACCENT) } else { Stroke::new(1.0, t.border) };
ui.painter().rect_stroke(r, CornerRadius::same(9), stroke, egui::StrokeKind::Inside);
crate::a11y::radio(&resp, name, on);
if resp.on_hover_text(name).clicked() {
app.set_props(&ids, json!({"fills": [hex]}));
}
+1 -1
View File
@@ -70,7 +70,7 @@ fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str {
pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) {
note_platform(ctx);
// Text fields in panels own the keyboard (also the key that ends their editing).
if typing || ctx.memory(|m| m.focused().is_some()) || app.ui.palette.is_some() {
if typing || crate::a11y::field_focused(ctx) || app.ui.palette.is_some() {
return;
}
let events: Vec<Event> = ctx.input(|i| i.events.clone());
+3 -2
View File
@@ -15,6 +15,7 @@ use figcraft_engine::library;
use serde_json::{Value, json};
use crate::FigApp;
use crate::a11y::Tip;
use crate::colorpicker::PickerTarget;
use crate::icons::{self, Icon};
use crate::theme::{Tokens, regular, semibold};
@@ -286,7 +287,7 @@ struct Frame {
/// An icon button on the dark popover.
fn menu_icon(ui: &mut Ui, rect: Rect, icon: Icon, tip: &str, on: bool) -> Response {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, ui.id().with(("style_picker_btn", tip)), Sense::click()).on_hover_text(tip);
let resp = ui.interact(rect, ui.id().with(("style_picker_btn", tip)), Sense::click()).tip(tip);
if on || resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.menu_hover);
}
@@ -531,7 +532,7 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut
}
ui.add_space(4.0);
let r = ui.add(egui::TextEdit::singleline(&mut p.query).hint_text("Search").font(regular(11.0)).desired_width(W));
if !r.has_focus() && ui.memory(|m| m.focused().is_none()) {
if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) {
r.request_focus();
}
ui.add_space(4.0);
+8 -5
View File
@@ -5,6 +5,7 @@
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use crate::a11y::Tip;
use crate::icons::{self, Icon};
use crate::theme::{Tokens, regular};
use crate::{FigApp, TitleBar};
@@ -30,8 +31,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 };
// Home.
let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT));
let hresp = ui.interact(hr, ui.id().with("home"), Sense::click()).on_hover_text("Home");
let hresp = ui.interact(hr, ui.id().with("home"), Sense::click()).tip("Home");
let home_active = app.ui.home || app.session.docs.is_empty();
crate::a11y::tab(&hresp, "Home", home_active);
if home_active {
ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active);
} else if hresp.hovered() {
@@ -51,6 +53,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let w = (regular(12.0).size * 0.6 * name.chars().count() as f32 + 64.0).clamp(120.0, 220.0);
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT));
let resp = ui.interact(r, ui.id().with(("tab", d.uid)), Sense::click());
crate::a11y::tab(&resp, &name, active);
if active {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active);
} else if resp.hovered() {
@@ -77,7 +80,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0));
if resp.hovered() || active {
let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click());
let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click()).tip("Close tab");
if cresp.hovered() {
ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed);
}
@@ -97,7 +100,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
// New tab.
let nr = Rect::from_center_size(pos2(x + 20.0, rect.center().y), vec2(28.0, 28.0));
let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).on_hover_text(crate::shortcuts::tip("New design file", "file.new"));
let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).tip(crate::shortcuts::tip("New design file", "file.new"));
if nresp.hovered() {
ui.painter().rect_filled(nr, CornerRadius::same(5), t.hover);
}
@@ -108,7 +111,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
// Right side: theme toggle (left of the window buttons).
let tr = Rect::from_center_size(pos2(rect.max.x - buttons_w - 24.0, rect.center().y), vec2(28.0, 28.0));
let tresp = ui.interact(tr, ui.id().with("theme"), Sense::click()).on_hover_text("Toggle light/dark theme");
let tresp = ui.interact(tr, ui.id().with("theme"), Sense::click()).tip("Toggle light/dark theme");
if tresp.hovered() {
ui.painter().rect_filled(tr, CornerRadius::same(5), t.hover);
}
@@ -150,7 +153,7 @@ fn window_buttons(ui: &mut Ui, bar: Rect) -> f32 {
];
for (k, (icon, tip, cmd)) in buttons.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(bar.max.x - W * (3 - k) as f32, bar.min.y), vec2(W, bar.height()));
let resp = ui.interact(r, ui.id().with(("window_button", k)), Sense::click()).on_hover_text(tip);
let resp = ui.interact(r, ui.id().with(("window_button", k)), Sense::click()).tip(tip);
let close = k == 2;
let mut col = t.icon;
if resp.hovered() {
+20 -2
View File
@@ -39,6 +39,9 @@ pub struct Tokens {
pub menu_text: Color32,
pub menu_hover: Color32,
pub menu_dim: Color32,
/// The ring around the control with the keyboard (keyboard navigation): at least 3:1 against
/// the panels and the canvas.
pub focus: Color32,
}
pub const ACCENT: Color32 = Color32::from_rgb(0x0D, 0x99, 0xFF);
@@ -58,7 +61,8 @@ impl Tokens {
border: Color32::from_rgb(0xE6, 0xE6, 0xE6),
border_strong: Color32::from_rgb(0xD9, 0xD9, 0xD9),
text: Color32::from_rgb(0x1E, 0x1E, 0x1E),
text_secondary: Color32::from_rgb(0x75, 0x75, 0x75),
// 4.7:1 on fields and hovered rows (#F5F5F5), 4.6:1 on selected rows: WCAG AA.
text_secondary: Color32::from_rgb(0x6E, 0x6E, 0x6E),
text_tertiary: Color32::from_rgb(0xB3, 0xB3, 0xB3),
icon: Color32::from_rgb(0x1E, 0x1E, 0x1E),
icon_secondary: Color32::from_rgb(0x75, 0x75, 0x75),
@@ -68,7 +72,8 @@ impl Tokens {
field_hover_border: Color32::from_rgb(0xE6, 0xE6, 0xE6),
accent: ACCENT,
accent_hover: Color32::from_rgb(0x00, 0x7B, 0xE5),
accent_text: Color32::from_rgb(0x00, 0x7B, 0xE5),
// 5.2:1 on white, 4.6:1 on selected rows: WCAG AA for text.
accent_text: Color32::from_rgb(0x00, 0x6B, 0xD1),
selected_row: Color32::from_rgb(0xE5, 0xF4, 0xFF),
selected_row_child: Color32::from_rgb(0xF2, 0xF9, 0xFF),
component: COMPONENT,
@@ -84,6 +89,7 @@ impl Tokens {
menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF),
menu_hover: ACCENT,
menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A),
focus: Color32::from_rgb(0x00, 0x6B, 0xD1),
}
}
@@ -122,6 +128,7 @@ impl Tokens {
menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF),
menu_hover: ACCENT,
menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A),
focus: ACCENT,
}
}
@@ -134,8 +141,17 @@ fn tokens_id() -> egui::Id {
egui::Id::new("figcraft_tokens")
}
/// egui's default for how long hover fades and openings animate.
const ANIMATION_TIME: f32 = 0.2;
/// Install tokens and egui visuals.
pub fn apply(ctx: &Context, t: Tokens) {
apply_with(ctx, t, false);
}
/// [`apply`], with egui's own animations (hover fades, openings, smooth scrolling to a target) off
/// when the system or the preferences ask for reduced motion. Prototype transitions still play.
pub fn apply_with(ctx: &Context, t: Tokens, reduce_motion: bool) {
ctx.data_mut(|d| d.insert_temp(tokens_id(), t));
ctx.set_visuals(visuals(t));
ctx.global_style_mut(|s| {
@@ -153,6 +169,8 @@ pub fn apply(ctx: &Context, t: Tokens) {
]
.into();
s.interaction.tooltip_delay = 0.4;
s.animation_time = if reduce_motion { 0.0 } else { ANIMATION_TIME };
s.scroll_animation = if reduce_motion { egui::style::ScrollAnimation::none() } else { egui::style::ScrollAnimation::default() };
});
}
+87 -54
View File
@@ -6,19 +6,27 @@ use figcraft_engine::vectoredit::VTool;
use serde_json::json;
use crate::FigApp;
use crate::a11y::{self, Nav, Region, Tip};
use crate::icons::{self, Icon};
use crate::theme::{ACCENT, Tokens, regular};
struct Group {
key: &'static str,
/// The flyout button's name for screen readers.
name: &'static str,
tools: &'static [(ToolId, Icon)],
}
const GROUPS: &[Group] = &[
Group { key: "move", tools: &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Scale, Icon::Scale)] },
Group { key: "frame", tools: &[(ToolId::Frame, Icon::Frame), (ToolId::Section, Icon::Section), (ToolId::Slice, Icon::Slice)] },
Group { key: "move", name: "Move tools", tools: &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Scale, Icon::Scale)] },
Group {
key: "frame",
name: "Frame tools",
tools: &[(ToolId::Frame, Icon::Frame), (ToolId::Section, Icon::Section), (ToolId::Slice, Icon::Slice)],
},
Group {
key: "shape",
name: "Shape tools",
tools: &[
(ToolId::Rectangle, Icon::Rectangle),
(ToolId::Line, Icon::Line),
@@ -28,9 +36,13 @@ const GROUPS: &[Group] = &[
(ToolId::Star, Icon::Star),
],
},
Group { key: "pen", tools: &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)] },
Group { key: "text", tools: &[(ToolId::Text, Icon::Text)] },
Group { key: "comment", tools: &[(ToolId::Comment, Icon::Comment)] },
Group {
key: "pen",
name: "Drawing tools",
tools: &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)],
},
Group { key: "text", name: "Text", tools: &[(ToolId::Text, Icon::Text)] },
Group { key: "comment", name: "Comment", tools: &[(ToolId::Comment, Icon::Comment)] },
];
fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId {
@@ -53,7 +65,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
let cell = 32.0;
let gap = 4.0;
let width = GROUPS.len() as f32 * (cell + 4.0) + 4.0 * 10.0 + 2.0 * (cell + 4.0) + 16.0 + 4.0 * 34.0 + 24.0;
let rect = pill(ui, canvas, width, &t);
let rect = pill(ui, &mut app.ui.nav, canvas, width, &t);
let current = app.session.tool.tool;
let mut x = rect.min.x + 8.0;
let cy = rect.center().y;
@@ -62,7 +74,10 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) };
let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move);
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
let resp = tool_cell(ui, &t, r, ("tool", g.key), icon, in_group).on_hover_text(format!("{} {}", shown.label(), shown.shortcut()));
let resp = tool_cell(ui, &t, r, ("tool", g.key), icon, in_group, &format!("{} {}", shown.label(), pretty_shortcut(shown.shortcut())));
if in_group {
a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id);
}
if resp.clicked() {
app.run("tool.set", json!({"tool": shown.id()}));
}
@@ -71,6 +86,11 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
if g.tools.len() > 1 {
let cr = Rect::from_center_size(pos2(x + 5.0, cy), vec2(10.0, cell));
let cresp = ui.interact(cr, ui.id().with(("flyout", g.key)), Sense::click());
let open = app.ui.tool_flyout == Some(g.key);
a11y::menu_button(&cresp, g.name, open);
if open {
a11y::popup(&mut app.ui.nav, cresp.id, flyout_id(g.key));
}
if cresp.hovered() {
ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover);
}
@@ -91,7 +111,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
(Icon::Actions, crate::shortcuts::tip("Actions", "app.actions"), "actions"),
] {
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
let resp = ui.interact(r, ui.id().with(id), Sense::click()).on_hover_text(tip);
let resp = ui.interact(r, ui.id().with(id), Sense::click()).tip(tip);
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
@@ -121,6 +141,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
for (i, (icon, tip)) in modes.iter().enumerate() {
let r = Rect::from_min_size(pos2(seg.min.x + 2.0 + i as f32 * 34.0, seg.min.y + 2.0), vec2(34.0, 32.0));
let resp = ui.interact(r, ui.id().with(("mode", i)), Sense::click()).on_hover_text(*tip);
a11y::toggle(&resp, tip, i == active);
if i == active {
ui.painter().rect_filled(r, CornerRadius::same(6), t.panel);
ui.painter().rect_stroke(r, CornerRadius::same(6), Stroke::new(1.0, t.border), StrokeKind::Inside);
@@ -147,7 +168,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
}
// Floating help button at the bottom right of the canvas.
let hr = Rect::from_center_size(pos2(canvas.max.x - 32.0, canvas.max.y - 32.0), vec2(32.0, 32.0));
let hresp = ui.interact(hr, ui.id().with("help"), Sense::click()).on_hover_text("Help and resources");
let hresp = ui.interact(hr, ui.id().with("help"), Sense::click()).tip("Help and resources");
ui.painter().circle_filled(hr.center() + vec2(0.0, 1.0), 17.0, Color32::from_black_alpha(20));
ui.painter().circle_filled(hr.center(), 16.0, t.panel);
ui.painter().text(hr.center(), Align2::CENTER_CENTER, "?", crate::theme::regular(15.0), t.text);
@@ -167,7 +188,7 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
let canvas = app.canvas.rect;
let cell = 32.0;
let width = VTool::ALL.len() as f32 * (cell + 4.0) + 8.0 + 12.0 + DONE_W + 8.0;
let rect = pill(ui, canvas, width, &t);
let rect = pill(ui, &mut app.ui.nav, canvas, width, &t);
let cy = rect.center().y;
let mut x = rect.min.x + 8.0;
// Holding Cmd with the Move tool bends: show it.
@@ -190,7 +211,11 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
VTool::Eraser => Icon::Eraser,
VTool::Width => Icon::VariableWidth,
};
if tool_cell(ui, &t, r, ("vtool", tool.id()), icon, active).on_hover_text(tip).clicked() {
let resp = tool_cell(ui, &t, r, ("vtool", tool.id()), icon, active, &tip);
if active {
a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id);
}
if resp.clicked() {
app.run("vector.edit.tool", json!({"tool": tool.id()}));
}
x += cell + 4.0;
@@ -216,12 +241,17 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
};
let galley = ui.painter().layout_no_wrap(hint, regular(12.0), t.text_secondary);
let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + galley.size().x + 12.0 + 12.0 + DONE_W + 8.0;
let rect = pill(ui, app.canvas.rect, width, &t);
let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);
let cy = rect.center().y;
let mut x = rect.min.x + 8.0;
for (mode, icon, tip) in [("merge", Icon::ShapeBuilder, "Merge regions"), ("remove", Icon::ShapeBuilderRemove, "Remove regions")] {
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
if tool_cell(ui, &t, r, ("sbmode", mode), icon, (mode == "remove") == remove).on_hover_text(tip).clicked() {
let active = (mode == "remove") == remove;
let resp = tool_cell(ui, &t, r, ("sbmode", mode), icon, active, tip);
if active {
a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id);
}
if resp.clicked() {
app.run("shapeBuilder.mode", json!({"mode": mode}));
}
x += cell + 4.0;
@@ -236,9 +266,12 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
}
}
/// The floating pill (shadow, panel, border) `width` wide at the bottom centre of the canvas.
fn pill(ui: &Ui, canvas: Rect, width: f32, t: &Tokens) -> Rect {
/// The floating pill (shadow, panel, border) `width` wide at the bottom centre of the canvas: the
/// toolbar region keyboard navigation moves through.
fn pill(ui: &Ui, nav: &mut Nav, canvas: Rect, width: f32, t: &Tokens) -> Rect {
let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0));
a11y::area(nav, Region::Toolbar, rect);
a11y::container_bounds(ui, rect);
let painter = ui.painter();
for (i, a) in [(6.0, 10u8), (3.0, 16), (1.0, 24)] {
painter.rect_filled(rect.expand(i).translate(vec2(0.0, i * 0.5)), CornerRadius::same((13.0 + i) as u8), Color32::from_black_alpha(a));
@@ -248,9 +281,12 @@ fn pill(ui: &Ui, canvas: Rect, width: f32, t: &Tokens) -> Rect {
rect
}
/// A square tool button: accent when active, tinted on hover.
fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Icon, active: bool) -> egui::Response {
let resp = ui.interact(r, ui.id().with(id), Sense::click());
/// A square tool button: accent when active, tinted on hover; `tip` is its tooltip and name.
fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Icon, active: bool, tip: &str) -> egui::Response {
let resp = ui.interact(r, ui.id().with(id), Sense::click()).on_hover_ui(|ui| {
ui.label(tip);
});
a11y::toggle(&resp, tip, active);
let bg = if active {
ACCENT
} else if resp.hovered() {
@@ -274,7 +310,7 @@ const DONE_W: f32 = 56.0;
fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool {
divider(ui, t, x, cy);
let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0));
let resp = ui.interact(done, ui.id().with("tool_done"), Sense::click()).on_hover_text(tip);
let resp = ui.interact(done, ui.id().with("tool_done"), Sense::click()).tip(tip);
ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { ACCENT });
ui.painter().text(done.center(), Align2::CENTER_CENTER, "Done", crate::theme::semibold(11.5), Color32::WHITE);
resp.clicked()
@@ -286,42 +322,34 @@ fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) {
let w = 220.0;
let h = item_h * g.tools.len() as f32 + 16.0;
let mut chosen = None;
egui::Area::new(egui::Id::new(("flyout_area", g.key))).fixed_pos(pos2(bottom_left.x, bottom_left.y - h)).order(egui::Order::Foreground).show(
ui.ctx(),
|ui| {
let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
ui.painter().rect_filled(rect, CornerRadius::same(13), t.menu);
let mut y = rect.min.y + 8.0;
for (tid, icon) in g.tools {
let r = Rect::from_min_size(pos2(rect.min.x + 8.0, y), vec2(w - 16.0, item_h));
let resp = ui.interact(r, ui.id().with(("fly", tid.id())), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
if *tid == app.session.tool.tool {
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(12.0, 12.0)),
Icon::Check,
t.menu_text,
);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 32.0, r.center().y), vec2(16.0, 16.0)), *icon, t.menu_text);
ui.painter().text(pos2(r.min.x + 48.0, r.center().y), Align2::LEFT_CENTER, tid.label(), regular(12.0), t.menu_text);
ui.painter().text(
pos2(r.max.x - 8.0, r.center().y),
Align2::RIGHT_CENTER,
pretty_shortcut(tid.shortcut()),
regular(12.0),
t.menu_dim,
);
if resp.clicked() {
chosen = Some(*tid);
}
y += item_h;
egui::Area::new(flyout_id(g.key)).fixed_pos(pos2(bottom_left.x, bottom_left.y - h)).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
ui.painter().rect_filled(rect, CornerRadius::same(13), t.menu);
let mut y = rect.min.y + 8.0;
for (tid, icon) in g.tools {
let r = Rect::from_min_size(pos2(rect.min.x + 8.0, y), vec2(w - 16.0, item_h));
let resp = ui.interact(r, ui.id().with(("fly", tid.id())), Sense::click());
a11y::menu_item(&resp, tid.label(), tid.shortcut(), Some(*tid == app.session.tool.tool));
// The keyboard's item is highlighted like the hovered one.
a11y::own_focus(&resp);
if resp.hovered() || resp.has_focus() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
},
);
if *tid == app.session.tool.tool {
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, t.menu_text);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 32.0, r.center().y), vec2(16.0, 16.0)), *icon, t.menu_text);
ui.painter().text(pos2(r.min.x + 48.0, r.center().y), Align2::LEFT_CENTER, tid.label(), regular(12.0), t.menu_text);
ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, pretty_shortcut(tid.shortcut()), regular(12.0), t.menu_dim);
if resp.clicked() {
chosen = Some(*tid);
}
y += item_h;
}
});
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.tool_flyout = None;
}
if let Some(tid) = chosen {
if let Some(i) = ToolId::ALL.iter().position(|x| *x == tid) {
ui.data_mut(|d| d.insert_temp(egui::Id::new(("tb_last", g.key)), i as u8));
@@ -331,6 +359,11 @@ fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) {
}
}
/// A tool group's flyout (an egui area).
fn flyout_id(key: &str) -> egui::Id {
egui::Id::new(("flyout_area", key))
}
/// "Shift+Cmd+K" → "⇧⌘K" on macOS, "Ctrl+Shift+K" elsewhere.
pub fn pretty_shortcut(s: &str) -> String {
figcraft_engine::keymap::display(s, crate::shortcuts::platform())
+8 -3
View File
@@ -92,7 +92,7 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
if hdr == Some(1) {
crate::style_picker::open(app, ui, &ids, crate::style_picker::StyleSlot::Text);
}
crate::variable_ui::bound_row(app, ui, &ids, "characters", VariableType::String, Lead::Icon(Icon::Text), f32::INFINITY);
crate::variable_ui::bound_row(app, ui, &ids, "characters", VariableType::String, Lead::Icon(Icon::Text, "Text"), f32::INFINITY);
// A text style: its chip stands for the font rows (they show again when the chars differ).
crate::style_picker::chip(app, ui, doc, &ids, crate::style_picker::StyleSlot::Text);
if !matches!(crate::style_picker::text_link(app, doc, &ids), Some(Some(_))) {
@@ -641,6 +641,7 @@ fn text_seg(ui: &mut Ui, salt: &str, items: &[(&str, &str)], active: Option<usiz
for (i, (label, tip)) in items.iter().enumerate() {
let r = Rect::from_min_size(pos2(rect.min.x + i as f32 * cell, rect.min.y), vec2(cell, FIELD_H));
let resp = ui.interact(r, Id::new(("ty_seg", salt, i)), Sense::click()).on_hover_text(*tip);
crate::a11y::toggle(&resp, tip, active == Some(i));
if active == Some(i) {
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(4), t.panel);
ui.painter().rect_stroke(r.shrink(2.0), CornerRadius::same(4), Stroke::new(1.0, t.border), StrokeKind::Inside);
@@ -763,6 +764,7 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti
let editing_id = id.with("editing");
let mut editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
let resp = ui.interact(rect, id.with("click"), Sense::click()).on_hover_text(tip);
crate::a11y::text(&resp, tip, value);
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
if editing.is_some() {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside);
@@ -770,7 +772,8 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside);
}
paint_lead(ui.painter(), lead, glyph, t.icon_secondary);
if editing.is_none() && resp.clicked() {
let started = editing.is_none() && resp.clicked();
if started {
editing = Some(if value == MIXED { String::new() } else { value.to_string() });
ui.memory_mut(|m| m.request_focus(id.with("te")));
}
@@ -785,7 +788,9 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti
.font(regular(11.0))
.desired_width(text_rect.width() - 6.0),
);
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
crate::a11y::own_focus(&r);
// The Enter that started the edit (from the keyboard) doesn't end it.
let enter = !started && ui.input(|i| i.key_pressed(egui::Key::Enter));
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
if esc {
ui.data_mut(|d| d.remove::<String>(editing_id));
+3 -2
View File
@@ -13,6 +13,7 @@ use figcraft_engine::library;
use serde_json::json;
use crate::FigApp;
use crate::a11y::Tip;
use crate::icons::{self, Icon};
use crate::theme::{Tokens, regular, semibold};
use crate::widgets::{self, BindEvent, FieldEdit, Lead};
@@ -280,7 +281,7 @@ pub fn variable_button(ui: &mut Ui, rect: Rect, id: Id) -> bool {
}
let t = Tokens::get(ui.ctx());
let b = Rect::from_center_size(pos2(rect.max.x - 11.0, rect.center().y), vec2(18.0, 18.0));
let resp = ui.interact(b, id, Sense::click()).on_hover_text("Apply variable");
let resp = ui.interact(b, id, Sense::click()).tip("Apply variable");
if resp.hovered() {
ui.painter().rect_filled(b, CornerRadius::same(4), t.hover);
}
@@ -335,7 +336,7 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
});
ui.add_space(4.0);
let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search").font(regular(11.0)).desired_width(224.0));
if !r.has_focus() && ui.memory(|m| m.focused().is_none()) {
if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) {
r.request_focus();
}
ui.add_space(4.0);
+3 -2
View File
@@ -10,6 +10,7 @@ use figcraft_engine::library;
use serde_json::{Value, json};
use crate::FigApp;
use crate::a11y::Tip;
use crate::colorpicker::PickerTarget;
use crate::icons::{self, Icon};
use crate::right::menu_item;
@@ -179,7 +180,7 @@ fn header(ui: &mut Ui, open: &mut bool) {
/// A square icon button painted at `rect`; true when clicked.
fn small_button(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, id, Sense::click()).on_hover_text(tip);
let resp = ui.interact(rect, id, Sense::click()).tip(tip);
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover);
}
@@ -692,7 +693,7 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C
ui.painter().rect_filled(body, CornerRadius::same(5), t.field);
let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0));
widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c));
if ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).on_hover_text("Edit colour").clicked() {
if ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).tip("Edit colour").clicked() {
app.ui.picker = Some(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() });
}
let hex_r = Rect::from_min_max(pos2(sw.max.x + 8.0, body.min.y + 4.0), pos2(body.max.x - 44.0, body.max.y - 4.0));
+2 -2
View File
@@ -50,7 +50,7 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
row(ui, 32.0, |ui| {
let e = widgets::number_field(ui, Id::new("wpt_t"), Lead::None, Some(round(p.t * 100.0)), "%", col, 1.0);
point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "t": v / 100.0}));
let e = widgets::number_field(ui, Id::new("wpt_w"), Lead::Icon(Icon::StrokeIcon), Some(round(p.scale * weight)), "", col, 1.0);
let e = widgets::number_field(ui, Id::new("wpt_w"), Lead::Icon(Icon::StrokeIcon, "Width"), Some(round(p.scale * weight)), "", col, 1.0);
point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "width": v.max(0.0)}));
});
ui.add_space(8.0);
@@ -105,7 +105,7 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
};
widgets::sublabels(ui, "Corner radius", None);
row(ui, 32.0, |ui| {
let e = widgets::number_field(ui, Id::new("vpt_radius"), Lead::Icon(Icon::CornerRadius), radius.map(round), "", col, 1.0);
let e = widgets::number_field(ui, Id::new("vpt_radius"), Lead::Icon(Icon::CornerRadius, "Corner radius"), radius.map(round), "", col, 1.0);
point_field(app, "vector.edit.cornerRadius", e, |v| json!({"radius": v.max(0.0)}));
});
widgets::sublabels(ui, "Mirroring", None);
+2 -1
View File
@@ -7,6 +7,7 @@ use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use crate::FigApp;
use crate::a11y::Tip;
use crate::icons::{self, Icon};
use crate::theme::{Tokens, regular, semibold};
use crate::widgets;
@@ -361,7 +362,7 @@ fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option<A
}
icons::paint(ui.painter(), br.shrink(5.0), Icon::Dots, t.icon);
}
let more = more.on_hover_text("More");
let more = more.tip("More");
let mut menu = |ui: &mut Ui| {
for (label, a) in [
("Restore this version", Action::Restore(row.id.clone())),
+77 -21
View File
@@ -3,6 +3,7 @@
use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use crate::a11y::{self, Tip};
use crate::icons::{self, Icon};
use crate::theme::{Tokens, regular, semibold};
@@ -10,14 +11,28 @@ pub const ROW_H: f32 = 32.0;
pub const FIELD_H: f32 = 24.0;
pub const PAD: f32 = 16.0;
/// What a numeric field's label shows: text ("X") or an icon.
/// What a numeric field's label shows: text ("X") or an icon with the field's name (for screen
/// readers: an icon doesn't say which property it is).
#[derive(Clone, Copy)]
pub enum Lead<'a> {
Text(&'a str),
Icon(Icon),
Icon(Icon, &'a str),
None,
}
impl Lead<'_> {
/// The field's name for screen readers ("W" is "Width").
pub fn name(&self) -> &str {
match self {
Lead::Text("W") => "Width",
Lead::Text("H") => "Height",
Lead::Text(s) => s,
Lead::Icon(_, name) => name,
Lead::None => "",
}
}
}
/// Result of a field edit.
pub enum FieldEdit {
/// Committed a new value (Enter / focus loss).
@@ -26,7 +41,7 @@ pub enum FieldEdit {
Scrub(f64, bool),
}
fn fmt_num(v: f64) -> String {
pub(crate) fn fmt_num(v: f64) -> String {
if !v.is_finite() {
return String::new();
}
@@ -88,14 +103,17 @@ fn number_field_impl(
let lead_w = match lead {
Lead::None => 6.0,
Lead::Text(s) => (12.0 + s.chars().count() as f32 * 7.0).max(24.0),
Lead::Icon(_) => 24.0,
Lead::Icon(..) => 24.0,
};
let lead_rect = Rect::from_min_size(rect.min, vec2(lead_w, FIELD_H));
let edit_rect = Rect::from_min_max(pos2(rect.min.x + lead_w, rect.min.y), rect.max);
let editing_id = id.with("editing");
let mut editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
let field_resp = ui.interact(edit_rect, id.with("field"), Sense::click());
let lead_resp = ui.interact(lead_rect, id.with("lead"), Sense::click_and_drag());
a11y::number(&field_resp, lead.name(), value, suffix, empty);
a11y::ring_around(ui, rect, &field_resp);
// The label scrubs with the pointer; the keyboard stops at the value only.
let lead_resp = ui.interact(lead_rect, id.with("lead"), Sense::CLICK | Sense::DRAG);
let hovered = field_resp.hovered() || lead_resp.hovered() || editing.is_some();
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
if editing.is_some() {
@@ -107,7 +125,7 @@ fn number_field_impl(
Lead::Text(s) => {
ui.painter().text(pos2(lead_rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary);
}
Lead::Icon(i) => {
Lead::Icon(i, _) => {
icons::paint(ui.painter(), Rect::from_center_size(lead_rect.center() + vec2(1.0, 0.0), vec2(14.0, 14.0)), i, t.icon_secondary)
}
Lead::None => {}
@@ -147,6 +165,7 @@ fn number_field_impl(
.vertical_align(egui::Align::Center);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect.shrink2(vec2(0.0, 4.0))));
let r = child.add(te);
a11y::own_focus(&r);
keep_focus(&r, started);
if ui.data(|d| d.get_temp::<bool>(id.with("selectall"))).unwrap_or(false)
&& let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id.with("te"))
@@ -156,7 +175,8 @@ fn number_field_impl(
state.store(ui.ctx(), id.with("te"));
ui.data_mut(|d| d.remove::<bool>(id.with("selectall")));
}
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
// The Enter that started the edit (from the keyboard) doesn't end it.
let enter = !started && ui.input(|i| i.key_pressed(egui::Key::Enter));
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
let arrow = ui.input(|i| {
if i.key_pressed(egui::Key::ArrowUp) {
@@ -203,13 +223,13 @@ fn number_field_impl(
if binding.is_some() && ui.data(|d| d.get_temp::<String>(editing_id)).is_none() {
let br = Rect::from_center_size(pos2(rect.max.x - 11.0, rect.center().y), vec2(18.0, 18.0));
let bresp = ui.interact(br, id.with("bind"), Sense::click());
if rect.contains(ui.input(|i| i.pointer.hover_pos()).unwrap_or(egui::Pos2::new(f32::NAN, f32::NAN))) {
if bresp.has_focus() || rect.contains(ui.input(|i| i.pointer.hover_pos()).unwrap_or(egui::Pos2::new(f32::NAN, f32::NAN))) {
if bresp.hovered() {
ui.painter().rect_filled(br, CornerRadius::same(4), t.hover);
}
icons::paint(ui.painter(), br.shrink(3.0), Icon::Variable, t.icon_secondary);
}
let bresp = bresp.on_hover_text("Apply variable");
let bresp = bresp.tip("Apply variable");
if bresp.clicked() {
bind = Some(BindEvent::Open(rect.left_bottom()));
}
@@ -223,6 +243,7 @@ fn number_field_impl(
pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> Option<BindEvent> {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, id.with("bound"), Sense::click());
a11y::combo(&resp, lead.name(), name, false);
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
if resp.hovered() {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside);
@@ -233,7 +254,7 @@ pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> O
ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary);
(12.0 + s.chars().count() as f32 * 7.0).max(24.0)
}
Lead::Icon(i) => {
Lead::Icon(i, _) => {
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.min.x + 13.0, rect.center().y), vec2(14.0, 14.0)), i, t.icon_secondary);
24.0
}
@@ -246,8 +267,8 @@ pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> O
let short = name.rsplit('/').next().unwrap_or(name);
let galley = ui.painter().layout_no_wrap(short.to_string(), regular(11.0), t.text);
ui.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).galley(pos2(chip.min.x + 5.0, chip.center().y - galley.size().y / 2.0), galley, t.text);
let dresp = ui.interact(dr, id.with("detach"), Sense::click()).on_hover_text("Detach variable");
if resp.hovered() || dresp.hovered() {
let dresp = ui.interact(dr, id.with("detach"), Sense::click()).tip("Detach variable");
if resp.hovered() || dresp.hovered() || dresp.has_focus() {
if dresp.hovered() {
ui.painter().rect_filled(dr, CornerRadius::same(4), t.hover);
}
@@ -301,7 +322,7 @@ pub fn eval(s: &str, cur: Option<f64>) -> Option<f64> {
/// A text field that commits on Enter / focus loss. Returns the new text when committed.
pub fn text_field(ui: &mut Ui, id: Id, value: &str, width: f32, placeholder: &str) -> Option<String> {
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
text_field_in(ui, id, value, rect, placeholder)
text_field_in(ui, id, value, rect, placeholder, placeholder)
}
/// A multi-line text field across the panel (descriptions, notes) that commits when it loses
@@ -343,10 +364,11 @@ pub fn multiline_field(ui: &mut Ui, id: Id, value: &str, hint: &str, rows: usize
pub fn combo_field(ui: &mut Ui, id: Id, value: &str, width: f32, options: &[String]) -> Option<String> {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
let mut out = text_field_in(ui, id, value, rect, "");
let mut out = text_field_in(ui, id, value, rect, "", "");
// The chevron sits on top of the field's right end.
let chev = Rect::from_min_max(pos2(rect.max.x - 18.0, rect.min.y), rect.max);
let resp = ui.interact(chev, id.with("menu"), Sense::click());
a11y::combo(&resp, "Presets", value, a11y::menu_open(&resp));
if resp.hovered() {
ui.painter().rect_filled(chev.shrink(2.0), CornerRadius::same(4), t.hover);
}
@@ -362,12 +384,13 @@ pub fn combo_field(ui: &mut Ui, id: Id, value: &str, width: f32, options: &[Stri
out
}
/// [`text_field`] drawn into `rect`.
fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str) -> Option<String> {
/// [`text_field`] drawn into `rect`; `name` names it for screen readers.
fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str, name: &str) -> Option<String> {
let t = Tokens::get(ui.ctx());
let editing_id = id.with("editing");
let mut editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
let resp = ui.interact(rect, id.with("click"), Sense::click());
a11y::text(&resp, name, value);
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
if editing.is_some() {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside);
@@ -390,8 +413,9 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str
.font(regular(11.0))
.desired_width(rect.width() - 16.0),
);
a11y::own_focus(&r);
keep_focus(&r, started);
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let enter = !started && ui.input(|i| i.key_pressed(egui::Key::Enter));
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
if esc {
ui.data_mut(|d| d.remove::<String>(editing_id));
@@ -423,6 +447,7 @@ fn keep_focus(r: &Response, started: bool) {
pub fn dropdown(ui: &mut Ui, label: &str, width: f32, lead: Option<Icon>) -> Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::click());
a11y::combo(&resp, "", label, a11y::menu_open(&resp));
let bg = t.field;
ui.painter().rect_filled(rect, CornerRadius::same(5), bg);
if resp.hovered() {
@@ -447,7 +472,13 @@ const DROPDOWN_CHROME: f32 = 30.0;
pub fn elided_dropdown(ui: &mut Ui, label: &str, width: f32) -> egui::Response {
let shown = elide(ui, label, width - DROPDOWN_CHROME);
let resp = dropdown(ui, &shown, width, None);
if shown != label { resp.on_hover_text(label) } else { resp }
if shown != label {
// Screen readers get the whole name.
a11y::combo(&resp, "", label, a11y::menu_open(&resp));
resp.on_hover_text(label)
} else {
resp
}
}
/// `s` shortened with an ellipsis to fit `max_w` on one line.
@@ -474,6 +505,7 @@ pub fn segmented(ui: &mut Ui, items: &[(Icon, &str)], active: Option<usize>, cel
for (i, (icon, tip)) in items.iter().enumerate() {
let r = Rect::from_min_size(pos2(rect.min.x + i as f32 * cell, rect.min.y), vec2(cell, FIELD_H));
let resp = ui.interact(r, ui.id().with(("seg", i, *tip)), Sense::click()).on_hover_text(*tip);
a11y::toggle(&resp, tip, active == Some(i));
if active == Some(i) {
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(4), t.panel);
ui.painter().rect_stroke(r.shrink(2.0), CornerRadius::same(4), Stroke::new(1.0, t.border), StrokeKind::Inside);
@@ -510,7 +542,7 @@ pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: b
let mut x = rect.max.x - 8.0;
for (i, (icon, tip)) in buttons.iter().enumerate().rev() {
let r = Rect::from_min_size(pos2(x - 24.0, rect.center().y - 12.0), vec2(24.0, 24.0));
let resp = ui.interact(r, ui.id().with(("hdr", title, i)), Sense::click()).on_hover_text(*tip);
let resp = ui.interact(r, ui.id().with(("hdr", title, i)), Sense::click()).tip(*tip);
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
@@ -566,10 +598,27 @@ pub fn checker(painter: &egui::Painter, rect: Rect, cell: f32) {
/// A paint swatch (with checkerboard under transparent colours).
pub fn swatch(ui: &mut Ui, paint: &figcraft_color::Paint, size: f32) -> Response {
let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click());
// The value is only spelled out while a screen reader reads the tree.
let value = if a11y::active(&resp.ctx) { paint_name(paint) } else { String::new() };
a11y::color(&resp, "Color", &value);
paint_swatch(ui.painter(), rect, paint);
resp
}
/// A paint as a screen reader says it: "#FF0000", "#FF0000 50%", "Gradient", "Image".
pub fn paint_name(paint: &figcraft_color::Paint) -> String {
use figcraft_color::PaintKind;
match &paint.kind {
PaintKind::Solid { color } => {
let hex = color.to_hex();
let alpha = color.a * paint.opacity;
if alpha < 0.999 { format!("{hex} {}%", (alpha * 100.0).round()) } else { hex }
}
PaintKind::Gradient(_) => "Gradient".into(),
PaintKind::Image(_) => "Image".into(),
}
}
pub fn paint_swatch(painter: &egui::Painter, rect: Rect, paint: &figcraft_color::Paint) {
let t = Tokens::get(painter.ctx());
use figcraft_color::PaintKind;
@@ -607,6 +656,7 @@ pub fn paint_swatch(painter: &egui::Painter, rect: Rect, paint: &figcraft_color:
pub fn checkbox(ui: &mut Ui, on: bool, label: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(200.0), FIELD_H), Sense::click());
a11y::checkbox(&resp, label, on);
let b = Rect::from_min_size(pos2(rect.min.x, rect.center().y - 7.0), vec2(14.0, 14.0));
if on {
ui.painter().rect_filled(b, CornerRadius::same(3), t.accent);
@@ -622,6 +672,7 @@ pub fn checkbox(ui: &mut Ui, on: bool, label: &str) -> bool {
pub fn radio(ui: &mut Ui, on: bool, label: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(240.0), FIELD_H), Sense::click());
a11y::radio(&resp, label, on);
let c = pos2(rect.min.x + 7.0, rect.center().y);
if on {
ui.painter().circle_filled(c, 7.0, t.accent);
@@ -640,6 +691,7 @@ pub fn button(ui: &mut Ui, label: &str, primary: bool, enabled: bool) -> Respons
let galley = ui.painter().layout_no_wrap(label.to_string(), regular(11.0), t.text);
let sense = if enabled { Sense::click() } else { Sense::hover() };
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 24.0, FIELD_H), sense);
a11y::button(&resp, label);
let hot = enabled && resp.hovered();
let (fill, text) = match (primary, hot) {
(true, false) => (t.accent, Color32::WHITE),
@@ -670,7 +722,9 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu
let focused = ui.memory(|m| m.has_focus(te_id));
let mut buf: Option<String> = ui.data(|d| d.get_temp(buf_id));
let resp = ui.interact(rect, id.with("click"), Sense::click());
if buf.is_none() && (focused || resp.clicked()) {
a11y::text(&resp, "Name", value);
let started = buf.is_none() && (focused || resp.clicked());
if started {
buf = Some(value.to_string());
ui.memory_mut(|m| m.request_focus(te_id));
// A rename starts with the whole name selected.
@@ -685,7 +739,9 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu
ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.accent), StrokeKind::Inside);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
let r = crate::bidi::single_line(&mut child, te_id, &mut text, font, rect.width());
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
a11y::own_focus(&r);
// The Enter that started the edit (from the keyboard) doesn't end it.
let enter = !started && ui.input(|i| i.key_pressed(egui::Key::Enter));
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
if esc {
ui.data_mut(|d| d.remove::<String>(buf_id));
+9 -2
View File
@@ -80,6 +80,11 @@ whether a click where nothing reacts flashes them (`hotspotHints`) and they are
starts with (`scale`, `deviceFrame`, `comments`, `hotspotHints`, `inspect`).
`textPreview` is true while a live text preview shows on the canvas (a hovered text style or named
instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)).
`focus` is keyboard navigation's state: the `region` with the keyboard (`toolbar`, `layers`,
`canvas` or `properties`), whether `keyboard` mode is on (F6 or Tab moved the focus since the last
pointer press, so the focus ring shows) and the name of the `control` with the keyboard (what a
screen reader announces; empty when nothing has it). `reduceMotion` is true when the preference or
the system's accessibility setting turns the interface's animations off.
### `ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?}`
@@ -133,10 +138,12 @@ separate calls. Returns `{queued}`.
Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`.
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?}`
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?}`
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `iconGallery` (bool:
`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `focusRegion`
(`toolbar|layers|canvas|properties`: give that region the keyboard as F6 would, on the next frame),
`iconGallery` (bool:
every UI icon at 12, 16, 24 and 32 px over the window, for reviewing the icon set), `colorPicker`
(`{fills?, index?}`: open the colour picker on the selection's paint; `null` closes it), `variables` (bool,
or a collection id or name to show, or `{library, collection?}` for an enabled library's collection: the
+6
View File
@@ -176,6 +176,12 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
- `view.designPrototype` (`Shift+E`), `view.devMode` (`Shift+D`) and `view.leftSidebar`
(`Shift+Cmd+\`) switch the right panel's tab or hide the left sidebar in the app (no-ops headless).
- `doc.nextTab` / `doc.previousTab` (`Ctrl+Tab` / `Ctrl+Shift+Tab`): the next or previous open file.
- `view.nextRegion` / `view.previousRegion` (`F6` / `Shift+F6`, live while editing text too): move
the keyboard between the app's regions (toolbar, layers, canvas, properties). In the app, Tab and
Shift+Tab then move between the region's controls, Enter or Space activate one, the arrows move
along the toolbar and through the layer rows, and Escape returns to the canvas (no-ops headless).
- `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's
accessibility setting turns it on too). Prototype transitions still play.
### Editing vectors
+1 -1
View File
@@ -1,6 +1,6 @@
# Parity report
Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (437 commands in the engine).
Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (442 commands in the engine).
**197 / 202 menu items (97.5%)**