Another project
0

Configure Feed

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

at main 17 kB View raw
1use crate::a11y::{AccessNode, Role}; 2use crate::frame::{FrameCtx, InteractDeclaration}; 3use crate::hit_test::Sense; 4use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5use crate::strings::StringKey; 6use crate::theme::{Border, Color, Step12, StrokeWidth}; 7use crate::widget_id::WidgetId; 8 9use super::keys::take_activation; 10use super::paint::{HorizontalAlign, LabelText, WidgetPaint}; 11use super::visuals::push_focus_ring; 12 13#[derive(Copy, Clone, Debug, PartialEq, Eq)] 14pub enum StatusAlign { 15 Start, 16 Center, 17 End, 18} 19 20#[derive(Clone, Debug, PartialEq)] 21pub struct StatusItem { 22 pub id: WidgetId, 23 pub label: LabelText, 24 pub align: StatusAlign, 25 pub width: LayoutPx, 26 pub interactive: bool, 27 pub badge: Option<Color>, 28} 29 30impl StatusItem { 31 #[must_use] 32 pub fn new(id: WidgetId, label: StringKey, align: StatusAlign, width: LayoutPx) -> Self { 33 Self::with_text(id, LabelText::Key(label), align, width) 34 } 35 36 #[must_use] 37 pub fn with_text(id: WidgetId, label: LabelText, align: StatusAlign, width: LayoutPx) -> Self { 38 Self { 39 id, 40 label, 41 align, 42 width, 43 interactive: false, 44 badge: None, 45 } 46 } 47 48 #[must_use] 49 pub fn interactive(self, interactive: bool) -> Self { 50 Self { 51 interactive, 52 ..self 53 } 54 } 55 56 #[must_use] 57 pub fn badge(self, color: Color) -> Self { 58 Self { 59 badge: Some(color), 60 ..self 61 } 62 } 63} 64 65#[derive(Copy, Clone, Debug, PartialEq)] 66pub struct StatusBar<'a> { 67 pub id: WidgetId, 68 pub rect: LayoutRect, 69 pub label: StringKey, 70 pub items: &'a [StatusItem], 71} 72 73impl<'a> StatusBar<'a> { 74 #[must_use] 75 pub const fn new( 76 id: WidgetId, 77 rect: LayoutRect, 78 label: StringKey, 79 items: &'a [StatusItem], 80 ) -> Self { 81 Self { 82 id, 83 rect, 84 label, 85 items, 86 } 87 } 88} 89 90#[derive(Clone, Debug, PartialEq)] 91pub struct StatusBarResponse { 92 pub activated: Option<WidgetId>, 93 pub paint: Vec<WidgetPaint>, 94} 95 96#[must_use] 97pub fn show_status_bar(ctx: &mut FrameCtx<'_>, bar: StatusBar<'_>) -> StatusBarResponse { 98 ctx.a11y.push( 99 bar.id, 100 bar.rect, 101 AccessNode::new(Role::Status).with_label(bar.label), 102 ); 103 let mut paint = vec![WidgetPaint::Surface { 104 rect: bar.rect, 105 fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BG), 106 border: Some(Border { 107 width: StrokeWidth::HAIRLINE, 108 color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER), 109 }), 110 radius: ctx.theme().radius.none, 111 elevation: None, 112 }]; 113 let direction = ctx.direction(); 114 let layouts: Vec<LayoutRect> = lay_out_items(bar.rect, bar.items) 115 .into_iter() 116 .map(|r| r.mirror_horizontally_within(bar.rect, direction)) 117 .collect(); 118 paint.extend(cell_dividers(ctx, bar.rect, bar.items, &layouts)); 119 let mut activated: Option<WidgetId> = None; 120 bar.items 121 .iter() 122 .zip(layouts.iter()) 123 .for_each(|(item, item_rect)| { 124 let item_paint = draw_item(ctx, item, *item_rect, &mut activated); 125 paint.extend(item_paint); 126 }); 127 StatusBarResponse { activated, paint } 128} 129 130fn draw_item( 131 ctx: &mut FrameCtx<'_>, 132 item: &StatusItem, 133 rect: LayoutRect, 134 activated: &mut Option<WidgetId>, 135) -> Vec<WidgetPaint> { 136 let mut paint = Vec::new(); 137 let live_focused = if item.interactive { 138 let interaction = ctx.interact( 139 InteractDeclaration::new(item.id, rect, Sense::INTERACTIVE) 140 .focusable(true) 141 .a11y(AccessNode::new(Role::Button).with_label_text(item.label.clone())), 142 ); 143 let focused = ctx.is_focused(item.id); 144 let activated_via_pointer = interaction.click(); 145 let activated_via_key = focused && take_activation(ctx.input); 146 if (activated_via_pointer || activated_via_key) && activated.is_none() { 147 *activated = Some(item.id); 148 } 149 if interaction.hover() || interaction.pressed() { 150 paint.push(WidgetPaint::Surface { 151 rect, 152 fill: ctx.theme().colors.neutral.step(if interaction.pressed() { 153 Step12::SELECTED_BG 154 } else { 155 Step12::HOVER_BG 156 }), 157 border: None, 158 radius: ctx.theme().radius.none, 159 elevation: None, 160 }); 161 } 162 focused 163 } else { 164 false 165 }; 166 if let Some(badge) = item.badge { 167 let dot_rect = badge_rect(rect); 168 paint.push(WidgetPaint::Surface { 169 rect: dot_rect, 170 fill: badge, 171 border: None, 172 radius: ctx.theme().radius.pill, 173 elevation: None, 174 }); 175 } 176 let text_align = if item.badge.is_some() { 177 HorizontalAlign::Start 178 } else { 179 match item.align { 180 StatusAlign::Start => HorizontalAlign::Start, 181 StatusAlign::Center => HorizontalAlign::Center, 182 StatusAlign::End => HorizontalAlign::End, 183 } 184 }; 185 paint.push(WidgetPaint::AlignedLabel { 186 rect: label_rect(rect, item.badge.is_some()), 187 text: item.label.clone(), 188 color: ctx.theme().colors.text_primary(), 189 role: ctx.theme().typography.label, 190 align: text_align, 191 }); 192 push_focus_ring(ctx, &mut paint, rect, ctx.theme().radius.none, live_focused); 193 paint 194} 195 196const BADGE_PX: f32 = 8.0; 197const BADGE_GAP: f32 = 6.0; 198const ITEM_PAD_X: f32 = 6.0; 199 200fn badge_rect(item: LayoutRect) -> LayoutRect { 201 let pad = (item.size.height.value() - BADGE_PX).max(0.0) / 2.0; 202 LayoutRect::new( 203 LayoutPos::new( 204 LayoutPx::new(item.origin.x.value() + ITEM_PAD_X), 205 LayoutPx::new(item.origin.y.value() + pad), 206 ), 207 LayoutSize::new(LayoutPx::new(BADGE_PX), LayoutPx::new(BADGE_PX)), 208 ) 209} 210 211fn label_rect(item: LayoutRect, has_badge: bool) -> LayoutRect { 212 let lead = ITEM_PAD_X + if has_badge { BADGE_PX + BADGE_GAP } else { 0.0 }; 213 LayoutRect::new( 214 LayoutPos::new(LayoutPx::new(item.origin.x.value() + lead), item.origin.y), 215 LayoutSize::new( 216 LayoutPx::saturating_nonneg(item.size.width.value() - lead - ITEM_PAD_X), 217 item.size.height, 218 ), 219 ) 220} 221 222fn cell_dividers( 223 ctx: &FrameCtx<'_>, 224 bar: LayoutRect, 225 items: &[StatusItem], 226 layouts: &[LayoutRect], 227) -> Vec<WidgetPaint> { 228 items 229 .windows(2) 230 .zip(layouts.windows(2)) 231 .filter(|(pair, _)| pair[0].align == pair[1].align) 232 .filter_map(|(_, rects)| divider_at_seam(ctx, bar, rects[0], rects[1])) 233 .collect() 234} 235 236fn divider_at_seam( 237 ctx: &FrameCtx<'_>, 238 bar: LayoutRect, 239 a: LayoutRect, 240 b: LayoutRect, 241) -> Option<WidgetPaint> { 242 let (left, right) = if a.origin.x.value() <= b.origin.x.value() { 243 (a, b) 244 } else { 245 (b, a) 246 }; 247 let seam = left.max_x().value(); 248 if (right.origin.x.value() - seam).abs() > 0.5 { 249 return None; 250 } 251 Some(WidgetPaint::Surface { 252 rect: LayoutRect::new( 253 LayoutPos::new(LayoutPx::new(seam), bar.origin.y), 254 LayoutSize::new( 255 LayoutPx::new(StrokeWidth::HAIRLINE.value_px()), 256 bar.size.height, 257 ), 258 ), 259 fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER), 260 border: None, 261 radius: ctx.theme().radius.none, 262 elevation: None, 263 }) 264} 265 266#[derive(Copy, Clone)] 267struct AlignTotals { 268 start: f32, 269 center: f32, 270 end: f32, 271} 272 273fn lay_out_items(bar: LayoutRect, items: &[StatusItem]) -> Vec<LayoutRect> { 274 let totals = items.iter().fold( 275 AlignTotals { 276 start: 0.0, 277 center: 0.0, 278 end: 0.0, 279 }, 280 |t, item| match item.align { 281 StatusAlign::Start => AlignTotals { 282 start: t.start + item.width.value(), 283 ..t 284 }, 285 StatusAlign::Center => AlignTotals { 286 center: t.center + item.width.value(), 287 ..t 288 }, 289 StatusAlign::End => AlignTotals { 290 end: t.end + item.width.value(), 291 ..t 292 }, 293 }, 294 ); 295 let bar_x = bar.origin.x.value(); 296 let bar_w = bar.size.width.value(); 297 let start_end = bar_x + totals.start; 298 let ideal_center = bar_x + (bar_w - totals.center) / 2.0; 299 let center_origin = ideal_center.max(start_end); 300 let center_end = center_origin + totals.center; 301 let ideal_end = bar_x + bar_w - totals.end; 302 let end_origin = ideal_end.max(center_end); 303 items 304 .iter() 305 .scan( 306 (bar_x, center_origin, end_origin), 307 |(start_cursor, center_cursor, end_cursor), item| { 308 let origin_x = match item.align { 309 StatusAlign::Start => { 310 let r = *start_cursor; 311 *start_cursor += item.width.value(); 312 r 313 } 314 StatusAlign::Center => { 315 let r = *center_cursor; 316 *center_cursor += item.width.value(); 317 r 318 } 319 StatusAlign::End => { 320 let r = *end_cursor; 321 *end_cursor += item.width.value(); 322 r 323 } 324 }; 325 Some(LayoutRect::new( 326 LayoutPos::new(LayoutPx::new(origin_x), bar.origin.y), 327 LayoutSize::new(item.width, bar.size.height), 328 )) 329 }, 330 ) 331 .collect() 332} 333 334#[cfg(test)] 335mod tests { 336 use std::sync::Arc; 337 338 use super::{StatusAlign, StatusBar, StatusItem, show_status_bar}; 339 use crate::focus::FocusManager; 340 use crate::frame::FrameCtx; 341 use crate::hit_test::{HitFrame, HitState, resolve}; 342 use crate::hotkey::HotkeyTable; 343 use crate::input::{ 344 FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample, 345 }; 346 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 347 use crate::strings::{StringKey, StringTable}; 348 use crate::theme::Theme; 349 use crate::widget_id::{WidgetId, WidgetKey}; 350 351 fn item_id(name: &'static str) -> WidgetId { 352 WidgetId::ROOT.child(WidgetKey::new(name)) 353 } 354 355 fn bar_id() -> WidgetId { 356 WidgetId::ROOT.child(WidgetKey::new("status_bar")) 357 } 358 359 fn items() -> Vec<StatusItem> { 360 vec![ 361 StatusItem::new( 362 item_id("status"), 363 StringKey::new("status.fully"), 364 StatusAlign::Start, 365 LayoutPx::new(140.0), 366 ), 367 StatusItem::new( 368 item_id("zoom"), 369 StringKey::new("status.zoom"), 370 StatusAlign::End, 371 LayoutPx::new(80.0), 372 ) 373 .interactive(true), 374 ] 375 } 376 377 fn bar_rect() -> LayoutRect { 378 LayoutRect::new( 379 LayoutPos::new(LayoutPx::ZERO, LayoutPx::new(700.0)), 380 LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(24.0)), 381 ) 382 } 383 384 fn render( 385 items: &[StatusItem], 386 focus: &mut FocusManager, 387 snap: &mut InputSnapshot, 388 prev: &HitState, 389 ) -> (super::StatusBarResponse, HitState) { 390 let theme = Arc::new(Theme::light()); 391 let table = HotkeyTable::new(); 392 let mut hits = HitFrame::new(); 393 let response = { 394 let mut shaper = bone_text::Shaper::new(); 395 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 396 let mut ctx = FrameCtx::new( 397 theme, 398 snap, 399 focus, 400 &table, 401 StringTable::empty(), 402 &mut hits, 403 prev, 404 &mut a11y, 405 &mut shaper, 406 ); 407 show_status_bar( 408 &mut ctx, 409 StatusBar::new(bar_id(), bar_rect(), StringKey::new("test.status"), items), 410 ) 411 }; 412 let next = resolve(prev, &hits, snap, focus.focused()); 413 (response, next) 414 } 415 416 #[test] 417 fn renders_one_paint_per_item_plus_surface() { 418 let items = items(); 419 let mut focus = FocusManager::new(); 420 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 421 let prev = HitState::new(); 422 let (response, _) = render(&items, &mut focus, &mut snap, &prev); 423 let label_count = response 424 .paint 425 .iter() 426 .filter(|p| matches!(p, super::WidgetPaint::AlignedLabel { .. })) 427 .count(); 428 assert_eq!(label_count, 2); 429 } 430 431 #[test] 432 fn click_interactive_item_activates_it() { 433 let items = items(); 434 let mut focus = FocusManager::new(); 435 let mut prev = HitState::new(); 436 let click_pos = LayoutPos::new(LayoutPx::new(760.0), LayoutPx::new(712.0)); 437 let mut last: Option<super::StatusBarResponse> = None; 438 [press(click_pos), release(click_pos), idle(click_pos)] 439 .into_iter() 440 .for_each(|mut snap| { 441 let (response, next) = render(&items, &mut focus, &mut snap, &prev); 442 last = Some(response); 443 prev = next; 444 }); 445 let Some(response) = last else { 446 panic!("response missing") 447 }; 448 assert_eq!(response.activated, Some(items[1].id)); 449 } 450 451 #[test] 452 fn enter_on_focused_interactive_item_activates_it() { 453 use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey}; 454 455 let items = items(); 456 let interactive_id = items[1].id; 457 let mut focus = FocusManager::new(); 458 let prev = HitState::new(); 459 focus.request_focus(interactive_id); 460 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 461 let _ = render(&items, &mut focus, &mut warm, &prev); 462 assert_eq!(focus.focused(), Some(interactive_id)); 463 464 let mut enter = InputSnapshot::idle(FrameInstant::ZERO); 465 enter.keys_pressed.push(KeyEvent::new( 466 KeyCode::Named(NamedKey::Enter), 467 ModifierMask::NONE, 468 )); 469 let (response, _) = render(&items, &mut focus, &mut enter, &prev); 470 assert_eq!(response.activated, Some(interactive_id)); 471 } 472 473 #[test] 474 fn overflow_packs_center_and_end_after_start_without_underflow() { 475 let crowded = vec![ 476 StatusItem::new( 477 item_id("a"), 478 StringKey::new("status.a"), 479 StatusAlign::Start, 480 LayoutPx::new(400.0), 481 ), 482 StatusItem::new( 483 item_id("b"), 484 StringKey::new("status.b"), 485 StatusAlign::Center, 486 LayoutPx::new(400.0), 487 ), 488 StatusItem::new( 489 item_id("c"), 490 StringKey::new("status.c"), 491 StatusAlign::End, 492 LayoutPx::new(400.0), 493 ), 494 ]; 495 let rects = super::lay_out_items(bar_rect(), &crowded); 496 assert_eq!(rects.len(), 3); 497 let bar_x = bar_rect().origin.x.value(); 498 rects.iter().for_each(|r| { 499 assert!( 500 r.origin.x.value() >= bar_x, 501 "item origin must not underflow bar start", 502 ); 503 }); 504 assert!(rects[1].origin.x.value() >= rects[0].origin.x.value() + 400.0); 505 assert!(rects[2].origin.x.value() >= rects[1].origin.x.value() + 400.0); 506 } 507 508 #[test] 509 fn non_interactive_item_does_not_activate() { 510 let items = items(); 511 let mut focus = FocusManager::new(); 512 let mut prev = HitState::new(); 513 let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(712.0)); 514 [press(click_pos), release(click_pos), idle(click_pos)] 515 .into_iter() 516 .for_each(|mut snap| { 517 let (response, next) = render(&items, &mut focus, &mut snap, &prev); 518 assert!(response.activated.is_none()); 519 prev = next; 520 }); 521 } 522 523 fn press(pos: LayoutPos) -> InputSnapshot { 524 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 525 s.pointer = Some(PointerSample::new(pos)); 526 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 527 s 528 } 529 530 fn release(pos: LayoutPos) -> InputSnapshot { 531 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 532 s.pointer = Some(PointerSample::new(pos)); 533 s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 534 s 535 } 536 537 fn idle(pos: LayoutPos) -> InputSnapshot { 538 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 539 s.pointer = Some(PointerSample::new(pos)); 540 s 541 } 542}