feat(shell-core): Phase B2 — four static inspector widgets
Lands the four Step 1b inspector widgets in shell-core (per spec
§1.4 — widget logic lives here so shell-native + shell-web reuse
it; only the RenderBackend impl + DOM event mapping + accesskit
DOM mirror are platform-owned).
Widgets:
- `widgets::TreeWidget` (Role::Tree, label "Layers") — sample
3-item tree (Frame / Title / Button) with selection-aware
blue-row paint and depth-indented labels. WidgetIds 100-103.
- `widgets::PropertyRow` (Role::Group, label "{label} {value}")
— single-row label/value pair. PropertyRow uses Role::Group
rather than Role::GenericContainer so the row label survives
ARIA filtering on the way to VoiceOver / NVDA (codex B2 R1
CONCERN). WidgetIds 200-299.
- `widgets::Dropdown` (Role::ComboBox, label "Blend") — sample
blend-mode picker with 3 options. Phase B static slice does
NOT yet pop a menu when `state.open == true`; Phase C wires
click + keyboard handling. WidgetIds 300-399.
- `widgets::TextInput` (Role::TextInput) — single-line input
with CJK IME preview. Paints `state.preedit` (in-progress
composition) when present, else `state.value`; non-empty
preedit also draws an 80px underline. Phase C lands
compositionstart / update / end → state mutation in shell-web.
WidgetIds 400-499.
State separation:
- `DropdownState { selected, open }` and `TextInputState { value,
preedit }` live as their own structs so Phase C event handlers
can swap them without taking ownership of the surrounding
widget. `TextInputState::default()` returns the empty state.
Tests (`tests/widgets_static.rs`):
- `four_inspector_widgets_paint_static_content` — paints all
four widgets through one RecordingBackend, asserts
≥5 fills / ≥3 strokes / ≥7 text dispatches (each tightened
vs the plan sketch to actually catch per-widget regressions).
- Per-widget role + label assertions (Tree / Group / ComboBox /
TextInput).
- `text_input_paints_preedit_underline_when_composing` —
drives `state.preedit = "你好"`, paints, asserts the IME
branch emits 1 fill + 2 strokes (border + underline) + 1
text run.
- `dropdown_state_independent_state_struct` +
`text_input_state_default_is_empty` — verify state structs
are independent + default-constructible.
Plan-vs-implementation deviations (deliberate):
- `WidgetId::new(N)` instead of the plan's `WidgetId(N)` tuple
literal so the sample/new constructors exercise the B1
debug_assert non-zero check. Tuple stays public for pattern
matching + `const` contexts.
- WidgetId range conventions per widget kind (Tree=100s /
PropertyRow=200s / Dropdown=300s / TextInput=400s) added as
doc-only comments. Real Phase C host will allocate from a
counter; the conventions just keep the B-phase fixtures
predictable.
- Per-widget paint counts in `four_inspector_widgets_…` test
tightened to ≥5/≥3/≥7 (plan sketch had ≥4/≥3/≥4 which
wouldn't catch a regression in Tree's selection-row fill).
Verification:
- `cargo test -p openpencil-shell-core` — 10/10 passing
- `cargo check -p openpencil-shell-core --target
wasm32-unknown-unknown` — green (shell-core stays
wasm32-clean per spec §1.2 — no platform deps creeping in)
- `cargo check -p openpencil-shell-native` — green
Codex iterate review: 2 rounds → GO.
This commit is contained in:
parent
b6efa324d1
commit
f214f4b4e3
81
crates/openpencil-shell-core/src/widgets/dropdown.rs
Normal file
81
crates/openpencil-shell-core/src/widgets/dropdown.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! `Dropdown` — Step 1b inspector blend-mode style picker.
|
||||
//!
|
||||
//! Phase B static slice: shows the currently-selected option only;
|
||||
//! `state.open == true` does not yet pop a menu (Phase C lands click +
|
||||
//! keyboard handling that toggles `open` and renders the menu).
|
||||
|
||||
use super::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
|
||||
use crate::{Color, Point2D, Rect, TextLayout};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DropdownState {
|
||||
pub selected: usize,
|
||||
pub open: bool,
|
||||
}
|
||||
|
||||
pub struct Dropdown {
|
||||
pub id: WidgetId,
|
||||
pub label: String,
|
||||
pub options: Vec<String>,
|
||||
pub state: DropdownState,
|
||||
}
|
||||
|
||||
impl Dropdown {
|
||||
/// Sample blend-mode dropdown. WidgetId range 300-399 is reserved
|
||||
/// for dropdowns by Step 1b convention.
|
||||
pub fn sample() -> Self {
|
||||
Self {
|
||||
id: WidgetId::new(300),
|
||||
label: "Blend".to_string(),
|
||||
options: vec![
|
||||
"Normal".to_string(),
|
||||
"Multiply".to_string(),
|
||||
"Screen".to_string(),
|
||||
],
|
||||
state: DropdownState {
|
||||
selected: 0,
|
||||
open: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Dropdown {
|
||||
fn id(&self) -> WidgetId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn layout(&self, cx: &LayoutCx) -> LayoutBox {
|
||||
LayoutBox {
|
||||
rect: Rect {
|
||||
origin: Point2D::new(0.0, 0.0),
|
||||
size: Point2D::new(cx.available_width, 34.0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
|
||||
cx.backend.fill_rect(rect, Color::WHITE);
|
||||
cx.backend.stroke_rect(rect, Color::BLACK, 1.0);
|
||||
let selected = self
|
||||
.options
|
||||
.get(self.state.selected)
|
||||
.map(String::as_str)
|
||||
.unwrap_or("");
|
||||
let text = TextLayout::single_run(
|
||||
selected,
|
||||
"system-ui",
|
||||
13.0,
|
||||
jian_core::scene::Color::rgb(20, 20, 20),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
cx.backend
|
||||
.draw_text(&text, rect.origin + Point2D::new(8.0, 21.0));
|
||||
}
|
||||
|
||||
fn access_node(&self) -> accesskit::Node {
|
||||
let mut node = accesskit::Node::new(accesskit::Role::ComboBox);
|
||||
node.set_label(self.label.clone());
|
||||
node
|
||||
}
|
||||
}
|
||||
|
|
@ -2,14 +2,23 @@
|
|||
//! both shell-native and shell-web reuse it; only RenderBackend / DOM event
|
||||
//! mapping / accesskit DOM mirror are platform-owned).
|
||||
//!
|
||||
//! B1 (this commit): the bare trait + paint / layout contexts + WidgetId.
|
||||
//! B2 lands the four static inspector widgets (Tree / PropertyRow /
|
||||
//! Dropdown / TextInput) under this module's child modules; they are
|
||||
//! declared in B2 — keep the surface here minimal until then so the crate
|
||||
//! compiles standalone after B1.
|
||||
//! B1: the bare trait + paint / layout contexts + WidgetId.
|
||||
//! B2 (this commit): four static inspector widgets — `TreeWidget` /
|
||||
//! `PropertyRow` / `Dropdown` / `TextInput` — each with a sample/new
|
||||
//! constructor, stable WidgetId range, layout/paint/access_node impl.
|
||||
|
||||
use crate::{Point2D, Rect, RenderBackend};
|
||||
|
||||
pub mod dropdown;
|
||||
pub mod prop_row;
|
||||
pub mod text_input;
|
||||
pub mod tree;
|
||||
|
||||
pub use dropdown::{Dropdown, DropdownState};
|
||||
pub use prop_row::PropertyRow;
|
||||
pub use text_input::{TextInput, TextInputState};
|
||||
pub use tree::{TreeItem, TreeWidget};
|
||||
|
||||
/// Stable identifier assigned by the widget host. Used by accesskit
|
||||
/// (`accesskit::NodeId(WidgetId.0)`), the DOM mirror, and event routing.
|
||||
///
|
||||
|
|
|
|||
76
crates/openpencil-shell-core/src/widgets/prop_row.rs
Normal file
76
crates/openpencil-shell-core/src/widgets/prop_row.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//! `PropertyRow` — Step 1b inspector widget showing a label/value pair.
|
||||
//!
|
||||
//! Phase B static slice: text content is owned by the widget and never
|
||||
//! mutates here; Phase C event handling will introduce a separate
|
||||
//! `*State` if the row gains an editable value.
|
||||
|
||||
use super::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
|
||||
use crate::{Color, Point2D, Rect, TextLayout};
|
||||
|
||||
pub struct PropertyRow {
|
||||
pub id: WidgetId,
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl PropertyRow {
|
||||
/// Construct a non-root property row. `id` MUST be non-zero (id 0 is
|
||||
/// reserved for the host root — see [`super::ROOT_WIDGET_ID`]); the
|
||||
/// [`super::WidgetId::new`] debug_assert enforces this in dev builds.
|
||||
pub fn new(id: u64, label: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: WidgetId::new(id),
|
||||
label: label.into(),
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for PropertyRow {
|
||||
fn id(&self) -> WidgetId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn layout(&self, cx: &LayoutCx) -> LayoutBox {
|
||||
LayoutBox {
|
||||
rect: Rect {
|
||||
origin: Point2D::new(0.0, 0.0),
|
||||
size: Point2D::new(cx.available_width, 32.0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
|
||||
cx.backend.fill_rect(rect, Color::WHITE);
|
||||
cx.backend.stroke_rect(rect, Color::BLACK, 1.0);
|
||||
let label = TextLayout::single_run(
|
||||
&self.label,
|
||||
"system-ui",
|
||||
13.0,
|
||||
jian_core::scene::Color::rgb(80, 80, 80),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
let value = TextLayout::single_run(
|
||||
&self.value,
|
||||
"system-ui",
|
||||
13.0,
|
||||
jian_core::scene::Color::rgb(20, 20, 20),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
cx.backend
|
||||
.draw_text(&label, rect.origin + Point2D::new(8.0, 20.0));
|
||||
cx.backend
|
||||
.draw_text(&value, rect.origin + Point2D::new(rect.size.x * 0.5, 20.0));
|
||||
}
|
||||
|
||||
fn access_node(&self) -> accesskit::Node {
|
||||
// `Role::Group` instead of `Role::GenericContainer`: GenericContainer
|
||||
// maps to ARIA `none`/`presentation` and is filtered out of platform
|
||||
// a11y trees, so the label would be dropped on the way to VoiceOver
|
||||
// / NVDA. `Group` is the canonical "labelled cluster of related
|
||||
// children" role and survives the filter (codex B2 R1 CONCERN).
|
||||
let mut node = accesskit::Node::new(accesskit::Role::Group);
|
||||
node.set_label(format!("{} {}", self.label, self.value));
|
||||
node
|
||||
}
|
||||
}
|
||||
89
crates/openpencil-shell-core/src/widgets/text_input.rs
Normal file
89
crates/openpencil-shell-core/src/widgets/text_input.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//! `TextInput` — Step 1b inspector single-line text field with CJK IME
|
||||
//! preview support.
|
||||
//!
|
||||
//! Phase B static slice: paints `state.preedit` (in-progress IME
|
||||
//! composition) when present, else `state.value` (committed). The
|
||||
//! preedit underline is the only visual cue; Phase C event handling
|
||||
//! lands compositionstart / compositionupdate / compositionend → state
|
||||
//! mutation in shell-web.
|
||||
|
||||
use super::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
|
||||
use crate::{Color, Point2D, Rect, TextLayout};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TextInputState {
|
||||
pub value: String,
|
||||
pub preedit: String,
|
||||
}
|
||||
|
||||
pub struct TextInput {
|
||||
pub id: WidgetId,
|
||||
pub label: String,
|
||||
pub state: TextInputState,
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
/// Sample text input. WidgetId range 400-499 is reserved for text
|
||||
/// inputs by Step 1b convention.
|
||||
pub fn sample() -> Self {
|
||||
Self {
|
||||
id: WidgetId::new(400),
|
||||
label: "Name".to_string(),
|
||||
state: TextInputState {
|
||||
value: "Frame 1".to_string(),
|
||||
preedit: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for TextInput {
|
||||
fn id(&self) -> WidgetId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn layout(&self, cx: &LayoutCx) -> LayoutBox {
|
||||
LayoutBox {
|
||||
rect: Rect {
|
||||
origin: Point2D::new(0.0, 0.0),
|
||||
size: Point2D::new(cx.available_width, 34.0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
|
||||
cx.backend.fill_rect(rect, Color::WHITE);
|
||||
cx.backend.stroke_rect(rect, Color::BLACK, 1.0);
|
||||
let display = if self.state.preedit.is_empty() {
|
||||
self.state.value.as_str()
|
||||
} else {
|
||||
self.state.preedit.as_str()
|
||||
};
|
||||
let text = TextLayout::single_run(
|
||||
display,
|
||||
"system-ui",
|
||||
13.0,
|
||||
jian_core::scene::Color::rgb(20, 20, 20),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
cx.backend
|
||||
.draw_text(&text, rect.origin + Point2D::new(8.0, 21.0));
|
||||
if !self.state.preedit.is_empty() {
|
||||
cx.backend.stroke_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(rect.origin.x + 8.0, rect.origin.y + 25.0),
|
||||
size: Point2D::new(80.0, 1.0),
|
||||
},
|
||||
Color::BLACK,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn access_node(&self) -> accesskit::Node {
|
||||
let mut node = accesskit::Node::new(accesskit::Role::TextInput);
|
||||
node.set_label(self.label.clone());
|
||||
node.set_value(self.state.value.clone());
|
||||
node
|
||||
}
|
||||
}
|
||||
103
crates/openpencil-shell-core/src/widgets/tree.rs
Normal file
103
crates/openpencil-shell-core/src/widgets/tree.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
//! `TreeWidget` — Step 1b inspector "Layers" panel.
|
||||
//!
|
||||
//! Phase B static slice: items are pre-baked via `TreeWidget::sample()`;
|
||||
//! Phase C event handling adds selection state mutation. Each `TreeItem`
|
||||
//! carries its own `WidgetId` so accesskit can address rows individually
|
||||
//! when the DOM mirror lands in Phase D.
|
||||
|
||||
use super::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
|
||||
use crate::{Color, Point2D, Rect, TextLayout};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TreeItem {
|
||||
pub id: WidgetId,
|
||||
pub label: String,
|
||||
pub depth: u8,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
pub struct TreeWidget {
|
||||
pub id: WidgetId,
|
||||
pub items: Vec<TreeItem>,
|
||||
}
|
||||
|
||||
impl TreeWidget {
|
||||
/// Sample tree mirroring the inspector's "Layers" panel. WidgetId
|
||||
/// range 100-199 is reserved for tree nodes by Step 1b convention so
|
||||
/// id collisions across widget kinds (PropertyRow=200s, Dropdown=300s,
|
||||
/// TextInput=400s) cannot happen by accident.
|
||||
pub fn sample() -> Self {
|
||||
Self {
|
||||
id: WidgetId::new(100),
|
||||
items: vec![
|
||||
TreeItem {
|
||||
id: WidgetId::new(101),
|
||||
label: "Frame".to_string(),
|
||||
depth: 0,
|
||||
selected: true,
|
||||
},
|
||||
TreeItem {
|
||||
id: WidgetId::new(102),
|
||||
label: "Title".to_string(),
|
||||
depth: 1,
|
||||
selected: false,
|
||||
},
|
||||
TreeItem {
|
||||
id: WidgetId::new(103),
|
||||
label: "Button".to_string(),
|
||||
depth: 1,
|
||||
selected: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for TreeWidget {
|
||||
fn id(&self) -> WidgetId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn layout(&self, cx: &LayoutCx) -> LayoutBox {
|
||||
LayoutBox {
|
||||
rect: Rect {
|
||||
origin: Point2D::new(0.0, 0.0),
|
||||
size: Point2D::new(cx.available_width, self.items.len() as f32 * 28.0 + 8.0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
|
||||
cx.backend.fill_rect(rect, Color::WHITE);
|
||||
for (index, item) in self.items.iter().enumerate() {
|
||||
let y = rect.origin.y + 4.0 + index as f32 * 28.0;
|
||||
let row = Rect {
|
||||
origin: Point2D::new(rect.origin.x + 4.0, y),
|
||||
size: Point2D::new(rect.size.x - 8.0, 24.0),
|
||||
};
|
||||
if item.selected {
|
||||
cx.backend.fill_rect(row, Color::BLUE);
|
||||
}
|
||||
let text = TextLayout::single_run(
|
||||
&item.label,
|
||||
"system-ui",
|
||||
13.0,
|
||||
jian_core::scene::Color::rgb(0, 0, 0),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
cx.backend.draw_text(
|
||||
&text,
|
||||
Point2D::new(
|
||||
row.origin.x + 8.0 + f32::from(item.depth) * 16.0,
|
||||
row.origin.y + 17.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn access_node(&self) -> accesskit::Node {
|
||||
let mut node = accesskit::Node::new(accesskit::Role::Tree);
|
||||
node.set_label("Layers");
|
||||
node
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
//! Phase B1 widget facade smoke tests.
|
||||
//! Phase B1 + B2 widget facade smoke tests.
|
||||
//!
|
||||
//! Proves the `Widget` trait + `PaintCx` / `LayoutCx` shape compiles and
|
||||
//! that a recording backend can be plugged in via the `&mut dyn
|
||||
//! RenderBackend` field. B2 lands the four real widgets and extends this
|
||||
//! file with per-widget paint-call assertions; today we only verify the
|
||||
//! plumbing.
|
||||
//! 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 openpencil_shell_core::widgets::{
|
||||
LayoutBox, LayoutCx, PaintCx, ROOT_WIDGET_ID, Widget, WidgetId, rect,
|
||||
Dropdown, DropdownState, LayoutBox, LayoutCx, PaintCx, PropertyRow, ROOT_WIDGET_ID, TextInput,
|
||||
TextInputState, TreeWidget, Widget, WidgetId, rect,
|
||||
};
|
||||
use openpencil_shell_core::{Color, Point2D, Rect, RenderBackend, TextLayout};
|
||||
|
||||
|
|
@ -131,7 +132,129 @@ fn widget_trait_dispatches_layout_and_paint() {
|
|||
|
||||
// Trait surface check: access_node returns the placeholder
|
||||
// `Role::GenericContainer` advertised by `StubWidget`. Real B2 widgets
|
||||
// will assert their semantic roles (TreeItem / EditableText / etc).
|
||||
// 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, "");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue