Another project
1
fork

Configure Feed

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

feat(ui): hit-test resolver, interaction state

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

+1105
+1105
crates/bone-ui/src/hit_test.rs
··· 1 + use std::collections::{BTreeMap, BTreeSet}; 2 + 3 + use serde::{Deserialize, Serialize}; 4 + 5 + use crate::input::{ 6 + ClickCount, DoubleClickWindow, FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, 7 + }; 8 + use crate::layout::{LayoutOffset, LayoutPos, LayoutRect}; 9 + use crate::widget_id::WidgetId; 10 + 11 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 12 + #[serde(transparent)] 13 + pub struct Sense(u8); 14 + 15 + impl Sense { 16 + pub const HOVER: Self = Self(1 << 0); 17 + pub const PRESS: Self = Self(1 << 1); 18 + pub const DRAG: Self = Self(1 << 2); 19 + pub const INTERACTIVE: Self = Self(Self::HOVER.0 | Self::PRESS.0); 20 + pub const DRAGGABLE: Self = Self(Self::HOVER.0 | Self::PRESS.0 | Self::DRAG.0); 21 + 22 + #[must_use] 23 + pub const fn contains(self, other: Self) -> bool { 24 + (self.0 & other.0) == other.0 25 + } 26 + } 27 + 28 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 29 + #[serde(transparent)] 30 + pub struct ZLayer(u32); 31 + 32 + impl ZLayer { 33 + pub const BASE: Self = Self(0); 34 + 35 + #[must_use] 36 + pub const fn new(value: u32) -> Self { 37 + Self(value) 38 + } 39 + 40 + #[must_use] 41 + pub const fn get(self) -> u32 { 42 + self.0 43 + } 44 + } 45 + 46 + #[derive(Copy, Clone, Debug, PartialEq)] 47 + pub struct HitItem { 48 + pub id: WidgetId, 49 + pub rect: LayoutRect, 50 + pub sense: Sense, 51 + pub z: ZLayer, 52 + pub disabled: bool, 53 + pub active: bool, 54 + } 55 + 56 + #[derive(Clone, Debug, Default, PartialEq)] 57 + pub struct HitFrame { 58 + items: Vec<HitItem>, 59 + seen: BTreeSet<WidgetId>, 60 + } 61 + 62 + impl HitFrame { 63 + #[must_use] 64 + pub fn new() -> Self { 65 + Self::default() 66 + } 67 + 68 + pub fn push(&mut self, item: HitItem) { 69 + assert!( 70 + !item.sense.contains(Sense::DRAG) || item.sense.contains(Sense::PRESS), 71 + "Sense::DRAG implies Sense::PRESS", 72 + ); 73 + assert!( 74 + !item.sense.contains(Sense::PRESS) || item.sense.contains(Sense::HOVER), 75 + "Sense::PRESS implies Sense::HOVER", 76 + ); 77 + assert!( 78 + self.seen.insert(item.id), 79 + "duplicate WidgetId in HitFrame: {:?}", 80 + item.id, 81 + ); 82 + self.items.push(item); 83 + } 84 + 85 + #[must_use] 86 + pub fn items(&self) -> &[HitItem] { 87 + &self.items 88 + } 89 + 90 + pub fn clear(&mut self) { 91 + self.items.clear(); 92 + self.seen.clear(); 93 + } 94 + 95 + fn topmost(&self, pos: LayoutPos, required: Sense) -> Option<&HitItem> { 96 + self.items 97 + .iter() 98 + .enumerate() 99 + .filter(|(_, item)| item.sense.contains(required) && item.rect.contains(pos)) 100 + .max_by_key(|(idx, item)| (item.z, *idx)) 101 + .map(|(_, item)| item) 102 + } 103 + 104 + fn lookup(&self, id: WidgetId) -> Option<&HitItem> { 105 + self.items.iter().find(|item| item.id == id) 106 + } 107 + } 108 + 109 + #[derive(Copy, Clone, Debug, PartialEq)] 110 + pub struct PointerCapture { 111 + pub id: WidgetId, 112 + pub button: PointerButton, 113 + } 114 + 115 + #[derive(Clone, Debug, Default, PartialEq)] 116 + pub struct PointerCaptureStack { 117 + captures: Vec<PointerCapture>, 118 + } 119 + 120 + impl PointerCaptureStack { 121 + #[must_use] 122 + pub fn new() -> Self { 123 + Self::default() 124 + } 125 + 126 + #[must_use] 127 + pub fn top(&self) -> Option<PointerCapture> { 128 + self.captures.last().copied() 129 + } 130 + 131 + pub fn push(&mut self, capture: PointerCapture) { 132 + self.captures.push(capture); 133 + } 134 + 135 + pub fn release_button(&mut self, button: PointerButton) { 136 + self.captures.retain(|c| c.button != button); 137 + } 138 + 139 + #[must_use] 140 + pub fn captures(&self) -> &[PointerCapture] { 141 + &self.captures 142 + } 143 + 144 + #[must_use] 145 + pub fn is_empty(&self) -> bool { 146 + self.captures.is_empty() 147 + } 148 + } 149 + 150 + #[derive(Copy, Clone, Debug, PartialEq)] 151 + pub struct PressedRecord { 152 + pub id: WidgetId, 153 + pub button: PointerButton, 154 + pub origin: LayoutPos, 155 + pub started_at: FrameInstant, 156 + pub drag_active: bool, 157 + } 158 + 159 + #[derive(Copy, Clone, Debug, PartialEq)] 160 + pub struct ClickRecord { 161 + pub id: WidgetId, 162 + pub at: FrameInstant, 163 + pub count: ClickCount, 164 + } 165 + 166 + #[derive( 167 + Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize, 168 + )] 169 + #[serde(transparent)] 170 + pub struct InteractionState(u16); 171 + 172 + impl InteractionState { 173 + pub const NONE: Self = Self(0); 174 + pub const HOVER: Self = Self(1 << 0); 175 + pub const PRESSED: Self = Self(1 << 1); 176 + pub const FOCUSED: Self = Self(1 << 2); 177 + pub const DISABLED: Self = Self(1 << 3); 178 + pub const ACTIVE: Self = Self(1 << 4); 179 + pub const CLICK: Self = Self(1 << 5); 180 + pub const DOUBLE_CLICK: Self = Self(1 << 6); 181 + pub const DRAG_START: Self = Self(1 << 7); 182 + pub const DRAG_RELEASE: Self = Self(1 << 8); 183 + 184 + #[must_use] 185 + pub const fn contains(self, other: Self) -> bool { 186 + (self.0 & other.0) == other.0 187 + } 188 + 189 + #[must_use] 190 + pub const fn with(self, flag: Self, on: bool) -> Self { 191 + if on { 192 + Self(self.0 | flag.0) 193 + } else { 194 + Self(self.0 & !flag.0) 195 + } 196 + } 197 + } 198 + 199 + #[derive(Copy, Clone, Debug, PartialEq, Default)] 200 + pub struct Interaction { 201 + pub state: InteractionState, 202 + pub drag_delta: LayoutOffset, 203 + pub pressed_buttons: PointerButtonMask, 204 + pub click_button: Option<PointerButton>, 205 + pub drag_button: Option<PointerButton>, 206 + } 207 + 208 + impl Interaction { 209 + #[must_use] 210 + pub const fn idle() -> Self { 211 + Self { 212 + state: InteractionState::NONE, 213 + drag_delta: LayoutOffset::ZERO, 214 + pressed_buttons: PointerButtonMask::EMPTY, 215 + click_button: None, 216 + drag_button: None, 217 + } 218 + } 219 + 220 + #[must_use] 221 + pub const fn hover(self) -> bool { 222 + self.state.contains(InteractionState::HOVER) 223 + } 224 + 225 + #[must_use] 226 + pub const fn pressed(self) -> bool { 227 + self.state.contains(InteractionState::PRESSED) 228 + } 229 + 230 + #[must_use] 231 + pub const fn focused(self) -> bool { 232 + self.state.contains(InteractionState::FOCUSED) 233 + } 234 + 235 + #[must_use] 236 + pub const fn disabled(self) -> bool { 237 + self.state.contains(InteractionState::DISABLED) 238 + } 239 + 240 + #[must_use] 241 + pub const fn active(self) -> bool { 242 + self.state.contains(InteractionState::ACTIVE) 243 + } 244 + 245 + #[must_use] 246 + pub const fn click(self) -> bool { 247 + self.state.contains(InteractionState::CLICK) 248 + } 249 + 250 + #[must_use] 251 + pub const fn double_click(self) -> bool { 252 + self.state.contains(InteractionState::DOUBLE_CLICK) 253 + } 254 + 255 + #[must_use] 256 + pub const fn drag_start(self) -> bool { 257 + self.state.contains(InteractionState::DRAG_START) 258 + } 259 + 260 + #[must_use] 261 + pub const fn drag_release(self) -> bool { 262 + self.state.contains(InteractionState::DRAG_RELEASE) 263 + } 264 + } 265 + 266 + #[derive(Clone, Debug, Default, PartialEq)] 267 + pub struct HitState { 268 + pub hovered: Option<WidgetId>, 269 + pub pressed: BTreeMap<PointerButton, PressedRecord>, 270 + pub last_click: BTreeMap<PointerButton, ClickRecord>, 271 + pub captures: PointerCaptureStack, 272 + pub interactions: BTreeMap<WidgetId, Interaction>, 273 + pub last_pointer: Option<LayoutPos>, 274 + } 275 + 276 + impl HitState { 277 + #[must_use] 278 + pub fn new() -> Self { 279 + Self::default() 280 + } 281 + 282 + #[must_use] 283 + pub fn interaction(&self, id: WidgetId) -> Interaction { 284 + self.interactions 285 + .get(&id) 286 + .copied() 287 + .unwrap_or(Interaction::idle()) 288 + } 289 + } 290 + 291 + #[derive(Copy, Clone, Debug, PartialEq)] 292 + struct ButtonEvent { 293 + id: WidgetId, 294 + button: PointerButton, 295 + } 296 + 297 + #[derive(Copy, Clone, Debug, PartialEq)] 298 + struct ClickEvent { 299 + id: WidgetId, 300 + button: PointerButton, 301 + count: ClickCount, 302 + } 303 + 304 + #[derive(Default)] 305 + struct Phase { 306 + pressed: BTreeMap<PointerButton, PressedRecord>, 307 + captures: PointerCaptureStack, 308 + last_click: BTreeMap<PointerButton, ClickRecord>, 309 + drag_starts: Vec<ButtonEvent>, 310 + drag_releases: Vec<ButtonEvent>, 311 + clicks: Vec<ClickEvent>, 312 + release_records: Vec<PressedRecord>, 313 + } 314 + 315 + #[must_use] 316 + pub fn resolve( 317 + prev: &HitState, 318 + frame: &HitFrame, 319 + input: &InputSnapshot, 320 + focused: Option<WidgetId>, 321 + ) -> HitState { 322 + let pointer = input.pointer.map(|p| p.position); 323 + let phase = Phase { 324 + pressed: prev.pressed.clone(), 325 + captures: prev.captures.clone(), 326 + last_click: prev.last_click.clone(), 327 + ..Phase::default() 328 + }; 329 + let phase = apply_press(phase, frame, input, pointer); 330 + let phase = apply_drag_start(phase, input, frame, pointer); 331 + let phase = apply_release(phase, frame, input, pointer); 332 + 333 + let routed_id = route_pointer(&phase.captures, frame, pointer); 334 + let sticky_pointer = pointer.or(prev.last_pointer); 335 + let interactions = build_interactions( 336 + frame, 337 + &InteractionInputs { 338 + hovered: routed_id, 339 + focused, 340 + pressed: &phase.pressed, 341 + release_records: &phase.release_records, 342 + pointer: sticky_pointer, 343 + drag_starts: &phase.drag_starts, 344 + drag_releases: &phase.drag_releases, 345 + clicks: &phase.clicks, 346 + }, 347 + ); 348 + 349 + HitState { 350 + hovered: routed_id, 351 + pressed: phase.pressed, 352 + last_click: phase.last_click, 353 + captures: phase.captures, 354 + interactions, 355 + last_pointer: sticky_pointer, 356 + } 357 + } 358 + 359 + fn route_pointer( 360 + captures: &PointerCaptureStack, 361 + frame: &HitFrame, 362 + pointer: Option<LayoutPos>, 363 + ) -> Option<WidgetId> { 364 + if let Some(cap) = captures.top() 365 + && frame.lookup(cap.id).is_some() 366 + { 367 + return Some(cap.id); 368 + } 369 + pointer 370 + .and_then(|p| frame.topmost(p, Sense::HOVER)) 371 + .map(|item| item.id) 372 + } 373 + 374 + fn apply_press( 375 + phase: Phase, 376 + frame: &HitFrame, 377 + input: &InputSnapshot, 378 + pointer: Option<LayoutPos>, 379 + ) -> Phase { 380 + PointerButton::ALL 381 + .iter() 382 + .copied() 383 + .filter(|button| input.buttons_pressed.contains(*button)) 384 + .fold(phase, |mut phase, button| { 385 + let target = pointer 386 + .and_then(|p| frame.topmost(p, Sense::PRESS)) 387 + .filter(|item| !item.disabled); 388 + if let Some(item) = target 389 + && let Some(p) = pointer 390 + { 391 + phase.pressed.insert( 392 + button, 393 + PressedRecord { 394 + id: item.id, 395 + button, 396 + origin: p, 397 + started_at: input.frame, 398 + drag_active: false, 399 + }, 400 + ); 401 + if item.sense.contains(Sense::DRAG) { 402 + phase.captures.push(PointerCapture { 403 + id: item.id, 404 + button, 405 + }); 406 + } 407 + } 408 + phase 409 + }) 410 + } 411 + 412 + fn apply_drag_start( 413 + phase: Phase, 414 + input: &InputSnapshot, 415 + frame: &HitFrame, 416 + pointer: Option<LayoutPos>, 417 + ) -> Phase { 418 + let candidates: Vec<PressedRecord> = phase 419 + .pressed 420 + .values() 421 + .filter(|rec| !rec.drag_active) 422 + .filter(|rec| { 423 + frame 424 + .lookup(rec.id) 425 + .is_some_and(|item| item.sense.contains(Sense::DRAG) && !item.disabled) 426 + }) 427 + .copied() 428 + .collect(); 429 + candidates.into_iter().fold(phase, |mut phase, rec| { 430 + let Some(p) = pointer else { return phase }; 431 + let offset = LayoutOffset::between(rec.origin, p); 432 + if !input.drag_threshold.exceeded_by(offset) { 433 + return phase; 434 + } 435 + if let Some(record) = phase.pressed.get_mut(&rec.button) { 436 + record.drag_active = true; 437 + } 438 + let already_captured = phase 439 + .captures 440 + .captures() 441 + .iter() 442 + .any(|c| c.id == rec.id && c.button == rec.button); 443 + if !already_captured { 444 + phase.captures.push(PointerCapture { 445 + id: rec.id, 446 + button: rec.button, 447 + }); 448 + } 449 + phase.drag_starts.push(ButtonEvent { 450 + id: rec.id, 451 + button: rec.button, 452 + }); 453 + phase 454 + }) 455 + } 456 + 457 + fn apply_release( 458 + phase: Phase, 459 + frame: &HitFrame, 460 + input: &InputSnapshot, 461 + pointer: Option<LayoutPos>, 462 + ) -> Phase { 463 + PointerButton::ALL 464 + .iter() 465 + .copied() 466 + .filter(|button| input.buttons_released.contains(*button)) 467 + .fold(phase, |mut phase, button| { 468 + if let Some(rec) = phase.pressed.remove(&button) { 469 + phase.release_records.push(rec); 470 + if rec.drag_active { 471 + phase.drag_releases.push(ButtonEvent { id: rec.id, button }); 472 + } else { 473 + let pointer_over = pointer.is_some_and(|p| { 474 + frame 475 + .lookup(rec.id) 476 + .is_some_and(|item| item.rect.contains(p)) 477 + }); 478 + if pointer_over { 479 + let count = next_click_count( 480 + phase.last_click.get(&button).copied(), 481 + rec.id, 482 + input.frame, 483 + input.double_click_window, 484 + ); 485 + phase.last_click.insert( 486 + button, 487 + ClickRecord { 488 + id: rec.id, 489 + at: input.frame, 490 + count, 491 + }, 492 + ); 493 + phase.clicks.push(ClickEvent { 494 + id: rec.id, 495 + button, 496 + count, 497 + }); 498 + } 499 + } 500 + } 501 + phase.captures.release_button(button); 502 + phase 503 + }) 504 + } 505 + 506 + struct InteractionInputs<'a> { 507 + hovered: Option<WidgetId>, 508 + focused: Option<WidgetId>, 509 + pressed: &'a BTreeMap<PointerButton, PressedRecord>, 510 + release_records: &'a [PressedRecord], 511 + pointer: Option<LayoutPos>, 512 + drag_starts: &'a [ButtonEvent], 513 + drag_releases: &'a [ButtonEvent], 514 + clicks: &'a [ClickEvent], 515 + } 516 + 517 + fn build_interactions( 518 + frame: &HitFrame, 519 + inputs: &InteractionInputs<'_>, 520 + ) -> BTreeMap<WidgetId, Interaction> { 521 + frame 522 + .items() 523 + .iter() 524 + .map(|item| (item.id, interaction_for(item, inputs))) 525 + .collect() 526 + } 527 + 528 + fn interaction_for(item: &HitItem, inputs: &InteractionInputs<'_>) -> Interaction { 529 + let pressed_buttons = inputs 530 + .pressed 531 + .values() 532 + .filter(|rec| rec.id == item.id) 533 + .fold(PointerButtonMask::EMPTY, |mask, rec| mask.with(rec.button)); 534 + let drag_record = inputs 535 + .pressed 536 + .values() 537 + .find(|rec| rec.id == item.id && rec.drag_active) 538 + .copied() 539 + .or_else(|| { 540 + inputs 541 + .release_records 542 + .iter() 543 + .find(|rec| rec.id == item.id && rec.drag_active) 544 + .copied() 545 + }); 546 + let drag_delta = match (drag_record, inputs.pointer) { 547 + (Some(rec), Some(p)) => LayoutOffset::between(rec.origin, p), 548 + _ => LayoutOffset::ZERO, 549 + }; 550 + let drag_start_event = inputs.drag_starts.iter().find(|ev| ev.id == item.id); 551 + let drag_button = drag_record 552 + .map(|rec| rec.button) 553 + .or_else(|| drag_start_event.map(|ev| ev.button)); 554 + let click_match = inputs.clicks.iter().find(|ev| ev.id == item.id); 555 + let state = InteractionState::NONE 556 + .with(InteractionState::HOVER, inputs.hovered == Some(item.id)) 557 + .with(InteractionState::FOCUSED, inputs.focused == Some(item.id)) 558 + .with(InteractionState::PRESSED, !pressed_buttons.is_empty()) 559 + .with(InteractionState::DISABLED, item.disabled) 560 + .with(InteractionState::ACTIVE, item.active) 561 + .with(InteractionState::CLICK, click_match.is_some()) 562 + .with( 563 + InteractionState::DOUBLE_CLICK, 564 + click_match.is_some_and(|ev| ev.count.is_double()), 565 + ) 566 + .with(InteractionState::DRAG_START, drag_start_event.is_some()) 567 + .with( 568 + InteractionState::DRAG_RELEASE, 569 + inputs.drag_releases.iter().any(|ev| ev.id == item.id), 570 + ); 571 + Interaction { 572 + state, 573 + drag_delta, 574 + pressed_buttons, 575 + click_button: click_match.map(|ev| ev.button), 576 + drag_button, 577 + } 578 + } 579 + 580 + fn next_click_count( 581 + last: Option<ClickRecord>, 582 + id: WidgetId, 583 + now: FrameInstant, 584 + window: DoubleClickWindow, 585 + ) -> ClickCount { 586 + match last { 587 + Some(prev) if prev.id == id && window.contains(now.since(prev.at)) => prev.count.next(), 588 + _ => ClickCount::SINGLE, 589 + } 590 + } 591 + 592 + #[cfg(test)] 593 + mod tests { 594 + use core::time::Duration; 595 + 596 + use super::{HitFrame, HitItem, HitState, Interaction, Sense, ZLayer, resolve}; 597 + use crate::input::{ 598 + DoubleClickWindow, DragThreshold, FrameInstant, InputSnapshot, PointerButton, 599 + PointerButtonMask, PointerSample, 600 + }; 601 + use crate::layout::{LayoutOffset, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 602 + use crate::widget_id::{WidgetId, WidgetKey}; 603 + 604 + fn id(key: &'static str) -> WidgetId { 605 + WidgetId::ROOT.child(WidgetKey::new(key)) 606 + } 607 + 608 + fn rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { 609 + LayoutRect::new( 610 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 611 + LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), 612 + ) 613 + } 614 + 615 + fn pos(x: f32, y: f32) -> LayoutPos { 616 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)) 617 + } 618 + 619 + fn item(id: WidgetId, rect: LayoutRect, sense: Sense) -> HitItem { 620 + HitItem { 621 + id, 622 + rect, 623 + sense, 624 + z: ZLayer::BASE, 625 + disabled: false, 626 + active: false, 627 + } 628 + } 629 + 630 + fn snap_at(pointer: LayoutPos, frame_ms: u64) -> InputSnapshot { 631 + let mut s = 632 + InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(frame_ms))); 633 + s.pointer = Some(PointerSample::new(pointer)); 634 + s 635 + } 636 + 637 + fn press_snap(pointer: LayoutPos, frame_ms: u64, button: PointerButton) -> InputSnapshot { 638 + let mut s = snap_at(pointer, frame_ms); 639 + s.buttons_pressed = PointerButtonMask::just(button); 640 + s 641 + } 642 + 643 + fn release_snap(pointer: LayoutPos, frame_ms: u64, button: PointerButton) -> InputSnapshot { 644 + let mut s = snap_at(pointer, frame_ms); 645 + s.buttons_released = PointerButtonMask::just(button); 646 + s 647 + } 648 + 649 + #[test] 650 + fn hover_routes_to_topmost_item() { 651 + let mut frame = HitFrame::new(); 652 + frame.push(item( 653 + id("under"), 654 + rect(0.0, 0.0, 100.0, 100.0), 655 + Sense::HOVER, 656 + )); 657 + frame.push(HitItem { 658 + z: ZLayer::new(10), 659 + ..item(id("over"), rect(20.0, 20.0, 40.0, 40.0), Sense::HOVER) 660 + }); 661 + let state = resolve(&HitState::new(), &frame, &snap_at(pos(30.0, 30.0), 0), None); 662 + assert_eq!(state.hovered, Some(id("over"))); 663 + } 664 + 665 + #[test] 666 + fn click_fires_on_press_and_release_inside() { 667 + let mut frame = HitFrame::new(); 668 + frame.push(item( 669 + id("btn"), 670 + rect(0.0, 0.0, 50.0, 50.0), 671 + Sense::INTERACTIVE, 672 + )); 673 + let press = press_snap(pos(10.0, 10.0), 0, PointerButton::Primary); 674 + let after_press = resolve(&HitState::new(), &frame, &press, None); 675 + assert!(after_press.interaction(id("btn")).pressed()); 676 + assert!(!after_press.interaction(id("btn")).click()); 677 + 678 + let release = release_snap(pos(15.0, 15.0), 50, PointerButton::Primary); 679 + let after_release = resolve(&after_press, &frame, &release, None); 680 + let i = after_release.interaction(id("btn")); 681 + assert!(i.click()); 682 + assert!(!i.pressed()); 683 + assert_eq!(i.click_button, Some(PointerButton::Primary)); 684 + } 685 + 686 + #[test] 687 + fn release_outside_does_not_fire_click() { 688 + let mut frame = HitFrame::new(); 689 + frame.push(item( 690 + id("btn"), 691 + rect(0.0, 0.0, 50.0, 50.0), 692 + Sense::INTERACTIVE, 693 + )); 694 + let press = press_snap(pos(10.0, 10.0), 0, PointerButton::Primary); 695 + let after_press = resolve(&HitState::new(), &frame, &press, None); 696 + 697 + let release = release_snap(pos(200.0, 200.0), 50, PointerButton::Primary); 698 + let after_release = resolve(&after_press, &frame, &release, None); 699 + assert!(!after_release.interaction(id("btn")).click()); 700 + } 701 + 702 + #[test] 703 + fn double_click_fires_within_window() { 704 + let mut frame = HitFrame::new(); 705 + frame.push(item( 706 + id("btn"), 707 + rect(0.0, 0.0, 50.0, 50.0), 708 + Sense::INTERACTIVE, 709 + )); 710 + let s1 = resolve( 711 + &HitState::new(), 712 + &frame, 713 + &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary), 714 + None, 715 + ); 716 + let s2 = resolve( 717 + &s1, 718 + &frame, 719 + &release_snap(pos(5.0, 5.0), 30, PointerButton::Primary), 720 + None, 721 + ); 722 + assert!(s2.interaction(id("btn")).click()); 723 + assert!(!s2.interaction(id("btn")).double_click()); 724 + 725 + let s3 = resolve( 726 + &s2, 727 + &frame, 728 + &press_snap(pos(5.0, 5.0), 60, PointerButton::Primary), 729 + None, 730 + ); 731 + let s4 = resolve( 732 + &s3, 733 + &frame, 734 + &release_snap(pos(5.0, 5.0), 90, PointerButton::Primary), 735 + None, 736 + ); 737 + let i = s4.interaction(id("btn")); 738 + assert!(i.click()); 739 + assert!(i.double_click()); 740 + } 741 + 742 + #[test] 743 + fn triple_click_keeps_double_click_flag() { 744 + let mut frame = HitFrame::new(); 745 + frame.push(item( 746 + id("btn"), 747 + rect(0.0, 0.0, 50.0, 50.0), 748 + Sense::INTERACTIVE, 749 + )); 750 + let states = (0..3).fold(HitState::new(), |state, n| { 751 + let press_ms = n * 60; 752 + let release_ms = press_ms + 30; 753 + let s = resolve( 754 + &state, 755 + &frame, 756 + &press_snap(pos(5.0, 5.0), press_ms, PointerButton::Primary), 757 + None, 758 + ); 759 + resolve( 760 + &s, 761 + &frame, 762 + &release_snap(pos(5.0, 5.0), release_ms, PointerButton::Primary), 763 + None, 764 + ) 765 + }); 766 + let Some(last) = states.last_click.get(&PointerButton::Primary).copied() else { 767 + panic!("triple-click leaves a click record"); 768 + }; 769 + assert_eq!(last.count.get(), 3); 770 + assert!(last.count.is_double()); 771 + } 772 + 773 + #[test] 774 + fn drag_capture_routes_motion_outside_bounds() { 775 + let mut frame = HitFrame::new(); 776 + frame.push(item( 777 + id("handle"), 778 + rect(0.0, 0.0, 20.0, 20.0), 779 + Sense::DRAGGABLE, 780 + )); 781 + let s1 = resolve( 782 + &HitState::new(), 783 + &frame, 784 + &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary), 785 + None, 786 + ); 787 + assert_eq!(s1.captures.top().map(|c| c.id), Some(id("handle"))); 788 + 789 + let motion = snap_at(pos(200.0, 200.0), 16); 790 + let s2 = resolve(&s1, &frame, &motion, None); 791 + let i = s2.interaction(id("handle")); 792 + assert!(i.drag_start()); 793 + assert_ne!(i.drag_delta, LayoutOffset::ZERO); 794 + assert_eq!(i.drag_button, Some(PointerButton::Primary)); 795 + assert_eq!(s2.hovered, Some(id("handle"))); 796 + } 797 + 798 + #[test] 799 + fn drag_release_clears_capture_and_skips_click() { 800 + let mut frame = HitFrame::new(); 801 + frame.push(item( 802 + id("handle"), 803 + rect(0.0, 0.0, 20.0, 20.0), 804 + Sense::DRAGGABLE, 805 + )); 806 + let s1 = resolve( 807 + &HitState::new(), 808 + &frame, 809 + &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary), 810 + None, 811 + ); 812 + let motion = snap_at(pos(200.0, 200.0), 16); 813 + let s2 = resolve(&s1, &frame, &motion, None); 814 + let s3 = resolve( 815 + &s2, 816 + &frame, 817 + &release_snap(pos(220.0, 220.0), 32, PointerButton::Primary), 818 + None, 819 + ); 820 + let i = s3.interaction(id("handle")); 821 + assert!(i.drag_release()); 822 + assert!(!i.click()); 823 + assert!(s3.captures.is_empty()); 824 + } 825 + 826 + #[test] 827 + fn disabled_item_does_not_press() { 828 + let mut frame = HitFrame::new(); 829 + frame.push(HitItem { 830 + disabled: true, 831 + ..item(id("btn"), rect(0.0, 0.0, 50.0, 50.0), Sense::INTERACTIVE) 832 + }); 833 + let press = press_snap(pos(10.0, 10.0), 0, PointerButton::Primary); 834 + let s = resolve(&HitState::new(), &frame, &press, None); 835 + let i = s.interaction(id("btn")); 836 + assert!(!i.pressed()); 837 + assert!(i.disabled()); 838 + } 839 + 840 + #[test] 841 + fn focused_id_threads_into_interaction() { 842 + let mut frame = HitFrame::new(); 843 + frame.push(item(id("btn"), rect(0.0, 0.0, 50.0, 50.0), Sense::HOVER)); 844 + let s = resolve( 845 + &HitState::new(), 846 + &frame, 847 + &snap_at(pos(10.0, 10.0), 0), 848 + Some(id("btn")), 849 + ); 850 + assert!(s.interaction(id("btn")).focused()); 851 + } 852 + 853 + #[test] 854 + fn active_flag_threads_from_hit_item() { 855 + let mut frame = HitFrame::new(); 856 + frame.push(HitItem { 857 + active: true, 858 + ..item(id("toggle"), rect(0.0, 0.0, 50.0, 50.0), Sense::HOVER) 859 + }); 860 + let s = resolve(&HitState::new(), &frame, &snap_at(pos(10.0, 10.0), 0), None); 861 + assert!(s.interaction(id("toggle")).active()); 862 + } 863 + 864 + #[test] 865 + fn interaction_idle_default() { 866 + let i = Interaction::idle(); 867 + assert!(!i.hover()); 868 + assert!(!i.click()); 869 + assert_eq!(i.drag_delta, LayoutOffset::ZERO); 870 + assert!(i.pressed_buttons.is_empty()); 871 + assert_eq!(i.click_button, None); 872 + assert_eq!(i.drag_button, None); 873 + } 874 + 875 + #[test] 876 + fn pressed_buttons_records_each_button_held() { 877 + let mut frame = HitFrame::new(); 878 + frame.push(item( 879 + id("btn"), 880 + rect(0.0, 0.0, 50.0, 50.0), 881 + Sense::INTERACTIVE, 882 + )); 883 + let mut snap = snap_at(pos(10.0, 10.0), 0); 884 + snap.buttons_pressed = 885 + PointerButtonMask::just(PointerButton::Primary).with(PointerButton::Secondary); 886 + let s = resolve(&HitState::new(), &frame, &snap, None); 887 + let i = s.interaction(id("btn")); 888 + assert!(i.pressed_buttons.contains(PointerButton::Primary)); 889 + assert!(i.pressed_buttons.contains(PointerButton::Secondary)); 890 + assert!(!i.pressed_buttons.contains(PointerButton::Middle)); 891 + } 892 + 893 + #[test] 894 + #[should_panic(expected = "Sense::PRESS implies Sense::HOVER")] 895 + fn press_without_hover_panics_on_push() { 896 + let mut frame = HitFrame::new(); 897 + frame.push(item(id("rogue"), rect(0.0, 0.0, 10.0, 10.0), Sense::PRESS)); 898 + } 899 + 900 + #[test] 901 + #[should_panic(expected = "duplicate WidgetId in HitFrame")] 902 + fn duplicate_widget_id_in_frame_panics() { 903 + let mut frame = HitFrame::new(); 904 + frame.push(item(id("dup"), rect(0.0, 0.0, 10.0, 10.0), Sense::HOVER)); 905 + frame.push(item(id("dup"), rect(20.0, 0.0, 10.0, 10.0), Sense::HOVER)); 906 + } 907 + 908 + #[test] 909 + fn release_button_clears_all_captures_for_that_button() { 910 + let mut stack = super::PointerCaptureStack::new(); 911 + stack.push(super::PointerCapture { 912 + id: id("ghost"), 913 + button: PointerButton::Primary, 914 + }); 915 + stack.push(super::PointerCapture { 916 + id: id("active"), 917 + button: PointerButton::Primary, 918 + }); 919 + stack.push(super::PointerCapture { 920 + id: id("middle"), 921 + button: PointerButton::Middle, 922 + }); 923 + stack.release_button(PointerButton::Primary); 924 + assert_eq!(stack.captures().len(), 1); 925 + assert_eq!(stack.captures()[0].button, PointerButton::Middle); 926 + } 927 + 928 + #[test] 929 + fn drag_start_skipped_when_widget_disables_mid_press() { 930 + let id_h = id("handle"); 931 + let mut frame = HitFrame::new(); 932 + frame.push(item(id_h, rect(0.0, 0.0, 20.0, 20.0), Sense::DRAGGABLE)); 933 + let s1 = resolve( 934 + &HitState::new(), 935 + &frame, 936 + &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary), 937 + None, 938 + ); 939 + let mut disabled_frame = HitFrame::new(); 940 + disabled_frame.push(HitItem { 941 + disabled: true, 942 + ..item(id_h, rect(0.0, 0.0, 20.0, 20.0), Sense::DRAGGABLE) 943 + }); 944 + let s2 = resolve(&s1, &disabled_frame, &snap_at(pos(80.0, 80.0), 16), None); 945 + assert!(!s2.interaction(id_h).drag_start()); 946 + } 947 + 948 + #[test] 949 + fn double_click_window_default() { 950 + assert_eq!( 951 + DoubleClickWindow::DEFAULT.duration(), 952 + Duration::from_millis(400) 953 + ); 954 + } 955 + 956 + #[test] 957 + fn drag_threshold_default_4_px() { 958 + assert_eq!(DragThreshold::DEFAULT.px(), LayoutPx::new(4.0)); 959 + } 960 + 961 + #[test] 962 + fn secondary_button_press_release_fires_click() { 963 + let mut frame = HitFrame::new(); 964 + frame.push(item( 965 + id("btn"), 966 + rect(0.0, 0.0, 50.0, 50.0), 967 + Sense::INTERACTIVE, 968 + )); 969 + let s1 = resolve( 970 + &HitState::new(), 971 + &frame, 972 + &press_snap(pos(10.0, 10.0), 0, PointerButton::Secondary), 973 + None, 974 + ); 975 + assert!(s1.interaction(id("btn")).pressed()); 976 + let s2 = resolve( 977 + &s1, 978 + &frame, 979 + &release_snap(pos(10.0, 10.0), 30, PointerButton::Secondary), 980 + None, 981 + ); 982 + let i = s2.interaction(id("btn")); 983 + assert!(i.click()); 984 + assert_eq!(i.click_button, Some(PointerButton::Secondary)); 985 + } 986 + 987 + #[test] 988 + fn two_buttons_pressed_in_same_snapshot() { 989 + let mut frame = HitFrame::new(); 990 + frame.push(item( 991 + id("btn"), 992 + rect(0.0, 0.0, 50.0, 50.0), 993 + Sense::INTERACTIVE, 994 + )); 995 + let mut snap = snap_at(pos(10.0, 10.0), 0); 996 + snap.buttons_pressed = 997 + PointerButtonMask::just(PointerButton::Primary).with(PointerButton::Secondary); 998 + let s = resolve(&HitState::new(), &frame, &snap, None); 999 + assert_eq!(s.pressed.len(), 2); 1000 + assert!(s.pressed.contains_key(&PointerButton::Primary)); 1001 + assert!(s.pressed.contains_key(&PointerButton::Secondary)); 1002 + assert!(s.interaction(id("btn")).pressed()); 1003 + } 1004 + 1005 + #[test] 1006 + fn middle_button_drag_records_drag_button() { 1007 + let mut frame = HitFrame::new(); 1008 + frame.push(item( 1009 + id("viewport"), 1010 + rect(0.0, 0.0, 200.0, 200.0), 1011 + Sense::DRAGGABLE, 1012 + )); 1013 + let s1 = resolve( 1014 + &HitState::new(), 1015 + &frame, 1016 + &press_snap(pos(50.0, 50.0), 0, PointerButton::Middle), 1017 + None, 1018 + ); 1019 + let motion = snap_at(pos(120.0, 120.0), 16); 1020 + let s2 = resolve(&s1, &frame, &motion, None); 1021 + let i = s2.interaction(id("viewport")); 1022 + assert!(i.drag_start()); 1023 + assert_eq!(i.drag_button, Some(PointerButton::Middle)); 1024 + } 1025 + 1026 + #[test] 1027 + #[should_panic(expected = "Sense::DRAG implies Sense::PRESS")] 1028 + fn drag_without_press_panics_on_push() { 1029 + let mut frame = HitFrame::new(); 1030 + frame.push(item(id("rogue"), rect(0.0, 0.0, 10.0, 10.0), Sense::DRAG)); 1031 + } 1032 + 1033 + #[test] 1034 + fn drag_delta_falls_back_to_last_pointer_when_pointer_drops() { 1035 + let mut frame = HitFrame::new(); 1036 + frame.push(item( 1037 + id("handle"), 1038 + rect(0.0, 0.0, 20.0, 20.0), 1039 + Sense::DRAGGABLE, 1040 + )); 1041 + let s1 = resolve( 1042 + &HitState::new(), 1043 + &frame, 1044 + &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary), 1045 + None, 1046 + ); 1047 + let s2 = resolve(&s1, &frame, &snap_at(pos(80.0, 80.0), 16), None); 1048 + assert!(s2.interaction(id("handle")).drag_start()); 1049 + 1050 + let mut idle = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(32))); 1051 + idle.pointer = None; 1052 + let s3 = resolve(&s2, &frame, &idle, None); 1053 + let i = s3.interaction(id("handle")); 1054 + assert_ne!(i.drag_delta, LayoutOffset::ZERO); 1055 + assert_eq!(s3.last_pointer, Some(pos(80.0, 80.0))); 1056 + } 1057 + 1058 + #[test] 1059 + fn ghost_capture_falls_through_to_position_routing() { 1060 + let mut frame = HitFrame::new(); 1061 + frame.push(item( 1062 + id("handle"), 1063 + rect(0.0, 0.0, 20.0, 20.0), 1064 + Sense::DRAGGABLE, 1065 + )); 1066 + let s1 = resolve( 1067 + &HitState::new(), 1068 + &frame, 1069 + &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary), 1070 + None, 1071 + ); 1072 + assert_eq!(s1.captures.top().map(|c| c.id), Some(id("handle"))); 1073 + 1074 + let mut next_frame = HitFrame::new(); 1075 + next_frame.push(item( 1076 + id("other"), 1077 + rect(50.0, 50.0, 20.0, 20.0), 1078 + Sense::HOVER, 1079 + )); 1080 + let s2 = resolve(&s1, &next_frame, &snap_at(pos(55.0, 55.0), 16), None); 1081 + assert_eq!(s2.hovered, Some(id("other"))); 1082 + } 1083 + 1084 + #[test] 1085 + fn release_without_press_clears_stale_capture() { 1086 + let mut frame = HitFrame::new(); 1087 + frame.push(item( 1088 + id("handle"), 1089 + rect(0.0, 0.0, 20.0, 20.0), 1090 + Sense::DRAGGABLE, 1091 + )); 1092 + let mut prev = HitState::new(); 1093 + prev.captures.push(super::PointerCapture { 1094 + id: id("handle"), 1095 + button: PointerButton::Primary, 1096 + }); 1097 + let s = resolve( 1098 + &prev, 1099 + &frame, 1100 + &release_snap(pos(5.0, 5.0), 0, PointerButton::Primary), 1101 + None, 1102 + ); 1103 + assert!(s.captures.is_empty()); 1104 + } 1105 + }