Another project
1
fork

Configure Feed

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

feat(ui): property grid widget

Lewis: May this revision serve well! <lu5a@proton.me>

+646
+646
crates/bone-ui/src/widgets/property_grid.rs
··· 1 + use uom::si::f64::{Angle, Length}; 2 + 3 + use crate::frame::FrameCtx; 4 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5 + use crate::strings::StringKey; 6 + use crate::theme::Step12; 7 + use crate::widget_id::WidgetId; 8 + 9 + use super::checkbox::{Checkbox, CheckboxState, show_checkbox}; 10 + use super::dimensioned_input::DimensionedInput; 11 + use super::dropdown::{Dropdown, DropdownItem, DropdownState, show_dropdown}; 12 + use super::paint::{LabelText, WidgetPaint}; 13 + use super::parsed_input::show_parsed_input; 14 + use super::text_input::{ 15 + AlwaysValid, Clipboard, TextInput, TextInputState, show_text_input, 16 + }; 17 + 18 + #[derive(Copy, Clone, Debug, PartialEq)] 19 + pub struct PropertyCell { 20 + pub row_id: WidgetId, 21 + pub label: StringKey, 22 + pub rect: LayoutRect, 23 + pub read_only: bool, 24 + } 25 + 26 + pub trait PropertyEditor { 27 + fn render( 28 + &mut self, 29 + ctx: &mut FrameCtx<'_>, 30 + cell: PropertyCell, 31 + clipboard: &mut dyn Clipboard, 32 + paint: &mut Vec<WidgetPaint>, 33 + ) -> bool; 34 + } 35 + 36 + pub struct PropertyRow<'a> { 37 + pub id: WidgetId, 38 + pub label: StringKey, 39 + pub editor: &'a mut dyn PropertyEditor, 40 + pub read_only: bool, 41 + } 42 + 43 + pub struct PropertyGrid<'a, 'rows> { 44 + pub id: WidgetId, 45 + pub rect: LayoutRect, 46 + pub rows: &'a mut [PropertyRow<'rows>], 47 + pub row_height: LayoutPx, 48 + pub label_width: LayoutPx, 49 + pub padding: LayoutPx, 50 + } 51 + 52 + impl<'a, 'rows> PropertyGrid<'a, 'rows> { 53 + #[must_use] 54 + pub fn new(id: WidgetId, rect: LayoutRect, rows: &'a mut [PropertyRow<'rows>]) -> Self { 55 + Self { 56 + id, 57 + rect, 58 + rows, 59 + row_height: LayoutPx::new(28.0), 60 + label_width: LayoutPx::new(120.0), 61 + padding: LayoutPx::new(8.0), 62 + } 63 + } 64 + } 65 + 66 + #[derive(Clone, Debug, PartialEq)] 67 + pub struct PropertyGridResponse { 68 + pub changed_rows: Vec<WidgetId>, 69 + pub paint: Vec<WidgetPaint>, 70 + } 71 + 72 + #[must_use] 73 + pub fn show_property_grid( 74 + ctx: &mut FrameCtx<'_>, 75 + grid: PropertyGrid<'_, '_>, 76 + clipboard: &mut dyn Clipboard, 77 + ) -> PropertyGridResponse { 78 + let PropertyGrid { 79 + id: _, 80 + rect, 81 + rows, 82 + row_height, 83 + label_width, 84 + padding, 85 + } = grid; 86 + let mut paint = vec![WidgetPaint::Surface { 87 + rect, 88 + fill: ctx.theme.colors.surface(crate::theme::SurfaceLevel::L0), 89 + border: None, 90 + radius: ctx.theme.radius.none, 91 + elevation: None, 92 + }]; 93 + let changed_rows = rows 94 + .iter_mut() 95 + .enumerate() 96 + .filter_map(|(idx, row)| { 97 + let row_rect = property_row_rect(rect, idx, row_height); 98 + paint.push(WidgetPaint::Label { 99 + rect: label_rect_at(row_rect, label_width, padding), 100 + text: LabelText::Key(row.label), 101 + color: ctx.theme.colors.text_secondary(), 102 + role: ctx.theme.typography.label, 103 + }); 104 + paint.push(WidgetPaint::Surface { 105 + rect: divider_rect(row_rect), 106 + fill: ctx.theme.colors.neutral.step(Step12::SUBTLE_BORDER), 107 + border: None, 108 + radius: ctx.theme.radius.none, 109 + elevation: None, 110 + }); 111 + let cell = PropertyCell { 112 + row_id: row.id, 113 + label: row.label, 114 + rect: editor_rect_at(row_rect, label_width, padding), 115 + read_only: row.read_only, 116 + }; 117 + row.editor 118 + .render(ctx, cell, clipboard, &mut paint) 119 + .then_some(row.id) 120 + }) 121 + .collect(); 122 + PropertyGridResponse { 123 + changed_rows, 124 + paint, 125 + } 126 + } 127 + 128 + fn property_row_rect(grid: LayoutRect, idx: usize, row_height: LayoutPx) -> LayoutRect { 129 + #[allow(clippy::cast_precision_loss, reason = "row index fits f32 mantissa")] 130 + let i = idx as f32; 131 + LayoutRect::new( 132 + LayoutPos::new( 133 + grid.origin.x, 134 + LayoutPx::new(grid.origin.y.value() + i * row_height.value()), 135 + ), 136 + LayoutSize::new(grid.size.width, row_height), 137 + ) 138 + } 139 + 140 + fn label_rect_at(row: LayoutRect, label_width: LayoutPx, padding: LayoutPx) -> LayoutRect { 141 + LayoutRect::new( 142 + LayoutPos::new( 143 + LayoutPx::new(row.origin.x.value() + padding.value()), 144 + row.origin.y, 145 + ), 146 + LayoutSize::new( 147 + LayoutPx::saturating_nonneg(label_width.value() - padding.value()), 148 + row.size.height, 149 + ), 150 + ) 151 + } 152 + 153 + fn editor_rect_at(row: LayoutRect, label_width: LayoutPx, padding: LayoutPx) -> LayoutRect { 154 + let editor_x = row.origin.x.value() + label_width.value(); 155 + let editor_w = (row.size.width.value() - label_width.value() - padding.value()).max(0.0); 156 + let row_pad = 4.0; 157 + LayoutRect::new( 158 + LayoutPos::new( 159 + LayoutPx::new(editor_x), 160 + LayoutPx::new(row.origin.y.value() + row_pad), 161 + ), 162 + LayoutSize::new( 163 + LayoutPx::new(editor_w), 164 + LayoutPx::saturating_nonneg(row.size.height.value() - 2.0 * row_pad), 165 + ), 166 + ) 167 + } 168 + 169 + fn divider_rect(row: LayoutRect) -> LayoutRect { 170 + LayoutRect::new( 171 + LayoutPos::new( 172 + row.origin.x, 173 + LayoutPx::new(row.origin.y.value() + row.size.height.value() - 1.0), 174 + ), 175 + LayoutSize::new(row.size.width, LayoutPx::new(1.0)), 176 + ) 177 + } 178 + 179 + #[derive(Clone, Debug, PartialEq, Eq)] 180 + pub struct BoolEditor { 181 + pub value: bool, 182 + } 183 + 184 + impl BoolEditor { 185 + #[must_use] 186 + pub const fn new(value: bool) -> Self { 187 + Self { value } 188 + } 189 + } 190 + 191 + impl PropertyEditor for BoolEditor { 192 + fn render( 193 + &mut self, 194 + ctx: &mut FrameCtx<'_>, 195 + cell: PropertyCell, 196 + _clipboard: &mut dyn Clipboard, 197 + paint: &mut Vec<WidgetPaint>, 198 + ) -> bool { 199 + let cb_state = if self.value { 200 + CheckboxState::Checked 201 + } else { 202 + CheckboxState::Unchecked 203 + }; 204 + let response = show_checkbox( 205 + ctx, 206 + Checkbox::new(cell.row_id, cell.rect, cell.label, cb_state).disabled(cell.read_only), 207 + ); 208 + paint.extend(response.paint); 209 + if response.toggled { 210 + self.value = response.state.is_active(); 211 + true 212 + } else { 213 + false 214 + } 215 + } 216 + } 217 + 218 + #[derive(Clone, Debug, PartialEq, Eq)] 219 + pub struct TextEditor { 220 + pub value: String, 221 + pub buffer: TextInputState, 222 + } 223 + 224 + impl TextEditor { 225 + #[must_use] 226 + pub fn new(value: impl Into<String>) -> Self { 227 + let value = value.into(); 228 + Self { 229 + buffer: TextInputState::from_text(value.clone()), 230 + value, 231 + } 232 + } 233 + } 234 + 235 + impl PropertyEditor for TextEditor { 236 + fn render( 237 + &mut self, 238 + ctx: &mut FrameCtx<'_>, 239 + cell: PropertyCell, 240 + clipboard: &mut dyn Clipboard, 241 + paint: &mut Vec<WidgetPaint>, 242 + ) -> bool { 243 + let editing = ctx.is_focused(cell.row_id); 244 + if !editing && self.buffer.text != self.value { 245 + self.buffer = TextInputState::from_text(self.value.clone()); 246 + } 247 + let response = show_text_input( 248 + ctx, 249 + TextInput { 250 + id: cell.row_id, 251 + rect: cell.rect, 252 + placeholder: cell.label, 253 + state: &mut self.buffer, 254 + disabled: cell.read_only, 255 + validator: AlwaysValid, 256 + }, 257 + clipboard, 258 + ); 259 + paint.extend(response.paint); 260 + if response.edits.is_empty() { 261 + false 262 + } else { 263 + self.value = self.buffer.text.clone(); 264 + true 265 + } 266 + } 267 + } 268 + 269 + #[derive(Clone, Debug, PartialEq)] 270 + pub struct LengthEditor { 271 + pub value: Length, 272 + pub buffer: TextInputState, 273 + } 274 + 275 + impl LengthEditor { 276 + #[must_use] 277 + pub fn new(value: Length) -> Self { 278 + Self { 279 + buffer: TextInputState::from_text(format_length(value)), 280 + value, 281 + } 282 + } 283 + } 284 + 285 + impl PropertyEditor for LengthEditor { 286 + fn render( 287 + &mut self, 288 + ctx: &mut FrameCtx<'_>, 289 + cell: PropertyCell, 290 + clipboard: &mut dyn Clipboard, 291 + paint: &mut Vec<WidgetPaint>, 292 + ) -> bool { 293 + let editing = ctx.is_focused(cell.row_id); 294 + let formatted = format_length(self.value); 295 + if !editing && self.buffer.text != formatted { 296 + self.buffer = TextInputState::from_text(formatted); 297 + } 298 + let response = show_parsed_input::<Length, _>( 299 + ctx, 300 + DimensionedInput::<Length>::new(cell.row_id, cell.rect, cell.label, &mut self.buffer) 301 + .disabled(cell.read_only), 302 + clipboard, 303 + ); 304 + paint.extend(response.paint); 305 + match response.committed { 306 + Some(v) => { 307 + self.value = v; 308 + true 309 + } 310 + None => false, 311 + } 312 + } 313 + } 314 + 315 + #[derive(Clone, Debug, PartialEq)] 316 + pub struct AngleEditor { 317 + pub value: Angle, 318 + pub buffer: TextInputState, 319 + } 320 + 321 + impl AngleEditor { 322 + #[must_use] 323 + pub fn new(value: Angle) -> Self { 324 + Self { 325 + buffer: TextInputState::from_text(format_angle(value)), 326 + value, 327 + } 328 + } 329 + } 330 + 331 + impl PropertyEditor for AngleEditor { 332 + fn render( 333 + &mut self, 334 + ctx: &mut FrameCtx<'_>, 335 + cell: PropertyCell, 336 + clipboard: &mut dyn Clipboard, 337 + paint: &mut Vec<WidgetPaint>, 338 + ) -> bool { 339 + let editing = ctx.is_focused(cell.row_id); 340 + let formatted = format_angle(self.value); 341 + if !editing && self.buffer.text != formatted { 342 + self.buffer = TextInputState::from_text(formatted); 343 + } 344 + let response = show_parsed_input::<Angle, _>( 345 + ctx, 346 + DimensionedInput::<Angle>::new(cell.row_id, cell.rect, cell.label, &mut self.buffer) 347 + .disabled(cell.read_only), 348 + clipboard, 349 + ); 350 + paint.extend(response.paint); 351 + match response.committed { 352 + Some(v) => { 353 + self.value = v; 354 + true 355 + } 356 + None => false, 357 + } 358 + } 359 + } 360 + 361 + #[derive(Clone, Debug, PartialEq)] 362 + pub struct PropertyOption { 363 + pub label: StringKey, 364 + } 365 + 366 + #[derive(Clone, Debug, PartialEq)] 367 + pub struct SelectionEditor { 368 + pub options: Vec<PropertyOption>, 369 + pub current: Option<usize>, 370 + pub state: DropdownState, 371 + } 372 + 373 + impl SelectionEditor { 374 + #[must_use] 375 + pub fn new(options: Vec<PropertyOption>, current: Option<usize>) -> Self { 376 + Self { 377 + options, 378 + current, 379 + state: DropdownState::closed(), 380 + } 381 + } 382 + } 383 + 384 + impl PropertyEditor for SelectionEditor { 385 + fn render( 386 + &mut self, 387 + ctx: &mut FrameCtx<'_>, 388 + cell: PropertyCell, 389 + _clipboard: &mut dyn Clipboard, 390 + paint: &mut Vec<WidgetPaint>, 391 + ) -> bool { 392 + let items: Vec<DropdownItem<usize>> = self 393 + .options 394 + .iter() 395 + .enumerate() 396 + .map(|(i, opt)| DropdownItem { 397 + value: i, 398 + label: opt.label, 399 + }) 400 + .collect(); 401 + let response = show_dropdown( 402 + ctx, 403 + Dropdown::new( 404 + cell.row_id, 405 + cell.rect, 406 + LayoutPx::new(24.0), 407 + items, 408 + self.current, 409 + cell.label, 410 + &mut self.state, 411 + ) 412 + .disabled(cell.read_only), 413 + ); 414 + paint.extend(response.paint); 415 + if response.changed { 416 + self.current = response.selected; 417 + true 418 + } else { 419 + false 420 + } 421 + } 422 + } 423 + 424 + fn format_length(value: Length) -> String { 425 + use uom::si::length::millimeter; 426 + format!("{} mm", value.get::<millimeter>()) 427 + } 428 + 429 + fn format_angle(value: Angle) -> String { 430 + use uom::si::angle::degree; 431 + format!("{} deg", value.get::<degree>()) 432 + } 433 + 434 + #[cfg(test)] 435 + mod tests { 436 + use std::sync::Arc; 437 + 438 + use super::{ 439 + BoolEditor, PropertyGrid, PropertyRow, SelectionEditor, TextEditor, show_property_grid, 440 + }; 441 + use crate::focus::FocusManager; 442 + use crate::frame::FrameCtx; 443 + use crate::hit_test::{HitFrame, HitState, resolve}; 444 + use crate::hotkey::HotkeyTable; 445 + use crate::input::{ 446 + FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample, 447 + }; 448 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 449 + use crate::strings::{StringKey, StringTable}; 450 + use crate::theme::Theme; 451 + use crate::widget_id::{WidgetId, WidgetKey}; 452 + use crate::widgets::text_input::MemoryClipboard; 453 + 454 + fn grid_id() -> WidgetId { 455 + WidgetId::ROOT.child(WidgetKey::new("grid")) 456 + } 457 + 458 + fn rect() -> LayoutRect { 459 + LayoutRect::new( 460 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 461 + LayoutSize::new(LayoutPx::new(280.0), LayoutPx::new(200.0)), 462 + ) 463 + } 464 + 465 + fn press(pos: LayoutPos) -> InputSnapshot { 466 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 467 + s.pointer = Some(PointerSample::new(pos)); 468 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 469 + s 470 + } 471 + 472 + fn release(pos: LayoutPos) -> InputSnapshot { 473 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 474 + s.pointer = Some(PointerSample::new(pos)); 475 + s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 476 + s 477 + } 478 + 479 + fn idle(pos: LayoutPos) -> InputSnapshot { 480 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 481 + s.pointer = Some(PointerSample::new(pos)); 482 + s 483 + } 484 + 485 + #[test] 486 + fn click_bool_row_marks_row_changed_and_flips_value() { 487 + let row_id = grid_id().child(WidgetKey::new("show_grid")); 488 + let mut bool_editor = BoolEditor::new(false); 489 + let mut clipboard = MemoryClipboard::default(); 490 + let mut focus = FocusManager::new(); 491 + let mut prev = HitState::new(); 492 + let click_pos = LayoutPos::new(LayoutPx::new(160.0), LayoutPx::new(14.0)); 493 + let theme = Arc::new(Theme::light()); 494 + let table = HotkeyTable::new(); 495 + let mut last: Option<super::PropertyGridResponse> = None; 496 + [press(click_pos), release(click_pos), idle(click_pos)] 497 + .into_iter() 498 + .for_each(|mut snap| { 499 + let mut hits = HitFrame::new(); 500 + let response = { 501 + let mut ctx = FrameCtx::new( 502 + theme.clone(), 503 + &mut snap, 504 + &mut focus, 505 + &table, 506 + StringTable::empty(), 507 + &mut hits, 508 + &prev, 509 + ); 510 + let mut rows = [PropertyRow { 511 + id: row_id, 512 + label: StringKey::new("prop.show_grid"), 513 + editor: &mut bool_editor, 514 + read_only: false, 515 + }]; 516 + show_property_grid( 517 + &mut ctx, 518 + PropertyGrid::new(grid_id(), rect(), &mut rows), 519 + &mut clipboard, 520 + ) 521 + }; 522 + last = Some(response); 523 + prev = resolve(&prev, &hits, &snap, focus.focused()); 524 + }); 525 + let Some(response) = last else { 526 + panic!("response missing") 527 + }; 528 + assert_eq!(response.changed_rows, vec![row_id]); 529 + assert!(bool_editor.value); 530 + } 531 + 532 + #[test] 533 + fn unfocused_text_editor_adopts_external_value_change() { 534 + let row_id = grid_id().child(WidgetKey::new("name")); 535 + let mut text_editor = TextEditor::new("alpha"); 536 + let mut clipboard = MemoryClipboard::default(); 537 + let mut focus = FocusManager::new(); 538 + let prev = HitState::new(); 539 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 540 + let theme = Arc::new(Theme::light()); 541 + let table = HotkeyTable::new(); 542 + let mut hits = HitFrame::new(); 543 + { 544 + let mut ctx = FrameCtx::new( 545 + theme.clone(), 546 + &mut snap, 547 + &mut focus, 548 + &table, 549 + StringTable::empty(), 550 + &mut hits, 551 + &prev, 552 + ); 553 + let mut rows = [PropertyRow { 554 + id: row_id, 555 + label: StringKey::new("prop.name"), 556 + editor: &mut text_editor, 557 + read_only: false, 558 + }]; 559 + let _ = show_property_grid( 560 + &mut ctx, 561 + PropertyGrid::new(grid_id(), rect(), &mut rows), 562 + &mut clipboard, 563 + ); 564 + } 565 + text_editor.value = "beta".into(); 566 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 567 + let mut hits = HitFrame::new(); 568 + { 569 + let mut ctx = FrameCtx::new( 570 + theme, 571 + &mut snap, 572 + &mut focus, 573 + &table, 574 + StringTable::empty(), 575 + &mut hits, 576 + &prev, 577 + ); 578 + let mut rows = [PropertyRow { 579 + id: row_id, 580 + label: StringKey::new("prop.name"), 581 + editor: &mut text_editor, 582 + read_only: false, 583 + }]; 584 + let _ = show_property_grid( 585 + &mut ctx, 586 + PropertyGrid::new(grid_id(), rect(), &mut rows), 587 + &mut clipboard, 588 + ); 589 + } 590 + assert_eq!(text_editor.buffer.text, "beta"); 591 + } 592 + 593 + #[test] 594 + fn rows_paint_label_per_row() { 595 + let mut bool_editor = BoolEditor::new(false); 596 + let mut select_editor = SelectionEditor::new( 597 + vec![super::PropertyOption { 598 + label: StringKey::new("opt"), 599 + }], 600 + None, 601 + ); 602 + let mut clipboard = MemoryClipboard::default(); 603 + let mut focus = FocusManager::new(); 604 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 605 + let prev = HitState::new(); 606 + let theme = Arc::new(Theme::light()); 607 + let table = HotkeyTable::new(); 608 + let mut hits = HitFrame::new(); 609 + let response = { 610 + let mut ctx = FrameCtx::new( 611 + theme, 612 + &mut snap, 613 + &mut focus, 614 + &table, 615 + StringTable::empty(), 616 + &mut hits, 617 + &prev, 618 + ); 619 + let mut rows = [ 620 + PropertyRow { 621 + id: grid_id().child(WidgetKey::new("a")), 622 + label: StringKey::new("prop.a"), 623 + editor: &mut bool_editor, 624 + read_only: false, 625 + }, 626 + PropertyRow { 627 + id: grid_id().child(WidgetKey::new("b")), 628 + label: StringKey::new("prop.b"), 629 + editor: &mut select_editor, 630 + read_only: false, 631 + }, 632 + ]; 633 + show_property_grid( 634 + &mut ctx, 635 + PropertyGrid::new(grid_id(), rect(), &mut rows), 636 + &mut clipboard, 637 + ) 638 + }; 639 + let label_count = response 640 + .paint 641 + .iter() 642 + .filter(|p| matches!(p, super::WidgetPaint::Label { .. })) 643 + .count(); 644 + assert!(label_count >= 2); 645 + } 646 + }