Another project
0

Configure Feed

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

feat(ui): owned label text, modal scrim, double-click open

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

author
Lewis
date (May 17, 2026, 8:32 PM +0300) commit 8d3c4bd9 parent 5ae36bc4 change-id lorzunxr
+575 -97
+11
crates/bone-ui/src/frame.rs
··· 162 162 interaction 163 163 } 164 164 165 + pub fn block_pointer(&mut self, id: WidgetId, rect: LayoutRect) { 166 + self.hits.push(HitItem { 167 + id, 168 + rect, 169 + sense: Sense::INTERACTIVE, 170 + z: ZLayer::BASE, 171 + disabled: false, 172 + active: false, 173 + }); 174 + } 175 + 165 176 pub fn theme_scope<R>( 166 177 &mut self, 167 178 modify: impl FnOnce(&Theme) -> Theme,
+13 -13
crates/bone-ui/src/gallery.rs
··· 20 20 AlwaysValid, AngleEditor, BoolEditor, Button, ButtonState, ButtonVariant, Checkbox, 21 21 CheckboxState, ConfirmationDialog, ContextMenu, Dialog, DialogButton, Dropdown, DropdownItem, 22 22 DropdownState, FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerState, 23 - HotkeyCapture, HotkeyCaptureState, LengthEditor, ListItem, ListView, ListViewState, 23 + HotkeyCapture, HotkeyCaptureState, LabelText, LengthEditor, ListItem, ListView, ListViewState, 24 24 MemoryClipboard, Menu, MenuBar, MenuBarEntry, MenuBarState, MenuItem, MenuState, Modal, 25 25 NumericInput, Panel, PanelState, PanelTitlebar, PropertyGrid, PropertyOption, PropertyRow, 26 - RadioGroup, RadioOption, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, 27 - SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, 28 - Table, TableColumn, TableRow, TableState, Tabs, TabsOrientation, TextEditor, TextInput, 29 - TextInputState, Toast, ToastKind, ToastState, ToggleButton, Toolbar, ToolbarItem, Tooltip, 30 - TooltipPlacement, TooltipState, TreeNode, TreeView, TreeViewState, WidgetPaint, show_button, 31 - show_checkbox, show_confirmation, show_context_menu, show_dialog, show_dropdown, 32 - show_file_picker, show_hotkey_capture, show_list_view, show_menu, show_menu_bar, show_modal, 33 - show_panel, show_parsed_input, show_property_grid, show_radio_group, show_ribbon, show_slider, 26 + RadioGroup, RadioOption, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, SelectionEditor, 27 + Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Table, TableColumn, 28 + TableRow, TableState, Tabs, TabsOrientation, TextEditor, TextInput, TextInputState, Toast, 29 + ToastKind, ToastState, ToggleButton, Toolbar, ToolbarItem, Tooltip, TooltipPlacement, 30 + TooltipState, TreeNode, TreeView, TreeViewState, WidgetPaint, show_button, show_checkbox, 31 + show_confirmation, show_context_menu, show_dialog, show_dropdown, show_file_picker, 32 + show_hotkey_capture, show_list_view, show_menu, show_menu_bar, show_modal, show_panel, 33 + show_parsed_input, show_property_grid, show_radio_group, show_ribbon, show_slider, 34 34 show_status_bar, show_table, show_tabs, show_text_input, show_toast, show_toggle_button, 35 35 show_toolbar, show_tooltip, show_tree_view, 36 36 }; ··· 592 592 let list_items = [ 593 593 ListItem { 594 594 id: id("list_a"), 595 - label: GALLERY_LABEL, 595 + label: LabelText::Key(GALLERY_LABEL), 596 596 }, 597 597 ListItem { 598 598 id: id("list_b"), 599 - label: GALLERY_LABEL, 599 + label: LabelText::Key(GALLERY_LABEL), 600 600 }, 601 601 ]; 602 602 let response = show_list_view( ··· 1009 1009 paint.extend(response.paint); 1010 1010 let picker_entries = [FilePickerEntry { 1011 1011 id: id("picker_entry"), 1012 - label: GALLERY_LABEL, 1012 + label: LabelText::Key(GALLERY_LABEL), 1013 1013 }]; 1014 1014 let response = show_file_picker( 1015 1015 ctx, ··· 1026 1026 paint.extend(response.paint); 1027 1027 let open_entries = [FilePickerEntry { 1028 1028 id: id("picker_entry_open"), 1029 - label: GALLERY_LABEL, 1029 + label: LabelText::Key(GALLERY_LABEL), 1030 1030 }]; 1031 1031 let response = show_file_picker( 1032 1032 ctx,
+26
crates/bone-ui/src/widget_id.rs
··· 35 35 self.mix(key.as_str(), index) 36 36 } 37 37 38 + #[must_use] 39 + pub fn child_named(self, key: WidgetKey, name: &str) -> Self { 40 + self.child(key).mix(name, 0) 41 + } 42 + 38 43 fn mix(self, key: &str, index: u64) -> Self { 39 44 let parent = self.0.get().to_le_bytes(); 40 45 let suffix = index.to_le_bytes(); ··· 114 119 let a = WidgetId::ROOT.child(PANEL); 115 120 let b = WidgetId::ROOT.child_indexed(PANEL, 0); 116 121 assert_eq!(a, b); 122 + } 123 + 124 + #[test] 125 + fn child_named_distinguishes_names() { 126 + let a = WidgetId::ROOT.child_named(ITEM, "alpha"); 127 + let b = WidgetId::ROOT.child_named(ITEM, "beta"); 128 + assert_ne!(a, b); 129 + } 130 + 131 + #[test] 132 + fn child_named_is_deterministic() { 133 + let a = WidgetId::ROOT.child_named(ITEM, "alpha"); 134 + let b = WidgetId::ROOT.child_named(ITEM, "alpha"); 135 + assert_eq!(a, b); 136 + } 137 + 138 + #[test] 139 + fn child_named_differs_from_child_indexed() { 140 + let a = WidgetId::ROOT.child_named(ITEM, "alpha"); 141 + let b = WidgetId::ROOT.child_indexed(ITEM, 0); 142 + assert_ne!(a, b); 117 143 } 118 144 119 145 #[test]
+61
crates/bone-ui/src/widgets/dialog.rs
··· 56 56 radius: ctx.theme().radius.none, 57 57 elevation: None, 58 58 }]; 59 + let scrim_id = modal.id.child(WidgetKey::new("scrim")); 60 + ctx.block_pointer(scrim_id, modal.viewport); 59 61 let body_rect = center_rect(modal.viewport, modal.size); 60 62 paint.push(WidgetPaint::Surface { 61 63 rect: body_rect, ··· 541 543 let (response, _) = run_dialog(&mut focus, &mut snap, &prev, &buttons); 542 544 let cx = response.body_rect.origin.x.value() + response.body_rect.size.width.value() / 2.0; 543 545 assert!((cx - 400.0).abs() <= 1.0); 546 + } 547 + 548 + #[test] 549 + fn modal_scrim_blocks_pointer_hits_to_chrome_below() { 550 + use crate::frame::InteractDeclaration; 551 + use crate::hit_test::Sense; 552 + 553 + let theme = Arc::new(Theme::light()); 554 + let table = HotkeyTable::new(); 555 + let mut focus = FocusManager::new(); 556 + let mut hits = HitFrame::new(); 557 + let prev = HitState::new(); 558 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 559 + let chrome_id = WidgetId::ROOT.child(WidgetKey::new("chrome.button")); 560 + let chrome_rect = LayoutRect::new( 561 + LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0)), 562 + LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(24.0)), 563 + ); 564 + snap.pointer = Some(PointerSample::new(LayoutPos::new( 565 + LayoutPx::new(20.0), 566 + LayoutPx::new(20.0), 567 + ))); 568 + snap.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 569 + snap.buttons_released = PointerButtonMask::just(PointerButton::Primary); 570 + { 571 + let mut shaper = bone_text::Shaper::new(); 572 + let mut a11y = crate::a11y::AccessTreeBuilder::new(); 573 + let mut ctx = FrameCtx::new( 574 + theme, 575 + &mut snap, 576 + &mut focus, 577 + &table, 578 + StringTable::empty(), 579 + &mut hits, 580 + &prev, 581 + &mut a11y, 582 + &mut shaper, 583 + ); 584 + ctx.interact(InteractDeclaration::new( 585 + chrome_id, 586 + chrome_rect, 587 + Sense::INTERACTIVE, 588 + )); 589 + let (_response, ()) = show_modal( 590 + &mut ctx, 591 + Modal::new( 592 + modal_id(), 593 + viewport(), 594 + LayoutSize::new(LayoutPx::new(300.0), LayoutPx::new(200.0)), 595 + StringKey::new("test.modal"), 596 + ), 597 + |_ctx, _body_rect, _paint| {}, 598 + ); 599 + } 600 + let resolved = resolve(&prev, &hits, &snap, focus.focused()); 601 + assert!( 602 + !resolved.interaction(chrome_id).click(), 603 + "modal scrim must absorb pointer clicks aimed at chrome below the modal", 604 + ); 544 605 } 545 606 546 607 #[test]
+178 -30
crates/bone-ui/src/widgets/file_picker.rs
··· 15 15 Save, 16 16 } 17 17 18 - #[derive(Copy, Clone, Debug, PartialEq)] 18 + #[derive(Clone, Debug, PartialEq)] 19 19 pub struct FilePickerEntry { 20 20 pub id: WidgetId, 21 - pub label: StringKey, 21 + pub label: LabelText, 22 22 } 23 23 24 24 #[derive(Clone, Debug, Default, PartialEq)] ··· 34 34 pub viewport: LayoutRect, 35 35 pub size: LayoutSize, 36 36 pub mode: FilePickerMode, 37 - pub current_path: StringKey, 37 + pub current_path: LabelText, 38 38 pub entries: &'a [FilePickerEntry], 39 39 pub state: &'state mut FilePickerState, 40 40 pub title: StringKey, ··· 46 46 id: WidgetId, 47 47 viewport: LayoutRect, 48 48 mode: FilePickerMode, 49 - current_path: StringKey, 49 + current_path: impl Into<LabelText>, 50 50 entries: &'a [FilePickerEntry], 51 51 title: StringKey, 52 52 state: &'state mut FilePickerState, ··· 56 56 viewport, 57 57 size: LayoutSize::new(LayoutPx::new(540.0), LayoutPx::new(420.0)), 58 58 mode, 59 - current_path, 59 + current_path: current_path.into(), 60 60 entries, 61 61 state, 62 62 title, ··· 104 104 FilePickerMode::Open => StringKey::new("file_picker.open"), 105 105 FilePickerMode::Save => StringKey::new("file_picker.save"), 106 106 }; 107 + let trimmed_empty = state.filename.text.trim().is_empty(); 108 + let any_text = !state.filename.text.is_empty(); 109 + let confirm_disabled = match mode { 110 + FilePickerMode::Open => state.list.focused.is_none(), 111 + FilePickerMode::Save => trimmed_empty && (any_text || state.list.focused.is_none()), 112 + }; 107 113 let buttons = [ 108 114 DialogButton::secondary(cancel_id, StringKey::new("file_picker.cancel")), 109 - DialogButton::primary(confirm_id, confirm_label), 115 + DialogButton { 116 + disabled: confirm_disabled, 117 + ..DialogButton::primary(confirm_id, confirm_label) 118 + }, 110 119 ]; 111 120 let list_items: Vec<ListItem> = entries 112 121 .iter() 113 122 .map(|e| ListItem { 114 123 id: e.id, 115 - label: e.label, 124 + label: e.label.clone(), 116 125 }) 117 126 .collect(); 118 - let (response, navigated_to) = show_dialog( 127 + let (response, list_opened) = show_dialog( 119 128 ctx, 120 129 Dialog::new(id, viewport, size, title, &buttons), 121 130 |ctx, body_rect, paint| { 122 131 paint.push(WidgetPaint::Label { 123 132 rect: path_label_rect(body_rect), 124 - text: LabelText::Key(current_path), 133 + text: current_path.clone(), 125 134 color: ctx.theme().colors.text_secondary(), 126 135 role: ctx.theme().typography.caption, 127 136 }); ··· 159 168 let text_response = show_text_input(ctx, widget, &mut state.clipboard); 160 169 paint.extend(text_response.paint); 161 170 } 162 - list_response.activated 171 + list_response.opened 163 172 }, 164 173 ); 165 - let outcome = if response.dismissed || response.activated == Some(cancel_id) { 166 - Some(FilePickerOutcome::Cancelled) 167 - } else if response.activated == Some(confirm_id) { 168 - match mode { 169 - FilePickerMode::Open => navigated_to 170 - .or(state.list.focused) 171 - .map(|folder| FilePickerOutcome::Open { folder }), 172 - FilePickerMode::Save => Some(FilePickerOutcome::Save { 173 - folder: state.list.focused, 174 - filename: state.filename.text.clone(), 175 - }), 176 - } 177 - } else { 178 - None 179 - }; 174 + let outcome = resolve_outcome( 175 + ResolveInputs { 176 + mode, 177 + confirm_id, 178 + cancel_id, 179 + opened: list_opened, 180 + list_focused: state.list.focused, 181 + filename: state.filename.text.as_str(), 182 + }, 183 + &response, 184 + ); 180 185 FilePickerResponse { 181 186 outcome, 182 - navigated_to, 187 + navigated_to: list_opened, 183 188 paint: response.paint, 184 189 } 185 190 } 186 191 192 + #[derive(Copy, Clone)] 193 + struct ResolveInputs<'a> { 194 + mode: FilePickerMode, 195 + confirm_id: WidgetId, 196 + cancel_id: WidgetId, 197 + opened: Option<WidgetId>, 198 + list_focused: Option<WidgetId>, 199 + filename: &'a str, 200 + } 201 + 202 + fn resolve_outcome( 203 + inputs: ResolveInputs<'_>, 204 + response: &super::dialog::DialogResponse, 205 + ) -> Option<FilePickerOutcome> { 206 + if response.dismissed || response.activated == Some(inputs.cancel_id) { 207 + return Some(FilePickerOutcome::Cancelled); 208 + } 209 + if inputs.mode == FilePickerMode::Open 210 + && let Some(folder) = inputs.opened 211 + { 212 + return Some(FilePickerOutcome::Open { folder }); 213 + } 214 + if response.activated != Some(inputs.confirm_id) { 215 + return None; 216 + } 217 + match inputs.mode { 218 + FilePickerMode::Open => inputs 219 + .list_focused 220 + .map(|folder| FilePickerOutcome::Open { folder }), 221 + FilePickerMode::Save => Some(FilePickerOutcome::Save { 222 + folder: inputs.list_focused, 223 + filename: inputs.filename.to_owned(), 224 + }), 225 + } 226 + } 227 + 187 228 fn path_label_rect(body: LayoutRect) -> LayoutRect { 188 229 LayoutRect::new( 189 230 LayoutPos::new( ··· 234 275 235 276 use super::{ 236 277 FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerOutcome, FilePickerState, 237 - show_file_picker, 278 + LabelText, show_file_picker, 238 279 }; 239 280 use crate::focus::FocusManager; 240 281 use crate::frame::FrameCtx; 241 282 use crate::hit_test::{HitFrame, HitState, resolve}; 242 283 use crate::hotkey::HotkeyTable; 243 - use crate::input::{FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey}; 284 + use crate::input::{ 285 + FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, 286 + PointerButtonMask, PointerSample, 287 + }; 244 288 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 245 289 use crate::strings::{StringKey, StringTable}; 246 290 use crate::theme::Theme; ··· 254 298 vec![ 255 299 FilePickerEntry { 256 300 id: picker_id().child(WidgetKey::new("doc1")), 257 - label: StringKey::new("doc.first"), 301 + label: LabelText::Key(StringKey::new("doc.first")), 258 302 }, 259 303 FilePickerEntry { 260 304 id: picker_id().child(WidgetKey::new("doc2")), 261 - label: StringKey::new("doc.second"), 305 + label: LabelText::Key(StringKey::new("doc.second")), 262 306 }, 263 307 ] 264 308 } ··· 268 312 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 269 313 LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(600.0)), 270 314 ) 315 + } 316 + 317 + fn run_picker( 318 + focus: &mut FocusManager, 319 + hits: &mut HitFrame, 320 + prev: &HitState, 321 + snap: &mut InputSnapshot, 322 + state: &mut FilePickerState, 323 + entries: &[FilePickerEntry], 324 + mode: FilePickerMode, 325 + ) -> super::FilePickerResponse { 326 + let theme = Arc::new(Theme::light()); 327 + let table = HotkeyTable::new(); 328 + let mut shaper = bone_text::Shaper::new(); 329 + let mut a11y = crate::a11y::AccessTreeBuilder::new(); 330 + let mut ctx = FrameCtx::new( 331 + theme, 332 + snap, 333 + focus, 334 + &table, 335 + StringTable::empty(), 336 + hits, 337 + prev, 338 + &mut a11y, 339 + &mut shaper, 340 + ); 341 + show_file_picker( 342 + &mut ctx, 343 + FilePickerDialog::new( 344 + picker_id(), 345 + viewport(), 346 + mode, 347 + StringKey::new("path.home"), 348 + entries, 349 + StringKey::new("file_picker.title"), 350 + state, 351 + ), 352 + ) 353 + } 354 + 355 + fn row_center(entries: &[FilePickerEntry], idx: usize) -> LayoutPos { 356 + let body_y = viewport().origin.y.value() + viewport().size.height.value() * 0.5 - 210.0; 357 + let body_top = body_y + 44.0; 358 + let list_top = body_top + 32.0; 359 + let row_height = 24.0; 360 + let _ = entries; 361 + let center_x = viewport().origin.x.value() + viewport().size.width.value() * 0.5 - 200.0; 362 + #[allow(clippy::cast_precision_loss, reason = "row index fits f32 mantissa")] 363 + let center_y = list_top + (idx as f32 + 0.5) * row_height; 364 + LayoutPos::new(LayoutPx::new(center_x), LayoutPx::new(center_y)) 365 + } 366 + 367 + #[test] 368 + fn double_click_row_in_open_mode_commits_open() { 369 + let entries = entries(); 370 + let mut state = FilePickerState::default(); 371 + let mut focus = FocusManager::new(); 372 + let mut prev = HitState::new(); 373 + let click_pos = row_center(&entries, 1); 374 + let press = |pos: LayoutPos| { 375 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 376 + snap.pointer = Some(PointerSample::new(pos)); 377 + snap.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 378 + snap 379 + }; 380 + let release = |pos: LayoutPos| { 381 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 382 + snap.pointer = Some(PointerSample::new(pos)); 383 + snap.buttons_released = PointerButtonMask::just(PointerButton::Primary); 384 + snap 385 + }; 386 + let idle_pos = |pos: LayoutPos| { 387 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 388 + snap.pointer = Some(PointerSample::new(pos)); 389 + snap 390 + }; 391 + let frames = [ 392 + press(click_pos), 393 + release(click_pos), 394 + press(click_pos), 395 + release(click_pos), 396 + idle_pos(click_pos), 397 + ]; 398 + let mut last_outcome: Option<FilePickerOutcome> = None; 399 + frames.into_iter().for_each(|mut snap| { 400 + let mut hits = HitFrame::new(); 401 + let response = run_picker( 402 + &mut focus, 403 + &mut hits, 404 + &prev, 405 + &mut snap, 406 + &mut state, 407 + &entries, 408 + FilePickerMode::Open, 409 + ); 410 + last_outcome = response.outcome; 411 + prev = resolve(&prev, &hits, &snap, focus.focused()); 412 + }); 413 + assert_eq!( 414 + last_outcome, 415 + Some(FilePickerOutcome::Open { 416 + folder: entries[1].id, 417 + }), 418 + ); 271 419 } 272 420 273 421 #[test]
+1 -3
crates/bone-ui/src/widgets/mod.rs
··· 63 63 pub use radio_group::{ 64 64 RadioGroup, RadioGroupResponse, RadioOption, RadioOrientation, show_radio_group, 65 65 }; 66 - pub use ribbon::{ 67 - Ribbon, RibbonGroup, RibbonIconSize, RibbonResponse, RibbonTab, show_ribbon, 68 - }; 66 + pub use ribbon::{Ribbon, RibbonGroup, RibbonIconSize, RibbonResponse, RibbonTab, show_ribbon}; 69 67 pub use slider::{ 70 68 Slider, SliderCoarseStep, SliderRange, SliderRangeError, SliderResponse, SliderScalar, 71 69 SliderStep, SliderStepError, show_slider,
+24 -5
crates/bone-ui/src/widgets/paint.rs
··· 83 83 } 84 84 } 85 85 86 + impl From<StringKey> for LabelText { 87 + fn from(key: StringKey) -> Self { 88 + Self::Key(key) 89 + } 90 + } 91 + 92 + impl From<String> for LabelText { 93 + fn from(value: String) -> Self { 94 + Self::Owned(value) 95 + } 96 + } 97 + 98 + impl From<&str> for LabelText { 99 + fn from(value: &str) -> Self { 100 + Self::Owned(value.to_owned()) 101 + } 102 + } 103 + 86 104 #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize)] 87 105 pub enum HorizontalAlign { 88 106 #[default] ··· 178 196 border: *border, 179 197 radius: *radius, 180 198 }, 181 - WidgetPaint::Label { rect, color, .. } 182 - | WidgetPaint::AlignedLabel { rect, color, .. } => PaintPrim::solid( 183 - label_placeholder_bar(*rect), 184 - color.with_alpha(LABEL_PLACEHOLDER_ALPHA * color.alpha()), 185 - ), 199 + WidgetPaint::Label { rect, color, .. } | WidgetPaint::AlignedLabel { rect, color, .. } => { 200 + PaintPrim::solid( 201 + label_placeholder_bar(*rect), 202 + color.with_alpha(LABEL_PLACEHOLDER_ALPHA * color.alpha()), 203 + ) 204 + } 186 205 WidgetPaint::Mark { rect, color, .. } => { 187 206 PaintPrim::solid(centered_square(*rect, MARK_PLACEHOLDER_FACTOR), *color) 188 207 }
+65 -8
crates/bone-ui/src/widgets/table.rs
··· 35 35 pub direction: SortDirection, 36 36 } 37 37 38 - #[derive(Copy, Clone, Debug, PartialEq)] 38 + #[derive(Clone, Debug, PartialEq)] 39 39 pub struct ListItem { 40 40 pub id: WidgetId, 41 - pub label: StringKey, 41 + pub label: LabelText, 42 42 } 43 43 44 44 #[derive(Copy, Clone, Debug, PartialEq, Eq)] ··· 93 93 #[derive(Clone, Debug, PartialEq)] 94 94 pub struct ListViewResponse { 95 95 pub activated: Option<WidgetId>, 96 + pub opened: Option<WidgetId>, 96 97 pub paint: Vec<WidgetPaint>, 97 98 } 98 99 ··· 124 125 ctx.focus.register_tab_stop(id); 125 126 } 126 127 let mut activated: Option<WidgetId> = None; 128 + let mut opened: Option<WidgetId> = None; 127 129 items.iter().enumerate().for_each(|(idx, item)| { 128 130 let row_rect = list_row_rect(rect, idx, row_height); 129 131 let selected = state.selection.contains(&item.id); ··· 133 135 .active(selected) 134 136 .a11y( 135 137 AccessNode::new(Role::ListBoxOption) 136 - .with_label(item.label) 138 + .with_label_text(item.label.clone()) 137 139 .with_selected(selected), 138 140 ), 139 141 ); ··· 146 148 activated = Some(item.id); 147 149 } 148 150 } 151 + if interaction.double_click() && opened.is_none() { 152 + opened = Some(item.id); 153 + } 149 154 let fill = list_row_fill(ctx, &interaction, state.selection.contains(&item.id)); 150 155 paint.push(WidgetPaint::Surface { 151 156 rect: row_rect, ··· 156 161 }); 157 162 paint.push(WidgetPaint::Label { 158 163 rect: row_rect, 159 - text: LabelText::Key(item.label), 164 + text: item.label.clone(), 160 165 color: ctx.theme().colors.text_primary(), 161 166 role: ctx.theme().typography.body, 162 167 }); ··· 169 174 ); 170 175 }); 171 176 handle_list_keyboard(ctx, items, state); 172 - ListViewResponse { activated, paint } 177 + ListViewResponse { 178 + activated, 179 + opened, 180 + paint, 181 + } 173 182 } 174 183 175 184 fn list_row_rect(view: LayoutRect, idx: usize, row_height: LayoutPx) -> LayoutRect { ··· 743 752 use std::sync::Arc; 744 753 745 754 use super::{ 746 - ListItem, ListView, ListViewState, SortDirection, Table, TableColumn, TableRow, TableState, 747 - show_list_view, show_table, 755 + LabelText, ListItem, ListView, ListViewState, SortDirection, Table, TableColumn, TableRow, 756 + TableState, show_list_view, show_table, 748 757 }; 749 758 use crate::focus::FocusManager; 750 759 use crate::frame::FrameCtx; ··· 767 776 (0..count) 768 777 .map(|i| ListItem { 769 778 id: list_id().child_indexed(WidgetKey::new("item"), i), 770 - label: StringKey::new("list.item"), 779 + label: LabelText::Key(StringKey::new("list.item")), 771 780 }) 772 781 .collect() 773 782 } ··· 868 877 )); 869 878 let _ = render_list(&items, &mut state, &mut focus, &mut snap, &prev); 870 879 assert_eq!(state.focused, Some(items[1].id)); 880 + } 881 + 882 + #[test] 883 + fn double_click_list_item_sets_opened() { 884 + let items = list_items(3); 885 + let mut state = ListViewState::default(); 886 + let mut focus = FocusManager::new(); 887 + let mut prev = HitState::new(); 888 + let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(33.0)); 889 + let mut last: Option<super::ListViewResponse> = None; 890 + let frames = [ 891 + press(click_pos), 892 + release(click_pos), 893 + press(click_pos), 894 + release(click_pos), 895 + idle(click_pos), 896 + ]; 897 + frames.into_iter().for_each(|mut snap| { 898 + let (response, next) = render_list(&items, &mut state, &mut focus, &mut snap, &prev); 899 + last = Some(response); 900 + prev = next; 901 + }); 902 + let Some(response) = last else { 903 + panic!("response missing") 904 + }; 905 + assert_eq!(response.opened, Some(items[1].id)); 906 + } 907 + 908 + #[test] 909 + fn single_click_list_item_does_not_set_opened() { 910 + let items = list_items(3); 911 + let mut state = ListViewState::default(); 912 + let mut focus = FocusManager::new(); 913 + let mut prev = HitState::new(); 914 + let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(33.0)); 915 + let mut last: Option<super::ListViewResponse> = None; 916 + [press(click_pos), release(click_pos), idle(click_pos)] 917 + .into_iter() 918 + .for_each(|mut snap| { 919 + let (response, next) = 920 + render_list(&items, &mut state, &mut focus, &mut snap, &prev); 921 + last = Some(response); 922 + prev = next; 923 + }); 924 + let Some(response) = last else { 925 + panic!("response missing") 926 + }; 927 + assert!(response.opened.is_none()); 871 928 } 872 929 873 930 #[test]
+196 -38
crates/bone-ui/src/widgets/tree_view.rs
··· 102 102 pub clipboard: MemoryClipboard, 103 103 pub drag_source: Option<WidgetId>, 104 104 pub drop_target: Option<DropTarget>, 105 + pub pending_rename: Option<PendingRename>, 106 + } 107 + 108 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 109 + pub struct PendingRename { 110 + pub id: WidgetId, 111 + pub at: crate::input::FrameInstant, 105 112 } 106 113 107 114 #[derive(Debug, PartialEq)] ··· 224 231 acc 225 232 }); 226 233 paint.extend(row_paint); 234 + commit_pending_rename(ctx, &visible, state, renamable); 227 235 let rename_committed = if let Some(id) = state.renaming 228 236 && take_key(ctx.input, &[TakeKey::named(NamedKey::Enter)]).is_some() 229 237 { ··· 427 435 outcomes: RowOutcomes<'a>, 428 436 } 429 437 438 + fn commit_pending_rename( 439 + ctx: &mut FrameCtx<'_>, 440 + visible: &[VisibleRow], 441 + state: &mut TreeViewState, 442 + renamable: &[WidgetId], 443 + ) { 444 + let Some(pending) = state.pending_rename else { 445 + return; 446 + }; 447 + if state.renaming.is_some() || !renamable.contains(&pending.id) { 448 + state.pending_rename = None; 449 + return; 450 + } 451 + let Some(row) = visible.iter().find(|r| r.id == pending.id) else { 452 + state.pending_rename = None; 453 + return; 454 + }; 455 + let elapsed = ctx.input.frame.since(pending.at); 456 + if elapsed < ctx.input.double_click_window.duration() { 457 + return; 458 + } 459 + let text = match &row.label { 460 + LabelText::Key(k) => ctx.strings.resolve(*k).to_owned(), 461 + LabelText::Owned(s) => s.clone(), 462 + }; 463 + state.renaming = Some(pending.id); 464 + state.rename_buffer = TextInputState::from_text(&text); 465 + state.pending_rename = None; 466 + } 467 + 430 468 fn apply_row_interaction(ctx: &mut FrameCtx<'_>, args: RowInteractionArgs<'_>) { 431 469 let RowInteractionArgs { 432 470 row, ··· 454 492 } 455 493 state.focused = Some(row.id); 456 494 ctx.focus.request_focus(row.id); 457 - if !is_double 458 - && was_selected 459 - && state.renaming.is_none() 460 - && renamable.contains(&row.id) 461 - { 462 - let text = match &row.label { 463 - LabelText::Key(k) => ctx.strings.resolve(*k).to_owned(), 464 - LabelText::Owned(s) => s.clone(), 465 - }; 466 - state.renaming = Some(row.id); 467 - state.rename_buffer = TextInputState::from_text(&text); 495 + let row_pending = state.pending_rename.is_some_and(|p| p.id == row.id); 496 + if !is_double && was_selected && state.renaming.is_none() && renamable.contains(&row.id) { 497 + if !row_pending { 498 + state.pending_rename = Some(PendingRename { 499 + id: row.id, 500 + at: ctx.input.frame, 501 + }); 502 + } 503 + } else if is_double || (!was_selected && row_pending) { 504 + state.pending_rename = None; 468 505 } 469 506 } 470 - if interaction.double_click() && double_activated.is_none() { 471 - *double_activated = Some(row.id); 507 + if interaction.double_click() { 508 + if state.pending_rename.is_some_and(|p| p.id == row.id) { 509 + state.pending_rename = None; 510 + } 511 + if double_activated.is_none() { 512 + *double_activated = Some(row.id); 513 + } 472 514 } 473 515 if interaction.drag_start() { 474 516 state.drag_source = Some(row.id); ··· 844 886 use std::collections::BTreeSet; 845 887 use std::sync::Arc; 846 888 847 - use super::{TreeNode, TreeSelectionMode, TreeView, TreeViewState, show_tree_view}; 889 + use super::{ 890 + PendingRename, TreeNode, TreeSelectionMode, TreeView, TreeViewState, show_tree_view, 891 + }; 848 892 use crate::focus::FocusManager; 849 893 use crate::frame::FrameCtx; 850 894 use crate::hit_test::{HitFrame, HitState, resolve}; 851 895 use crate::hotkey::HotkeyTable; 852 896 use crate::input::{ 853 - FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, 854 - PointerButtonMask, PointerSample, 897 + DoubleClickWindow, FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, 898 + PointerButton, PointerButtonMask, PointerSample, 855 899 }; 856 900 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 857 901 use crate::strings::{StringKey, StringTable}; ··· 932 976 } 933 977 934 978 fn press(pos: LayoutPos) -> InputSnapshot { 935 - let mut s = InputSnapshot::idle(FrameInstant::ZERO); 979 + press_at(pos, FrameInstant::ZERO) 980 + } 981 + 982 + fn release(pos: LayoutPos) -> InputSnapshot { 983 + release_at(pos, FrameInstant::ZERO) 984 + } 985 + 986 + fn idle(pos: LayoutPos) -> InputSnapshot { 987 + idle_at(pos, FrameInstant::ZERO) 988 + } 989 + 990 + fn press_at(pos: LayoutPos, at: FrameInstant) -> InputSnapshot { 991 + let mut s = InputSnapshot::idle(at); 936 992 s.pointer = Some(PointerSample::new(pos)); 937 993 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 938 994 s 939 995 } 940 996 941 - fn release(pos: LayoutPos) -> InputSnapshot { 942 - let mut s = InputSnapshot::idle(FrameInstant::ZERO); 997 + fn release_at(pos: LayoutPos, at: FrameInstant) -> InputSnapshot { 998 + let mut s = InputSnapshot::idle(at); 943 999 s.pointer = Some(PointerSample::new(pos)); 944 1000 s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 945 1001 s 946 1002 } 947 1003 948 - fn idle(pos: LayoutPos) -> InputSnapshot { 949 - let mut s = InputSnapshot::idle(FrameInstant::ZERO); 1004 + fn idle_at(pos: LayoutPos, at: FrameInstant) -> InputSnapshot { 1005 + let mut s = InputSnapshot::idle(at); 950 1006 s.pointer = Some(PointerSample::new(pos)); 951 1007 s 952 1008 } ··· 1214 1270 focus.end_frame(); 1215 1271 let prev = HitState::new(); 1216 1272 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1217 - snap.keys_pressed 1218 - .push(KeyEvent::new(KeyCode::Named(NamedKey::F2), ModifierMask::NONE)); 1273 + snap.keys_pressed.push(KeyEvent::new( 1274 + KeyCode::Named(NamedKey::F2), 1275 + ModifierMask::NONE, 1276 + )); 1219 1277 let (_, _) = render_with( 1220 1278 &roots, 1221 1279 &mut state, ··· 1239 1297 let mut focus = FocusManager::new(); 1240 1298 let mut prev = HitState::new(); 1241 1299 let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1242 - [press(click_pos), release(click_pos), idle(click_pos)] 1243 - .into_iter() 1244 - .for_each(|mut snap| { 1245 - let (_, next) = render_with( 1246 - &roots, 1247 - &mut state, 1248 - &mut focus, 1249 - &mut snap, 1250 - &prev, 1251 - &[sketch_id], 1252 - ); 1253 - prev = next; 1254 - }); 1300 + let window = DoubleClickWindow::DEFAULT.duration(); 1301 + let after_click = FrameInstant::from_duration(core::time::Duration::from_millis(10)); 1302 + let after_window = FrameInstant::from_duration( 1303 + after_click.duration() + window + core::time::Duration::from_millis(5), 1304 + ); 1305 + let frames = [ 1306 + press_at(click_pos, FrameInstant::ZERO), 1307 + release_at(click_pos, FrameInstant::ZERO), 1308 + idle_at(click_pos, after_click), 1309 + idle_at(click_pos, after_window), 1310 + ]; 1311 + frames.into_iter().for_each(|mut snap| { 1312 + let (_, next) = render_with( 1313 + &roots, 1314 + &mut state, 1315 + &mut focus, 1316 + &mut snap, 1317 + &prev, 1318 + &[sketch_id], 1319 + ); 1320 + prev = next; 1321 + }); 1255 1322 assert_eq!(state.renaming, Some(sketch_id)); 1256 1323 assert_eq!(state.rename_buffer.text, "Profile"); 1257 1324 } 1258 1325 1259 1326 #[test] 1327 + fn extra_slow_click_on_already_pending_row_does_not_reset_pending_at() { 1328 + let sketch_id = root_id("sketch"); 1329 + let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; 1330 + let original_at = FrameInstant::from_duration(core::time::Duration::from_millis(5)); 1331 + let mut state = TreeViewState { 1332 + selection: BTreeSet::from([sketch_id]), 1333 + pending_rename: Some(PendingRename { 1334 + id: sketch_id, 1335 + at: original_at, 1336 + }), 1337 + ..TreeViewState::default() 1338 + }; 1339 + let mut focus = FocusManager::new(); 1340 + let mut prev = HitState::new(); 1341 + let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1342 + let new_click = FrameInstant::from_duration(core::time::Duration::from_millis(120)); 1343 + let new_visible = FrameInstant::from_duration( 1344 + new_click.duration() + core::time::Duration::from_millis(10), 1345 + ); 1346 + let frames = [ 1347 + press_at(click_pos, new_click), 1348 + release_at(click_pos, new_click), 1349 + idle_at(click_pos, new_visible), 1350 + ]; 1351 + frames.into_iter().for_each(|mut snap| { 1352 + let (_, next) = render_with( 1353 + &roots, 1354 + &mut state, 1355 + &mut focus, 1356 + &mut snap, 1357 + &prev, 1358 + &[sketch_id], 1359 + ); 1360 + prev = next; 1361 + }); 1362 + let Some(pending) = state.pending_rename else { 1363 + panic!("pending rename must persist across slow extra clicks"); 1364 + }; 1365 + assert_eq!( 1366 + pending.at, original_at, 1367 + "subsequent slow click on already-pending row must keep the original at", 1368 + ); 1369 + } 1370 + 1371 + #[test] 1372 + fn double_click_on_already_selected_renamable_row_does_not_start_rename() { 1373 + let sketch_id = root_id("sketch"); 1374 + let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; 1375 + let mut state = TreeViewState { 1376 + selection: BTreeSet::from([sketch_id]), 1377 + ..TreeViewState::default() 1378 + }; 1379 + let mut focus = FocusManager::new(); 1380 + let mut prev = HitState::new(); 1381 + let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0)); 1382 + let window = DoubleClickWindow::DEFAULT.duration(); 1383 + let fast = FrameInstant::from_duration(core::time::Duration::from_millis(60)); 1384 + let after_window = FrameInstant::from_duration( 1385 + fast.duration() + window + core::time::Duration::from_millis(5), 1386 + ); 1387 + let frames = [ 1388 + press_at(click_pos, FrameInstant::ZERO), 1389 + release_at(click_pos, FrameInstant::ZERO), 1390 + press_at(click_pos, fast), 1391 + release_at(click_pos, fast), 1392 + idle_at(click_pos, after_window), 1393 + ]; 1394 + frames.into_iter().for_each(|mut snap| { 1395 + let (_, next) = render_with( 1396 + &roots, 1397 + &mut state, 1398 + &mut focus, 1399 + &mut snap, 1400 + &prev, 1401 + &[sketch_id], 1402 + ); 1403 + prev = next; 1404 + }); 1405 + assert_eq!( 1406 + state.renaming, None, 1407 + "fast double-click on already-selected row must not start rename", 1408 + ); 1409 + assert_eq!( 1410 + state.pending_rename, None, 1411 + "double-click must clear any deferred rename", 1412 + ); 1413 + } 1414 + 1415 + #[test] 1260 1416 fn double_click_on_renamable_row_does_not_start_rename() { 1261 1417 let sketch_id = root_id("sketch"); 1262 1418 let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())]; ··· 1305 1461 focus.end_frame(); 1306 1462 let prev = HitState::new(); 1307 1463 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1308 - snap.keys_pressed 1309 - .push(KeyEvent::new(KeyCode::Named(NamedKey::F2), ModifierMask::NONE)); 1464 + snap.keys_pressed.push(KeyEvent::new( 1465 + KeyCode::Named(NamedKey::F2), 1466 + ModifierMask::NONE, 1467 + )); 1310 1468 let (_, _) = render(&roots, &mut state, &mut focus, &mut snap, &prev); 1311 1469 assert!(state.renaming.is_none()); 1312 1470 }
crates/bone-ui/tests/snapshots/gallery_dark.png

This is a binary file and will not be displayed.

crates/bone-ui/tests/snapshots/gallery_light.png

This is a binary file and will not be displayed.