Another project
0

Configure Feed

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

at main 42 kB View raw
1use crate::a11y::{AccessNode, Role}; 2use crate::frame::{FrameCtx, InteractDeclaration}; 3use crate::hit_test::{Sense, ZLayer}; 4use crate::input::{KeyCode, NamedKey}; 5use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 6use crate::strings::StringKey; 7use crate::theme::{Border, Color, Step12, StrokeWidth}; 8use crate::widget_id::{WidgetId, WidgetKey}; 9 10use super::keys::{TakeKey, take_key}; 11use bone_text::{ShapeRequest, ShapedLine}; 12 13use super::paint::{GlyphMark, HorizontalAlign, LabelText, WidgetPaint}; 14use super::visuals::push_focus_ring; 15 16#[derive(Clone, Debug, PartialEq)] 17pub enum MenuItem { 18 Action { 19 id: WidgetId, 20 label: StringKey, 21 shortcut: Option<super::paint::LabelText>, 22 disabled: bool, 23 }, 24 Submenu { 25 id: WidgetId, 26 label: StringKey, 27 items: Vec<MenuItem>, 28 }, 29 Separator, 30} 31 32impl MenuItem { 33 #[must_use] 34 pub fn id(&self) -> Option<WidgetId> { 35 match self { 36 Self::Action { id, .. } | Self::Submenu { id, .. } => Some(*id), 37 Self::Separator => None, 38 } 39 } 40 41 #[must_use] 42 pub fn is_focusable(&self) -> bool { 43 match self { 44 Self::Action { disabled, .. } => !*disabled, 45 Self::Submenu { .. } => true, 46 Self::Separator => false, 47 } 48 } 49} 50 51#[derive(Clone, Debug, Default, PartialEq, Eq)] 52pub struct MenuState { 53 pub open_submenu: Option<WidgetId>, 54 pub highlighted: Option<usize>, 55 pub submenu: Option<Box<MenuState>>, 56} 57 58#[derive(Copy, Clone, Debug, PartialEq)] 59pub struct MenuMetrics { 60 pub item_height: LayoutPx, 61 pub separator_height: LayoutPx, 62 pub padding_x: LayoutPx, 63 pub min_width: LayoutPx, 64 pub shortcut_gap: LayoutPx, 65} 66 67impl MenuMetrics { 68 #[must_use] 69 pub const fn standard() -> Self { 70 Self { 71 item_height: LayoutPx::new(24.0), 72 separator_height: LayoutPx::new(7.0), 73 padding_x: LayoutPx::new(10.0), 74 min_width: LayoutPx::new(180.0), 75 shortcut_gap: LayoutPx::new(24.0), 76 } 77 } 78} 79 80#[derive(Debug, PartialEq)] 81pub struct Menu<'a, 'state> { 82 pub id: WidgetId, 83 pub origin: LayoutPos, 84 pub label: StringKey, 85 pub items: &'a [MenuItem], 86 pub metrics: MenuMetrics, 87 pub state: &'state mut MenuState, 88} 89 90impl<'a, 'state> Menu<'a, 'state> { 91 #[must_use] 92 pub fn new( 93 id: WidgetId, 94 origin: LayoutPos, 95 label: StringKey, 96 items: &'a [MenuItem], 97 state: &'state mut MenuState, 98 ) -> Self { 99 Self { 100 id, 101 origin, 102 label, 103 items, 104 metrics: MenuMetrics::standard(), 105 state, 106 } 107 } 108 109 #[must_use] 110 pub fn metrics(self, metrics: MenuMetrics) -> Self { 111 Self { metrics, ..self } 112 } 113} 114 115#[derive(Clone, Debug, PartialEq)] 116pub struct MenuResponse { 117 pub activated: Option<WidgetId>, 118 pub close: bool, 119 pub paint: Vec<WidgetPaint>, 120 pub rect: LayoutRect, 121} 122 123#[must_use] 124pub fn show_menu(ctx: &mut FrameCtx<'_>, menu: Menu<'_, '_>) -> MenuResponse { 125 let Menu { 126 id, 127 origin, 128 label, 129 items, 130 metrics, 131 state, 132 } = menu; 133 let rect = menu_rect(origin, items, metrics); 134 ctx.a11y 135 .push(id, rect, AccessNode::new(Role::Menu).with_label(label)); 136 let mut paint = vec![WidgetPaint::Surface { 137 rect, 138 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1), 139 border: Some(Border { 140 width: StrokeWidth::px(1.5), 141 color: ctx.theme().colors.neutral.step(Step12::BORDER), 142 }), 143 radius: ctx.theme().radius.sm, 144 elevation: Some(ctx.theme().elevation.level1), 145 }]; 146 let layouts = item_rects(rect, items, metrics); 147 let mut activated: Option<WidgetId> = None; 148 let mut close = false; 149 let prev_open = state.open_submenu; 150 let snap_open = state.open_submenu; 151 items 152 .iter() 153 .zip(layouts.iter()) 154 .enumerate() 155 .for_each(|(idx, (item, item_rect))| { 156 let is_highlighted = state.highlighted == Some(idx); 157 let item_result = draw_item( 158 ctx, 159 ItemDrawArgs { 160 item, 161 rect: *item_rect, 162 metrics, 163 is_highlighted, 164 is_open_submenu: matches!(item, MenuItem::Submenu { id: sid, .. } if snap_open == Some(*sid)), 165 }, 166 ); 167 paint.extend(item_result.paint); 168 if item_result.hovered { 169 state.highlighted = Some(idx); 170 } 171 if let Some(activate) = item_result.activated { 172 if activated.is_none() { 173 activated = Some(activate); 174 } 175 close = true; 176 } 177 if let Some(sid) = item_result.opened_submenu { 178 state.open_submenu = Some(sid); 179 } 180 }); 181 if state.open_submenu != prev_open { 182 state.submenu = None; 183 } 184 render_open_submenu( 185 ctx, 186 SubmenuArgs { 187 parent_id: id, 188 items, 189 layouts: &layouts, 190 metrics, 191 state, 192 }, 193 &mut paint, 194 &mut activated, 195 &mut close, 196 ); 197 handle_keyboard(ctx, items, state, &mut activated, &mut close); 198 MenuResponse { 199 activated, 200 close, 201 paint, 202 rect, 203 } 204} 205 206struct SubmenuArgs<'a, 'state> { 207 parent_id: WidgetId, 208 items: &'a [MenuItem], 209 layouts: &'a [LayoutRect], 210 metrics: MenuMetrics, 211 state: &'state mut MenuState, 212} 213 214fn render_open_submenu( 215 ctx: &mut FrameCtx<'_>, 216 args: SubmenuArgs<'_, '_>, 217 paint: &mut Vec<WidgetPaint>, 218 activated: &mut Option<WidgetId>, 219 close: &mut bool, 220) { 221 let SubmenuArgs { 222 parent_id, 223 items, 224 layouts, 225 metrics, 226 state, 227 } = args; 228 let Some(sid) = state.open_submenu else { 229 return; 230 }; 231 let active = items.iter().zip(layouts.iter()).find_map(|(item, rect)| { 232 matches!(item, MenuItem::Submenu { id: iid, .. } if *iid == sid).then(|| match item { 233 MenuItem::Submenu { 234 items: sub, label, .. 235 } => (*rect, sub.as_slice(), *label), 236 _ => unreachable!(), 237 }) 238 }); 239 let Some((item_rect, subitems, sub_label)) = active else { 240 state.open_submenu = None; 241 state.submenu = None; 242 return; 243 }; 244 let sub_origin = LayoutPos::new( 245 LayoutPx::new(item_rect.origin.x.value() + item_rect.size.width.value()), 246 item_rect.origin.y, 247 ); 248 let sub_id = parent_id.child(WidgetKey::new("submenu")); 249 let sub_state = state 250 .submenu 251 .get_or_insert_with(|| Box::new(MenuState::default())); 252 let sub_response = show_menu( 253 ctx, 254 Menu::new(sub_id, sub_origin, sub_label, subitems, sub_state).metrics(metrics), 255 ); 256 paint.extend(sub_response.paint); 257 if let Some(a) = sub_response.activated { 258 if activated.is_none() { 259 *activated = Some(a); 260 } 261 *close = true; 262 } else if sub_response.close { 263 state.open_submenu = None; 264 state.submenu = None; 265 } 266} 267 268#[derive(Copy, Clone)] 269struct ItemDrawArgs<'a> { 270 item: &'a MenuItem, 271 rect: LayoutRect, 272 metrics: MenuMetrics, 273 is_highlighted: bool, 274 is_open_submenu: bool, 275} 276 277struct ItemDrawResult { 278 paint: Vec<WidgetPaint>, 279 hovered: bool, 280 activated: Option<WidgetId>, 281 opened_submenu: Option<WidgetId>, 282} 283 284fn draw_item(ctx: &mut FrameCtx<'_>, args: ItemDrawArgs<'_>) -> ItemDrawResult { 285 match args.item { 286 MenuItem::Separator => ItemDrawResult { 287 paint: separator_paint(ctx, args.rect, args.metrics), 288 hovered: false, 289 activated: None, 290 opened_submenu: None, 291 }, 292 MenuItem::Action { 293 id, 294 label, 295 shortcut, 296 disabled, 297 } => { 298 let interaction = ctx.interact( 299 InteractDeclaration::new(*id, args.rect, Sense::INTERACTIVE) 300 .at_z(ZLayer::POPUP) 301 .focusable(false) 302 .disabled(*disabled) 303 .a11y( 304 AccessNode::new(Role::MenuItem) 305 .with_label(*label) 306 .with_disabled(*disabled), 307 ), 308 ); 309 let activated = (!*disabled && interaction.click()).then_some(*id); 310 let mut paint = 311 item_surface(ctx, args.rect, args.is_highlighted || interaction.hover()); 312 paint.push(WidgetPaint::AlignedLabel { 313 rect: label_only_rect(args.rect, args.metrics), 314 text: LabelText::Key(*label), 315 color: if *disabled { 316 ctx.theme().colors.text_disabled() 317 } else { 318 ctx.theme().colors.text_primary() 319 }, 320 role: ctx.theme().typography.body, 321 align: HorizontalAlign::Start, 322 }); 323 if let Some(sc) = shortcut { 324 paint.push(WidgetPaint::AlignedLabel { 325 rect: shortcut_rect(args.rect, args.metrics), 326 text: sc.clone(), 327 color: ctx.theme().colors.text_secondary(), 328 role: ctx.theme().typography.caption, 329 align: HorizontalAlign::End, 330 }); 331 } 332 ItemDrawResult { 333 paint, 334 hovered: interaction.hover(), 335 activated, 336 opened_submenu: None, 337 } 338 } 339 MenuItem::Submenu { id, label, .. } => { 340 let interaction = ctx.interact( 341 InteractDeclaration::new(*id, args.rect, Sense::INTERACTIVE) 342 .at_z(ZLayer::POPUP) 343 .focusable(false) 344 .active(args.is_open_submenu) 345 .a11y( 346 AccessNode::new(Role::MenuItem) 347 .with_label(*label) 348 .with_expanded(args.is_open_submenu), 349 ), 350 ); 351 let opened = (interaction.click() || (interaction.hover() && !args.is_open_submenu)) 352 .then_some(*id); 353 let mut paint = item_surface( 354 ctx, 355 args.rect, 356 args.is_highlighted || interaction.hover() || args.is_open_submenu, 357 ); 358 paint.push(WidgetPaint::AlignedLabel { 359 rect: label_only_rect(args.rect, args.metrics), 360 text: LabelText::Key(*label), 361 color: ctx.theme().colors.text_primary(), 362 role: ctx.theme().typography.body, 363 align: HorizontalAlign::Start, 364 }); 365 paint.push(WidgetPaint::Mark { 366 rect: arrow_rect(args.rect, args.metrics), 367 kind: GlyphMark::SubmenuArrow, 368 color: ctx.theme().colors.text_secondary(), 369 }); 370 ItemDrawResult { 371 paint, 372 hovered: interaction.hover(), 373 activated: None, 374 opened_submenu: opened, 375 } 376 } 377 } 378} 379 380fn item_surface(ctx: &FrameCtx<'_>, rect: LayoutRect, highlighted: bool) -> Vec<WidgetPaint> { 381 if !highlighted { 382 return Vec::new(); 383 } 384 vec![WidgetPaint::Surface { 385 rect, 386 fill: ctx.theme().colors.accent.step(Step12::HOVER_BG), 387 border: None, 388 radius: ctx.theme().radius.sm, 389 elevation: None, 390 }] 391} 392 393fn separator_paint(ctx: &FrameCtx<'_>, rect: LayoutRect, metrics: MenuMetrics) -> Vec<WidgetPaint> { 394 let line_y = rect.origin.y.value() + metrics.separator_height.value() / 2.0 - 0.5; 395 let line_rect = LayoutRect::new( 396 LayoutPos::new( 397 LayoutPx::new(rect.origin.x.value() + metrics.padding_x.value()), 398 LayoutPx::new(line_y), 399 ), 400 LayoutSize::new( 401 LayoutPx::saturating_nonneg(rect.size.width.value() - 2.0 * metrics.padding_x.value()), 402 LayoutPx::new(1.0), 403 ), 404 ); 405 vec![WidgetPaint::Surface { 406 rect: line_rect, 407 fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER), 408 border: None, 409 radius: ctx.theme().radius.none, 410 elevation: None, 411 }] 412} 413 414fn label_only_rect(item: LayoutRect, metrics: MenuMetrics) -> LayoutRect { 415 LayoutRect::new( 416 LayoutPos::new( 417 LayoutPx::new(item.origin.x.value() + metrics.padding_x.value()), 418 item.origin.y, 419 ), 420 LayoutSize::new( 421 LayoutPx::saturating_nonneg(item.size.width.value() - 2.0 * metrics.padding_x.value()), 422 item.size.height, 423 ), 424 ) 425} 426 427fn shortcut_rect(item: LayoutRect, metrics: MenuMetrics) -> LayoutRect { 428 let half = item.size.width.value() / 2.0; 429 LayoutRect::new( 430 LayoutPos::new(LayoutPx::new(item.origin.x.value() + half), item.origin.y), 431 LayoutSize::new( 432 LayoutPx::saturating_nonneg(half - metrics.padding_x.value()), 433 item.size.height, 434 ), 435 ) 436} 437 438fn arrow_rect(item: LayoutRect, metrics: MenuMetrics) -> LayoutRect { 439 let arrow_px = 12.0; 440 let pad = (item.size.height.value() - arrow_px).max(0.0) / 2.0; 441 LayoutRect::new( 442 LayoutPos::new( 443 LayoutPx::new( 444 item.origin.x.value() + item.size.width.value() 445 - arrow_px 446 - metrics.padding_x.value(), 447 ), 448 LayoutPx::new(item.origin.y.value() + pad), 449 ), 450 LayoutSize::new(LayoutPx::new(arrow_px), LayoutPx::new(arrow_px)), 451 ) 452} 453 454fn menu_rect(origin: LayoutPos, items: &[MenuItem], metrics: MenuMetrics) -> LayoutRect { 455 let height: f32 = items 456 .iter() 457 .map(|i| match i { 458 MenuItem::Separator => metrics.separator_height.value(), 459 _ => metrics.item_height.value(), 460 }) 461 .sum(); 462 LayoutRect::new( 463 origin, 464 LayoutSize::new(metrics.min_width, LayoutPx::new(height)), 465 ) 466} 467 468fn item_rects(menu: LayoutRect, items: &[MenuItem], metrics: MenuMetrics) -> Vec<LayoutRect> { 469 items 470 .iter() 471 .scan(menu.origin.y.value(), |y, item| { 472 let h = match item { 473 MenuItem::Separator => metrics.separator_height.value(), 474 _ => metrics.item_height.value(), 475 }; 476 let rect = LayoutRect::new( 477 LayoutPos::new(menu.origin.x, LayoutPx::new(*y)), 478 LayoutSize::new(menu.size.width, LayoutPx::new(h)), 479 ); 480 *y += h; 481 Some(rect) 482 }) 483 .collect() 484} 485 486fn handle_keyboard( 487 ctx: &mut FrameCtx<'_>, 488 items: &[MenuItem], 489 state: &mut MenuState, 490 activated: &mut Option<WidgetId>, 491 close: &mut bool, 492) { 493 let event = take_key( 494 ctx.input, 495 &[ 496 TakeKey::named(NamedKey::ArrowUp), 497 TakeKey::named(NamedKey::ArrowDown), 498 TakeKey::named(NamedKey::Enter), 499 TakeKey::named(NamedKey::Space), 500 TakeKey::named(NamedKey::Escape), 501 TakeKey::named(NamedKey::Home), 502 TakeKey::named(NamedKey::End), 503 ], 504 ); 505 let Some(event) = event else { return }; 506 match event.code { 507 KeyCode::Named(NamedKey::Escape) => { 508 *close = true; 509 } 510 KeyCode::Named(NamedKey::ArrowDown) => { 511 state.highlighted = next_focusable(items, state.highlighted, false); 512 } 513 KeyCode::Named(NamedKey::ArrowUp) => { 514 state.highlighted = next_focusable(items, state.highlighted, true); 515 } 516 KeyCode::Named(NamedKey::Home) => { 517 state.highlighted = first_focusable(items); 518 } 519 KeyCode::Named(NamedKey::End) => { 520 state.highlighted = last_focusable(items); 521 } 522 KeyCode::Named(NamedKey::Enter | NamedKey::Space) => { 523 if let Some(idx) = state.highlighted 524 && let Some(MenuItem::Action { id, disabled, .. }) = items.get(idx) 525 && !*disabled 526 { 527 *activated = Some(*id); 528 *close = true; 529 } 530 } 531 KeyCode::Named(_) | KeyCode::Char(_) => {} 532 } 533} 534 535fn first_focusable(items: &[MenuItem]) -> Option<usize> { 536 items.iter().position(MenuItem::is_focusable) 537} 538 539fn last_focusable(items: &[MenuItem]) -> Option<usize> { 540 items 541 .iter() 542 .enumerate() 543 .rev() 544 .find(|(_, i)| i.is_focusable()) 545 .map(|(idx, _)| idx) 546} 547 548fn next_focusable(items: &[MenuItem], from: Option<usize>, reverse: bool) -> Option<usize> { 549 if items.is_empty() { 550 return None; 551 } 552 let len = items.len(); 553 let start = from.unwrap_or(if reverse { 0 } else { len - 1 }); 554 (1..=len) 555 .find_map(|delta| { 556 let idx = if reverse { 557 (start + len - delta) % len 558 } else { 559 (start + delta) % len 560 }; 561 items[idx].is_focusable().then_some(idx) 562 }) 563 .or(from) 564} 565 566#[derive(Debug, PartialEq)] 567pub struct ContextMenu<'a, 'state> { 568 pub id: WidgetId, 569 pub anchor: LayoutPos, 570 pub label: StringKey, 571 pub items: &'a [MenuItem], 572 pub state: &'state mut MenuState, 573 pub metrics: MenuMetrics, 574} 575 576impl<'a, 'state> ContextMenu<'a, 'state> { 577 #[must_use] 578 pub fn at_cursor( 579 id: WidgetId, 580 anchor: LayoutPos, 581 label: StringKey, 582 items: &'a [MenuItem], 583 state: &'state mut MenuState, 584 ) -> Self { 585 Self { 586 id, 587 anchor, 588 label, 589 items, 590 state, 591 metrics: MenuMetrics::standard(), 592 } 593 } 594} 595 596#[must_use] 597pub fn show_context_menu(ctx: &mut FrameCtx<'_>, menu: ContextMenu<'_, '_>) -> MenuResponse { 598 let ContextMenu { 599 id, 600 anchor, 601 label, 602 items, 603 state, 604 metrics, 605 } = menu; 606 show_menu( 607 ctx, 608 Menu { 609 id, 610 origin: anchor, 611 label, 612 items, 613 metrics, 614 state, 615 }, 616 ) 617} 618 619#[derive(Clone, Debug, PartialEq)] 620pub struct MenuBarEntry { 621 pub id: WidgetId, 622 pub label: StringKey, 623 pub items: Vec<MenuItem>, 624} 625 626#[derive(Clone, Debug, Default, PartialEq, Eq)] 627pub struct MenuBarState { 628 pub open: Option<WidgetId>, 629 pub menu: MenuState, 630} 631 632#[derive(Debug, PartialEq)] 633pub struct MenuBar<'a, 'state> { 634 pub id: WidgetId, 635 pub rect: LayoutRect, 636 pub label: StringKey, 637 pub entries: &'a [MenuBarEntry], 638 pub state: &'state mut MenuBarState, 639 pub min_item_width: LayoutPx, 640 pub item_padding: LayoutPx, 641 pub document_label: Option<LabelText>, 642} 643 644impl<'a, 'state> MenuBar<'a, 'state> { 645 #[must_use] 646 pub const fn new( 647 id: WidgetId, 648 rect: LayoutRect, 649 label: StringKey, 650 entries: &'a [MenuBarEntry], 651 state: &'state mut MenuBarState, 652 ) -> Self { 653 Self { 654 id, 655 rect, 656 label, 657 entries, 658 state, 659 min_item_width: LayoutPx::new(36.0), 660 item_padding: LayoutPx::new(10.0), 661 document_label: None, 662 } 663 } 664 665 #[must_use] 666 pub fn with_document_label(mut self, label: LabelText) -> Self { 667 self.document_label = Some(label); 668 self 669 } 670} 671 672#[derive(Clone, Debug, PartialEq)] 673pub struct MenuBarResponse { 674 pub activated: Option<WidgetId>, 675 pub paint: Vec<WidgetPaint>, 676 pub popover_paint: Vec<WidgetPaint>, 677} 678 679#[must_use] 680pub fn show_menu_bar(ctx: &mut FrameCtx<'_>, bar: MenuBar<'_, '_>) -> MenuBarResponse { 681 let MenuBar { 682 id, 683 rect, 684 label, 685 entries, 686 state, 687 min_item_width, 688 item_padding, 689 document_label, 690 } = bar; 691 ctx.a11y 692 .push(id, rect, AccessNode::new(Role::MenuBar).with_label(label)); 693 let mut paint = vec![WidgetPaint::Surface { 694 rect, 695 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L0), 696 border: Some(Border { 697 width: StrokeWidth::HAIRLINE, 698 color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER), 699 }), 700 radius: ctx.theme().radius.none, 701 elevation: None, 702 }]; 703 let role = ctx.theme().typography.label; 704 let request = ShapeRequest { 705 face: role.face, 706 size_px: role.size.as_px_f32(), 707 weight: role.weight, 708 line_height_px: 0.0, 709 letter_spacing_px: 0.0, 710 max_width: None, 711 }; 712 let widths: Vec<LayoutPx> = entries 713 .iter() 714 .map(|e| { 715 let resolved = ctx.strings.resolve(e.label); 716 let advance = ctx 717 .shaper 718 .shape(resolved, request) 719 .lines 720 .first() 721 .map_or(0.0, ShapedLine::visible_advance_px); 722 let total = advance + 2.0 * item_padding.value(); 723 LayoutPx::new(total.max(min_item_width.value())) 724 }) 725 .collect(); 726 let direction = ctx.direction(); 727 let raw_entry_layouts = entry_rects(rect, &widths); 728 let entry_layouts: Vec<LayoutRect> = raw_entry_layouts 729 .iter() 730 .map(|r| r.mirror_horizontally_within(rect, direction)) 731 .collect(); 732 entries 733 .iter() 734 .zip(entry_layouts.iter()) 735 .for_each(|(entry, entry_rect)| { 736 paint.extend(draw_menu_bar_entry( 737 ctx, 738 entry, 739 *entry_rect, 740 item_padding, 741 state, 742 )); 743 }); 744 if let Some(label_text) = document_label { 745 paint.push(document_label_paint( 746 ctx, 747 label_text, 748 rect, 749 request, 750 raw_entry_layouts.as_slice(), 751 direction, 752 )); 753 } 754 let mut popover_paint = Vec::new(); 755 let activated = open_menu_bar_dropdown(ctx, entries, &entry_layouts, state, &mut popover_paint); 756 MenuBarResponse { 757 activated, 758 paint, 759 popover_paint, 760 } 761} 762 763fn draw_menu_bar_entry( 764 ctx: &mut FrameCtx<'_>, 765 entry: &MenuBarEntry, 766 entry_rect: LayoutRect, 767 item_padding: LayoutPx, 768 state: &mut MenuBarState, 769) -> Vec<WidgetPaint> { 770 let is_open = state.open == Some(entry.id); 771 let interaction = ctx.interact( 772 InteractDeclaration::new(entry.id, entry_rect, Sense::INTERACTIVE) 773 .focusable(true) 774 .active(is_open) 775 .a11y( 776 AccessNode::new(Role::MenuItem) 777 .with_label(entry.label) 778 .with_expanded(is_open), 779 ), 780 ); 781 let live_focused = ctx.is_focused(entry.id); 782 let pointer_toggled = interaction.click(); 783 let key_toggled = live_focused 784 && take_key( 785 ctx.input, 786 &[ 787 TakeKey::named(NamedKey::Enter), 788 TakeKey::named(NamedKey::Space), 789 TakeKey::named(NamedKey::ArrowDown), 790 ], 791 ) 792 .is_some(); 793 if pointer_toggled || key_toggled { 794 state.open = if is_open { None } else { Some(entry.id) }; 795 state.menu = MenuState::default(); 796 } else if state.open.is_some() && !is_open && interaction.hover() { 797 state.open = Some(entry.id); 798 state.menu = MenuState::default(); 799 } 800 let mut paint = vec![ 801 WidgetPaint::Surface { 802 rect: entry_rect, 803 fill: if is_open { 804 ctx.theme().colors.neutral.step(Step12::SELECTED_BG) 805 } else if interaction.hover() { 806 ctx.theme().colors.neutral.step(Step12::HOVER_BG) 807 } else { 808 Color::TRANSPARENT 809 }, 810 border: None, 811 radius: ctx.theme().radius.sm, 812 elevation: None, 813 }, 814 WidgetPaint::AlignedLabel { 815 rect: LayoutRect::new( 816 LayoutPos::new( 817 LayoutPx::new(entry_rect.origin.x.value() + item_padding.value()), 818 entry_rect.origin.y, 819 ), 820 LayoutSize::new( 821 LayoutPx::saturating_nonneg( 822 entry_rect.size.width.value() - 2.0 * item_padding.value(), 823 ), 824 entry_rect.size.height, 825 ), 826 ), 827 text: LabelText::Key(entry.label), 828 color: ctx.theme().colors.text_primary(), 829 role: ctx.theme().typography.label, 830 align: HorizontalAlign::Start, 831 }, 832 ]; 833 push_focus_ring( 834 ctx, 835 &mut paint, 836 entry_rect, 837 ctx.theme().radius.sm, 838 live_focused, 839 ); 840 paint 841} 842 843fn open_menu_bar_dropdown( 844 ctx: &mut FrameCtx<'_>, 845 entries: &[MenuBarEntry], 846 entry_layouts: &[LayoutRect], 847 state: &mut MenuBarState, 848 paint: &mut Vec<WidgetPaint>, 849) -> Option<WidgetId> { 850 let open_id = state.open?; 851 let (entry, entry_rect) = entries 852 .iter() 853 .zip(entry_layouts.iter()) 854 .find(|(e, _)| e.id == open_id)?; 855 let menu_origin = LayoutPos::new( 856 entry_rect.origin.x, 857 LayoutPx::new(entry_rect.origin.y.value() + entry_rect.size.height.value()), 858 ); 859 let response = show_menu( 860 ctx, 861 Menu::new( 862 entry.id.child(WidgetKey::new("menu")), 863 menu_origin, 864 entry.label, 865 &entry.items, 866 &mut state.menu, 867 ), 868 ); 869 paint.extend(response.paint); 870 if response.close { 871 state.open = None; 872 state.menu = MenuState::default(); 873 } 874 response.activated 875} 876 877fn document_label_paint( 878 ctx: &mut FrameCtx<'_>, 879 label_text: LabelText, 880 bar_rect: LayoutRect, 881 request: ShapeRequest, 882 entry_layouts: &[LayoutRect], 883 direction: LayoutDirection, 884) -> WidgetPaint { 885 let resolved = label_text.resolve(ctx.strings); 886 let advance = ctx 887 .shaper 888 .shape(resolved, request) 889 .lines 890 .first() 891 .map_or(0.0, ShapedLine::visible_advance_px); 892 let bar_min_x = bar_rect.origin.x.value(); 893 let bar_max_x = bar_min_x + bar_rect.size.width.value(); 894 let width = advance.min(bar_rect.size.width.value()).max(0.0); 895 let entries_end = entry_layouts 896 .last() 897 .map_or(bar_min_x, |r| r.origin.x.value() + r.size.width.value()); 898 let centered_x = bar_min_x + (bar_rect.size.width.value() - width) * 0.5; 899 let max_x = (bar_max_x - width).max(bar_min_x); 900 let label_x = centered_x.clamp(entries_end.min(max_x), max_x); 901 let label_rect = LayoutRect::new( 902 LayoutPos::new(LayoutPx::saturating(label_x), bar_rect.origin.y), 903 LayoutSize::new(LayoutPx::saturating_nonneg(width), bar_rect.size.height), 904 ); 905 WidgetPaint::AlignedLabel { 906 rect: label_rect.mirror_horizontally_within(bar_rect, direction), 907 text: label_text, 908 color: ctx.theme().colors.text_primary(), 909 role: ctx.theme().typography.label, 910 align: HorizontalAlign::Center, 911 } 912} 913 914fn entry_rects(bar: LayoutRect, widths: &[LayoutPx]) -> Vec<LayoutRect> { 915 widths 916 .iter() 917 .scan(bar.origin.x.value(), |x, w| { 918 let rect = LayoutRect::new( 919 LayoutPos::new(LayoutPx::new(*x), bar.origin.y), 920 LayoutSize::new(*w, bar.size.height), 921 ); 922 *x += w.value(); 923 Some(rect) 924 }) 925 .collect() 926} 927 928#[cfg(test)] 929mod tests { 930 use std::sync::Arc; 931 932 use super::{ 933 Menu, MenuBar, MenuBarEntry, MenuBarState, MenuItem, MenuState, show_menu, show_menu_bar, 934 }; 935 use crate::focus::FocusManager; 936 use crate::frame::FrameCtx; 937 use crate::hit_test::{HitFrame, HitState, resolve}; 938 use crate::hotkey::HotkeyTable; 939 use crate::input::{ 940 FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, 941 PointerButtonMask, PointerSample, 942 }; 943 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 944 use crate::strings::{StringKey, StringTable}; 945 use crate::theme::Theme; 946 use crate::widget_id::{WidgetId, WidgetKey}; 947 948 fn menu_root() -> WidgetId { 949 WidgetId::ROOT.child(WidgetKey::new("menu")) 950 } 951 952 fn menu_bar_id() -> WidgetId { 953 WidgetId::ROOT.child(WidgetKey::new("menu_bar")) 954 } 955 956 fn action(name: &'static str) -> MenuItem { 957 MenuItem::Action { 958 id: menu_root().child(WidgetKey::new(name)), 959 label: StringKey::new("menu.action"), 960 shortcut: Some(super::super::paint::LabelText::Key(StringKey::new( 961 "menu.shortcut", 962 ))), 963 disabled: false, 964 } 965 } 966 967 fn disabled_action(name: &'static str) -> MenuItem { 968 match action(name) { 969 MenuItem::Action { 970 id, 971 label, 972 shortcut, 973 .. 974 } => MenuItem::Action { 975 id, 976 label, 977 shortcut, 978 disabled: true, 979 }, 980 _ => unreachable!(), 981 } 982 } 983 984 fn render( 985 items: &[MenuItem], 986 state: &mut MenuState, 987 focus: &mut FocusManager, 988 snap: &mut InputSnapshot, 989 prev: &HitState, 990 ) -> (super::MenuResponse, HitState) { 991 let theme = Arc::new(Theme::light()); 992 let table = HotkeyTable::new(); 993 let mut hits = HitFrame::new(); 994 let response = { 995 let mut shaper = bone_text::Shaper::new(); 996 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 997 let mut ctx = FrameCtx::new( 998 theme, 999 snap, 1000 focus, 1001 &table, 1002 StringTable::empty(), 1003 &mut hits, 1004 prev, 1005 &mut a11y, 1006 &mut shaper, 1007 ); 1008 show_menu( 1009 &mut ctx, 1010 Menu::new( 1011 menu_root(), 1012 LayoutPos::ORIGIN, 1013 StringKey::new("test.menu"), 1014 items, 1015 state, 1016 ), 1017 ) 1018 }; 1019 let next = resolve(prev, &hits, snap, focus.focused()); 1020 (response, next) 1021 } 1022 1023 fn press(pos: LayoutPos) -> InputSnapshot { 1024 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 1025 s.pointer = Some(PointerSample::new(pos)); 1026 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 1027 s 1028 } 1029 1030 fn release(pos: LayoutPos) -> InputSnapshot { 1031 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 1032 s.pointer = Some(PointerSample::new(pos)); 1033 s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 1034 s 1035 } 1036 1037 fn idle(pos: LayoutPos) -> InputSnapshot { 1038 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 1039 s.pointer = Some(PointerSample::new(pos)); 1040 s 1041 } 1042 1043 #[test] 1044 fn click_action_item_activates() { 1045 let items = vec![action("save"), action("save_as")]; 1046 let mut state = MenuState::default(); 1047 let mut focus = FocusManager::new(); 1048 let mut prev = HitState::new(); 1049 let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(36.0)); 1050 let mut last_response: Option<super::MenuResponse> = None; 1051 [press(click_pos), release(click_pos), idle(click_pos)] 1052 .into_iter() 1053 .for_each(|mut snap| { 1054 let (response, next) = render(&items, &mut state, &mut focus, &mut snap, &prev); 1055 last_response = Some(response); 1056 prev = next; 1057 }); 1058 let Some(r) = last_response else { 1059 panic!("response missing") 1060 }; 1061 let MenuItem::Action { id: target_id, .. } = &items[1] else { 1062 panic!("expected action"); 1063 }; 1064 assert_eq!(r.activated, Some(*target_id)); 1065 assert!(r.close); 1066 } 1067 1068 #[test] 1069 fn disabled_action_does_not_activate() { 1070 let items = vec![disabled_action("save")]; 1071 let mut state = MenuState::default(); 1072 let mut focus = FocusManager::new(); 1073 let mut prev = HitState::new(); 1074 let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(12.0)); 1075 [press(click_pos), release(click_pos), idle(click_pos)] 1076 .into_iter() 1077 .for_each(|mut snap| { 1078 let (response, next) = render(&items, &mut state, &mut focus, &mut snap, &prev); 1079 assert!(response.activated.is_none()); 1080 assert!(!response.close); 1081 prev = next; 1082 }); 1083 } 1084 1085 #[test] 1086 fn arrow_down_skips_separator_and_disabled() { 1087 let items = vec![ 1088 disabled_action("a"), 1089 MenuItem::Separator, 1090 action("b"), 1091 action("c"), 1092 ]; 1093 let mut state = MenuState::default(); 1094 let mut focus = FocusManager::new(); 1095 let prev = HitState::new(); 1096 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1097 snap.keys_pressed.push(KeyEvent::new( 1098 KeyCode::Named(NamedKey::ArrowDown), 1099 ModifierMask::NONE, 1100 )); 1101 let _ = render(&items, &mut state, &mut focus, &mut snap, &prev); 1102 assert_eq!(state.highlighted, Some(2)); 1103 } 1104 1105 #[test] 1106 fn enter_with_highlighted_action_activates_it() { 1107 let items = vec![action("a"), action("b")]; 1108 let mut state = MenuState { 1109 highlighted: Some(1), 1110 ..MenuState::default() 1111 }; 1112 let mut focus = FocusManager::new(); 1113 let prev = HitState::new(); 1114 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1115 snap.keys_pressed.push(KeyEvent::new( 1116 KeyCode::Named(NamedKey::Enter), 1117 ModifierMask::NONE, 1118 )); 1119 let (response, _) = render(&items, &mut state, &mut focus, &mut snap, &prev); 1120 let MenuItem::Action { id, .. } = &items[1] else { 1121 panic!() 1122 }; 1123 assert_eq!(response.activated, Some(*id)); 1124 assert!(response.close); 1125 } 1126 1127 #[test] 1128 fn escape_closes_without_activating() { 1129 let items = vec![action("a")]; 1130 let mut state = MenuState::default(); 1131 let mut focus = FocusManager::new(); 1132 let prev = HitState::new(); 1133 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1134 snap.keys_pressed.push(KeyEvent::new( 1135 KeyCode::Named(NamedKey::Escape), 1136 ModifierMask::NONE, 1137 )); 1138 let (response, _) = render(&items, &mut state, &mut focus, &mut snap, &prev); 1139 assert!(response.activated.is_none()); 1140 assert!(response.close); 1141 } 1142 1143 fn bar_state() -> MenuBarState { 1144 MenuBarState::default() 1145 } 1146 1147 fn entry(name: &'static str) -> MenuBarEntry { 1148 let entry_id = menu_root().child(WidgetKey::new(name)); 1149 MenuBarEntry { 1150 id: entry_id, 1151 label: StringKey::new("menubar.entry"), 1152 items: vec![MenuItem::Action { 1153 id: entry_id.child(WidgetKey::new("first")), 1154 label: StringKey::new("menu.action"), 1155 shortcut: None, 1156 disabled: false, 1157 }], 1158 } 1159 } 1160 1161 #[test] 1162 fn click_menubar_entry_opens_its_menu() { 1163 let entries = vec![entry("file"), entry("edit")]; 1164 let mut state = bar_state(); 1165 let theme = Arc::new(Theme::light()); 1166 let table = HotkeyTable::new(); 1167 let mut focus = FocusManager::new(); 1168 let mut prev = HitState::new(); 1169 let click_pos = LayoutPos::new(LayoutPx::new(200.0), LayoutPx::new(10.0)); 1170 let bar_rect = LayoutRect::new( 1171 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1172 LayoutSize::new(LayoutPx::new(600.0), LayoutPx::new(24.0)), 1173 ); 1174 [press(click_pos), release(click_pos), idle(click_pos)] 1175 .into_iter() 1176 .for_each(|mut snap| { 1177 let mut hits = HitFrame::new(); 1178 { 1179 let mut shaper = bone_text::Shaper::new(); 1180 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1181 let mut ctx = FrameCtx::new( 1182 theme.clone(), 1183 &mut snap, 1184 &mut focus, 1185 &table, 1186 StringTable::empty(), 1187 &mut hits, 1188 &prev, 1189 &mut a11y, 1190 &mut shaper, 1191 ); 1192 let _ = show_menu_bar( 1193 &mut ctx, 1194 MenuBar::new( 1195 menu_bar_id(), 1196 bar_rect, 1197 StringKey::new("test.menu_bar"), 1198 &entries, 1199 &mut state, 1200 ), 1201 ); 1202 } 1203 prev = resolve(&prev, &hits, &snap, focus.focused()); 1204 }); 1205 assert_eq!(state.open, Some(entries[1].id)); 1206 } 1207 1208 #[test] 1209 fn arrow_down_on_focused_entry_opens_its_menu() { 1210 use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey}; 1211 1212 let entries = vec![entry("file"), entry("edit")]; 1213 let mut state = bar_state(); 1214 let theme = Arc::new(Theme::light()); 1215 let table = HotkeyTable::new(); 1216 let mut focus = FocusManager::new(); 1217 let bar_rect = LayoutRect::new( 1218 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1219 LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(24.0)), 1220 ); 1221 focus.request_focus(entries[0].id); 1222 let prev = HitState::new(); 1223 1224 [InputSnapshot::idle(FrameInstant::ZERO), { 1225 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 1226 s.keys_pressed.push(KeyEvent::new( 1227 KeyCode::Named(NamedKey::ArrowDown), 1228 ModifierMask::NONE, 1229 )); 1230 s 1231 }] 1232 .into_iter() 1233 .for_each(|mut snap| { 1234 let mut hits = HitFrame::new(); 1235 let mut shaper = bone_text::Shaper::new(); 1236 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1237 let mut ctx = FrameCtx::new( 1238 theme.clone(), 1239 &mut snap, 1240 &mut focus, 1241 &table, 1242 StringTable::empty(), 1243 &mut hits, 1244 &prev, 1245 &mut a11y, 1246 &mut shaper, 1247 ); 1248 let _ = show_menu_bar( 1249 &mut ctx, 1250 MenuBar::new( 1251 menu_bar_id(), 1252 bar_rect, 1253 StringKey::new("test.menu_bar"), 1254 &entries, 1255 &mut state, 1256 ), 1257 ); 1258 }); 1259 assert_eq!(state.open, Some(entries[0].id)); 1260 } 1261 1262 #[test] 1263 fn document_label_centers_in_the_bar() { 1264 use super::super::paint::{HorizontalAlign, LabelText, WidgetPaint}; 1265 1266 let entries = vec![entry("file"), entry("edit"), entry("view")]; 1267 let mut state = bar_state(); 1268 let theme = Arc::new(Theme::light()); 1269 let table = HotkeyTable::new(); 1270 let mut focus = FocusManager::new(); 1271 let bar_rect = LayoutRect::new( 1272 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1273 LayoutSize::new(LayoutPx::new(1920.0), LayoutPx::new(24.0)), 1274 ); 1275 let doc = "Part117 *"; 1276 let prev = HitState::new(); 1277 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1278 let mut hits = HitFrame::new(); 1279 let mut shaper = bone_text::Shaper::new(); 1280 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1281 let response = { 1282 let mut ctx = FrameCtx::new( 1283 theme.clone(), 1284 &mut snap, 1285 &mut focus, 1286 &table, 1287 StringTable::empty(), 1288 &mut hits, 1289 &prev, 1290 &mut a11y, 1291 &mut shaper, 1292 ); 1293 show_menu_bar( 1294 &mut ctx, 1295 MenuBar::new( 1296 menu_bar_id(), 1297 bar_rect, 1298 StringKey::new("test.menu_bar"), 1299 &entries, 1300 &mut state, 1301 ) 1302 .with_document_label(LabelText::Owned(doc.to_owned())), 1303 ) 1304 }; 1305 let found = response.paint.iter().find_map(|p| match p { 1306 WidgetPaint::AlignedLabel { 1307 rect, 1308 text: LabelText::Owned(t), 1309 align, 1310 .. 1311 } if t.as_str() == doc => Some((*rect, *align)), 1312 _ => None, 1313 }); 1314 let Some((rect, align)) = found else { 1315 panic!("document label is painted"); 1316 }; 1317 assert_eq!(align, HorizontalAlign::Center); 1318 let label_center = rect.origin.x.value() + rect.size.width.value() * 0.5; 1319 let bar_center = bar_rect.origin.x.value() + bar_rect.size.width.value() * 0.5; 1320 assert!( 1321 (label_center - bar_center).abs() <= 0.5, 1322 "document label center {label_center} expected bar center {bar_center}", 1323 ); 1324 } 1325}