package mujica import ( "log/slog" "github.com/egoist/mygo/ui" "mujicaui/icons" "mujicaui/internal" "mujicaui/theme" ) // PopupMenuItemOptions configure one item of a PopupMenu. type PopupMenuItemOptions struct { Disabled bool // KeepOpen keeps the menu open after the item is chosen. KeepOpen bool } // PopupMenu builds the items of a DropdownMenu or ContextMenu, reporting choices as they happen. type PopupMenu struct { c *ui.Context root *popupMenuRoot level *popupMenuLevel subs []popupMenuSub seq, rows int autofocus bool } // popupMenuRoot carries what every level of one open menu shares in a frame. type popupMenuRoot struct{ close bool } // popupMenuLevel is what one menu level keeps while it shows. type popupMenuLevel struct { open int // seq of the open submenu, 0 for none byKey bool // the open submenu was opened from the keyboard } type popupMenuSub struct { item *ui.Element seq int build func(m *PopupMenu) } // Item adds an item and reports its choice, which closes the menu unless KeepOpen. func (m *PopupMenu) Item(label string, opts PopupMenuItemOptions) bool { it := m.row(ui.ButtonBase(m.c), opts.Disabled, false) m.content(it, label, false, false) if !it.Clicked() { return false } slog.Debug("mujica: popup_menu choose", "item", label) m.root.close = m.root.close || !opts.KeepOpen return true } // Check adds an item toggling *checked and reports whether it changed. func (m *PopupMenu) Check(label string, checked *bool, opts PopupMenuItemOptions) bool { // The base toggles as it is built, so a disabled wrapper and a copy come first. value := checked if opts.Disabled { cp := *checked value = &cp } var base *ui.Element ui.Column(m.c).Shrink(0).Disabled(opts.Disabled).Children(func() { base = ui.CheckboxBase(m.c, value) }) it := m.row(base, opts.Disabled, false) m.content(it, label, *checked, false) if opts.Disabled || !it.Changed() { return false } slog.Debug("mujica: popup_menu check", "item", label, "checked", *checked) m.root.close = m.root.close || !opts.KeepOpen return true } // Submenu adds an item opening a nested menu that build fills. func (m *PopupMenu) Submenu(label string, opts PopupMenuItemOptions, build func(m *PopupMenu)) { if build == nil { panic("mujica: PopupMenu Submenu needs a build function") } it := m.row(ui.ButtonBase(m.c), opts.Disabled, true) seq := m.seq m.content(it, label, false, true) if opts.Disabled { return } if it.Clicked() || it.Shortcut(0, ui.KeyRight) { m.level.open, m.level.byKey = seq, it.FocusVisible() slog.Debug("mujica: popup_menu submenu", "item", label) } m.subs = append(m.subs, popupMenuSub{it, seq, build}) } // Group adds a captioned group of items, separated from what comes before. func (m *PopupMenu) Group(label string, fn func()) { k := Tokens(m.c) u := Density(m.c).Unit() if m.rows > 0 { m.Separator() } if label != "" { ui.Text(m.c, label).FontSize(FontSize(m.c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(u, u*3, u/2, u*3+16+u*2).Shrink(0) } fn() } // Separator adds a line between items. func (m *PopupMenu) Separator() { u := Density(m.c).Unit() ui.Box(m.c).Height(theme.BorderWidth).Margin(u, u*2).Background(Tokens(m.c).Border).Shrink(0) } // row styles an item element and runs the hover, focus and highlight logic. func (m *PopupMenu) row(it *ui.Element, disabled, sub bool) *ui.Element { c := m.c k := Tokens(c) d := Density(c) m.seq++ m.rows++ seq := m.seq it.Justify(ui.Start).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3). Gap(d.Unit() * 2).Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.Text) internal.Ring(it, k.Focus, theme.ControlRadius) if disabled { return it } if m.autofocus { it.AutoFocus() m.autofocus = false } hovered := it.Hovered() was := ui.Local(it, "mujica.menu.hover", func() bool { return false }) if hovered && !*was { it.Focus() // focus follows the pointer if sub { m.level.open, m.level.byKey = seq, false } else { m.level.open = 0 } } *was = hovered if hovered || it.FocusVisible() || m.level.open == seq { it.Background(k.Selection).Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1) }) } return it } // content lays out the check column, label and submenu chevron of an item. func (m *PopupMenu) content(it *ui.Element, label string, checked, sub bool) { c := m.c k := Tokens(c) it.Children(func() { mark := ui.Box(c).Size(16, 16).Shrink(0) if checked { mark.Children(func() { ui.Icon(c, icons.Must("check")).Size(16, 16).TextColor(k.AccentText) }) } ui.Text(c, label).SingleLine().Grow(1) if sub { ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).TextColor(k.TextMuted).Shrink(0) } }) } // popupMenuOpen shows the menu build fills below anchor while *open is true. func popupMenuOpen(c *ui.Context, anchor *ui.Element, open *bool, build func(m *PopupMenu)) *ui.Element { if !*open { return nil } overlayLayer(c, open) b := anchor.Bounds() root := &popupMenuRoot{} panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { overlayStyle(c, panel, overlayAnchored(c, panel, b)) panel.Margin(4, 0, 0, 0).MinWidth(popoverWide(c, max(b.W, 200))).MaxWidth(popoverWide(c, 360)).MaxHeight(popoverRoom(c, b)) popupMenuLevelBuild(c, panel, root, build, true) // The panel holds every level, so Tab in a submenu closes the whole menu too. if panel.Shortcut(0, ui.KeyTab) || panel.Shortcut(ui.Shift, ui.KeyTab) { root.close = true } }) if root.close { *open = false } if !*open { slog.Debug("mujica: popup_menu close") } return panel } // popupMenuLevelBuild builds one level's items in outer, then its open submenu. func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, build func(m *PopupMenu), autofocus bool) { u := Density(c).Unit() // The padding leaves room for the items' focus rings inside the clipping scroll. items := ui.Scroll(c).Shrink(1).Padding(u + theme.FocusGap + theme.FocusWidth).FocusGroup(ui.Vertical) m := &PopupMenu{c: c, root: root, autofocus: autofocus} m.level = ui.Local(items, "mujica.menu.level", func() popupMenuLevel { return popupMenuLevel{} }) outer.Role(ui.RolePopup) items.Children(func() { build(m) }) for _, s := range m.subs { if m.level.open == s.seq { popupMenuSubmenu(c, outer, root, m.level, s) } } } // popupMenuSubmenu builds a submenu beside its item, flipped to stay in the window. func popupMenuSubmenu(c *ui.Context, outer *ui.Element, root *popupMenuRoot, lv *popupMenuLevel, s popupMenuSub) { u := Density(c).Unit() ob, ib := outer.Bounds(), s.item.Bounds() ww, wh := c.Size() box := ui.Column(c).Absolute().Key(s.seq).MinWidth(min(180, ww-8)).MaxWidth(min(360, ww-8)) sb := box.Bounds() left, top := ib.X+ib.W+u-ob.X, ib.Y-u-ob.Y if ob.X+left+sb.W > ww-4 { left = ib.X - u - sb.W - ob.X } // Flipped or not, the submenu stays inside the window. left = max(4-ob.X, min(left, ww-4-sb.W-ob.X)) top = max(4-ob.Y, min(top, wh-4-sb.H-ob.Y)) box.Left(left).Top(top).MaxHeight(wh - 8) p := overlayEnter(c, box) if sb.W == 0 || ob.W == 0 { p = 0 // not measured yet c.AnimationFrame() } overlayStyle(c, box, p) box.Children(func() { popupMenuLevelBuild(c, box, root, s.build, lv.byKey) }) if box.Shortcut(0, ui.KeyLeft) || box.Shortcut(0, ui.KeyEscape) { lv.open = 0 s.item.Focus() } } // popupMenuTrigger styles a menu trigger button with a trailing chevron. func popupMenuTrigger(c *ui.Context, b *ui.Element, label string, icon *ui.SVG, open bool) { k := Tokens(c) d := Density(c) b.MinHeight(d.ControlHeight()).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3, 0, d.Unit()*4). Gap(d.Unit() * 2).Radius(theme.ControlRadius) internal.Ring(b, k.Focus, theme.ControlRadius) bg, fg, border := buttonColors(k, Secondary, b.Hovered() && !b.IsDisabled(), b.Pressed() || open) b.Background(bg).TextColor(fg).Border(theme.BorderWidth, border) b.Children(func() { if icon != nil { ui.Icon(c, icon).Size(16, 16) } if label != "" { ui.Text(c, label).SingleLine() } ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted) }) }