Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 30 kB View raw
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}