Choices: review r1 fixes
No files matched your search
@@ -43,8 +43,7 @@ type CascaderLevel struct {
|
|||||||
type CascaderOptions struct {
|
type CascaderOptions struct {
|
||||||
// Level returns the children under path; the empty path is the roots.
|
// Level returns the children under path; the empty path is the roots.
|
||||||
Level func(path []string) CascaderLevel
|
Level func(path []string) CascaderLevel
|
||||||
// Load fetches the children under path, once while Idle and on retry; it
|
// Load fetches the children under path while Idle and on retry; it marks the level Loading.
|
||||||
// should mark the level Loading and finish through Window.Update.
|
|
||||||
Load func(path []string)
|
Load func(path []string)
|
||||||
// AnyLevel lets branches be chosen too; by default only leaves are.
|
// AnyLevel lets branches be chosen too; by default only leaves are.
|
||||||
AnyLevel bool
|
AnyLevel bool
|
||||||
@@ -66,9 +65,7 @@ type cascaderState struct {
|
|||||||
|
|
||||||
type cascaderKey struct{}
|
type cascaderKey struct{}
|
||||||
|
|
||||||
// Cascader chooses a path through levels the caller supplies, loading
|
// Cascader chooses a path through levels the caller supplies, loading children on demand.
|
||||||
// children on demand. Up and Down move in a column, Right opens a branch,
|
|
||||||
// Left goes back, Enter or Space chooses; Escape closes.
|
|
||||||
func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) ChoiceResult {
|
func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) ChoiceResult {
|
||||||
if opts.Level == nil {
|
if opts.Level == nil {
|
||||||
panic("mujica: Cascader needs Level")
|
panic("mujica: Cascader needs Level")
|
||||||
@@ -79,6 +76,9 @@ func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) ChoiceResult
|
|||||||
tr.Label(opts.Label)
|
tr.Label(opts.Label)
|
||||||
}
|
}
|
||||||
st := *ui.Local(tr, cascaderKey{}, func() *cascaderState { return &cascaderState{requested: map[string]bool{}} })
|
st := *ui.Local(tr, cascaderKey{}, func() *cascaderState { return &cascaderState{requested: map[string]bool{}} })
|
||||||
|
if opts.Disabled {
|
||||||
|
st.open = false
|
||||||
|
}
|
||||||
if tr.Clicked() || (!st.open && tr.Shortcut(0, ui.KeyDown)) {
|
if tr.Clicked() || (!st.open && tr.Shortcut(0, ui.KeyDown)) {
|
||||||
st.open = !st.open
|
st.open = !st.open
|
||||||
if st.open {
|
if st.open {
|
||||||
@@ -186,7 +186,7 @@ func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts Cascader
|
|||||||
d := Density(c)
|
d := Density(c)
|
||||||
full := append(slices.Clone(prefix), n.Value)
|
full := append(slices.Clone(prefix), n.Value)
|
||||||
depth := len(prefix)
|
depth := len(prefix)
|
||||||
b := ui.ButtonBase(c).Key(n.Value).Disabled(n.Disabled).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*2).
|
b := ui.ButtonBase(c).Key(n.Value).Disabled(n.Disabled).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
|
||||||
Gap(d.Unit() * 2).Justify(ui.Start).Radius(theme.ControlRadius).FillWidth().Role(ui.RoleListItem)
|
Gap(d.Unit() * 2).Justify(ui.Start).Radius(theme.ControlRadius).FillWidth().Role(ui.RoleListItem)
|
||||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||||
if st.focusSet && (slices.Equal(st.focus, full) || first && slices.Equal(st.focus, append(slices.Clone(prefix), ""))) {
|
if st.focusSet && (slices.Equal(st.focus, full) || first && slices.Equal(st.focus, append(slices.Clone(prefix), ""))) {
|
||||||
@@ -214,6 +214,7 @@ func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts Cascader
|
|||||||
st.focus, st.focusSet = slices.Clone(prefix), true
|
st.focus, st.focusSet = slices.Clone(prefix), true
|
||||||
}
|
}
|
||||||
chosen := len(*path) > depth && slices.Equal((*path)[:depth+1], full)
|
chosen := len(*path) > depth && slices.Equal((*path)[:depth+1], full)
|
||||||
|
b.Selected(chosen).Background(ui.Transparent).TextColor(k.Text)
|
||||||
switch {
|
switch {
|
||||||
case on:
|
case on:
|
||||||
b.Background(k.Selection)
|
b.Background(k.Selection)
|
||||||
@@ -224,6 +225,7 @@ func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts Cascader
|
|||||||
b.TextColor(k.AccentText)
|
b.TextColor(k.AccentText)
|
||||||
}
|
}
|
||||||
b.Children(func() {
|
b.Children(func() {
|
||||||
|
selectCheck(c, chosen)
|
||||||
ui.Text(c, n.Label).SingleLine().Grow(1).Shrink(1)
|
ui.Text(c, n.Label).SingleLine().Grow(1).Shrink(1)
|
||||||
if !n.Leaf {
|
if !n.Leaf {
|
||||||
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// choiceBetween builds control between two buttons, for Tab order checks.
|
||||||
|
func choiceBetween(control func(c *ui.Context)) *ui.Tester {
|
||||||
|
return ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{})
|
||||||
|
ui.Column(c).Padding(10).Gap(10).Width(300).Children(func() {
|
||||||
|
Button(c, "Before", ButtonOptions{})
|
||||||
|
control(c)
|
||||||
|
Button(c, "After", ButtonOptions{})
|
||||||
|
})
|
||||||
|
}, 400, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// choiceTabSkips reports whether Tab from Before lands on After.
|
||||||
|
func choiceTabSkips(t *testing.T, tt *ui.Tester) bool {
|
||||||
|
t.Helper()
|
||||||
|
if err := tt.Click("Before"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
return tt.Focused("After")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToggleDisabled(t *testing.T) {
|
||||||
|
for _, disabled := range []bool{false, true} {
|
||||||
|
on := false
|
||||||
|
tt := choiceBetween(func(c *ui.Context) {
|
||||||
|
Toggle(c, &on, "Italic", ToggleOptions{Disabled: disabled})
|
||||||
|
})
|
||||||
|
_ = tt.Click("Italic")
|
||||||
|
if on == disabled {
|
||||||
|
t.Fatalf("disabled=%v: click left on=%v", disabled, on)
|
||||||
|
}
|
||||||
|
if choiceTabSkips(t, tt) != disabled {
|
||||||
|
t.Fatalf("disabled=%v: Tab skip wrong", disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSliderDisabled(t *testing.T) {
|
||||||
|
for _, disabled := range []bool{false, true} {
|
||||||
|
v := 30.0
|
||||||
|
tt := choiceBetween(func(c *ui.Context) {
|
||||||
|
Slider(c, &v, SliderOptions{Step: 1, Disabled: disabled, Label: "Volume"})
|
||||||
|
})
|
||||||
|
_ = tt.Click("Volume")
|
||||||
|
if (v != 30) == disabled {
|
||||||
|
t.Fatalf("disabled=%v: click left %v", disabled, v)
|
||||||
|
}
|
||||||
|
if choiceTabSkips(t, tt) != disabled {
|
||||||
|
t.Fatalf("disabled=%v: Tab skip wrong", disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRangeSliderDisabled(t *testing.T) {
|
||||||
|
for _, disabled := range []bool{false, true} {
|
||||||
|
low, high := 20.0, 40.0
|
||||||
|
tt := choiceBetween(func(c *ui.Context) {
|
||||||
|
RangeSlider(c, &low, &high, RangeSliderOptions{Step: 1, Disabled: disabled, Label: "Price"})
|
||||||
|
})
|
||||||
|
_ = tt.Click("Price")
|
||||||
|
if (high != 40) == disabled || low != 20 {
|
||||||
|
t.Fatalf("disabled=%v: click left %v..%v", disabled, low, high)
|
||||||
|
}
|
||||||
|
if choiceTabSkips(t, tt) != disabled {
|
||||||
|
t.Fatalf("disabled=%v: Tab skip wrong", disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabling a focused control in the frame a key arrives must block the key.
|
||||||
|
func TestChoiceDisabledWhileFocused(t *testing.T) {
|
||||||
|
disabled := false
|
||||||
|
on, pressed, group := false, false, []string{"left"}
|
||||||
|
items := []ToggleItem[string]{{Value: "left", Label: "Left"}, {Value: "right", Label: "Right"}}
|
||||||
|
for name, build := range map[string]func(c *ui.Context){
|
||||||
|
"switch": func(c *ui.Context) { Switch(c, &on, "Sync", SwitchOptions{Disabled: disabled}) },
|
||||||
|
"toggle": func(c *ui.Context) { Toggle(c, &pressed, "Bold", ToggleOptions{Disabled: disabled}) },
|
||||||
|
"group": func(c *ui.Context) {
|
||||||
|
items[1].Disabled = disabled
|
||||||
|
ToggleGroup(c, &group, items, ToggleGroupOptions{})
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
disabled, on, pressed, group = false, false, false, []string{"left"}
|
||||||
|
tt := choiceBetween(build)
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
if name == "group" {
|
||||||
|
tt.Key(0, ui.KeyRight)
|
||||||
|
}
|
||||||
|
disabled = true
|
||||||
|
tt.Key(0, ui.KeySpace)
|
||||||
|
if on || pressed || group[0] != "left" {
|
||||||
|
t.Errorf("%s: Space changed a disabled control: %v %v %v", name, on, pressed, group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A popup left open must stop taking choices once its control is disabled.
|
||||||
|
func TestChoicePopupsCloseWhenDisabled(t *testing.T) {
|
||||||
|
disabled := false
|
||||||
|
var multi []int
|
||||||
|
var paths, ids []string
|
||||||
|
f := newCascaderFake()
|
||||||
|
cases := []struct {
|
||||||
|
name, open, pick string
|
||||||
|
build func(c *ui.Context)
|
||||||
|
same func() bool
|
||||||
|
}{
|
||||||
|
{"multi", "Select…", "Lily", func(c *ui.Context) {
|
||||||
|
MultiSelect(c, &multi, multiSelectTestOptions, MultiSelectOptions{Disabled: disabled})
|
||||||
|
}, func() bool { return len(multi) == 0 }},
|
||||||
|
{"cascader", "Select…", "Europe", func(c *ui.Context) {
|
||||||
|
Cascader(c, &paths, CascaderOptions{Level: f.level, Load: f.load, AnyLevel: true, Disabled: disabled})
|
||||||
|
}, func() bool { return len(paths) == 0 }},
|
||||||
|
{"tree", "Select…", "Guard", func(c *ui.Context) {
|
||||||
|
TreeSelect(c, &ids, treeSelectTestNodes, TreeSelectOptions{Disabled: disabled})
|
||||||
|
}, func() bool { return len(ids) == 0 }},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
disabled = false
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{})
|
||||||
|
ui.Column(c).Padding(10).Children(func() { tc.build(c) })
|
||||||
|
}, 700, 500)
|
||||||
|
if err := tt.Click(tc.open); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !tt.HasText(tc.pick) {
|
||||||
|
t.Fatalf("%s: popup did not open", tc.name)
|
||||||
|
}
|
||||||
|
disabled = true
|
||||||
|
tt.Frame()
|
||||||
|
_ = tt.Click(tc.pick)
|
||||||
|
if !tc.same() || tt.HasText(tc.pick) {
|
||||||
|
t.Errorf("%s: disabled control took a choice or kept its popup", tc.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTreeSelectSingleMarksOwnNode(t *testing.T) {
|
||||||
|
single := TreeSelectOptions{}
|
||||||
|
court := &treeSelectTestNodes[0]
|
||||||
|
if got := treeSelectCheck(court, []string{"court"}, single); got != internal.Checked {
|
||||||
|
t.Fatalf("chosen parent shows %v", got)
|
||||||
|
}
|
||||||
|
if got := treeSelectCheck(court, []string{"king", "queen"}, single); got != internal.Unchecked {
|
||||||
|
t.Fatalf("parent of chosen leaves shows %v", got)
|
||||||
|
}
|
||||||
|
var sel []string
|
||||||
|
changes := 0
|
||||||
|
tt := treeSelectTester(&sel, single, &changes)
|
||||||
|
_ = tt.Click("Select…")
|
||||||
|
_ = tt.Click("Court")
|
||||||
|
if !slices.Equal(sel, []string{"court"}) {
|
||||||
|
t.Fatalf("sel %v", sel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTreeSelectRightSkipsHiddenChildren(t *testing.T) {
|
||||||
|
var sel []string
|
||||||
|
changes := 0
|
||||||
|
tt := treeSelectTester(&sel, TreeSelectOptions{Multiple: true}, &changes)
|
||||||
|
_ = tt.Click("Select…")
|
||||||
|
tt.Type("queen")
|
||||||
|
tt.Key(0, ui.KeyDown)
|
||||||
|
if !tt.Focused("Court") {
|
||||||
|
t.Fatal("Down did not reach Court")
|
||||||
|
}
|
||||||
|
tt.Key(0, ui.KeyRight)
|
||||||
|
if !tt.Focused("Queen") {
|
||||||
|
t.Fatal("Right did not reach the only shown child")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pixel reads the light theme color at (x, y) of the last frame.
|
||||||
|
func pixel(tt *ui.Tester, x, y float32) color.RGBA {
|
||||||
|
return tt.Image().RGBAAt(int(x), int(y))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectChosenRowHasSelectionFill(t *testing.T) {
|
||||||
|
v, changes := "banana", 0
|
||||||
|
tt := selectTester(&v, selectTestOptions, SelectOptions{}, &changes)
|
||||||
|
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
tt.Key(0, ui.KeyDown)
|
||||||
|
tt.Move(0, 0)
|
||||||
|
tt.Key(0, ui.KeyDown)
|
||||||
|
blue, ok := tt.Find("Blueberry")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("popup closed")
|
||||||
|
}
|
||||||
|
h := theme.Default.ControlHeight()
|
||||||
|
got := pixel(tt, blue.X+blue.W+20, blue.Y+blue.H/2-h)
|
||||||
|
s := theme.Light().Selection
|
||||||
|
if got.R != s.R || got.G != s.G || got.B != s.B {
|
||||||
|
t.Fatalf("chosen row %v, want selection %v", got, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChoiceHitAreas(t *testing.T) {
|
||||||
|
v, changes := "apple", 0
|
||||||
|
tt := selectTester(&v, selectTestOptions, SelectOptions{Clearable: true}, &changes)
|
||||||
|
if r, _ := tt.Find("Clear"); r.W < theme.MinHitSize || r.H < theme.MinHitSize {
|
||||||
|
t.Errorf("clear button %v", r)
|
||||||
|
}
|
||||||
|
var sel []string
|
||||||
|
tt = treeSelectTester(&sel, TreeSelectOptions{}, &changes)
|
||||||
|
_ = tt.Click("Select…")
|
||||||
|
if r, _ := tt.Find("Expand Court"); r.W < theme.MinHitSize || r.H < theme.MinHitSize {
|
||||||
|
t.Errorf("tree arrow %v", r)
|
||||||
|
}
|
||||||
|
font := ""
|
||||||
|
tt = ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{})
|
||||||
|
Combobox(c, &font, comboboxTestFonts, ComboboxOptions{})
|
||||||
|
}, 400, 300)
|
||||||
|
if r, _ := tt.Find("Show options"); r.W < theme.MinHitSize || r.H < theme.MinHitSize {
|
||||||
|
t.Errorf("combobox arrow %v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChoiceRowsFollowDensity(t *testing.T) {
|
||||||
|
for _, d := range []theme.Density{theme.Compact, theme.Default, theme.Comfortable} {
|
||||||
|
v, on := "apple", false
|
||||||
|
var sw *ui.Element
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{Density: d})
|
||||||
|
ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() {
|
||||||
|
sw = Switch(c, &on, "Wi-Fi", SwitchOptions{})
|
||||||
|
Select(c, &v, selectTestOptions, SelectOptions{Label: "Fruit"})
|
||||||
|
})
|
||||||
|
}, 400, 500)
|
||||||
|
if h := sw.Bounds().H; h < d.ControlHeight() {
|
||||||
|
t.Errorf("%v: switch %v high", d, h)
|
||||||
|
}
|
||||||
|
_ = tt.Click("Fruit")
|
||||||
|
a, _ := tt.Find("Cherry")
|
||||||
|
b, _ := tt.Find("Date")
|
||||||
|
if b.Y-a.Y < d.ControlHeight() {
|
||||||
|
t.Errorf("%v: option rows %v apart", d, b.Y-a.Y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSelectCaptionFollowsTextScale(t *testing.T) {
|
||||||
|
height := func(scale float32) float32 {
|
||||||
|
values := []int{1}
|
||||||
|
tt := multiSelectTester(&values, MultiSelectOptions{Max: 2}, new(int))
|
||||||
|
tt.SetPreferences(ui.Preferences{TextScale: scale})
|
||||||
|
_ = tt.Click("Rose")
|
||||||
|
r, ok := tt.Find("1 of 2 chosen")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("limit note missing")
|
||||||
|
}
|
||||||
|
return r.H
|
||||||
|
}
|
||||||
|
if one, two := height(1), height(2); two < one*1.5 {
|
||||||
|
t.Fatalf("caption %v high at 2x, %v at 1x", two, one)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComboboxCaretAfterChoice(t *testing.T) {
|
||||||
|
r := &comboboxRun{}
|
||||||
|
tt := comboboxTester(r, fixedFonts, ComboboxOptions{})
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
tt.Type("di")
|
||||||
|
tt.Key(0, ui.KeyEnter)
|
||||||
|
if r.value != "Didot" {
|
||||||
|
t.Fatalf("value %q", r.value)
|
||||||
|
}
|
||||||
|
tt.Type("x")
|
||||||
|
if r.query != "Didotx" {
|
||||||
|
t.Fatalf("typing after a choice gave %q", r.query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first Tab enters a toggle group at its first enabled item; later at the last focused one.
|
||||||
|
func TestToggleGroupTabEntry(t *testing.T) {
|
||||||
|
value := []string{"right"}
|
||||||
|
tt := choiceBetween(func(c *ui.Context) {
|
||||||
|
ToggleGroup(c, &value, toggleGroupTestItems, ToggleGroupOptions{})
|
||||||
|
})
|
||||||
|
_ = tt.Click("Before")
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
if !tt.Focused("Left") {
|
||||||
|
t.Fatal("first Tab did not land on the first item")
|
||||||
|
}
|
||||||
|
tt.Key(0, ui.KeyRight)
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
_ = tt.Click("Before")
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
if !tt.Focused("Center") {
|
||||||
|
t.Fatal("Tab did not return to the last focused item")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,9 +41,7 @@ func (r ComboboxResult) QueryChanged() bool { return r.queryChanged }
|
|||||||
// comboboxKey keeps this file's state apart from MyGo's own.
|
// comboboxKey keeps this file's state apart from MyGo's own.
|
||||||
type comboboxKey struct{ name string }
|
type comboboxKey struct{ name string }
|
||||||
|
|
||||||
// Combobox chooses *value by typing to filter options and picking one, as
|
// Combobox chooses *value by typing to filter options and picking one, or typed text with AllowCustom.
|
||||||
// an autocomplete does. Up and Down move, Enter or a click chooses, Escape
|
|
||||||
// closes. With AllowCustom, Enter or leaving the field keeps typed text.
|
|
||||||
func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptions) ComboboxResult {
|
func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptions) ComboboxResult {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
d := Density(c)
|
d := Density(c)
|
||||||
@@ -52,9 +50,21 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio
|
|||||||
Gap(d.Unit() * 2).Radius(theme.ControlRadius).Background(k.Surface).Cursor(ui.CursorText).Disabled(opts.Disabled)
|
Gap(d.Unit() * 2).Radius(theme.ControlRadius).Background(k.Surface).Cursor(ui.CursorText).Disabled(opts.Disabled)
|
||||||
query := ui.Local(f, comboboxKey{"query"}, func() string { return *value })
|
query := ui.Local(f, comboboxKey{"query"}, func() string { return *value })
|
||||||
wasFocused := ui.Local(f, comboboxKey{"focused"}, func() bool { return false })
|
wasFocused := ui.Local(f, comboboxKey{"focused"}, func() bool { return false })
|
||||||
|
// MyGo keeps the caret on replaced text (ui/editor.go:117): a choice builds a fresh input.
|
||||||
|
gen := ui.Local(f, comboboxKey{"input"}, func() int { return 0 })
|
||||||
|
refocus := ui.Local(f, comboboxKey{"refocus"}, func() bool { return false })
|
||||||
var p *ui.ComboboxParts
|
var p *ui.ComboboxParts
|
||||||
f.Children(func() {
|
f.Children(func() {
|
||||||
p = ui.ComboboxBase(c, query)
|
ui.Row(c).Key(*gen).Grow(1).Children(func() {
|
||||||
|
p = ui.ComboboxBase(c, choiceTarget(query, opts.Disabled))
|
||||||
|
})
|
||||||
|
if *refocus {
|
||||||
|
p.Input.Focus()
|
||||||
|
*refocus = false
|
||||||
|
}
|
||||||
|
if opts.Disabled {
|
||||||
|
p.SetOpen(false)
|
||||||
|
}
|
||||||
pad := max(0, (d.ControlHeight()-2-theme.BodySize*theme.LineHeight)/2)
|
pad := max(0, (d.ControlHeight()-2-theme.BodySize*theme.LineHeight)/2)
|
||||||
p.Input.Grow(1).MinWidth(80).Padding(pad, d.Unit()*3).Placeholder(opts.Placeholder)
|
p.Input.Grow(1).MinWidth(80).Padding(pad, d.Unit()*3).Placeholder(opts.Placeholder)
|
||||||
if opts.Label != "" {
|
if opts.Label != "" {
|
||||||
@@ -63,13 +73,16 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio
|
|||||||
if opts.Loading {
|
if opts.Loading {
|
||||||
Spinner(c, SpinnerOptions{Size: 14})
|
Spinner(c, SpinnerOptions{Size: 14})
|
||||||
}
|
}
|
||||||
arrow := ui.Box(c).Size(16, 16).Shrink(0).Cursor(ui.CursorPointer)
|
arrow := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Cursor(ui.CursorPointer).
|
||||||
|
Role(ui.RoleButton).Label(msg(c, "combobox.toggle"))
|
||||||
if arrow.Clicked() {
|
if arrow.Clicked() {
|
||||||
p.SetOpen(!p.Open())
|
p.SetOpen(!p.Open())
|
||||||
p.Input.Focus()
|
p.Input.Focus()
|
||||||
}
|
}
|
||||||
open := p.Open()
|
open := p.Open()
|
||||||
arrow.Draw(func(pt *ui.Painter, r ui.Rect) { internal.Mark(pt, r, selectChevron(open), k.TextMuted) })
|
arrow.Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||||
|
internal.Mark(pt, selectCenter(r, 16), selectChevron(open), k.TextMuted)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
in := p.Input
|
in := p.Input
|
||||||
if f.Clicked() {
|
if f.Clicked() {
|
||||||
@@ -116,11 +129,13 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio
|
|||||||
focused := in.Focused()
|
focused := in.Focused()
|
||||||
if v, ok := p.Chosen(); ok {
|
if v, ok := p.Chosen(); ok {
|
||||||
*value, *query = v, v
|
*value, *query = v, v
|
||||||
|
*gen, *refocus = *gen+1, focused
|
||||||
} else if in.Submitted() && opts.AllowCustom {
|
} else if in.Submitted() && opts.AllowCustom {
|
||||||
*value = strings.TrimSpace(*query)
|
*value = strings.TrimSpace(*query)
|
||||||
} else if in.Submitted() && p.Open() && len(shown) > 0 {
|
} else if in.Submitted() && p.Open() && len(shown) > 0 {
|
||||||
// Results came after typing left nothing highlighted: Enter takes the first.
|
// Results came after typing left nothing highlighted: Enter takes the first.
|
||||||
*value, *query = shown[0], shown[0]
|
*value, *query = shown[0], shown[0]
|
||||||
|
*gen, *refocus = *gen+1, true
|
||||||
p.SetOpen(false)
|
p.SetOpen(false)
|
||||||
} else if *wasFocused && !focused {
|
} else if *wasFocused && !focused {
|
||||||
if opts.AllowCustom {
|
if opts.AllowCustom {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ ui.Render(view, 400, 300, 1)
|
|||||||
|
|
||||||
## 键盘操作
|
## 键盘操作
|
||||||
|
|
||||||
- Tab:进入组,落在按下项(或上次聚焦项);再按 Tab 离开。
|
- Tab:进入组。首次落在第一个可用项(与哪项按下无关);之后回到组内上次聚焦的项。再按 Tab 离开。
|
||||||
- ←/→(纵向为 ↑/↓)、Home、End:在项之间移动焦点,跳过禁用项,首尾循环。
|
- ←/→(纵向为 ↑/↓)、Home、End:在项之间移动焦点,跳过禁用项,首尾循环。
|
||||||
- Space:按下或松开当前项。
|
- Space:按下或松开当前项。
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ ui.Render(view, 400, 300, 1)
|
|||||||
| `Step` | `float64` | 0 | 步长;值为 `Min + n·Step`。0 为连续。 |
|
| `Step` | `float64` | 0 | 步长;值为 `Min + n·Step`。0 为连续。 |
|
||||||
| `Marks` | `[]SliderMark` | nil | 刻度:`Value` 处画刻线,`Label` 非空时在下方显示文字。 |
|
| `Marks` | `[]SliderMark` | nil | 刻度:`Value` 处画刻线,`Label` 非空时在下方显示文字。 |
|
||||||
| `ShowValue` | `bool` | false | 悬停、拖动或聚焦时在滑块上方显示数值提示(预留高度,不改变布局)。 |
|
| `ShowValue` | `bool` | false | 悬停、拖动或聚焦时在滑块上方显示数值提示(预留高度,不改变布局)。 |
|
||||||
| `Format` | `func(float64) string` | 按步长精度 | 数值文字。 |
|
| `Format` | `func(float64) string` | 按 `Min`、`Step` 所需小数位(连续时至少 1 位) | 数值文字。 |
|
||||||
| `Disabled` | `bool` | false | 禁用。 |
|
| `Disabled` | `bool` | false | 禁用。 |
|
||||||
| `Label` | `string` | "" | 无障碍名称。 |
|
| `Label` | `string` | "" | 无障碍名称。 |
|
||||||
|
|
||||||
@@ -44,5 +44,6 @@ ui.Render(view, 400, 300, 1)
|
|||||||
|
|
||||||
## 限制
|
## 限制
|
||||||
|
|
||||||
- 基于 `ui.SliderBase`。MyGo 的方向键步长固定为范围的 1%(ui/base.go:116),本组件把每次按键结果换算为一个 `Step`;当 `Step` 小于范围的 1% 且滑块距端点不足 1% 时,方向键会直接到端点。
|
- 基于 `ui.SliderBase`,它向辅助技术报告 Slider 角色、范围与当前值(ui/base.go:128)。
|
||||||
- `SliderBase` 在构建时就处理指针(ui/base.go:111),禁用时组件改为让它操作副本,保证值不变。
|
- MyGo 只把方向键、Home、End 交给 `SliderBase` 本身(ui/base.go:118,按键路由见 ui/input.go:495),并固定以范围的 1% 移动。本组件让它操作副本,按方向判断按键后自己按 `Step` 移动;距端点不足 1% 时 MyGo 的方向键与 Home/End 结果相同,组件改看松开的是哪个键(`HandleInput` 能收到 KeyUp),在键松开前不改值。
|
||||||
|
- `SliderBase` 在构建时就处理指针(ui/base.go:111),禁用时外层先禁用,且只操作副本,保证值不变。
|
||||||
@@ -25,9 +25,9 @@ ui.Render(view, 400, 300, 1)
|
|||||||
| `Step` | `float64` | 0 | 步长,0 为连续。 |
|
| `Step` | `float64` | 0 | 步长,0 为连续。 |
|
||||||
| `Marks` | `[]SliderMark` | nil | 刻度与文字。 |
|
| `Marks` | `[]SliderMark` | nil | 刻度与文字。 |
|
||||||
| `ShowValue` | `bool` | false | 悬停、拖动或聚焦时显示两端数值;低端提示向左、高端向右,互不遮挡。 |
|
| `ShowValue` | `bool` | false | 悬停、拖动或聚焦时显示两端数值;低端提示向左、高端向右,互不遮挡。 |
|
||||||
| `Format` | `func(float64) string` | 按步长精度 | 数值文字。 |
|
| `Format` | `func(float64) string` | 按 `Min`、`Step` 所需小数位(连续时至少 1 位) | 数值文字。 |
|
||||||
| `Disabled` | `bool` | false | 禁用。 |
|
| `Disabled` | `bool` | false | 禁用。 |
|
||||||
| `Label` | `string` | "" | 区间名称;两个滑块分别读作“`Label` 最小值, 20”“`Label` 最大值, 80”。 |
|
| `Label` | `string` | "" | 区间名称;两个滑块分别名为“`Label` 最小值”“`Label` 最大值”,数值与范围由 Slider 角色报告。 |
|
||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
@@ -46,5 +46,6 @@ ui.Render(view, 400, 300, 1)
|
|||||||
|
|
||||||
## 限制
|
## 限制
|
||||||
|
|
||||||
- MyGo 自带的 `ui.RangeSlider` 使用玻璃质感滑块(ui/widgets.go:419),不符合本库风格;MyGo 也没有双滑块 Base,故本组件用公开的 `Dragged`、`PointerPosition`、`Shortcut` 组合实现。
|
- MyGo 自带的 `ui.RangeSlider` 使用玻璃质感滑块(ui/widgets.go:419),不符合本库风格;MyGo 也没有双滑块 Base,故每个滑块各是一个 `SliderBase`(报告 Slider 角色、范围与当前值,ui/base.go:128),拖动由外层的 `Dragged`、`PointerPosition` 处理。
|
||||||
- 滑块的无障碍角色为 Slider,但数值范围属性只能由 `SliderBase` 设置(ui/base.go:128),因此当前值写在名称里(如“Price 最小值, 20”),屏幕阅读器读名称而非数值属性。
|
- 两个滑块的范围都报告为整条轨道 `Min`–`Max`;“不能越过另一端”这一动态上下限无法告诉辅助技术。
|
||||||
|
- 按键处理与 Slider 相同:距端点不足 1% 时等到按键松开再移动(见 034 限制)。
|
||||||
@@ -36,7 +36,7 @@ ui.Render(view, 400, 300, 1)
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
默认、悬停(边框加深)、展开(酒红边框、箭头朝上)、聚焦、禁用;弹层中高亮项(浅底)、已选项(勾号 + 酒红文字)、禁用项、空状态。
|
默认、悬停(边框加深)、展开(酒红边框、箭头朝上)、聚焦、禁用;弹层中高亮项(浅底)、已选项(玫瑰底 + 勾号 + 酒红文字)、键盘高亮项另有左侧竖条、禁用项、空状态。
|
||||||
|
|
||||||
## 事件
|
## 事件
|
||||||
|
|
||||||
@@ -52,4 +52,5 @@ ui.Render(view, 400, 300, 1)
|
|||||||
## 限制
|
## 限制
|
||||||
|
|
||||||
- 基于 `ui.SelectBase`。其高亮索引没有公开的设置接口(ui/base.go:260),所以弹层打开时输入字母不移动高亮,只在弹层关闭时定位。
|
- 基于 `ui.SelectBase`。其高亮索引没有公开的设置接口(ui/base.go:260),所以弹层打开时输入字母不移动高亮,只在弹层关闭时定位。
|
||||||
- 清空按钮不占 Tab 位(与 MyGo 搜索框一致),键盘用 Backspace/Delete 清空。
|
- 清空按钮不占 Tab 位(与 MyGo 搜索框一致),键盘用 Backspace/Delete 清空;它的命中区域为 28×28 DIP。
|
||||||
|
- 选项行的角色为 ListItem,选中状态由 `SelectBase.Item` 报告。`SelectParts` 没有关闭弹层的接口(ui/base.go:310),所以选择器在弹层打开时被禁用后不再构建弹层;重新启用时弹层会再次显示。
|
||||||
@@ -49,6 +49,6 @@ ui.Render(view, 400, 300, 1)
|
|||||||
|
|
||||||
## 限制
|
## 限制
|
||||||
|
|
||||||
- 选项行基于 `ui.CheckboxBase` 自绘方框;全选的“半选”只在画面上表示,MyGo 没有为自定义元素设置 mixed 状态的公开接口(ui/element.go:282 的 checked 字段仅由内部设置),屏幕阅读器读作未选中。
|
- 选项行基于 `ui.CheckboxBase` 自绘方框,选中状态由 MyGo 报告。全选的“半选”无法作为 mixed 状态报告:MyGo 没有为自定义元素设置它的公开接口(ui/element.go:282 的 checked 字段仅由内部设置)。组件改用 `Description` 读出“部分选中”,屏幕阅读器仍把复选框本身读作未选中。
|
||||||
- 标签上的移除按钮不占 Tab 位,键盘用 Backspace 移除。
|
- 标签上的移除按钮不占 Tab 位,键盘用 Backspace 移除。
|
||||||
- 列表未虚拟化,适合数百项以内。
|
- 列表未虚拟化,适合数百项以内。
|
||||||
@@ -53,3 +53,4 @@ ui.Render(view, 400, 300, 1)
|
|||||||
|
|
||||||
- 基于 `ui.ComboboxBase`。它在没有选项时隐藏弹层(ui/combobox.go:191),所以“加载中”“无匹配”用一个不接收输入的独立浮层显示。
|
- 基于 `ui.ComboboxBase`。它在没有选项时隐藏弹层(ui/combobox.go:191),所以“加载中”“无匹配”用一个不接收输入的独立浮层显示。
|
||||||
- 异步结果在打字后才到达时 MyGo 不会自动高亮首项,组件让 Enter 选择首个结果。
|
- 异步结果在打字后才到达时 MyGo 不会自动高亮首项,组件让 Enter 选择首个结果。
|
||||||
|
- MyGo 替换输入框文字时保留原光标位置(ui/editor.go:117),且不公开光标设置;组件在选择后重建输入框,使光标落在新文字末尾并保持焦点。
|
||||||
@@ -58,3 +58,4 @@ ui.Render(view, 400, 300, 1)
|
|||||||
|
|
||||||
- `Load` 在视图构建中同步调用,不得阻塞;异步任务完成后请用 `Window.Update` 回到界面线程更新数据。
|
- `Load` 在视图构建中同步调用,不得阻塞;异步任务完成后请用 `Window.Update` 回到界面线程更新数据。
|
||||||
- 列表未虚拟化,每列适合数百项以内。
|
- 列表未虚拟化,每列适合数百项以内。
|
||||||
|
- 已选路径上的节点通过 `Selected` 报告选中;当前展开的分支无法报告 expanded 状态(ui/base.go:297 等处仅内部设置),仅在画面上用底色表示。
|
||||||
@@ -55,5 +55,6 @@ ui.Render(view, 400, 300, 1)
|
|||||||
## 限制
|
## 限制
|
||||||
|
|
||||||
- 树行基于 `ui.CheckboxBase` / `ui.ButtonBase` 自绘。MyGo 的 `TreeItem` 只能显示文字标签,无法放入复选框(ui/tree.go:44),故未复用。
|
- 树行基于 `ui.CheckboxBase` / `ui.ButtonBase` 自绘。MyGo 的 `TreeItem` 只能显示文字标签,无法放入复选框(ui/tree.go:44),故未复用。
|
||||||
- 展开状态与半选状态只在画面上表示:MyGo 不对自定义元素公开 expanded 与 mixed 的设置(ui/base.go:297、ui/element.go:282 仅内部设置),屏幕阅读器读不到这两个状态;展开箭头带“展开/收起 + 节点名”的名称。
|
- MyGo 不对自定义元素公开 expanded 与 mixed 状态的设置(ui/tree.go:98、ui/element.go:282 仅内部设置)。组件改用 `Description` 读出“已展开/已收起”“部分选中”,展开箭头带“展开/收起 + 节点名”的名称;屏幕阅读器读不到真正的 expanded/mixed 状态属性。单选行通过 `Selected` 报告选中,并显示勾号。
|
||||||
|
- `CollapsibleBase` 的触发器能报告 expanded,但它自身要占一个焦点位,会让方向键在箭头与节点间交替停留,故未采用。
|
||||||
- 列表未虚拟化,适合数千节点以内。
|
- 列表未虚拟化,适合数千节点以内。
|
||||||
@@ -105,10 +105,10 @@ view := func(c *ui.Context) {
|
|||||||
func choiceDemo(c *ui.Context, controls, samples func()) {
|
func choiceDemo(c *ui.Context, controls, samples func()) {
|
||||||
k := mujica.Tokens(c)
|
k := mujica.Tokens(c)
|
||||||
ui.Column(c).Gap(20).AlignItems(ui.Start).Children(func() {
|
ui.Column(c).Gap(20).AlignItems(ui.Start).Children(func() {
|
||||||
ui.Text(c, "Parameters").Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text)
|
ui.Text(c, "Parameters").Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||||
ui.Row(c).Gap(20).Wrap().AlignItems(ui.Center).Children(controls)
|
ui.Row(c).Gap(20).Wrap().AlignItems(ui.Center).Children(controls)
|
||||||
ui.Box(c).Height(1).FillWidth().Background(k.Ornament)
|
ui.Box(c).Height(1).FillWidth().Background(k.Ornament)
|
||||||
ui.Text(c, "States").Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text)
|
ui.Text(c, "States").Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||||
ui.Column(c).Gap(16).AlignItems(ui.Start).Children(samples)
|
ui.Column(c).Gap(16).AlignItems(ui.Start).Children(samples)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ func choiceFlag(c *ui.Context, on *bool, label string, log func(string)) {
|
|||||||
// choiceLabeled shows a caption above a sample.
|
// choiceLabeled shows a caption above a sample.
|
||||||
func choiceLabeled(c *ui.Context, caption string, fn func()) {
|
func choiceLabeled(c *ui.Context, caption string, fn func()) {
|
||||||
ui.Column(c).Gap(6).Children(func() {
|
ui.Column(c).Gap(6).Children(func() {
|
||||||
ui.Text(c, caption).FontSize(theme.CaptionSize).TextColor(mujica.Tokens(c).TextMuted)
|
ui.Text(c, caption).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(mujica.Tokens(c).TextMuted)
|
||||||
fn()
|
fn()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -288,7 +288,7 @@ var choiceFruits = []mujica.SelectOption[string]{
|
|||||||
|
|
||||||
func selectDemo(c *ui.Context, log func(string)) {
|
func selectDemo(c *ui.Context, log func(string)) {
|
||||||
type state struct {
|
type state struct {
|
||||||
value string
|
value, picked string
|
||||||
clearable, disabled, noData bool
|
clearable, disabled, noData bool
|
||||||
}
|
}
|
||||||
s := *ui.Local(c.Root(), "036", func() *state { return &state{value: "banana", clearable: true} })
|
s := *ui.Local(c.Root(), "036", func() *state { return &state{value: "banana", clearable: true} })
|
||||||
@@ -306,9 +306,10 @@ func selectDemo(c *ui.Context, log func(string)) {
|
|||||||
log(fmt.Sprintf("Fruit: %q", s.value))
|
log(fmt.Sprintf("Fruit: %q", s.value))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
empty := ""
|
|
||||||
choiceLabeled(c, "Placeholder", func() {
|
choiceLabeled(c, "Placeholder", func() {
|
||||||
mujica.Select(c, &empty, choiceFruits, mujica.SelectOptions{Placeholder: "Pick a fruit"})
|
if mujica.Select(c, &s.picked, choiceFruits, mujica.SelectOptions{Placeholder: "Pick a fruit", Clearable: true}).Changed() {
|
||||||
|
log(fmt.Sprintf("Picked: %q", s.picked))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
mujica "mujicaui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// choiceDemoTester runs the demo of page id at text scale and returns its tester and event log.
|
||||||
|
func choiceDemoTester(t *testing.T, id string, scale float32) (*ui.Tester, *[]string) {
|
||||||
|
t.Helper()
|
||||||
|
i := slices.IndexFunc(choicePages, func(p page) bool { return p.ID == id })
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatalf("no page %s", id)
|
||||||
|
}
|
||||||
|
events := &[]string{}
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
mujica.Use(c, mujica.Settings{Mode: mujica.Light})
|
||||||
|
ui.Scroll(c).Fill().Padding(24).Children(func() {
|
||||||
|
choicePages[i].Demo(c, func(s string) { *events = append(*events, s) })
|
||||||
|
})
|
||||||
|
}, 900, 700)
|
||||||
|
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: scale})
|
||||||
|
return tt, events
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChoiceSelectDemoKeepsPlaceholderChoice(t *testing.T) {
|
||||||
|
tt, events := choiceDemoTester(t, "036", 1)
|
||||||
|
if err := tt.Click("Pick a fruit"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tt.Click("Fig"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tt.Frame()
|
||||||
|
if !tt.HasText("Fig") || !slices.Contains(*events, `Picked: "fig"`) {
|
||||||
|
t.Fatalf("choice lost: texts %q, events %q", tt.Texts(), *events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChoiceDemoHeadingsFollowTextScale(t *testing.T) {
|
||||||
|
height := func(scale float32) float32 {
|
||||||
|
tt, _ := choiceDemoTester(t, "031", scale)
|
||||||
|
tt.Frame()
|
||||||
|
r, ok := tt.Find("Parameters")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("heading missing")
|
||||||
|
}
|
||||||
|
return r.H
|
||||||
|
}
|
||||||
|
if one, two := height(1), height(2); two < one*1.5 {
|
||||||
|
t.Fatalf("heading %v high at 2x, %v at 1x", two, one)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,12 +36,11 @@ func CheckBox(p *ui.Painter, r ui.Rect, s CheckState, k theme.Tokens, hover bool
|
|||||||
if s == Mixed {
|
if s == Mixed {
|
||||||
mark = "minus"
|
mark = "minus"
|
||||||
}
|
}
|
||||||
Mark(p, r, mark, k.OnAccent)
|
in := r.W * 0.12
|
||||||
|
Mark(p, ui.Rect{X: r.X + in, Y: r.Y + in, W: r.W - 2*in, H: r.H - 2*in}, mark, k.OnAccent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark paints the Lucide icon name inset in r. Icons sit on the pixel grid,
|
// Mark paints the Lucide icon name in r, on the pixel grid so masks never vary.
|
||||||
// so their cached masks never differ between frames or tests.
|
|
||||||
func Mark(p *ui.Painter, r ui.Rect, name string, c ui.Color) {
|
func Mark(p *ui.Painter, r ui.Rect, name string, c ui.Color) {
|
||||||
in := r.W * 0.12
|
p.Icon(icons.Must(name), r, c)
|
||||||
p.Icon(icons.Must(name), ui.Rect{X: r.X + in, Y: r.Y + in, W: r.W - 2*in, H: r.H - 2*in}, c)
|
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ func init() {
|
|||||||
"multi_select.limit": {"%d of %d chosen", "已选 %d / %d"},
|
"multi_select.limit": {"%d of %d chosen", "已选 %d / %d"},
|
||||||
"multi_select.more": {"+%d", "+%d"},
|
"multi_select.more": {"+%d", "+%d"},
|
||||||
"multi_select.remove": {"Remove %s", "移除 %s"},
|
"multi_select.remove": {"Remove %s", "移除 %s"},
|
||||||
|
"combobox.toggle": {"Show options", "显示选项"},
|
||||||
"combobox.loading": {"Loading…", "加载中…"},
|
"combobox.loading": {"Loading…", "加载中…"},
|
||||||
"combobox.no_results": {"No matches", "无匹配项"},
|
"combobox.no_results": {"No matches", "无匹配项"},
|
||||||
"combobox.use": {"Use “%s”", "使用“%s”"},
|
"combobox.use": {"Use “%s”", "使用“%s”"},
|
||||||
@@ -24,5 +25,9 @@ func init() {
|
|||||||
"tree_select.no_results": {"No results", "无匹配结果"},
|
"tree_select.no_results": {"No results", "无匹配结果"},
|
||||||
"tree_select.expand": {"Expand %s", "展开 %s"},
|
"tree_select.expand": {"Expand %s", "展开 %s"},
|
||||||
"tree_select.collapse": {"Collapse %s", "收起 %s"},
|
"tree_select.collapse": {"Collapse %s", "收起 %s"},
|
||||||
|
"tree_select.expanded": {"expanded", "已展开"},
|
||||||
|
"tree_select.collapsed": {"collapsed", "已收起"},
|
||||||
|
"tree_select.mixed": {"partly selected", "部分选中"},
|
||||||
|
"multi_select.mixed": {"partly selected", "部分选中"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -28,9 +28,7 @@ type MultiSelectOptions struct {
|
|||||||
// multiSelectKey keeps this file's state apart from MyGo's own.
|
// multiSelectKey keeps this file's state apart from MyGo's own.
|
||||||
type multiSelectKey struct{ name string }
|
type multiSelectKey struct{ name string }
|
||||||
|
|
||||||
// MultiSelect chooses several *values from options in a popup with a
|
// MultiSelect chooses several *values from options in a popup with search and "Select all".
|
||||||
// search field, "Select all" for the matches, and an optional limit.
|
|
||||||
// Down from the search moves into the list; Space checks; Escape closes.
|
|
||||||
func MultiSelect[T comparable](c *ui.Context, values *[]T, options []SelectOption[T], opts MultiSelectOptions) ChoiceResult {
|
func MultiSelect[T comparable](c *ui.Context, values *[]T, options []SelectOption[T], opts MultiSelectOptions) ChoiceResult {
|
||||||
if opts.Max < 0 || opts.MaxTags < 0 {
|
if opts.Max < 0 || opts.MaxTags < 0 {
|
||||||
panic("mujica: MultiSelect Max and MaxTags must not be negative")
|
panic("mujica: MultiSelect Max and MaxTags must not be negative")
|
||||||
@@ -42,6 +40,9 @@ func MultiSelect[T comparable](c *ui.Context, values *[]T, options []SelectOptio
|
|||||||
tr.Label(opts.Label)
|
tr.Label(opts.Label)
|
||||||
}
|
}
|
||||||
open := ui.Local(tr, multiSelectKey{"open"}, func() bool { return false })
|
open := ui.Local(tr, multiSelectKey{"open"}, func() bool { return false })
|
||||||
|
if opts.Disabled {
|
||||||
|
*open = false
|
||||||
|
}
|
||||||
query := ui.Local(tr, multiSelectKey{"query"}, func() string { return "" })
|
query := ui.Local(tr, multiSelectKey{"query"}, func() string { return "" })
|
||||||
if tr.Clicked() || (!*open && tr.Shortcut(0, ui.KeyDown)) {
|
if tr.Clicked() || (!*open && tr.Shortcut(0, ui.KeyDown)) {
|
||||||
*open = !*open
|
*open = !*open
|
||||||
@@ -100,7 +101,7 @@ func multiSelectTag(c *ui.Context, label string, disabled bool) bool {
|
|||||||
removed := false
|
removed := false
|
||||||
ui.Row(c).Gap(2).Padding(1, 2, 1, 6).Radius(theme.ControlRadius).Background(k.Selection).
|
ui.Row(c).Gap(2).Padding(1, 2, 1, 6).Radius(theme.ControlRadius).Background(k.Selection).
|
||||||
Border(theme.BorderWidth, k.Border).Shrink(0).Children(func() {
|
Border(theme.BorderWidth, k.Border).Shrink(0).Children(func() {
|
||||||
ui.Text(c, label).FontSize(theme.CaptionSize).TextColor(k.Text).SingleLine()
|
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text).SingleLine()
|
||||||
if !disabled {
|
if !disabled {
|
||||||
removed = selectClear(c, k, fmt.Sprintf(msg(c, "multi_select.remove"), label))
|
removed = selectClear(c, k, fmt.Sprintf(msg(c, "multi_select.remove"), label))
|
||||||
}
|
}
|
||||||
@@ -170,7 +171,7 @@ func multiSelectPopup[T comparable](c *ui.Context, k theme.Tokens, values *[]T,
|
|||||||
})
|
})
|
||||||
if opts.Max > 0 {
|
if opts.Max > 0 {
|
||||||
ui.Text(c, fmt.Sprintf(msg(c, "multi_select.limit"), len(*values), opts.Max)).
|
ui.Text(c, fmt.Sprintf(msg(c, "multi_select.limit"), len(*values), opts.Max)).
|
||||||
FontSize(theme.CaptionSize).TextColor(k.TextMuted).Padding(2, d.Unit()*2)
|
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(2, d.Unit()*2)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -178,12 +179,16 @@ func multiSelectPopup[T comparable](c *ui.Context, k theme.Tokens, values *[]T,
|
|||||||
// multiSelectCheck is a CheckboxBase row drawing its own box.
|
// multiSelectCheck is a CheckboxBase row drawing its own box.
|
||||||
func multiSelectCheck(c *ui.Context, k theme.Tokens, on *bool, st internal.CheckState, label string, disabled bool) *ui.Element {
|
func multiSelectCheck(c *ui.Context, k theme.Tokens, on *bool, st internal.CheckState, label string, disabled bool) *ui.Element {
|
||||||
d := Density(c)
|
d := Density(c)
|
||||||
scratch := *on
|
var cb *ui.Element
|
||||||
if disabled {
|
// A disabled wrapper and a copy block the base, which toggles as it is built.
|
||||||
on = &scratch
|
ui.Row(c).Grow(1).Disabled(disabled).Children(func() {
|
||||||
|
cb = ui.CheckboxBase(c, choiceTarget(on, disabled)).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
|
||||||
|
Gap(d.Unit() * 2).AlignItems(ui.Center).Radius(theme.ControlRadius).Grow(1)
|
||||||
|
})
|
||||||
|
if st == internal.Mixed {
|
||||||
|
// MyGo sets the mixed state only inside its own widgets (ui/element.go:282).
|
||||||
|
cb.Description(msg(c, "multi_select.mixed"))
|
||||||
}
|
}
|
||||||
cb := ui.CheckboxBase(c, on).Disabled(disabled).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*2).
|
|
||||||
Gap(d.Unit() * 2).AlignItems(ui.Center).Radius(theme.ControlRadius).Grow(1)
|
|
||||||
internal.Ring(cb, k.Focus, theme.ControlRadius)
|
internal.Ring(cb, k.Focus, theme.ControlRadius)
|
||||||
hover := cb.Hovered()
|
hover := cb.Hovered()
|
||||||
if hover {
|
if hover {
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ type RangeSliderOptions struct {
|
|||||||
Label string
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
// RangeSlider sets *low and *high, which never cross. A press moves the
|
// RangeSlider sets *low and *high, which never cross; a press moves the nearer thumb, each takes the keys.
|
||||||
// nearer thumb; each thumb takes the focus and the arrows, Home and End.
|
|
||||||
func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) ChoiceResult {
|
func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) ChoiceResult {
|
||||||
g, h := sliderSetup(opts.Min, opts.Max, opts.Step, opts.Marks, opts.ShowValue, c)
|
g, h := sliderSetup(opts.Min, opts.Max, opts.Step, opts.Marks, opts.ShowValue, c)
|
||||||
format := sliderFormat(opts.Format, opts.Step)
|
format := sliderFormat(opts.Format, g)
|
||||||
|
size := FontSize(c, theme.CaptionSize)
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
*low, *high = g.snap(math.Min(*low, *high)), g.snap(math.Max(*low, *high))
|
*low, *high = g.snap(math.Min(*low, *high)), g.snap(math.Max(*low, *high))
|
||||||
oldLow, oldHigh := *low, *high
|
oldLow, oldHigh := *low, *high
|
||||||
@@ -47,32 +47,24 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) Cho
|
|||||||
from, to = *low, g.hi
|
from, to = *low, g.hi
|
||||||
}
|
}
|
||||||
x := g.x(ui.Rect{W: r.W}, *v) - sliderThumb/2
|
x := g.x(ui.Rect{W: r.W}, *v) - sliderThumb/2
|
||||||
kb := ui.Box(c).Key(i).Absolute().Left(x).Top(g.trackY-sliderThumb/2).Size(sliderThumb, sliderThumb).
|
var kb *ui.Element
|
||||||
Focusable().FocusRing(false).Role(ui.RoleSlider)
|
var next float64
|
||||||
|
var held bool
|
||||||
|
// Each thumb is a SliderBase, which reports the range and value to assistive technology.
|
||||||
|
ui.Box(c).Key(i).Absolute().Left(x).Top(g.trackY-sliderThumb/2).Size(sliderThumb, sliderThumb).Children(func() {
|
||||||
|
kb, next, held = sliderControl(c, *v, g, from, to)
|
||||||
|
})
|
||||||
|
kb.Size(sliderThumb, sliderThumb).FocusRing(false)
|
||||||
name := msg(c, []string{"range_slider.min", "range_slider.max"}[i])
|
name := msg(c, []string{"range_slider.min", "range_slider.max"}[i])
|
||||||
if opts.Label != "" {
|
if opts.Label != "" {
|
||||||
name = opts.Label + " " + name
|
name = opts.Label + " " + name
|
||||||
}
|
}
|
||||||
kb.Label(name + ", " + format(*v))
|
kb.Label(name)
|
||||||
step := func(d float64) float64 {
|
if held {
|
||||||
if g.step > 0 {
|
|
||||||
return g.snap(*v + d*g.step)
|
|
||||||
}
|
|
||||||
return *v + d*(g.hi-g.lo)/100
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case kb.Shortcut(0, ui.KeyLeft), kb.Shortcut(0, ui.KeyDown):
|
|
||||||
*v = max(from, step(-1))
|
|
||||||
case kb.Shortcut(0, ui.KeyRight), kb.Shortcut(0, ui.KeyUp):
|
|
||||||
*v = min(to, step(1))
|
|
||||||
case kb.Shortcut(0, ui.KeyHome):
|
|
||||||
*v = from
|
|
||||||
case kb.Shortcut(0, ui.KeyEnd):
|
|
||||||
*v = to
|
|
||||||
}
|
|
||||||
if _, _, ok := kb.Dragged(); ok {
|
|
||||||
pressed = true
|
pressed = true
|
||||||
*drag = i
|
*drag = i
|
||||||
|
} else if !opts.Disabled {
|
||||||
|
*v = next
|
||||||
}
|
}
|
||||||
knobs[i] = kb
|
knobs[i] = kb
|
||||||
}
|
}
|
||||||
@@ -104,12 +96,12 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) Cho
|
|||||||
hover := e.Hovered()
|
hover := e.Hovered()
|
||||||
e.Draw(func(p *ui.Painter, r ui.Rect) {
|
e.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
x0, x1 := g.x(r, lo), g.x(r, hi)
|
x0, x1 := g.x(r, lo), g.x(r, hi)
|
||||||
sliderTrackPaint(p, r, g, k, opts.Marks, format, x0, x1)
|
sliderTrackPaint(p, r, g, k, opts.Marks, size, x0, x1)
|
||||||
for i, x := range []float32{x0, x1} {
|
for i, x := range []float32{x0, x1} {
|
||||||
hot := which == i || knobs[i].Hovered()
|
hot := which == i || knobs[i].Hovered()
|
||||||
sliderThumbPaint(p, k, x, r.Y+g.trackY, hot, knobs[i].FocusVisible())
|
sliderThumbPaint(p, k, x, r.Y+g.trackY, hot, knobs[i].FocusVisible())
|
||||||
if opts.ShowValue && (hot || hover || knobs[i].Focused()) {
|
if opts.ShowValue && (hot || hover || knobs[i].Focused()) {
|
||||||
rangeSliderBubble(p, k, x, r.Y+g.trackY, format([]float64{lo, hi}[i]), i)
|
rangeSliderBubble(p, k, x, r.Y+g.trackY, format([]float64{lo, hi}[i]), i, size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -120,11 +112,11 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) Cho
|
|||||||
type rangeSliderDrag struct{}
|
type rangeSliderDrag struct{}
|
||||||
|
|
||||||
// rangeSliderBubble keeps the two tips apart: the low one leans left, the high one right.
|
// rangeSliderBubble keeps the two tips apart: the low one leans left, the high one right.
|
||||||
func rangeSliderBubble(p *ui.Painter, k theme.Tokens, x, y float32, s string, i int) {
|
func rangeSliderBubble(p *ui.Painter, k theme.Tokens, x, y float32, s string, i int, size float32) {
|
||||||
w, _ := p.MeasureText(1000, ui.Span{Text: s, Size: theme.CaptionSize})
|
w, _ := p.MeasureText(1000, ui.Span{Text: s, Size: size})
|
||||||
shift := (w+12)/2 - sliderThumb/2
|
shift := (w+12)/2 - sliderThumb/2
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
shift = -shift
|
shift = -shift
|
||||||
}
|
}
|
||||||
sliderBubblePaint(p, k, x+shift, y, s)
|
sliderBubblePaint(p, k, x+shift, y, s, size)
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ func TestRangeSliderKeysPerThumb(t *testing.T) {
|
|||||||
low, high, changes := 20.0, 80.0, 0
|
low, high, changes := 20.0, 80.0, 0
|
||||||
tt := rangeTester(&low, &high, RangeSliderOptions{Step: 10, Label: "Price"}, &changes)
|
tt := rangeTester(&low, &high, RangeSliderOptions{Step: 10, Label: "Price"}, &changes)
|
||||||
tt.Key(0, ui.KeyTab)
|
tt.Key(0, ui.KeyTab)
|
||||||
if !tt.Focused("Price minimum, 20") {
|
if !tt.Focused("Price minimum") {
|
||||||
t.Fatal("first Tab did not focus the low thumb")
|
t.Fatal("first Tab did not focus the low thumb")
|
||||||
}
|
}
|
||||||
tt.Key(0, ui.KeyRight)
|
tt.Key(0, ui.KeyRight)
|
||||||
@@ -37,7 +37,7 @@ func TestRangeSliderKeysPerThumb(t *testing.T) {
|
|||||||
t.Fatalf("low crossed high: %v", low)
|
t.Fatalf("low crossed high: %v", low)
|
||||||
}
|
}
|
||||||
tt.Key(0, ui.KeyTab)
|
tt.Key(0, ui.KeyTab)
|
||||||
if !tt.Focused("Price maximum, 80") {
|
if !tt.Focused("Price maximum") {
|
||||||
t.Fatal("second Tab did not focus the high thumb")
|
t.Fatal("second Tab did not focus the high thumb")
|
||||||
}
|
}
|
||||||
tt.Key(0, ui.KeyLeft)
|
tt.Key(0, ui.KeyLeft)
|
||||||
@@ -77,18 +77,6 @@ func TestRangeSliderOrdersSwappedEnds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRangeSliderDisabled(t *testing.T) {
|
|
||||||
low, high, changes := 20.0, 40.0, 0
|
|
||||||
tt := rangeTester(&low, &high, RangeSliderOptions{Disabled: true}, &changes)
|
|
||||||
tt.Key(0, ui.KeyTab)
|
|
||||||
tt.Key(0, ui.KeyRight)
|
|
||||||
tt.Press(150, 18)
|
|
||||||
tt.Release(150, 18)
|
|
||||||
if low != 20 || high != 40 || changes != 0 {
|
|
||||||
t.Fatalf("disabled moved: %v..%v", low, high)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRangeSliderSnapshot(t *testing.T) {
|
func TestRangeSliderSnapshot(t *testing.T) {
|
||||||
snapshot(t, "range_slider", 420, 170, func(c *ui.Context) {
|
snapshot(t, "range_slider", 420, 170, func(c *ui.Context) {
|
||||||
a, b := 20.0, 70.0
|
a, b := 20.0, 70.0
|
||||||
|
|||||||
@@ -41,19 +41,15 @@ type selectTypeahead struct {
|
|||||||
at time.Time
|
at time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select chooses *value from options in a popup. Enter, Space and the
|
// Select chooses *value from options in a popup; typing on the closed select jumps to a match.
|
||||||
// arrows open it; the arrows, Home and End move; Enter chooses; Escape
|
|
||||||
// closes. Typing on the closed select chooses the option starting with it.
|
|
||||||
func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], opts SelectOptions) ChoiceResult {
|
func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], opts SelectOptions) ChoiceResult {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
old := *value
|
old := *value
|
||||||
if opts.Disabled {
|
value = choiceTarget(value, opts.Disabled)
|
||||||
// SelectBase takes clicks as it is built: a disabled one works on a copy.
|
var s *ui.SelectParts[T]
|
||||||
scratch := old
|
// SelectBase takes clicks as it is built: a disabled wrapper and a copy block them.
|
||||||
value = &scratch
|
ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { s = ui.SelectBase(c, value) })
|
||||||
}
|
tr := s.Trigger
|
||||||
s := ui.SelectBase(c, value)
|
|
||||||
tr := s.Trigger.Disabled(opts.Disabled)
|
|
||||||
if opts.Label != "" {
|
if opts.Label != "" {
|
||||||
tr.Label(opts.Label)
|
tr.Label(opts.Label)
|
||||||
}
|
}
|
||||||
@@ -93,6 +89,10 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
lastHi := ui.Local(tr, selectKey{"shown"}, func() int { return -1 })
|
lastHi := ui.Local(tr, selectKey{"shown"}, func() int { return -1 })
|
||||||
|
if opts.Disabled {
|
||||||
|
// SelectParts has no way to close it (ui/base.go:310): a disabled select builds no popup.
|
||||||
|
return ChoiceResult{tr, false}
|
||||||
|
}
|
||||||
s.Popup(func(panel *ui.Element) {
|
s.Popup(func(panel *ui.Element) {
|
||||||
selectPanel(c, panel)
|
selectPanel(c, panel)
|
||||||
ui.Scroll(c).MaxHeight(280).Children(func() {
|
ui.Scroll(c).MaxHeight(280).Children(func() {
|
||||||
@@ -191,17 +191,33 @@ func selectChevron(open bool) string {
|
|||||||
return "chevron-down"
|
return "chevron-down"
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectClear draws a small clear button that takes no Tab stop; it reports a click.
|
// selectClear draws a clear cross outside the Tab order, hit area 28 DIP laid out as 18; it reports a click.
|
||||||
func selectClear(c *ui.Context, k theme.Tokens, name string) bool {
|
func selectClear(c *ui.Context, k theme.Tokens, name string) bool {
|
||||||
x := ui.Box(c).Size(18, 18).Shrink(0).Radius(theme.ControlRadius).Role(ui.RoleButton).Label(name)
|
x := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Margin(-5).Shrink(0).Radius(theme.ControlRadius).
|
||||||
|
Role(ui.RoleButton).Label(name).Cursor(ui.CursorPointer)
|
||||||
clicked := x.Clicked()
|
clicked := x.Clicked()
|
||||||
if x.Hovered() {
|
if x.Hovered() {
|
||||||
x.Background(k.SurfaceHover)
|
x.Background(k.SurfaceHover)
|
||||||
}
|
}
|
||||||
x.Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, r, "x", k.TextMuted) })
|
x.Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, selectCenter(r, 14), "x", k.TextMuted) })
|
||||||
return clicked
|
return clicked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectCenter returns the size×size square centered in r.
|
||||||
|
func selectCenter(r ui.Rect, size float32) ui.Rect {
|
||||||
|
return ui.Rect{X: r.X + (r.W-size)/2, Y: r.Y + (r.H-size)/2, W: size, H: size}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectCheck reserves room for the chosen mark and draws it when on.
|
||||||
|
func selectCheck(c *ui.Context, on bool) {
|
||||||
|
k := Tokens(c)
|
||||||
|
ui.Box(c).Size(16, 16).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
if on {
|
||||||
|
internal.Mark(p, r, "check", k.AccentText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// selectPanel styles a popup panel.
|
// selectPanel styles a popup panel.
|
||||||
func selectPanel(c *ui.Context, panel *ui.Element) {
|
func selectPanel(c *ui.Context, panel *ui.Element) {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
@@ -209,24 +225,25 @@ func selectPanel(c *ui.Context, panel *ui.Element) {
|
|||||||
Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.14))
|
Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.14))
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectRow styles an option row: highlight background, check mark when chosen.
|
// selectRow styles an option row: chosen has the selection fill and a check, highlighted a side bar.
|
||||||
func selectRow(c *ui.Context, row *ui.Element, highlighted, chosen bool, label string) {
|
func selectRow(c *ui.Context, row *ui.Element, highlighted, chosen bool, label string) {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
d := Density(c)
|
d := Density(c)
|
||||||
row.MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*3, 0, d.Unit()*2).Gap(d.Unit() * 2).
|
row.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*2).Gap(d.Unit() * 2).
|
||||||
AlignItems(ui.Center).Radius(theme.ControlRadius)
|
AlignItems(ui.Center).Radius(theme.ControlRadius).Role(ui.RoleListItem)
|
||||||
if highlighted {
|
switch {
|
||||||
|
case chosen:
|
||||||
|
row.Background(k.Selection).TextColor(k.AccentText)
|
||||||
|
case highlighted:
|
||||||
row.Background(k.SurfaceHover)
|
row.Background(k.SurfaceHover)
|
||||||
}
|
}
|
||||||
if chosen {
|
if highlighted {
|
||||||
row.TextColor(k.AccentText)
|
row.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
row.Children(func() {
|
row.Children(func() {
|
||||||
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
selectCheck(c, chosen)
|
||||||
if chosen {
|
|
||||||
internal.Mark(p, r, "check", k.AccentText)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
ui.Text(c, label).SingleLine()
|
ui.Text(c, label).SingleLine()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/egoist/mygo/ui"
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ type SliderOptions struct {
|
|||||||
Marks []SliderMark
|
Marks []SliderMark
|
||||||
// ShowValue shows the value above the thumb while hovered, dragged or focused.
|
// ShowValue shows the value above the thumb while hovered, dragged or focused.
|
||||||
ShowValue bool
|
ShowValue bool
|
||||||
// Format writes the value; nil prints it with the step's precision.
|
// Format writes the value; nil prints as many decimals as Min and Step need.
|
||||||
Format func(float64) string
|
Format func(float64) string
|
||||||
Disabled bool
|
Disabled bool
|
||||||
// Label names the slider for assistive technology.
|
// Label names the slider for assistive technology.
|
||||||
@@ -63,20 +64,67 @@ func (g sliderGeom) snap(v float64) float64 {
|
|||||||
return math.Round(v*1e9) / 1e9
|
return math.Round(v*1e9) / 1e9
|
||||||
}
|
}
|
||||||
|
|
||||||
// keyed returns the value after a key moved old to raw, MyGo's 1% key step,
|
// sliderKey is the last key released on a slider, and a move waiting for it.
|
||||||
// as one step of the slider: Home and End land on the ends.
|
type sliderKey struct {
|
||||||
func (g sliderGeom) keyed(old, raw float64) float64 {
|
up ui.Key
|
||||||
switch {
|
pending int
|
||||||
case g.step <= 0:
|
}
|
||||||
return raw
|
|
||||||
case raw == g.lo:
|
type sliderKeyID struct{}
|
||||||
return g.lo
|
|
||||||
case raw == g.hi:
|
// sliderControl builds a SliderBase on a copy of v and returns where the pointer or a key moves v.
|
||||||
return g.snap(g.hi)
|
func sliderControl(c *ui.Context, v float64, g sliderGeom, from, to float64) (s *ui.Element, next float64, dragged bool) {
|
||||||
case raw > old:
|
raw := v
|
||||||
return g.snap(min(g.hi, old+g.step))
|
s = ui.SliderBase(c, &raw, g.lo, g.hi)
|
||||||
|
ks := *ui.Local(s, sliderKeyID{}, func() *sliderKey { return &sliderKey{} })
|
||||||
|
// MyGo gives key downs to the base alone (ui/base.go:118); releases still reach this handler.
|
||||||
|
s.HandleInput(func(ev ui.InputEvent) bool {
|
||||||
|
if ev.Kind != ui.InputKeyUp {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ks.up = ev.Key
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
up, dir := ks.up, ks.pending
|
||||||
|
ks.up, ks.pending = 0, 0
|
||||||
|
if _, _, dragged = s.Dragged(); dragged {
|
||||||
|
return s, max(from, min(to, g.snap(raw))), true
|
||||||
}
|
}
|
||||||
return g.snap(old - g.step)
|
unsure := dir != 0
|
||||||
|
switch {
|
||||||
|
case raw < v:
|
||||||
|
dir, unsure = -1, false
|
||||||
|
case raw > v:
|
||||||
|
dir, unsure = 1, false
|
||||||
|
}
|
||||||
|
if dir == 0 {
|
||||||
|
return s, v, false
|
||||||
|
}
|
||||||
|
end, limit := g.lo, from
|
||||||
|
if dir > 0 {
|
||||||
|
end, limit = g.hi, to
|
||||||
|
}
|
||||||
|
jump := raw == end
|
||||||
|
// An arrow within 1% of an end lands on it too: the released key tells which it was.
|
||||||
|
if unsure || jump && math.Abs(v-end) <= (g.hi-g.lo)/100*(1+1e-9) {
|
||||||
|
switch up {
|
||||||
|
case ui.KeyHome, ui.KeyEnd:
|
||||||
|
jump = true
|
||||||
|
case ui.KeyLeft, ui.KeyRight, ui.KeyUp, ui.KeyDown:
|
||||||
|
jump = false
|
||||||
|
default:
|
||||||
|
ks.pending = dir
|
||||||
|
return s, v, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jump {
|
||||||
|
return s, limit, false
|
||||||
|
}
|
||||||
|
step := g.step
|
||||||
|
if step <= 0 {
|
||||||
|
step = (g.hi - g.lo) / 100
|
||||||
|
}
|
||||||
|
return s, max(from, min(to, g.snap(v+float64(dir)*step))), false
|
||||||
}
|
}
|
||||||
|
|
||||||
func sliderSetup(min, max, step float64, marks []SliderMark, showValue bool, c *ui.Context) (sliderGeom, float32) {
|
func sliderSetup(min, max, step float64, marks []SliderMark, showValue bool, c *ui.Context) (sliderGeom, float32) {
|
||||||
@@ -101,66 +149,70 @@ func sliderSetup(min, max, step float64, marks []SliderMark, showValue bool, c *
|
|||||||
return g, h
|
return g, h
|
||||||
}
|
}
|
||||||
|
|
||||||
func sliderFormat(format func(float64) string, step float64) func(float64) string {
|
// sliderDecimals counts the decimals the shortest form of each value needs.
|
||||||
|
func sliderDecimals(vs ...float64) int {
|
||||||
|
n := 0
|
||||||
|
for _, v := range vs {
|
||||||
|
s := strconv.FormatFloat(v, 'f', -1, 64)
|
||||||
|
if i := strings.IndexByte(s, '.'); i >= 0 {
|
||||||
|
n = max(n, len(s)-i-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func sliderFormat(format func(float64) string, g sliderGeom) func(float64) string {
|
||||||
if format != nil {
|
if format != nil {
|
||||||
return format
|
return format
|
||||||
}
|
}
|
||||||
prec := 0
|
prec := sliderDecimals(g.lo, g.step)
|
||||||
if step > 0 && step < 1 {
|
if g.step == 0 {
|
||||||
prec = int(math.Ceil(-math.Log10(step) - 1e-9))
|
prec = max(prec, 1)
|
||||||
} else if step == 0 {
|
|
||||||
prec = 1
|
|
||||||
}
|
}
|
||||||
return func(v float64) string { return strconv.FormatFloat(v, 'f', prec, 64) }
|
return func(v float64) string { return strconv.FormatFloat(v, 'f', prec, 64) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slider sets *value between Min and Max by dragging anywhere on its box,
|
// Slider sets *value by dragging anywhere on its box, the arrows (one Step), Home and End.
|
||||||
// the arrows (one Step), Home and End. The thumb is a diamond; the hit area
|
|
||||||
// is the whole rectangle.
|
|
||||||
func Slider(c *ui.Context, value *float64, opts SliderOptions) ChoiceResult {
|
func Slider(c *ui.Context, value *float64, opts SliderOptions) ChoiceResult {
|
||||||
g, h := sliderSetup(opts.Min, opts.Max, opts.Step, opts.Marks, opts.ShowValue, c)
|
g, h := sliderSetup(opts.Min, opts.Max, opts.Step, opts.Marks, opts.ShowValue, c)
|
||||||
format := sliderFormat(opts.Format, opts.Step)
|
format := sliderFormat(opts.Format, g)
|
||||||
|
size := FontSize(c, theme.CaptionSize)
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
old := g.snap(*value)
|
old := g.snap(*value)
|
||||||
*value = old
|
*value = old
|
||||||
target := value
|
var s *ui.Element
|
||||||
if opts.Disabled {
|
var next float64
|
||||||
// SliderBase takes the pointer as it is built: a disabled one moves a copy.
|
var held bool
|
||||||
scratch := old
|
// Disabled on a wrapper blocks the base before it takes input.
|
||||||
target = &scratch
|
ui.Column(c).Disabled(opts.Disabled).Children(func() {
|
||||||
}
|
s, next, held = sliderControl(c, old, g, g.lo, g.hi)
|
||||||
s := ui.SliderBase(c, target, g.lo, g.hi).Height(h).MinWidth(120).PaddingX(sliderThumb / 2).
|
})
|
||||||
FocusRing(false).Disabled(opts.Disabled).Cursor(ui.CursorPointer)
|
s.Height(h).MinWidth(120).PaddingX(sliderThumb / 2).FocusRing(false).Cursor(ui.CursorPointer)
|
||||||
if opts.Label != "" {
|
if opts.Label != "" {
|
||||||
s.Label(opts.Label)
|
s.Label(opts.Label)
|
||||||
}
|
}
|
||||||
if *value != old {
|
if !opts.Disabled {
|
||||||
if _, _, drag := s.Dragged(); drag {
|
*value = next
|
||||||
*value = g.snap(*value)
|
|
||||||
} else {
|
|
||||||
*value = g.keyed(old, *value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
changed := *value != old
|
changed := *value != old
|
||||||
if changed {
|
if changed {
|
||||||
slog.Debug("mujica: slider changed", "label", opts.Label, "value", *value)
|
slog.Debug("mujica: slider changed", "label", opts.Label, "value", *value)
|
||||||
}
|
}
|
||||||
_, _, held := s.Dragged()
|
|
||||||
hover := s.Hovered()
|
hover := s.Hovered()
|
||||||
v := *value
|
v := *value
|
||||||
s.Draw(func(p *ui.Painter, r ui.Rect) {
|
s.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
sliderTrackPaint(p, r, g, k, opts.Marks, format, g.x(r, g.lo), g.x(r, v))
|
sliderTrackPaint(p, r, g, k, opts.Marks, size, g.x(r, g.lo), g.x(r, v))
|
||||||
x := g.x(r, v)
|
x := g.x(r, v)
|
||||||
sliderThumbPaint(p, k, x, r.Y+g.trackY, hover || held, s.FocusVisible())
|
sliderThumbPaint(p, k, x, r.Y+g.trackY, hover || held, s.FocusVisible())
|
||||||
if opts.ShowValue && (hover || held || s.Focused()) {
|
if opts.ShowValue && (hover || held || s.Focused()) {
|
||||||
sliderBubblePaint(p, k, x, r.Y+g.trackY, format(v))
|
sliderBubblePaint(p, k, x, r.Y+g.trackY, format(v), size)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return ChoiceResult{s, changed}
|
return ChoiceResult{s, changed}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sliderTrackPaint draws the rail, the filled part from x0 to x1, and the marks.
|
// sliderTrackPaint draws the rail, the filled part from x0 to x1, and the marks.
|
||||||
func sliderTrackPaint(p *ui.Painter, r ui.Rect, g sliderGeom, k theme.Tokens, marks []SliderMark, format func(float64) string, x0, x1 float32) {
|
func sliderTrackPaint(p *ui.Painter, r ui.Rect, g sliderGeom, k theme.Tokens, marks []SliderMark, size, x0, x1 float32) {
|
||||||
y := r.Y + g.trackY
|
y := r.Y + g.trackY
|
||||||
left, right := g.x(r, g.lo), g.x(r, g.hi)
|
left, right := g.x(r, g.lo), g.x(r, g.hi)
|
||||||
p.Fill(ui.Rect{X: left, Y: y - sliderTrack/2, W: right - left, H: sliderTrack}, k.Border, 0)
|
p.Fill(ui.Rect{X: left, Y: y - sliderTrack/2, W: right - left, H: sliderTrack}, k.Border, 0)
|
||||||
@@ -170,8 +222,8 @@ func sliderTrackPaint(p *ui.Painter, r ui.Rect, g sliderGeom, k theme.Tokens, ma
|
|||||||
x := g.x(r, m.Value)
|
x := g.x(r, m.Value)
|
||||||
p.Fill(ui.Rect{X: x - 0.5, Y: y + sliderTrack/2 + 2, W: 1, H: 5}, k.TextMuted, 0)
|
p.Fill(ui.Rect{X: x - 0.5, Y: y + sliderTrack/2 + 2, W: 1, H: 5}, k.TextMuted, 0)
|
||||||
if m.Label != "" {
|
if m.Label != "" {
|
||||||
w, _ := p.MeasureText(1000, ui.Span{Text: m.Label, Size: theme.CaptionSize})
|
w, _ := p.MeasureText(1000, ui.Span{Text: m.Label, Size: size})
|
||||||
p.Text(x-w/2, y+sliderTrack/2+8, m.Label, theme.CaptionSize, k.TextMuted)
|
p.Text(x-w/2, y+sliderTrack/2+8, m.Label, size, k.TextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,10 +243,10 @@ func sliderThumbPaint(p *ui.Painter, k theme.Tokens, x, y float32, hot, focus bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sliderBubblePaint draws the value tip above the thumb at (x, y).
|
// sliderBubblePaint draws the value tip above the thumb at (x, y).
|
||||||
func sliderBubblePaint(p *ui.Painter, k theme.Tokens, x, y float32, s string) {
|
func sliderBubblePaint(p *ui.Painter, k theme.Tokens, x, y float32, s string, size float32) {
|
||||||
w, h := p.MeasureText(1000, ui.Span{Text: s, Size: theme.CaptionSize})
|
w, h := p.MeasureText(1000, ui.Span{Text: s, Size: size})
|
||||||
b := ui.Rect{X: x - w/2 - 6, Y: y - sliderThumb/2 - h - 10, W: w + 12, H: h + 4}
|
b := ui.Rect{X: x - w/2 - 6, Y: y - sliderThumb/2 - h - 10, W: w + 12, H: h + 4}
|
||||||
p.Fill(b, k.Surface, theme.ControlRadius)
|
p.Fill(b, k.Surface, theme.ControlRadius)
|
||||||
p.Stroke(b, k.ControlBorder, theme.ControlRadius, theme.BorderWidth)
|
p.Stroke(b, k.ControlBorder, theme.ControlRadius, theme.BorderWidth)
|
||||||
p.Text(b.X+6, b.Y+2, s, theme.CaptionSize, k.Text)
|
p.Text(b.X+6, b.Y+2, s, size, k.Text)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSliderFineStepNearEnds(t *testing.T) {
|
||||||
|
v, changes := 99.2, 0
|
||||||
|
tt := sliderTester(&v, SliderOptions{Step: 0.1, Label: "Gain"}, &changes)
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
tt.Key(0, ui.KeyRight)
|
||||||
|
if v != 99.3 {
|
||||||
|
t.Fatalf("Right from 99.2: %v", v)
|
||||||
|
}
|
||||||
|
tt.Key(0, ui.KeyEnd)
|
||||||
|
if v != 100 {
|
||||||
|
t.Fatalf("End: %v", v)
|
||||||
|
}
|
||||||
|
v = 0.5
|
||||||
|
tt.Frame()
|
||||||
|
tt.Key(0, ui.KeyLeft)
|
||||||
|
if v != 0.4 {
|
||||||
|
t.Fatalf("Left from 0.5: %v", v)
|
||||||
|
}
|
||||||
|
tt.Key(0, ui.KeyHome)
|
||||||
|
if v != 0 {
|
||||||
|
t.Fatalf("Home: %v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRangeSliderFineStepNearEnds(t *testing.T) {
|
||||||
|
low, high, changes := 0.3, 99.8, 0
|
||||||
|
tt := rangeTester(&low, &high, RangeSliderOptions{Step: 0.1, Label: "Band"}, &changes)
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
tt.Key(0, ui.KeyLeft)
|
||||||
|
if low != 0.2 {
|
||||||
|
t.Fatalf("Left from 0.3: %v", low)
|
||||||
|
}
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
tt.Key(0, ui.KeyRight)
|
||||||
|
if high != 99.9 {
|
||||||
|
t.Fatalf("Right from 99.8: %v", high)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSliderFormatKeepsStepDecimals(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
g sliderGeom
|
||||||
|
v float64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{sliderGeom{lo: 0, hi: 1, step: 0.25}, 0.75, "0.75"},
|
||||||
|
{sliderGeom{lo: 0.5, hi: 10, step: 1}, 2.5, "2.5"},
|
||||||
|
{sliderGeom{lo: 0, hi: 100, step: 5}, 35, "35"},
|
||||||
|
} {
|
||||||
|
if got := sliderFormat(nil, tc.g)(tc.v); got != tc.want {
|
||||||
|
t.Errorf("%+v: %q, want %q", tc.g, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSliderMarksFollowTextScale(t *testing.T) {
|
||||||
|
render := func(scale float32) []byte {
|
||||||
|
v, low, high := 50.0, 20.0, 80.0
|
||||||
|
marks := []SliderMark{{Value: 0, Label: "Low"}, {Value: 100, Label: "High"}}
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{})
|
||||||
|
ui.Column(c).Width(300).Children(func() {
|
||||||
|
Slider(c, &v, SliderOptions{Marks: marks})
|
||||||
|
RangeSlider(c, &low, &high, RangeSliderOptions{Marks: marks})
|
||||||
|
})
|
||||||
|
}, 320, 200)
|
||||||
|
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: scale})
|
||||||
|
tt.Frame()
|
||||||
|
return encode(t, tt.Image())
|
||||||
|
}
|
||||||
|
if bytes.Equal(render(1), render(2)) {
|
||||||
|
t.Fatal("mark captions ignore the text scale")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,18 +70,6 @@ func TestSliderClampsOutOfRangeValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSliderDisabled(t *testing.T) {
|
|
||||||
v, changes := 30.0, 0
|
|
||||||
tt := sliderTester(&v, SliderOptions{Disabled: true, Step: 1}, &changes)
|
|
||||||
tt.Key(0, ui.KeyTab)
|
|
||||||
tt.Key(0, ui.KeyRight)
|
|
||||||
tt.Press(100, 18)
|
|
||||||
tt.Release(100, 18)
|
|
||||||
if v != 30 {
|
|
||||||
t.Fatalf("disabled slider moved to %v", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSliderBadRange(t *testing.T) {
|
func TestSliderBadRange(t *testing.T) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if recover() == nil {
|
if recover() == nil {
|
||||||
|
|||||||
@@ -24,13 +24,17 @@ const (
|
|||||||
switchK float32 = 14
|
switchK float32 = 14
|
||||||
)
|
)
|
||||||
|
|
||||||
// Switch turns *on over when clicked or with Space. Changed reports the
|
// Switch turns *on over by click or Space; Changed reports it, the caller applies it.
|
||||||
// new value; the caller applies it and may set Loading while it does.
|
|
||||||
func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) *ui.Element {
|
func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) *ui.Element {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
d := Density(c)
|
d := Density(c)
|
||||||
sw := ui.SwitchBase(c, on).Gap(d.Unit() * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).
|
blocked := opts.Disabled || opts.Loading
|
||||||
FocusRing(false).Disabled(opts.Disabled || opts.Loading)
|
var sw *ui.Element
|
||||||
|
// Disabled on a wrapper, and a copy, block the base before it takes input.
|
||||||
|
ui.Row(c).Shrink(0).Disabled(blocked).Children(func() {
|
||||||
|
sw = ui.SwitchBase(c, choiceTarget(on, blocked)).Gap(d.Unit() * 2).AlignItems(ui.Center).
|
||||||
|
MinHeight(d.ControlHeight()).FocusRing(false)
|
||||||
|
})
|
||||||
if opts.Description != "" {
|
if opts.Description != "" {
|
||||||
sw.Description(opts.Description)
|
sw.Description(opts.Description)
|
||||||
}
|
}
|
||||||
@@ -78,6 +82,15 @@ func Switch(c *ui.Context, on *bool, label string, opts SwitchOptions) *ui.Eleme
|
|||||||
return sw
|
return sw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// choiceTarget is v, or a throwaway copy of it while input is blocked.
|
||||||
|
func choiceTarget[T any](v *T, blocked bool) *T {
|
||||||
|
if !blocked {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
cp := *v
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
|
||||||
func switchFloat(b bool) float32 {
|
func switchFloat(b bool) float32 {
|
||||||
if b {
|
if b {
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -23,7 +23,10 @@ func Toggle(c *ui.Context, on *bool, label string, opts ToggleOptions) *ui.Eleme
|
|||||||
if label == "" && opts.Label == "" {
|
if label == "" && opts.Label == "" {
|
||||||
panic("mujica: Toggle needs a label or Label")
|
panic("mujica: Toggle needs a label or Label")
|
||||||
}
|
}
|
||||||
b := ui.ToggleBase(c, on).Disabled(opts.Disabled)
|
var b *ui.Element
|
||||||
|
ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() {
|
||||||
|
b = ui.ToggleBase(c, choiceTarget(on, opts.Disabled))
|
||||||
|
})
|
||||||
if b.Changed() {
|
if b.Changed() {
|
||||||
slog.Debug("mujica: toggle changed", "label", label+opts.Label, "on", *on)
|
slog.Debug("mujica: toggle changed", "label", label+opts.Label, "on", *on)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import (
|
|||||||
"mujicaui/theme"
|
"mujicaui/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChoiceResult is the element of a choice control and whether its value
|
// ChoiceResult is a choice control's element and whether its value changed this frame.
|
||||||
// changed in this frame.
|
|
||||||
type ChoiceResult struct {
|
type ChoiceResult struct {
|
||||||
*ui.Element
|
*ui.Element
|
||||||
changed bool
|
changed bool
|
||||||
@@ -42,9 +41,7 @@ type ToggleGroupOptions struct {
|
|||||||
Label string
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToggleGroup is a joined row of toggles choosing *value: one item, or
|
// ToggleGroup is a joined row of toggles choosing one item of *value, or several with Multiple.
|
||||||
// several with Multiple. Tab enters the group, the arrows move among the
|
|
||||||
// items, Space presses one.
|
|
||||||
func ToggleGroup[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], opts ToggleGroupOptions) ChoiceResult {
|
func ToggleGroup[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T], opts ToggleGroupOptions) ChoiceResult {
|
||||||
if !opts.Multiple && len(*value) > 1 {
|
if !opts.Multiple && len(*value) > 1 {
|
||||||
panic("mujica: single ToggleGroup holds more than one value")
|
panic("mujica: single ToggleGroup holds more than one value")
|
||||||
@@ -67,7 +64,8 @@ func ToggleGroup[T comparable](c *ui.Context, value *[]T, items []ToggleItem[T],
|
|||||||
on := slices.Contains(*value, it.Value)
|
on := slices.Contains(*value, it.Value)
|
||||||
pressed := on
|
pressed := on
|
||||||
b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled)
|
b := ui.ToggleBase(c, &pressed).Disabled(it.Disabled)
|
||||||
if b.Changed() && toggleGroupApply(value, it.Value, pressed, opts) {
|
// The base took input before Disabled applied: drop a disabled item's press.
|
||||||
|
if b.Changed() && !it.Disabled && !opts.Disabled && toggleGroupApply(value, it.Value, pressed, opts) {
|
||||||
changed = true
|
changed = true
|
||||||
on = pressed
|
on = pressed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,20 +42,6 @@ func TestToggleIconOnlyNamed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToggleDisabled(t *testing.T) {
|
|
||||||
on := false
|
|
||||||
tt := ui.NewTester(func(c *ui.Context) {
|
|
||||||
Use(c, Settings{})
|
|
||||||
Toggle(c, &on, "Italic", ToggleOptions{Disabled: true})
|
|
||||||
}, 300, 120)
|
|
||||||
_ = tt.Click("Italic")
|
|
||||||
tt.Key(0, ui.KeyTab)
|
|
||||||
tt.Key(0, ui.KeySpace)
|
|
||||||
if on {
|
|
||||||
t.Fatal("disabled toggle changed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToggleNeedsName(t *testing.T) {
|
func TestToggleNeedsName(t *testing.T) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if recover() == nil {
|
if recover() == nil {
|
||||||
|
|||||||
@@ -59,9 +59,7 @@ type treeSelectRow struct {
|
|||||||
parent string
|
parent string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TreeSelect chooses one node, or several with Multiple, from a tree in a
|
// TreeSelect chooses one node, or several with Multiple, from a searchable tree in a popup.
|
||||||
// popup with search. Up and Down move, Right expands, Left collapses or
|
|
||||||
// goes to the parent, Space or Enter chooses; Escape closes.
|
|
||||||
func TreeSelect(c *ui.Context, selected *[]string, nodes []TreeSelectNode, opts TreeSelectOptions) ChoiceResult {
|
func TreeSelect(c *ui.Context, selected *[]string, nodes []TreeSelectNode, opts TreeSelectOptions) ChoiceResult {
|
||||||
if !opts.Multiple && len(*selected) > 1 {
|
if !opts.Multiple && len(*selected) > 1 {
|
||||||
panic("mujica: single TreeSelect holds more than one ID")
|
panic("mujica: single TreeSelect holds more than one ID")
|
||||||
@@ -73,6 +71,9 @@ func TreeSelect(c *ui.Context, selected *[]string, nodes []TreeSelectNode, opts
|
|||||||
tr.Label(opts.Label)
|
tr.Label(opts.Label)
|
||||||
}
|
}
|
||||||
st := *ui.Local(tr, treeSelectKey{}, func() *treeSelectState { return &treeSelectState{expanded: map[string]bool{}} })
|
st := *ui.Local(tr, treeSelectKey{}, func() *treeSelectState { return &treeSelectState{expanded: map[string]bool{}} })
|
||||||
|
if opts.Disabled {
|
||||||
|
st.open = false
|
||||||
|
}
|
||||||
if tr.Clicked() || (!st.open && tr.Shortcut(0, ui.KeyDown)) {
|
if tr.Clicked() || (!st.open && tr.Shortcut(0, ui.KeyDown)) {
|
||||||
st.open, st.query = !st.open, ""
|
st.open, st.query = !st.open, ""
|
||||||
}
|
}
|
||||||
@@ -104,10 +105,14 @@ func TreeSelect(c *ui.Context, selected *[]string, nodes []TreeSelectNode, opts
|
|||||||
selectNote(c, msg(c, "tree_select.no_results"))
|
selectNote(c, msg(c, "tree_select.no_results"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
shown := make(map[string]bool, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
shown[r.node.ID] = true
|
||||||
|
}
|
||||||
ui.Scroll(c).MaxHeight(300).Padding(4).Children(func() {
|
ui.Scroll(c).MaxHeight(300).Padding(4).Children(func() {
|
||||||
ui.Column(c).FocusGroup(ui.Vertical).Children(func() {
|
ui.Column(c).FocusGroup(ui.Vertical).Children(func() {
|
||||||
for i, r := range rows {
|
for i, r := range rows {
|
||||||
treeSelectRowView(c, st, selected, r, i == 0, opts)
|
treeSelectRowView(c, st, selected, r, i == 0, shown, opts)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -127,7 +132,7 @@ func treeSelectWalk(nodes []TreeSelectNode, fn func(n *TreeSelectNode)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// treeSelectVisible lists the rows to show: expanded ones, or while searching the matches and their ancestors.
|
// treeSelectVisible lists the rows to show: expanded ones, or matches and their ancestors.
|
||||||
func treeSelectVisible(nodes []TreeSelectNode, st *treeSelectState) []treeSelectRow {
|
func treeSelectVisible(nodes []TreeSelectNode, st *treeSelectState) []treeSelectRow {
|
||||||
q := strings.ToLower(strings.TrimSpace(st.query))
|
q := strings.ToLower(strings.TrimSpace(st.query))
|
||||||
var rows []treeSelectRow
|
var rows []treeSelectRow
|
||||||
@@ -155,9 +160,9 @@ func treeSelectVisible(nodes []TreeSelectNode, st *treeSelectState) []treeSelect
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
// treeSelectCheck returns a node's check state under the rule.
|
// treeSelectCheck returns a node's check state: its own in single mode, else by the rule.
|
||||||
func treeSelectCheck(n *TreeSelectNode, selected []string, opts TreeSelectOptions) internal.CheckState {
|
func treeSelectCheck(n *TreeSelectNode, selected []string, opts TreeSelectOptions) internal.CheckState {
|
||||||
if opts.Rule == TreeSelectIndependent || len(n.Children) == 0 {
|
if !opts.Multiple || opts.Rule == TreeSelectIndependent || len(n.Children) == 0 {
|
||||||
if slices.Contains(selected, n.ID) {
|
if slices.Contains(selected, n.ID) {
|
||||||
return internal.Checked
|
return internal.Checked
|
||||||
}
|
}
|
||||||
@@ -201,14 +206,38 @@ func treeSelectToggle(n *TreeSelectNode, selected []string, on bool, opts TreeSe
|
|||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
|
|
||||||
func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r treeSelectRow, first bool, opts TreeSelectOptions) {
|
// treeSelectFirstChild returns the first child of n that shows and is enabled, or "".
|
||||||
|
func treeSelectFirstChild(n *TreeSelectNode, shown map[string]bool) string {
|
||||||
|
for _, ch := range n.Children {
|
||||||
|
if shown[ch.ID] && !ch.Disabled {
|
||||||
|
return ch.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeSelectDescribe says what MyGo cannot set on custom rows (ui/element.go:282).
|
||||||
|
func treeSelectDescribe(c *ui.Context, b *ui.Element, branch, open bool, state internal.CheckState) {
|
||||||
|
var parts []string
|
||||||
|
if branch {
|
||||||
|
parts = append(parts, msg(c, map[bool]string{true: "tree_select.expanded", false: "tree_select.collapsed"}[open]))
|
||||||
|
}
|
||||||
|
if state == internal.Mixed {
|
||||||
|
parts = append(parts, msg(c, "tree_select.mixed"))
|
||||||
|
}
|
||||||
|
if len(parts) > 0 {
|
||||||
|
b.Description(strings.Join(parts, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r treeSelectRow, first bool, shown map[string]bool, opts TreeSelectOptions) {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
d := Density(c)
|
d := Density(c)
|
||||||
n := r.node
|
n := r.node
|
||||||
branch := len(n.Children) > 0
|
branch := len(n.Children) > 0
|
||||||
open := branch && (st.expanded[n.ID] || strings.TrimSpace(st.query) != "")
|
open := branch && (st.expanded[n.ID] || strings.TrimSpace(st.query) != "")
|
||||||
ui.Row(c).Key(n.ID).AlignItems(ui.Center).Gap(2).Padding(0, 0, 0, float32(r.depth)*18).Children(func() {
|
ui.Row(c).Key(n.ID).AlignItems(ui.Center).Gap(2).Padding(0, 0, 0, float32(r.depth)*18).Children(func() {
|
||||||
arrow := ui.Box(c).Size(18, 18).Shrink(0).Radius(theme.ControlRadius)
|
arrow := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Radius(theme.ControlRadius)
|
||||||
if branch {
|
if branch {
|
||||||
arrow.Role(ui.RoleButton).Label(fmt.Sprintf(msg(c, map[bool]string{true: "tree_select.collapse", false: "tree_select.expand"}[open]), n.Label))
|
arrow.Role(ui.RoleButton).Label(fmt.Sprintf(msg(c, map[bool]string{true: "tree_select.collapse", false: "tree_select.expand"}[open]), n.Label))
|
||||||
if arrow.Clicked() {
|
if arrow.Clicked() {
|
||||||
@@ -218,6 +247,8 @@ func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r
|
|||||||
arrow.Background(k.SurfaceHover)
|
arrow.Background(k.SurfaceHover)
|
||||||
}
|
}
|
||||||
arrow.Draw(func(p *ui.Painter, rc ui.Rect) {
|
arrow.Draw(func(p *ui.Painter, rc ui.Rect) {
|
||||||
|
in := (rc.W - 16) / 2
|
||||||
|
rc = ui.Rect{X: rc.X + in, Y: rc.Y + in, W: 16, H: 16}
|
||||||
internal.Mark(p, rc, map[bool]string{true: "chevron-down", false: "chevron-right"}[open], k.TextMuted)
|
internal.Mark(p, rc, map[bool]string{true: "chevron-down", false: "chevron-right"}[open], k.TextMuted)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -230,20 +261,28 @@ func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r
|
|||||||
*selected = treeSelectToggle(n, *selected, on, opts)
|
*selected = treeSelectToggle(n, *selected, on, opts)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
b = ui.ButtonBase(c).Disabled(n.Disabled).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*2).
|
chosen := state == internal.Checked
|
||||||
Justify(ui.Start).Radius(theme.ControlRadius).Grow(1)
|
ui.Row(c).Grow(1).Disabled(n.Disabled).Children(func() {
|
||||||
|
b = ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).
|
||||||
|
Justify(ui.Start).Radius(theme.ControlRadius).Grow(1)
|
||||||
|
})
|
||||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||||
if b.Clicked() {
|
if b.Clicked() {
|
||||||
*selected, st.open = []string{n.ID}, false
|
*selected, st.open = []string{n.ID}, false
|
||||||
}
|
}
|
||||||
|
b.Role(ui.RoleTreeItem).Selected(chosen).Background(ui.Transparent).TextColor(k.Text)
|
||||||
switch {
|
switch {
|
||||||
case state == internal.Checked:
|
case chosen:
|
||||||
b.Background(k.Selection).TextColor(k.AccentText)
|
b.Background(k.Selection).TextColor(k.AccentText)
|
||||||
case b.Hovered():
|
case b.Hovered():
|
||||||
b.Background(k.SurfaceHover)
|
b.Background(k.SurfaceHover)
|
||||||
}
|
}
|
||||||
b.Role(ui.RoleTreeItem).Children(func() { ui.Text(c, n.Label).SingleLine() })
|
b.Children(func() {
|
||||||
|
ui.Text(c, n.Label).SingleLine().Grow(1)
|
||||||
|
selectCheck(c, chosen)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
treeSelectDescribe(c, b, branch, open, state)
|
||||||
if st.focus == n.ID || first && st.focus == "\x00" {
|
if st.focus == n.ID || first && st.focus == "\x00" {
|
||||||
b.Focus()
|
b.Focus()
|
||||||
st.focus = ""
|
st.focus = ""
|
||||||
@@ -253,7 +292,7 @@ func treeSelectRowView(c *ui.Context, st *treeSelectState, selected *[]string, r
|
|||||||
case right && branch && !open:
|
case right && branch && !open:
|
||||||
st.expanded[n.ID] = true
|
st.expanded[n.ID] = true
|
||||||
case right && branch:
|
case right && branch:
|
||||||
st.focus = n.Children[0].ID
|
st.focus = treeSelectFirstChild(n, shown)
|
||||||
case left && open && st.expanded[n.ID]:
|
case left && open && st.expanded[n.ID]:
|
||||||
st.expanded[n.ID] = false
|
st.expanded[n.ID] = false
|
||||||
case left && r.parent != "":
|
case left && r.parent != "":
|
||||||
|
|||||||