Another project
1
fork

Configure Feed

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

feat(ui): menu, menu bar, context menu widgets

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

+1049
+1049
crates/bone-ui/src/widgets/menu.rs
··· 1 + use crate::frame::{FrameCtx, InteractDeclaration}; 2 + use crate::hit_test::{Sense, ZLayer}; 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(Clone, Debug, PartialEq)] 14 + pub enum MenuItem { 15 + Action { 16 + id: WidgetId, 17 + label: StringKey, 18 + shortcut: Option<StringKey>, 19 + disabled: bool, 20 + }, 21 + Submenu { 22 + id: WidgetId, 23 + label: StringKey, 24 + items: Vec<MenuItem>, 25 + }, 26 + Separator, 27 + } 28 + 29 + impl MenuItem { 30 + #[must_use] 31 + pub fn id(&self) -> Option<WidgetId> { 32 + match self { 33 + Self::Action { id, .. } | Self::Submenu { id, .. } => Some(*id), 34 + Self::Separator => None, 35 + } 36 + } 37 + 38 + #[must_use] 39 + pub fn is_focusable(&self) -> bool { 40 + match self { 41 + Self::Action { disabled, .. } => !*disabled, 42 + Self::Submenu { .. } => true, 43 + Self::Separator => false, 44 + } 45 + } 46 + } 47 + 48 + #[derive(Clone, Debug, Default, PartialEq, Eq)] 49 + pub struct MenuState { 50 + pub open_submenu: Option<WidgetId>, 51 + pub highlighted: Option<usize>, 52 + pub submenu: Option<Box<MenuState>>, 53 + } 54 + 55 + #[derive(Copy, Clone, Debug, PartialEq)] 56 + pub struct MenuMetrics { 57 + pub item_height: LayoutPx, 58 + pub separator_height: LayoutPx, 59 + pub padding_x: LayoutPx, 60 + pub min_width: LayoutPx, 61 + pub shortcut_gap: LayoutPx, 62 + } 63 + 64 + impl MenuMetrics { 65 + #[must_use] 66 + pub const fn standard() -> Self { 67 + Self { 68 + item_height: LayoutPx::new(24.0), 69 + separator_height: LayoutPx::new(7.0), 70 + padding_x: LayoutPx::new(10.0), 71 + min_width: LayoutPx::new(180.0), 72 + shortcut_gap: LayoutPx::new(24.0), 73 + } 74 + } 75 + } 76 + 77 + #[derive(Debug, PartialEq)] 78 + pub struct Menu<'a, 'state> { 79 + pub id: WidgetId, 80 + pub origin: LayoutPos, 81 + pub items: &'a [MenuItem], 82 + pub metrics: MenuMetrics, 83 + pub state: &'state mut MenuState, 84 + } 85 + 86 + impl<'a, 'state> Menu<'a, 'state> { 87 + #[must_use] 88 + pub fn new( 89 + id: WidgetId, 90 + origin: LayoutPos, 91 + items: &'a [MenuItem], 92 + state: &'state mut MenuState, 93 + ) -> Self { 94 + Self { 95 + id, 96 + origin, 97 + items, 98 + metrics: MenuMetrics::standard(), 99 + state, 100 + } 101 + } 102 + 103 + #[must_use] 104 + pub fn metrics(self, metrics: MenuMetrics) -> Self { 105 + Self { metrics, ..self } 106 + } 107 + } 108 + 109 + #[derive(Clone, Debug, PartialEq)] 110 + pub struct MenuResponse { 111 + pub activated: Option<WidgetId>, 112 + pub close: bool, 113 + pub paint: Vec<WidgetPaint>, 114 + pub rect: LayoutRect, 115 + } 116 + 117 + #[must_use] 118 + pub fn show_menu(ctx: &mut FrameCtx<'_>, menu: Menu<'_, '_>) -> MenuResponse { 119 + let Menu { 120 + id, 121 + origin, 122 + items, 123 + metrics, 124 + state, 125 + } = menu; 126 + let rect = menu_rect(origin, items, metrics); 127 + let mut paint = vec![WidgetPaint::Surface { 128 + rect, 129 + fill: ctx.theme.colors.surface(crate::theme::SurfaceLevel::L1), 130 + border: Some(Border { 131 + width: StrokeWidth::HAIRLINE, 132 + color: ctx.theme.colors.neutral.step(Step12::BORDER), 133 + }), 134 + radius: ctx.theme.radius.sm, 135 + elevation: Some(ctx.theme.elevation.level1), 136 + }]; 137 + let layouts = item_rects(rect, items, metrics); 138 + let mut activated: Option<WidgetId> = None; 139 + let mut close = false; 140 + let prev_open = state.open_submenu; 141 + let snap_open = state.open_submenu; 142 + items 143 + .iter() 144 + .zip(layouts.iter()) 145 + .enumerate() 146 + .for_each(|(idx, (item, item_rect))| { 147 + let is_highlighted = state.highlighted == Some(idx); 148 + let item_result = draw_item( 149 + ctx, 150 + ItemDrawArgs { 151 + item, 152 + rect: *item_rect, 153 + metrics, 154 + is_highlighted, 155 + is_open_submenu: matches!(item, MenuItem::Submenu { id: sid, .. } if snap_open == Some(*sid)), 156 + }, 157 + ); 158 + paint.extend(item_result.paint); 159 + if item_result.hovered { 160 + state.highlighted = Some(idx); 161 + } 162 + if let Some(activate) = item_result.activated { 163 + if activated.is_none() { 164 + activated = Some(activate); 165 + } 166 + close = true; 167 + } 168 + if let Some(sid) = item_result.opened_submenu { 169 + state.open_submenu = Some(sid); 170 + } 171 + }); 172 + if state.open_submenu != prev_open { 173 + state.submenu = None; 174 + } 175 + render_open_submenu( 176 + ctx, 177 + SubmenuArgs { 178 + parent_id: id, 179 + items, 180 + layouts: &layouts, 181 + metrics, 182 + state, 183 + }, 184 + &mut paint, 185 + &mut activated, 186 + &mut close, 187 + ); 188 + handle_keyboard(ctx, items, state, &mut activated, &mut close); 189 + MenuResponse { 190 + activated, 191 + close, 192 + paint, 193 + rect, 194 + } 195 + } 196 + 197 + struct SubmenuArgs<'a, 'state> { 198 + parent_id: WidgetId, 199 + items: &'a [MenuItem], 200 + layouts: &'a [LayoutRect], 201 + metrics: MenuMetrics, 202 + state: &'state mut MenuState, 203 + } 204 + 205 + fn render_open_submenu( 206 + ctx: &mut FrameCtx<'_>, 207 + args: SubmenuArgs<'_, '_>, 208 + paint: &mut Vec<WidgetPaint>, 209 + activated: &mut Option<WidgetId>, 210 + close: &mut bool, 211 + ) { 212 + let SubmenuArgs { 213 + parent_id, 214 + items, 215 + layouts, 216 + metrics, 217 + state, 218 + } = args; 219 + let Some(sid) = state.open_submenu else { 220 + return; 221 + }; 222 + let active = items.iter().zip(layouts.iter()).find_map(|(item, rect)| { 223 + matches!(item, MenuItem::Submenu { id: iid, .. } if *iid == sid).then(|| match item { 224 + MenuItem::Submenu { items: sub, .. } => (*rect, sub.as_slice()), 225 + _ => unreachable!(), 226 + }) 227 + }); 228 + let Some((item_rect, subitems)) = active else { 229 + state.open_submenu = None; 230 + state.submenu = None; 231 + return; 232 + }; 233 + let sub_origin = LayoutPos::new( 234 + LayoutPx::new(item_rect.origin.x.value() + item_rect.size.width.value()), 235 + item_rect.origin.y, 236 + ); 237 + let sub_id = parent_id.child(WidgetKey::new("submenu")); 238 + let sub_state = state 239 + .submenu 240 + .get_or_insert_with(|| Box::new(MenuState::default())); 241 + let sub_response = show_menu( 242 + ctx, 243 + Menu::new(sub_id, sub_origin, subitems, sub_state).metrics(metrics), 244 + ); 245 + paint.extend(sub_response.paint); 246 + if let Some(a) = sub_response.activated { 247 + if activated.is_none() { 248 + *activated = Some(a); 249 + } 250 + *close = true; 251 + } else if sub_response.close { 252 + state.open_submenu = None; 253 + state.submenu = None; 254 + } 255 + } 256 + 257 + #[derive(Copy, Clone)] 258 + struct ItemDrawArgs<'a> { 259 + item: &'a MenuItem, 260 + rect: LayoutRect, 261 + metrics: MenuMetrics, 262 + is_highlighted: bool, 263 + is_open_submenu: bool, 264 + } 265 + 266 + struct ItemDrawResult { 267 + paint: Vec<WidgetPaint>, 268 + hovered: bool, 269 + activated: Option<WidgetId>, 270 + opened_submenu: Option<WidgetId>, 271 + } 272 + 273 + fn draw_item(ctx: &mut FrameCtx<'_>, args: ItemDrawArgs<'_>) -> ItemDrawResult { 274 + match args.item { 275 + MenuItem::Separator => ItemDrawResult { 276 + paint: separator_paint(ctx, args.rect, args.metrics), 277 + hovered: false, 278 + activated: None, 279 + opened_submenu: None, 280 + }, 281 + MenuItem::Action { 282 + id, 283 + label, 284 + shortcut, 285 + disabled, 286 + } => { 287 + let interaction = ctx.interact( 288 + InteractDeclaration::new(*id, args.rect, Sense::INTERACTIVE) 289 + .at_z(ZLayer::POPUP) 290 + .focusable(false) 291 + .disabled(*disabled), 292 + ); 293 + let activated = (!*disabled && interaction.click()).then_some(*id); 294 + let mut paint = 295 + item_surface(ctx, args.rect, args.is_highlighted || interaction.hover()); 296 + paint.push(WidgetPaint::Label { 297 + rect: label_only_rect(args.rect, args.metrics), 298 + text: LabelText::Key(*label), 299 + color: if *disabled { 300 + ctx.theme.colors.text_disabled() 301 + } else { 302 + ctx.theme.colors.text_primary() 303 + }, 304 + role: ctx.theme.typography.body, 305 + }); 306 + if let Some(sc) = shortcut { 307 + paint.push(WidgetPaint::Label { 308 + rect: shortcut_rect(args.rect, args.metrics), 309 + text: LabelText::Key(*sc), 310 + color: ctx.theme.colors.text_secondary(), 311 + role: ctx.theme.typography.caption, 312 + }); 313 + } 314 + ItemDrawResult { 315 + paint, 316 + hovered: interaction.hover(), 317 + activated, 318 + opened_submenu: None, 319 + } 320 + } 321 + MenuItem::Submenu { id, label, .. } => { 322 + let interaction = ctx.interact( 323 + InteractDeclaration::new(*id, args.rect, Sense::INTERACTIVE) 324 + .at_z(ZLayer::POPUP) 325 + .focusable(false) 326 + .active(args.is_open_submenu), 327 + ); 328 + let opened = (interaction.click() || (interaction.hover() && !args.is_open_submenu)) 329 + .then_some(*id); 330 + let mut paint = item_surface( 331 + ctx, 332 + args.rect, 333 + args.is_highlighted || interaction.hover() || args.is_open_submenu, 334 + ); 335 + paint.push(WidgetPaint::Label { 336 + rect: label_only_rect(args.rect, args.metrics), 337 + text: LabelText::Key(*label), 338 + color: ctx.theme.colors.text_primary(), 339 + role: ctx.theme.typography.body, 340 + }); 341 + paint.push(WidgetPaint::Mark { 342 + rect: arrow_rect(args.rect, args.metrics), 343 + kind: GlyphMark::SubmenuArrow, 344 + color: ctx.theme.colors.text_secondary(), 345 + }); 346 + ItemDrawResult { 347 + paint, 348 + hovered: interaction.hover(), 349 + activated: None, 350 + opened_submenu: opened, 351 + } 352 + } 353 + } 354 + } 355 + 356 + fn item_surface(ctx: &FrameCtx<'_>, rect: LayoutRect, highlighted: bool) -> Vec<WidgetPaint> { 357 + if !highlighted { 358 + return Vec::new(); 359 + } 360 + vec![WidgetPaint::Surface { 361 + rect, 362 + fill: ctx.theme.colors.accent.step(Step12::HOVER_BG), 363 + border: None, 364 + radius: ctx.theme.radius.sm, 365 + elevation: None, 366 + }] 367 + } 368 + 369 + fn separator_paint(ctx: &FrameCtx<'_>, rect: LayoutRect, metrics: MenuMetrics) -> Vec<WidgetPaint> { 370 + let line_y = rect.origin.y.value() + metrics.separator_height.value() / 2.0 - 0.5; 371 + let line_rect = LayoutRect::new( 372 + LayoutPos::new( 373 + LayoutPx::new(rect.origin.x.value() + metrics.padding_x.value()), 374 + LayoutPx::new(line_y), 375 + ), 376 + LayoutSize::new( 377 + LayoutPx::saturating_nonneg(rect.size.width.value() - 2.0 * metrics.padding_x.value()), 378 + LayoutPx::new(1.0), 379 + ), 380 + ); 381 + vec![WidgetPaint::Surface { 382 + rect: line_rect, 383 + fill: ctx.theme.colors.neutral.step(Step12::SUBTLE_BORDER), 384 + border: None, 385 + radius: ctx.theme.radius.none, 386 + elevation: None, 387 + }] 388 + } 389 + 390 + fn label_only_rect(item: LayoutRect, metrics: MenuMetrics) -> LayoutRect { 391 + LayoutRect::new( 392 + LayoutPos::new( 393 + LayoutPx::new(item.origin.x.value() + metrics.padding_x.value()), 394 + item.origin.y, 395 + ), 396 + LayoutSize::new( 397 + LayoutPx::saturating_nonneg(item.size.width.value() - 2.0 * metrics.padding_x.value()), 398 + item.size.height, 399 + ), 400 + ) 401 + } 402 + 403 + fn shortcut_rect(item: LayoutRect, metrics: MenuMetrics) -> LayoutRect { 404 + let half = item.size.width.value() / 2.0; 405 + LayoutRect::new( 406 + LayoutPos::new(LayoutPx::new(item.origin.x.value() + half), item.origin.y), 407 + LayoutSize::new( 408 + LayoutPx::saturating_nonneg(half - metrics.padding_x.value()), 409 + item.size.height, 410 + ), 411 + ) 412 + } 413 + 414 + fn arrow_rect(item: LayoutRect, metrics: MenuMetrics) -> LayoutRect { 415 + let arrow_px = 12.0; 416 + let pad = (item.size.height.value() - arrow_px).max(0.0) / 2.0; 417 + LayoutRect::new( 418 + LayoutPos::new( 419 + LayoutPx::new( 420 + item.origin.x.value() + item.size.width.value() 421 + - arrow_px 422 + - metrics.padding_x.value(), 423 + ), 424 + LayoutPx::new(item.origin.y.value() + pad), 425 + ), 426 + LayoutSize::new(LayoutPx::new(arrow_px), LayoutPx::new(arrow_px)), 427 + ) 428 + } 429 + 430 + fn menu_rect(origin: LayoutPos, items: &[MenuItem], metrics: MenuMetrics) -> LayoutRect { 431 + let height: f32 = items 432 + .iter() 433 + .map(|i| match i { 434 + MenuItem::Separator => metrics.separator_height.value(), 435 + _ => metrics.item_height.value(), 436 + }) 437 + .sum(); 438 + LayoutRect::new( 439 + origin, 440 + LayoutSize::new(metrics.min_width, LayoutPx::new(height)), 441 + ) 442 + } 443 + 444 + fn item_rects(menu: LayoutRect, items: &[MenuItem], metrics: MenuMetrics) -> Vec<LayoutRect> { 445 + items 446 + .iter() 447 + .scan(menu.origin.y.value(), |y, item| { 448 + let h = match item { 449 + MenuItem::Separator => metrics.separator_height.value(), 450 + _ => metrics.item_height.value(), 451 + }; 452 + let rect = LayoutRect::new( 453 + LayoutPos::new(menu.origin.x, LayoutPx::new(*y)), 454 + LayoutSize::new(menu.size.width, LayoutPx::new(h)), 455 + ); 456 + *y += h; 457 + Some(rect) 458 + }) 459 + .collect() 460 + } 461 + 462 + fn handle_keyboard( 463 + ctx: &mut FrameCtx<'_>, 464 + items: &[MenuItem], 465 + state: &mut MenuState, 466 + activated: &mut Option<WidgetId>, 467 + close: &mut bool, 468 + ) { 469 + let event = take_key( 470 + ctx.input, 471 + &[ 472 + TakeKey::named(NamedKey::ArrowUp), 473 + TakeKey::named(NamedKey::ArrowDown), 474 + TakeKey::named(NamedKey::Enter), 475 + TakeKey::named(NamedKey::Space), 476 + TakeKey::named(NamedKey::Escape), 477 + TakeKey::named(NamedKey::Home), 478 + TakeKey::named(NamedKey::End), 479 + ], 480 + ); 481 + let Some(event) = event else { return }; 482 + match event.code { 483 + KeyCode::Named(NamedKey::Escape) => { 484 + *close = true; 485 + } 486 + KeyCode::Named(NamedKey::ArrowDown) => { 487 + state.highlighted = next_focusable(items, state.highlighted, false); 488 + } 489 + KeyCode::Named(NamedKey::ArrowUp) => { 490 + state.highlighted = next_focusable(items, state.highlighted, true); 491 + } 492 + KeyCode::Named(NamedKey::Home) => { 493 + state.highlighted = first_focusable(items); 494 + } 495 + KeyCode::Named(NamedKey::End) => { 496 + state.highlighted = last_focusable(items); 497 + } 498 + KeyCode::Named(NamedKey::Enter | NamedKey::Space) => { 499 + if let Some(idx) = state.highlighted 500 + && let Some(MenuItem::Action { id, disabled, .. }) = items.get(idx) 501 + && !*disabled 502 + { 503 + *activated = Some(*id); 504 + *close = true; 505 + } 506 + } 507 + KeyCode::Named(_) | KeyCode::Char(_) => {} 508 + } 509 + } 510 + 511 + fn first_focusable(items: &[MenuItem]) -> Option<usize> { 512 + items.iter().position(MenuItem::is_focusable) 513 + } 514 + 515 + fn last_focusable(items: &[MenuItem]) -> Option<usize> { 516 + items 517 + .iter() 518 + .enumerate() 519 + .rev() 520 + .find(|(_, i)| i.is_focusable()) 521 + .map(|(idx, _)| idx) 522 + } 523 + 524 + fn next_focusable(items: &[MenuItem], from: Option<usize>, reverse: bool) -> Option<usize> { 525 + if items.is_empty() { 526 + return None; 527 + } 528 + let len = items.len(); 529 + let start = from.unwrap_or(if reverse { 0 } else { len - 1 }); 530 + (1..=len) 531 + .find_map(|delta| { 532 + let idx = if reverse { 533 + (start + len - delta) % len 534 + } else { 535 + (start + delta) % len 536 + }; 537 + items[idx].is_focusable().then_some(idx) 538 + }) 539 + .or(from) 540 + } 541 + 542 + #[derive(Debug, PartialEq)] 543 + pub struct ContextMenu<'a, 'state> { 544 + pub id: WidgetId, 545 + pub anchor: LayoutPos, 546 + pub items: &'a [MenuItem], 547 + pub state: &'state mut MenuState, 548 + pub metrics: MenuMetrics, 549 + } 550 + 551 + impl<'a, 'state> ContextMenu<'a, 'state> { 552 + #[must_use] 553 + pub fn at_cursor( 554 + id: WidgetId, 555 + anchor: LayoutPos, 556 + items: &'a [MenuItem], 557 + state: &'state mut MenuState, 558 + ) -> Self { 559 + Self { 560 + id, 561 + anchor, 562 + items, 563 + state, 564 + metrics: MenuMetrics::standard(), 565 + } 566 + } 567 + } 568 + 569 + #[must_use] 570 + pub fn show_context_menu(ctx: &mut FrameCtx<'_>, menu: ContextMenu<'_, '_>) -> MenuResponse { 571 + let ContextMenu { 572 + id, 573 + anchor, 574 + items, 575 + state, 576 + metrics, 577 + } = menu; 578 + show_menu( 579 + ctx, 580 + Menu { 581 + id, 582 + origin: anchor, 583 + items, 584 + metrics, 585 + state, 586 + }, 587 + ) 588 + } 589 + 590 + #[derive(Clone, Debug, PartialEq)] 591 + pub struct MenuBarEntry { 592 + pub id: WidgetId, 593 + pub label: StringKey, 594 + pub items: Vec<MenuItem>, 595 + } 596 + 597 + #[derive(Clone, Debug, Default, PartialEq, Eq)] 598 + pub struct MenuBarState { 599 + pub open: Option<WidgetId>, 600 + pub menu: MenuState, 601 + } 602 + 603 + #[derive(Debug, PartialEq)] 604 + pub struct MenuBar<'a, 'state> { 605 + pub rect: LayoutRect, 606 + pub entries: &'a [MenuBarEntry], 607 + pub state: &'state mut MenuBarState, 608 + pub item_width: LayoutPx, 609 + pub item_padding: LayoutPx, 610 + } 611 + 612 + impl<'a, 'state> MenuBar<'a, 'state> { 613 + #[must_use] 614 + pub const fn new( 615 + rect: LayoutRect, 616 + entries: &'a [MenuBarEntry], 617 + state: &'state mut MenuBarState, 618 + ) -> Self { 619 + Self { 620 + rect, 621 + entries, 622 + state, 623 + item_width: LayoutPx::new(56.0), 624 + item_padding: LayoutPx::new(10.0), 625 + } 626 + } 627 + } 628 + 629 + #[derive(Clone, Debug, PartialEq)] 630 + pub struct MenuBarResponse { 631 + pub activated: Option<WidgetId>, 632 + pub paint: Vec<WidgetPaint>, 633 + } 634 + 635 + #[must_use] 636 + pub fn show_menu_bar(ctx: &mut FrameCtx<'_>, bar: MenuBar<'_, '_>) -> MenuBarResponse { 637 + let MenuBar { 638 + rect, 639 + entries, 640 + state, 641 + item_width, 642 + item_padding, 643 + } = bar; 644 + let mut paint = vec![WidgetPaint::Surface { 645 + rect, 646 + fill: ctx.theme.colors.surface(crate::theme::SurfaceLevel::L0), 647 + border: Some(Border { 648 + width: StrokeWidth::HAIRLINE, 649 + color: ctx.theme.colors.neutral.step(Step12::SUBTLE_BORDER), 650 + }), 651 + radius: ctx.theme.radius.none, 652 + elevation: None, 653 + }]; 654 + let entry_layouts = entry_rects(rect, entries, item_width); 655 + entries 656 + .iter() 657 + .zip(entry_layouts.iter()) 658 + .for_each(|(entry, entry_rect)| { 659 + paint.extend(draw_menu_bar_entry( 660 + ctx, 661 + entry, 662 + *entry_rect, 663 + item_padding, 664 + state, 665 + )); 666 + }); 667 + let activated = open_menu_bar_dropdown(ctx, entries, &entry_layouts, state, &mut paint); 668 + MenuBarResponse { activated, paint } 669 + } 670 + 671 + fn draw_menu_bar_entry( 672 + ctx: &mut FrameCtx<'_>, 673 + entry: &MenuBarEntry, 674 + entry_rect: LayoutRect, 675 + item_padding: LayoutPx, 676 + state: &mut MenuBarState, 677 + ) -> Vec<WidgetPaint> { 678 + let is_open = state.open == Some(entry.id); 679 + let interaction = ctx.interact( 680 + InteractDeclaration::new(entry.id, entry_rect, Sense::INTERACTIVE) 681 + .focusable(true) 682 + .active(is_open), 683 + ); 684 + let live_focused = ctx.is_focused(entry.id); 685 + let pointer_toggled = interaction.click(); 686 + let key_toggled = live_focused 687 + && take_key( 688 + ctx.input, 689 + &[ 690 + TakeKey::named(NamedKey::Enter), 691 + TakeKey::named(NamedKey::Space), 692 + TakeKey::named(NamedKey::ArrowDown), 693 + ], 694 + ) 695 + .is_some(); 696 + if pointer_toggled || key_toggled { 697 + state.open = if is_open { None } else { Some(entry.id) }; 698 + state.menu = MenuState::default(); 699 + } else if state.open.is_some() && !is_open && interaction.hover() { 700 + state.open = Some(entry.id); 701 + state.menu = MenuState::default(); 702 + } 703 + let mut paint = vec![ 704 + WidgetPaint::Surface { 705 + rect: entry_rect, 706 + fill: if is_open { 707 + ctx.theme.colors.neutral.step(Step12::SELECTED_BG) 708 + } else if interaction.hover() { 709 + ctx.theme.colors.neutral.step(Step12::HOVER_BG) 710 + } else { 711 + Color::TRANSPARENT 712 + }, 713 + border: None, 714 + radius: ctx.theme.radius.sm, 715 + elevation: None, 716 + }, 717 + WidgetPaint::Label { 718 + rect: LayoutRect::new( 719 + LayoutPos::new( 720 + LayoutPx::new(entry_rect.origin.x.value() + item_padding.value()), 721 + entry_rect.origin.y, 722 + ), 723 + LayoutSize::new( 724 + LayoutPx::saturating_nonneg( 725 + entry_rect.size.width.value() - 2.0 * item_padding.value(), 726 + ), 727 + entry_rect.size.height, 728 + ), 729 + ), 730 + text: LabelText::Key(entry.label), 731 + color: ctx.theme.colors.text_primary(), 732 + role: ctx.theme.typography.label, 733 + }, 734 + ]; 735 + push_focus_ring( 736 + ctx, 737 + &mut paint, 738 + entry_rect, 739 + ctx.theme.radius.sm, 740 + live_focused, 741 + ); 742 + paint 743 + } 744 + 745 + fn open_menu_bar_dropdown( 746 + ctx: &mut FrameCtx<'_>, 747 + entries: &[MenuBarEntry], 748 + entry_layouts: &[LayoutRect], 749 + state: &mut MenuBarState, 750 + paint: &mut Vec<WidgetPaint>, 751 + ) -> Option<WidgetId> { 752 + let open_id = state.open?; 753 + let (entry, entry_rect) = entries 754 + .iter() 755 + .zip(entry_layouts.iter()) 756 + .find(|(e, _)| e.id == open_id)?; 757 + let menu_origin = LayoutPos::new( 758 + entry_rect.origin.x, 759 + LayoutPx::new(entry_rect.origin.y.value() + entry_rect.size.height.value()), 760 + ); 761 + let response = show_menu( 762 + ctx, 763 + Menu::new( 764 + entry.id.child(WidgetKey::new("menu")), 765 + menu_origin, 766 + &entry.items, 767 + &mut state.menu, 768 + ), 769 + ); 770 + paint.extend(response.paint); 771 + if response.close { 772 + state.open = None; 773 + state.menu = MenuState::default(); 774 + } 775 + response.activated 776 + } 777 + 778 + fn entry_rects(bar: LayoutRect, entries: &[MenuBarEntry], item_width: LayoutPx) -> Vec<LayoutRect> { 779 + entries 780 + .iter() 781 + .enumerate() 782 + .map(|(idx, _)| { 783 + #[allow( 784 + clippy::cast_precision_loss, 785 + reason = "menu bar entries fit in f32 mantissa" 786 + )] 787 + let i = idx as f32; 788 + LayoutRect::new( 789 + LayoutPos::new( 790 + LayoutPx::new(bar.origin.x.value() + i * item_width.value()), 791 + bar.origin.y, 792 + ), 793 + LayoutSize::new(item_width, bar.size.height), 794 + ) 795 + }) 796 + .collect() 797 + } 798 + 799 + #[cfg(test)] 800 + mod tests { 801 + use std::sync::Arc; 802 + 803 + use super::{ 804 + Menu, MenuBar, MenuBarEntry, MenuBarState, MenuItem, MenuState, show_menu, show_menu_bar, 805 + }; 806 + use crate::focus::FocusManager; 807 + use crate::frame::FrameCtx; 808 + use crate::hit_test::{HitFrame, HitState, resolve}; 809 + use crate::hotkey::HotkeyTable; 810 + use crate::input::{ 811 + FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, 812 + PointerButtonMask, PointerSample, 813 + }; 814 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 815 + use crate::strings::{StringKey, StringTable}; 816 + use crate::theme::Theme; 817 + use crate::widget_id::{WidgetId, WidgetKey}; 818 + 819 + fn menu_root() -> WidgetId { 820 + WidgetId::ROOT.child(WidgetKey::new("menu")) 821 + } 822 + 823 + fn action(name: &'static str) -> MenuItem { 824 + MenuItem::Action { 825 + id: menu_root().child(WidgetKey::new(name)), 826 + label: StringKey::new("menu.action"), 827 + shortcut: Some(StringKey::new("menu.shortcut")), 828 + disabled: false, 829 + } 830 + } 831 + 832 + fn disabled_action(name: &'static str) -> MenuItem { 833 + match action(name) { 834 + MenuItem::Action { 835 + id, 836 + label, 837 + shortcut, 838 + .. 839 + } => MenuItem::Action { 840 + id, 841 + label, 842 + shortcut, 843 + disabled: true, 844 + }, 845 + _ => unreachable!(), 846 + } 847 + } 848 + 849 + fn render( 850 + items: &[MenuItem], 851 + state: &mut MenuState, 852 + focus: &mut FocusManager, 853 + snap: &mut InputSnapshot, 854 + prev: &HitState, 855 + ) -> (super::MenuResponse, HitState) { 856 + let theme = Arc::new(Theme::light()); 857 + let table = HotkeyTable::new(); 858 + let mut hits = HitFrame::new(); 859 + let response = { 860 + let mut ctx = FrameCtx::new( 861 + theme, 862 + snap, 863 + focus, 864 + &table, 865 + StringTable::empty(), 866 + &mut hits, 867 + prev, 868 + ); 869 + show_menu( 870 + &mut ctx, 871 + Menu::new(menu_root(), LayoutPos::ORIGIN, items, state), 872 + ) 873 + }; 874 + let next = resolve(prev, &hits, snap, focus.focused()); 875 + (response, next) 876 + } 877 + 878 + fn press(pos: LayoutPos) -> InputSnapshot { 879 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 880 + s.pointer = Some(PointerSample::new(pos)); 881 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 882 + s 883 + } 884 + 885 + fn release(pos: LayoutPos) -> InputSnapshot { 886 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 887 + s.pointer = Some(PointerSample::new(pos)); 888 + s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 889 + s 890 + } 891 + 892 + fn idle(pos: LayoutPos) -> InputSnapshot { 893 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 894 + s.pointer = Some(PointerSample::new(pos)); 895 + s 896 + } 897 + 898 + #[test] 899 + fn click_action_item_activates() { 900 + let items = vec![action("save"), action("save_as")]; 901 + let mut state = MenuState::default(); 902 + let mut focus = FocusManager::new(); 903 + let mut prev = HitState::new(); 904 + let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(36.0)); 905 + let mut last_response: Option<super::MenuResponse> = None; 906 + [press(click_pos), release(click_pos), idle(click_pos)] 907 + .into_iter() 908 + .for_each(|mut snap| { 909 + let (response, next) = render(&items, &mut state, &mut focus, &mut snap, &prev); 910 + last_response = Some(response); 911 + prev = next; 912 + }); 913 + let Some(r) = last_response else { 914 + panic!("response missing") 915 + }; 916 + let MenuItem::Action { id: target_id, .. } = &items[1] else { 917 + panic!("expected action"); 918 + }; 919 + assert_eq!(r.activated, Some(*target_id)); 920 + assert!(r.close); 921 + } 922 + 923 + #[test] 924 + fn disabled_action_does_not_activate() { 925 + let items = vec![disabled_action("save")]; 926 + let mut state = MenuState::default(); 927 + let mut focus = FocusManager::new(); 928 + let mut prev = HitState::new(); 929 + let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(12.0)); 930 + [press(click_pos), release(click_pos), idle(click_pos)] 931 + .into_iter() 932 + .for_each(|mut snap| { 933 + let (response, next) = render(&items, &mut state, &mut focus, &mut snap, &prev); 934 + assert!(response.activated.is_none()); 935 + assert!(!response.close); 936 + prev = next; 937 + }); 938 + } 939 + 940 + #[test] 941 + fn arrow_down_skips_separator_and_disabled() { 942 + let items = vec![ 943 + disabled_action("a"), 944 + MenuItem::Separator, 945 + action("b"), 946 + action("c"), 947 + ]; 948 + let mut state = MenuState::default(); 949 + let mut focus = FocusManager::new(); 950 + let prev = HitState::new(); 951 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 952 + snap.keys_pressed.push(KeyEvent::new( 953 + KeyCode::Named(NamedKey::ArrowDown), 954 + ModifierMask::NONE, 955 + )); 956 + let _ = render(&items, &mut state, &mut focus, &mut snap, &prev); 957 + assert_eq!(state.highlighted, Some(2)); 958 + } 959 + 960 + #[test] 961 + fn enter_with_highlighted_action_activates_it() { 962 + let items = vec![action("a"), action("b")]; 963 + let mut state = MenuState { 964 + highlighted: Some(1), 965 + ..MenuState::default() 966 + }; 967 + let mut focus = FocusManager::new(); 968 + let prev = HitState::new(); 969 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 970 + snap.keys_pressed.push(KeyEvent::new( 971 + KeyCode::Named(NamedKey::Enter), 972 + ModifierMask::NONE, 973 + )); 974 + let (response, _) = render(&items, &mut state, &mut focus, &mut snap, &prev); 975 + let MenuItem::Action { id, .. } = &items[1] else { 976 + panic!() 977 + }; 978 + assert_eq!(response.activated, Some(*id)); 979 + assert!(response.close); 980 + } 981 + 982 + #[test] 983 + fn escape_closes_without_activating() { 984 + let items = vec![action("a")]; 985 + let mut state = MenuState::default(); 986 + let mut focus = FocusManager::new(); 987 + let prev = HitState::new(); 988 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 989 + snap.keys_pressed.push(KeyEvent::new( 990 + KeyCode::Named(NamedKey::Escape), 991 + ModifierMask::NONE, 992 + )); 993 + let (response, _) = render(&items, &mut state, &mut focus, &mut snap, &prev); 994 + assert!(response.activated.is_none()); 995 + assert!(response.close); 996 + } 997 + 998 + fn bar_state() -> MenuBarState { 999 + MenuBarState::default() 1000 + } 1001 + 1002 + fn entry(name: &'static str) -> MenuBarEntry { 1003 + let entry_id = menu_root().child(WidgetKey::new(name)); 1004 + MenuBarEntry { 1005 + id: entry_id, 1006 + label: StringKey::new("menubar.entry"), 1007 + items: vec![MenuItem::Action { 1008 + id: entry_id.child(WidgetKey::new("first")), 1009 + label: StringKey::new("menu.action"), 1010 + shortcut: None, 1011 + disabled: false, 1012 + }], 1013 + } 1014 + } 1015 + 1016 + #[test] 1017 + fn click_menubar_entry_opens_its_menu() { 1018 + let entries = vec![entry("file"), entry("edit")]; 1019 + let mut state = bar_state(); 1020 + let theme = Arc::new(Theme::light()); 1021 + let table = HotkeyTable::new(); 1022 + let mut focus = FocusManager::new(); 1023 + let mut prev = HitState::new(); 1024 + let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(10.0)); 1025 + let bar_rect = LayoutRect::new( 1026 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1027 + LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(24.0)), 1028 + ); 1029 + [press(click_pos), release(click_pos), idle(click_pos)] 1030 + .into_iter() 1031 + .for_each(|mut snap| { 1032 + let mut hits = HitFrame::new(); 1033 + { 1034 + let mut ctx = FrameCtx::new( 1035 + theme.clone(), 1036 + &mut snap, 1037 + &mut focus, 1038 + &table, 1039 + StringTable::empty(), 1040 + &mut hits, 1041 + &prev, 1042 + ); 1043 + let _ = show_menu_bar(&mut ctx, MenuBar::new(bar_rect, &entries, &mut state)); 1044 + } 1045 + prev = resolve(&prev, &hits, &snap, focus.focused()); 1046 + }); 1047 + assert_eq!(state.open, Some(entries[1].id)); 1048 + } 1049 + }