Merge branch 'worktree-agent-a3c9c595e531146d2'
No files matched your search
@@ -91,13 +91,13 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP
|
|||||||
st.sync(*color)
|
st.sync(*color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if opts.Disabled {
|
if trigger.IsDisabled() {
|
||||||
st.open = false
|
st.open = false
|
||||||
} else if trigger.Clicked() {
|
} else if trigger.Clicked() {
|
||||||
st.open = !st.open
|
st.open = !st.open
|
||||||
}
|
}
|
||||||
border := k.ControlBorder
|
border := k.ControlBorder
|
||||||
if trigger.Hovered() && !opts.Disabled {
|
if trigger.Hovered() {
|
||||||
border = k.ControlBorder.Mix(k.Text, 0.25)
|
border = k.ControlBorder.Mix(k.Text, 0.25)
|
||||||
trigger.Background(k.SurfaceHover)
|
trigger.Background(k.SurfaceHover)
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,7 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if st.wasOpen && !st.open && !opts.Disabled {
|
if st.wasOpen && !st.open && !trigger.IsDisabled() {
|
||||||
trigger.Focus()
|
trigger.Focus()
|
||||||
}
|
}
|
||||||
st.wasOpen = st.open
|
st.wasOpen = st.open
|
||||||
@@ -212,10 +212,12 @@ func colorPickerSquare(c *ui.Context, st *colorPickerState, set func(colorPicker
|
|||||||
// colorPickerSlider is a channel slider from 0 to hi over the track track paints.
|
// colorPickerSlider is a channel slider from 0 to hi over the track track paints.
|
||||||
func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) *ui.Element {
|
func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) *ui.Element {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
const knob = 10
|
const knob, look = 10, 18
|
||||||
s := ui.SliderBase(c, v, 0, hi).Height(18).PaddingX(knob / 2).FocusRing(false).Label(name)
|
s := ui.SliderBase(c, v, 0, hi).Height(theme.MinHitSize).PaddingX(knob / 2).FocusRing(false).Label(name)
|
||||||
frac := float32(*v / hi)
|
frac := float32(*v / hi)
|
||||||
s.Draw(func(p *ui.Painter, r ui.Rect) {
|
s.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
// The hit area is taller than the drawn slider, which sits in its middle.
|
||||||
|
r = ui.Rect{X: r.X, Y: r.Y + (r.H-look)/2, W: r.W, H: look}
|
||||||
bar := ui.Rect{X: r.X, Y: r.Y + 3, W: r.W, H: r.H - 6}
|
bar := ui.Rect{X: r.X, Y: r.Y + 3, W: r.W, H: r.H - 6}
|
||||||
p.Clip(bar, theme.ControlRadius, func() { track(p, bar) })
|
p.Clip(bar, theme.ControlRadius, func() { track(p, bar) })
|
||||||
p.Stroke(bar, k.Border, theme.ControlRadius, theme.BorderWidth)
|
p.Stroke(bar, k.Border, theme.ControlRadius, theme.BorderWidth)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/egoist/mygo/ui"
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
func colorPickerTester(col *ui.Color, opts ColorPickerOptions, changes *int) *ui.Tester {
|
func colorPickerTester(col *ui.Color, opts ColorPickerOptions, changes *int) *ui.Tester {
|
||||||
@@ -111,7 +113,7 @@ func TestColorPickerDisabledAndParse(t *testing.T) {
|
|||||||
|
|
||||||
func TestColorPickerSnapshot(t *testing.T) {
|
func TestColorPickerSnapshot(t *testing.T) {
|
||||||
a, b := ui.Hex("#7D2034"), ui.Hex("#355E8099")
|
a, b := ui.Hex("#7D2034"), ui.Hex("#355E8099")
|
||||||
snapshot(t, "color_picker", 340, 520, func(c *ui.Context) {
|
snapshot(t, "color_picker", 340, 560, func(c *ui.Context) {
|
||||||
ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
|
ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
|
||||||
ColorPicker(c, &b, ColorPickerOptions{Disabled: true})
|
ColorPicker(c, &b, ColorPickerOptions{Disabled: true})
|
||||||
p := ColorPicker(c, &a, ColorPickerOptions{})
|
p := ColorPicker(c, &a, ColorPickerOptions{})
|
||||||
@@ -157,3 +159,17 @@ func TestColorPickerSwatchHitArea(t *testing.T) {
|
|||||||
t.Fatalf("swatch %v", r)
|
t.Fatalf("swatch %v", r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestColorPickerSliderHitAreaCompact(t *testing.T) {
|
||||||
|
col := ui.Hex("#7D2034")
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{Density: theme.Compact})
|
||||||
|
ColorPicker(c, &col, ColorPickerOptions{Label: "Accent"})
|
||||||
|
}, 400, 560)
|
||||||
|
_ = tt.Click("Accent")
|
||||||
|
for _, name := range []string{"Hue", "Opacity"} {
|
||||||
|
if r, ok := tt.Find(name); !ok || r.H < 28 {
|
||||||
|
t.Fatalf("%s slider %v", name, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,11 +152,12 @@ func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions)
|
|||||||
col.Children(func() {
|
col.Children(func() {
|
||||||
zone := ui.Column(c).AlignItems(ui.Center).Justify(ui.Center).Gap(d.Unit() * 2).MinHeight(140).Padding(d.Unit() * 5).
|
zone := ui.Column(c).AlignItems(ui.Center).Justify(ui.Center).Gap(d.Unit() * 2).MinHeight(140).Padding(d.Unit() * 5).
|
||||||
Radius(theme.CardRadius).Disabled(opts.Disabled)
|
Radius(theme.CardRadius).Disabled(opts.Disabled)
|
||||||
if dropped := zone.DroppedFiles(); dropped != nil && !opts.Disabled {
|
disabled := zone.IsDisabled()
|
||||||
|
if dropped := zone.DroppedFiles(); dropped != nil && !disabled {
|
||||||
slog.Debug("mujica: file drop zone dropped", "count", len(dropped))
|
slog.Debug("mujica: file drop zone dropped", "count", len(dropped))
|
||||||
take(dropped)
|
take(dropped)
|
||||||
}
|
}
|
||||||
over := zone.FileDragOver() && !opts.Disabled
|
over := zone.FileDragOver() && !disabled
|
||||||
bg, border := k.Surface, k.ControlBorder
|
bg, border := k.Surface, k.ControlBorder
|
||||||
if over {
|
if over {
|
||||||
bg, border = k.Selection, k.AccentText
|
bg, border = k.Selection, k.AccentText
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type formFieldState struct {
|
|||||||
pending bool
|
pending bool
|
||||||
hasAsync bool
|
hasAsync bool
|
||||||
check func() string
|
check func() string
|
||||||
|
refresh func()
|
||||||
startAsync func()
|
startAsync func()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +197,12 @@ func formStartSubmit(st *FormState) {
|
|||||||
|
|
||||||
// formSettle finishes a waiting submit once no check is pending; it reports success.
|
// formSettle finishes a waiting submit once no check is pending; it reports success.
|
||||||
func formSettle(c *ui.Context, st *FormState) bool {
|
func formSettle(c *ui.Context, st *FormState) bool {
|
||||||
|
// Current values only: later edits and cross-field rules count.
|
||||||
|
for _, name := range st.order {
|
||||||
|
f := st.fields[name]
|
||||||
|
f.refresh()
|
||||||
|
f.err = f.check()
|
||||||
|
}
|
||||||
first, pending := "", false
|
first, pending := "", false
|
||||||
for _, name := range st.order {
|
for _, name := range st.order {
|
||||||
if st.Error(name) != "" && first == "" {
|
if st.Error(name) != "" && first == "" {
|
||||||
@@ -244,11 +251,10 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T
|
|||||||
panic("mujica: FormItem " + name + " registered twice")
|
panic("mujica: FormItem " + name + " registered twice")
|
||||||
}
|
}
|
||||||
st.order = append(st.order, name)
|
st.order = append(st.order, name)
|
||||||
snap := fmt.Sprintf("%#v", *value)
|
|
||||||
fs := st.fields[name]
|
fs := st.fields[name]
|
||||||
if fs == nil {
|
if fs == nil {
|
||||||
st.seq++
|
st.seq++
|
||||||
fs = &formFieldState{snapshot: snap, version: st.seq}
|
fs = &formFieldState{snapshot: fmt.Sprintf("%#v", *value), version: st.seq}
|
||||||
st.fields[name] = fs
|
st.fields[name] = fs
|
||||||
}
|
}
|
||||||
fs.hasAsync = opts.Async != nil
|
fs.hasAsync = opts.Async != nil
|
||||||
@@ -268,12 +274,18 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T
|
|||||||
opts.Async(*value, fs.version)
|
opts.Async(*value, fs.version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if snap != fs.snapshot {
|
// refresh gives an edited value a new version; it runs after the control took its input.
|
||||||
|
fs.refresh = func() {
|
||||||
|
snap := fmt.Sprintf("%#v", *value)
|
||||||
|
if snap == fs.snapshot {
|
||||||
|
return
|
||||||
|
}
|
||||||
st.seq++
|
st.seq++
|
||||||
fs.snapshot, fs.version, fs.pending, fs.asyncErr = snap, st.seq, false, ""
|
fs.snapshot, fs.version, fs.pending, fs.asyncErr = snap, st.seq, false, ""
|
||||||
if fs.touched || st.tried {
|
if fs.touched || st.tried {
|
||||||
fs.err = fs.check()
|
fs.err = fs.check()
|
||||||
}
|
}
|
||||||
|
c.Invalidate()
|
||||||
}
|
}
|
||||||
initial := opts.Initial
|
initial := opts.Initial
|
||||||
st.resets = append(st.resets, func() { *value = initial })
|
st.resets = append(st.resets, func() { *value = initial })
|
||||||
@@ -286,6 +298,7 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T
|
|||||||
ctrl = control()
|
ctrl = control()
|
||||||
return ctrl
|
return ctrl
|
||||||
})
|
})
|
||||||
|
fs.refresh()
|
||||||
focused := ctrl.FocusWithin()
|
focused := ctrl.FocusWithin()
|
||||||
if fs.focused && !focused {
|
if fs.focused && !focused {
|
||||||
fs.touched = true
|
fs.touched = true
|
||||||
|
|||||||
@@ -262,3 +262,59 @@ func TestFormSnapshot(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Text typed in the frame the check passes gets its own check before submitting.
|
||||||
|
func TestFormInputAfterAsyncPassIsRechecked(t *testing.T) {
|
||||||
|
p := &formProbe{name: "Uika", tags: []string{"x"}, email: "good@x", useAsync: true}
|
||||||
|
tt := ui.NewTester(p.run, 600, 500)
|
||||||
|
_ = tt.Click("Email")
|
||||||
|
p.st.Submit()
|
||||||
|
tt.Frame()
|
||||||
|
if len(p.asyncCalls) != 1 {
|
||||||
|
t.Fatalf("calls %v", p.asyncCalls)
|
||||||
|
}
|
||||||
|
p.st.Resolve("email", p.asyncCalls[0], "")
|
||||||
|
tt.Type("bad")
|
||||||
|
if p.submits != 0 || len(p.asyncValues) != 2 || p.asyncValues[1] != "good@xbad" {
|
||||||
|
t.Fatalf("submits %d checked %q", p.submits, p.asyncValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cross-field rule sees the other field's latest value when the submit settles.
|
||||||
|
func TestFormCrossFieldRuleAtSettle(t *testing.T) {
|
||||||
|
var st FormState
|
||||||
|
user, pw, confirm := "uika", "a", "a"
|
||||||
|
var version uint64
|
||||||
|
submits := 0
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{})
|
||||||
|
res := Form(c, &st, FormOptions{}, func(f *FormScope) {
|
||||||
|
FormItem(f, "user", &user, FormItemOptions[string]{Label: "User", Async: func(_ string, v uint64) { version = v }}, func() *ui.Element {
|
||||||
|
return InputGroup(c, &user, InputGroupOptions{}).Input
|
||||||
|
})
|
||||||
|
FormItem(f, "pw", &pw, FormItemOptions[string]{Label: "Password"}, func() *ui.Element {
|
||||||
|
return InputGroup(c, &pw, InputGroupOptions{}).Input
|
||||||
|
})
|
||||||
|
FormItem(f, "confirm", &confirm, FormItemOptions[string]{Label: "Confirm", Validate: func(v string) string {
|
||||||
|
if v != pw {
|
||||||
|
return "Passwords differ."
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}}, func() *ui.Element {
|
||||||
|
return InputGroup(c, &confirm, InputGroupOptions{}).Input
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if res.Submitted() {
|
||||||
|
submits++
|
||||||
|
}
|
||||||
|
}, 600, 500)
|
||||||
|
_ = tt.Click("Submit")
|
||||||
|
pw = "b"
|
||||||
|
tt.Frame()
|
||||||
|
st.Resolve("user", version, "")
|
||||||
|
tt.Frame()
|
||||||
|
tt.Frame()
|
||||||
|
if submits != 0 || !tt.HasText("Passwords differ.") {
|
||||||
|
t.Fatalf("mismatched pair submitted: %d", submits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,13 +41,14 @@ type InputGroupParts struct {
|
|||||||
Input *ui.Element
|
Input *ui.Element
|
||||||
// Clicked is the index in Buttons of the button clicked, or -1.
|
// Clicked is the index in Buttons of the button clicked, or -1.
|
||||||
Clicked int
|
Clicked int
|
||||||
|
locked bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Changed reports whether the user edited the value this frame.
|
// Changed reports whether the user edited the value this frame.
|
||||||
func (p InputGroupParts) Changed() bool { return p.Input.Changed() }
|
func (p InputGroupParts) Changed() bool { return !p.locked && p.Input.Changed() }
|
||||||
|
|
||||||
// Submitted reports whether Enter was pressed in the input.
|
// Submitted reports whether Enter was pressed in the input.
|
||||||
func (p InputGroupParts) Submitted() bool { return p.Input.Submitted() }
|
func (p InputGroupParts) Submitted() bool { return !p.locked && p.Input.Submitted() }
|
||||||
|
|
||||||
// InputGroup joins an input, fixed text and buttons, each its own Tab stop, in one border.
|
// InputGroup joins an input, fixed text and buttons, each its own Tab stop, in one border.
|
||||||
func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroupParts {
|
func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroupParts {
|
||||||
@@ -91,7 +92,7 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup
|
|||||||
}
|
}
|
||||||
button := func(i int) {
|
button := func(i int) {
|
||||||
b := opts.Buttons[i]
|
b := opts.Buttons[i]
|
||||||
bb := ui.ButtonBase(c).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).MinWidth(d.ControlHeight()).
|
bb := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).MinWidth(d.ControlHeight()).
|
||||||
Disabled(b.Disabled).TextColor(k.AccentText).Label(b.Label)
|
Disabled(b.Disabled).TextColor(k.AccentText).Label(b.Label)
|
||||||
segment(bb)
|
segment(bb)
|
||||||
internal.Ring(bb, k.Focus, theme.ControlRadius)
|
internal.Ring(bb, k.Focus, theme.ControlRadius)
|
||||||
@@ -133,7 +134,8 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup
|
|||||||
// As wide as MyGo makes a text input.
|
// As wide as MyGo makes a text input.
|
||||||
parts.Input = ui.Text(c, *value).SingleLine().Selectable().Focusable().FocusRing(false).Grow(1).Width(200).MinWidth(0)
|
parts.Input = ui.Text(c, *value).SingleLine().Selectable().Focusable().FocusRing(false).Grow(1).Width(200).MinWidth(0)
|
||||||
} else {
|
} else {
|
||||||
parts.Input = ui.TextInputBase(c, value).FocusRing(false).Grow(1).MinWidth(0).Placeholder(opts.Placeholder)
|
parts.locked = box.IsDisabled()
|
||||||
|
parts.Input = inputGroupEditor(c, box, value, parts.locked).FocusRing(false).Grow(1).MinWidth(0).Placeholder(opts.Placeholder)
|
||||||
}
|
}
|
||||||
if opts.Label != "" {
|
if opts.Label != "" {
|
||||||
parts.Input.Label(opts.Label)
|
parts.Input.Label(opts.Label)
|
||||||
@@ -169,3 +171,15 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup
|
|||||||
})
|
})
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inputGroupEditor builds the input; a disabled one edits a copy, so queued input cannot reach value.
|
||||||
|
func inputGroupEditor(c *ui.Context, box *ui.Element, value *string, locked bool) *ui.Element {
|
||||||
|
if !locked {
|
||||||
|
return ui.TextInputBase(c, value)
|
||||||
|
}
|
||||||
|
shown := ui.Local(box, "locked", func() string { return *value })
|
||||||
|
*shown = *value
|
||||||
|
in := ui.TextInputBase(c, shown)
|
||||||
|
*shown = *value
|
||||||
|
return in
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/egoist/mygo/ui"
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
"mujicaui/icons"
|
"mujicaui/icons"
|
||||||
|
"mujicaui/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestInputGroupTypingAndButtons(t *testing.T) {
|
func TestInputGroupTypingAndButtons(t *testing.T) {
|
||||||
@@ -108,3 +109,35 @@ func TestInputGroupSnapshot(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Input queued while focused must not edit a group disabled since.
|
||||||
|
func TestInputGroupDisabledAfterFocus(t *testing.T) {
|
||||||
|
value, disabled, changes := "abc", false, 0
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{})
|
||||||
|
ui.Column(c).Disabled(disabled).Children(func() {
|
||||||
|
if InputGroup(c, &value, InputGroupOptions{Label: "Code"}).Changed() {
|
||||||
|
changes++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 400, 100)
|
||||||
|
_ = tt.Click("Code")
|
||||||
|
disabled = true
|
||||||
|
tt.Type("zz")
|
||||||
|
tt.Key(0, ui.KeyBackspace)
|
||||||
|
tt.Frame()
|
||||||
|
if value != "abc" || changes != 0 {
|
||||||
|
t.Fatalf("value %q changes %d", value, changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputGroupHitAreasCompact(t *testing.T) {
|
||||||
|
value := ""
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{Density: theme.Compact})
|
||||||
|
InputGroup(c, &value, InputGroupOptions{Label: "Query", Buttons: []InputGroupButton{{Label: "Clear", Icon: icons.Must("x"), IconOnly: true}}})
|
||||||
|
}, 400, 100)
|
||||||
|
if r, ok := tt.Find("Clear"); !ok || r.W < 28 || r.H < 28 {
|
||||||
|
t.Fatalf("button %v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,10 @@ func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts
|
|||||||
slog.Debug("mujica: pin input completed", "length", n)
|
slog.Debug("mujica: pin input completed", "length", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
disabled := e.IsDisabled()
|
||||||
|
if disabled {
|
||||||
|
st.queue, st.compose = st.queue[:0], ""
|
||||||
|
}
|
||||||
for _, ev := range st.queue {
|
for _, ev := range st.queue {
|
||||||
switch {
|
switch {
|
||||||
case ev.paste:
|
case ev.paste:
|
||||||
@@ -97,9 +101,7 @@ func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
st.queue = st.queue[:0]
|
st.queue = st.queue[:0]
|
||||||
if opts.Disabled {
|
if !disabled {
|
||||||
st.compose = ""
|
|
||||||
} else {
|
|
||||||
e.Focusable().FocusRing(false).HandleInput(func(ev ui.InputEvent) bool {
|
e.Focusable().FocusRing(false).HandleInput(func(ev ui.InputEvent) bool {
|
||||||
switch {
|
switch {
|
||||||
case ev.Kind == ui.InputText:
|
case ev.Kind == ui.InputText:
|
||||||
|
|||||||
@@ -102,3 +102,23 @@ func TestPinInputSnapshot(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPinInputDisabledAfterFocus(t *testing.T) {
|
||||||
|
p := &pinInputProbe{value: "12"}
|
||||||
|
opts := PinInputOptions{Label: "Code"}
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{})
|
||||||
|
r := PinInput(c, &p.value, opts)
|
||||||
|
if r.Changed() {
|
||||||
|
p.changes++
|
||||||
|
}
|
||||||
|
}, 480, 120)
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
opts.Disabled = true
|
||||||
|
tt.Type("3")
|
||||||
|
tt.Key(0, ui.KeyBackspace)
|
||||||
|
tt.Frame()
|
||||||
|
if p.value != "12" || p.changes != 0 {
|
||||||
|
t.Fatalf("value %q changes %d", p.value, p.changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,8 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts {
|
|||||||
}
|
}
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
size := Density(c).ControlHeight() * 0.6
|
size := Density(c).ControlHeight() * 0.6
|
||||||
|
// Each star sits centered in a hit area of at least the minimum size.
|
||||||
|
hit := max(size, theme.MinHitSize)
|
||||||
label := opts.Label
|
label := opts.Label
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = msg(c, "rating.label")
|
label = msg(c, "rating.label")
|
||||||
@@ -64,10 +66,10 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts {
|
|||||||
e := ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit() * 2).Shrink(0)
|
e := ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit() * 2).Shrink(0)
|
||||||
parts.Element = e
|
parts.Element = e
|
||||||
e.Label(label)
|
e.Label(label)
|
||||||
interactive := !opts.ReadOnly && !opts.Disabled
|
e.Disabled(opts.Disabled)
|
||||||
if opts.Disabled {
|
disabled := e.IsDisabled()
|
||||||
e.Disabled(true)
|
interactive := !opts.ReadOnly && !disabled
|
||||||
} else {
|
if !disabled {
|
||||||
e.Focusable()
|
e.Focusable()
|
||||||
internal.Ring(e, k.Focus, theme.ControlRadius)
|
internal.Ring(e, k.Focus, theme.ControlRadius)
|
||||||
}
|
}
|
||||||
@@ -85,10 +87,10 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
e.Children(func() {
|
e.Children(func() {
|
||||||
stars := ui.Box(c).Size(size*float32(n), size).Shrink(0)
|
stars := ui.Box(c).Size(hit*float32(n), hit).Shrink(0)
|
||||||
shown, fill := *value, k.AccentText
|
shown, fill := *value, k.AccentText
|
||||||
if interactive {
|
if interactive {
|
||||||
at := func(x float32) float64 { return math.Ceil(float64(x/size)/step-1e-9) * step }
|
at := func(x float32) float64 { return math.Ceil(float64(x/hit)/step-1e-9) * step }
|
||||||
if _, _, ok := stars.Dragged(); ok {
|
if _, _, ok := stars.Dragged(); ok {
|
||||||
x, _, _ := stars.PointerPosition()
|
x, _, _ := stars.PointerPosition()
|
||||||
set(at(x))
|
set(at(x))
|
||||||
@@ -99,7 +101,7 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts {
|
|||||||
}
|
}
|
||||||
stars.Draw(func(p *ui.Painter, r ui.Rect) {
|
stars.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
for i := range n {
|
for i := range n {
|
||||||
cell := ui.Rect{X: r.X + float32(i)*size, Y: r.Y, W: size, H: size}
|
cell := ui.Rect{X: r.X + float32(i)*hit + (hit-size)/2, Y: r.Y + (hit-size)/2, W: size, H: size}
|
||||||
path := ratingStar(cell)
|
path := ratingStar(cell)
|
||||||
p.StrokePath(path, 1.25, k.ControlBorder)
|
p.StrokePath(path, 1.25, k.ControlBorder)
|
||||||
part := float32(math.Max(0, math.Min(1, shown-float64(i))))
|
part := float32(math.Max(0, math.Min(1, shown-float64(i))))
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/egoist/mygo/ui"
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ratingTester(v *float64, opts RatingOptions, changes *int) *ui.Tester {
|
func ratingTester(v *float64, opts RatingOptions, changes *int) *ui.Tester {
|
||||||
@@ -48,7 +50,7 @@ func TestRatingKeyboardSteps(t *testing.T) {
|
|||||||
func TestRatingPointer(t *testing.T) {
|
func TestRatingPointer(t *testing.T) {
|
||||||
v, n := 0.0, 0
|
v, n := 0.0, 0
|
||||||
tt := ratingTester(&v, RatingOptions{Step: 0.5, Label: "Taste"}, &n)
|
tt := ratingTester(&v, RatingOptions{Step: 0.5, Label: "Taste"}, &n)
|
||||||
size := float32(36) * 0.6
|
size := theme.MinHitSize
|
||||||
// The left half of the third star.
|
// The left half of the third star.
|
||||||
tt.ClickAt(10+2*size+size*0.25, 10+size/2)
|
tt.ClickAt(10+2*size+size*0.25, 10+size/2)
|
||||||
if v != 2.5 || n != 1 {
|
if v != 2.5 || n != 1 {
|
||||||
@@ -100,3 +102,18 @@ func TestRatingSnapshot(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRatingHitAreaCompact(t *testing.T) {
|
||||||
|
v := 2.0
|
||||||
|
var r RatingParts
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{Density: theme.Compact})
|
||||||
|
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||||
|
r = Rating(c, &v, RatingOptions{})
|
||||||
|
})
|
||||||
|
}, 300, 100)
|
||||||
|
tt.Frame()
|
||||||
|
if b := r.Element.Bounds(); b.W < 5*28 || b.H < 28 {
|
||||||
|
t.Fatalf("stars %v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,9 +89,10 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
|
|||||||
parts.changed, st.err = true, ""
|
parts.changed, st.err = true, ""
|
||||||
slog.Debug("mujica: tags input removed", "count", len(*tags))
|
slog.Debug("mujica: tags input removed", "count", len(*tags))
|
||||||
}
|
}
|
||||||
|
disabled := opts.Disabled || outer.IsDisabled()
|
||||||
if st.backspace {
|
if st.backspace {
|
||||||
st.backspace = false
|
st.backspace = false
|
||||||
if len(*tags) > 0 && st.query == "" {
|
if !disabled && len(*tags) > 0 && st.query == "" {
|
||||||
remove(len(*tags) - 1)
|
remove(len(*tags) - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,14 +118,15 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
// The input keeps its identity whatever chips come before it.
|
// The input keeps its identity whatever chips come before it.
|
||||||
ui.Box(c).Key("input").Grow(1).MinWidth(d.Unit() * 24).Children(func() {
|
holder := ui.Box(c).Key("input").Grow(1).MinWidth(d.Unit() * 24)
|
||||||
in := ui.TextInputBase(c, &st.query).FocusRing(false).FillWidth().Placeholder(opts.Placeholder)
|
holder.Children(func() {
|
||||||
|
in := inputGroupEditor(c, holder, &st.query, disabled).FocusRing(false).FillWidth().Placeholder(opts.Placeholder)
|
||||||
parts.Input = in
|
parts.Input = in
|
||||||
if opts.Label != "" {
|
if opts.Label != "" {
|
||||||
in.Label(opts.Label)
|
in.Label(opts.Label)
|
||||||
}
|
}
|
||||||
in.HandleInput(func(ev ui.InputEvent) bool {
|
in.HandleInput(func(ev ui.InputEvent) bool {
|
||||||
if ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace && st.query == "" {
|
if !disabled && ev.Kind == ui.InputKeyDown && ev.Mods == 0 && ev.Key == ui.KeyBackspace && st.query == "" {
|
||||||
st.backspace = true
|
st.backspace = true
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -137,22 +139,8 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
in := parts.Input
|
in := parts.Input
|
||||||
if in.Changed() {
|
if !disabled {
|
||||||
st.err = ""
|
tagsInputCommit(st, in, seps, add)
|
||||||
}
|
|
||||||
if strings.ContainsAny(st.query, seps) {
|
|
||||||
pieces := strings.FieldsFunc(st.query+"\x00", func(r rune) bool { return strings.ContainsRune(seps, r) })
|
|
||||||
last := strings.TrimSuffix(pieces[len(pieces)-1], "\x00")
|
|
||||||
var kept []string
|
|
||||||
for _, p := range pieces[:len(pieces)-1] {
|
|
||||||
if !add(p) {
|
|
||||||
kept = append(kept, strings.TrimSpace(p))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
st.query = strings.Join(append(kept, last), string([]rune(seps)[0]))
|
|
||||||
}
|
|
||||||
if in.Submitted() && add(st.query) {
|
|
||||||
st.query = ""
|
|
||||||
}
|
}
|
||||||
if box.Clicked() {
|
if box.Clicked() {
|
||||||
in.Focus()
|
in.Focus()
|
||||||
@@ -174,3 +162,24 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
|
|||||||
})
|
})
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tagsInputCommit adds the text before each separator, and all of it on Enter.
|
||||||
|
func tagsInputCommit(st *tagsInputState, in *ui.Element, seps string, add func(string) bool) {
|
||||||
|
if in.Changed() {
|
||||||
|
st.err = ""
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(st.query, seps) {
|
||||||
|
pieces := strings.FieldsFunc(st.query+"\x00", func(r rune) bool { return strings.ContainsRune(seps, r) })
|
||||||
|
last := strings.TrimSuffix(pieces[len(pieces)-1], "\x00")
|
||||||
|
var kept []string
|
||||||
|
for _, p := range pieces[:len(pieces)-1] {
|
||||||
|
if !add(p) {
|
||||||
|
kept = append(kept, strings.TrimSpace(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.query = strings.Join(append(kept, last), string([]rune(seps)[0]))
|
||||||
|
}
|
||||||
|
if in.Submitted() && add(st.query) {
|
||||||
|
st.query = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,3 +124,23 @@ func TestTagsInputRemoveHitArea(t *testing.T) {
|
|||||||
t.Fatalf("remove button %v", r)
|
t.Fatalf("remove button %v", r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTagsInputDisabledAfterFocus(t *testing.T) {
|
||||||
|
tags, n := []string{"rose"}, 0
|
||||||
|
opts := TagsInputOptions{Label: "Tags"}
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{})
|
||||||
|
if TagsInput(c, &tags, opts).Changed() {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}, 520, 200)
|
||||||
|
_ = tt.Click("Tags")
|
||||||
|
opts.Disabled = true
|
||||||
|
tt.Key(0, ui.KeyBackspace)
|
||||||
|
tt.Type("ivy,")
|
||||||
|
tt.Key(0, ui.KeyEnter)
|
||||||
|
tt.Frame()
|
||||||
|
if !slices.Equal(tags, []string{"rose"}) || n != 0 {
|
||||||
|
t.Fatalf("tags %q changes %d", tags, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -151,11 +151,11 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite
|
|||||||
}
|
}
|
||||||
checked := transferCount(st, shown)
|
checked := transferCount(st, shown)
|
||||||
ui.Column(c).Width(240).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Children(func() {
|
ui.Column(c).Width(240).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Children(func() {
|
||||||
head := ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).
|
head := ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*3-(theme.MinHitSize-16)/2).
|
||||||
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border)
|
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border)
|
||||||
head.Children(func() {
|
head.Children(func() {
|
||||||
all := checked > 0 && checked == enabled
|
all := checked > 0 && checked == enabled
|
||||||
box := ui.CheckboxBase(c, &all).Label(fmt.Sprintf(msg(c, "transfer.all"), title)).Disabled(enabled == 0)
|
box := ui.CheckboxBase(c, &all).Size(theme.MinHitSize, theme.MinHitSize).Justify(ui.Center).Label(fmt.Sprintf(msg(c, "transfer.all"), title)).Disabled(enabled == 0)
|
||||||
transferMark(c, box, all, checked > 0 && !all)
|
transferMark(c, box, all, checked > 0 && !all)
|
||||||
box.Description(transferPartial(c, checked, enabled))
|
box.Description(transferPartial(c, checked, enabled))
|
||||||
if box.Changed() {
|
if box.Changed() {
|
||||||
@@ -184,7 +184,7 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite
|
|||||||
for _, it := range shown {
|
for _, it := range shown {
|
||||||
on := st.checked[it.ID]
|
on := st.checked[it.ID]
|
||||||
ui.Box(c).Key(it.ID).Children(func() {
|
ui.Box(c).Key(it.ID).Children(func() {
|
||||||
cb := ui.CheckboxBase(c, &on).Gap(d.Unit()*2).MinHeight(d.ControlHeight()-d.Unit()).Padding(0, d.Unit()*2).
|
cb := ui.CheckboxBase(c, &on).Gap(d.Unit()*2).MinHeight(max(d.ControlHeight()-d.Unit(), theme.MinHitSize)).Padding(0, d.Unit()*2).
|
||||||
Radius(theme.ControlRadius).FillWidth().Disabled(it.Disabled)
|
Radius(theme.ControlRadius).FillWidth().Disabled(it.Disabled)
|
||||||
if cb.Changed() {
|
if cb.Changed() {
|
||||||
st.checked[it.ID] = on
|
st.checked[it.ID] = on
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/egoist/mygo/ui"
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
var transferCast = []TransferItem{
|
var transferCast = []TransferItem{
|
||||||
@@ -116,3 +118,14 @@ func TestTransferPartialDescription(t *testing.T) {
|
|||||||
t.Fatalf("descriptions %q", got)
|
t.Fatalf("descriptions %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTransferSelectAllHitAreaCompact(t *testing.T) {
|
||||||
|
target := []string{}
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{Density: theme.Compact})
|
||||||
|
Transfer(c, &target, transferCast, TransferOptions{})
|
||||||
|
}, 640, 420)
|
||||||
|
if r, ok := tt.Find("Select all in Available"); !ok || r.W < 28 || r.H < 28 {
|
||||||
|
t.Fatalf("select all %v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||