Relocate the widget facade (widgets/, including the Widget trait,
render primitives, the editor-UI compositions, the CanvasViewport
center canvas, the lucide icon drawer, and editor_state_ext), the
theme tokens, the layout-resolved render scene (layout_scene +
layout_scene_hit), and the design-variable aggregation (scene_vars)
out of openpencil-shell-core into a dedicated op-editor-ui crate.
The canvas widgets (canvas_viewport*) stay inside op-editor-ui rather
than splitting into a separate op-canvas crate: they depend on the
widgets/ siblings editor_state_ext + icons and on the Widget trait, so
a clean mechanical split is not possible — per the task's explicit
allowance not to force a fragile split.
op-editor-ui's lib.rs mirrors the old shell-core crate-root re-exports
(render_backend facade types + jian gesture types + the i18n alias) so
every intra-module `crate::Color` / `crate::theme` / `crate::widgets`
path resolves unchanged — a pure relocation with no path rewrites
inside the moved modules. openpencil-shell-core becomes a thin
re-export shim (`pub use op_editor_ui::{widgets, theme, ...}`) so the
hosts keep resolving `openpencil_shell_core::*` until the Task 7.3
host rename dissolves the crate. The widgets_static integration test
moves to op-editor-ui/tests with its imports rewritten. The widget
boundary script's reverse-check path is updated to the new crate.
No behaviour change; all tests move with their code.
511 lines
16 KiB
Rust
511 lines
16 KiB
Rust
//! Phase B1 + B2 widget facade smoke tests.
|
|
//!
|
|
//! B1 piece: proves the `Widget` trait + `PaintCx` / `LayoutCx` shape
|
|
//! compiles + that a recording backend plugs in via `&mut dyn
|
|
//! RenderBackend`. B2 piece: proves the four inspector widgets (Tree /
|
|
//! PropertyRow / Dropdown / TextInput) paint and emit accesskit nodes
|
|
//! with the expected semantic roles.
|
|
|
|
use op_editor_ui::widgets::{
|
|
rect, Dropdown, DropdownState, LayoutBox, LayoutCx, PaintCx, PropertyRow, TextInput,
|
|
TextInputState, TreeWidget, Widget, WidgetId, ROOT_WIDGET_ID,
|
|
};
|
|
use op_editor_ui::{
|
|
Color, ImeEvent, ImeKind, KeyCode, KeyEvent, KeyLocation, KeyState, KeyValue, Modifiers,
|
|
NamedKey, Point2D, Rect, RenderBackend, TextLayout,
|
|
};
|
|
|
|
#[derive(Default)]
|
|
struct RecordingBackend {
|
|
rects: usize,
|
|
strokes: usize,
|
|
text: usize,
|
|
saves: usize,
|
|
restores: usize,
|
|
clips: usize,
|
|
translates: usize,
|
|
}
|
|
|
|
impl RenderBackend for RecordingBackend {
|
|
fn begin_frame(&mut self) {}
|
|
fn end_frame(&mut self) {}
|
|
fn fill_rect(&mut self, _rect: Rect, _color: Color) {
|
|
self.rects += 1;
|
|
}
|
|
fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {
|
|
self.strokes += 1;
|
|
}
|
|
fn draw_text(&mut self, _layout: &TextLayout, _origin: Point2D) {
|
|
self.text += 1;
|
|
}
|
|
fn clip_rect(&mut self, _rect: Rect) {
|
|
self.clips += 1;
|
|
}
|
|
fn save(&mut self) {
|
|
self.saves += 1;
|
|
}
|
|
fn restore(&mut self) {
|
|
self.restores += 1;
|
|
}
|
|
fn translate(&mut self, _offset: Point2D) {
|
|
self.translates += 1;
|
|
}
|
|
fn stroke_line(&mut self, _from: Point2D, _to: Point2D, _color: Color, _width: f32) {
|
|
// Counted alongside strokes for the existing test asserts;
|
|
// a separate counter is unnecessary for Step 4 visual lift.
|
|
self.strokes += 1;
|
|
}
|
|
fn fill_round_rect(&mut self, _rect: Rect, _radius: f32, _color: Color) {
|
|
self.rects += 1;
|
|
}
|
|
fn stroke_round_rect(&mut self, _rect: Rect, _radius: f32, _color: Color, _width: f32) {
|
|
self.strokes += 1;
|
|
}
|
|
fn stroke_svg_path(
|
|
&mut self,
|
|
_d: &str,
|
|
_top_left: Point2D,
|
|
_size: f32,
|
|
_color: Color,
|
|
_width: f32,
|
|
) {
|
|
self.strokes += 1;
|
|
}
|
|
fn resize(&mut self, _width: u32, _height: u32) {}
|
|
fn dpi_scale(&self) -> f32 {
|
|
1.0
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn paint_cx_dispatches_through_dyn_backend() {
|
|
let mut backend = RecordingBackend::default();
|
|
{
|
|
let cx = PaintCx {
|
|
backend: &mut backend,
|
|
};
|
|
cx.backend.fill_rect(rect(0.0, 0.0, 10.0, 10.0), Color::RED);
|
|
cx.backend
|
|
.stroke_rect(rect(1.0, 2.0, 8.0, 8.0), Color::WHITE, 1.5);
|
|
cx.backend.save();
|
|
cx.backend.translate(Point2D::new(2.0, 3.0));
|
|
cx.backend.clip_rect(rect(0.0, 0.0, 4.0, 4.0));
|
|
cx.backend.restore();
|
|
}
|
|
assert_eq!(backend.rects, 1, "fill_rect dispatch");
|
|
assert_eq!(backend.strokes, 1, "stroke_rect dispatch");
|
|
assert_eq!(backend.saves, 1, "save dispatch");
|
|
assert_eq!(backend.restores, 1, "restore dispatch");
|
|
assert_eq!(backend.translates, 1, "translate dispatch");
|
|
assert_eq!(backend.clips, 1, "clip_rect dispatch");
|
|
}
|
|
|
|
/// A trivial widget impl proves the trait shape. Uses
|
|
/// `accesskit::Role::GenericContainer` (canonical "intentional placeholder",
|
|
/// ARIA `none`/`presentation`) so the test stays stable across unrelated
|
|
/// accesskit version bumps; B2 widgets use semantic roles.
|
|
struct StubWidget {
|
|
id: WidgetId,
|
|
box_rect: Rect,
|
|
}
|
|
|
|
impl Widget for StubWidget {
|
|
fn id(&self) -> WidgetId {
|
|
self.id
|
|
}
|
|
fn layout(&self, _cx: &LayoutCx) -> LayoutBox {
|
|
LayoutBox {
|
|
rect: self.box_rect,
|
|
}
|
|
}
|
|
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
|
|
cx.backend.fill_rect(rect, Color::WHITE);
|
|
}
|
|
fn access_node(&self) -> accesskit::Node {
|
|
// `GenericContainer` (ARIA `none` / `presentation`) is the canonical
|
|
// "intentional placeholder" role. Real B2 widgets use semantic
|
|
// roles (TreeItem / EditableText / etc); see codex B1 review NIT-5.
|
|
accesskit::Node::new(accesskit::Role::GenericContainer)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn widget_trait_dispatches_layout_and_paint() {
|
|
let widget = StubWidget {
|
|
id: WidgetId::new(7),
|
|
box_rect: rect(0.0, 0.0, 100.0, 24.0),
|
|
};
|
|
let layout_cx = LayoutCx {
|
|
available_width: 320.0,
|
|
dpi: 1.0,
|
|
};
|
|
let layout = widget.layout(&layout_cx);
|
|
assert_eq!(layout.rect.size.x, 100.0);
|
|
|
|
let mut backend = RecordingBackend::default();
|
|
{
|
|
let mut paint_cx = PaintCx {
|
|
backend: &mut backend,
|
|
};
|
|
widget.paint(&mut paint_cx, layout.rect);
|
|
}
|
|
assert_eq!(backend.rects, 1);
|
|
assert_eq!(widget.id(), WidgetId::new(7));
|
|
// Sanity check the root id constant survives codegen.
|
|
assert_eq!(ROOT_WIDGET_ID.0, 0);
|
|
|
|
// Trait surface check: access_node returns the placeholder
|
|
// `Role::GenericContainer` advertised by `StubWidget`. Real B2 widgets
|
|
// assert their semantic roles in the tests below.
|
|
let node = widget.access_node();
|
|
assert_eq!(node.role(), accesskit::Role::GenericContainer);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// B2: four inspector widgets paint static content + expose semantic
|
|
// accesskit roles.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn four_inspector_widgets_paint_static_content() {
|
|
let layout = LayoutCx {
|
|
available_width: 240.0,
|
|
dpi: 1.0,
|
|
};
|
|
let widgets: Vec<Box<dyn Widget>> = vec![
|
|
Box::new(TreeWidget::sample()),
|
|
Box::new(PropertyRow::new(200, "Width", "960")),
|
|
Box::new(Dropdown::sample()),
|
|
Box::new(TextInput::sample()),
|
|
];
|
|
let mut backend = RecordingBackend::default();
|
|
for widget in widgets {
|
|
let box_ = widget.layout(&layout);
|
|
let mut cx = PaintCx {
|
|
backend: &mut backend,
|
|
};
|
|
widget.paint(&mut cx, box_.rect);
|
|
}
|
|
|
|
// Each widget paints at least its background fill (4); Tree adds one
|
|
// more for the selected row → ≥ 5. Stroked rects: PropertyRow,
|
|
// Dropdown, TextInput each stroke their border (3). Text runs:
|
|
// PropertyRow has 2 (label+value); Tree has 3 items; Dropdown has 1;
|
|
// TextInput has 1 → ≥ 7 total.
|
|
assert!(
|
|
backend.rects >= 5,
|
|
"fill_rect dispatch ≥ 5 (got {})",
|
|
backend.rects
|
|
);
|
|
assert!(
|
|
backend.strokes >= 3,
|
|
"stroke_rect dispatch ≥ 3 (got {})",
|
|
backend.strokes
|
|
);
|
|
assert!(
|
|
backend.text >= 7,
|
|
"draw_text dispatch ≥ 7 (got {})",
|
|
backend.text
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tree_widget_advertises_tree_role_and_layers_label() {
|
|
let tree = TreeWidget::sample();
|
|
let node = tree.access_node();
|
|
assert_eq!(node.role(), accesskit::Role::Tree);
|
|
// accesskit::Node exposes label() returning Option<&str> in 0.24.
|
|
assert_eq!(node.label(), Some("Layers"));
|
|
// Sample tree has 3 items.
|
|
assert_eq!(tree.items.len(), 3);
|
|
assert!(tree.items.iter().any(|item| item.selected));
|
|
}
|
|
|
|
#[test]
|
|
fn property_row_advertises_label_and_value() {
|
|
let row = PropertyRow::new(201, "Width", "960");
|
|
let node = row.access_node();
|
|
// `Role::Group` (not GenericContainer) so the label survives ARIA
|
|
// filtering — see codex B2 R1 CONCERN + the fix in prop_row.rs.
|
|
assert_eq!(node.role(), accesskit::Role::Group);
|
|
assert_eq!(node.label(), Some("Width 960"));
|
|
}
|
|
|
|
#[test]
|
|
fn dropdown_advertises_combobox_role() {
|
|
let drop = Dropdown::sample();
|
|
let node = drop.access_node();
|
|
assert_eq!(node.role(), accesskit::Role::ComboBox);
|
|
assert_eq!(node.label(), Some("Blend"));
|
|
// Sample preserves the closed/first-selected state.
|
|
assert_eq!(drop.state.selected, 0);
|
|
assert!(!drop.state.open);
|
|
}
|
|
|
|
#[test]
|
|
fn text_input_advertises_text_input_role_and_value() {
|
|
let input = TextInput::sample();
|
|
let node = input.access_node();
|
|
assert_eq!(node.role(), accesskit::Role::TextInput);
|
|
assert_eq!(node.label(), Some("Name"));
|
|
assert_eq!(node.value(), Some("Frame 1"));
|
|
}
|
|
|
|
#[test]
|
|
fn text_input_paints_preedit_underline_when_composing() {
|
|
// The preedit-underline painting branch only fires when
|
|
// `state.preedit` is non-empty; verify it via the recording backend.
|
|
let mut input = TextInput::sample();
|
|
input.state.preedit = "你好".to_string();
|
|
let layout_cx = LayoutCx {
|
|
available_width: 240.0,
|
|
dpi: 1.0,
|
|
};
|
|
let layout = input.layout(&layout_cx);
|
|
let mut backend = RecordingBackend::default();
|
|
{
|
|
let mut cx = PaintCx {
|
|
backend: &mut backend,
|
|
};
|
|
input.paint(&mut cx, layout.rect);
|
|
}
|
|
// 1 fill (background) + 2 strokes (border + preedit underline) +
|
|
// 1 text run (preedit content).
|
|
assert_eq!(backend.rects, 1);
|
|
assert_eq!(backend.strokes, 2);
|
|
assert_eq!(backend.text, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn dropdown_state_independent_state_struct() {
|
|
// DropdownState lives on its own so input handling can swap it
|
|
// without taking ownership of the surrounding Dropdown widget.
|
|
let s = DropdownState {
|
|
selected: 2,
|
|
open: true,
|
|
};
|
|
assert_eq!(s.selected, 2);
|
|
assert!(s.open);
|
|
}
|
|
|
|
#[test]
|
|
fn text_input_state_default_is_empty() {
|
|
let s = TextInputState::default();
|
|
assert_eq!(s.value, "");
|
|
assert_eq!(s.preedit, "");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// C2 widget event handlers (apply_ime / apply_key)
|
|
// ---------------------------------------------------------------------
|
|
|
|
fn keydown(named: NamedKey) -> KeyEvent {
|
|
KeyEvent {
|
|
key: KeyValue::Named(named),
|
|
code: KeyCode::Unknown(String::new()),
|
|
location: KeyLocation::Standard,
|
|
modifiers: Modifiers::empty(),
|
|
state: KeyState::Pressed,
|
|
repeat: false,
|
|
is_composing: false,
|
|
}
|
|
}
|
|
|
|
fn keyup(named: NamedKey) -> KeyEvent {
|
|
KeyEvent {
|
|
state: KeyState::Released,
|
|
..keydown(named)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn text_input_apply_ime_start_clears_preedit() {
|
|
let mut state = TextInputState {
|
|
value: "Frame 1".into(),
|
|
preedit: "stale".into(),
|
|
};
|
|
state.apply_ime(&ImeEvent {
|
|
kind: ImeKind::CompositionStart,
|
|
text: String::new(),
|
|
});
|
|
assert_eq!(state.preedit, "");
|
|
// CompositionStart must NOT touch the committed value.
|
|
assert_eq!(state.value, "Frame 1");
|
|
}
|
|
|
|
#[test]
|
|
fn text_input_apply_ime_update_replaces_preedit() {
|
|
let mut state = TextInputState {
|
|
value: "Frame ".into(),
|
|
preedit: String::new(),
|
|
};
|
|
state.apply_ime(&ImeEvent {
|
|
kind: ImeKind::CompositionUpdate { selection: None },
|
|
text: "你".into(),
|
|
});
|
|
assert_eq!(state.preedit, "你");
|
|
state.apply_ime(&ImeEvent {
|
|
kind: ImeKind::CompositionUpdate { selection: None },
|
|
text: "你好".into(),
|
|
});
|
|
assert_eq!(state.preedit, "你好");
|
|
// Update path NEVER mutates value.
|
|
assert_eq!(state.value, "Frame ");
|
|
}
|
|
|
|
#[test]
|
|
fn text_input_apply_ime_end_appends_to_value_and_clears_preedit() {
|
|
let mut state = TextInputState {
|
|
value: "Frame ".into(),
|
|
preedit: "你好".into(),
|
|
};
|
|
state.apply_ime(&ImeEvent {
|
|
kind: ImeKind::CompositionEnd,
|
|
text: "你好".into(),
|
|
});
|
|
assert_eq!(state.value, "Frame 你好");
|
|
assert_eq!(state.preedit, "");
|
|
}
|
|
|
|
#[test]
|
|
fn text_input_apply_ime_double_start_clears_preedit_each_time() {
|
|
// Pathological host state machine: a CompositionStart followed by
|
|
// another CompositionStart without an intervening End. Each Start
|
|
// unconditionally resets preedit; the committed value never moves.
|
|
// (Codex C2.1 R1 CONCERN-1.)
|
|
let mut state = TextInputState {
|
|
value: "Frame ".into(),
|
|
preedit: "old".into(),
|
|
};
|
|
state.apply_ime(&ImeEvent {
|
|
kind: ImeKind::CompositionStart,
|
|
text: String::new(),
|
|
});
|
|
assert_eq!(state.preedit, "");
|
|
state.preedit = "leftover".into();
|
|
state.apply_ime(&ImeEvent {
|
|
kind: ImeKind::CompositionStart,
|
|
text: String::new(),
|
|
});
|
|
assert_eq!(state.preedit, "");
|
|
assert_eq!(state.value, "Frame ");
|
|
}
|
|
|
|
#[test]
|
|
fn dropdown_apply_key_arrow_down_advances_and_opens() {
|
|
let mut state = DropdownState {
|
|
selected: 0,
|
|
open: false,
|
|
};
|
|
state.apply_key(&keydown(NamedKey::ArrowDown), 3);
|
|
assert_eq!(state.selected, 1);
|
|
assert!(state.open);
|
|
state.apply_key(&keydown(NamedKey::ArrowDown), 3);
|
|
assert_eq!(state.selected, 2);
|
|
// Saturates at option_count - 1; does not wrap.
|
|
state.apply_key(&keydown(NamedKey::ArrowDown), 3);
|
|
assert_eq!(state.selected, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn dropdown_apply_key_arrow_up_retreats_with_saturating_sub() {
|
|
let mut state = DropdownState {
|
|
selected: 1,
|
|
open: false,
|
|
};
|
|
state.apply_key(&keydown(NamedKey::ArrowUp), 3);
|
|
assert_eq!(state.selected, 0);
|
|
assert!(state.open);
|
|
// Already at 0 — saturating_sub holds at 0, no panic.
|
|
state.apply_key(&keydown(NamedKey::ArrowUp), 3);
|
|
assert_eq!(state.selected, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn dropdown_apply_key_enter_and_escape_close() {
|
|
let mut state = DropdownState {
|
|
selected: 1,
|
|
open: true,
|
|
};
|
|
state.apply_key(&keydown(NamedKey::Enter), 3);
|
|
assert!(!state.open);
|
|
assert_eq!(state.selected, 1);
|
|
|
|
state.open = true;
|
|
state.apply_key(&keydown(NamedKey::Escape), 3);
|
|
assert!(!state.open);
|
|
// Escape closes the menu but does NOT mutate selection — confirm
|
|
// both halves of the close path leave selected stable (codex C2.1
|
|
// R1 CONCERN-2).
|
|
assert_eq!(state.selected, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn dropdown_apply_key_ignores_keyup() {
|
|
let mut state = DropdownState {
|
|
selected: 1,
|
|
open: false,
|
|
};
|
|
state.apply_key(&keyup(NamedKey::ArrowDown), 3);
|
|
// Released event is a no-op.
|
|
assert_eq!(state.selected, 1);
|
|
assert!(!state.open);
|
|
}
|
|
|
|
#[test]
|
|
fn dropdown_apply_key_ignores_zero_options() {
|
|
let mut state = DropdownState {
|
|
selected: 0,
|
|
open: false,
|
|
};
|
|
state.apply_key(&keydown(NamedKey::ArrowDown), 0);
|
|
// No options → no-op rather than panic on `option_count - 1`.
|
|
assert_eq!(state.selected, 0);
|
|
assert!(!state.open);
|
|
}
|
|
|
|
#[test]
|
|
fn dropdown_apply_key_ignores_unrelated_keys() {
|
|
let mut state = DropdownState {
|
|
selected: 1,
|
|
open: true,
|
|
};
|
|
state.apply_key(&keydown(NamedKey::Tab), 3);
|
|
// Tab is not bound; state should be unchanged.
|
|
assert_eq!(state.selected, 1);
|
|
assert!(state.open);
|
|
}
|
|
|
|
#[test]
|
|
fn dropdown_apply_key_ignores_keys_during_ime_composition() {
|
|
// Spec §2.4 widget rule: skip key dispatch while is_composing is
|
|
// true. ArrowDown during a CJK composition belongs to the IME's
|
|
// candidate-picker UI, NOT to the dropdown selection cycle.
|
|
// Codex Phase C stop-hook (#2): "focused IME textarea lets
|
|
// composing keys mutate dropdown state."
|
|
let mut state = DropdownState {
|
|
selected: 0,
|
|
open: false,
|
|
};
|
|
let composing_arrow = KeyEvent {
|
|
is_composing: true,
|
|
..keydown(NamedKey::ArrowDown)
|
|
};
|
|
state.apply_key(&composing_arrow, 3);
|
|
assert_eq!(state.selected, 0, "composing ArrowDown must not advance");
|
|
assert!(!state.open, "composing ArrowDown must not open");
|
|
|
|
// Same protection for Enter / Escape — IME 'commit' shouldn't
|
|
// close the dropdown.
|
|
let mut state2 = DropdownState {
|
|
selected: 1,
|
|
open: true,
|
|
};
|
|
let composing_enter = KeyEvent {
|
|
is_composing: true,
|
|
..keydown(NamedKey::Enter)
|
|
};
|
|
state2.apply_key(&composing_enter, 3);
|
|
assert!(state2.open, "composing Enter must not close");
|
|
}
|