mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 20:06:53 -06:00
* 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>
291 lines
14 KiB
Rust
291 lines
14 KiB
Rust
//! View menu, preferences and tools. View state is per document and drivable like everything else.
|
|
|
|
use figcraft_doc::*;
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, ok};
|
|
use crate::params::*;
|
|
use crate::tools::ToolId;
|
|
use crate::{Result, Session, ViewState, bad, spec};
|
|
|
|
fn view(s: &mut Session) -> Result<&mut ViewState> {
|
|
Ok(&mut s.ds_mut()?.view)
|
|
}
|
|
|
|
fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Result<Value> {
|
|
let v = view(s)?;
|
|
let slot = f(v);
|
|
*slot = boolean(p, "on").unwrap_or(!*slot);
|
|
Ok(json!({"on": *slot}))
|
|
}
|
|
|
|
/// Pixel preview: toggles, or turns on at a resolution of 1x or 2x.
|
|
fn pixel_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let scale = match p.get("scale") {
|
|
None | Some(Value::Null) => None,
|
|
Some(v) => match v.as_u64() {
|
|
Some(k @ (1 | 2)) => Some(k as u8),
|
|
_ => return Err(bad("view.pixelPreview", "`scale` must be 1 or 2")),
|
|
},
|
|
};
|
|
let v = view(s)?;
|
|
if let Some(k) = scale {
|
|
v.pixel_scale = k;
|
|
}
|
|
v.pixel_preview = boolean(p, "on").unwrap_or(scale.is_some() || !v.pixel_preview);
|
|
Ok(json!({"on": v.pixel_preview, "scale": v.pixel_scale}))
|
|
}
|
|
|
|
fn zoom_to(s: &mut Session, z: f64) -> Result<Value> {
|
|
let v = view(s)?;
|
|
let c = (v.cx, v.cy);
|
|
v.set_zoom_at(z, c);
|
|
Ok(json!({"zoom": v.zoom}))
|
|
}
|
|
|
|
/// Figma's zoom steps.
|
|
const STEPS: &[f64] = &[0.02, 0.03, 0.06, 0.13, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0];
|
|
|
|
fn step(z: f64, up: bool) -> f64 {
|
|
if up {
|
|
STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(256.0)
|
|
} else {
|
|
STEPS.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(0.02)
|
|
}
|
|
}
|
|
|
|
/// The zoom tool: a click zooms a step in (or out) about the clicked point, a dragged area fills
|
|
/// the view.
|
|
fn zoom_at(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let (Some(x), Some(y)) = (num(p, "x").filter(|v| v.is_finite()), num(p, "y").filter(|v| v.is_finite())) else {
|
|
return Err(bad("view.zoomAt", "`x` and `y` (page coordinates) are required"));
|
|
};
|
|
let v = view(s)?;
|
|
match (num(p, "width"), num(p, "height")) {
|
|
(Some(w), Some(h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => {
|
|
v.fit(figcraft_geom::Rect::new(x, y, x + w, y + h), ViewState::MAX_ZOOM);
|
|
}
|
|
_ => {
|
|
let z = step(v.zoom, !boolean(p, "out").unwrap_or(false));
|
|
v.set_zoom_at(z, (x, y));
|
|
}
|
|
}
|
|
Ok(json!({"zoom": v.zoom, "cx": v.cx, "cy": v.cy}))
|
|
}
|
|
|
|
fn zoom_frame(s: &mut Session, dir: i64) -> Result<Value> {
|
|
let ds = s.ds()?;
|
|
let frames = ds.doc.top_frames(ds.page);
|
|
if frames.is_empty() {
|
|
return ok();
|
|
}
|
|
let cur = ds.selection.first().and_then(|i| ds.doc.top_level_frame(*i)).and_then(|f| frames.iter().position(|x| *x == f));
|
|
let n = frames.len() as i64;
|
|
let next = match cur {
|
|
Some(c) => (c as i64 + dir).rem_euclid(n) as usize,
|
|
None => 0,
|
|
};
|
|
let Some(f) = frames.get(next).copied() else { return ok() };
|
|
let b = ds.doc.world_bounds(f).unwrap_or_default();
|
|
let ds = s.ds_mut()?;
|
|
ds.selection = vec![f];
|
|
ds.view.fit(b, 256.0);
|
|
Ok(json!({"frame": f}))
|
|
}
|
|
|
|
fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
|
|
let ds = s.ds()?;
|
|
let pages = &ds.doc.pages;
|
|
let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64;
|
|
let n = pages.len() as i64;
|
|
let next = (i + dir).clamp(0, n - 1) as usize;
|
|
let Some(pg) = pages.get(next).copied() else { return ok() };
|
|
let ds = s.ds_mut()?;
|
|
ds.page = pg;
|
|
ds.selection.clear();
|
|
ds.zoom_to_fit();
|
|
Ok(json!({"page": pg}))
|
|
}
|
|
|
|
fn ui(s: &mut Session, kind: &str) -> Result<Value> {
|
|
s.request_ui(kind, json!({}));
|
|
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);
|
|
Ok(json!({"on": *slot}))
|
|
}
|
|
|
|
fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
|
s.set_tool(t);
|
|
Ok(json!({"tool": t.id()}))
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
spec!("view.zoomIn", "Zoom in", "{}", false, |s, _| {
|
|
let z = view(s)?.zoom;
|
|
zoom_to(s, step(z, true))
|
|
}),
|
|
spec!("view.zoomOut", "Zoom out", "{}", false, |s, _| {
|
|
let z = view(s)?.zoom;
|
|
zoom_to(s, step(z, false))
|
|
}),
|
|
spec!("view.zoom100", "Zoom to 100%", "{}", false, |s, _| zoom_to(s, 1.0)),
|
|
spec!("view.zoom50", "Zoom to 50%", "{}", false, |s, _| zoom_to(s, 0.5)),
|
|
spec!("view.zoom200", "Zoom to 200%", "{}", false, |s, _| zoom_to(s, 2.0)),
|
|
spec!("view.zoomFit", "Zoom to fit", "{}", false, |s, _| {
|
|
let ds = s.ds_mut()?;
|
|
if let Some(b) = ds.doc.page_content_bounds(ds.page) {
|
|
ds.view.fit(b, 256.0);
|
|
}
|
|
Ok(json!({"zoom": ds.view.zoom}))
|
|
}),
|
|
spec!("view.zoomSelection", "Zoom to selection", "{}", false, |s, _| {
|
|
let ds = s.ds_mut()?;
|
|
if let Some(b) = ds.doc.bounds_of(&ds.selection) {
|
|
ds.view.fit(b, 256.0);
|
|
}
|
|
Ok(json!({"zoom": ds.view.zoom}))
|
|
}),
|
|
spec!("view.set", "Set view", "{zoom?, cx?, cy? (page coordinate at the centre), vw?, vh? (viewport size)}", false, |s, p| {
|
|
let v = view(s)?;
|
|
if let Some(z) = num(p, "zoom") {
|
|
v.zoom = z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM);
|
|
}
|
|
v.cx = num_or(p, "cx", v.cx);
|
|
v.cy = num_or(p, "cy", v.cy);
|
|
if let Some(w) = num(p, "vw") {
|
|
v.vw = w.clamp(10.0, 20000.0);
|
|
}
|
|
if let Some(h) = num(p, "vh") {
|
|
v.vh = h.clamp(10.0, 20000.0);
|
|
}
|
|
Ok(serde_json::to_value(&*v).unwrap_or(Value::Null))
|
|
}),
|
|
spec!(
|
|
"view.zoomAt",
|
|
"Zoom tool",
|
|
"{x, y (page point kept in place), out? (zoom out a step), width?, height? (an area to fit instead)}",
|
|
false,
|
|
zoom_at
|
|
),
|
|
spec!("view.get", "Get view", "{}", false, |s, _| Ok(serde_json::to_value(&s.ds()?.view).unwrap_or(Value::Null))),
|
|
spec!("view.prevFrame", "Zoom to previous frame", "{}", false, |s, _| zoom_frame(s, -1)),
|
|
spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)),
|
|
spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)),
|
|
spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)),
|
|
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)),
|
|
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)),
|
|
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)),
|
|
spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)),
|
|
spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)),
|
|
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)),
|
|
spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview),
|
|
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)),
|
|
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)),
|
|
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)),
|
|
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)),
|
|
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
|
|
spec!("view.layers", "Layers panel", "{}", false, |s, _| ui(s, "panel.layers")),
|
|
spec!("view.assets", "Assets panel", "{}", false, |s, _| ui(s, "panel.assets")),
|
|
spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")),
|
|
spec!("view.prototype", "Prototype panel", "{}", false, |s, _| ui(s, "panel.prototype")),
|
|
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()
|
|
}),
|
|
spec!("view.themeDark", "Dark theme", "{}", false, |s, _| {
|
|
s.prefs.theme = "dark".into();
|
|
ok()
|
|
}),
|
|
spec!("view.themeSystem", "Follow system theme", "{}", false, |s, _| {
|
|
s.prefs.theme = "system".into();
|
|
ok()
|
|
}),
|
|
spec!("pref.keepTool", "Keep tool selected after use", "{on?}", false, |s, p| pref(s, p, |x| &mut x.keep_tool)),
|
|
spec!("pref.highlightHover", "Highlight layers on hover", "{on?}", false, |s, p| pref(s, p, |x| &mut x.highlight_on_hover)),
|
|
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);
|
|
Ok(json!({"small": s.prefs.nudge_small, "big": s.prefs.nudge_big}))
|
|
}),
|
|
spec!("pref.userName", "Your name in comments", "{name (blank: the default, \"You\")}", false, |s, p| {
|
|
let name = req_string("pref.userName", p, "name")?;
|
|
let name: String = name.trim().chars().filter(|c| !c.is_control()).take(64).collect();
|
|
s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name };
|
|
Ok(json!({"name": s.prefs.user_name}))
|
|
}),
|
|
spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")),
|
|
spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")),
|
|
spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")),
|
|
spec!("help.about", "About FigCraft", "{}", false, |s, _| ui(s, "dialog.about")),
|
|
spec!("help.page", "Help page", "{}", false, |s, _| ui(s, "open.help")),
|
|
spec!("libraries.open", "Libraries", "{}", false, |s, _| ui(s, "panel.libraries")),
|
|
spec!(
|
|
"tool.set",
|
|
"Choose tool",
|
|
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder}",
|
|
false,
|
|
|s, p| {
|
|
let t = req_string("tool.set", p, "tool")?;
|
|
let t = ToolId::from_id(&t).ok_or_else(|| bad("tool.set", format!("unknown tool `{t}`")))?;
|
|
tool(s, t)
|
|
}
|
|
),
|
|
spec!(
|
|
"tool.scaleAnchor",
|
|
"Scale anchor",
|
|
"{anchor?: tl|t|tr|l|c|r|bl|b|br} (the point of the selection that stays put when the Scale tool scales; omit to read it)",
|
|
false,
|
|
|s, p| {
|
|
if let Some(a) = string(p, "anchor") {
|
|
s.tool.scale_anchor = crate::tools::anchor_of(&a).ok_or_else(|| bad("tool.scaleAnchor", format!("unknown anchor `{a}`")))?;
|
|
if let Ok(ds) = s.ds_mut() {
|
|
ds.revision += 1;
|
|
}
|
|
}
|
|
Ok(json!({"anchor": crate::tools::anchor_name(s.tool.scale_anchor)}))
|
|
}
|
|
),
|
|
spec!("tool.move", "Move", "{}", false, |s, _| tool(s, ToolId::Move)),
|
|
spec!("tool.scale", "Scale", "{}", false, |s, _| tool(s, ToolId::Scale)),
|
|
spec!("tool.hand", "Hand", "{}", false, |s, _| tool(s, ToolId::Hand)),
|
|
spec!("tool.frame", "Frame", "{}", false, |s, _| tool(s, ToolId::Frame)),
|
|
spec!("tool.section", "Section", "{}", false, |s, _| tool(s, ToolId::Section)),
|
|
spec!("tool.slice", "Slice", "{}", false, |s, _| tool(s, ToolId::Slice)),
|
|
spec!("tool.rectangle", "Rectangle", "{}", false, |s, _| tool(s, ToolId::Rectangle)),
|
|
spec!("tool.line", "Line", "{}", false, |s, _| tool(s, ToolId::Line)),
|
|
spec!("tool.arrow", "Arrow", "{}", false, |s, _| tool(s, ToolId::Arrow)),
|
|
spec!("tool.ellipse", "Ellipse", "{}", false, |s, _| tool(s, ToolId::Ellipse)),
|
|
spec!("tool.polygon", "Polygon", "{}", false, |s, _| tool(s, ToolId::Polygon)),
|
|
spec!("tool.star", "Star", "{}", false, |s, _| tool(s, ToolId::Star)),
|
|
spec!("tool.pen", "Pen", "{}", false, |s, _| tool(s, ToolId::Pen)),
|
|
spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)),
|
|
spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)),
|
|
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
|
|
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
|
|
spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)),
|
|
]
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn unused(_: NodeId) {}
|