internal.PopIn is gone. Breadcrumb, NavigationMenu, Pagination, Menubar panels, CommandPalette, Select (and the other selectPanel users), datetime popups, ColorPicker and the StatusBar overflow now style their panel with overlayStyle(c, panel, overlayEnter(c, panel)): one shadow (0 6 18, 16% black, scaled by entry) and one 160 ms entry fade. Snapshots: color_picker and command_palette (light and dark) change only in the panel shadow. ColorPicker loses its 0 4 12 18% shadow for the softer shared one; CommandPalette loses its heavy 0 12 32 30% shadow, and its backdrop now fades by alpha with the panel instead of through the backdrop's opacity. Fade and reduced-motion tests pass unchanged.
291 lines
7.9 KiB
Go
291 lines
7.9 KiB
Go
package mujica
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"mujicaui/internal"
|
|
"mujicaui/theme"
|
|
)
|
|
|
|
// StatusItemTone colors a status item's text and icon. The zero value is muted text.
|
|
type StatusItemTone int
|
|
|
|
const (
|
|
StatusItemNeutral StatusItemTone = iota
|
|
StatusItemSuccess
|
|
StatusItemWarning
|
|
StatusItemDanger
|
|
StatusItemInfo
|
|
)
|
|
|
|
// StatusItem is one entry of a StatusBar.
|
|
type StatusItem struct {
|
|
// ID is the item's stable key and the value StatusBarResult.Clicked reports.
|
|
ID string
|
|
Text string
|
|
Icon *ui.SVG
|
|
Tone StatusItemTone
|
|
// ShowProgress draws a short bar filled to Progress, from 0 to 1.
|
|
ShowProgress bool
|
|
Progress float32
|
|
// Action makes the item a button.
|
|
Action bool
|
|
// Label names an item without Text for assistive technology.
|
|
Label string
|
|
// Secondary items hide first, last first, when the bar is too narrow.
|
|
Secondary bool
|
|
}
|
|
|
|
// StatusBarOptions configure a StatusBar.
|
|
type StatusBarOptions struct {
|
|
Left, Right []StatusItem
|
|
}
|
|
|
|
// StatusBarResult is a StatusBar and its event in this frame.
|
|
type StatusBarResult struct {
|
|
Element *ui.Element
|
|
// Clicked is the ID of the action item clicked, or empty.
|
|
Clicked string
|
|
}
|
|
|
|
const (
|
|
statusBarProgressWidth float32 = 64
|
|
statusBarIcon float32 = 16
|
|
)
|
|
|
|
// StatusBar is a window's bottom strip with left and right groups of status items.
|
|
func StatusBar(c *ui.Context, opts StatusBarOptions) StatusBarResult {
|
|
k := Tokens(c)
|
|
d := Density(c)
|
|
statusBarCheck(opts)
|
|
gap := d.Unit() * 2
|
|
e := ui.Row(c).MinHeight(max(theme.MinHitSize, d.ControlHeight()-8)).Shrink(0).PaddingX(d.Unit()*2).Gap(gap).
|
|
Background(k.Surface).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border).
|
|
Label(msg(c, "status_bar.label"))
|
|
was := e.Bounds()
|
|
internal.Remeasure(c, e, was.W, was.H)
|
|
hidden := statusBarHide(c, opts, was.W-d.Unit()*4, gap)
|
|
open := ui.Local(e, "status_bar.open", func() bool { return false })
|
|
*open = *open && len(hidden) > 0
|
|
// focused is the bar item that had the focus last frame, for moving it to "+N" when that item folds.
|
|
focused := ui.Local(e, "status_bar.focused", func() string { return "" })
|
|
wasFocused := *focused
|
|
*focused = ""
|
|
var res StatusBarResult
|
|
item := func(it StatusItem) bool {
|
|
clicked, hasFocus := statusBarItem(c, it)
|
|
if clicked {
|
|
res.Clicked = it.ID
|
|
*open = false
|
|
slog.Debug("mujica: status_bar clicked", "id", it.ID)
|
|
}
|
|
return hasFocus
|
|
}
|
|
group := func(key string, items []StatusItem) {
|
|
ui.Row(c).Key(key).Gap(gap).Shrink(0).Dividers(theme.BorderWidth, k.Border).Children(func() {
|
|
for _, it := range items {
|
|
if !hidden[it.ID] && item(it) {
|
|
*focused = it.ID
|
|
}
|
|
}
|
|
})
|
|
}
|
|
e.Children(func() {
|
|
group("status_bar.left", opts.Left)
|
|
ui.Spacer(c).Key("status_bar.spacer")
|
|
if len(hidden) > 0 {
|
|
more := statusBarMore(c, len(hidden))
|
|
if wasFocused != "" && hidden[wasFocused] {
|
|
more.Focus()
|
|
slog.Debug("mujica: status_bar focus folded", "id", wasFocused)
|
|
}
|
|
if more.Clicked() {
|
|
*open = !*open
|
|
slog.Debug("mujica: status_bar hidden items", "open", *open)
|
|
}
|
|
if *open {
|
|
ui.PopoverBase(c, more, open, func(panel *ui.Element) {
|
|
overlayStyle(c, panel, overlayEnter(c, panel))
|
|
panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit()).Gap(d.Unit()).AlignItems(ui.Stretch)
|
|
for _, it := range statusBarAll(opts) {
|
|
if hidden[it.ID] {
|
|
item(it)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
group("status_bar.right", opts.Right)
|
|
})
|
|
res.Element = e
|
|
return res
|
|
}
|
|
|
|
// statusBarMore is the "+N" button that opens the hidden items.
|
|
func statusBarMore(c *ui.Context, n int) *ui.Element {
|
|
k := Tokens(c)
|
|
b := ui.ButtonBase(c).Key("status_bar.more").MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).
|
|
PaddingX(Density(c).Unit()).Radius(theme.ControlRadius).TextColor(k.Text).
|
|
Label(fmt.Sprintf(msg(c, "status_bar.hidden"), n))
|
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
|
if b.Pressed() {
|
|
b.Background(k.Selection)
|
|
} else if b.Hovered() {
|
|
b.Background(k.SurfaceHover)
|
|
}
|
|
return b.Children(func() {
|
|
ui.Text(c, fmt.Sprintf("+%d", n)).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
|
|
})
|
|
}
|
|
|
|
func statusBarAll(opts StatusBarOptions) []StatusItem {
|
|
return append(append([]StatusItem{}, opts.Left...), opts.Right...)
|
|
}
|
|
|
|
func statusBarCheck(opts StatusBarOptions) {
|
|
seen := map[string]bool{}
|
|
for _, it := range statusBarAll(opts) {
|
|
switch {
|
|
case it.ID == "":
|
|
panic("mujica: StatusItem needs an ID")
|
|
case seen[it.ID]:
|
|
panic("mujica: duplicate StatusItem ID " + it.ID)
|
|
case it.Progress < 0 || it.Progress > 1:
|
|
panic("mujica: StatusItem Progress must be within 0 and 1")
|
|
case it.Text == "" && it.Label == "":
|
|
panic("mujica: StatusItem " + it.ID + " needs Text or Label")
|
|
}
|
|
seen[it.ID] = true
|
|
}
|
|
}
|
|
|
|
// statusBarHide returns the secondary items to hide, last first, so the rest fit in room DIPs.
|
|
func statusBarHide(c *ui.Context, opts StatusBarOptions, room, gap float32) map[string]bool {
|
|
hidden := map[string]bool{}
|
|
if room <= 0 {
|
|
// Not laid out yet: show all; Remeasure rebuilds once measured.
|
|
return hidden
|
|
}
|
|
u := Density(c).Unit()
|
|
group := func(items []StatusItem) float32 {
|
|
var w float32
|
|
n := 0
|
|
for _, it := range items {
|
|
if !hidden[it.ID] {
|
|
w += statusBarWidth(c, it)
|
|
n++
|
|
}
|
|
}
|
|
return w + gap*float32(max(n-1, 0))
|
|
}
|
|
// The bar's children: left group, spacer, the "+N" button when shown, right group.
|
|
need := func() float32 {
|
|
w := group(opts.Left) + group(opts.Right) + 2*gap
|
|
if len(hidden) > 0 {
|
|
tw, _ := c.MeasureText(0, ui.Span{Text: fmt.Sprintf("+%d", len(hidden)), Size: FontSize(c, theme.CaptionSize)})
|
|
w += max(theme.MinHitSize, tw+2*u) + gap
|
|
}
|
|
return w
|
|
}
|
|
all := statusBarAll(opts)
|
|
for i := len(all) - 1; i >= 0 && need() > room; i-- {
|
|
if all[i].Secondary {
|
|
hidden[all[i].ID] = true
|
|
}
|
|
}
|
|
return hidden
|
|
}
|
|
|
|
// statusBarWidth is the width an item takes, as statusBarItem lays it out.
|
|
func statusBarWidth(c *ui.Context, it StatusItem) float32 {
|
|
u := Density(c).Unit()
|
|
var parts []float32
|
|
if it.Icon != nil {
|
|
parts = append(parts, statusBarIcon)
|
|
}
|
|
if it.Text != "" {
|
|
tw, _ := c.MeasureText(0, ui.Span{Text: it.Text, Size: FontSize(c, theme.CaptionSize)})
|
|
parts = append(parts, tw)
|
|
}
|
|
if it.ShowProgress {
|
|
parts = append(parts, statusBarProgressWidth)
|
|
}
|
|
w := 2 * u
|
|
for i, p := range parts {
|
|
w += p
|
|
if i > 0 {
|
|
w += u
|
|
}
|
|
}
|
|
if it.Action {
|
|
w = max(w, theme.MinHitSize)
|
|
}
|
|
return w
|
|
}
|
|
|
|
// statusBarItem builds one item and reports whether its action was clicked and whether it has the focus.
|
|
func statusBarItem(c *ui.Context, it StatusItem) (clicked, focused bool) {
|
|
k := Tokens(c)
|
|
d := Density(c)
|
|
fg := k.TextMuted
|
|
if it.Action {
|
|
fg = k.Text
|
|
}
|
|
switch it.Tone {
|
|
case StatusItemNeutral:
|
|
case StatusItemSuccess:
|
|
fg = k.Success
|
|
case StatusItemWarning:
|
|
fg = k.Warning
|
|
case StatusItemDanger:
|
|
fg = k.Danger
|
|
case StatusItemInfo:
|
|
fg = k.Info
|
|
default:
|
|
panic("mujica: invalid StatusItemTone")
|
|
}
|
|
var e *ui.Element
|
|
if it.Action {
|
|
e = ui.ButtonBase(c).Key(it.ID).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Radius(theme.ControlRadius)
|
|
internal.Ring(e, k.Focus, theme.ControlRadius)
|
|
if e.Pressed() {
|
|
e.Background(k.Selection)
|
|
} else if e.Hovered() {
|
|
e.Background(k.SurfaceHover)
|
|
}
|
|
} else {
|
|
e = ui.Row(c).Key(it.ID).MinHeight(theme.MinHitSize)
|
|
}
|
|
e.PaddingX(d.Unit()).Gap(d.Unit()).TextColor(fg).Shrink(0)
|
|
if it.Label != "" {
|
|
e.Label(it.Label)
|
|
}
|
|
e.Children(func() {
|
|
if it.Icon != nil {
|
|
ui.Icon(c, it.Icon).Size(statusBarIcon, statusBarIcon)
|
|
}
|
|
if it.Text != "" {
|
|
ui.Text(c, it.Text).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
|
|
}
|
|
if it.ShowProgress {
|
|
statusBarProgress(c, it.Progress)
|
|
}
|
|
})
|
|
return it.Action && e.Clicked(), e.FocusWithin()
|
|
}
|
|
|
|
func statusBarProgress(c *ui.Context, v float32) {
|
|
k := Tokens(c)
|
|
ui.Box(c).Size(statusBarProgressWidth, 4).Shrink(0).Role(ui.RoleProgress).
|
|
Description(fmt.Sprintf("%.0f%%", v*100)).
|
|
Draw(func(p *ui.Painter, r ui.Rect) {
|
|
p.Fill(r, k.Border, 2)
|
|
if v > 0 {
|
|
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: max(r.W*v, r.H), H: r.H}, k.AccentText, 2)
|
|
}
|
|
})
|
|
}
|