Another project
1
fork

Configure Feed

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

feat(ui): tabs widget

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

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