WIP essentials 4
This commit is contained in:
9 files changed
+358
No files matched your search
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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", "文字过长,无法生成二维码"},
|
||||
})
|
||||
|
||||
BIN
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 |
Reference in new issue
Block a user