//! The menu family: a bar of titles, the popups they open, and the rows //! inside them. //! //! Five widgets, one shape. A `menu-bar` holds `menu` titles; a `menu` is a //! title (in a bar) or a submenu row (in a menu), and owns the *deferred //! content* of the popup it opens; a `menu-panel` is that popup, built by the //! tree when the menu opens; `menu-item` and `menu-separator` are the rows. //! The compiler enforces that shape (a descriptor's `required_parents`), which //! is what lets a menu know at construction whether it is a title or a //! submenu row rather than sniffing its parent at runtime. //! //! **Nothing here opens an overlay.** A widget cannot reach the tree, and //! menus need the tree's overlay stack, its focus, and its structural //! knowledge. So these widgets *report* — a clicked title emits //! [`EventData::MenuOpen`], a panel's arrow key emits //! [`EventData::MenuNav`] — and the tree acts. It is the same division a //! button draws when its Enter becomes `EventData::Activated`. //! //! Highlight is not focus. The panel holds focus while it is open and moves a //! highlighted row within itself, so arrowing through a menu costs no focus //! traffic and leaves the focus to be restored when the menu closes. A row's //! highlight is its own state, so it selects between token variants //! (`background` vs `background-highlighted`) exactly as a checkbox selects //! between `box-fill` and `box-fill-checked` — there is no `:highlighted` //! theme selector, for the same reason there is no `:checked` one. use guiduck_scene::Fragment; use guiduck_scene::geom::{Point, Rect, Size}; use guiduck_scene::paint::{Brush, Color}; use parley::{Alignment, AlignmentOptions, Layout, StyleProperty}; use taffy::AvailableSpace; use std::cell::Cell; use std::rc::Rc; use super::overlay::{AnchorSide, OverlayOptions}; use super::{CursorShape, Widget, WidgetId, WidgetTree}; use crate::content::ContentBuilder; use crate::dirty::Dirt; use crate::event::{EventData, EventKind, Key, KeyInput, PointerEvent}; use crate::graphic::{Graphic, GraphicPaint}; use crate::signals::Scope; use crate::style::{ComputedStyle, StyleReader}; use crate::text::TextContext; /// Which way a keystroke moves the highlight, or steps through the stack. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum MenuNav { Previous, Next, First, Last, /// Enter: activate the highlighted row. Activate, /// Right: open the highlighted row's submenu, or step to the next title. Deeper, /// Left: close this level, or step to the previous title. Shallower, } /// Inner padding of a menu row and of a bar title. const ROW_PAD_X: f64 = 12.0; const ROW_PAD_Y: f64 = 5.0; /// Gap between a row's label and its accelerator text. const ACCEL_GAP: f64 = 24.0; /// Side of the submenu mark's square, and its gap from the accel column. const MARK_SIZE: f64 = 9.0; const MARK_GAP: f64 = 8.0; /// A separator's line and the air around it. const SEPARATOR_PAD_Y: f64 = 3.0; /// A shaped run of text, rebuilt when its content, metrics, or brush change. struct TextRun { text: String, layout: Option>, } impl TextRun { fn new(text: impl Into) -> Self { Self { text: text.into(), layout: None, } } fn set(&mut self, text: impl Into) -> bool { let text = text.into(); if self.text == text { return false; } self.text = text; self.layout = None; true } fn invalidate(&mut self) { self.layout = None; } fn shape( &mut self, text_cx: &mut TextContext, font_size: f32, family: Option<&str>, brush: &Brush, ) -> &Layout { if self.layout.is_none() { let mut builder = text_cx .layout_cx .ranged_builder(&mut text_cx.font_cx, &self.text, 1.0, true); if let Some(family) = family { builder.push_default(StyleProperty::FontFamily(crate::text::font_family(family))); } builder.push_default(StyleProperty::FontSize(font_size)); builder.push_default(StyleProperty::Brush(brush.clone())); let mut layout = builder.build(&self.text); layout.break_all_lines(None); layout.align(Alignment::Start, AlignmentOptions::default()); self.layout = Some(layout); } self.layout.as_ref().expect("just shaped") } fn width(&self) -> f32 { self.layout.as_ref().map_or(0.0, Layout::width) } fn height(&self) -> f32 { self.layout.as_ref().map_or(0.0, Layout::height) } } // --- menu-separator ------------------------------------------------------- /// The appearance tokens a separator reads from the theme. struct SeparatorTokens { color: Brush, thickness: f64, } impl SeparatorTokens { fn blank() -> Self { Self { color: Color::TRANSPARENT.into(), thickness: 0.0, } } fn read(&mut self, style: &ComputedStyle) -> bool { let mut reader = StyleReader::new(style); reader.brush(&mut self.color, "color"); reader.number(&mut self.thickness, "thickness"); reader.changed() } } impl Default for SeparatorTokens { fn default() -> Self { let mut tokens = Self::blank(); tokens.read(&super::fallback_style("menu-separator")); tokens } } /// A divider between groups of rows. pub struct MenuSeparator { tokens: SeparatorTokens, dirt: Dirt, } impl MenuSeparator { pub fn new() -> Self { Self { tokens: SeparatorTokens::default(), dirt: Dirt::CLEAN, } } } impl Default for MenuSeparator { fn default() -> Self { Self::new() } } impl Widget for MenuSeparator { fn measure( &mut self, _text: &mut TextContext, known: taffy::Size>, _available: taffy::Size, ) -> taffy::Size { taffy::Size { width: known.width.unwrap_or(0.0), height: (self.tokens.thickness + SEPARATOR_PAD_Y * 2.0) as f32, } } fn paint(&mut self, fragment: &mut Fragment, size: Size) { let y = (size.height - self.tokens.thickness) / 2.0; fragment.fill( Rect::new(0.0, y, size.width, y + self.tokens.thickness), self.tokens.color.clone(), ); } fn role(&self) -> accesskit::Role { accesskit::Role::Splitter } fn take_dirt(&mut self) -> Dirt { std::mem::take(&mut self.dirt) } fn type_name(&self) -> &'static str { "menu-separator" } fn apply_style(&mut self, style: &ComputedStyle) { if self.tokens.read(style) { self.dirt.mark_layout(); } } } // --- shared row appearance ------------------------------------------------ /// The appearance tokens a row (a `menu-item`, or a `menu` acting as a title /// or submenu row) reads from the theme. struct RowTokens { background: Brush, background_highlighted: Brush, color: Brush, color_highlighted: Brush, accel_color: Brush, corner_radius: f64, submenu_mark: Graphic, } impl RowTokens { fn blank() -> Self { Self { background: Color::TRANSPARENT.into(), background_highlighted: Color::TRANSPARENT.into(), color: Color::TRANSPARENT.into(), color_highlighted: Color::TRANSPARENT.into(), accel_color: Color::TRANSPARENT.into(), corner_radius: 0.0, submenu_mark: Graphic::Path(Default::default()), } } fn read(&mut self, style: &ComputedStyle) -> bool { let mut reader = StyleReader::new(style); reader.brush(&mut self.background, "background"); reader.brush(&mut self.background_highlighted, "background-highlighted"); reader.brush(&mut self.color, "color"); reader.brush(&mut self.color_highlighted, "color-highlighted"); reader.brush(&mut self.accel_color, "accel-color"); reader.number(&mut self.corner_radius, "corner-radius"); reader.graphic(&mut self.submenu_mark, "submenu-mark"); reader.changed() } fn for_kind(kind: &'static str) -> Self { let mut tokens = Self::blank(); tokens.read(&super::fallback_style(kind)); tokens } /// The background and label brushes for the row's own highlight state — /// widget-internal state selecting between token variants, the checkbox /// pattern. fn painted(&self, highlighted: bool) -> (&Brush, &Brush) { if highlighted { (&self.background_highlighted, &self.color_highlighted) } else { (&self.background, &self.color) } } } /// Paint a row's background, label, accelerator, and submenu mark. struct RowPaint<'a> { tokens: &'a RowTokens, highlighted: bool, label: &'a TextRun, accel: Option<&'a TextRun>, submenu: bool, } impl RowPaint<'_> { fn paint(&self, fragment: &mut Fragment, size: Size) { let (background, _) = self.tokens.painted(self.highlighted); let bounds = Rect::from_origin_size((0.0, 0.0), size); if !super::is_transparent(background) { fragment.fill( bounds.to_rounded_rect(self.tokens.corner_radius), background.clone(), ); } if let Some(layout) = &self.label.layout { let origin = Point::new(ROW_PAD_X, (size.height - f64::from(layout.height())) / 2.0); crate::text::append_layout(fragment, layout, origin); } // The accelerator sits against the right edge — the convention that // makes a column of them readable. let mut right = size.width - ROW_PAD_X; if self.submenu { let top = (size.height - MARK_SIZE) / 2.0; let mark = Rect::from_origin_size((right - MARK_SIZE, top), Size::new(MARK_SIZE, MARK_SIZE)); let (_, label_color) = self.tokens.painted(self.highlighted); self.tokens.submenu_mark.paint_into( fragment, mark, &GraphicPaint::Fill(label_color.clone()), ); right -= MARK_SIZE + MARK_GAP; } if let Some(accel) = self.accel && let Some(layout) = &accel.layout { let origin = Point::new( right - f64::from(layout.width()), (size.height - f64::from(layout.height())) / 2.0, ); crate::text::append_layout(fragment, layout, origin); } } /// The row's natural size: label, accelerator column, submenu mark. fn measure(&self) -> taffy::Size { let mut width = f64::from(self.label.width()) + ROW_PAD_X * 2.0; if let Some(accel) = self.accel { width += ACCEL_GAP + f64::from(accel.width()); } if self.submenu { width += MARK_GAP + MARK_SIZE; } let height = f64::from(self.label.height()).max(MARK_SIZE) + ROW_PAD_Y * 2.0; taffy::Size { width: width.ceil() as f32, height: height.ceil() as f32, } } } // --- menu-item ------------------------------------------------------------ /// One activatable row of a menu. pub struct MenuItem { label: TextRun, accel: Option, highlighted: bool, font_size: f32, family: Option, tokens: RowTokens, dirt: Dirt, emitted: Vec, } impl Default for MenuItem { fn default() -> Self { Self { label: TextRun::new(""), accel: None, highlighted: false, font_size: 14.0, family: None, tokens: RowTokens::for_kind("menu-item"), dirt: Dirt::CLEAN, emitted: Vec::new(), } } } impl MenuItem { pub fn new(label: impl Into) -> Self { let mut item = Self::default(); item.set_label(label); item } /// The accelerator's rendered text. The `Keystroke` it also stands for is /// registered by the tree, not held here. pub fn set_accel_text(&mut self, text: impl Into) { match &mut self.accel { Some(accel) => { if accel.set(text) { self.dirt.mark_layout(); } } None => { self.accel = Some(TextRun::new(text)); self.dirt.mark_layout(); } } } pub fn accel_text(mut self, text: impl Into) -> Self { self.set_accel_text(text); self } pub fn family(mut self, family: impl Into) -> Self { self.set_family(family); self } pub fn font_size(mut self, size: f32) -> Self { self.set_font_size(size); self } pub fn set_label(&mut self, label: impl Into) { if self.label.set(label) { self.dirt.mark_layout(); } } pub fn set_font_size(&mut self, size: f32) { if self.font_size != size { self.font_size = size; self.label.invalidate(); if let Some(accel) = &mut self.accel { accel.invalidate(); } self.dirt.mark_layout(); } } pub fn set_family(&mut self, family: impl Into) { let family = Some(family.into()); if self.family != family { self.family = family; self.label.invalidate(); if let Some(accel) = &mut self.accel { accel.invalidate(); } self.dirt.mark_layout(); } } pub fn label(&self) -> &str { &self.label.text } pub fn is_highlighted(&self) -> bool { self.highlighted } /// Highlight or unhighlight the row. Driven by the tree, which owns which /// row of an open menu is current. pub fn set_highlighted(&mut self, highlighted: bool) { if self.highlighted != highlighted { self.highlighted = highlighted; // The label's brush changes with the highlight, so its shaped run // is stale — same width, so paint-only. self.label.invalidate(); self.dirt.mark_paint(); } } /// Report that this row was chosen. The tree turns it into the /// `:on-select` dispatch and closes the menu stack. pub fn select(&mut self) { self.emitted.push(EventData::Selected); } fn row(&self) -> RowPaint<'_> { RowPaint { tokens: &self.tokens, highlighted: self.highlighted, label: &self.label, accel: self.accel.as_ref(), submenu: false, } } } impl Widget for MenuItem { fn measure( &mut self, text_cx: &mut TextContext, _known: taffy::Size>, _available: taffy::Size, ) -> taffy::Size { let (_, label_color) = self.tokens.painted(self.highlighted); let label_color = label_color.clone(); let accel_color = self.tokens.accel_color.clone(); let (size, family) = (self.font_size, self.family.clone()); self.label .shape(text_cx, size, family.as_deref(), &label_color); if let Some(accel) = &mut self.accel { accel.shape(text_cx, size, family.as_deref(), &accel_color); } self.row().measure() } fn finalize_layout(&mut self, text_cx: &mut TextContext, _size: Size, _content: Size) { self.measure( text_cx, taffy::Size::NONE, taffy::Size { width: AvailableSpace::MaxContent, height: AvailableSpace::MaxContent, }, ); } fn paint(&mut self, fragment: &mut Fragment, size: Size) { self.row().paint(fragment, size); } fn role(&self) -> accesskit::Role { accesskit::Role::MenuItem } fn accessibility(&self, node: &mut accesskit::Node) { node.set_label(self.label.text.clone()); if let Some(accel) = &self.accel { node.set_keyboard_shortcut(accel.text.clone()); } } fn take_dirt(&mut self) -> Dirt { std::mem::take(&mut self.dirt) } fn take_emitted(&mut self) -> Vec { std::mem::take(&mut self.emitted) } fn cursor(&self, _local: Point) -> CursorShape { CursorShape::Pointer } fn type_name(&self) -> &'static str { "menu-item" } fn on_pointer( &mut self, kind: EventKind, _event: &PointerEvent, _text: &mut TextContext, _clipboard: &mut dyn crate::clipboard::Clipboard, ) -> bool { // Hovering a row makes it current, but that is the tree's doing (see // `set_hover_chain`): enter and leave never reach a widget's own // hooks. if kind == EventKind::Click { self.select(); true } else { false } } fn apply_style(&mut self, style: &ComputedStyle) { if self.tokens.read(style) { self.label.invalidate(); if let Some(accel) = &mut self.accel { accel.invalidate(); } self.dirt.mark_paint(); } if let Some(size) = style.number("font-size") { self.set_font_size(size as f32); } if let Some(family) = style.string("font-family") { self.set_family(family.to_owned()); } } } // --- menu ----------------------------------------------------------------- /// A menu title (in a bar) or a submenu row (in a menu), and the deferred /// content of the popup it opens. pub struct Menu { label: TextRun, submenu: bool, highlighted: bool, open: bool, content: ContentBuilder, font_size: f32, family: Option, tokens: RowTokens, dirt: Dirt, emitted: Vec, } impl Default for Menu { fn default() -> Self { Self { label: TextRun::new(""), submenu: false, highlighted: false, open: false, content: ContentBuilder::default(), font_size: 14.0, family: None, tokens: RowTokens::for_kind("menu"), dirt: Dirt::CLEAN, emitted: Vec::new(), } } } impl Menu { pub fn new(label: impl Into) -> Self { let mut menu = Self::default(); menu.set_label(label); menu } /// Whether this menu is a submenu row (inside another menu) rather than a /// bar title. Both consumers know it from the parent IR node's kind, so /// it arrives here as a fact rather than as something to sniff — which is /// exactly what the compiler's structural rule guarantees. pub fn set_submenu(&mut self, submenu: bool) { if self.submenu != submenu { self.submenu = submenu; self.dirt.mark_layout(); } } pub fn submenu(mut self, submenu: bool) -> Self { self.set_submenu(submenu); self } /// Use a specific font family instead of the collection's default. pub fn family(mut self, family: impl Into) -> Self { self.set_family(family); self } pub fn font_size(mut self, size: f32) -> Self { self.set_font_size(size); self } pub fn content(mut self, content: ContentBuilder) -> Self { self.set_content(content); self } pub fn label(&self) -> &str { &self.label.text } pub fn is_submenu(&self) -> bool { self.submenu } pub fn is_open(&self) -> bool { self.open } pub fn content_builder(&self) -> ContentBuilder { self.content.clone() } /// Told by the tree, which owns the menu stack. pub fn set_open(&mut self, open: bool) { if self.open != open { self.open = open; self.dirt.mark_paint(); } } pub fn is_highlighted(&self) -> bool { self.highlighted } pub fn set_highlighted(&mut self, highlighted: bool) { if self.highlighted != highlighted { self.highlighted = highlighted; self.label.invalidate(); self.dirt.mark_paint(); } } pub fn set_label(&mut self, label: impl Into) { if self.label.set(label) { self.dirt.mark_layout(); } } pub fn set_font_size(&mut self, size: f32) { if self.font_size != size { self.font_size = size; self.label.invalidate(); self.dirt.mark_layout(); } } pub fn set_family(&mut self, family: impl Into) { let family = Some(family.into()); if self.family != family { self.family = family; self.label.invalidate(); self.dirt.mark_layout(); } } /// Ask the tree to open this menu's popup. pub fn request_open(&mut self) { self.emitted.push(EventData::MenuOpen); } fn row(&self) -> RowPaint<'_> { RowPaint { tokens: &self.tokens, // An open menu's title stays lit while its popup is up, so the // bar shows where you are. highlighted: self.highlighted || self.open, label: &self.label, accel: None, submenu: self.submenu, } } } impl Widget for Menu { fn popup(&self) -> Option { Some(Popup { content: self.content.clone(), beside: self.is_submenu(), match_width: false, }) } fn defers_content(&self) -> bool { true } /// The popup's content, built each time it opens. fn set_content(&mut self, content: ContentBuilder) { self.content = content; } fn measure( &mut self, text_cx: &mut TextContext, _known: taffy::Size>, _available: taffy::Size, ) -> taffy::Size { let (_, label_color) = self.tokens.painted(self.highlighted || self.open); let label_color = label_color.clone(); let (size, family) = (self.font_size, self.family.clone()); self.label .shape(text_cx, size, family.as_deref(), &label_color); self.row().measure() } fn finalize_layout(&mut self, text_cx: &mut TextContext, _size: Size, _content: Size) { self.measure( text_cx, taffy::Size::NONE, taffy::Size { width: AvailableSpace::MaxContent, height: AvailableSpace::MaxContent, }, ); } fn paint(&mut self, fragment: &mut Fragment, size: Size) { self.row().paint(fragment, size); } fn role(&self) -> accesskit::Role { accesskit::Role::MenuItem } fn accessibility(&self, node: &mut accesskit::Node) { node.set_label(self.label.text.clone()); node.set_expanded(self.open); } fn take_dirt(&mut self) -> Dirt { std::mem::take(&mut self.dirt) } fn take_emitted(&mut self) -> Vec { std::mem::take(&mut self.emitted) } fn cursor(&self, _local: Point) -> CursorShape { CursorShape::Pointer } fn type_name(&self) -> &'static str { "menu" } fn on_pointer( &mut self, kind: EventKind, _event: &PointerEvent, _text: &mut TextContext, _clipboard: &mut dyn crate::clipboard::Clipboard, ) -> bool { if kind == EventKind::Click { self.request_open(); true } else { false } } fn apply_style(&mut self, style: &ComputedStyle) { if self.tokens.read(style) { self.label.invalidate(); self.dirt.mark_paint(); } if let Some(size) = style.number("font-size") { self.set_font_size(size as f32); } if let Some(family) = style.string("font-family") { self.set_family(family.to_owned()); } } } // --- menu-bar ------------------------------------------------------------- /// The appearance tokens a bar reads from the theme. struct BarTokens { background: Brush, } impl BarTokens { fn read(&mut self, style: &ComputedStyle) -> bool { let mut reader = StyleReader::new(style); reader.brush(&mut self.background, "background"); reader.changed() } } impl Default for BarTokens { fn default() -> Self { let mut tokens = Self { background: Color::TRANSPARENT.into(), }; tokens.read(&super::fallback_style("menu-bar")); tokens } } /// A row of menu titles. pub struct MenuBar { tokens: BarTokens, dirt: Dirt, } impl MenuBar { pub fn new() -> Self { Self { tokens: BarTokens::default(), dirt: Dirt::CLEAN, } } } impl Default for MenuBar { fn default() -> Self { Self::new() } } impl Widget for MenuBar { fn adjust_style(&self, style: &mut taffy::Style) { // A bar is a row of titles; the caller cannot forget that. style.display = taffy::Display::Flex; style.flex_direction = taffy::FlexDirection::Row; } fn paint(&mut self, fragment: &mut Fragment, size: Size) { fragment.fill( Rect::from_origin_size((0.0, 0.0), size), self.tokens.background.clone(), ); } fn role(&self) -> accesskit::Role { accesskit::Role::MenuBar } fn take_dirt(&mut self) -> Dirt { std::mem::take(&mut self.dirt) } fn type_name(&self) -> &'static str { "menu-bar" } fn apply_style(&mut self, style: &ComputedStyle) { if self.tokens.read(style) { self.dirt.mark_paint(); } } } // --- menu-panel ----------------------------------------------------------- /// The appearance tokens a popup panel reads from the theme. struct PanelTokens { background: Brush, border_color: Brush, border_width: f64, corner_radius: f64, } impl PanelTokens { fn read(&mut self, style: &ComputedStyle) -> bool { let mut reader = StyleReader::new(style); reader.brush(&mut self.background, "background"); reader.brush(&mut self.border_color, "border-color"); reader.number(&mut self.border_width, "border-width"); reader.number(&mut self.corner_radius, "corner-radius"); reader.changed() } } impl Default for PanelTokens { fn default() -> Self { let mut tokens = Self { background: Color::TRANSPARENT.into(), border_color: Color::TRANSPARENT.into(), border_width: 0.0, corner_radius: 0.0, }; tokens.read(&super::fallback_style("menu-panel")); tokens } } /// The popup a menu opens: the rows' home, and the keyboard's. /// /// It holds focus while it is up — so the highlight can rove without any /// focus traffic, and the overlay restores the previous focus when it closes — /// and reports navigation keys rather than acting on them, because moving a /// highlight needs its rows, which only the tree can see. pub struct MenuPanel { tokens: PanelTokens, dirt: Dirt, emitted: Vec, } impl MenuPanel { pub fn new() -> Self { Self { tokens: PanelTokens::default(), dirt: Dirt::CLEAN, emitted: Vec::new(), } } } impl Default for MenuPanel { fn default() -> Self { Self::new() } } impl Widget for MenuPanel { fn adjust_style(&self, style: &mut taffy::Style) { style.display = taffy::Display::Flex; style.flex_direction = taffy::FlexDirection::Column; } fn paint(&mut self, fragment: &mut Fragment, size: Size) { let t = &self.tokens; let bounds = Rect::from_origin_size((0.0, 0.0), size); fragment.fill( bounds.to_rounded_rect(t.corner_radius), t.background.clone(), ); if t.border_width > 0.0 { let inset = bounds.inset(-t.border_width / 2.0); fragment.stroke( inset.to_rounded_rect((t.corner_radius - t.border_width / 2.0).max(0.0)), t.border_color.clone(), guiduck_scene::geom::Stroke::new(t.border_width), ); } } fn role(&self) -> accesskit::Role { accesskit::Role::Menu } fn take_dirt(&mut self) -> Dirt { std::mem::take(&mut self.dirt) } fn take_emitted(&mut self) -> Vec { std::mem::take(&mut self.emitted) } fn type_name(&self) -> &'static str { "menu-panel" } fn focusable(&self) -> bool { true } fn on_key( &mut self, key: &KeyInput, _text: &mut TextContext, _clipboard: &mut dyn crate::clipboard::Clipboard, ) -> bool { if !key.pressed { return false; } let nav = match &key.key { Key::Up => MenuNav::Previous, Key::Down => MenuNav::Next, Key::Home => MenuNav::First, Key::End => MenuNav::Last, Key::Enter => MenuNav::Activate, Key::Right => MenuNav::Deeper, Key::Left => MenuNav::Shallower, // Escape is the overlay's business: it closes the topmost // dismissable layer, which is exactly "one level". _ => return false, }; self.emitted.push(EventData::MenuNav(nav)); true } fn apply_style(&mut self, style: &ComputedStyle) { if self.tokens.read(style) { self.dirt.mark_paint(); } } } // --- the tree's half ------------------------------------------------------ /// One open level of the menu stack. pub(crate) struct OpenMenu { /// The `menu` whose popup this is. menu: WidgetId, /// The overlay layer holding it. overlay: WidgetId, /// The `menu-panel` inside that layer. panel: WidgetId, /// The row the keyboard is currently on, if any. highlight: Option, /// The reactive scope the content was built in. Every effect a menu's /// content creates lives here, so closing the menu disposes exactly what /// opening it created: teardown is scope disposal. scope: Option, } /// How a widget's popup opens. /// /// Returned by [`Widget::popup`](super::Widget::popup); the tree reads it /// rather than asking what kind of widget this is. pub struct Popup { /// The content to build into the panel when it opens. pub content: ContentBuilder, /// Out to the side rather than below — a submenu row rather than a title. pub beside: bool, /// Make the panel as wide as the opener. pub match_width: bool, } impl WidgetTree { /// Open a menu's popup: the tree's half of [`EventData::MenuOpen`]. /// /// Clicking an open menu closes it, which is what a bar title should do. /// Otherwise every level that is not an ancestor of this one closes /// first, so opening a sibling replaces rather than stacks. pub(crate) fn open_menu(&mut self, menu: WidgetId) { if self.menu_stack.iter().any(|open| open.menu == menu) { self.close_menus_from(menu); return; } // A menu opened from inside another menu's panel keeps that panel up; // anything else is a sibling and goes. let depth = self .menu_stack .iter() .position(|open| self.is_within(menu, open.panel)) .map_or(0, |index| index + 1); self.close_menu_levels(depth); // The widget says how its popup opens; the tree does not carry a list // of which kinds have one. let Some(spec) = self.with_widget(menu, |widget| widget.popup()).flatten() else { return; }; let (submenu, content) = (spec.beside, spec.content); // A field's list matches the field, so the popup reads as the field // opening rather than as a menu appearing near it. let field_width = spec.match_width.then(|| self.layout(menu).width()); // A bar title drops its popup below itself; a submenu row sends it out // to the side. Placement flips and clamps against the viewport. let side = if submenu { AnchorSide::RightOf } else { AnchorSide::Below }; // However this layer closes — Escape, an outside click, a selection — // it comes back through `menu_closed`, so there is one teardown path. // The id it needs is the overlay's, which does not exist yet, so the // callback reads it from a cell the open fills in. let commands = self.commands(); let closing: Rc>> = Rc::new(Cell::new(None)); let mut options = OverlayOptions::popup(menu, side); options.dismiss_on_outside_click = true; options.dismiss_on_escape = true; options.on_close = Some({ let closing = Rc::clone(&closing); Box::new(move || { if let Some(overlay) = closing.get() { commands.push(move |tree| tree.menu_closed(overlay)); } }) }); let overlay = self.open_overlay(taffy::Style::default(), options); closing.set(Some(overlay)); let panel_style = match field_width { Some(width) => taffy::Style { size: taffy::Size { width: taffy::prelude::length(width as f32), height: taffy::prelude::auto(), }, ..Default::default() }, None => taffy::Style::default(), }; let panel = self.insert(MenuPanel::new(), panel_style, Some(overlay)); // Everything the content creates — widgets, effects, subscriptions — // is created now and belongs to this scope. Nothing existed while the // menu was closed, and nothing survives its closing. let scope = Scope::new(); scope.run(|| content.build(self, panel)); self.menu_stack.push(OpenMenu { menu, overlay, panel, highlight: None, scope: Some(scope), }); self.set_menu_open(menu, true); // The panel holds focus while it is up, so the highlight can rove // without focus traffic; the overlay restores the previous focus. self.set_focus(Some(panel)); } /// Close every level from `depth` up, innermost first. /// /// The stack is popped *here*, synchronously, rather than left to the /// overlay's `on_close`: that callback cannot touch the tree, so it can /// only queue a command, and a loop waiting for a queued pop would spin /// forever. Disposing the scope here is what makes closing exactly undo /// opening — every effect the content created goes with it. fn close_menu_levels(&mut self, depth: usize) { while self.menu_stack.len() > depth { let open = self.menu_stack.pop().expect("checked non-empty"); self.set_menu_open(open.menu, false); if let Some(scope) = open.scope { scope.dispose(); } // Already gone when the close came *from* the overlay (Escape, an // outside click); still here when we are the ones closing it. if self.is_overlay(open.overlay) { self.close_overlay(open.overlay); } } } /// A menu's overlay closed on its own — dismissed by Escape or by an /// outside click. Drop its level, and any above it. pub(crate) fn menu_closed(&mut self, overlay: WidgetId) { if let Some(index) = self .menu_stack .iter() .position(|open| open.overlay == overlay) { self.close_menu_levels(index); } } /// Close `menu`'s level and everything above it. fn close_menus_from(&mut self, menu: WidgetId) { if let Some(index) = self.menu_stack.iter().position(|open| open.menu == menu) { self.close_menu_levels(index); } } /// Close the whole stack — a row was chosen, and the command is done. pub(crate) fn close_all_menus(&mut self) { self.close_menu_levels(0); } } /// The rows of an open panel, in order: what the highlight moves through. /// /// Separators are not rows — you cannot land on one — which is why this asks /// the widgets rather than taking every child. fn panel_rows(tree: &WidgetTree, panel: WidgetId) -> Vec { let mut rows = Vec::new(); let mut stack: Vec = tree.children(panel).to_vec(); stack.reverse(); while let Some(id) = stack.pop() { // A row is one of the two kinds you can land on. Asked by type, not // by name: a separator is not a row, and neither is the scaffolding a // `for` region puts around its rows. if tree.widget::(id).is_some() || tree.widget::(id).is_some() { rows.push(id); continue; } // A `for` region wraps its rows in markers, so the rows of a // data-driven menu are found by looking through the structure rather // than only at the panel's direct children. let mut children = tree.children(id).to_vec(); children.reverse(); stack.extend(children); } rows } impl WidgetTree { /// Move the highlight, or act on it: the tree's half of /// [`EventData::MenuNav`]. pub(crate) fn menu_nav(&mut self, panel: WidgetId, nav: MenuNav) { let Some(level) = self.menu_stack.iter().position(|open| open.panel == panel) else { return; }; let rows = panel_rows(self, panel); if rows.is_empty() { return; } let current = self.menu_stack[level] .highlight .and_then(|id| rows.iter().position(|row| *row == id)); let next = match nav { MenuNav::Previous => Some(match current { Some(0) | None => rows.len() - 1, Some(index) => index - 1, }), MenuNav::Next => Some(match current { Some(index) if index + 1 < rows.len() => index + 1, _ => 0, }), MenuNav::First => Some(0), MenuNav::Last => Some(rows.len() - 1), MenuNav::Activate => { if let Some(row) = self.menu_stack[level].highlight { self.activate_menu_row(row); } return; } MenuNav::Deeper => { // Right opens the highlighted submenu; on a plain row it does // nothing, since there is nowhere deeper to go. if let Some(row) = self.menu_stack[level].highlight && self.widget::(row).is_some() { self.open_menu(row); } return; } MenuNav::Shallower => { // Left closes this level and returns to the row that opened // it, which is where the eye already is. let menu = self.menu_stack[level].menu; self.close_menus_from(menu); return; } }; if let Some(index) = next { self.set_menu_highlight(level, Some(rows[index])); } } /// A row asked to become the current one (the pointer entered it): the /// tree's half of [`EventData::MenuHighlight`]. pub(crate) fn highlight_menu_row(&mut self, row: WidgetId) { let Some(level) = self .menu_stack .iter() .position(|open| self.is_within(row, open.panel)) else { return; }; // Pointing at a row of an outer menu abandons any submenu it opened. self.close_menu_levels(level + 1); self.set_menu_highlight(level, Some(row)); } /// Move one level's highlight, telling the rows that gained and lost it. fn set_menu_highlight(&mut self, level: usize, row: Option) { let previous = self.menu_stack[level].highlight; if previous == row { return; } self.menu_stack[level].highlight = row; for (id, highlighted) in [(previous, false), (row, true)] { let Some(id) = id else { continue }; if let Some(mut widget) = self.widget_mut::(id) { widget.set_highlighted(highlighted); } else if let Some(mut widget) = self.widget_mut::(id) { widget.set_highlighted(highlighted); } } } /// Choose a row, whichever way it was chosen. A submenu row opens rather /// than selects — there is nothing to report about opening a menu. pub(crate) fn activate_menu_row(&mut self, row: WidgetId) { if self.widget::(row).is_some() { self.open_menu(row); } else if self.widget::(row).is_some() { self.select_menu_row(row); } } /// Fire a row's `:on-select` and close the stack. /// /// The one selection path: a pointer click reports `EventData::Selected` /// and lands here, and so do Enter and an accelerator. /// /// The wire fires now; the close is queued. Both halves of that matter. /// The wire must run before the rows are gone, and the close must not run /// *during* a dispatch that is still walking those rows — a pointer Click /// arrives mid-traversal, and pulling the widgets out from under it would /// leave the walk holding dead ids. The command queue is the seam for /// exactly this: it runs when the frame's dispatch has unwound. pub(crate) fn select_menu_row(&mut self, row: WidgetId) { self.dispatch_semantic( row, crate::event::EventKind::Select, crate::event::EventData::Selected, ); self.commands().push(|tree| tree.close_all_menus()); } /// Tell whatever opened a popup that it is (or is no longer) open — a /// `menu` title lights up, a `dropdown` field turns its arrow. fn set_menu_open(&mut self, id: WidgetId, open: bool) { if let Some(mut widget) = self.widget_mut::(id) { widget.set_open(open); } else if let Some(mut widget) = self.widget_mut::(id) { widget.set_open(open); } else if let Some(mut widget) = self.widget_mut::(id) { widget.set_menu_open(open); } } /// Whether any menu is open — the shell's cue that a click outside is a /// dismissal rather than ordinary input. pub fn menus_open(&self) -> bool { !self.menu_stack.is_empty() } } #[cfg(test)] mod tests; /// The runtime keystroke an accelerator declaration stands for. /// /// The grammar lives in the compiler, which is where a typo becomes a /// diagnostic — so this parses the *same* string with the *same* parser /// rather than keeping a second opinion about what `"Ctrl+S"` means. It /// cannot fail in practice: validation already accepted it. fn keystroke_for(accel: &str) -> Option { use guiduck_component_core::accel::{AccelKey, parse_accel}; let parsed = parse_accel(accel, guiduck_component_core::sexpr::Span::new(0, 0)).ok()?; Some(crate::event::Keystroke { key: match parsed.key { AccelKey::Character(c) => Key::Character(c), AccelKey::Enter => Key::Enter, AccelKey::Delete => Key::Delete, AccelKey::Home => Key::Home, AccelKey::End => Key::End, AccelKey::Left => Key::Left, AccelKey::Right => Key::Right, AccelKey::Up => Key::Up, AccelKey::Down => Key::Down, }, modifiers: crate::event::Modifiers { ctrl: parsed.ctrl, shift: parsed.shift, alt: parsed.alt, logo: parsed.logo, }, }) } impl WidgetTree { /// Register a menu item's accelerator. /// /// `run` is the item's own `:on-select` work, not a dispatch at the row: /// an accelerator's whole point is firing while its menu is *closed*, and /// a closed menu's rows do not exist. So the two routes share the handler /// rather than the widget — which is also why an accelerator may not sit /// inside `if` or `for`, where the row is per-item and there is nothing to /// register at mount. /// /// The key-routing order already does the right thing: the focused widget /// sees the key first, so a text input's Ctrl+C beats an accelerator. /// `owner` is the widget the accelerator belongs to — the menu that /// declared it. When that widget goes, so does the accelerator: a dev /// reload rebuilds the subtree, and a registration outliving its menu /// would shadow the one that replaced it. pub fn on_menu_accel(&mut self, owner: WidgetId, accel: &str, mut run: impl FnMut() + 'static) { let Some(keystroke) = keystroke_for(accel) else { return; }; let commands = self.commands(); self.shortcuts.push(( Some(owner), keystroke, Box::new(move || { // An accelerator can fire with a menu open (you can reach for // Ctrl+S mid-browse), so the stack goes, exactly as choosing the // row would take it. commands.push(|tree| tree.close_all_menus()); run(); }), )); } } // --- context-menu --------------------------------------------------------- /// Rows that open at the pointer when the enclosing widget is right-pressed. /// /// It draws nothing and takes no space: it is an *attachment point* that /// happens to live in the tree, which is what lets it be written where the /// thing it belongs to is written. Its rows are deferred exactly as a menu's /// are, so a context menu on every row of a long list costs nothing until one /// is opened. #[derive(Default)] pub struct ContextMenu { content: ContentBuilder, } impl ContextMenu { pub fn new(content: ContentBuilder) -> Self { Self { content } } pub fn content_builder(&self) -> ContentBuilder { self.content.clone() } } impl Widget for ContextMenu { fn defers_content(&self) -> bool { true } /// The popup's rows, built each time it opens. fn set_content(&mut self, content: ContentBuilder) { self.content = content; } fn adjust_style(&self, style: &mut taffy::Style) { // Present in the tree, absent from the layout — the caller cannot // forget, because there is nothing here to lay out. style.display = taffy::Display::None; } fn type_name(&self) -> &'static str { "context-menu" } } impl WidgetTree { /// The context menu attached at or above `target`, if any. /// /// Innermost wins: a row's own menu beats the list's, which is what /// nesting them is for. fn context_menu_for(&self, target: WidgetId) -> Option { let mut current = Some(target); while let Some(id) = current { if let Some(found) = self .children(id) .iter() .find(|child| self.widget::(**child).is_some()) { return Some(*found); } current = self.parent(id); } None } /// Open the context menu attached at or above `target`, at `at`. Reports /// whether one was there — an unclaimed right-press is not ours. pub(crate) fn open_context_menu(&mut self, target: WidgetId, at: Point) -> bool { let Some(menu) = self.context_menu_for(target) else { return false; }; // A right-press elsewhere abandons whatever was open, exactly as a // left-press outside would. self.close_all_menus(); let Some(content) = self .widget::(menu) .map(|widget| widget.content_builder()) else { return false; }; let commands = self.commands(); let closing: Rc>> = Rc::new(Cell::new(None)); let mut options = OverlayOptions::popup_at(at); options.dismiss_on_outside_click = true; options.dismiss_on_escape = true; options.on_close = Some({ let closing = Rc::clone(&closing); Box::new(move || { if let Some(overlay) = closing.get() { commands.push(move |tree| tree.menu_closed(overlay)); } }) }); let overlay = self.open_overlay(taffy::Style::default(), options); closing.set(Some(overlay)); let panel = self.insert(MenuPanel::new(), taffy::Style::default(), Some(overlay)); let scope = Scope::new(); scope.run(|| content.build(self, panel)); // It joins the same stack as a bar menu's popup: one set of rules for // dismissal, navigation, and selection, whatever opened it. self.menu_stack.push(OpenMenu { menu, overlay, panel, highlight: None, scope: Some(scope), }); self.set_focus(Some(panel)); true } } // --- dropdown ------------------------------------------------------------- /// The appearance tokens a dropdown reads from the theme. struct DropdownTokens { background: Brush, color: Brush, border: Brush, border_width: f64, corner_radius: f64, focus_ring_color: Brush, focus_ring_width: f64, arrow_mark: Graphic, arrow_color: Brush, } impl DropdownTokens { fn read(&mut self, style: &ComputedStyle) -> bool { let mut reader = StyleReader::new(style); reader.brush(&mut self.background, "background"); reader.brush(&mut self.color, "color"); reader.brush(&mut self.border, "border-color"); reader.number(&mut self.border_width, "border-width"); reader.number(&mut self.corner_radius, "corner-radius"); reader.brush(&mut self.focus_ring_color, "focus-ring-color"); reader.number(&mut self.focus_ring_width, "focus-ring-width"); reader.graphic(&mut self.arrow_mark, "arrow-mark"); reader.brush(&mut self.arrow_color, "arrow-color"); reader.changed() } } impl Default for DropdownTokens { fn default() -> Self { let mut tokens = Self { background: Color::TRANSPARENT.into(), color: Color::TRANSPARENT.into(), border: Color::TRANSPARENT.into(), border_width: 0.0, corner_radius: 0.0, focus_ring_color: Color::TRANSPARENT.into(), focus_ring_width: 0.0, arrow_mark: Graphic::Path(Default::default()), arrow_color: Color::TRANSPARENT.into(), }; tokens.read(&super::fallback_style("dropdown")); tokens } } /// A field that opens a list. /// /// Structurally this *is* a [`Menu`]: deferred rows, one popup, the same /// stack, highlight, dismissal, and selection. What it adds is a control's /// manners — it is focusable and opens from the keyboard, it draws like a /// field, and its popup matches its own width instead of hugging its rows. /// That short list is the whole difference, which is the point: the fourth /// customer of deferred content needed a widget, not machinery. /// /// It holds no list and no selection. `:label` is the chosen option's text, /// bindable, computed by the app from its own data — `for` is the model. pub struct Dropdown { label: TextRun, open: bool, content: ContentBuilder, font_size: f32, family: Option, tokens: DropdownTokens, dirt: Dirt, emitted: Vec, } impl Default for Dropdown { fn default() -> Self { Self { label: TextRun::new(""), open: false, content: ContentBuilder::default(), font_size: 14.0, family: None, tokens: DropdownTokens::default(), dirt: Dirt::CLEAN, emitted: Vec::new(), } } } impl Dropdown { pub fn new(label: impl Into) -> Self { let mut dropdown = Self::default(); dropdown.set_label(label); dropdown } pub fn content(mut self, content: ContentBuilder) -> Self { self.set_content(content); self } pub fn family(mut self, family: impl Into) -> Self { self.set_family(family); self } pub fn font_size(mut self, size: f32) -> Self { self.set_font_size(size); self } pub fn label(&self) -> &str { &self.label.text } pub fn is_open(&self) -> bool { self.open } pub fn content_builder(&self) -> ContentBuilder { self.content.clone() } pub fn set_open(&mut self, open: bool) { if self.open != open { self.open = open; self.dirt.mark_paint(); } } pub fn set_label(&mut self, label: impl Into) { if self.label.set(label) { self.dirt.mark_layout(); } } pub fn set_font_size(&mut self, size: f32) { if self.font_size != size { self.font_size = size; self.label.invalidate(); self.dirt.mark_layout(); } } pub fn set_family(&mut self, family: impl Into) { let family = Some(family.into()); if self.family != family { self.family = family; self.label.invalidate(); self.dirt.mark_layout(); } } } impl Widget for Dropdown { fn popup(&self) -> Option { Some(Popup { content: self.content.clone(), beside: false, match_width: true, }) } fn defers_content(&self) -> bool { true } /// The list's rows, built each time it opens. fn set_content(&mut self, content: ContentBuilder) { self.content = content; } fn measure( &mut self, text_cx: &mut TextContext, _known: taffy::Size>, _available: taffy::Size, ) -> taffy::Size { let color = self.tokens.color.clone(); let (size, family) = (self.font_size, self.family.clone()); self.label.shape(text_cx, size, family.as_deref(), &color); taffy::Size { width: (f64::from(self.label.width()) + ROW_PAD_X * 2.0 + MARK_GAP + MARK_SIZE).ceil() as f32, height: (f64::from(self.label.height()).max(MARK_SIZE) + ROW_PAD_Y * 2.0).ceil() as f32, } } fn finalize_layout(&mut self, text_cx: &mut TextContext, _size: Size, _content: Size) { self.measure( text_cx, taffy::Size::NONE, taffy::Size { width: AvailableSpace::MaxContent, height: AvailableSpace::MaxContent, }, ); } fn paint(&mut self, fragment: &mut Fragment, size: Size) { let t = &self.tokens; let bounds = Rect::from_origin_size((0.0, 0.0), size); fragment.fill( bounds.to_rounded_rect(t.corner_radius), t.background.clone(), ); if t.border_width > 0.0 { let inset = bounds.inset(-t.border_width / 2.0); fragment.stroke( inset.to_rounded_rect((t.corner_radius - t.border_width / 2.0).max(0.0)), t.border.clone(), guiduck_scene::geom::Stroke::new(t.border_width), ); } // The `:focus` rule makes the ring opaque; the widget never asks // whether it is focused. if !super::is_transparent(&t.focus_ring_color) { let ring = bounds.inset(-t.focus_ring_width / 2.0 - 1.0); fragment.stroke( ring.to_rounded_rect((t.corner_radius + 1.0).max(0.0)), t.focus_ring_color.clone(), guiduck_scene::geom::Stroke::new(t.focus_ring_width), ); } if let Some(layout) = &self.label.layout { let origin = Point::new(ROW_PAD_X, (size.height - f64::from(layout.height())) / 2.0); crate::text::append_layout(fragment, layout, origin); } let mark = Rect::from_origin_size( ( size.width - ROW_PAD_X - MARK_SIZE, (size.height - MARK_SIZE) / 2.0, ), Size::new(MARK_SIZE, MARK_SIZE), ); t.arrow_mark .paint_into(fragment, mark, &GraphicPaint::Fill(t.arrow_color.clone())); } fn role(&self) -> accesskit::Role { accesskit::Role::ComboBox } fn accessibility(&self, node: &mut accesskit::Node) { node.set_value(self.label.text.clone()); node.set_expanded(self.open); } fn take_dirt(&mut self) -> Dirt { std::mem::take(&mut self.dirt) } fn take_emitted(&mut self) -> Vec { std::mem::take(&mut self.emitted) } fn cursor(&self, _local: Point) -> CursorShape { CursorShape::Pointer } fn type_name(&self) -> &'static str { "dropdown" } fn focusable(&self) -> bool { true } fn on_key( &mut self, key: &KeyInput, _text: &mut TextContext, _clipboard: &mut dyn crate::clipboard::Clipboard, ) -> bool { if !key.pressed { return false; } // The three ways a closed field is opened from the keyboard. match &key.key { Key::Enter | Key::Down => {} Key::Character(text) if text == " " => {} _ => return false, } self.emitted.push(EventData::MenuOpen); true } fn on_pointer( &mut self, kind: EventKind, _event: &PointerEvent, _text: &mut TextContext, _clipboard: &mut dyn crate::clipboard::Clipboard, ) -> bool { if kind == EventKind::Click { self.emitted.push(EventData::MenuOpen); true } else { false } } fn apply_style(&mut self, style: &ComputedStyle) { if self.tokens.read(style) { self.label.invalidate(); self.dirt.mark_paint(); } if let Some(size) = style.number("font-size") { self.set_font_size(size as f32); } if let Some(family) = style.string("font-family") { self.set_family(family.to_owned()); } } }