package mujica import ( "log/slog" "strings" "github.com/egoist/mygo/ui" "mujicaui/icons" "mujicaui/internal" "mujicaui/theme" ) // NavigationLink is a page of a NavigationMenu, or with Items a group of pages. type NavigationLink struct { Label string // Path is pushed on the router; groups need none. Path string Icon *ui.SVG Description string Items []NavigationLink } // NavigationMenuOptions configure a NavigationMenu. type NavigationMenuOptions struct { // History shows back and forward buttons for the router. History bool // Label names the menu for assistive technology. Label string } // NavigationMenu shows links pushing pages on router; the router's path marks the current one. func NavigationMenu(c *ui.Context, router *ui.Router, links []NavigationLink, opts NavigationMenuOptions) *ui.Element { if router == nil { panic("mujica: NavigationMenu needs a router") } if len(links) == 0 { panic("mujica: NavigationMenu needs links") } k := Tokens(c) u := Density(c).Unit() path := router.Path() label := opts.Label if label == "" { label = msg(c, "navigation_menu.label") } push := func(p string) { if p != path { slog.Debug("mujica: navigation menu push", "from", path, "to", p) router.Push(p) } } row := ui.Row(c).AlignItems(ui.Center).Gap(u * 2) row.Children(func() { if opts.History { ui.Row(c).Gap(u).Children(func() { navigationMenuHistory(c, ui.BackButton(c, router), msg(c, "navigation_menu.back")) navigationMenuHistory(c, ui.ForwardButton(c, router), msg(c, "navigation_menu.forward")) }) ui.Box(c).Size(1, 20).Background(k.Ornament).Role(ui.RoleNone) } ui.Row(c).AlignItems(ui.Center).Gap(u).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(label).Children(func() { for _, l := range links { navigationMenuTop(c, l, path, push) } }) }) return row } func navigationMenuHistory(c *ui.Context, b *ui.Element, name string) { k := Tokens(c) d := Density(c) b.Label(name).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).Radius(theme.ControlRadius).Background(k.Surface) internal.Ring(b, k.Focus, theme.ControlRadius) } // navigationMenuActive reports whether l or a link inside it is the page at path. func navigationMenuActive(l NavigationLink, path string) bool { // The root page is current only on itself, not under every path. if l.Path != "" && (path == l.Path || l.Path != "/" && strings.HasPrefix(path, strings.TrimSuffix(l.Path, "/")+"/")) { return true } for _, it := range l.Items { if navigationMenuActive(it, path) { return true } } return false } func navigationMenuTop(c *ui.Context, l NavigationLink, path string, push func(string)) { if l.Label == "" { panic("mujica: NavigationLink needs a Label") } if l.Items == nil && l.Path == "" { panic("mujica: NavigationLink needs a Path or Items") } k := Tokens(c) d := Density(c) u := d.Unit() on := navigationMenuActive(l, path) b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, u*3).Gap(u * 1.5).Radius(theme.ControlRadius). Label(l.Label).Cursor(ui.CursorPointer) open := ui.Local(b, "open", func() bool { return false }) if l.Items == nil { b.Role(ui.RoleLink) } else { b.Role(ui.RoleMenuButton) } fg := k.TextMuted if on { b.FontWeight(600) fg = k.Text } if b.Hovered() || *open { b.Background(k.SurfaceHover) fg = k.Text } internal.Ease(b, Motion(c, theme.StateDuration), fg) b.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) { if on { p.Fill(ui.Rect{X: r.X + u*3, Y: r.Y + r.H - 2, W: r.W - u*6, H: 2}, k.AccentText, 1) } if b.FocusVisible() { internal.FocusRing(p, r, theme.ControlRadius, k.Focus) } }) b.Children(func() { if l.Icon != nil { ui.Icon(c, l.Icon).Size(16, 16) } ui.Text(c, l.Label).SingleLine() if l.Items != nil { ui.Icon(c, icons.Must("chevron-down")).Size(12, 12).TextColor(k.TextMuted) } }) if l.Items == nil { if b.Clicked() { push(l.Path) } return } if b.Clicked() { *open = !*open } else if b.Shortcut(0, ui.KeyDown) { *open = true } ui.PopoverBase(c, b, open, func(panel *ui.Element) { overlayStyle(c, panel, overlayEnter(c, panel)) // Capped to the room beside the anchor; the links scroll inside. _, wh := c.Size() r := b.Bounds() panel.Margin(u, 0, 0, 0).Padding(u * 2).MaxHeight(max(r.Y, wh-r.Y-r.H) - 16).MinWidth(240).MaxWidth(360) ui.Scroll(c).Shrink(1).Gap(u).FocusGroup(ui.Vertical).Children(func() { navigationMenuPanel(c, l.Items, path, open, push) }) }) } // navigationMenuPanel lists a group's links, its subgroups under serif headings. func navigationMenuPanel(c *ui.Context, items []NavigationLink, path string, open *bool, push func(string)) { k := Tokens(c) u := Density(c).Unit() first := true var list func(items []NavigationLink) list = func(items []NavigationLink) { for _, it := range items { if it.Label == "" { panic("mujica: NavigationLink needs a Label") } if it.Items != nil { ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)). Padding(u*2, u*2, u, u*2).SingleLine() list(it.Items) continue } if it.Path == "" { panic("mujica: NavigationLink needs a Path or Items") } if navigationMenuItem(c, it, it.Path == path, first).Clicked() { *open = false push(it.Path) } first = false } } list(items) } func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Element { k := Tokens(c) u := Density(c).Unit() e := ui.ButtonBase(c).Role(ui.RoleLink).Justify(ui.Start).AlignItems(ui.Start).Gap(u*3). Padding(u*2, u*3).Radius(theme.ControlRadius).Label(l.Label).TextColor(k.Text).Cursor(ui.CursorPointer) if first { e.AutoFocus() } internal.Ring(e, k.Focus, theme.ControlRadius) switch { case on: e.Background(k.Selection).FontWeight(600) e.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 3, H: r.H - 8}, k.AccentText, 1.5) }) case e.Hovered(): e.Background(k.SurfaceHover) } internal.Ease(e, Motion(c, theme.StateDuration), k.Text) e.Children(func() { if l.Icon != nil { ui.Icon(c, l.Icon).Size(16, 16).Margin(2, 0, 0, 0) } ui.Column(c).Gap(2).MinWidth(0).Children(func() { ui.Text(c, l.Label).SingleLine() if l.Description != "" { ui.Text(c, l.Description).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontWeight(400) } }) }) return e }