Another project
0

Configure Feed

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

feat(app): actual dimension editor popup

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

author
Lewis
date (May 13, 2026, 12:03 AM +0300) commit e3bb5f1c parent ab6f6c2a change-id rmwvppmu
+691
+691
crates/bone-app/src/dimension_editor.rs
··· 1 + use bone_document::{DimensionValue, SketchDimension}; 2 + use bone_render::Camera2; 3 + use bone_types::{Angle, Length, Point2}; 4 + use bone_ui::frame::FrameCtx; 5 + use bone_ui::input::{NamedKey, PointerButton}; 6 + use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 7 + use bone_ui::widgets::{ 8 + Button, ButtonState, ButtonVariant, MemoryClipboard, ParsedInput, ParsedInputResponse, TakeKey, 9 + TextInputState, WidgetPaint, show_button, show_parsed_input, take_key, 10 + }; 11 + use bone_ui::{WidgetId, WidgetKey}; 12 + use uom::si::angle::degree; 13 + use uom::si::length::millimeter; 14 + 15 + use crate::sketch_mode::PendingDimension; 16 + use crate::smart_dimension; 17 + use crate::strings; 18 + 19 + const EDITOR_WIDTH_PX: f32 = 140.0; 20 + const EDITOR_HEIGHT_PX: f32 = 24.0; 21 + const EDITOR_OFFSET_X_PX: f32 = 12.0; 22 + const EDITOR_OFFSET_Y_PX: f32 = -12.0; 23 + const SWAP_BUTTON_WIDTH_PX: f32 = 64.0; 24 + const SWAP_BUTTON_GAP_PX: f32 = 4.0; 25 + 26 + #[derive(Default)] 27 + pub struct DimensionEditorState { 28 + pub text: TextInputState, 29 + pub clipboard: MemoryClipboard, 30 + pub last_seeded_for: Option<SketchDimension>, 31 + pub focus_request_pending: bool, 32 + } 33 + 34 + impl DimensionEditorState { 35 + pub fn reset_for(&mut self, proto: SketchDimension) { 36 + self.text = TextInputState::from_text(format_initial_value(proto.value())); 37 + self.last_seeded_for = Some(proto); 38 + self.focus_request_pending = true; 39 + } 40 + 41 + pub fn close(&mut self) { 42 + self.text = TextInputState::default(); 43 + self.last_seeded_for = None; 44 + self.focus_request_pending = false; 45 + } 46 + } 47 + 48 + #[derive(Clone, Debug, PartialEq)] 49 + pub enum DimensionEditorAction { 50 + Idle, 51 + Commit(DimensionValue), 52 + Cancel, 53 + Swap(SketchDimension), 54 + } 55 + 56 + pub struct DimensionEditorOutcome { 57 + pub paints: Vec<WidgetPaint>, 58 + pub action: DimensionEditorAction, 59 + pub bounds: LayoutRect, 60 + pub claimed_pointer: bool, 61 + } 62 + 63 + pub fn render( 64 + ctx: &mut FrameCtx<'_>, 65 + pending: PendingDimension, 66 + anchor: Point2, 67 + camera: &Camera2, 68 + viewport_rect: LayoutRect, 69 + state: &mut DimensionEditorState, 70 + ) -> DimensionEditorOutcome { 71 + let editor_id = WidgetId::ROOT.child(WidgetKey::new("dim.editor")); 72 + let radius_id = editor_id.child(WidgetKey::new("swap.radius")); 73 + let diameter_id = editor_id.child(WidgetKey::new("swap.diameter")); 74 + let editor_rect = editor_rect(viewport_rect, camera, anchor); 75 + let mut paints = Vec::new(); 76 + if state.last_seeded_for != Some(pending.proto) { 77 + state.reset_for(pending.proto); 78 + } 79 + if state.focus_request_pending { 80 + ctx.request_focus(editor_id); 81 + state.focus_request_pending = false; 82 + } 83 + let placeholder = smart_dimension::placeholder_for(pending.proto.value()); 84 + let parsed = match pending.proto.value() { 85 + DimensionValue::Length(_) => render_length(ctx, editor_id, editor_rect, placeholder, state), 86 + DimensionValue::Angle(_) => render_angle(ctx, editor_id, editor_rect, placeholder, state), 87 + }; 88 + paints.extend(parsed.paint); 89 + let effective_proto = merge_typed_value(pending.proto, parsed.value); 90 + let swap = render_swap(ctx, &effective_proto, editor_rect, radius_id, diameter_id); 91 + paints.extend(swap.paints); 92 + let canceled = take_key(ctx.input, &[TakeKey::named(NamedKey::Escape)]).is_some(); 93 + let bounds = match swap.bounds { 94 + Some(swap_rect) => editor_rect.union(swap_rect), 95 + None => editor_rect, 96 + }; 97 + let pressed_outside = primary_press_outside(ctx, bounds); 98 + let action = if canceled { 99 + DimensionEditorAction::Cancel 100 + } else if let Some(swapped) = swap.action { 101 + DimensionEditorAction::Swap(swapped) 102 + } else if let Some(value) = parsed.committed { 103 + DimensionEditorAction::Commit(value) 104 + } else if pressed_outside { 105 + match parsed.value { 106 + Some(value) => DimensionEditorAction::Commit(value), 107 + None => DimensionEditorAction::Cancel, 108 + } 109 + } else { 110 + DimensionEditorAction::Idle 111 + }; 112 + DimensionEditorOutcome { 113 + paints, 114 + action, 115 + bounds, 116 + claimed_pointer: pressed_outside, 117 + } 118 + } 119 + 120 + #[must_use] 121 + fn merge_typed_value(proto: SketchDimension, typed: Option<DimensionValue>) -> SketchDimension { 122 + typed 123 + .and_then(|v| proto.with_value(v).ok()) 124 + .unwrap_or(proto) 125 + } 126 + 127 + fn primary_press_outside(ctx: &FrameCtx<'_>, bounds: LayoutRect) -> bool { 128 + if !ctx.input.buttons_pressed.contains(PointerButton::Primary) { 129 + return false; 130 + } 131 + let Some(p) = ctx.input.pointer.as_ref() else { 132 + return false; 133 + }; 134 + !bounds.contains(p.position) 135 + } 136 + 137 + struct ParsedOutcome { 138 + paint: Vec<WidgetPaint>, 139 + value: Option<DimensionValue>, 140 + committed: Option<DimensionValue>, 141 + } 142 + 143 + fn render_length( 144 + ctx: &mut FrameCtx<'_>, 145 + id: WidgetId, 146 + rect: LayoutRect, 147 + placeholder: bone_ui::strings::StringKey, 148 + state: &mut DimensionEditorState, 149 + ) -> ParsedOutcome { 150 + let widget = ParsedInput::<Length>::new(id, rect, placeholder, &mut state.text); 151 + let ParsedInputResponse { 152 + value, 153 + committed, 154 + paint, 155 + .. 156 + } = show_parsed_input(ctx, widget, &mut state.clipboard); 157 + ParsedOutcome { 158 + paint, 159 + value: value.map(DimensionValue::Length), 160 + committed: committed.map(DimensionValue::Length), 161 + } 162 + } 163 + 164 + fn render_angle( 165 + ctx: &mut FrameCtx<'_>, 166 + id: WidgetId, 167 + rect: LayoutRect, 168 + placeholder: bone_ui::strings::StringKey, 169 + state: &mut DimensionEditorState, 170 + ) -> ParsedOutcome { 171 + let widget = ParsedInput::<Angle>::new(id, rect, placeholder, &mut state.text); 172 + let ParsedInputResponse { 173 + value, 174 + committed, 175 + paint, 176 + .. 177 + } = show_parsed_input(ctx, widget, &mut state.clipboard); 178 + ParsedOutcome { 179 + paint, 180 + value: value.map(DimensionValue::Angle), 181 + committed: committed.map(DimensionValue::Angle), 182 + } 183 + } 184 + 185 + struct SwapOutcome { 186 + paints: Vec<WidgetPaint>, 187 + action: Option<SketchDimension>, 188 + bounds: Option<LayoutRect>, 189 + } 190 + 191 + fn render_swap( 192 + ctx: &mut FrameCtx<'_>, 193 + proto: &SketchDimension, 194 + editor_rect: LayoutRect, 195 + radius_id: WidgetId, 196 + diameter_id: WidgetId, 197 + ) -> SwapOutcome { 198 + let (is_radius, is_diameter) = match proto { 199 + SketchDimension::Radius { .. } => (true, false), 200 + SketchDimension::Diameter { .. } => (false, true), 201 + SketchDimension::Linear { .. } | SketchDimension::Angular { .. } => { 202 + return SwapOutcome { 203 + paints: Vec::new(), 204 + action: None, 205 + bounds: None, 206 + }; 207 + } 208 + }; 209 + let radius_rect = swap_button_rect(editor_rect, 0); 210 + let diameter_rect = swap_button_rect(editor_rect, 1); 211 + let radius_btn = Button::new( 212 + radius_id, 213 + radius_rect, 214 + strings::TOOL_RADIUS, 215 + if is_radius { 216 + ButtonVariant::Primary 217 + } else { 218 + ButtonVariant::Secondary 219 + }, 220 + ) 221 + .with_state(if is_radius { 222 + ButtonState::Disabled 223 + } else { 224 + ButtonState::Idle 225 + }); 226 + let diameter_btn = Button::new( 227 + diameter_id, 228 + diameter_rect, 229 + strings::TOOL_DIAMETER, 230 + if is_diameter { 231 + ButtonVariant::Primary 232 + } else { 233 + ButtonVariant::Secondary 234 + }, 235 + ) 236 + .with_state(if is_diameter { 237 + ButtonState::Disabled 238 + } else { 239 + ButtonState::Idle 240 + }); 241 + let radius_resp = show_button(ctx, radius_btn); 242 + let diameter_resp = show_button(ctx, diameter_btn); 243 + let mut paints = Vec::new(); 244 + paints.extend(radius_resp.paint); 245 + paints.extend(diameter_resp.paint); 246 + let action = if radius_resp.activated || diameter_resp.activated { 247 + smart_dimension::swap_radius_diameter(*proto) 248 + } else { 249 + None 250 + }; 251 + SwapOutcome { 252 + paints, 253 + action, 254 + bounds: Some(radius_rect.union(diameter_rect)), 255 + } 256 + } 257 + 258 + fn editor_rect(viewport_rect: LayoutRect, camera: &Camera2, anchor: Point2) -> LayoutRect { 259 + let LayoutPos { x: ax, y: ay } = world_to_screen(camera, anchor); 260 + let raw_x = ax.value() + EDITOR_OFFSET_X_PX; 261 + let raw_y = ay.value() + EDITOR_OFFSET_Y_PX; 262 + let total_width = EDITOR_WIDTH_PX + 2.0 * (SWAP_BUTTON_WIDTH_PX + SWAP_BUTTON_GAP_PX); 263 + let min_x = viewport_rect.min_x().value(); 264 + let min_y = viewport_rect.min_y().value(); 265 + let max_x = (min_x + viewport_rect.size.width.value() - total_width).max(min_x); 266 + let max_y = (min_y + viewport_rect.size.height.value() - EDITOR_HEIGHT_PX).max(min_y); 267 + let x = raw_x.clamp(min_x, max_x); 268 + let y = raw_y.clamp(min_y, max_y); 269 + LayoutRect::new( 270 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 271 + LayoutSize::new( 272 + LayoutPx::new(EDITOR_WIDTH_PX), 273 + LayoutPx::new(EDITOR_HEIGHT_PX), 274 + ), 275 + ) 276 + } 277 + 278 + fn swap_button_rect(editor_rect: LayoutRect, index: u32) -> LayoutRect { 279 + #[allow( 280 + clippy::cast_precision_loss, 281 + reason = "swap-button index is 0 or 1; f32 round-trip is exact" 282 + )] 283 + let offset = editor_rect.size.width.value() 284 + + SWAP_BUTTON_GAP_PX 285 + + (index as f32) * (SWAP_BUTTON_WIDTH_PX + SWAP_BUTTON_GAP_PX); 286 + LayoutRect::new( 287 + LayoutPos::new( 288 + LayoutPx::new(editor_rect.min_x().value() + offset), 289 + editor_rect.origin.y, 290 + ), 291 + LayoutSize::new(LayoutPx::new(SWAP_BUTTON_WIDTH_PX), editor_rect.size.height), 292 + ) 293 + } 294 + 295 + #[allow( 296 + clippy::cast_possible_truncation, 297 + reason = "world-to-screen px collapses f64 to LayoutPx (f32) at the sub-pixel limit" 298 + )] 299 + fn world_to_screen(camera: &Camera2, world: Point2) -> LayoutPos { 300 + let extent = camera.extent(); 301 + let w = f64::from(extent.width().value()); 302 + let h = f64::from(extent.height().value()); 303 + let zoom = camera.zoom().value(); 304 + let (pan_x, pan_y) = camera.pan_mm().coords_mm(); 305 + let (wx, wy) = world.coords_mm(); 306 + let cursor_x = w * 0.5 + (wx - pan_x) * zoom; 307 + let cursor_y = h * 0.5 - (wy - pan_y) * zoom; 308 + LayoutPos::new( 309 + LayoutPx::saturating(cursor_x as f32), 310 + LayoutPx::saturating(cursor_y as f32), 311 + ) 312 + } 313 + 314 + fn format_initial_value(value: DimensionValue) -> String { 315 + match value { 316 + DimensionValue::Length(l) => format!("{:.3}", l.get::<millimeter>()), 317 + DimensionValue::Angle(a) => format!("{:.3}", a.get::<degree>()), 318 + } 319 + } 320 + 321 + #[cfg(test)] 322 + mod tests { 323 + use super::*; 324 + use bone_document::{DimensionKind, SketchDimension}; 325 + use bone_render::{PixelsPerMm, ViewportExtent, ViewportPx}; 326 + use bone_types::{Length, SketchEntityId}; 327 + use uom::si::length::millimeter; 328 + 329 + fn sample_radius() -> SketchDimension { 330 + SketchDimension::Radius { 331 + target: SketchEntityId::default(), 332 + value: Length::new::<millimeter>(3.0), 333 + kind: DimensionKind::Driving, 334 + } 335 + } 336 + 337 + fn sample_camera() -> Camera2 { 338 + let extent = ViewportExtent::new(ViewportPx::new(800), ViewportPx::new(600)); 339 + Camera2::new(extent).with_zoom(PixelsPerMm::new(10.0)) 340 + } 341 + 342 + #[test] 343 + fn merge_typed_value_returns_proto_when_no_typed_input() { 344 + let proto = sample_radius(); 345 + assert_eq!(merge_typed_value(proto, None), proto); 346 + } 347 + 348 + #[test] 349 + fn merge_typed_value_overrides_proto_value_when_typed_matches() { 350 + let proto = sample_radius(); 351 + let typed = DimensionValue::Length(Length::new::<millimeter>(5.0)); 352 + let merged = merge_typed_value(proto, Some(typed)); 353 + let SketchDimension::Radius { value, .. } = merged else { 354 + panic!("expected Radius"); 355 + }; 356 + assert!((value.get::<millimeter>() - 5.0).abs() < 1e-9); 357 + } 358 + 359 + #[test] 360 + fn merge_typed_value_falls_back_when_typed_kind_mismatches() { 361 + let proto = sample_radius(); 362 + let bad = DimensionValue::Angle(bone_types::Angle::new::<uom::si::angle::radian>(1.0)); 363 + assert_eq!(merge_typed_value(proto, Some(bad)), proto); 364 + } 365 + 366 + #[test] 367 + fn editor_state_reset_seeds_text_from_value() { 368 + let mut state = DimensionEditorState::default(); 369 + state.reset_for(sample_radius()); 370 + assert_eq!(state.text.text, "3.000"); 371 + assert_eq!(state.last_seeded_for, Some(sample_radius())); 372 + } 373 + 374 + #[test] 375 + fn editor_state_close_drops_seeded_proto() { 376 + let mut state = DimensionEditorState::default(); 377 + state.reset_for(sample_radius()); 378 + state.close(); 379 + assert_eq!(state.last_seeded_for, None); 380 + assert_eq!(state.text.text, ""); 381 + } 382 + 383 + #[test] 384 + fn second_frame_keeps_seeded_text_after_first_render() { 385 + use bone_ui::a11y::AccessTreeBuilder; 386 + use bone_ui::focus::FocusManager; 387 + use bone_ui::hit_test::{HitFrame, HitState, resolve}; 388 + use bone_ui::hotkey::HotkeyTable; 389 + use bone_ui::input::{FrameInstant, InputSnapshot}; 390 + use bone_ui::strings::StringTable; 391 + use bone_ui::theme::Theme; 392 + use bone_ui::widgets::{LabelText, WidgetPaint}; 393 + use std::sync::Arc; 394 + let mut state = DimensionEditorState::default(); 395 + let pending = PendingDimension { 396 + proto: sample_radius(), 397 + anchor: Point2::origin(), 398 + }; 399 + let camera = sample_camera(); 400 + let viewport = LayoutRect::new( 401 + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 402 + LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(600.0)), 403 + ); 404 + let theme = Arc::new(Theme::light()); 405 + let table = HotkeyTable::new(); 406 + let mut focus = FocusManager::new(); 407 + let mut hit_state = HitState::new(); 408 + let strings = StringTable::empty(); 409 + let run_one = 410 + |state: &mut DimensionEditorState, focus: &mut FocusManager, prev_hit: &HitState| { 411 + let mut hits = HitFrame::new(); 412 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 413 + let mut shaper = bone_text::Shaper::new(); 414 + let mut a11y = AccessTreeBuilder::new(); 415 + let outcome = { 416 + let mut ctx = FrameCtx::new( 417 + Arc::clone(&theme), 418 + &mut input, 419 + focus, 420 + &table, 421 + strings, 422 + &mut hits, 423 + prev_hit, 424 + &mut a11y, 425 + &mut shaper, 426 + ); 427 + render(&mut ctx, pending, pending.anchor, &camera, viewport, state) 428 + }; 429 + let next_hit = resolve(prev_hit, &hits, &input, focus.focused()); 430 + (outcome, next_hit) 431 + }; 432 + let (_, next_hit) = run_one(&mut state, &mut focus, &hit_state); 433 + hit_state = next_hit; 434 + let (outcome2, _) = run_one(&mut state, &mut focus, &hit_state); 435 + let any_label = outcome2.paints.iter().any(|p| { 436 + matches!( 437 + p, 438 + WidgetPaint::Label { text: LabelText::Owned(t), .. } if t == "3.000", 439 + ) 440 + }); 441 + assert!(any_label, "second frame must still paint the seeded value"); 442 + } 443 + 444 + #[test] 445 + fn render_emits_surface_and_label_for_seeded_state() { 446 + use bone_ui::a11y::AccessTreeBuilder; 447 + use bone_ui::focus::FocusManager; 448 + use bone_ui::hit_test::{HitFrame, HitState}; 449 + use bone_ui::hotkey::HotkeyTable; 450 + use bone_ui::input::{FrameInstant, InputSnapshot}; 451 + use bone_ui::strings::StringTable; 452 + use bone_ui::theme::Theme; 453 + use bone_ui::widgets::{LabelText, WidgetPaint}; 454 + use std::sync::Arc; 455 + let mut state = DimensionEditorState::default(); 456 + let pending = PendingDimension { 457 + proto: sample_radius(), 458 + anchor: Point2::origin(), 459 + }; 460 + let camera = sample_camera(); 461 + let viewport = LayoutRect::new( 462 + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 463 + LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(600.0)), 464 + ); 465 + let theme = Arc::new(Theme::light()); 466 + let table = HotkeyTable::new(); 467 + let mut focus = FocusManager::new(); 468 + let mut hits = HitFrame::new(); 469 + let prev = HitState::new(); 470 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 471 + let mut shaper = bone_text::Shaper::new(); 472 + let mut a11y = AccessTreeBuilder::new(); 473 + let mut ctx = FrameCtx::new( 474 + theme, 475 + &mut input, 476 + &mut focus, 477 + &table, 478 + StringTable::empty(), 479 + &mut hits, 480 + &prev, 481 + &mut a11y, 482 + &mut shaper, 483 + ); 484 + let outcome = render( 485 + &mut ctx, 486 + pending, 487 + pending.anchor, 488 + &camera, 489 + viewport, 490 + &mut state, 491 + ); 492 + let any_label_with_text = outcome.paints.iter().any(|p| { 493 + matches!( 494 + p, 495 + WidgetPaint::Label { text: LabelText::Owned(t), .. } if t == "3.000", 496 + ) 497 + }); 498 + assert!(any_label_with_text, "popup must paint the seeded value"); 499 + let any_surface = outcome 500 + .paints 501 + .iter() 502 + .any(|p| matches!(p, WidgetPaint::Surface { .. })); 503 + assert!(any_surface, "popup must paint a surface fill"); 504 + } 505 + 506 + #[test] 507 + fn world_to_screen_at_pan_origin_is_window_center() { 508 + let camera = sample_camera(); 509 + let p = world_to_screen(&camera, Point2::origin()); 510 + assert!((p.x.value() - 400.0).abs() < 0.1); 511 + assert!((p.y.value() - 300.0).abs() < 0.1); 512 + } 513 + 514 + #[test] 515 + fn world_to_screen_inverts_y() { 516 + let camera = sample_camera(); 517 + let above = world_to_screen(&camera, Point2::from_mm(0.0, 5.0)); 518 + let below = world_to_screen(&camera, Point2::from_mm(0.0, -5.0)); 519 + assert!(above.y.value() < below.y.value()); 520 + } 521 + 522 + #[test] 523 + fn editor_rect_does_not_panic_when_viewport_is_narrower_than_editor() { 524 + let viewport = LayoutRect::new( 525 + LayoutPos::new(LayoutPx::new(250.0), LayoutPx::new(40.0)), 526 + LayoutSize::new(LayoutPx::new(120.0), LayoutPx::new(60.0)), 527 + ); 528 + let camera = sample_camera(); 529 + let rect = editor_rect(viewport, &camera, Point2::origin()); 530 + assert!(rect.min_x().value() >= viewport.min_x().value()); 531 + assert!(rect.min_y().value() >= viewport.min_y().value()); 532 + } 533 + 534 + #[test] 535 + fn editor_rect_clamps_inside_viewport_when_anchor_is_far_right() { 536 + let viewport = LayoutRect::new( 537 + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 538 + LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(600.0)), 539 + ); 540 + let camera = sample_camera(); 541 + let anchor = Point2::from_mm(100.0, 0.0); 542 + let rect = editor_rect(viewport, &camera, anchor); 543 + let total_width = EDITOR_WIDTH_PX + 2.0 * (SWAP_BUTTON_WIDTH_PX + SWAP_BUTTON_GAP_PX); 544 + assert!(rect.max_x().value() <= viewport.max_x().value()); 545 + assert!(rect.min_x().value() + total_width <= viewport.max_x().value() + 1e-3); 546 + } 547 + 548 + #[test] 549 + fn swap_button_rects_sit_to_right_of_editor() { 550 + let editor = LayoutRect::new( 551 + LayoutPos::new(LayoutPx::new(50.0), LayoutPx::new(60.0)), 552 + LayoutSize::new(LayoutPx::new(140.0), LayoutPx::new(24.0)), 553 + ); 554 + let radius = swap_button_rect(editor, 0); 555 + let diameter = swap_button_rect(editor, 1); 556 + assert!(radius.min_x().value() > editor.max_x().value()); 557 + assert!(diameter.min_x().value() > radius.max_x().value()); 558 + assert!((radius.origin.y.value() - editor.origin.y.value()).abs() < 1e-9); 559 + } 560 + 561 + #[test] 562 + fn primary_press_outside_bounds_commits_current_value() { 563 + use bone_ui::a11y::AccessTreeBuilder; 564 + use bone_ui::focus::FocusManager; 565 + use bone_ui::hit_test::{HitFrame, HitState}; 566 + use bone_ui::hotkey::HotkeyTable; 567 + use bone_ui::input::{ 568 + FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample, 569 + }; 570 + use bone_ui::strings::StringTable; 571 + use bone_ui::theme::Theme; 572 + use std::sync::Arc; 573 + let mut state = DimensionEditorState::default(); 574 + state.reset_for(sample_radius()); 575 + let pending = PendingDimension { 576 + proto: sample_radius(), 577 + anchor: Point2::origin(), 578 + }; 579 + let camera = sample_camera(); 580 + let viewport = LayoutRect::new( 581 + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 582 + LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(600.0)), 583 + ); 584 + let theme = Arc::new(Theme::light()); 585 + let table = HotkeyTable::new(); 586 + let mut focus = FocusManager::new(); 587 + let mut hits = HitFrame::new(); 588 + let prev = HitState::new(); 589 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 590 + input.pointer = Some(PointerSample::new(LayoutPos::new( 591 + LayoutPx::new(700.0), 592 + LayoutPx::new(500.0), 593 + ))); 594 + input.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 595 + let mut shaper = bone_text::Shaper::new(); 596 + let mut a11y = AccessTreeBuilder::new(); 597 + let outcome = { 598 + let mut ctx = FrameCtx::new( 599 + theme, 600 + &mut input, 601 + &mut focus, 602 + &table, 603 + StringTable::empty(), 604 + &mut hits, 605 + &prev, 606 + &mut a11y, 607 + &mut shaper, 608 + ); 609 + render( 610 + &mut ctx, 611 + pending, 612 + pending.anchor, 613 + &camera, 614 + viewport, 615 + &mut state, 616 + ) 617 + }; 618 + match outcome.action { 619 + DimensionEditorAction::Commit(_) => {} 620 + other => panic!("expected Commit, got {other:?}"), 621 + } 622 + } 623 + 624 + #[test] 625 + fn primary_press_inside_editor_does_not_commit() { 626 + use bone_ui::a11y::AccessTreeBuilder; 627 + use bone_ui::focus::FocusManager; 628 + use bone_ui::hit_test::{HitFrame, HitState}; 629 + use bone_ui::hotkey::HotkeyTable; 630 + use bone_ui::input::{ 631 + FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample, 632 + }; 633 + use bone_ui::strings::StringTable; 634 + use bone_ui::theme::Theme; 635 + use std::sync::Arc; 636 + let mut state = DimensionEditorState::default(); 637 + state.reset_for(sample_radius()); 638 + let pending = PendingDimension { 639 + proto: sample_radius(), 640 + anchor: Point2::origin(), 641 + }; 642 + let camera = sample_camera(); 643 + let viewport = LayoutRect::new( 644 + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 645 + LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(600.0)), 646 + ); 647 + let bounds_probe = editor_rect(viewport, &camera, pending.anchor); 648 + let inside_x = bounds_probe.min_x().value() + 4.0; 649 + let inside_y = bounds_probe.min_y().value() + 4.0; 650 + 651 + let theme = Arc::new(Theme::light()); 652 + let table = HotkeyTable::new(); 653 + let mut focus = FocusManager::new(); 654 + let mut hits = HitFrame::new(); 655 + let prev = HitState::new(); 656 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 657 + input.pointer = Some(PointerSample::new(LayoutPos::new( 658 + LayoutPx::new(inside_x), 659 + LayoutPx::new(inside_y), 660 + ))); 661 + input.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 662 + let mut shaper = bone_text::Shaper::new(); 663 + let mut a11y = AccessTreeBuilder::new(); 664 + let outcome = { 665 + let mut ctx = FrameCtx::new( 666 + theme, 667 + &mut input, 668 + &mut focus, 669 + &table, 670 + StringTable::empty(), 671 + &mut hits, 672 + &prev, 673 + &mut a11y, 674 + &mut shaper, 675 + ); 676 + render( 677 + &mut ctx, 678 + pending, 679 + pending.anchor, 680 + &camera, 681 + viewport, 682 + &mut state, 683 + ) 684 + }; 685 + let action = &outcome.action; 686 + assert!( 687 + !matches!(action, DimensionEditorAction::Commit(_)), 688 + "press inside editor must not commit, got {action:?}", 689 + ); 690 + } 691 + }