Another project
1
fork

Configure Feed

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

feat(ui): slider widget

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

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