Another project
0

Configure Feed

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

at main 20 kB View raw
1use crate::a11y::{AccessNode, Role}; 2use crate::frame::{FrameCtx, InteractDeclaration}; 3use crate::hit_test::{Interaction, Sense}; 4use crate::input::{KeyCode, NamedKey}; 5use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 6use crate::strings::StringKey; 7use crate::theme::{Border, Color, Step12, StrokeWidth, SurfaceLevel}; 8use crate::widget_id::{WidgetId, WidgetKey}; 9 10use bone_types::IconId; 11 12use super::keys::{TakeKey, take_key}; 13use super::paint::{IconSlot, IconTint, LabelText, WidgetPaint}; 14use super::visuals::push_focus_ring; 15 16const TAB_ICON_PX: LayoutPx = LayoutPx::new(16.0); 17 18#[derive(Copy, Clone, Debug, PartialEq, Eq)] 19pub enum TabsOrientation { 20 Top, 21 Bottom, 22 Side, 23} 24 25#[derive(Copy, Clone, Debug, PartialEq)] 26pub struct Tab { 27 pub id: WidgetId, 28 pub rect: LayoutRect, 29 pub label: StringKey, 30 pub disabled: bool, 31 pub closable: bool, 32 pub icon: Option<IconId>, 33} 34 35impl Tab { 36 #[must_use] 37 pub const fn new(id: WidgetId, rect: LayoutRect, label: StringKey) -> Self { 38 Self { 39 id, 40 rect, 41 label, 42 disabled: false, 43 closable: false, 44 icon: None, 45 } 46 } 47 48 #[must_use] 49 pub const fn disabled(self, disabled: bool) -> Self { 50 Self { disabled, ..self } 51 } 52 53 #[must_use] 54 pub const fn closable(self, closable: bool) -> Self { 55 Self { closable, ..self } 56 } 57 58 #[must_use] 59 pub const fn with_icon(self, icon: IconId) -> Self { 60 Self { 61 icon: Some(icon), 62 ..self 63 } 64 } 65} 66 67#[derive(Copy, Clone, Debug, PartialEq)] 68pub struct Tabs<'a> { 69 pub id: WidgetId, 70 pub orientation: TabsOrientation, 71 pub label: StringKey, 72 pub tabs: &'a [Tab], 73 pub active: WidgetId, 74} 75 76impl<'a> Tabs<'a> { 77 #[must_use] 78 pub const fn new( 79 id: WidgetId, 80 orientation: TabsOrientation, 81 label: StringKey, 82 tabs: &'a [Tab], 83 active: WidgetId, 84 ) -> Self { 85 Self { 86 id, 87 orientation, 88 label, 89 tabs, 90 active, 91 } 92 } 93} 94 95#[derive(Clone, Debug, PartialEq)] 96pub struct TabsResponse { 97 pub activated: Option<WidgetId>, 98 pub closed: Option<WidgetId>, 99 pub paint: Vec<WidgetPaint>, 100} 101 102const CLOSE_BUTTON_PX: f32 = 14.0; 103const CLOSE_BUTTON_GAP: f32 = 6.0; 104 105#[must_use] 106pub fn show_tabs(ctx: &mut FrameCtx<'_>, tabs: Tabs<'_>) -> TabsResponse { 107 let Tabs { 108 id: tabs_id, 109 orientation, 110 label, 111 tabs: items, 112 active, 113 } = tabs; 114 let active_present = items.iter().any(|t| t.id == active && !t.disabled); 115 let tab_stop = items 116 .iter() 117 .find(|t| t.id == active && !t.disabled) 118 .or_else(|| items.iter().find(|t| !t.disabled)) 119 .map(|t| t.id); 120 if let Some(stop) = tab_stop { 121 ctx.focus.register_tab_stop(stop); 122 } 123 if let Some(strip_rect) = items.iter().map(|t| t.rect).reduce(LayoutRect::union) { 124 ctx.a11y.push( 125 tabs_id, 126 strip_rect, 127 AccessNode::new(Role::TabList).with_label(label), 128 ); 129 } 130 let mut paint = Vec::new(); 131 let folded = items 132 .iter() 133 .map(|tab| draw_tab(ctx, tabs_id, tab, tab.id == active && active_present)) 134 .fold( 135 (None::<WidgetId>, None::<WidgetId>), 136 |(activated, closed), per_tab| { 137 let new_activated = activated.or(per_tab.activated); 138 let new_closed = closed.or(per_tab.closed); 139 paint.extend(per_tab.paint); 140 (new_activated, new_closed) 141 }, 142 ); 143 let in_strip_focus = ctx 144 .focus 145 .focused() 146 .is_some_and(|f| items.iter().any(|t| t.id == f)); 147 let activated_via_keys = if in_strip_focus { 148 handle_keyboard(ctx, items, orientation) 149 } else { 150 None 151 }; 152 let activated = folded.0.or(activated_via_keys); 153 TabsResponse { 154 activated, 155 closed: folded.1, 156 paint, 157 } 158} 159 160struct PerTab { 161 activated: Option<WidgetId>, 162 closed: Option<WidgetId>, 163 paint: Vec<WidgetPaint>, 164} 165 166fn draw_tab(ctx: &mut FrameCtx<'_>, tabs_id: WidgetId, tab: &Tab, is_active: bool) -> PerTab { 167 let interactive = !tab.disabled; 168 let interaction = ctx.interact( 169 InteractDeclaration::new(tab.id, tab.rect, Sense::INTERACTIVE) 170 .focusable(false) 171 .disabled(!interactive) 172 .active(is_active) 173 .a11y( 174 AccessNode::new(Role::Tab) 175 .with_label(tab.label) 176 .with_disabled(!interactive) 177 .with_selected(is_active), 178 ), 179 ); 180 if interactive && interaction.click() { 181 ctx.focus.request_focus(tab.id); 182 } 183 let live_focused = ctx.is_focused(tab.id); 184 let mut paint = Vec::new(); 185 paint.extend(tab_surface_paint(ctx, tab.rect, is_active, interaction)); 186 let label_color = tab_label_color(ctx, is_active, tab.disabled); 187 if let Some(icon) = tab.icon { 188 paint.push(IconSlot::new(icon, TAB_ICON_PX).paint_in( 189 label_rect(tab.rect, tab.closable), 190 IconTint::from_disabled(tab.disabled), 191 )); 192 } else { 193 paint.push(WidgetPaint::Label { 194 rect: label_rect(tab.rect, tab.closable), 195 text: LabelText::Key(tab.label), 196 color: label_color, 197 role: ctx.theme().typography.label, 198 }); 199 } 200 let mut closed = None; 201 let close_id = tabs_id.child_indexed(WidgetKey::new("close"), tab_close_index(tab.id)); 202 if tab.closable { 203 let close_rect = close_button_rect(tab.rect); 204 let close_interaction = ctx.interact( 205 InteractDeclaration::new(close_id, close_rect, Sense::INTERACTIVE) 206 .focusable(false) 207 .disabled(!interactive) 208 .a11y( 209 AccessNode::new(Role::Button) 210 .with_label(StringKey::new("tabs.close")) 211 .with_disabled(!interactive), 212 ), 213 ); 214 if interactive && close_interaction.click() { 215 closed = Some(tab.id); 216 } 217 paint.push(WidgetPaint::Surface { 218 rect: close_rect, 219 fill: if close_interaction.hover() && interactive { 220 ctx.theme().colors.neutral.step(Step12::HOVER_BG) 221 } else { 222 Color::TRANSPARENT 223 }, 224 border: None, 225 radius: ctx.theme().radius.sm, 226 elevation: None, 227 }); 228 paint.push(WidgetPaint::Icon { 229 rect: close_rect, 230 icon: IconId::Cross, 231 tint: IconTint::Solid(tab_label_color(ctx, is_active, tab.disabled)), 232 }); 233 } 234 push_focus_ring( 235 ctx, 236 &mut paint, 237 tab.rect, 238 ctx.theme().radius.sm, 239 live_focused, 240 ); 241 let activated = (interactive && !is_active && interaction.click()).then_some(tab.id); 242 PerTab { 243 activated, 244 closed, 245 paint, 246 } 247} 248 249fn tab_close_index(tab_id: WidgetId) -> u64 { 250 tab_id.raw().get() 251} 252 253fn label_rect(tab: LayoutRect, closable: bool) -> LayoutRect { 254 if !closable { 255 return tab; 256 } 257 let trim = LayoutPx::new(CLOSE_BUTTON_PX + CLOSE_BUTTON_GAP); 258 let width = (tab.size.width.value() - trim.value()).max(0.0); 259 LayoutRect::new( 260 tab.origin, 261 LayoutSize::new(LayoutPx::new(width), tab.size.height), 262 ) 263} 264 265fn close_button_rect(tab: LayoutRect) -> LayoutRect { 266 let pad = (tab.size.height.value() - CLOSE_BUTTON_PX).max(0.0) / 2.0; 267 let x = tab.origin.x.value() + tab.size.width.value() - CLOSE_BUTTON_PX - pad; 268 let y = tab.origin.y.value() + pad; 269 LayoutRect::new( 270 LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 271 LayoutSize::new( 272 LayoutPx::new(CLOSE_BUTTON_PX), 273 LayoutPx::new(CLOSE_BUTTON_PX), 274 ), 275 ) 276} 277 278fn tab_surface_paint( 279 ctx: &FrameCtx<'_>, 280 rect: LayoutRect, 281 active: bool, 282 interaction: Interaction, 283) -> Vec<WidgetPaint> { 284 let neutral = ctx.theme().colors.neutral; 285 if active && !interaction.disabled() { 286 return vec![WidgetPaint::Surface { 287 rect, 288 fill: ctx.theme().colors.surface(SurfaceLevel::L0), 289 border: Some(Border { 290 width: StrokeWidth::HAIRLINE, 291 color: neutral.step(Step12::SUBTLE_BORDER), 292 }), 293 radius: ctx.theme().radius.none, 294 elevation: None, 295 }]; 296 } 297 let fill = if interaction.disabled() { 298 Color::TRANSPARENT 299 } else if interaction.pressed() { 300 neutral.step(Step12::SELECTED_BG) 301 } else if interaction.hover() { 302 neutral.step(Step12::HOVER_BG) 303 } else { 304 Color::TRANSPARENT 305 }; 306 vec![WidgetPaint::Surface { 307 rect, 308 fill, 309 border: None, 310 radius: ctx.theme().radius.none, 311 elevation: None, 312 }] 313} 314 315fn tab_label_color(ctx: &FrameCtx<'_>, active: bool, disabled: bool) -> Color { 316 if disabled { 317 ctx.theme().colors.text_disabled() 318 } else if active { 319 ctx.theme().colors.text_primary() 320 } else { 321 ctx.theme().colors.text_secondary() 322 } 323} 324 325fn handle_keyboard( 326 ctx: &mut FrameCtx<'_>, 327 items: &[Tab], 328 orientation: TabsOrientation, 329) -> Option<WidgetId> { 330 let (prev, next) = match orientation { 331 TabsOrientation::Top | TabsOrientation::Bottom => { 332 (NamedKey::ArrowLeft, NamedKey::ArrowRight) 333 } 334 TabsOrientation::Side => (NamedKey::ArrowUp, NamedKey::ArrowDown), 335 }; 336 let event = take_key( 337 ctx.input, 338 &[ 339 TakeKey::named(prev), 340 TakeKey::named(next), 341 TakeKey::named(NamedKey::Home), 342 TakeKey::named(NamedKey::End), 343 TakeKey::named(NamedKey::Enter), 344 TakeKey::named(NamedKey::Space), 345 ], 346 )?; 347 let focused = ctx.focus.focused()?; 348 let current = items.iter().position(|t| t.id == focused)?; 349 match event.code { 350 KeyCode::Named(NamedKey::Enter | NamedKey::Space) => { 351 items.get(current).filter(|t| !t.disabled).map(|t| t.id) 352 } 353 KeyCode::Named(key) => { 354 let target = step_to(items, current, key, prev, next)?; 355 ctx.focus.request_focus(items[target].id); 356 None 357 } 358 KeyCode::Char(_) => None, 359 } 360} 361 362fn step_to( 363 items: &[Tab], 364 current: usize, 365 key: NamedKey, 366 prev: NamedKey, 367 next: NamedKey, 368) -> Option<usize> { 369 let len = items.len(); 370 if len == 0 { 371 return None; 372 } 373 let candidates: Vec<usize> = if key == prev { 374 (1..=len) 375 .map(|delta| (current + len - delta) % len) 376 .collect() 377 } else if key == next { 378 (1..=len).map(|delta| (current + delta) % len).collect() 379 } else if matches!(key, NamedKey::Home) { 380 (0..len).collect() 381 } else if matches!(key, NamedKey::End) { 382 (0..len).rev().collect() 383 } else { 384 return None; 385 }; 386 candidates.into_iter().find(|&idx| !items[idx].disabled) 387} 388 389#[cfg(test)] 390mod tests { 391 use std::sync::Arc; 392 393 use super::{Tab, Tabs, TabsOrientation, show_tabs}; 394 use crate::focus::FocusManager; 395 use crate::frame::FrameCtx; 396 use crate::hit_test::{HitFrame, HitState, resolve}; 397 use crate::hotkey::HotkeyTable; 398 use crate::input::{ 399 FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, 400 PointerButtonMask, PointerSample, 401 }; 402 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 403 use crate::strings::{StringKey, StringTable}; 404 use crate::theme::Theme; 405 use crate::widget_id::{WidgetId, WidgetKey}; 406 407 fn tabs_id() -> WidgetId { 408 WidgetId::ROOT.child(WidgetKey::new("tabs")) 409 } 410 411 fn make_tabs() -> Vec<Tab> { 412 let label_keys = ["tabs.first", "tabs.second", "tabs.third"]; 413 label_keys 414 .iter() 415 .enumerate() 416 .map(|(idx, key)| { 417 #[allow(clippy::cast_precision_loss, reason = "small index fits f32 mantissa")] 418 let i_f32 = idx as f32; 419 let id = tabs_id().child_indexed(WidgetKey::new("t"), idx as u64); 420 let rect = LayoutRect::new( 421 LayoutPos::new(LayoutPx::new(i_f32 * 80.0), LayoutPx::ZERO), 422 LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(28.0)), 423 ); 424 Tab::new(id, rect, StringKey::new(key)).closable(true) 425 }) 426 .collect() 427 } 428 429 fn render_with( 430 items: &[Tab], 431 active: WidgetId, 432 focus: &mut FocusManager, 433 snap: &mut InputSnapshot, 434 prev: &HitState, 435 ) -> (super::TabsResponse, HitState) { 436 let theme = Arc::new(Theme::light()); 437 let table = HotkeyTable::new(); 438 let mut hits = HitFrame::new(); 439 let response = { 440 let mut shaper = bone_text::Shaper::new(); 441 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 442 let mut ctx = FrameCtx::new( 443 theme, 444 snap, 445 focus, 446 &table, 447 StringTable::empty(), 448 &mut hits, 449 prev, 450 &mut a11y, 451 &mut shaper, 452 ); 453 show_tabs( 454 &mut ctx, 455 Tabs::new( 456 tabs_id(), 457 TabsOrientation::Top, 458 StringKey::new("test.tabs"), 459 items, 460 active, 461 ), 462 ) 463 }; 464 let next_state = resolve(prev, &hits, snap, focus.focused()); 465 (response, next_state) 466 } 467 468 fn pointer_press(pos: LayoutPos) -> InputSnapshot { 469 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 470 s.pointer = Some(PointerSample::new(pos)); 471 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 472 s 473 } 474 475 fn pointer_release(pos: LayoutPos) -> InputSnapshot { 476 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 477 s.pointer = Some(PointerSample::new(pos)); 478 s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 479 s 480 } 481 482 fn click_at( 483 items: &[Tab], 484 active: WidgetId, 485 focus: &mut FocusManager, 486 prev: &mut HitState, 487 pos: LayoutPos, 488 ) -> super::TabsResponse { 489 let mut last_response: Option<super::TabsResponse> = None; 490 [pointer_press(pos), pointer_release(pos), pointer_idle(pos)] 491 .into_iter() 492 .for_each(|mut snap| { 493 let (response, next) = render_with(items, active, focus, &mut snap, prev); 494 last_response = Some(response); 495 *prev = next; 496 }); 497 let Some(response) = last_response else { 498 panic!("three snapshots produced a response"); 499 }; 500 response 501 } 502 503 fn pointer_idle(pos: LayoutPos) -> InputSnapshot { 504 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 505 s.pointer = Some(PointerSample::new(pos)); 506 s 507 } 508 509 #[test] 510 fn click_on_inactive_tab_activates_it() { 511 let items = make_tabs(); 512 let mut focus = FocusManager::new(); 513 let mut prev = HitState::new(); 514 let response = click_at( 515 &items, 516 items[0].id, 517 &mut focus, 518 &mut prev, 519 LayoutPos::new(LayoutPx::new(100.0), LayoutPx::new(10.0)), 520 ); 521 assert_eq!(response.activated, Some(items[1].id)); 522 assert!(response.closed.is_none()); 523 } 524 525 #[test] 526 fn click_on_close_button_emits_closed_not_activated() { 527 let items = make_tabs(); 528 let mut focus = FocusManager::new(); 529 let mut prev = HitState::new(); 530 let close_pos = LayoutPos::new(LayoutPx::new(160.0 + 64.0), LayoutPx::new(14.0)); 531 let response = click_at(&items, items[0].id, &mut focus, &mut prev, close_pos); 532 assert_eq!(response.closed, Some(items[2].id)); 533 assert!(response.activated.is_none()); 534 } 535 536 #[test] 537 fn click_on_active_tab_does_not_re_activate() { 538 let items = make_tabs(); 539 let mut focus = FocusManager::new(); 540 let mut prev = HitState::new(); 541 let response = click_at( 542 &items, 543 items[1].id, 544 &mut focus, 545 &mut prev, 546 LayoutPos::new(LayoutPx::new(100.0), LayoutPx::new(10.0)), 547 ); 548 assert!(response.activated.is_none()); 549 } 550 551 fn focused_setup(target: WidgetId) -> FocusManager { 552 let mut focus = FocusManager::new(); 553 focus.register_focusable(target); 554 focus.request_focus(target); 555 focus.end_frame(); 556 focus 557 } 558 559 #[test] 560 fn arrow_right_roves_focus_skipping_disabled() { 561 let mut items = make_tabs(); 562 items[1] = items[1].disabled(true); 563 let first_id = items[0].id; 564 let third_id = items[2].id; 565 let mut focus = focused_setup(first_id); 566 let prev = HitState::new(); 567 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 568 snap.keys_pressed.push(KeyEvent::new( 569 KeyCode::Named(NamedKey::ArrowRight), 570 ModifierMask::NONE, 571 )); 572 let _ = render_with(&items, first_id, &mut focus, &mut snap, &prev); 573 assert_eq!(focus.focused(), Some(third_id)); 574 } 575 576 #[test] 577 fn enter_on_focused_tab_activates_it() { 578 let items = make_tabs(); 579 let second_id = items[1].id; 580 let mut focus = focused_setup(second_id); 581 let prev = HitState::new(); 582 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 583 snap.keys_pressed.push(KeyEvent::new( 584 KeyCode::Named(NamedKey::Enter), 585 ModifierMask::NONE, 586 )); 587 let (response, _) = render_with(&items, items[0].id, &mut focus, &mut snap, &prev); 588 assert_eq!(response.activated, Some(second_id)); 589 } 590 591 #[test] 592 fn arrow_wraps_at_end() { 593 let items = make_tabs(); 594 let last_id = items[2].id; 595 let first_id = items[0].id; 596 let mut focus = focused_setup(last_id); 597 let prev = HitState::new(); 598 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 599 snap.keys_pressed.push(KeyEvent::new( 600 KeyCode::Named(NamedKey::ArrowRight), 601 ModifierMask::NONE, 602 )); 603 let _ = render_with(&items, items[0].id, &mut focus, &mut snap, &prev); 604 assert_eq!(focus.focused(), Some(first_id)); 605 } 606 607 #[test] 608 fn home_jumps_to_first_enabled() { 609 let mut items = make_tabs(); 610 items[0] = items[0].disabled(true); 611 let last_id = items[2].id; 612 let second_id = items[1].id; 613 let mut focus = focused_setup(last_id); 614 let prev = HitState::new(); 615 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 616 snap.keys_pressed.push(KeyEvent::new( 617 KeyCode::Named(NamedKey::Home), 618 ModifierMask::NONE, 619 )); 620 let _ = render_with(&items, last_id, &mut focus, &mut snap, &prev); 621 assert_eq!(focus.focused(), Some(second_id)); 622 } 623 624 #[test] 625 fn click_on_inactive_tab_moves_focus_to_clicked_tab() { 626 let items = make_tabs(); 627 let mut focus = FocusManager::new(); 628 let mut prev = HitState::new(); 629 let _ = click_at( 630 &items, 631 items[0].id, 632 &mut focus, 633 &mut prev, 634 LayoutPos::new(LayoutPx::new(100.0), LayoutPx::new(10.0)), 635 ); 636 assert_eq!(focus.focused(), Some(items[1].id)); 637 } 638 639 #[test] 640 fn only_active_tab_is_in_tab_order() { 641 let items = make_tabs(); 642 let mut focus = FocusManager::new(); 643 let prev = HitState::new(); 644 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 645 let _ = render_with(&items, items[1].id, &mut focus, &mut snap, &prev); 646 let stops: Vec<WidgetId> = focus.tab_stops().iter().map(|(id, _)| *id).collect(); 647 assert_eq!(stops, vec![items[1].id]); 648 } 649}