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.
225 lines
6.4 KiB
Go
225 lines
6.4 KiB
Go
package mujica
|
|
|
|
import (
|
|
"log/slog"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"mujicaui/icons"
|
|
"mujicaui/internal"
|
|
"mujicaui/theme"
|
|
)
|
|
|
|
// Command is an action of a CommandPalette.
|
|
type Command struct {
|
|
ID string
|
|
Label string
|
|
// Group heads the command in the list; commands keep their order within groups.
|
|
Group string
|
|
// Keywords also match the search.
|
|
Keywords []string
|
|
Icon *ui.SVG
|
|
}
|
|
|
|
// CommandPaletteOptions configure a CommandPalette.
|
|
type CommandPaletteOptions struct {
|
|
// OpenMods and OpenKey open the palette; zero key means Cmd+K (Ctrl+K off macOS).
|
|
OpenMods ui.Modifiers
|
|
OpenKey ui.Key
|
|
}
|
|
|
|
// CommandPaletteResult is the built CommandPalette; Element is nil while closed.
|
|
type CommandPaletteResult struct {
|
|
Element *ui.Element
|
|
chosen string
|
|
}
|
|
|
|
// Chosen returns the ID of the command run in this frame; running closes the palette.
|
|
func (r CommandPaletteResult) Chosen() (string, bool) { return r.chosen, r.chosen != "" }
|
|
|
|
type commandPaletteKey struct{ open *bool }
|
|
|
|
type commandPaletteState struct {
|
|
query string
|
|
hl int
|
|
ptr [2]float32
|
|
wasOpen bool
|
|
reveal bool
|
|
}
|
|
|
|
// CommandPalette is a searchable command dialog opened by a shortcut or by setting *open.
|
|
func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandPaletteOptions) CommandPaletteResult {
|
|
seen := map[string]bool{}
|
|
for _, cmd := range commands {
|
|
if cmd.ID == "" || cmd.Label == "" {
|
|
panic("mujica: Command needs an ID and a Label")
|
|
}
|
|
if seen[cmd.ID] {
|
|
panic("mujica: duplicate Command ID " + cmd.ID)
|
|
}
|
|
seen[cmd.ID] = true
|
|
}
|
|
mods, key := opts.OpenMods, opts.OpenKey
|
|
if key == 0 {
|
|
mods, key = ui.Cmd, ui.KeyK
|
|
}
|
|
st := ui.Local(c.Root(), commandPaletteKey{open}, func() commandPaletteState { return commandPaletteState{} })
|
|
if c.Shortcut(mods, key) && !*open {
|
|
*open = true
|
|
}
|
|
if *open && !st.wasOpen {
|
|
st.query, st.hl = "", 0
|
|
slog.Debug("mujica: command palette open")
|
|
}
|
|
st.wasOpen = *open
|
|
res := CommandPaletteResult{}
|
|
k := Tokens(c)
|
|
d := Density(c)
|
|
u := d.Unit()
|
|
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
|
|
ww, wh := c.Size()
|
|
p := overlayEnter(c, panel)
|
|
back.Background(ui.RGBA(0, 0, 0, 0.4*p)).Justify(ui.Start)
|
|
overlayStyle(c, panel, p)
|
|
panel.Margin(wh*0.12, 0, 0, 0).Width(560).MaxWidth(ww - 32).MaxHeight(wh * 0.76).
|
|
AlignItems(ui.Stretch).Label(msg(c, "command_palette.label"))
|
|
found := commandPaletteFilter(commands, st.query)
|
|
var in *ui.Element
|
|
ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*3, u*4).Children(func() {
|
|
ui.Icon(c, icons.Must("search")).Size(16, 16).TextColor(k.TextMuted)
|
|
in = ui.TextInputBase(c, &st.query).FillWidth().AutoFocus().Label(msg(c, "command_palette.search"))
|
|
})
|
|
ui.Box(c).Height(1).Background(k.Ornament).Role(ui.RoleNone)
|
|
if in.Changed() {
|
|
st.hl, st.reveal = 0, true
|
|
}
|
|
n := len(found)
|
|
switch {
|
|
case n == 0:
|
|
case in.Shortcut(0, ui.KeyDown):
|
|
st.hl, st.reveal = (st.hl+1)%n, true
|
|
case in.Shortcut(0, ui.KeyUp):
|
|
st.hl, st.reveal = (st.hl-1+n)%n, true
|
|
}
|
|
st.hl = max(0, min(st.hl, n-1))
|
|
// Focus stays in the search box, so the highlighted command is read out.
|
|
if n > 0 {
|
|
in.Description(found[st.hl].Label)
|
|
if st.reveal {
|
|
c.Announce(found[st.hl].Label)
|
|
}
|
|
} else {
|
|
in.Description(msg(c, "command_palette.empty"))
|
|
}
|
|
run := -1
|
|
if in.Submitted() && n > 0 {
|
|
run = st.hl
|
|
}
|
|
list := ui.Scroll(c).Padding(u * 2).Gap(2).MaxHeight(360).Role(ui.RoleList)
|
|
list.Children(func() {
|
|
if n == 0 {
|
|
ui.Text(c, msg(c, "command_palette.empty")).TextColor(k.TextMuted).Padding(u*6, u*2).TextAlign(ui.Center).FillWidth()
|
|
return
|
|
}
|
|
gutter := slices.ContainsFunc(found, func(cmd Command) bool { return cmd.Icon != nil })
|
|
group := "\x00"
|
|
for i, cmd := range found {
|
|
if cmd.Group != group {
|
|
group = cmd.Group
|
|
if group != "" {
|
|
ui.Text(c, group).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()
|
|
}
|
|
}
|
|
if commandPaletteRow(c, cmd, i, st, gutter) {
|
|
run = i
|
|
}
|
|
}
|
|
})
|
|
ui.Row(c).Gap(u*4).Padding(u*2, u*4).Background(k.Background).Children(func() {
|
|
for _, hint := range []string{"command_palette.hint_move", "command_palette.hint_run", "command_palette.hint_close"} {
|
|
ui.Text(c, msg(c, hint)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
|
}
|
|
})
|
|
if run >= 0 {
|
|
res.chosen = found[run].ID
|
|
*open = false
|
|
slog.Debug("mujica: command palette run", "id", res.chosen)
|
|
}
|
|
})
|
|
if !*open {
|
|
st.wasOpen = false
|
|
}
|
|
return res
|
|
}
|
|
|
|
// commandPaletteRow builds row i and reports a click on it.
|
|
func commandPaletteRow(c *ui.Context, cmd Command, i int, st *commandPaletteState, gutter bool) bool {
|
|
k := Tokens(c)
|
|
d := Density(c)
|
|
u := d.Unit()
|
|
row := ui.Row(c).AlignItems(ui.Center).Gap(u*3).MinHeight(d.ControlHeight()).Padding(0, u*3).
|
|
Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(cmd.Label).TextColor(k.Text).Cursor(ui.CursorPointer)
|
|
on := i == st.hl
|
|
if on {
|
|
row.Background(k.Selection).FontWeight(600)
|
|
row.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)
|
|
})
|
|
if st.reveal {
|
|
row.ScrollIntoView()
|
|
st.reveal = false
|
|
}
|
|
}
|
|
internal.Ease(row, Motion(c, theme.StateDuration), k.Text)
|
|
px, py, over := row.PointerPosition()
|
|
b := row.Bounds()
|
|
if at := [2]float32{b.X + px, b.Y + py}; over && at != st.ptr {
|
|
st.ptr = at
|
|
if !on {
|
|
st.hl = i
|
|
c.Invalidate()
|
|
}
|
|
}
|
|
row.Children(func() {
|
|
if cmd.Icon != nil {
|
|
ui.Icon(c, cmd.Icon).Size(16, 16).TextColor(k.TextMuted)
|
|
} else if gutter {
|
|
ui.Box(c).Size(16, 16).Role(ui.RoleNone)
|
|
}
|
|
ui.Text(c, cmd.Label).SingleLine()
|
|
})
|
|
return row.Clicked()
|
|
}
|
|
|
|
// commandPaletteFilter keeps commands matching every word of query, grouped by first appearance.
|
|
func commandPaletteFilter(commands []Command, query string) []Command {
|
|
words := strings.Fields(strings.ToLower(query))
|
|
var groups []string
|
|
byGroup := map[string][]Command{}
|
|
for _, cmd := range commands {
|
|
hay := strings.ToLower(cmd.Label + " " + cmd.Group + " " + strings.Join(cmd.Keywords, " "))
|
|
ok := true
|
|
for _, w := range words {
|
|
if !strings.Contains(hay, w) {
|
|
ok = false
|
|
break
|
|
}
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, seen := byGroup[cmd.Group]; !seen {
|
|
groups = append(groups, cmd.Group)
|
|
}
|
|
byGroup[cmd.Group] = append(byGroup[cmd.Group], cmd)
|
|
}
|
|
var out []Command
|
|
for _, g := range groups {
|
|
out = append(out, byGroup[g]...)
|
|
}
|
|
return out
|
|
}
|