WIP essentials 4

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:11:52 -04:00
1 parent 02800a16fe
commit e682ed4d39
9 files changed
+358

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
package mujica
import (
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ChoiceChipsOptions configure ChoiceChips.
type ChoiceChipsOptions struct {
// Multiple lets several chips be on; otherwise one or none.
Multiple bool
Disabled bool
// Label names the chip set for assistive technology.
Label string
}
// ChoiceChips are wrapping filter chips toggling values in *value; arrows move between them.
func ChoiceChips[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], opts ChoiceChipsOptions) ChoiceResult {
if value == nil {
panic("mujica: ChoiceChips needs a value")
}
if !opts.Multiple && len(*value) > 1 {
panic("mujica: single ChoiceChips holds more than one value")
}
k := Tokens(c)
d := Density(c)
g := ui.Row(c).Wrap().Gap(d.Unit()*2).Disabled(opts.Disabled).Role(ui.RoleGroup).FocusGroup(ui.Horizontal)
if opts.Label != "" {
g.Label(opts.Label)
}
changed := false
g.Children(func() {
for _, it := range items {
on := slices.Contains(*value, it.Value)
pressed := on
b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled)
// The base took input before Disabled applied: drop a disabled chip's press.
if b.Changed() && !b.IsDisabled() && toggleGroupApply(value, it.Value, pressed, ToggleGroupOptions{Multiple: opts.Multiple, AllowEmpty: true}) {
changed, on = true, pressed
}
choiceChipLook(c, k, d, b, on, it)
}
})
if changed {
slog.Debug("mujica: choice_chips changed", "label", opts.Label, "value", *value)
}
return ChoiceResult{g, changed}
}
func choiceChipLook[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, b *ui.Element, on bool, it ToggleItem[T]) {
b.MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).MinWidth(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit()*1.5).
Radius(theme.PopupRadius)
internal.Ring(b, k.Focus, theme.PopupRadius)
bg, fg, line := k.Surface, k.Text, k.ControlBorder
switch {
case on:
bg, fg, line = k.Selection, k.AccentText, k.AccentText
case b.Pressed():
bg = k.Selection
case b.Hovered():
bg = k.SurfaceHover
}
b.Background(bg).TextColor(fg).Border(theme.BorderWidth, line).Transition(colorFade)
if it.Name != "" {
b.Label(it.Name)
}
b.Children(func() {
switch {
case on:
ui.Icon(c, icons.Must("check")).Size(14, 14)
case it.Icon != nil:
ui.Icon(c, it.Icon).Size(14, 14)
}
if it.Label != "" {
ui.Text(c, it.Label).SingleLine()
}
})
}
+85
View File
@@ -0,0 +1,85 @@
package mujica
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
var chipItems = []ToggleItem[string]{{Value: "open", Label: "Open"}, {Value: "draft", Label: "Draft"}, {Value: "closed", Label: "Closed", Disabled: true}, {Value: "merged", Label: "Merged"}}
func chipTester(v *[]string, opts *ChoiceChipsOptions, n *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if ChoiceChips(c, v, chipItems, *opts).Changed() {
*n++
}
}, 600, 200)
}
func TestChoiceChipsSingleAndMultiple(t *testing.T) {
var v []string
opts := ChoiceChipsOptions{}
n := 0
tt := chipTester(&v, &opts, &n)
_ = tt.Click("Open")
_ = tt.Click("Draft")
if !slices.Equal(v, []string{"draft"}) || n != 2 {
t.Fatalf("single: %v %d", v, n)
}
_ = tt.Click("Draft")
if len(v) != 0 {
t.Fatalf("single chip did not turn off: %v", v)
}
_ = tt.Click("Closed")
if len(v) != 0 || n != 3 {
t.Fatalf("disabled chip: %v %d", v, n)
}
opts.Multiple = true
_ = tt.Click("Open")
_ = tt.Click("Merged")
if !slices.Equal(v, []string{"open", "merged"}) {
t.Fatalf("multiple: %v", v)
}
}
func TestChoiceChipsKeyboard(t *testing.T) {
var v []string
opts := ChoiceChipsOptions{Multiple: true}
n := 0
tt := chipTester(&v, &opts, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeySpace)
tt.Key(0, ui.KeyRight) // skips the disabled chip
tt.Key(0, ui.KeySpace)
if !slices.Equal(v, []string{"draft", "merged"}) {
t.Fatalf("keyboard: %v", v)
}
opts.Disabled = true
tt.Frame()
_ = tt.Click("Open")
if len(v) != 2 {
t.Fatalf("disabled set changed: %v", v)
}
}
func TestChoiceChipsRejectsTwoInSingle(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
v := []string{"open", "draft"}
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ChoiceChips(c, &v, chipItems, ChoiceChipsOptions{}) }, 300, 100)
}
func TestChoiceChipsSnapshot(t *testing.T) {
v := []string{"open", "merged"}
snapshot(t, "choice_chips", 480, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Children(func() {
ChoiceChips(c, &v, chipItems, ChoiceChipsOptions{Multiple: true})
})
})
}
+109
View File
@@ -0,0 +1,109 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// ColorSwatch is one preset of a ColorPalette; Name is read out.
type ColorSwatch struct {
Color ui.Color
Name string
}
// ColorPaletteOptions configure a ColorPalette.
type ColorPaletteOptions struct {
// Columns is the grid width in swatches; zero is 8.
Columns int
// Custom adds a ColorPicker after the swatches for any other color.
Custom bool
Disabled bool
// Label names the palette for assistive technology.
Label string
}
// ColorPalette picks *value from preset swatches in a grid; arrows move in two dimensions.
func ColorPalette(c *ui.Context, value *ui.Color, swatches []ColorSwatch, opts ColorPaletteOptions) ChoiceResult {
if value == nil || len(swatches) == 0 {
panic("mujica: ColorPalette needs a value and swatches")
}
if opts.Columns < 0 {
panic(fmt.Sprintf("mujica: ColorPalette Columns %d is negative", opts.Columns))
}
cols := opts.Columns
if cols == 0 {
cols = 8
}
for _, s := range swatches {
if s.Name == "" {
panic("mujica: ColorPalette swatch needs a Name")
}
}
k := Tokens(c)
u := Density(c).Unit()
g := ui.Column(c).Gap(u * 2).Disabled(opts.Disabled).Role(ui.RoleRadioGroup).Label(opts.Label)
disabled := g.IsDisabled()
changed := false
g.Children(func() {
grid := ui.Box(c).Grid().Columns(cols).Gap(u * 1.5).FocusGroup(ui.Horizontal)
var cells []*ui.Element
grid.Children(func() {
for i, s := range swatches {
b := colorPaletteSwatch(c, k, s, *value == s.Color).Key(i)
cells = append(cells, b)
if b.Clicked() && !disabled && *value != s.Color {
*value, changed = s.Color, true
}
}
})
// Up and Down move a whole row; FocusGroup moves by one.
for i, b := range cells {
switch {
case b.Shortcut(0, ui.KeyDown) && i+cols < len(cells):
cells[i+cols].Focus()
case b.Shortcut(0, ui.KeyUp) && i-cols >= 0:
cells[i-cols].Focus()
}
}
if opts.Custom {
ui.Row(c).Children(func() {
if ColorPicker(c, value, ColorPickerOptions{Label: msg(c, "color_palette.custom"), Disabled: disabled}).Changed() {
changed = true
}
})
}
})
if changed {
slog.Debug("mujica: color_palette changed", "color", colorPickerHex(*value))
}
return ChoiceResult{g, changed}
}
// colorPaletteSwatch is one square swatch; the chosen one has an inset frame and a check.
func colorPaletteSwatch(c *ui.Context, k theme.Tokens, s ColorSwatch, on bool) *ui.Element {
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RoleRadio).
Label(s.Name).Tooltip(s.Name).Selected(on)
internal.Ring(b, k.Focus, theme.ControlRadius)
edge := k.ControlBorder
if b.Hovered() {
edge = k.Text
}
b.Background(s.Color).Border(theme.BorderWidth, edge)
mark := theme.Light().Text
if theme.Contrast(s.Color, mark) < theme.Contrast(s.Color, theme.Light().OnAccent) {
mark = theme.Light().OnAccent
}
b.DrawOver(func(p *ui.Painter, r ui.Rect) {
if !on {
return
}
p.Stroke(ui.Rect{X: r.X + 3, Y: r.Y + 3, W: r.W - 6, H: r.H - 6}, mark, 1, 1.5)
internal.Mark(p, ui.Rect{X: r.X + 7, Y: r.Y + 7, W: r.W - 14, H: r.H - 14}, "check", mark)
})
return b
}
+78
View File
@@ -0,0 +1,78 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func paletteSwatches() []ColorSwatch {
var s []ColorSwatch
for i, c := range colorPickerPresets {
s = append(s, ColorSwatch{Color: c, Name: "Tone " + string(rune('A'+i))})
}
return s
}
func TestColorPaletteChooseAndKeyboard(t *testing.T) {
v := colorPickerPresets[0]
dis := false
n := 0
sw := paletteSwatches()
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if ColorPalette(c, &v, sw, ColorPaletteOptions{Columns: 4, Custom: true, Disabled: dis, Label: "Tag color"}).Changed() {
n++
}
}, 500, 400)
if err := tt.Click("Tone C"); err != nil || v != sw[2].Color || n != 1 {
t.Fatalf("click: %v %v %d", err, v, n)
}
tt.Key(0, ui.KeyDown) // focus stays on C: down a row is G
tt.Key(0, ui.KeyEnter)
if v != sw[6].Color {
t.Fatalf("Down then Enter chose %v, want %v", v, sw[6].Color)
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeySpace)
if v != sw[3].Color {
t.Fatalf("Right, Up, Space chose %v", v)
}
if _, ok := tt.Find("Custom color"); !ok {
t.Fatal("no custom color entry")
}
dis = true
tt.Frame()
_ = tt.Click("Tone A")
if v != sw[3].Color || n != 3 {
t.Fatalf("disabled palette changed: %v %d", v, n)
}
}
func TestColorPaletteRejects(t *testing.T) {
v := ui.Hex("#000000")
for name, f := range map[string]func(c *ui.Context){
"no name": func(c *ui.Context) { ColorPalette(c, &v, []ColorSwatch{{Color: v}}, ColorPaletteOptions{}) },
"columns": func(c *ui.Context) { ColorPalette(c, &v, paletteSwatches(), ColorPaletteOptions{Columns: -1}) },
"no items": func(c *ui.Context) { ColorPalette(c, &v, nil, ColorPaletteOptions{}) },
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200)
}()
}
}
func TestColorPaletteSnapshot(t *testing.T) {
v := colorPickerPresets[2]
snapshot(t, "color_palette", 300, 150, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
ColorPalette(c, &v, paletteSwatches(), ColorPaletteOptions{Columns: 6, Custom: true})
})
})
}
+2
View File
@@ -14,6 +14,8 @@ func init() {
"bulk_action_bar.selected": {"%d selected", "已选 %d 项"},
"bulk_action_bar.clear": {"Clear selection", "清除选择"},
"color_palette.custom": {"Custom color", "自定义颜色"},
"qr_code.label": {"QR code", "二维码"},
"qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"},
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB