Another project
0

Configure Feed

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

at main 27 kB View raw
1use core::fmt::Debug; 2use core::num::NonZeroU8; 3 4use crate::a11y::{AccessNode, AccessRange, Role}; 5use crate::frame::{FrameCtx, InteractDeclaration}; 6use crate::hit_test::{Interaction, Sense}; 7use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey}; 8use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 9use crate::strings::StringKey; 10use crate::theme::{Border, Step12, StrokeWidth}; 11use crate::widget_id::WidgetId; 12 13use super::keys::{TakeKey, take_key}; 14use super::paint::{GlyphMark, WidgetPaint}; 15use super::visuals::push_focus_ring; 16 17pub trait SliderScalar: Copy + Debug + Default + PartialOrd { 18 #[must_use] 19 fn to_unit(self, range: SliderRange<Self>) -> f64; 20 #[must_use] 21 fn from_unit(unit: f64, range: SliderRange<Self>) -> Self; 22 #[must_use] 23 fn to_f64(self) -> f64; 24 #[must_use] 25 fn step_by(self, step: SliderStep<Self>, sign: i32) -> Self; 26 #[must_use] 27 fn clamp_to(self, range: SliderRange<Self>) -> Self; 28 #[must_use] 29 fn snap_to_step(self, step: SliderStep<Self>, range: SliderRange<Self>) -> Self; 30} 31 32#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] 33pub enum SliderRangeError { 34 #[error("slider range min must be strictly less than max")] 35 NotOrdered, 36 #[error("slider range bounds must be comparable")] 37 NotComparable, 38} 39 40#[derive(Copy, Clone, Debug, PartialEq)] 41pub struct SliderRange<T> { 42 min: T, 43 max: T, 44} 45 46impl<T: SliderScalar> SliderRange<T> { 47 pub fn try_new(min: T, max: T) -> Result<Self, SliderRangeError> { 48 match min.partial_cmp(&max) { 49 Some(core::cmp::Ordering::Less) => Ok(Self { min, max }), 50 Some(_) => Err(SliderRangeError::NotOrdered), 51 None => Err(SliderRangeError::NotComparable), 52 } 53 } 54 55 #[must_use] 56 pub fn min(self) -> T { 57 self.min 58 } 59 60 #[must_use] 61 pub fn max(self) -> T { 62 self.max 63 } 64} 65 66#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] 67pub enum SliderStepError { 68 #[error("slider step must be strictly positive")] 69 NotPositive, 70} 71 72#[derive(Copy, Clone, Debug, PartialEq)] 73pub struct SliderStep<T>(T); 74 75impl<T: SliderScalar> SliderStep<T> { 76 pub fn try_new(value: T) -> Result<Self, SliderStepError> { 77 match value.partial_cmp(&T::default()) { 78 Some(core::cmp::Ordering::Greater) => Ok(Self(value)), 79 _ => Err(SliderStepError::NotPositive), 80 } 81 } 82 83 #[must_use] 84 pub fn value(self) -> T { 85 self.0 86 } 87} 88 89#[derive(Copy, Clone, Debug, PartialEq, Eq)] 90pub struct SliderCoarseStep(NonZeroU8); 91 92impl SliderCoarseStep { 93 pub const DEFAULT: Self = match NonZeroU8::new(10) { 94 Some(n) => Self(n), 95 None => panic!("default coarse step must be non-zero"), 96 }; 97 98 #[must_use] 99 pub const fn new(value: NonZeroU8) -> Self { 100 Self(value) 101 } 102 103 #[must_use] 104 pub const fn get(self) -> NonZeroU8 { 105 self.0 106 } 107} 108 109pub struct Slider<T: SliderScalar> { 110 pub id: WidgetId, 111 pub rect: LayoutRect, 112 pub label: StringKey, 113 pub value: T, 114 pub range: SliderRange<T>, 115 pub step: SliderStep<T>, 116 pub coarse_multiplier: SliderCoarseStep, 117 pub disabled: bool, 118} 119 120impl<T: SliderScalar> Slider<T> { 121 #[must_use] 122 pub fn new( 123 id: WidgetId, 124 rect: LayoutRect, 125 label: StringKey, 126 value: T, 127 range: SliderRange<T>, 128 step: SliderStep<T>, 129 ) -> Self { 130 Self { 131 id, 132 rect, 133 label, 134 value, 135 range, 136 step, 137 coarse_multiplier: SliderCoarseStep::DEFAULT, 138 disabled: false, 139 } 140 } 141 142 #[must_use] 143 pub fn disabled(self, disabled: bool) -> Self { 144 Self { disabled, ..self } 145 } 146} 147 148#[derive(Clone, Debug, PartialEq)] 149pub struct SliderResponse<T: SliderScalar> { 150 pub interaction: Interaction, 151 pub value: T, 152 pub changed: bool, 153 pub paint: Vec<WidgetPaint>, 154} 155 156#[must_use] 157#[allow( 158 clippy::needless_pass_by_value, 159 reason = "destructure consumes the slider" 160)] 161pub fn show_slider<T: SliderScalar>( 162 ctx: &mut FrameCtx<'_>, 163 slider: Slider<T>, 164) -> SliderResponse<T> { 165 let Slider { 166 id, 167 rect, 168 label, 169 value: initial_value, 170 range, 171 step, 172 coarse_multiplier, 173 disabled, 174 } = slider; 175 let interactive = !disabled; 176 let interaction = ctx.interact( 177 InteractDeclaration::new(id, rect, Sense::DRAGGABLE) 178 .focusable(interactive) 179 .disabled(!interactive), 180 ); 181 let mut value = initial_value; 182 let mut changed = false; 183 184 if interactive 185 && (interaction.click() || interaction.drag_start() || interaction.pressed()) 186 && let Some(unit) = pointer_unit(rect, ctx.input) 187 { 188 let next = T::from_unit(unit, range) 189 .snap_to_step(step, range) 190 .clamp_to(range); 191 if next != value { 192 value = next; 193 changed = true; 194 } 195 } 196 197 let live_focused = ctx.is_focused(id); 198 if interactive 199 && live_focused 200 && let Some(event) = take_key(ctx.input, &KEYBOARD_TARGETS) 201 { 202 let next = apply_key(value, range, step, coarse_multiplier, event); 203 if next != value { 204 value = next; 205 changed = true; 206 } 207 } 208 209 ctx.a11y.push( 210 id, 211 rect, 212 AccessNode::new(Role::Slider) 213 .with_label(label) 214 .with_disabled(!interactive) 215 .with_range(AccessRange { 216 value: value.to_f64(), 217 min: range.min().to_f64(), 218 max: range.max().to_f64(), 219 step: step.value().to_f64(), 220 }), 221 ); 222 223 let paint = build_paint(ctx, rect, range, disabled, value, interaction, live_focused); 224 SliderResponse { 225 interaction, 226 value, 227 changed, 228 paint, 229 } 230} 231 232const KEYBOARD_TARGETS: [TakeKey; 10] = [ 233 TakeKey::named(NamedKey::ArrowLeft), 234 TakeKey::named(NamedKey::ArrowRight), 235 TakeKey::named(NamedKey::ArrowUp), 236 TakeKey::named(NamedKey::ArrowDown), 237 TakeKey::named(NamedKey::Home), 238 TakeKey::named(NamedKey::End), 239 TakeKey::new(KeyCode::Named(NamedKey::ArrowLeft), ModifierMask::SHIFT), 240 TakeKey::new(KeyCode::Named(NamedKey::ArrowRight), ModifierMask::SHIFT), 241 TakeKey::new(KeyCode::Named(NamedKey::ArrowUp), ModifierMask::SHIFT), 242 TakeKey::new(KeyCode::Named(NamedKey::ArrowDown), ModifierMask::SHIFT), 243]; 244 245fn pointer_unit(rect: LayoutRect, input: &crate::input::InputSnapshot) -> Option<f64> { 246 let width = rect.size.width.value(); 247 if width <= 0.0 { 248 return None; 249 } 250 let pointer = input.pointer?.position; 251 let local_x = pointer.x.value() - rect.origin.x.value(); 252 Some(f64::from((local_x / width).clamp(0.0, 1.0))) 253} 254 255fn apply_key<T: SliderScalar>( 256 value: T, 257 range: SliderRange<T>, 258 step: SliderStep<T>, 259 coarse: SliderCoarseStep, 260 event: KeyEvent, 261) -> T { 262 let magnitude = if event.modifiers.contains(ModifierMask::SHIFT) { 263 i32::from(coarse.get().get()) 264 } else { 265 1 266 }; 267 let stepped = |signed: i32| { 268 value 269 .step_by(step, signed) 270 .snap_to_step(step, range) 271 .clamp_to(range) 272 }; 273 match event.code { 274 KeyCode::Named(NamedKey::ArrowLeft | NamedKey::ArrowDown) => stepped(-magnitude), 275 KeyCode::Named(NamedKey::ArrowRight | NamedKey::ArrowUp) => stepped(magnitude), 276 KeyCode::Named(NamedKey::Home) => range.min(), 277 KeyCode::Named(NamedKey::End) => range.max(), 278 _ => value, 279 } 280} 281 282fn build_paint<T: SliderScalar>( 283 ctx: &FrameCtx<'_>, 284 rect: LayoutRect, 285 range: SliderRange<T>, 286 disabled: bool, 287 value: T, 288 interaction: Interaction, 289 live_focused: bool, 290) -> Vec<WidgetPaint> { 291 let neutral = ctx.theme().colors.neutral; 292 let accent = ctx.theme().colors.accent; 293 let radius = ctx.theme().radius.pill; 294 let track_height = LayoutPx::new(4.0); 295 let track_y = LayoutPx::new( 296 rect.origin.y.value() + rect.size.height.value() / 2.0 - track_height.value() / 2.0, 297 ); 298 let track_rect = LayoutRect::new( 299 LayoutPos::new(rect.origin.x, track_y), 300 LayoutSize::new(rect.size.width, track_height), 301 ); 302 let unit = value.to_unit(range).clamp(0.0, 1.0); 303 #[allow( 304 clippy::cast_possible_truncation, 305 reason = "unit is clamped to [0, 1] before f32 narrowing" 306 )] 307 let unit_f32 = unit as f32; 308 let filled_width = LayoutPx::new(rect.size.width.value() * unit_f32); 309 let filled_rect = LayoutRect::new( 310 LayoutPos::new(rect.origin.x, track_y), 311 LayoutSize::new(filled_width, track_height), 312 ); 313 let thumb_diameter = LayoutPx::new(rect.size.height.value().min(20.0)); 314 let thumb_x = LayoutPx::new( 315 rect.origin.x.value() + rect.size.width.value() * unit_f32 - thumb_diameter.value() / 2.0, 316 ); 317 let thumb_y = LayoutPx::new( 318 rect.origin.y.value() + rect.size.height.value() / 2.0 - thumb_diameter.value() / 2.0, 319 ); 320 let thumb_rect = LayoutRect::new( 321 LayoutPos::new(thumb_x, thumb_y), 322 LayoutSize::new(thumb_diameter, thumb_diameter), 323 ); 324 let track_fill = neutral.step(Step12::ELEMENT_BG); 325 let filled_fill = if disabled { 326 neutral.step(Step12::SUBTLE_BORDER) 327 } else { 328 accent.step(Step12::SOLID) 329 }; 330 let mut paint = vec![ 331 WidgetPaint::Surface { 332 rect: track_rect, 333 fill: track_fill, 334 border: Some(Border { 335 width: StrokeWidth::HAIRLINE, 336 color: neutral.step(Step12::BORDER), 337 }), 338 radius, 339 elevation: None, 340 }, 341 WidgetPaint::Surface { 342 rect: filled_rect, 343 fill: filled_fill, 344 border: None, 345 radius, 346 elevation: None, 347 }, 348 WidgetPaint::Surface { 349 rect: thumb_rect, 350 fill: if interaction.pressed() || interaction.hover() { 351 accent.step(Step12::HOVER_SOLID) 352 } else { 353 accent.step(Step12::SOLID) 354 }, 355 border: Some(Border { 356 width: StrokeWidth::HAIRLINE, 357 color: neutral.step(Step12::BORDER), 358 }), 359 radius, 360 elevation: None, 361 }, 362 WidgetPaint::Mark { 363 rect: thumb_rect, 364 kind: GlyphMark::SliderThumb, 365 color: ctx.theme().colors.contrast_text(filled_fill), 366 }, 367 ]; 368 push_focus_ring(ctx, &mut paint, thumb_rect, radius, live_focused); 369 paint 370} 371 372impl SliderScalar for f64 { 373 fn to_unit(self, range: SliderRange<Self>) -> f64 { 374 ((self - range.min) / (range.max - range.min)).clamp(0.0, 1.0) 375 } 376 377 fn from_unit(unit: f64, range: SliderRange<Self>) -> Self { 378 range.min + (range.max - range.min) * unit.clamp(0.0, 1.0) 379 } 380 381 fn to_f64(self) -> f64 { 382 self 383 } 384 385 fn step_by(self, step: SliderStep<Self>, sign: i32) -> Self { 386 self + step.value() * f64::from(sign) 387 } 388 389 fn clamp_to(self, range: SliderRange<Self>) -> Self { 390 self.clamp(range.min, range.max) 391 } 392 393 fn snap_to_step(self, step: SliderStep<Self>, range: SliderRange<Self>) -> Self { 394 let s = step.value(); 395 range.min + ((self - range.min) / s).round() * s 396 } 397} 398 399impl SliderScalar for f32 { 400 fn to_unit(self, range: SliderRange<Self>) -> f64 { 401 f64::from((self - range.min) / (range.max - range.min)).clamp(0.0, 1.0) 402 } 403 404 #[allow( 405 clippy::cast_possible_truncation, 406 reason = "unit is clamped to [0, 1] before f32 narrowing" 407 )] 408 fn from_unit(unit: f64, range: SliderRange<Self>) -> Self { 409 range.min + (range.max - range.min) * unit.clamp(0.0, 1.0) as f32 410 } 411 412 fn to_f64(self) -> f64 { 413 f64::from(self) 414 } 415 416 fn step_by(self, step: SliderStep<Self>, sign: i32) -> Self { 417 #[allow( 418 clippy::cast_precision_loss, 419 reason = "sign values fit f32 mantissa exactly" 420 )] 421 let mul = sign as f32; 422 self + step.value() * mul 423 } 424 425 fn clamp_to(self, range: SliderRange<Self>) -> Self { 426 self.clamp(range.min, range.max) 427 } 428 429 fn snap_to_step(self, step: SliderStep<Self>, range: SliderRange<Self>) -> Self { 430 let s = step.value(); 431 range.min + ((self - range.min) / s).round() * s 432 } 433} 434 435impl SliderScalar for i32 { 436 fn to_unit(self, range: SliderRange<Self>) -> f64 { 437 let span = i64::from(range.max) - i64::from(range.min); 438 let offset = i64::from(self) - i64::from(range.min); 439 #[allow( 440 clippy::cast_precision_loss, 441 reason = "i64 span fits in f64 for i32 inputs" 442 )] 443 let result = offset as f64 / span as f64; 444 result 445 } 446 447 fn to_f64(self) -> f64 { 448 f64::from(self) 449 } 450 451 fn from_unit(unit: f64, range: SliderRange<Self>) -> Self { 452 let span = i64::from(range.max) - i64::from(range.min); 453 #[allow( 454 clippy::cast_precision_loss, 455 reason = "i64 span fits in f64 for i32 inputs" 456 )] 457 let span_f = span as f64; 458 #[allow( 459 clippy::cast_possible_truncation, 460 reason = "round + clamp keeps result in i64 range" 461 )] 462 let offset = (span_f * unit.clamp(0.0, 1.0)).round() as i64; 463 let raw = i64::from(range.min).saturating_add(offset); 464 #[allow( 465 clippy::cast_possible_truncation, 466 reason = "clamp before cast keeps result in i32" 467 )] 468 let clamped = raw.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; 469 clamped 470 } 471 472 fn step_by(self, step: SliderStep<Self>, sign: i32) -> Self { 473 self.saturating_add(step.value().saturating_mul(sign)) 474 } 475 476 fn clamp_to(self, range: SliderRange<Self>) -> Self { 477 self.clamp(range.min, range.max) 478 } 479 480 fn snap_to_step(self, step: SliderStep<Self>, range: SliderRange<Self>) -> Self { 481 let offset = i64::from(self) - i64::from(range.min); 482 let step_i64 = i64::from(step.value()); 483 #[allow(clippy::cast_precision_loss, reason = "i32-derived spans fit in f64")] 484 let n = (offset as f64 / step_i64 as f64).round(); 485 #[allow( 486 clippy::cast_possible_truncation, 487 reason = "clamp before cast keeps result in i32" 488 )] 489 let snapped = i64::from(range.min) 490 .saturating_add((n as i64).saturating_mul(step_i64)) 491 .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; 492 snapped 493 } 494} 495 496#[cfg(test)] 497mod tests { 498 use std::sync::Arc; 499 500 use super::{ 501 Slider, SliderRange, SliderRangeError, SliderScalar, SliderStep, SliderStepError, 502 show_slider, 503 }; 504 use crate::focus::FocusManager; 505 use crate::frame::FrameCtx; 506 use crate::hit_test::{HitFrame, HitState}; 507 use crate::hotkey::HotkeyTable; 508 use crate::input::{FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey}; 509 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 510 use crate::strings::{StringKey, StringTable}; 511 use crate::theme::Theme; 512 use crate::widget_id::{WidgetId, WidgetKey}; 513 514 const LABEL: StringKey = StringKey::new("slider.label"); 515 516 fn rect() -> LayoutRect { 517 LayoutRect::new( 518 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 519 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(20.0)), 520 ) 521 } 522 523 fn slider_id() -> WidgetId { 524 WidgetId::ROOT.child(WidgetKey::new("slider")) 525 } 526 527 fn unit_range() -> SliderRange<f64> { 528 let Ok(range) = SliderRange::try_new(0.0_f64, 100.0) else { 529 panic!("test range must be valid"); 530 }; 531 range 532 } 533 534 fn unit_step() -> SliderStep<f64> { 535 let Ok(step) = SliderStep::try_new(1.0_f64) else { 536 panic!("test step must be valid"); 537 }; 538 step 539 } 540 541 fn run(value: f64, events: Vec<KeyEvent>) -> super::SliderResponse<f64> { 542 let theme = Arc::new(Theme::light()); 543 let mut focus = FocusManager::new(); 544 focus.register_focusable(slider_id()); 545 focus.request_focus(slider_id()); 546 focus.end_frame(); 547 let table = HotkeyTable::new(); 548 let mut hits = HitFrame::new(); 549 let prev = HitState::new(); 550 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 551 input.keys_pressed = events; 552 let widget = Slider::new(slider_id(), rect(), LABEL, value, unit_range(), unit_step()); 553 let mut shaper = bone_text::Shaper::new(); 554 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 555 let mut ctx = FrameCtx::new( 556 theme, 557 &mut input, 558 &mut focus, 559 &table, 560 StringTable::empty(), 561 &mut hits, 562 &prev, 563 &mut a11y, 564 &mut shaper, 565 ); 566 show_slider(&mut ctx, widget) 567 } 568 569 #[test] 570 fn arrow_right_steps_up() { 571 let response = run( 572 10.0, 573 vec![KeyEvent::new( 574 KeyCode::Named(NamedKey::ArrowRight), 575 ModifierMask::NONE, 576 )], 577 ); 578 assert!((response.value - 11.0).abs() < 1e-9); 579 assert!(response.changed); 580 } 581 582 #[test] 583 fn arrow_left_steps_down() { 584 let response = run( 585 10.0, 586 vec![KeyEvent::new( 587 KeyCode::Named(NamedKey::ArrowLeft), 588 ModifierMask::NONE, 589 )], 590 ); 591 assert!((response.value - 9.0).abs() < 1e-9); 592 } 593 594 #[test] 595 fn shift_arrow_uses_coarse_multiplier() { 596 let response = run( 597 10.0, 598 vec![KeyEvent::new( 599 KeyCode::Named(NamedKey::ArrowRight), 600 ModifierMask::SHIFT, 601 )], 602 ); 603 assert!((response.value - 20.0).abs() < 1e-9); 604 } 605 606 #[test] 607 fn home_jumps_to_min() { 608 let response = run( 609 42.0, 610 vec![KeyEvent::new( 611 KeyCode::Named(NamedKey::Home), 612 ModifierMask::NONE, 613 )], 614 ); 615 assert!((response.value - 0.0).abs() < 1e-9); 616 } 617 618 #[test] 619 fn end_jumps_to_max() { 620 let response = run( 621 42.0, 622 vec![KeyEvent::new( 623 KeyCode::Named(NamedKey::End), 624 ModifierMask::NONE, 625 )], 626 ); 627 assert!((response.value - 100.0).abs() < 1e-9); 628 } 629 630 #[test] 631 fn arrow_keys_clamp_at_range_edges() { 632 let response = run( 633 0.0, 634 vec![KeyEvent::new( 635 KeyCode::Named(NamedKey::ArrowLeft), 636 ModifierMask::NONE, 637 )], 638 ); 639 assert!((response.value - 0.0).abs() < 1e-9); 640 } 641 642 #[test] 643 fn idle_keeps_value_unchanged() { 644 let response = run(50.0, vec![]); 645 assert!((response.value - 50.0).abs() < 1e-9); 646 assert!(!response.changed); 647 } 648 649 #[test] 650 fn unit_conversions_round_trip() { 651 let range = unit_range(); 652 [0.0_f64, 25.0, 50.0, 75.0, 100.0] 653 .into_iter() 654 .for_each(|v| { 655 let unit = v.to_unit(range); 656 let back = f64::from_unit(unit, range); 657 assert!((back - v).abs() < 1e-9); 658 }); 659 } 660 661 #[test] 662 fn integer_slider_steps_by_one() { 663 let Ok(range) = SliderRange::try_new(0_i32, 10) else { 664 panic!("range valid"); 665 }; 666 let Ok(step) = SliderStep::try_new(2_i32) else { 667 panic!("step valid"); 668 }; 669 let unit_at_5 = 5_i32.to_unit(range); 670 assert!((unit_at_5 - 0.5).abs() < 1e-9); 671 assert_eq!(i32::from_unit(0.7, range), 7); 672 assert_eq!(5_i32.step_by(step, -1).clamp_to(range), 3); 673 } 674 675 #[test] 676 fn range_try_new_rejects_reversed() { 677 assert_eq!( 678 SliderRange::try_new(10.0_f64, 5.0), 679 Err(SliderRangeError::NotOrdered), 680 ); 681 } 682 683 #[test] 684 fn range_try_new_rejects_equal() { 685 assert_eq!( 686 SliderRange::try_new(5.0_f64, 5.0), 687 Err(SliderRangeError::NotOrdered), 688 ); 689 } 690 691 #[test] 692 fn range_try_new_rejects_nan() { 693 assert_eq!( 694 SliderRange::try_new(f64::NAN, 1.0), 695 Err(SliderRangeError::NotComparable), 696 ); 697 assert_eq!( 698 SliderRange::try_new(0.0, f64::NAN), 699 Err(SliderRangeError::NotComparable), 700 ); 701 } 702 703 #[test] 704 fn step_try_new_rejects_zero() { 705 assert_eq!( 706 SliderStep::try_new(0.0_f64), 707 Err(SliderStepError::NotPositive), 708 ); 709 } 710 711 #[test] 712 fn step_try_new_rejects_negative() { 713 assert_eq!( 714 SliderStep::try_new(-1.0_f64), 715 Err(SliderStepError::NotPositive), 716 ); 717 assert_eq!( 718 SliderStep::try_new(-1_i32), 719 Err(SliderStepError::NotPositive) 720 ); 721 } 722 723 #[test] 724 fn step_try_new_rejects_nan() { 725 assert_eq!( 726 SliderStep::try_new(f64::NAN), 727 Err(SliderStepError::NotPositive), 728 ); 729 } 730 731 fn run_pointer_press_at_with( 732 initial: f64, 733 x: f32, 734 step: SliderStep<f64>, 735 ) -> super::SliderResponse<f64> { 736 use crate::hit_test::resolve; 737 use crate::input::{PointerButton, PointerButtonMask, PointerSample}; 738 let theme = Arc::new(Theme::light()); 739 let mut focus = FocusManager::new(); 740 let table = HotkeyTable::new(); 741 let mut prev_state = HitState::new(); 742 let mut value = initial; 743 let pos = LayoutPos::new(LayoutPx::new(x), LayoutPx::new(10.0)); 744 let mut frame = |pressed: PointerButtonMask| -> super::SliderResponse<f64> { 745 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 746 snap.pointer = Some(PointerSample::new(pos)); 747 snap.buttons_pressed = pressed; 748 let mut hits = HitFrame::new(); 749 let response = { 750 let widget = Slider::new(slider_id(), rect(), LABEL, value, unit_range(), step); 751 let mut shaper = bone_text::Shaper::new(); 752 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 753 let mut ctx = FrameCtx::new( 754 theme.clone(), 755 &mut snap, 756 &mut focus, 757 &table, 758 StringTable::empty(), 759 &mut hits, 760 &prev_state, 761 &mut a11y, 762 &mut shaper, 763 ); 764 show_slider(&mut ctx, widget) 765 }; 766 value = response.value; 767 prev_state = resolve(&prev_state, &hits, &snap, focus.focused()); 768 response 769 }; 770 let _ = frame(PointerButtonMask::just(PointerButton::Primary)); 771 frame(PointerButtonMask::EMPTY) 772 } 773 774 #[test] 775 fn pointer_press_near_left_edge_snaps_value_to_min() { 776 let response = run_pointer_press_at_with(50.0, 1.0, unit_step()); 777 assert!( 778 response.value < 5.0, 779 "expected near-min, got {}", 780 response.value 781 ); 782 assert!(response.changed); 783 } 784 785 #[test] 786 fn pointer_press_near_right_edge_snaps_value_to_max() { 787 let response = run_pointer_press_at_with(50.0, 199.0, unit_step()); 788 assert!( 789 response.value > 95.0, 790 "expected near-max, got {}", 791 response.value 792 ); 793 assert!(response.changed); 794 } 795 796 #[test] 797 fn pointer_press_at_known_offset_yields_expected_value() { 798 let response = run_pointer_press_at_with(0.0, 50.0, unit_step()); 799 assert!( 800 (response.value - 25.0).abs() < 1.0, 801 "got {}", 802 response.value 803 ); 804 } 805 806 #[test] 807 fn pointer_drag_snaps_float_to_step_grid() { 808 let Ok(step5) = SliderStep::try_new(5.0_f64) else { 809 panic!("step valid"); 810 }; 811 let response = run_pointer_press_at_with(0.0, 53.0, step5); 812 assert!( 813 (response.value - 25.0).abs() < 1e-9, 814 "expected snap to nearest 5, got {}", 815 response.value, 816 ); 817 } 818 819 #[test] 820 fn fine_step_approximates_continuous_drag() { 821 let Ok(fine) = SliderStep::try_new(0.001_f64) else { 822 panic!("step valid"); 823 }; 824 let response = run_pointer_press_at_with(0.0, 53.0, fine); 825 assert!( 826 (response.value - 26.5).abs() < 0.5, 827 "expected near 26.5, got {}", 828 response.value, 829 ); 830 } 831 832 #[test] 833 fn integer_pointer_drag_snaps_to_step_grid() { 834 let Ok(range) = SliderRange::try_new(0_i32, 20) else { 835 panic!("range valid"); 836 }; 837 let Ok(step) = SliderStep::try_new(5_i32) else { 838 panic!("step valid"); 839 }; 840 let raw = i32::from_unit(0.55, range); 841 let snapped = raw.snap_to_step(step, range); 842 assert_eq!(snapped, 10, "0.55 rounds to 11 then snaps to 10"); 843 } 844 845 #[test] 846 fn keyboard_arrow_snaps_off_grid_value_to_grid() { 847 let Ok(step) = SliderStep::try_new(3.0_f64) else { 848 panic!("step valid"); 849 }; 850 let response = run_with_step( 851 5.0, 852 step, 853 vec![KeyEvent::new( 854 KeyCode::Named(NamedKey::ArrowRight), 855 ModifierMask::NONE, 856 )], 857 ); 858 assert!( 859 (response.value - 9.0).abs() < 1e-9, 860 "5 + 3 = 8, snap-to-3-grid = 9, got {}", 861 response.value, 862 ); 863 } 864 865 fn run_with_step( 866 value: f64, 867 step: SliderStep<f64>, 868 events: Vec<KeyEvent>, 869 ) -> super::SliderResponse<f64> { 870 let theme = Arc::new(Theme::light()); 871 let mut focus = FocusManager::new(); 872 focus.register_focusable(slider_id()); 873 focus.request_focus(slider_id()); 874 focus.end_frame(); 875 let table = HotkeyTable::new(); 876 let mut hits = HitFrame::new(); 877 let prev = HitState::new(); 878 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 879 input.keys_pressed = events; 880 let widget = Slider::new(slider_id(), rect(), LABEL, value, unit_range(), step); 881 let mut shaper = bone_text::Shaper::new(); 882 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 883 let mut ctx = FrameCtx::new( 884 theme, 885 &mut input, 886 &mut focus, 887 &table, 888 StringTable::empty(), 889 &mut hits, 890 &prev, 891 &mut a11y, 892 &mut shaper, 893 ); 894 show_slider(&mut ctx, widget) 895 } 896}