openpencil/crates/op-editor-ui/src/accessibility.rs
Kayshen-X 0104c8bd61 feat(editor): a11y + single-instance + layer-panel Rust↔TS parity
Close the non-web-productionization gaps from the 2026-06-18 recheck:

- a11y (#67/#57): assemble each widget's access_node() into an
  accesskit::TreeUpdate (op-editor-ui/accessibility.rs); publish on
  desktop via accesskit_macos/_windows/_unix SubclassingAdapter off the
  raw window handle (op-host-desktop/a11y.rs, NOT accesskit_winit — the
  casement winit fork), and on web via a hidden ARIA DOM mirror
  (op-host-web/a11y_dom.rs); native/web region enumeration + action
  routing in op-host-{native,web}.
- single-instance (#51): fixed-loopback-port guard + second-launch file
  forwarding to the running window (op-host-desktop/single_instance.rs).
- layer panel: drop-into-container inserts at index 0 (#12a); container
  predicate widened to frame/group/rectangle/ref (#12b); Escape closes
  the context menu on both hosts (#14).
- distribution: Homebrew cask fix + op formula + install-op.sh, README
  CLI install, CI codesign/notarize/signtool scaffolding gated on secrets.

Codex-reviewed (1 BLOCKER + 3 CONCERNs resolved). main.rs, app_handler.rs,
canvaskit.rs and Cargo.lock are staged whole and carry some unrelated
in-progress WIP they're interleaved with.
2026-06-18 22:18:07 +08:00

295 lines
11 KiB
Rust

//! Platform-free accessibility-tree assembler (#67).
//!
//! Every [`Widget`](crate::widgets::Widget) already exposes an
//! `accesskit::Node` via `Widget::access_node()`, but nothing assembled
//! those per-widget nodes into a single `accesskit::TreeUpdate` for an
//! OS accessibility adapter to consume — so the whole editor read as one
//! opaque canvas to VoiceOver / Narrator / Orca.
//!
//! This module is that missing seam. It is deliberately platform-free
//! (no winit / no skia / no platform adapter): it depends only on
//! `accesskit` + the widget facade, so it stays wasm32-clean and is
//! shared verbatim by the native + web hosts. Hosts collect the same
//! ordered set of top-level widgets they paint, pair each with the
//! screen-space rect they placed it at, and hand the slice here.
//!
//! ## Why the host supplies bounds
//!
//! `Widget::access_node()` sets a node's `role` + `label` but NOT its
//! bounds — a widget paints relative to a host-provided rect and has no
//! standing knowledge of where the host placed it. So the assembler
//! takes a `(rect, node)` pairing per widget and injects the rect as the
//! node's `bounds`. The host is the single source of truth for layout
//! (it reuses the very same enumeration its paint pass walks), so the
//! a11y tree and the painted frame never drift.
//!
//! ## NodeId convention
//!
//! `accesskit::NodeId(widget_id.0)` — the documented mapping on
//! [`WidgetId`](crate::widgets::WidgetId). The root host frame uses
//! [`ROOT_WIDGET_ID`](crate::widgets::ROOT_WIDGET_ID) (`WidgetId(0)`).
use crate::widgets::{Widget, WidgetId, ROOT_WIDGET_ID};
use crate::Rect;
/// A top-level widget placed at a host-resolved screen rect.
///
/// Hosts build one of these per region they paint (top bar, toolbar,
/// layer panel, canvas, property panel, chat, status bar, plus any open
/// overlays). The assembler reads `widget.id()` + `widget.access_node()`
/// and stamps `bounds` from `rect`.
pub struct PlacedWidget<'a> {
/// The widget — read for its stable id + its `access_node()`.
pub widget: &'a dyn Widget,
/// Screen-space rectangle the host painted the widget into.
pub bounds: Rect,
}
impl<'a> PlacedWidget<'a> {
/// Convenience constructor.
pub fn new(widget: &'a dyn Widget, bounds: Rect) -> Self {
Self { widget, bounds }
}
}
/// Map a `WidgetId` to its `accesskit::NodeId` per the documented
/// convention (`NodeId(WidgetId.0)`).
#[inline]
pub fn node_id(id: WidgetId) -> accesskit::NodeId {
accesskit::NodeId(id.0)
}
/// Convert a facade [`Rect`] (origin + size, `f32`) into an
/// `accesskit::Rect` (min/max corners, `f64`). AccessKit bounds are in
/// the same screen-space coordinate system the host paints in.
#[inline]
fn to_accesskit_rect(r: Rect) -> accesskit::Rect {
let x0 = r.origin.x as f64;
let y0 = r.origin.y as f64;
accesskit::Rect {
x0,
y0,
x1: x0 + r.size.x as f64,
y1: y0 + r.size.y as f64,
}
}
/// Build the root host node — a `Window`-roled frame whose children are
/// the supplied widget node ids, sized to the window bounds.
fn build_root(window_bounds: Rect, children: &[accesskit::NodeId]) -> accesskit::Node {
let mut root = accesskit::Node::new(accesskit::Role::Window);
root.set_label("OpenPencil");
root.set_bounds(to_accesskit_rect(window_bounds));
root.set_children(children.to_vec());
root
}
/// Assemble a complete `accesskit::TreeUpdate` for the editor.
///
/// * `window_bounds` — the host window's screen rect (root node bounds).
/// * `widgets` — the ordered top-level widgets the host painted, each
/// paired with its placement rect. Order is preserved as the root's
/// child order (i.e. reading order for the screen reader).
/// * `focus` — the `WidgetId` that should hold keyboard focus. If it is
/// not present among `widgets` (or is the root), focus falls back to
/// the root node so the update is always self-consistent (accesskit
/// requires `focus` to name a node that exists in the tree).
///
/// The returned update carries the full tree (`tree: Some(..)`) so it is
/// valid as an initial publish; hosts may also re-send it on every dirty
/// frame (accesskit suppresses no-op events).
pub fn assemble_tree_update(
window_bounds: Rect,
widgets: &[PlacedWidget<'_>],
focus: WidgetId,
) -> accesskit::TreeUpdate {
let root_id = node_id(ROOT_WIDGET_ID);
// Child id list (preserves host paint / reading order).
let child_ids: Vec<accesskit::NodeId> =
widgets.iter().map(|w| node_id(w.widget.id())).collect();
let mut nodes: Vec<(accesskit::NodeId, accesskit::Node)> =
Vec::with_capacity(widgets.len() + 1);
nodes.push((root_id, build_root(window_bounds, &child_ids)));
for placed in widgets {
let id = node_id(placed.widget.id());
let mut node = placed.widget.access_node();
// The widget set role + label; the host owns layout, so the
// bounds come from where it was painted.
node.set_bounds(to_accesskit_rect(placed.bounds));
nodes.push((id, node));
}
// Resolve focus: it must name a real node in this update. A focus
// request for a widget the host did not include this frame (e.g. a
// closed overlay) degrades to the root rather than producing an
// invalid update.
let focus_id = node_id(focus);
let focus_present =
focus != ROOT_WIDGET_ID && widgets.iter().any(|w| node_id(w.widget.id()) == focus_id);
let focus = if focus_present { focus_id } else { root_id };
accesskit::TreeUpdate {
nodes,
tree: Some(accesskit::Tree::new(root_id)),
tree_id: accesskit::TreeId::ROOT,
focus,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::{LayoutBox, LayoutCx, PaintCx};
use crate::Rect;
/// Minimal fake widget so the assembler tests don't depend on any
/// real widget's construction surface.
struct FakeWidget {
id: WidgetId,
role: accesskit::Role,
label: &'static str,
}
impl Widget for FakeWidget {
fn id(&self) -> WidgetId {
self.id
}
fn layout(&self, _cx: &LayoutCx) -> LayoutBox {
LayoutBox {
rect: crate::widgets::rect(0.0, 0.0, 1.0, 1.0),
}
}
fn paint(&self, _cx: &mut PaintCx<'_>, _rect: Rect) {}
fn access_node(&self) -> accesskit::Node {
let mut node = accesskit::Node::new(self.role);
node.set_label(self.label);
node
}
}
fn placed(
id: u64,
role: accesskit::Role,
label: &'static str,
bounds: Rect,
) -> (FakeWidget, Rect) {
(
FakeWidget {
id: WidgetId::new(id),
role,
label,
},
bounds,
)
}
fn window() -> Rect {
crate::widgets::rect(0.0, 0.0, 1280.0, 800.0)
}
#[test]
fn root_node_is_a_window_with_window_bounds() {
let update = assemble_tree_update(window(), &[], ROOT_WIDGET_ID);
let (root_id, root) = &update.nodes[0];
assert_eq!(*root_id, node_id(ROOT_WIDGET_ID));
assert_eq!(root.role(), accesskit::Role::Window);
let b = root.bounds().expect("root has bounds");
assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 0.0, 1280.0, 800.0));
}
#[test]
fn child_count_matches_input_and_every_child_has_a_node() {
let (top, top_r) = placed(5000, accesskit::Role::Header, "Title bar", window());
let (tool, tool_r) = placed(
3000,
accesskit::Role::Toolbar,
"Toolbar",
crate::widgets::rect(8.0, 48.0, 44.0, 300.0),
);
let (canvas, canvas_r) = placed(
4000,
accesskit::Role::Canvas,
"Canvas",
crate::widgets::rect(0.0, 40.0, 1280.0, 760.0),
);
let widgets = [
PlacedWidget::new(&top, top_r),
PlacedWidget::new(&tool, tool_r),
PlacedWidget::new(&canvas, canvas_r),
];
let update = assemble_tree_update(window(), &widgets, WidgetId::new(4000));
// Root + 3 children.
assert_eq!(update.nodes.len(), 4);
// Root child list matches input order and length.
let (_, root) = &update.nodes[0];
let child_ids: Vec<_> = root.children().to_vec();
assert_eq!(
child_ids,
vec![
node_id(WidgetId::new(5000)),
node_id(WidgetId::new(3000)),
node_id(WidgetId::new(4000))
]
);
// Every advertised child id maps to a real node in `nodes`.
for child in &child_ids {
assert!(
update.nodes.iter().any(|(id, _)| id == child),
"child {child:?} has no node entry"
);
}
}
#[test]
fn each_child_node_carries_its_widget_role_label_and_host_bounds() {
let bounds = crate::widgets::rect(10.0, 20.0, 30.0, 40.0);
let (canvas, _) = placed(4000, accesskit::Role::Canvas, "Canvas", bounds);
let widgets = [PlacedWidget::new(&canvas, bounds)];
let update = assemble_tree_update(window(), &widgets, WidgetId::new(4000));
let (_, node) = update
.nodes
.iter()
.find(|(id, _)| *id == node_id(WidgetId::new(4000)))
.expect("canvas node present");
assert_eq!(node.role(), accesskit::Role::Canvas);
assert_eq!(node.label(), Some("Canvas"));
let b = node.bounds().expect("canvas has host bounds");
assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 20.0, 40.0, 60.0));
}
#[test]
fn focus_resolves_to_a_real_node_in_the_tree() {
let (canvas, r) = placed(4000, accesskit::Role::Canvas, "Canvas", window());
let widgets = [PlacedWidget::new(&canvas, r)];
let update = assemble_tree_update(window(), &widgets, WidgetId::new(4000));
assert_eq!(update.focus, node_id(WidgetId::new(4000)));
// The focus id must be one of the emitted nodes.
assert!(update.nodes.iter().any(|(id, _)| *id == update.focus));
}
#[test]
fn focus_on_absent_widget_degrades_to_root() {
let (canvas, r) = placed(4000, accesskit::Role::Canvas, "Canvas", window());
let widgets = [PlacedWidget::new(&canvas, r)];
// Ask focus for a widget that wasn't included this frame.
let update = assemble_tree_update(window(), &widgets, WidgetId::new(7000));
assert_eq!(update.focus, node_id(ROOT_WIDGET_ID));
assert!(update.nodes.iter().any(|(id, _)| *id == update.focus));
}
#[test]
fn tree_root_is_the_root_widget_id() {
let update = assemble_tree_update(window(), &[], ROOT_WIDGET_ID);
let tree = update.tree.expect("initial update carries tree");
assert_eq!(tree.root, node_id(ROOT_WIDGET_ID));
assert_eq!(update.tree_id, accesskit::TreeId::ROOT);
}
}