Another project
1use bone_types::IconId;
2use uom::si::f64::{Angle, Length};
3
4use crate::a11y::{AccessNode, Role};
5use crate::frame::{FrameCtx, InteractDeclaration};
6use crate::hit_test::Sense;
7use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
8use crate::strings::StringKey;
9use crate::theme::{Border, Color, Step12, StrokeWidth};
10use crate::widget_id::WidgetId;
11
12use super::checkbox::{Checkbox, CheckboxState, show_checkbox};
13use super::dimensioned_input::DimensionedInput;
14use super::dropdown::{Dropdown, DropdownItem, DropdownState, show_dropdown};
15use super::keys::take_activation;
16use super::paint::{HorizontalAlign, IconTint, LabelText, WidgetPaint};
17use super::parsed_input::show_parsed_input;
18use super::text_input::{AlwaysValid, Clipboard, TextInput, TextInputState, show_text_input};
19use super::visuals::push_focus_ring;
20
21#[derive(Copy, Clone, Debug, PartialEq)]
22pub struct PropertyCell {
23 pub row_id: WidgetId,
24 pub label: StringKey,
25 pub rect: LayoutRect,
26 pub read_only: bool,
27}
28
29pub trait PropertyEditor {
30 fn render(
31 &mut self,
32 ctx: &mut FrameCtx<'_>,
33 cell: PropertyCell,
34 clipboard: &mut dyn Clipboard,
35 paint: &mut Vec<WidgetPaint>,
36 ) -> bool;
37}
38
39pub struct PropertyRow<'a> {
40 pub id: WidgetId,
41 pub label: StringKey,
42 pub editor: &'a mut dyn PropertyEditor,
43 pub read_only: bool,
44}
45
46pub struct PropertyGrid<'a, 'rows> {
47 pub id: WidgetId,
48 pub rect: LayoutRect,
49 pub label: StringKey,
50 pub rows: &'a mut [PropertyRow<'rows>],
51 pub row_height: LayoutPx,
52 pub label_width: LayoutPx,
53 pub padding: LayoutPx,
54}
55
56impl<'a, 'rows> PropertyGrid<'a, 'rows> {
57 #[must_use]
58 pub fn new(
59 id: WidgetId,
60 rect: LayoutRect,
61 label: StringKey,
62 rows: &'a mut [PropertyRow<'rows>],
63 ) -> Self {
64 Self {
65 id,
66 rect,
67 label,
68 rows,
69 row_height: LayoutPx::new(22.0),
70 label_width: LayoutPx::new(120.0),
71 padding: LayoutPx::new(8.0),
72 }
73 }
74}
75
76#[derive(Clone, Debug, PartialEq)]
77pub struct PropertyGridResponse {
78 pub changed_rows: Vec<WidgetId>,
79 pub paint: Vec<WidgetPaint>,
80}
81
82#[must_use]
83pub fn show_property_grid(
84 ctx: &mut FrameCtx<'_>,
85 grid: PropertyGrid<'_, '_>,
86 clipboard: &mut dyn Clipboard,
87) -> PropertyGridResponse {
88 let PropertyGrid {
89 id,
90 rect,
91 label,
92 rows,
93 row_height,
94 label_width,
95 padding,
96 } = grid;
97 ctx.a11y
98 .push(id, rect, AccessNode::new(Role::Form).with_label(label));
99 let mut paint = vec![WidgetPaint::Surface {
100 rect,
101 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L0),
102 border: None,
103 radius: ctx.theme().radius.none,
104 elevation: None,
105 }];
106 let changed_rows = rows
107 .iter_mut()
108 .enumerate()
109 .filter_map(|(idx, row)| {
110 let row_rect = property_row_rect(rect, idx, row_height);
111 paint.push(WidgetPaint::AlignedLabel {
112 rect: label_rect_at(row_rect, label_width, padding),
113 text: LabelText::Key(row.label),
114 color: ctx.theme().colors.text_secondary(),
115 role: ctx.theme().typography.label,
116 align: HorizontalAlign::Start,
117 });
118 paint.push(WidgetPaint::Surface {
119 rect: divider_rect(row_rect),
120 fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
121 border: None,
122 radius: ctx.theme().radius.none,
123 elevation: None,
124 });
125 let cell = PropertyCell {
126 row_id: row.id,
127 label: row.label,
128 rect: editor_rect_at(row_rect, label_width, padding),
129 read_only: row.read_only,
130 };
131 row.editor
132 .render(ctx, cell, clipboard, &mut paint)
133 .then_some(row.id)
134 })
135 .collect();
136 PropertyGridResponse {
137 changed_rows,
138 paint,
139 }
140}
141
142#[derive(Copy, Clone, Debug, PartialEq, Eq)]
143pub enum PropertyPaneAction {
144 Accept,
145 Cancel,
146}
147
148#[derive(Copy, Clone, Debug, PartialEq)]
149pub struct PropertyPaneHeader {
150 pub id: WidgetId,
151 pub rect: LayoutRect,
152 pub title: StringKey,
153 pub accept_id: WidgetId,
154 pub cancel_id: WidgetId,
155}
156
157#[derive(Clone, Debug, PartialEq)]
158pub struct PropertyPaneHeaderResponse {
159 pub action: Option<PropertyPaneAction>,
160 pub paint: Vec<WidgetPaint>,
161}
162
163const HEADER_PAD: f32 = 8.0;
164const HEADER_TITLE_HEIGHT: f32 = 22.0;
165const HEADER_BUTTON: f32 = 22.0;
166const HEADER_BUTTON_GAP: f32 = 4.0;
167
168#[must_use]
169pub fn show_property_pane_header(
170 ctx: &mut FrameCtx<'_>,
171 header: PropertyPaneHeader,
172) -> PropertyPaneHeaderResponse {
173 let PropertyPaneHeader {
174 id,
175 rect,
176 title,
177 accept_id,
178 cancel_id,
179 } = header;
180 ctx.a11y
181 .push(id, rect, AccessNode::new(Role::Pane).with_label(title));
182 let mut paint = vec![WidgetPaint::Surface {
183 rect,
184 fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BG),
185 border: Some(Border {
186 width: StrokeWidth::HAIRLINE,
187 color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
188 }),
189 radius: ctx.theme().radius.none,
190 elevation: None,
191 }];
192 paint.push(WidgetPaint::AlignedLabel {
193 rect: header_title_rect(rect),
194 text: LabelText::Key(title),
195 color: ctx.theme().colors.text_primary(),
196 role: ctx.theme().typography.title,
197 align: HorizontalAlign::Start,
198 });
199 let accept_color = ctx.theme().colors.success.step(Step12::SOLID);
200 let cancel_color = ctx.theme().colors.danger.step(Step12::SOLID);
201 let accepted = header_action_button(
202 ctx,
203 accept_id,
204 header_button_rect(rect, 0),
205 IconId::Check,
206 accept_color,
207 title,
208 &mut paint,
209 );
210 let cancelled = header_action_button(
211 ctx,
212 cancel_id,
213 header_button_rect(rect, 1),
214 IconId::Cross,
215 cancel_color,
216 title,
217 &mut paint,
218 );
219 let action = match (accepted, cancelled) {
220 (true, _) => Some(PropertyPaneAction::Accept),
221 (false, true) => Some(PropertyPaneAction::Cancel),
222 (false, false) => None,
223 };
224 PropertyPaneHeaderResponse { action, paint }
225}
226
227fn header_action_button(
228 ctx: &mut FrameCtx<'_>,
229 id: WidgetId,
230 rect: LayoutRect,
231 icon: IconId,
232 glyph_color: Color,
233 label: StringKey,
234 paint: &mut Vec<WidgetPaint>,
235) -> bool {
236 let interaction = ctx.interact(
237 InteractDeclaration::new(id, rect, Sense::INTERACTIVE)
238 .focusable(true)
239 .a11y(AccessNode::new(Role::Button).with_label(label)),
240 );
241 let live_focused = ctx.is_focused(id);
242 if interaction.hover() || interaction.pressed() {
243 let step = if interaction.pressed() {
244 Step12::SELECTED_BG
245 } else {
246 Step12::HOVER_BG
247 };
248 paint.push(WidgetPaint::Surface {
249 rect,
250 fill: ctx.theme().colors.neutral.step(step),
251 border: None,
252 radius: ctx.theme().radius.sm,
253 elevation: None,
254 });
255 }
256 paint.push(WidgetPaint::Icon {
257 rect,
258 icon,
259 tint: IconTint::Solid(glyph_color),
260 });
261 push_focus_ring(ctx, paint, rect, ctx.theme().radius.sm, live_focused);
262 interaction.click() || (live_focused && take_activation(ctx.input))
263}
264
265fn header_title_rect(rect: LayoutRect) -> LayoutRect {
266 LayoutRect::new(
267 LayoutPos::new(
268 LayoutPx::new(rect.origin.x.value() + HEADER_PAD),
269 rect.origin.y,
270 ),
271 LayoutSize::new(
272 LayoutPx::saturating_nonneg(rect.size.width.value() - 2.0 * HEADER_PAD),
273 LayoutPx::new(HEADER_TITLE_HEIGHT),
274 ),
275 )
276}
277
278fn header_button_rect(rect: LayoutRect, index: u8) -> LayoutRect {
279 let x =
280 rect.origin.x.value() + HEADER_PAD + f32::from(index) * (HEADER_BUTTON + HEADER_BUTTON_GAP);
281 let y = rect.origin.y.value() + HEADER_TITLE_HEIGHT;
282 LayoutRect::new(
283 LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)),
284 LayoutSize::new(LayoutPx::new(HEADER_BUTTON), LayoutPx::new(HEADER_BUTTON)),
285 )
286}
287
288fn property_row_rect(grid: LayoutRect, idx: usize, row_height: LayoutPx) -> LayoutRect {
289 #[allow(clippy::cast_precision_loss, reason = "row index fits f32 mantissa")]
290 let i = idx as f32;
291 LayoutRect::new(
292 LayoutPos::new(
293 grid.origin.x,
294 LayoutPx::new(grid.origin.y.value() + i * row_height.value()),
295 ),
296 LayoutSize::new(grid.size.width, row_height),
297 )
298}
299
300fn label_rect_at(row: LayoutRect, label_width: LayoutPx, padding: LayoutPx) -> LayoutRect {
301 LayoutRect::new(
302 LayoutPos::new(
303 LayoutPx::new(row.origin.x.value() + padding.value()),
304 row.origin.y,
305 ),
306 LayoutSize::new(
307 LayoutPx::saturating_nonneg(label_width.value() - padding.value()),
308 row.size.height,
309 ),
310 )
311}
312
313fn editor_rect_at(row: LayoutRect, label_width: LayoutPx, padding: LayoutPx) -> LayoutRect {
314 let editor_x = row.origin.x.value() + label_width.value();
315 let editor_w = (row.size.width.value() - label_width.value() - padding.value()).max(0.0);
316 let row_pad = 4.0;
317 LayoutRect::new(
318 LayoutPos::new(
319 LayoutPx::new(editor_x),
320 LayoutPx::new(row.origin.y.value() + row_pad),
321 ),
322 LayoutSize::new(
323 LayoutPx::new(editor_w),
324 LayoutPx::saturating_nonneg(row.size.height.value() - 2.0 * row_pad),
325 ),
326 )
327}
328
329fn divider_rect(row: LayoutRect) -> LayoutRect {
330 LayoutRect::new(
331 LayoutPos::new(
332 row.origin.x,
333 LayoutPx::new(row.origin.y.value() + row.size.height.value() - 1.0),
334 ),
335 LayoutSize::new(row.size.width, LayoutPx::new(1.0)),
336 )
337}
338
339#[derive(Clone, Debug, PartialEq, Eq)]
340pub struct BoolEditor {
341 pub value: bool,
342}
343
344impl BoolEditor {
345 #[must_use]
346 pub const fn new(value: bool) -> Self {
347 Self { value }
348 }
349}
350
351impl PropertyEditor for BoolEditor {
352 fn render(
353 &mut self,
354 ctx: &mut FrameCtx<'_>,
355 cell: PropertyCell,
356 _clipboard: &mut dyn Clipboard,
357 paint: &mut Vec<WidgetPaint>,
358 ) -> bool {
359 let cb_state = if self.value {
360 CheckboxState::Checked
361 } else {
362 CheckboxState::Unchecked
363 };
364 let response = show_checkbox(
365 ctx,
366 Checkbox::new(cell.row_id, cell.rect, cell.label, cb_state).disabled(cell.read_only),
367 );
368 paint.extend(response.paint);
369 if response.toggled {
370 self.value = response.state.is_active();
371 true
372 } else {
373 false
374 }
375 }
376}
377
378#[derive(Clone, Debug, PartialEq)]
379pub struct TextEditor {
380 pub value: String,
381 pub buffer: TextInputState,
382}
383
384impl TextEditor {
385 #[must_use]
386 pub fn new(value: impl Into<String>) -> Self {
387 let value = value.into();
388 Self {
389 buffer: TextInputState::from_text(value.clone()),
390 value,
391 }
392 }
393}
394
395impl PropertyEditor for TextEditor {
396 fn render(
397 &mut self,
398 ctx: &mut FrameCtx<'_>,
399 cell: PropertyCell,
400 clipboard: &mut dyn Clipboard,
401 paint: &mut Vec<WidgetPaint>,
402 ) -> bool {
403 let editing = ctx.is_focused(cell.row_id);
404 if !editing && self.buffer.text != self.value {
405 self.buffer = TextInputState::from_text(self.value.clone());
406 }
407 let response = show_text_input(
408 ctx,
409 TextInput {
410 id: cell.row_id,
411 rect: cell.rect,
412 placeholder: cell.label,
413 state: &mut self.buffer,
414 disabled: cell.read_only,
415 validator: AlwaysValid,
416 },
417 clipboard,
418 );
419 paint.extend(response.paint);
420 if response.edits.is_empty() {
421 false
422 } else {
423 self.value = self.buffer.text.clone();
424 true
425 }
426 }
427}
428
429#[derive(Clone, Debug, PartialEq)]
430pub struct LengthEditor {
431 pub value: Length,
432 pub buffer: TextInputState,
433}
434
435impl LengthEditor {
436 #[must_use]
437 pub fn new(value: Length) -> Self {
438 Self {
439 buffer: TextInputState::from_text(format_length(value)),
440 value,
441 }
442 }
443}
444
445impl PropertyEditor for LengthEditor {
446 fn render(
447 &mut self,
448 ctx: &mut FrameCtx<'_>,
449 cell: PropertyCell,
450 clipboard: &mut dyn Clipboard,
451 paint: &mut Vec<WidgetPaint>,
452 ) -> bool {
453 let editing = ctx.is_focused(cell.row_id);
454 let formatted = format_length(self.value);
455 if !editing && self.buffer.text != formatted {
456 self.buffer = TextInputState::from_text(formatted);
457 }
458 let response = show_parsed_input::<Length, _>(
459 ctx,
460 DimensionedInput::<Length>::new(cell.row_id, cell.rect, cell.label, &mut self.buffer)
461 .disabled(cell.read_only),
462 clipboard,
463 );
464 paint.extend(response.paint);
465 match response.committed {
466 Some(v) => {
467 self.value = v;
468 true
469 }
470 None => false,
471 }
472 }
473}
474
475#[derive(Clone, Debug, PartialEq)]
476pub struct AngleEditor {
477 pub value: Angle,
478 pub buffer: TextInputState,
479}
480
481impl AngleEditor {
482 #[must_use]
483 pub fn new(value: Angle) -> Self {
484 Self {
485 buffer: TextInputState::from_text(format_angle(value)),
486 value,
487 }
488 }
489}
490
491impl PropertyEditor for AngleEditor {
492 fn render(
493 &mut self,
494 ctx: &mut FrameCtx<'_>,
495 cell: PropertyCell,
496 clipboard: &mut dyn Clipboard,
497 paint: &mut Vec<WidgetPaint>,
498 ) -> bool {
499 let editing = ctx.is_focused(cell.row_id);
500 let formatted = format_angle(self.value);
501 if !editing && self.buffer.text != formatted {
502 self.buffer = TextInputState::from_text(formatted);
503 }
504 let response = show_parsed_input::<Angle, _>(
505 ctx,
506 DimensionedInput::<Angle>::new(cell.row_id, cell.rect, cell.label, &mut self.buffer)
507 .disabled(cell.read_only),
508 clipboard,
509 );
510 paint.extend(response.paint);
511 match response.committed {
512 Some(v) => {
513 self.value = v;
514 true
515 }
516 None => false,
517 }
518 }
519}
520
521#[derive(Clone, Debug, PartialEq)]
522pub struct PropertyOption {
523 pub label: StringKey,
524}
525
526#[derive(Clone, Debug, PartialEq)]
527pub struct SelectionEditor {
528 pub options: Vec<PropertyOption>,
529 pub current: Option<usize>,
530 pub state: DropdownState,
531}
532
533impl SelectionEditor {
534 #[must_use]
535 pub fn new(options: Vec<PropertyOption>, current: Option<usize>) -> Self {
536 Self {
537 options,
538 current,
539 state: DropdownState::closed(),
540 }
541 }
542}
543
544impl PropertyEditor for SelectionEditor {
545 fn render(
546 &mut self,
547 ctx: &mut FrameCtx<'_>,
548 cell: PropertyCell,
549 _clipboard: &mut dyn Clipboard,
550 paint: &mut Vec<WidgetPaint>,
551 ) -> bool {
552 let items: Vec<DropdownItem<usize>> = self
553 .options
554 .iter()
555 .enumerate()
556 .map(|(i, opt)| DropdownItem {
557 value: i,
558 label: opt.label,
559 })
560 .collect();
561 let response = show_dropdown(
562 ctx,
563 Dropdown::new(
564 cell.row_id,
565 cell.rect,
566 LayoutPx::new(24.0),
567 items,
568 self.current,
569 cell.label,
570 &mut self.state,
571 )
572 .disabled(cell.read_only),
573 );
574 paint.extend(response.paint);
575 if response.changed {
576 self.current = response.selected;
577 true
578 } else {
579 false
580 }
581 }
582}
583
584fn format_length(value: Length) -> String {
585 use uom::si::length::millimeter;
586 format!("{} mm", value.get::<millimeter>())
587}
588
589fn format_angle(value: Angle) -> String {
590 use uom::si::angle::degree;
591 format!("{} deg", value.get::<degree>())
592}
593
594#[cfg(test)]
595mod tests {
596 use std::sync::Arc;
597
598 use super::{
599 BoolEditor, PropertyGrid, PropertyRow, SelectionEditor, TextEditor, show_property_grid,
600 };
601 use crate::focus::FocusManager;
602 use crate::frame::FrameCtx;
603 use crate::hit_test::{HitFrame, HitState, resolve};
604 use crate::hotkey::HotkeyTable;
605 use crate::input::{
606 FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample,
607 };
608 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
609 use crate::strings::{StringKey, StringTable};
610 use crate::theme::Theme;
611 use crate::widget_id::{WidgetId, WidgetKey};
612 use crate::widgets::text_input::MemoryClipboard;
613
614 fn grid_id() -> WidgetId {
615 WidgetId::ROOT.child(WidgetKey::new("grid"))
616 }
617
618 fn rect() -> LayoutRect {
619 LayoutRect::new(
620 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
621 LayoutSize::new(LayoutPx::new(280.0), LayoutPx::new(200.0)),
622 )
623 }
624
625 fn press(pos: LayoutPos) -> InputSnapshot {
626 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
627 s.pointer = Some(PointerSample::new(pos));
628 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
629 s
630 }
631
632 fn release(pos: LayoutPos) -> InputSnapshot {
633 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
634 s.pointer = Some(PointerSample::new(pos));
635 s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
636 s
637 }
638
639 fn idle(pos: LayoutPos) -> InputSnapshot {
640 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
641 s.pointer = Some(PointerSample::new(pos));
642 s
643 }
644
645 #[test]
646 fn click_bool_row_marks_row_changed_and_flips_value() {
647 let row_id = grid_id().child(WidgetKey::new("show_grid"));
648 let mut bool_editor = BoolEditor::new(false);
649 let mut clipboard = MemoryClipboard::default();
650 let mut focus = FocusManager::new();
651 let mut prev = HitState::new();
652 let click_pos = LayoutPos::new(LayoutPx::new(160.0), LayoutPx::new(14.0));
653 let theme = Arc::new(Theme::light());
654 let table = HotkeyTable::new();
655 let mut last: Option<super::PropertyGridResponse> = None;
656 [press(click_pos), release(click_pos), idle(click_pos)]
657 .into_iter()
658 .for_each(|mut snap| {
659 let mut hits = HitFrame::new();
660 let response = {
661 let mut shaper = bone_text::Shaper::new();
662 let mut a11y = crate::a11y::AccessTreeBuilder::new();
663 let mut ctx = FrameCtx::new(
664 theme.clone(),
665 &mut snap,
666 &mut focus,
667 &table,
668 StringTable::empty(),
669 &mut hits,
670 &prev,
671 &mut a11y,
672 &mut shaper,
673 );
674 let mut rows = [PropertyRow {
675 id: row_id,
676 label: StringKey::new("prop.show_grid"),
677 editor: &mut bool_editor,
678 read_only: false,
679 }];
680 show_property_grid(
681 &mut ctx,
682 PropertyGrid::new(
683 grid_id(),
684 rect(),
685 StringKey::new("test.grid"),
686 &mut rows,
687 ),
688 &mut clipboard,
689 )
690 };
691 last = Some(response);
692 prev = resolve(&prev, &hits, &snap, focus.focused());
693 });
694 let Some(response) = last else {
695 panic!("response missing")
696 };
697 assert_eq!(response.changed_rows, vec![row_id]);
698 assert!(bool_editor.value);
699 }
700
701 #[test]
702 fn click_accept_button_emits_accept_action() {
703 let header_id = WidgetId::ROOT.child(WidgetKey::new("pane"));
704 let accept_id = WidgetId::ROOT.child(WidgetKey::new("accept"));
705 let cancel_id = WidgetId::ROOT.child(WidgetKey::new("cancel"));
706 let mut focus = FocusManager::new();
707 let mut prev = HitState::new();
708 let click_pos = LayoutPos::new(LayoutPx::new(19.0), LayoutPx::new(33.0));
709 let theme = Arc::new(Theme::light());
710 let table = HotkeyTable::new();
711 let mut last: Option<super::PropertyPaneHeaderResponse> = None;
712 [press(click_pos), release(click_pos), idle(click_pos)]
713 .into_iter()
714 .for_each(|mut snap| {
715 let mut hits = HitFrame::new();
716 let response = {
717 let mut shaper = bone_text::Shaper::new();
718 let mut a11y = crate::a11y::AccessTreeBuilder::new();
719 let mut ctx = FrameCtx::new(
720 theme.clone(),
721 &mut snap,
722 &mut focus,
723 &table,
724 StringTable::empty(),
725 &mut hits,
726 &prev,
727 &mut a11y,
728 &mut shaper,
729 );
730 super::show_property_pane_header(
731 &mut ctx,
732 super::PropertyPaneHeader {
733 id: header_id,
734 rect: LayoutRect::new(
735 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
736 LayoutSize::new(LayoutPx::new(240.0), LayoutPx::new(48.0)),
737 ),
738 title: StringKey::new("test.pane"),
739 accept_id,
740 cancel_id,
741 },
742 )
743 };
744 last = Some(response);
745 prev = resolve(&prev, &hits, &snap, focus.focused());
746 });
747 let Some(response) = last else {
748 panic!("response missing")
749 };
750 assert_eq!(response.action, Some(super::PropertyPaneAction::Accept));
751 }
752
753 #[test]
754 fn unfocused_text_editor_adopts_external_value_change() {
755 let row_id = grid_id().child(WidgetKey::new("name"));
756 let mut text_editor = TextEditor::new("alpha");
757 let mut clipboard = MemoryClipboard::default();
758 let mut focus = FocusManager::new();
759 let prev = HitState::new();
760 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
761 let theme = Arc::new(Theme::light());
762 let table = HotkeyTable::new();
763 let mut hits = HitFrame::new();
764 {
765 let mut shaper = bone_text::Shaper::new();
766 let mut a11y = crate::a11y::AccessTreeBuilder::new();
767 let mut ctx = FrameCtx::new(
768 theme.clone(),
769 &mut snap,
770 &mut focus,
771 &table,
772 StringTable::empty(),
773 &mut hits,
774 &prev,
775 &mut a11y,
776 &mut shaper,
777 );
778 let mut rows = [PropertyRow {
779 id: row_id,
780 label: StringKey::new("prop.name"),
781 editor: &mut text_editor,
782 read_only: false,
783 }];
784 let _ = show_property_grid(
785 &mut ctx,
786 PropertyGrid::new(grid_id(), rect(), StringKey::new("test.grid"), &mut rows),
787 &mut clipboard,
788 );
789 }
790 text_editor.value = "beta".into();
791 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
792 let mut hits = HitFrame::new();
793 {
794 let mut shaper = bone_text::Shaper::new();
795 let mut a11y = crate::a11y::AccessTreeBuilder::new();
796 let mut ctx = FrameCtx::new(
797 theme,
798 &mut snap,
799 &mut focus,
800 &table,
801 StringTable::empty(),
802 &mut hits,
803 &prev,
804 &mut a11y,
805 &mut shaper,
806 );
807 let mut rows = [PropertyRow {
808 id: row_id,
809 label: StringKey::new("prop.name"),
810 editor: &mut text_editor,
811 read_only: false,
812 }];
813 let _ = show_property_grid(
814 &mut ctx,
815 PropertyGrid::new(grid_id(), rect(), StringKey::new("test.grid"), &mut rows),
816 &mut clipboard,
817 );
818 }
819 assert_eq!(text_editor.buffer.text, "beta");
820 }
821
822 #[test]
823 fn tab_advances_focus_through_rows_in_order() {
824 use crate::focus::FocusRequest;
825
826 let mut bool_a = BoolEditor::new(false);
827 let mut bool_b = BoolEditor::new(false);
828 let mut clipboard = MemoryClipboard::default();
829 let mut focus = FocusManager::new();
830 let prev = HitState::new();
831 let theme = Arc::new(Theme::light());
832 let table = HotkeyTable::new();
833 let alpha_id = grid_id().child(WidgetKey::new("row_a"));
834 let beta_id = grid_id().child(WidgetKey::new("row_b"));
835 let mut render = |focus: &mut FocusManager,
836 bool_a: &mut BoolEditor,
837 bool_b: &mut BoolEditor,
838 mut snap: InputSnapshot| {
839 let mut hits = HitFrame::new();
840 let mut shaper = bone_text::Shaper::new();
841 let mut a11y = crate::a11y::AccessTreeBuilder::new();
842 let mut ctx = FrameCtx::new(
843 theme.clone(),
844 &mut snap,
845 focus,
846 &table,
847 StringTable::empty(),
848 &mut hits,
849 &prev,
850 &mut a11y,
851 &mut shaper,
852 );
853 let mut rows = [
854 PropertyRow {
855 id: alpha_id,
856 label: StringKey::new("prop.a"),
857 editor: bool_a,
858 read_only: false,
859 },
860 PropertyRow {
861 id: beta_id,
862 label: StringKey::new("prop.b"),
863 editor: bool_b,
864 read_only: false,
865 },
866 ];
867 let _ = show_property_grid(
868 &mut ctx,
869 PropertyGrid::new(grid_id(), rect(), StringKey::new("test.grid"), &mut rows),
870 &mut clipboard,
871 );
872 };
873
874 focus.request(FocusRequest::Advance);
875 render(
876 &mut focus,
877 &mut bool_a,
878 &mut bool_b,
879 InputSnapshot::idle(FrameInstant::ZERO),
880 );
881 assert_eq!(focus.focused(), Some(alpha_id));
882
883 focus.request(FocusRequest::Advance);
884 render(
885 &mut focus,
886 &mut bool_a,
887 &mut bool_b,
888 InputSnapshot::idle(FrameInstant::ZERO),
889 );
890 assert_eq!(focus.focused(), Some(beta_id));
891
892 focus.request(FocusRequest::Retreat);
893 render(
894 &mut focus,
895 &mut bool_a,
896 &mut bool_b,
897 InputSnapshot::idle(FrameInstant::ZERO),
898 );
899 assert_eq!(focus.focused(), Some(alpha_id));
900 }
901
902 #[test]
903 fn rows_paint_label_per_row() {
904 let mut bool_editor = BoolEditor::new(false);
905 let mut select_editor = SelectionEditor::new(
906 vec![super::PropertyOption {
907 label: StringKey::new("opt"),
908 }],
909 None,
910 );
911 let mut clipboard = MemoryClipboard::default();
912 let mut focus = FocusManager::new();
913 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
914 let prev = HitState::new();
915 let theme = Arc::new(Theme::light());
916 let table = HotkeyTable::new();
917 let mut hits = HitFrame::new();
918 let response = {
919 let mut shaper = bone_text::Shaper::new();
920 let mut a11y = crate::a11y::AccessTreeBuilder::new();
921 let mut ctx = FrameCtx::new(
922 theme,
923 &mut snap,
924 &mut focus,
925 &table,
926 StringTable::empty(),
927 &mut hits,
928 &prev,
929 &mut a11y,
930 &mut shaper,
931 );
932 let mut rows = [
933 PropertyRow {
934 id: grid_id().child(WidgetKey::new("a")),
935 label: StringKey::new("prop.a"),
936 editor: &mut bool_editor,
937 read_only: false,
938 },
939 PropertyRow {
940 id: grid_id().child(WidgetKey::new("b")),
941 label: StringKey::new("prop.b"),
942 editor: &mut select_editor,
943 read_only: false,
944 },
945 ];
946 show_property_grid(
947 &mut ctx,
948 PropertyGrid::new(grid_id(), rect(), StringKey::new("test.grid"), &mut rows),
949 &mut clipboard,
950 )
951 };
952 let label_count = response
953 .paint
954 .iter()
955 .filter(|p| matches!(p, super::WidgetPaint::Label { .. }))
956 .count();
957 assert!(label_count >= 2);
958 }
959}