Files
MujicaUI/popup_menu.go
T

246 lines
7.7 KiB
Go

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 {
it := m.row(ui.CheckboxBase(m.c, checked), opts.Disabled, false)
m.content(it, label, *checked, false)
if !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()
box := ui.Column(c).Absolute().Key(s.seq).MinWidth(180).MaxWidth(360)
sb := box.Bounds()
ww, wh := c.Size()
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
}
top = 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)
})
}