Another project
1
fork

Configure Feed

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

feat(ui): toolbar widget

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

+537
+537
crates/bone-ui/src/widgets/toolbar.rs
··· 1 + use crate::frame::{FrameCtx, InteractDeclaration}; 2 + use crate::hit_test::Sense; 3 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 4 + use crate::strings::StringKey; 5 + use crate::theme::{Border, Step12, StrokeWidth}; 6 + use crate::widget_id::{WidgetId, WidgetKey}; 7 + 8 + use super::keys::{TakeKey, take_activation, take_key}; 9 + use super::paint::{GlyphMark, LabelText, WidgetPaint}; 10 + use super::visuals::push_focus_ring; 11 + 12 + #[derive(Copy, Clone, Debug, PartialEq)] 13 + pub struct ToolbarItem { 14 + pub id: WidgetId, 15 + pub label: StringKey, 16 + pub disabled: bool, 17 + pub active: bool, 18 + } 19 + 20 + impl ToolbarItem { 21 + #[must_use] 22 + pub const fn new(id: WidgetId, label: StringKey) -> Self { 23 + Self { 24 + id, 25 + label, 26 + disabled: false, 27 + active: false, 28 + } 29 + } 30 + 31 + #[must_use] 32 + pub const fn disabled(self, disabled: bool) -> Self { 33 + Self { disabled, ..self } 34 + } 35 + 36 + #[must_use] 37 + pub const fn active(self, active: bool) -> Self { 38 + Self { active, ..self } 39 + } 40 + } 41 + 42 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 43 + pub enum ToolbarOrientation { 44 + Horizontal, 45 + Vertical, 46 + } 47 + 48 + #[derive(Copy, Clone, Debug, PartialEq)] 49 + pub struct Toolbar<'a> { 50 + pub id: WidgetId, 51 + pub rect: LayoutRect, 52 + pub items: &'a [ToolbarItem], 53 + pub item_size: LayoutPx, 54 + pub item_gap: LayoutPx, 55 + pub orientation: ToolbarOrientation, 56 + } 57 + 58 + impl<'a> Toolbar<'a> { 59 + #[must_use] 60 + pub const fn horizontal( 61 + id: WidgetId, 62 + rect: LayoutRect, 63 + items: &'a [ToolbarItem], 64 + item_size: LayoutPx, 65 + item_gap: LayoutPx, 66 + ) -> Self { 67 + Self { 68 + id, 69 + rect, 70 + items, 71 + item_size, 72 + item_gap, 73 + orientation: ToolbarOrientation::Horizontal, 74 + } 75 + } 76 + 77 + #[must_use] 78 + pub const fn vertical( 79 + id: WidgetId, 80 + rect: LayoutRect, 81 + items: &'a [ToolbarItem], 82 + item_size: LayoutPx, 83 + item_gap: LayoutPx, 84 + ) -> Self { 85 + Self { 86 + id, 87 + rect, 88 + items, 89 + item_size, 90 + item_gap, 91 + orientation: ToolbarOrientation::Vertical, 92 + } 93 + } 94 + } 95 + 96 + #[derive(Clone, Debug, PartialEq)] 97 + pub struct ToolbarResponse { 98 + pub activated: Option<WidgetId>, 99 + pub overflow_open: bool, 100 + pub visible_count: usize, 101 + pub overflow: Vec<WidgetId>, 102 + pub paint: Vec<WidgetPaint>, 103 + } 104 + 105 + #[must_use] 106 + pub fn show_toolbar( 107 + ctx: &mut FrameCtx<'_>, 108 + toolbar: Toolbar<'_>, 109 + overflow_open: &mut bool, 110 + ) -> ToolbarResponse { 111 + let Toolbar { 112 + id, 113 + rect, 114 + items, 115 + item_size, 116 + item_gap, 117 + orientation, 118 + } = toolbar; 119 + let visible_count = compute_visible_count(rect, items.len(), item_size, item_gap, orientation); 120 + let needs_overflow = visible_count < items.len(); 121 + let mut paint = Vec::new(); 122 + let mut activated: Option<WidgetId> = None; 123 + items 124 + .iter() 125 + .take(visible_count) 126 + .enumerate() 127 + .for_each(|(idx, item)| { 128 + let item_rect = item_rect(rect, idx, item_size, item_gap, orientation); 129 + let result = draw_item(ctx, item_rect, item); 130 + paint.extend(result.paint); 131 + if result.activated && activated.is_none() { 132 + activated = Some(item.id); 133 + } 134 + }); 135 + let overflow_ids: Vec<WidgetId> = items.iter().skip(visible_count).map(|i| i.id).collect(); 136 + if needs_overflow { 137 + let overflow_rect = item_rect(rect, visible_count, item_size, item_gap, orientation); 138 + let overflow_id = id.child(WidgetKey::new("overflow")); 139 + let interaction = ctx.interact( 140 + InteractDeclaration::new(overflow_id, overflow_rect, Sense::INTERACTIVE) 141 + .focusable(true) 142 + .active(*overflow_open), 143 + ); 144 + let live_focused = ctx.is_focused(overflow_id); 145 + if interaction.click() { 146 + *overflow_open = !*overflow_open; 147 + } 148 + if live_focused && take_activation(ctx.input) { 149 + *overflow_open = !*overflow_open; 150 + } 151 + paint.push(WidgetPaint::Surface { 152 + rect: overflow_rect, 153 + fill: if *overflow_open { 154 + ctx.theme.colors.neutral.step(Step12::SELECTED_BG) 155 + } else if interaction.hover() { 156 + ctx.theme.colors.neutral.step(Step12::HOVER_BG) 157 + } else { 158 + ctx.theme.colors.neutral.step(Step12::SUBTLE_BG) 159 + }, 160 + border: Some(Border { 161 + width: StrokeWidth::HAIRLINE, 162 + color: ctx.theme.colors.neutral.step(Step12::BORDER), 163 + }), 164 + radius: ctx.theme.radius.sm, 165 + elevation: None, 166 + }); 167 + paint.push(WidgetPaint::Mark { 168 + rect: overflow_rect, 169 + kind: GlyphMark::Ellipsis, 170 + color: ctx.theme.colors.text_secondary(), 171 + }); 172 + push_focus_ring( 173 + ctx, 174 + &mut paint, 175 + overflow_rect, 176 + ctx.theme.radius.sm, 177 + live_focused, 178 + ); 179 + } else { 180 + *overflow_open = false; 181 + } 182 + ToolbarResponse { 183 + activated, 184 + overflow_open: *overflow_open, 185 + visible_count, 186 + overflow: overflow_ids, 187 + paint, 188 + } 189 + } 190 + 191 + struct ItemDraw { 192 + activated: bool, 193 + paint: Vec<WidgetPaint>, 194 + } 195 + 196 + fn draw_item(ctx: &mut FrameCtx<'_>, rect: LayoutRect, item: &ToolbarItem) -> ItemDraw { 197 + let interaction = ctx.interact( 198 + InteractDeclaration::new(item.id, rect, Sense::INTERACTIVE) 199 + .focusable(true) 200 + .disabled(item.disabled) 201 + .active(item.active), 202 + ); 203 + let live_focused = ctx.is_focused(item.id); 204 + let activated_via_pointer = !item.disabled && interaction.click(); 205 + let activated_via_key = !item.disabled 206 + && live_focused 207 + && take_key( 208 + ctx.input, 209 + &[ 210 + TakeKey::named(crate::input::NamedKey::Enter), 211 + TakeKey::named(crate::input::NamedKey::Space), 212 + ], 213 + ) 214 + .is_some(); 215 + let mut paint = vec![WidgetPaint::Surface { 216 + rect, 217 + fill: item_fill(ctx, item, &interaction), 218 + border: None, 219 + radius: ctx.theme.radius.sm, 220 + elevation: None, 221 + }]; 222 + paint.push(WidgetPaint::Label { 223 + rect, 224 + text: LabelText::Key(item.label), 225 + color: if item.disabled { 226 + ctx.theme.colors.text_disabled() 227 + } else { 228 + ctx.theme.colors.text_primary() 229 + }, 230 + role: ctx.theme.typography.caption, 231 + }); 232 + push_focus_ring(ctx, &mut paint, rect, ctx.theme.radius.sm, live_focused); 233 + ItemDraw { 234 + activated: activated_via_pointer || activated_via_key, 235 + paint, 236 + } 237 + } 238 + 239 + fn item_fill( 240 + ctx: &FrameCtx<'_>, 241 + item: &ToolbarItem, 242 + interaction: &crate::hit_test::Interaction, 243 + ) -> crate::theme::Color { 244 + let neutral = ctx.theme.colors.neutral; 245 + if item.disabled { 246 + crate::theme::Color::TRANSPARENT 247 + } else if item.active || interaction.pressed() { 248 + neutral.step(Step12::SELECTED_BG) 249 + } else if interaction.hover() { 250 + neutral.step(Step12::HOVER_BG) 251 + } else { 252 + crate::theme::Color::TRANSPARENT 253 + } 254 + } 255 + 256 + fn compute_visible_count( 257 + rect: LayoutRect, 258 + total: usize, 259 + item_size: LayoutPx, 260 + gap: LayoutPx, 261 + orientation: ToolbarOrientation, 262 + ) -> usize { 263 + if total == 0 { 264 + return 0; 265 + } 266 + let available = match orientation { 267 + ToolbarOrientation::Horizontal => rect.size.width.value(), 268 + ToolbarOrientation::Vertical => rect.size.height.value(), 269 + }; 270 + let span = item_size.value() + gap.value(); 271 + if span <= 0.0 { 272 + return total; 273 + } 274 + #[allow( 275 + clippy::cast_possible_truncation, 276 + clippy::cast_sign_loss, 277 + reason = "item counts fit in usize" 278 + )] 279 + let raw = ((available + gap.value()) / span).floor() as usize; 280 + if raw >= total { 281 + total 282 + } else { 283 + raw.saturating_sub(1) 284 + } 285 + } 286 + 287 + fn item_rect( 288 + rect: LayoutRect, 289 + index: usize, 290 + size: LayoutPx, 291 + gap: LayoutPx, 292 + orientation: ToolbarOrientation, 293 + ) -> LayoutRect { 294 + #[allow( 295 + clippy::cast_precision_loss, 296 + reason = "toolbar item indices fit in f32 mantissa" 297 + )] 298 + let i = index as f32; 299 + let stride = size.value() + gap.value(); 300 + match orientation { 301 + ToolbarOrientation::Horizontal => LayoutRect::new( 302 + LayoutPos::new( 303 + LayoutPx::new(rect.origin.x.value() + i * stride), 304 + rect.origin.y, 305 + ), 306 + LayoutSize::new(size, rect.size.height.min(size_max(rect, orientation))), 307 + ), 308 + ToolbarOrientation::Vertical => LayoutRect::new( 309 + LayoutPos::new( 310 + rect.origin.x, 311 + LayoutPx::new(rect.origin.y.value() + i * stride), 312 + ), 313 + LayoutSize::new(rect.size.width.min(size_max(rect, orientation)), size), 314 + ), 315 + } 316 + } 317 + 318 + fn size_max(rect: LayoutRect, orientation: ToolbarOrientation) -> LayoutPx { 319 + match orientation { 320 + ToolbarOrientation::Horizontal => rect.size.height, 321 + ToolbarOrientation::Vertical => rect.size.width, 322 + } 323 + } 324 + 325 + #[cfg(test)] 326 + mod tests { 327 + use std::sync::Arc; 328 + 329 + use super::{Toolbar, ToolbarItem, show_toolbar}; 330 + use crate::focus::FocusManager; 331 + use crate::frame::FrameCtx; 332 + use crate::hit_test::{HitFrame, HitState, resolve}; 333 + use crate::hotkey::HotkeyTable; 334 + use crate::input::{ 335 + FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample, 336 + }; 337 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 338 + use crate::strings::{StringKey, StringTable}; 339 + use crate::theme::Theme; 340 + use crate::widget_id::{WidgetId, WidgetKey}; 341 + 342 + fn toolbar_id() -> WidgetId { 343 + WidgetId::ROOT.child(WidgetKey::new("toolbar")) 344 + } 345 + 346 + fn items(count: usize) -> Vec<ToolbarItem> { 347 + (0..count as u64) 348 + .map(|i| { 349 + ToolbarItem::new( 350 + toolbar_id().child_indexed(WidgetKey::new("item"), i), 351 + StringKey::new("toolbar.icon"), 352 + ) 353 + }) 354 + .collect() 355 + } 356 + 357 + fn render( 358 + items: &[ToolbarItem], 359 + rect: LayoutRect, 360 + overflow_open: &mut bool, 361 + focus: &mut FocusManager, 362 + snap: &mut InputSnapshot, 363 + prev: &HitState, 364 + ) -> (super::ToolbarResponse, HitState) { 365 + let theme = Arc::new(Theme::light()); 366 + let table = HotkeyTable::new(); 367 + let mut hits = HitFrame::new(); 368 + let response = { 369 + let mut ctx = FrameCtx::new( 370 + theme, 371 + snap, 372 + focus, 373 + &table, 374 + StringTable::empty(), 375 + &mut hits, 376 + prev, 377 + ); 378 + show_toolbar( 379 + &mut ctx, 380 + Toolbar::horizontal( 381 + toolbar_id(), 382 + rect, 383 + items, 384 + LayoutPx::new(28.0), 385 + LayoutPx::new(4.0), 386 + ), 387 + overflow_open, 388 + ) 389 + }; 390 + let next = resolve(prev, &hits, snap, focus.focused()); 391 + (response, next) 392 + } 393 + 394 + #[test] 395 + fn fits_all_items_no_overflow() { 396 + let items = items(3); 397 + let rect = LayoutRect::new( 398 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 399 + LayoutSize::new(LayoutPx::new(100.0), LayoutPx::new(28.0)), 400 + ); 401 + let mut overflow_open = false; 402 + let mut focus = FocusManager::new(); 403 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 404 + let prev = HitState::new(); 405 + let (response, _) = render( 406 + &items, 407 + rect, 408 + &mut overflow_open, 409 + &mut focus, 410 + &mut snap, 411 + &prev, 412 + ); 413 + assert_eq!(response.visible_count, 3); 414 + assert!(response.overflow.is_empty()); 415 + } 416 + 417 + #[test] 418 + fn truncates_to_make_room_for_overflow_button() { 419 + let items = items(5); 420 + let rect = LayoutRect::new( 421 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 422 + LayoutSize::new(LayoutPx::new(100.0), LayoutPx::new(28.0)), 423 + ); 424 + let mut overflow_open = false; 425 + let mut focus = FocusManager::new(); 426 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 427 + let prev = HitState::new(); 428 + let (response, _) = render( 429 + &items, 430 + rect, 431 + &mut overflow_open, 432 + &mut focus, 433 + &mut snap, 434 + &prev, 435 + ); 436 + assert!(response.visible_count < 5); 437 + assert!(!response.overflow.is_empty()); 438 + } 439 + 440 + #[test] 441 + fn click_visible_item_activates() { 442 + let items = items(3); 443 + let rect = LayoutRect::new( 444 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 445 + LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(28.0)), 446 + ); 447 + let mut overflow_open = false; 448 + let mut focus = FocusManager::new(); 449 + let mut prev = HitState::new(); 450 + let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(14.0)); 451 + let mut last: Option<super::ToolbarResponse> = None; 452 + [press(click_pos), release(click_pos), idle(click_pos)] 453 + .into_iter() 454 + .for_each(|mut snap| { 455 + let (response, next) = render( 456 + &items, 457 + rect, 458 + &mut overflow_open, 459 + &mut focus, 460 + &mut snap, 461 + &prev, 462 + ); 463 + last = Some(response); 464 + prev = next; 465 + }); 466 + let Some(response) = last else { 467 + panic!("response missing") 468 + }; 469 + assert_eq!(response.activated, Some(items[1].id)); 470 + } 471 + 472 + #[test] 473 + fn click_overflow_button_toggles_state() { 474 + let items = items(8); 475 + let rect = LayoutRect::new( 476 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 477 + LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(28.0)), 478 + ); 479 + let mut overflow_open = false; 480 + let mut focus = FocusManager::new(); 481 + let mut prev = HitState::new(); 482 + let initial_visible = { 483 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 484 + let (response, _) = render( 485 + &items, 486 + rect, 487 + &mut overflow_open, 488 + &mut focus, 489 + &mut snap, 490 + &prev, 491 + ); 492 + response.visible_count 493 + }; 494 + #[allow(clippy::cast_precision_loss, reason = "small index in test")] 495 + let initial_visible_f32 = initial_visible as f32; 496 + let overflow_x = initial_visible_f32 * (28.0 + 4.0) + 14.0; 497 + let overflow_pos = LayoutPos::new(LayoutPx::new(overflow_x), LayoutPx::new(14.0)); 498 + [ 499 + press(overflow_pos), 500 + release(overflow_pos), 501 + idle(overflow_pos), 502 + ] 503 + .into_iter() 504 + .for_each(|mut snap| { 505 + let (_, next) = render( 506 + &items, 507 + rect, 508 + &mut overflow_open, 509 + &mut focus, 510 + &mut snap, 511 + &prev, 512 + ); 513 + prev = next; 514 + }); 515 + assert!(overflow_open); 516 + } 517 + 518 + fn press(pos: LayoutPos) -> InputSnapshot { 519 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 520 + s.pointer = Some(PointerSample::new(pos)); 521 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 522 + s 523 + } 524 + 525 + fn release(pos: LayoutPos) -> InputSnapshot { 526 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 527 + s.pointer = Some(PointerSample::new(pos)); 528 + s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 529 + s 530 + } 531 + 532 + fn idle(pos: LayoutPos) -> InputSnapshot { 533 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 534 + s.pointer = Some(PointerSample::new(pos)); 535 + s 536 + } 537 + }