diff --git a/choice_chips.go b/choice_chips.go new file mode 100644 index 0000000..c979143 --- /dev/null +++ b/choice_chips.go @@ -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() + } + }) +} diff --git a/choice_chips_test.go b/choice_chips_test.go new file mode 100644 index 0000000..531a86a --- /dev/null +++ b/choice_chips_test.go @@ -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}) + }) + }) +} diff --git a/color_palette.go b/color_palette.go new file mode 100644 index 0000000..89b0df0 --- /dev/null +++ b/color_palette.go @@ -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 +} diff --git a/color_palette_test.go b/color_palette_test.go new file mode 100644 index 0000000..93eb283 --- /dev/null +++ b/color_palette_test.go @@ -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}) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index 3943040..fa7fc68 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -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", "文字过长,无法生成二维码"}, }) diff --git a/testdata/snapshots/darwin/choice_chips-dark.png b/testdata/snapshots/darwin/choice_chips-dark.png new file mode 100644 index 0000000..3a10fcf Binary files /dev/null and b/testdata/snapshots/darwin/choice_chips-dark.png differ diff --git a/testdata/snapshots/darwin/choice_chips-light.png b/testdata/snapshots/darwin/choice_chips-light.png new file mode 100644 index 0000000..473b5a3 Binary files /dev/null and b/testdata/snapshots/darwin/choice_chips-light.png differ diff --git a/testdata/snapshots/darwin/color_palette-dark.png b/testdata/snapshots/darwin/color_palette-dark.png new file mode 100644 index 0000000..8c4106e Binary files /dev/null and b/testdata/snapshots/darwin/color_palette-dark.png differ diff --git a/testdata/snapshots/darwin/color_palette-light.png b/testdata/snapshots/darwin/color_palette-light.png new file mode 100644 index 0000000..65fe9ad Binary files /dev/null and b/testdata/snapshots/darwin/color_palette-light.png differ