package mujica import ( "log/slog" "github.com/egoist/mygo/ui" "mujicaui/internal" "mujicaui/theme" ) // MenubarItemKind is what a menu item does. type MenubarItemKind int const ( // MenubarAction runs a command. MenubarAction MenubarItemKind = iota // MenubarCheck toggles a setting the caller keeps in Checked. MenubarCheck // MenubarRadio picks one of a group; the caller sets Checked on one. MenubarRadio // MenubarSeparator draws a line between groups of items. MenubarSeparator ) // MenubarShortcut is the key hint shown beside an item; Key zero shows none. type MenubarShortcut struct { Mods ui.Modifiers Key ui.Key } // MenubarItem is an item of a menu, or with Items a submenu. type MenubarItem struct { // ID is reported by Chosen; required for actions, checks and radios. ID string Label string Kind MenubarItemKind Checked bool Disabled bool // Shortcut is only shown; handle the key with c.Shortcut. Shortcut MenubarShortcut Items []MenubarItem } // MenubarMenu is a title of the bar and the menu it opens. type MenubarMenu struct { Label string Items []MenubarItem } // MenubarOptions configure a Menubar. type MenubarOptions struct { // Label names the bar; empty uses the built-in name. Label string } // MenubarResult is the built Menubar. type MenubarResult struct { *ui.Element chosen string } // Chosen returns the ID of the item the user chose in this frame. func (r MenubarResult) Chosen() (string, bool) { return r.chosen, r.chosen != "" } type menubarState struct { open int hl []int focusTo int ptr [2]float32 } // Menubar is an in-window menu bar drawn by MujicaUI, with submenus and full keyboard control. func Menubar(c *ui.Context, menus []MenubarMenu, opts MenubarOptions) MenubarResult { if len(menus) == 0 { panic("mujica: Menubar needs menus") } for _, m := range menus { if m.Label == "" { panic("mujica: MenubarMenu needs a Label") } menubarCheck(m.Items) } k := Tokens(c) d := Density(c) u := d.Unit() label := opts.Label if label == "" { label = msg(c, "menubar.label") } bar := ui.Row(c).AlignItems(ui.Center).Gap(u * 0.5).Padding(u).FocusGroup(ui.Horizontal). Role(ui.RoleToolbar).Label(label).Background(k.Surface) bar.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, k.Border, 0) }) st := ui.Local(bar, "state", func() *menubarState { return &menubarState{open: -1, focusTo: -1} }) m := &menubarRun{c: c, menus: menus, st: *st} res := MenubarResult{Element: bar} titles := make([]ui.Rect, len(menus)) bar.Children(func() { for i, menu := range menus { t := m.title(i, menu.Label) titles[i] = t.Bounds() } }) if m.st.open >= 0 { m.overlay(titles) } res.chosen = m.chosen return res } // menubarCheck panics on items that cannot work. func menubarCheck(items []MenubarItem) { for _, it := range items { if it.Kind == MenubarSeparator { continue } if it.Label == "" { panic("mujica: MenubarItem needs a Label") } if it.Items != nil { if it.Kind != MenubarAction { panic("mujica: a submenu item must be of kind MenubarAction") } menubarCheck(it.Items) } else if it.ID == "" { panic("mujica: MenubarItem " + it.Label + " needs an ID") } } } // menubarRun is one frame of a Menubar. type menubarRun struct { c *ui.Context menus []MenubarMenu st *menubarState chosen string } func (m *menubarRun) title(i int, label string) *ui.Element { c, st := m.c, m.st k := Tokens(c) d := Density(c) open := st.open == i t := ui.ButtonBase(c).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*3).Radius(theme.ControlRadius). Role(ui.RoleMenuButton).Label(label).TextColor(k.Text) internal.Ring(t, k.Focus, theme.ControlRadius) switch { case open: t.Background(k.Selection) case t.Hovered(): t.Background(k.SurfaceHover) } if st.focusTo == i { t.Focus() st.focusTo = -1 } if st.open < 0 { switch { case t.Shortcut(0, ui.KeyRight): m.focus((i + 1) % len(m.menus)) case t.Shortcut(0, ui.KeyLeft): m.focus((i - 1 + len(m.menus)) % len(m.menus)) case t.Shortcut(0, ui.KeyDown), t.Shortcut(0, ui.KeyUp): m.openMenu(i, true) } } else { m.keys(t) } if t.Clicked() { if open { m.close() } else { m.openMenu(i, t.FocusVisible()) } } t.Children(func() { ui.Text(c, label).SingleLine() }) return t } // keys drives an open menu from the focused title. func (m *menubarRun) keys(t *ui.Element) { st := m.st l := len(st.hl) - 1 items := m.items(l) h := st.hl[l] switch { case t.Shortcut(0, ui.KeyDown): st.hl[l] = menubarStep(items, h, 1) case t.Shortcut(0, ui.KeyUp): st.hl[l] = menubarStep(items, h, -1) case t.Shortcut(0, ui.KeyHome): st.hl[l] = menubarStep(items, -1, 1) case t.Shortcut(0, ui.KeyEnd): st.hl[l] = menubarStep(items, len(items), -1) case t.Shortcut(0, ui.KeyRight): if h >= 0 && items[h].Items != nil { st.hl = append(st.hl, menubarStep(items[h].Items, -1, 1)) } else { m.openMenu((st.open+1)%len(m.menus), true) } case t.Shortcut(0, ui.KeyLeft): if l > 0 { st.hl = st.hl[:l] } else { m.openMenu((st.open-1+len(m.menus))%len(m.menus), true) } case t.Shortcut(0, ui.KeyEnter), t.Shortcut(0, ui.KeySpace): if h >= 0 { m.activate(l, h) } case t.Shortcut(0, ui.KeyEscape): if l > 0 { st.hl = st.hl[:l] } else { m.close() } } } // items returns the items shown at level l of the open menu. func (m *menubarRun) items(l int) []MenubarItem { items := m.menus[m.st.open].Items for i := 0; i < l; i++ { items = items[m.st.hl[i]].Items } return items } func (m *menubarRun) focus(i int) { m.st.focusTo = i m.c.Invalidate() } func (m *menubarRun) openMenu(i int, highlight bool) { first := -1 if highlight { first = menubarStep(m.menus[i].Items, -1, 1) } m.st.open, m.st.hl = i, []int{first} m.focus(i) slog.Debug("mujica: menubar open", "menu", m.menus[i].Label) } func (m *menubarRun) close() { if m.st.open >= 0 { m.focus(m.st.open) } m.st.open, m.st.hl = -1, nil slog.Debug("mujica: menubar close") } // activate chooses item h of level l, or opens it when it is a submenu. func (m *menubarRun) activate(l, h int) { it := m.items(l)[h] if it.Disabled || it.Kind == MenubarSeparator { return } if it.Items != nil { m.st.hl = append(m.st.hl[:l+1], menubarStep(it.Items, -1, 1)) return } m.chosen = it.ID slog.Debug("mujica: menubar choose", "id", it.ID) m.close() } // menubarStep returns the next enabled item from h in direction dir, wrapping. func menubarStep(items []MenubarItem, h, dir int) int { n := len(items) for k := 1; k <= n; k++ { j := ((h+dir*k)%n + n) % n if it := items[j]; it.Kind != MenubarSeparator && !it.Disabled { return j } } return -1 }