Another project
0

Configure Feed

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

at main 21 kB View raw
1use crate::a11y::{AccessNode, AccessRange, Role}; 2use crate::frame::{FrameCtx, InteractDeclaration}; 3use crate::hit_test::{Interaction, Sense}; 4use crate::input::{KeyCode, NamedKey}; 5use crate::layout::{Axis, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 6use crate::strings::StringKey; 7use crate::theme::{Border, Step12, StrokeWidth}; 8use crate::widget_id::{WidgetId, WidgetKey}; 9 10use super::keys::{TakeKey, take_key}; 11use super::paint::WidgetPaint; 12use super::visuals::push_focus_ring; 13 14const MIN_THUMB_PX: f32 = 18.0; 15const LINE_STEP_PX: f32 = 24.0; 16const THUMB_INSET_PX: f32 = 2.0; 17const SCROLLBAR_WIDTH_PX: f32 = 14.0; 18 19pub struct RowWindow { 20 pub content_rect: LayoutRect, 21 pub bar: Option<LayoutRect>, 22 pub content_len: LayoutPx, 23 pub offset: LayoutPx, 24 pub first_row: usize, 25 pub last_row: usize, 26} 27 28#[must_use] 29pub fn row_window( 30 rect: LayoutRect, 31 row_count: usize, 32 row_height: LayoutPx, 33 requested: LayoutPx, 34) -> RowWindow { 35 let row_h = row_height.value(); 36 let viewport_h = rect.size.height.value(); 37 #[allow( 38 clippy::cast_precision_loss, 39 reason = "row count fits the f32 mantissa" 40 )] 41 let content_h = row_count as f32 * row_h; 42 let scrollable = row_h > 0.0 && content_h > viewport_h + 0.5; 43 let bar_w = if scrollable { SCROLLBAR_WIDTH_PX } else { 0.0 }; 44 let content_rect = LayoutRect::new( 45 rect.origin, 46 LayoutSize::new( 47 LayoutPx::saturating_nonneg(rect.size.width.value() - bar_w), 48 rect.size.height, 49 ), 50 ); 51 if !scrollable { 52 return RowWindow { 53 content_rect, 54 bar: None, 55 content_len: LayoutPx::new(content_h), 56 offset: LayoutPx::ZERO, 57 first_row: 0, 58 last_row: row_count, 59 }; 60 } 61 let max_offset = (content_h - viewport_h).max(0.0); 62 let clamped = requested.value().clamp(0.0, max_offset); 63 let (first_row, fit_rows) = row_span(clamped, viewport_h, row_h); 64 let last_row = (first_row + fit_rows).min(row_count); 65 #[allow( 66 clippy::cast_precision_loss, 67 reason = "row index fits the f32 mantissa" 68 )] 69 let snapped = first_row as f32 * row_h; 70 let bar = LayoutRect::new( 71 LayoutPos::new( 72 LayoutPx::new(rect.origin.x.value() + rect.size.width.value() - bar_w), 73 rect.origin.y, 74 ), 75 LayoutSize::new(LayoutPx::new(bar_w), rect.size.height), 76 ); 77 RowWindow { 78 content_rect, 79 bar: Some(bar), 80 content_len: LayoutPx::new(content_h), 81 offset: LayoutPx::new(snapped), 82 first_row, 83 last_row, 84 } 85} 86 87#[allow( 88 clippy::cast_possible_truncation, 89 clippy::cast_sign_loss, 90 reason = "non-negative row counts stay well within usize" 91)] 92fn row_span(offset_px: f32, viewport_h: f32, row_h: f32) -> (usize, usize) { 93 let first = (offset_px / row_h).floor() as usize; 94 let fit = (viewport_h / row_h).floor() as usize; 95 (first, fit) 96} 97 98#[must_use] 99pub fn wheel_scroll(ctx: &FrameCtx<'_>, rect: LayoutRect, offset: LayoutPx) -> LayoutPx { 100 if ctx.input.pointer.is_some_and(|p| rect.contains(p.position)) { 101 LayoutPx::new(offset.value() + ctx.input.scroll_y) 102 } else { 103 offset 104 } 105} 106 107#[must_use] 108pub fn window_scrollbar( 109 ctx: &mut FrameCtx<'_>, 110 container_id: WidgetId, 111 label: StringKey, 112 window: &RowWindow, 113 offset: LayoutPx, 114) -> (Vec<WidgetPaint>, LayoutPx) { 115 let Some(bar_rect) = window.bar else { 116 return (Vec::new(), offset); 117 }; 118 let response = show_scrollbar( 119 ctx, 120 Scrollbar::new( 121 container_id.child(WidgetKey::new("scrollbar")), 122 bar_rect, 123 Axis::Vertical, 124 label, 125 window.content_len, 126 offset, 127 ), 128 ); 129 let next = if response.changed { 130 response.offset 131 } else { 132 offset 133 }; 134 (response.paint, next) 135} 136 137pub struct Scrollbar { 138 pub id: WidgetId, 139 pub rect: LayoutRect, 140 pub axis: Axis, 141 pub label: StringKey, 142 pub content: LayoutPx, 143 pub offset: LayoutPx, 144 pub disabled: bool, 145} 146 147impl Scrollbar { 148 #[must_use] 149 pub fn new( 150 id: WidgetId, 151 rect: LayoutRect, 152 axis: Axis, 153 label: StringKey, 154 content: LayoutPx, 155 offset: LayoutPx, 156 ) -> Self { 157 Self { 158 id, 159 rect, 160 axis, 161 label, 162 content, 163 offset, 164 disabled: false, 165 } 166 } 167 168 #[must_use] 169 pub fn disabled(self, disabled: bool) -> Self { 170 Self { disabled, ..self } 171 } 172} 173 174#[derive(Clone, Debug, PartialEq)] 175pub struct ScrollbarResponse { 176 pub interaction: Interaction, 177 pub offset: LayoutPx, 178 pub changed: bool, 179 pub paint: Vec<WidgetPaint>, 180} 181 182#[derive(Copy, Clone, Debug, PartialEq)] 183struct Geometry { 184 track_len: f32, 185 thumb_len: f32, 186 max_offset: f32, 187} 188 189impl Geometry { 190 fn of(rect: LayoutRect, axis: Axis, content: LayoutPx) -> Self { 191 let track_len = axis_len(rect, axis); 192 let content = content.value().max(track_len); 193 let ratio = if content > 0.0 { 194 (track_len / content).clamp(0.0, 1.0) 195 } else { 196 1.0 197 }; 198 let thumb_len = (track_len * ratio).clamp(MIN_THUMB_PX.min(track_len), track_len); 199 Self { 200 track_len, 201 thumb_len, 202 max_offset: (content - track_len).max(0.0), 203 } 204 } 205 206 fn scrollable(self) -> bool { 207 self.max_offset > 0.0 208 } 209 210 fn travel(self) -> f32 { 211 (self.track_len - self.thumb_len).max(0.0) 212 } 213 214 fn thumb_start(self, offset: f32) -> f32 { 215 if self.max_offset <= 0.0 { 216 return 0.0; 217 } 218 (offset / self.max_offset).clamp(0.0, 1.0) * self.travel() 219 } 220 221 fn offset_at(self, local: f32) -> f32 { 222 let travel = self.travel(); 223 if travel <= 0.0 { 224 return 0.0; 225 } 226 let unit = ((local - self.thumb_len / 2.0) / travel).clamp(0.0, 1.0); 227 unit * self.max_offset 228 } 229} 230 231#[must_use] 232#[allow( 233 clippy::needless_pass_by_value, 234 reason = "destructure consumes the scrollbar" 235)] 236pub fn show_scrollbar(ctx: &mut FrameCtx<'_>, scrollbar: Scrollbar) -> ScrollbarResponse { 237 let Scrollbar { 238 id, 239 rect, 240 axis, 241 label, 242 content, 243 offset: initial, 244 disabled, 245 } = scrollbar; 246 let geom = Geometry::of(rect, axis, content); 247 let interactive = !disabled && geom.scrollable(); 248 let interaction = ctx.interact( 249 InteractDeclaration::new(id, rect, Sense::DRAGGABLE) 250 .focusable(interactive) 251 .disabled(!interactive), 252 ); 253 254 let mut offset = initial.value().clamp(0.0, geom.max_offset); 255 let mut changed = false; 256 257 if interactive 258 && (interaction.click() || interaction.drag_start() || interaction.pressed()) 259 && let Some(next) = pointer_offset(rect, axis, geom, ctx.input) 260 && (next - offset).abs() > f32::EPSILON 261 { 262 offset = next; 263 changed = true; 264 } 265 266 let live_focused = ctx.is_focused(id); 267 if interactive 268 && live_focused 269 && let Some(event) = take_key(ctx.input, keyboard_targets(axis)) 270 { 271 let next = apply_key(offset, geom, axis, event.code); 272 if (next - offset).abs() > f32::EPSILON { 273 offset = next; 274 changed = true; 275 } 276 } 277 278 ctx.a11y.push( 279 id, 280 rect, 281 AccessNode::new(Role::ScrollBar) 282 .with_label(label) 283 .with_disabled(!interactive) 284 .with_range(AccessRange { 285 value: f64::from(offset), 286 min: 0.0, 287 max: f64::from(geom.max_offset), 288 step: f64::from(LINE_STEP_PX), 289 }), 290 ); 291 292 let thumb = thumb_rect(rect, axis, geom, offset); 293 let paint = build_paint(ctx, rect, thumb, !interactive, interaction, live_focused); 294 ScrollbarResponse { 295 interaction, 296 offset: LayoutPx::new(offset), 297 changed, 298 paint, 299 } 300} 301 302fn keyboard_targets(axis: Axis) -> &'static [TakeKey] { 303 const VERTICAL: [TakeKey; 6] = [ 304 TakeKey::named(NamedKey::ArrowUp), 305 TakeKey::named(NamedKey::ArrowDown), 306 TakeKey::named(NamedKey::PageUp), 307 TakeKey::named(NamedKey::PageDown), 308 TakeKey::named(NamedKey::Home), 309 TakeKey::named(NamedKey::End), 310 ]; 311 const HORIZONTAL: [TakeKey; 6] = [ 312 TakeKey::named(NamedKey::ArrowLeft), 313 TakeKey::named(NamedKey::ArrowRight), 314 TakeKey::named(NamedKey::PageUp), 315 TakeKey::named(NamedKey::PageDown), 316 TakeKey::named(NamedKey::Home), 317 TakeKey::named(NamedKey::End), 318 ]; 319 match axis { 320 Axis::Vertical => &VERTICAL, 321 Axis::Horizontal => &HORIZONTAL, 322 } 323} 324 325fn apply_key(offset: f32, geom: Geometry, axis: Axis, code: KeyCode) -> f32 { 326 let (back, forward) = match axis { 327 Axis::Vertical => (NamedKey::ArrowUp, NamedKey::ArrowDown), 328 Axis::Horizontal => (NamedKey::ArrowLeft, NamedKey::ArrowRight), 329 }; 330 let next = match code { 331 KeyCode::Named(key) if key == back => offset - LINE_STEP_PX, 332 KeyCode::Named(key) if key == forward => offset + LINE_STEP_PX, 333 KeyCode::Named(NamedKey::PageUp) => offset - geom.track_len, 334 KeyCode::Named(NamedKey::PageDown) => offset + geom.track_len, 335 KeyCode::Named(NamedKey::Home) => 0.0, 336 KeyCode::Named(NamedKey::End) => geom.max_offset, 337 _ => offset, 338 }; 339 next.clamp(0.0, geom.max_offset) 340} 341 342fn pointer_offset( 343 rect: LayoutRect, 344 axis: Axis, 345 geom: Geometry, 346 input: &crate::input::InputSnapshot, 347) -> Option<f32> { 348 let pointer = input.pointer?.position; 349 let local = match axis { 350 Axis::Vertical => pointer.y.value() - rect.origin.y.value(), 351 Axis::Horizontal => pointer.x.value() - rect.origin.x.value(), 352 }; 353 Some(geom.offset_at(local)) 354} 355 356fn axis_len(rect: LayoutRect, axis: Axis) -> f32 { 357 match axis { 358 Axis::Vertical => rect.size.height.value(), 359 Axis::Horizontal => rect.size.width.value(), 360 } 361} 362 363fn build_paint( 364 ctx: &FrameCtx<'_>, 365 track: LayoutRect, 366 thumb: LayoutRect, 367 inactive: bool, 368 interaction: Interaction, 369 live_focused: bool, 370) -> Vec<WidgetPaint> { 371 let neutral = ctx.theme().colors.neutral; 372 let radius = ctx.theme().radius.sm; 373 let thumb_fill = if inactive { 374 neutral.step(Step12::SELECTED_BG) 375 } else if interaction.pressed() { 376 neutral.step(Step12::SOLID) 377 } else if interaction.hover() { 378 neutral.step(Step12::HOVER_BORDER) 379 } else { 380 neutral.step(Step12::BORDER) 381 }; 382 let mut paint = vec![ 383 WidgetPaint::Surface { 384 rect: track, 385 fill: neutral.step(Step12::SUBTLE_BG), 386 border: Some(Border { 387 width: StrokeWidth::HAIRLINE, 388 color: neutral.step(Step12::SUBTLE_BORDER), 389 }), 390 radius, 391 elevation: None, 392 }, 393 WidgetPaint::Surface { 394 rect: thumb, 395 fill: thumb_fill, 396 border: Some(Border { 397 width: StrokeWidth::HAIRLINE, 398 color: neutral.step(Step12::BORDER), 399 }), 400 radius, 401 elevation: None, 402 }, 403 ]; 404 push_focus_ring(ctx, &mut paint, thumb, radius, live_focused); 405 paint 406} 407 408fn thumb_rect(rect: LayoutRect, axis: Axis, geom: Geometry, offset: f32) -> LayoutRect { 409 let start = geom.thumb_start(offset); 410 let inset = THUMB_INSET_PX.min(cross_len(rect, axis) / 2.0); 411 match axis { 412 Axis::Vertical => LayoutRect::new( 413 LayoutPos::new( 414 LayoutPx::new(rect.origin.x.value() + inset), 415 LayoutPx::new(rect.origin.y.value() + start), 416 ), 417 LayoutSize::new( 418 LayoutPx::new((rect.size.width.value() - 2.0 * inset).max(0.0)), 419 LayoutPx::new(geom.thumb_len), 420 ), 421 ), 422 Axis::Horizontal => LayoutRect::new( 423 LayoutPos::new( 424 LayoutPx::new(rect.origin.x.value() + start), 425 LayoutPx::new(rect.origin.y.value() + inset), 426 ), 427 LayoutSize::new( 428 LayoutPx::new(geom.thumb_len), 429 LayoutPx::new((rect.size.height.value() - 2.0 * inset).max(0.0)), 430 ), 431 ), 432 } 433} 434 435fn cross_len(rect: LayoutRect, axis: Axis) -> f32 { 436 match axis { 437 Axis::Vertical => rect.size.width.value(), 438 Axis::Horizontal => rect.size.height.value(), 439 } 440} 441 442#[cfg(test)] 443mod tests { 444 use std::sync::Arc; 445 446 use super::{Geometry, MIN_THUMB_PX, Scrollbar, ScrollbarResponse, show_scrollbar}; 447 use crate::a11y::AccessTreeBuilder; 448 use crate::focus::FocusManager; 449 use crate::frame::FrameCtx; 450 use crate::hit_test::{HitFrame, HitState, resolve}; 451 use crate::hotkey::HotkeyTable; 452 use crate::input::{ 453 FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, 454 PointerButtonMask, PointerSample, 455 }; 456 use crate::layout::{Axis, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 457 use crate::strings::{StringKey, StringTable}; 458 use crate::theme::Theme; 459 use crate::widget_id::{WidgetId, WidgetKey}; 460 461 const LABEL: StringKey = StringKey::new("scrollbar.label"); 462 const TRACK_LEN: f32 = 140.0; 463 464 fn bar_id() -> WidgetId { 465 WidgetId::ROOT.child(WidgetKey::new("scrollbar")) 466 } 467 468 fn rect() -> LayoutRect { 469 LayoutRect::new( 470 LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 471 LayoutSize::new(LayoutPx::new(14.0), LayoutPx::new(TRACK_LEN)), 472 ) 473 } 474 475 #[test] 476 fn thumb_shrinks_with_content() { 477 let small = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(280.0)); 478 let large = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(700.0)); 479 assert!(small.thumb_len > large.thumb_len); 480 assert!((small.thumb_len - TRACK_LEN / 2.0).abs() < 1e-3); 481 } 482 483 #[test] 484 fn thumb_fills_track_when_content_fits() { 485 let geom = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(100.0)); 486 assert!((geom.thumb_len - TRACK_LEN).abs() < 1e-3); 487 assert!(!geom.scrollable()); 488 } 489 490 #[test] 491 fn thumb_never_shorter_than_minimum() { 492 let geom = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(100_000.0)); 493 assert!(geom.thumb_len >= MIN_THUMB_PX - 1e-3); 494 } 495 496 fn run_keys(offset: f32, content: f32, events: Vec<KeyEvent>) -> ScrollbarResponse { 497 let theme = Arc::new(Theme::light()); 498 let mut focus = FocusManager::new(); 499 focus.register_focusable(bar_id()); 500 focus.request_focus(bar_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 = Scrollbar::new( 508 bar_id(), 509 rect(), 510 Axis::Vertical, 511 LABEL, 512 LayoutPx::new(content), 513 LayoutPx::new(offset), 514 ); 515 let mut shaper = bone_text::Shaper::new(); 516 let mut a11y = AccessTreeBuilder::new(); 517 let mut ctx = FrameCtx::new( 518 theme, 519 &mut input, 520 &mut focus, 521 &table, 522 StringTable::empty(), 523 &mut hits, 524 &prev, 525 &mut a11y, 526 &mut shaper, 527 ); 528 show_scrollbar(&mut ctx, widget) 529 } 530 531 fn key(named: NamedKey) -> KeyEvent { 532 KeyEvent::new(KeyCode::Named(named), ModifierMask::NONE) 533 } 534 535 #[test] 536 fn arrow_down_steps_forward() { 537 let response = run_keys(0.0, 560.0, vec![key(NamedKey::ArrowDown)]); 538 assert!((response.offset.value() - 24.0).abs() < 1e-3); 539 assert!(response.changed); 540 } 541 542 #[test] 543 fn arrow_up_clamps_at_top() { 544 let response = run_keys(0.0, 560.0, vec![key(NamedKey::ArrowUp)]); 545 assert!((response.offset.value() - 0.0).abs() < 1e-3); 546 assert!(!response.changed); 547 } 548 549 #[test] 550 fn end_jumps_to_max_offset() { 551 let response = run_keys(0.0, 560.0, vec![key(NamedKey::End)]); 552 assert!((response.offset.value() - (560.0 - TRACK_LEN)).abs() < 1e-3); 553 } 554 555 #[test] 556 fn page_down_steps_by_track_length() { 557 let response = run_keys(0.0, 560.0, vec![key(NamedKey::PageDown)]); 558 assert!((response.offset.value() - TRACK_LEN).abs() < 1e-3); 559 } 560 561 #[test] 562 fn horizontal_ignores_vertical_arrows() { 563 let theme = Arc::new(Theme::light()); 564 let mut focus = FocusManager::new(); 565 focus.register_focusable(bar_id()); 566 focus.request_focus(bar_id()); 567 focus.end_frame(); 568 let table = HotkeyTable::new(); 569 let mut hits = HitFrame::new(); 570 let prev = HitState::new(); 571 let mut input = InputSnapshot::idle(FrameInstant::ZERO); 572 input.keys_pressed = vec![key(NamedKey::ArrowDown)]; 573 let rect = LayoutRect::new( 574 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 575 LayoutSize::new(LayoutPx::new(TRACK_LEN), LayoutPx::new(14.0)), 576 ); 577 let widget = Scrollbar::new( 578 bar_id(), 579 rect, 580 Axis::Horizontal, 581 LABEL, 582 LayoutPx::new(560.0), 583 LayoutPx::new(0.0), 584 ); 585 let mut shaper = bone_text::Shaper::new(); 586 let mut a11y = AccessTreeBuilder::new(); 587 let mut ctx = FrameCtx::new( 588 theme, 589 &mut input, 590 &mut focus, 591 &table, 592 StringTable::empty(), 593 &mut hits, 594 &prev, 595 &mut a11y, 596 &mut shaper, 597 ); 598 let response = show_scrollbar(&mut ctx, widget); 599 assert!(!response.changed); 600 } 601 602 #[test] 603 fn not_scrollable_ignores_keys() { 604 let response = run_keys(0.0, 100.0, vec![key(NamedKey::End)]); 605 assert!((response.offset.value() - 0.0).abs() < 1e-3); 606 assert!(!response.changed); 607 } 608 609 fn run_pointer_press_at(content: f32, y: f32) -> ScrollbarResponse { 610 let theme = Arc::new(Theme::light()); 611 let mut focus = FocusManager::new(); 612 let table = HotkeyTable::new(); 613 let mut prev_state = HitState::new(); 614 let mut offset = 0.0_f32; 615 let pos = LayoutPos::new(LayoutPx::new(7.0), LayoutPx::new(y)); 616 let mut frame = |pressed: PointerButtonMask| -> ScrollbarResponse { 617 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 618 snap.pointer = Some(PointerSample::new(pos)); 619 snap.buttons_pressed = pressed; 620 let mut hits = HitFrame::new(); 621 let response = { 622 let widget = Scrollbar::new( 623 bar_id(), 624 rect(), 625 Axis::Vertical, 626 LABEL, 627 LayoutPx::new(content), 628 LayoutPx::new(offset), 629 ); 630 let mut shaper = bone_text::Shaper::new(); 631 let mut a11y = AccessTreeBuilder::new(); 632 let mut ctx = FrameCtx::new( 633 theme.clone(), 634 &mut snap, 635 &mut focus, 636 &table, 637 StringTable::empty(), 638 &mut hits, 639 &prev_state, 640 &mut a11y, 641 &mut shaper, 642 ); 643 show_scrollbar(&mut ctx, widget) 644 }; 645 offset = response.offset.value(); 646 prev_state = resolve(&prev_state, &hits, &snap, focus.focused()); 647 response 648 }; 649 let _ = frame(PointerButtonMask::just(PointerButton::Primary)); 650 frame(PointerButtonMask::EMPTY) 651 } 652 653 #[test] 654 fn pointer_press_near_bottom_scrolls_toward_max() { 655 let response = run_pointer_press_at(560.0, TRACK_LEN - 1.0); 656 assert!( 657 response.offset.value() > (560.0 - TRACK_LEN) * 0.8, 658 "expected near max, got {}", 659 response.offset.value(), 660 ); 661 assert!(response.changed); 662 } 663 664 #[test] 665 fn pointer_press_near_top_scrolls_toward_zero() { 666 let response = run_pointer_press_at(560.0, 1.0); 667 assert!( 668 response.offset.value() < (560.0 - TRACK_LEN) * 0.2, 669 "expected near zero, got {}", 670 response.offset.value(), 671 ); 672 } 673}