Audit keyboard, focus, veils, motion and clocks across all 500
A cross-cutting pass, each fix with a test that failed first. Veils: MyGo still hands a key to a base that turned off this frame, so every element key now goes through pixel.Shortcut, which an archtest enforces. Input handlers record and the build applies them only while enabled; the editor turns away a veil-frame letter or copy; value bases get a copy and keep only enabled turns. Popups give a held focus back to their opener when the veil lifts. Motion: S5 is enforced at the clock. Every Tick, Since, Step, Toward and Shot runs at 4-15 fps; blinks keep 1 Hz on 4 fps steps, fast animations step at 15, and drawn time is quantized to frames. Clocks stepping back: Since holds at zero without overflow, and k.Elapsed moves a stored start to now, so nothing goes negative, runs backward or indexes out of range. Also: logs count typed text instead of holding it, gallery names use glyphs the font has, removable rows are keyed, components take their own Options (archtest), and sweeps over all 508 demos check veiled keys, clicks, wheels, the clock stepping back and names.
This commit is contained in:
198 files changed
+2708
-422
No files matched your search
@@ -3,6 +3,7 @@ package input
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
@@ -28,10 +29,12 @@ func Autocomplete(c *ui.Context, text string, value *string, suggestions []strin
|
||||
ed, e := field(c, value, pixel.EditorOptions{Label: text, Placeholder: o.Placeholder, Disabled: o.Disabled}, o.Width, nil, nil, nil)
|
||||
res := AutocompleteResult{Element: e, Changed: ed.Changed, Submitted: ed.Submitted}
|
||||
best := completion(suggestions, *value)
|
||||
tab, enter := best != "" && e.Shortcut(0, ui.KeyTab), best != "" && e.Shortcut(0, ui.KeyEnter)
|
||||
// Keys reach the wrapper; the editor's state gates them.
|
||||
on := best != "" && !ed.Input.IsDisabled()
|
||||
tab, enter := on && pixel.Shortcut(e, 0, ui.KeyTab), on && pixel.Shortcut(e, 0, ui.KeyEnter)
|
||||
if tab || enter {
|
||||
*value, res.Changed, res.Accepted = best, true, true
|
||||
slog.Debug("8bit: autocomplete accepted", "label", text, "value", best)
|
||||
slog.Debug("8bit: autocomplete accepted", "label", text, "runes", utf8.RuneCountInString(best))
|
||||
return res
|
||||
}
|
||||
if best != "" {
|
||||
|
||||
+3
-1
@@ -21,6 +21,8 @@ type ButtonOptions struct {
|
||||
|
||||
type ButtonResult struct {
|
||||
Element *ui.Element
|
||||
// Base is the focusable inside Element.
|
||||
Base *ui.Element
|
||||
Clicked bool
|
||||
}
|
||||
|
||||
@@ -68,7 +70,7 @@ func Button(c *ui.Context, text string, o ButtonOptions) ButtonResult {
|
||||
}
|
||||
g.Finish(st)
|
||||
})
|
||||
res := ButtonResult{Element: shell, Clicked: base.Clicked()}
|
||||
res := ButtonResult{Element: shell, Base: base, Clicked: base.Clicked()}
|
||||
if res.Clicked {
|
||||
slog.Debug("8bit: button", "label", name)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ func CheatCode(c *ui.Context, text string, o CheatCodeOptions) CheatCodeResult {
|
||||
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled)
|
||||
})
|
||||
s := ui.Local(base, cheatKey{}, func() cheat { return cheat{} })
|
||||
// The handler runs before this frame knows it is off.
|
||||
if base.IsDisabled() {
|
||||
*s = cheat{}
|
||||
}
|
||||
// Keys come in order here, however fast they are typed.
|
||||
base.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if ev.Kind != ui.InputKeyDown || ev.Mods != 0 {
|
||||
|
||||
@@ -51,7 +51,7 @@ func CheckboxGroup(c *ui.Context, text string, items []string, checked []bool, o
|
||||
}
|
||||
box(k.Canvas(p, r), text, mark, st)
|
||||
})
|
||||
if base.Changed() {
|
||||
if base.Changed() && !base.IsDisabled() {
|
||||
for i := range checked {
|
||||
checked[i] = all
|
||||
}
|
||||
|
||||
+10
-4
@@ -3,6 +3,7 @@ package input
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
@@ -53,14 +54,16 @@ func Combobox(c *ui.Context, text string, value *string, options []string, o Com
|
||||
if ed.Changed {
|
||||
st.open, st.typed, st.lit = true, true, 0
|
||||
}
|
||||
if e.Shortcut(0, ui.KeyDown) {
|
||||
// Keys reach the wrapper; the editor's state gates them.
|
||||
on := !ed.Input.IsDisabled()
|
||||
if on && pixel.Shortcut(e, 0, ui.KeyDown) {
|
||||
if st.open {
|
||||
st.lit++
|
||||
} else {
|
||||
st.open, st.typed, st.lit = true, false, 0
|
||||
}
|
||||
}
|
||||
if st.open && e.Shortcut(0, ui.KeyUp) {
|
||||
if st.open && on && pixel.Shortcut(e, 0, ui.KeyUp) {
|
||||
st.lit--
|
||||
}
|
||||
shown := options
|
||||
@@ -70,9 +73,12 @@ func Combobox(c *ui.Context, text string, value *string, options []string, o Com
|
||||
st.lit = max(0, min(st.lit, len(shown)-1))
|
||||
// The rows shown follow the lit option.
|
||||
st.top = max(0, min(st.top, st.lit, len(shown)-comboRows), st.lit-comboRows+1)
|
||||
if st.open && len(shown) > 0 && e.Shortcut(0, ui.KeyEnter) {
|
||||
if st.open && on && len(shown) > 0 && pixel.Shortcut(e, 0, ui.KeyEnter) {
|
||||
take(shown[st.lit])
|
||||
}
|
||||
if !on {
|
||||
st.open = false
|
||||
}
|
||||
show, held := st.open && len(shown) > 0, false
|
||||
panel := ui.PopoverBase(c, e, &show, func(panel *ui.Element) {
|
||||
panel.MinWidth(e.Bounds().W)
|
||||
@@ -132,7 +138,7 @@ func Combobox(c *ui.Context, text string, value *string, options []string, o Com
|
||||
c.Invalidate()
|
||||
}
|
||||
if res.Chosen {
|
||||
slog.Debug("8bit: combobox chosen", "label", text, "value", *value)
|
||||
slog.Debug("8bit: combobox chosen", "label", text, "runes", utf8.RuneCountInString(*value))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
+4
-4
@@ -52,13 +52,13 @@ func DPad(c *ui.Context, text string, o DPadOptions) DPadResult {
|
||||
key ui.Key
|
||||
arm Direction
|
||||
}{{ui.KeyUp, Up}, {ui.KeyDown, Down}, {ui.KeyLeft, Left}, {ui.KeyRight, Right}} {
|
||||
if base.Shortcut(0, m.key) {
|
||||
if pixel.Shortcut(base, 0, m.key) {
|
||||
res.Pressed = m.arm
|
||||
}
|
||||
}
|
||||
// Taking Enter and Space keeps keys from clicking.
|
||||
base.Shortcut(0, ui.KeyEnter)
|
||||
base.Shortcut(0, ui.KeySpace)
|
||||
pixel.Shortcut(base, 0, ui.KeyEnter)
|
||||
pixel.Shortcut(base, 0, ui.KeySpace)
|
||||
at := func() Direction {
|
||||
x, y, _ := base.PointerPosition()
|
||||
return armAt(int(x/k.Dot()), int(y/k.Dot()))
|
||||
@@ -71,7 +71,7 @@ func DPad(c *ui.Context, text string, o DPadOptions) DPadResult {
|
||||
slog.Debug("8bit: dpad", "label", text, "arm", res.Pressed)
|
||||
}
|
||||
down := None
|
||||
if k.Now().Sub(lit.at) < 150*time.Millisecond {
|
||||
if k.Elapsed(&lit.at) < 150*time.Millisecond {
|
||||
down = lit.arm
|
||||
k.Tick(c, 10)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package input
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
@@ -41,7 +42,9 @@ func EditableText(c *ui.Context, text string, value *string, o EditableTextOptio
|
||||
if s.on {
|
||||
ed, e := field(c, &s.draft, pixel.EditorOptions{Label: text, Placeholder: o.Placeholder}, w, nil, nil, nil)
|
||||
ed.Input.AutoFocus()
|
||||
enter, escape := e.Shortcut(0, ui.KeyEnter), e.Shortcut(0, ui.KeyEscape)
|
||||
// Keys reach the wrapper; the editor's state gates them.
|
||||
on := !ed.Input.IsDisabled()
|
||||
enter, escape := on && pixel.Shortcut(e, 0, ui.KeyEnter), on && pixel.Shortcut(e, 0, ui.KeyEscape)
|
||||
if enter {
|
||||
res.Changed = *value != s.draft
|
||||
*value = s.draft
|
||||
@@ -58,7 +61,7 @@ func EditableText(c *ui.Context, text string, value *string, o EditableTextOptio
|
||||
base.Focus()
|
||||
s.refocus = false
|
||||
}
|
||||
enter, double := base.Shortcut(0, ui.KeyEnter), base.DoubleClicked()
|
||||
enter, double := pixel.Shortcut(base, 0, ui.KeyEnter), base.DoubleClicked()
|
||||
if enter || double {
|
||||
s.on, s.draft = true, *value
|
||||
}
|
||||
@@ -77,7 +80,7 @@ func EditableText(c *ui.Context, text string, value *string, o EditableTextOptio
|
||||
})
|
||||
})
|
||||
if res.Changed {
|
||||
slog.Debug("8bit: text edited", "label", text, "value", *value)
|
||||
slog.Debug("8bit: text edited", "label", text, "runes", utf8.RuneCountInString(*value))
|
||||
}
|
||||
return res
|
||||
}
|
||||
+7
-7
@@ -48,28 +48,28 @@ func HoldButton(c *ui.Context, text string, o HoldButtonOptions) HoldButtonResul
|
||||
return ui.ButtonBase(c).Label(text).Description(k.Msg("hold")).Disabled(o.Disabled)
|
||||
})
|
||||
h := ui.Local(base, holdButtonKey{}, func() holding { return holding{} })
|
||||
if o.Disabled {
|
||||
if base.IsDisabled() {
|
||||
*h = holding{}
|
||||
}
|
||||
now := k.Now()
|
||||
// Held keys repeat; each repeat moves the hold on.
|
||||
enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace)
|
||||
enter, space := pixel.Shortcut(base, 0, ui.KeyEnter), pixel.Shortcut(base, 0, ui.KeySpace)
|
||||
if enter || space {
|
||||
if now.Sub(h.key) > repeatGap {
|
||||
if k.Elapsed(&h.key) > repeatGap {
|
||||
h.since = now
|
||||
}
|
||||
h.key = now
|
||||
}
|
||||
keyed := now.Sub(h.key) <= repeatGap
|
||||
keyed := k.Elapsed(&h.key) <= repeatGap
|
||||
var held time.Duration
|
||||
switch {
|
||||
case st.Press:
|
||||
if h.since.IsZero() {
|
||||
h.since = now
|
||||
}
|
||||
held = now.Sub(h.since)
|
||||
held = k.Elapsed(&h.since)
|
||||
case keyed:
|
||||
held = h.key.Sub(h.since)
|
||||
held = max(0, h.key.Sub(h.since))
|
||||
default:
|
||||
*h = holding{}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func HoldButton(c *ui.Context, text string, o HoldButtonOptions) HoldButtonResul
|
||||
slog.Debug("8bit: hold confirmed", "label", text)
|
||||
}
|
||||
if st.Press || keyed {
|
||||
k.Tick(c, 30)
|
||||
k.Tick(c, 15)
|
||||
}
|
||||
steps := min(8, int(held*8/d))
|
||||
fill := pixel.Accent
|
||||
|
||||
+4
-4
@@ -42,23 +42,23 @@ func Joystick(c *ui.Context, text string, o JoystickOptions) JoystickResult {
|
||||
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled)
|
||||
})
|
||||
s := ui.Local(base, stickKey{}, func() stick { return stick{} })
|
||||
ox, oy, now := s.x, s.y, k.Now()
|
||||
ox, oy, now, on := s.x, s.y, k.Now(), !base.IsDisabled()
|
||||
for _, m := range []struct {
|
||||
key ui.Key
|
||||
dx, dy float64
|
||||
}{{ui.KeyLeft, -0.25, 0}, {ui.KeyRight, 0.25, 0}, {ui.KeyUp, 0, -0.25}, {ui.KeyDown, 0, 0.25}} {
|
||||
if base.Shortcut(0, m.key) {
|
||||
if pixel.Shortcut(base, 0, m.key) {
|
||||
s.x, s.y, s.key = s.x+m.dx, s.y+m.dy, now
|
||||
}
|
||||
}
|
||||
_, _, held := base.Dragged()
|
||||
switch {
|
||||
case held && !o.Disabled:
|
||||
case held && on:
|
||||
px, py, _ := base.PointerPosition()
|
||||
s.x = (float64(px/k.Dot()) - stickSize/2) / stickReach
|
||||
s.y = (float64(py/k.Dot()) - stickSize/2) / stickReach
|
||||
s.key = time.Time{}
|
||||
case now.Sub(s.key) > repeatGap:
|
||||
case k.Elapsed(&s.key) > repeatGap:
|
||||
s.x, s.y = 0, 0
|
||||
default:
|
||||
k.Tick(c, 10)
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ func Knob(c *ui.Context, text string, value *float64, o KnobOptions) KnobResult
|
||||
old, set := *value, r.snap(value)
|
||||
// raw is the unrounded value a drag carries.
|
||||
raw := ui.Local(base, knobKey{}, func() float64 { return *value })
|
||||
if !o.Disabled {
|
||||
if !base.IsDisabled() {
|
||||
if _, dy, held := base.Dragged(); held {
|
||||
*raw = max(lo, min(hi, *raw-float64(dy/k.Dot())*(hi-lo)/64))
|
||||
set(*raw)
|
||||
|
||||
+13
-5
@@ -24,21 +24,26 @@ type MultiSelectResult struct {
|
||||
|
||||
type multiKey struct{}
|
||||
|
||||
// popup is open, and held the focus last frame.
|
||||
type popup struct{ open, held bool }
|
||||
|
||||
// MultiSelect checks many options in a popup; tags show them.
|
||||
func MultiSelect(c *ui.Context, text string, chosen *[]string, options []string, o MultiSelectOptions) MultiSelectResult {
|
||||
k := pixel.K(c)
|
||||
shell, base, st := pixel.Shell(c, func() *ui.Element {
|
||||
return ui.ButtonBase(c).Role(ui.RolePopUpButton).Label(text).Description(strings.Join(*chosen, ", ")).Disabled(o.Disabled)
|
||||
})
|
||||
open := ui.Local(base, multiKey{}, func() bool { return false })
|
||||
pop := ui.Local(base, multiKey{}, func() popup { return popup{} })
|
||||
open := &pop.open
|
||||
if o.Disabled || pixel.Current(c).Inert {
|
||||
// Closing on disable must not return focus to it.
|
||||
if *open {
|
||||
c.Root().Focus()
|
||||
// A focus in the closing popup parks on the field.
|
||||
if *open && pop.held {
|
||||
base.Focus()
|
||||
pixel.Park(c, base, true)
|
||||
}
|
||||
*open = false
|
||||
}
|
||||
if base.Clicked() || !*open && base.Shortcut(0, ui.KeyDown) {
|
||||
if base.Clicked() || !*open && pixel.Shortcut(base, 0, ui.KeyDown) {
|
||||
*open = !*open
|
||||
}
|
||||
shell.Size(k.D(pixel.Or("MultiSelect Width", o.Width, 160)), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
@@ -52,6 +57,7 @@ func MultiSelect(c *ui.Context, text string, chosen *[]string, options []string,
|
||||
g.Finish(st)
|
||||
})
|
||||
res := MultiSelectResult{Element: shell}
|
||||
pop.held = false
|
||||
ui.PopoverBase(c, base, open, func(panel *ui.Element) {
|
||||
menu(c, panel, func() {
|
||||
col := ui.Column(c).Gap(k.D(1)).FocusGroup(ui.Vertical)
|
||||
@@ -62,6 +68,8 @@ func MultiSelect(c *ui.Context, text string, chosen *[]string, options []string,
|
||||
if i == 0 {
|
||||
cb.AutoFocus()
|
||||
}
|
||||
// Focused, not FocusWithin: MyGo links parents after frames.
|
||||
pop.held = pop.held || cb.Focused()
|
||||
if cb.Changed() {
|
||||
res.Changed = true
|
||||
if on {
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ package input
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
@@ -82,7 +83,7 @@ func NameEntry(c *ui.Context, text string, name *string, o NameEntryOptions) Nam
|
||||
})
|
||||
})
|
||||
if res.Confirmed {
|
||||
slog.Debug("8bit: name entered", "label", text, "name", *name)
|
||||
slog.Debug("8bit: name entered", "label", text, "runes", utf8.RuneCountInString(*name))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -69,10 +69,10 @@ func NumberInput(c *ui.Context, label string, value *float64, o NumberInputOptio
|
||||
*value, st.seen, res.Changed = v, v, true
|
||||
}
|
||||
st.focused = ed.Focused
|
||||
if ed.Input.Shortcut(0, ui.KeyUp) {
|
||||
if pixel.Shortcut(ed.Input, 0, ui.KeyUp) {
|
||||
set(*value + step)
|
||||
}
|
||||
if ed.Input.Shortcut(0, ui.KeyDown) {
|
||||
if pixel.Shortcut(ed.Input, 0, ui.KeyDown) {
|
||||
set(*value - step)
|
||||
}
|
||||
res.Input, res.Submitted, res.Focused = ed.Input, ed.Submitted, ed.Focused
|
||||
|
||||
+15
-1
@@ -22,6 +22,12 @@ type PinInputResult struct {
|
||||
|
||||
type pinKey struct{}
|
||||
|
||||
// pin: a change since last frame, and the prior value.
|
||||
type pin struct {
|
||||
changed bool
|
||||
kept string
|
||||
}
|
||||
|
||||
const pinBox, pinGap = 12, 4
|
||||
|
||||
// PinInput types digits into boxes; Backspace goes back, paste fills.
|
||||
@@ -31,7 +37,12 @@ func PinInput(c *ui.Context, text string, value *string, o PinInputOptions) PinI
|
||||
shell, base, st := pixel.Shell(c, func() *ui.Element {
|
||||
return ui.Box(c).Focusable().Role(ui.RoleTextField).Label(text).Description(*value).Disabled(o.Disabled)
|
||||
})
|
||||
changed := ui.Local(base, pinKey{}, func() bool { return false })
|
||||
p := ui.Local(base, pinKey{}, func() pin { return pin{} })
|
||||
changed := &p.changed
|
||||
// The handler runs before this frame knows it is off.
|
||||
if p.changed && base.IsDisabled() {
|
||||
*value, p.changed = p.kept, false
|
||||
}
|
||||
put := func(s string) {
|
||||
for _, r := range s {
|
||||
if r >= '0' && r <= '9' && len(*value) < n {
|
||||
@@ -41,6 +52,9 @@ func PinInput(c *ui.Context, text string, value *string, o PinInputOptions) PinI
|
||||
}
|
||||
}
|
||||
base.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if !p.changed {
|
||||
p.kept = *value
|
||||
}
|
||||
switch {
|
||||
case ev.Kind == ui.InputText:
|
||||
put(ev.Text)
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ func Radio[T comparable](c *ui.Context, text string, value T, selected *T, o Rad
|
||||
return ui.CheckboxBase(c, &on).Role(ui.RoleRadio).Label(text).Disabled(o.Disabled)
|
||||
})
|
||||
res := RadioResult{Element: shell}
|
||||
if base.Changed() && !was {
|
||||
if base.Changed() && !was && !base.IsDisabled() {
|
||||
*selected, res.Changed = value, true
|
||||
slog.Debug("8bit: radio", "label", text)
|
||||
}
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ func RadioGroup(c *ui.Context, labels []string, selected *int, o RadioGroupOptio
|
||||
}
|
||||
// Arrow moves show focus and choose; Tab entry does not.
|
||||
moved := base.FocusVisible() && *last >= 0 && *last != i
|
||||
if !was && (base.Changed() || moved) {
|
||||
if !was && (base.Changed() || moved) && !base.IsDisabled() {
|
||||
*selected, res.Changed = i, true
|
||||
}
|
||||
shell.Size(k.D(12+pixel.Measure(l)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
|
||||
@@ -49,17 +49,17 @@ func knob(c *ui.Context, k pixel.Kit, track *ui.Element, v *float64, r rail, lo,
|
||||
return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(r.label).Description(fmtValue(*v)).Disabled(r.disabled)
|
||||
})
|
||||
set := func(x float64) { *v = max(r.lo, min(r.hi, x)) }
|
||||
if !r.disabled {
|
||||
if !base.IsDisabled() {
|
||||
if _, _, held := base.Dragged(); held {
|
||||
px, _, _ := base.PointerPosition()
|
||||
t := track.Bounds()
|
||||
set(lo + float64((base.Bounds().X+px-t.X)/max(1, t.W))*(hi-lo))
|
||||
}
|
||||
step := (hi - lo) / 20
|
||||
if base.Shortcut(0, ui.KeyLeft) || base.Shortcut(0, ui.KeyDown) {
|
||||
if pixel.Shortcut(base, 0, ui.KeyLeft) || pixel.Shortcut(base, 0, ui.KeyDown) {
|
||||
set(*v - step)
|
||||
}
|
||||
if base.Shortcut(0, ui.KeyRight) || base.Shortcut(0, ui.KeyUp) {
|
||||
if pixel.Shortcut(base, 0, ui.KeyRight) || pixel.Shortcut(base, 0, ui.KeyUp) {
|
||||
set(*v + step)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ func Rating(c *ui.Context, text string, value *int, o RatingOptions) RatingResul
|
||||
}
|
||||
// Arrow moves show focus and choose; clicks toggle.
|
||||
switch {
|
||||
case base.IsDisabled():
|
||||
case base.Changed() && was:
|
||||
*value, res.Changed = 0, true
|
||||
case base.Changed(), base.FocusVisible() && *last >= 0 && *last != i && !was:
|
||||
|
||||
@@ -18,7 +18,7 @@ type SearchFieldResult = TextInputResult
|
||||
// SearchField has a magnifier; Escape clears, Enter submits.
|
||||
func SearchField(c *ui.Context, label string, value *string, o SearchFieldOptions) SearchFieldResult {
|
||||
ed, e := field(c, value, pixel.EditorOptions{Label: label, Placeholder: o.Placeholder, Disabled: o.Disabled}, o.Width, nil, icon.Search, nil)
|
||||
if ed.Input.Shortcut(0, ui.KeyEscape) && *value != "" {
|
||||
if pixel.Shortcut(ed.Input, 0, ui.KeyEscape) && *value != "" {
|
||||
*value, ed.Changed = "", true
|
||||
}
|
||||
return result(e, ed, label)
|
||||
|
||||
+5
-2
@@ -20,7 +20,9 @@ type SegmentedResult struct {
|
||||
// Segmented joins segments choosing one; arrows move.
|
||||
func Segmented(c *ui.Context, text string, labels []string, selected *int, o SegmentedOptions) SegmentedResult {
|
||||
k := pixel.K(c)
|
||||
p := ui.SegmentedBase(c, selected, len(labels))
|
||||
// The base turns before it learns it is off.
|
||||
v := *selected
|
||||
p := ui.SegmentedBase(c, &v, len(labels))
|
||||
w := 0
|
||||
for _, l := range labels {
|
||||
w += pixel.Measure(l) + 12
|
||||
@@ -49,8 +51,9 @@ func Segmented(c *ui.Context, text string, labels []string, selected *int, o Seg
|
||||
})
|
||||
}
|
||||
})
|
||||
res := SegmentedResult{Element: p.Track, Changed: p.Track.Changed()}
|
||||
res := SegmentedResult{Element: p.Track, Changed: v != *selected && !o.Disabled && !pixel.Current(c).Inert}
|
||||
if res.Changed {
|
||||
*selected = v
|
||||
slog.Debug("8bit: segmented", "label", text, "selected", *selected)
|
||||
}
|
||||
return res
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ func (r rail) keys(base *ui.Element, v *float64, set func(float64)) {
|
||||
{ui.KeyRight, func() float64 { return *v + step }}, {ui.KeyUp, func() float64 { return *v + step }},
|
||||
{ui.KeyHome, func() float64 { return r.lo }}, {ui.KeyEnd, func() float64 { return r.hi }},
|
||||
} {
|
||||
if base.Shortcut(0, kd.key) {
|
||||
if pixel.Shortcut(base, 0, kd.key) {
|
||||
set(kd.to())
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -42,21 +42,26 @@ func SlotPicker(c *ui.Context, text string, value *int, options []string, o Slot
|
||||
s.changed = true
|
||||
}
|
||||
row := k.D(pixel.LineHeight)
|
||||
// The handler runs before this frame knows it is off.
|
||||
base.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if ev.Kind != ui.InputScroll || o.Disabled {
|
||||
if ev.Kind != ui.InputScroll {
|
||||
return false
|
||||
}
|
||||
s.wheel += ev.DY
|
||||
turn(&s.wheel, row, spin)
|
||||
return true
|
||||
})
|
||||
if base.Shortcut(0, ui.KeyDown) {
|
||||
if base.IsDisabled() {
|
||||
s.wheel = 0
|
||||
}
|
||||
turn(&s.wheel, row, spin)
|
||||
on := !base.IsDisabled()
|
||||
if pixel.Shortcut(base, 0, ui.KeyDown) {
|
||||
spin(1)
|
||||
}
|
||||
if base.Shortcut(0, ui.KeyUp) {
|
||||
if pixel.Shortcut(base, 0, ui.KeyUp) {
|
||||
spin(-1)
|
||||
}
|
||||
if _, dy, held := base.Dragged(); held && !o.Disabled {
|
||||
if _, dy, held := base.Dragged(); held && on {
|
||||
s.drag += dy
|
||||
turn(&s.drag, row, func(d int) { spin(-d) })
|
||||
} else {
|
||||
|
||||
+6
-5
@@ -39,10 +39,10 @@ func Stepper(c *ui.Context, text string, value *int, o StepperOptions) StepperRe
|
||||
return ui.Box(c).Focusable().Role(ui.RoleStepper).Label(text).Description(strconv.Itoa(*value)).Disabled(o.Disabled)
|
||||
})
|
||||
if !o.Disabled {
|
||||
if base.Shortcut(0, ui.KeyLeft) || base.Shortcut(0, ui.KeyDown) {
|
||||
if pixel.Shortcut(base, 0, ui.KeyLeft) || pixel.Shortcut(base, 0, ui.KeyDown) {
|
||||
set(*value - step)
|
||||
}
|
||||
if base.Shortcut(0, ui.KeyRight) || base.Shortcut(0, ui.KeyUp) {
|
||||
if pixel.Shortcut(base, 0, ui.KeyRight) || pixel.Shortcut(base, 0, ui.KeyUp) {
|
||||
set(*value + step)
|
||||
}
|
||||
}
|
||||
@@ -96,8 +96,9 @@ func held(c *ui.Context, k pixel.Kit, sp *pixel.Sprite, disabled bool) int {
|
||||
*h = hold{since: k.Now(), n: 1}
|
||||
return 1
|
||||
}
|
||||
want := 1 + max(0, int((k.Now().Sub(h.since)-400*time.Millisecond)/(100*time.Millisecond)))
|
||||
n := want - h.n
|
||||
h.n = want
|
||||
want := 1 + max(0, int((k.Elapsed(&h.since)-400*time.Millisecond)/(100*time.Millisecond)))
|
||||
// A clock stepping back restarts the count, never reverses it.
|
||||
n := max(0, want-h.n)
|
||||
h.n = max(h.n, want)
|
||||
return n
|
||||
}
|
||||
+8
-2
@@ -22,7 +22,13 @@ type SwitchResult struct {
|
||||
// Switch is a lever on an OFF/ON plate.
|
||||
func Switch(c *ui.Context, text string, on *bool, o SwitchOptions) SwitchResult {
|
||||
k := pixel.K(c)
|
||||
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.SwitchBase(c, on).Label(label(o.Label, text)).Disabled(o.Disabled) })
|
||||
// The base turns before it learns it is off.
|
||||
v := *on
|
||||
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.SwitchBase(c, &v).Label(label(o.Label, text)).Disabled(o.Disabled) })
|
||||
changed := v != *on && !base.IsDisabled()
|
||||
if changed {
|
||||
*on = v
|
||||
}
|
||||
shell.Size(k.D(32+pixel.Measure(text)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Frame(0, 1, 27, 14, pixel.Raised, pixel.Shade)
|
||||
@@ -43,7 +49,7 @@ func Switch(c *ui.Context, text string, on *bool, o SwitchOptions) SwitchResult
|
||||
g.Text(32, 0, text, ink, 1)
|
||||
g.Finish(st)
|
||||
})
|
||||
res := SwitchResult{Element: shell, Changed: base.Changed()}
|
||||
res := SwitchResult{Element: shell, Changed: changed}
|
||||
if res.Changed {
|
||||
slog.Debug("8bit: switch", "label", text, "on", *on)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package input_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
)
|
||||
|
||||
func TestDPadKeysDoNotClickWhereThePointerRests(t *testing.T) {
|
||||
var pressed []input.Direction
|
||||
tt := view(300, 200, nil, func(c *ui.Context) {
|
||||
if r := input.DPad(c, "Pad", input.DPadOptions{}); r.Pressed != input.None {
|
||||
pressed = append(pressed, r.Pressed)
|
||||
}
|
||||
})
|
||||
r := find(t, tt, "Pad")
|
||||
// The pointer rests on the up arm.
|
||||
tt.Move(r.X+r.W/2, r.Y+r.H/6)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(pressed) != 0 {
|
||||
t.Fatalf("Space and Enter pressed %v", pressed)
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -25,9 +25,15 @@ type ToggleResult struct {
|
||||
func Toggle(c *ui.Context, text string, on *bool, o ToggleOptions) ToggleResult {
|
||||
k := pixel.K(c)
|
||||
h, pad, top, sc := o.Size.metrics()
|
||||
// The base turns before it learns it is off.
|
||||
v := *on
|
||||
shell, base, st := pixel.Shell(c, func() *ui.Element {
|
||||
return ui.ToggleBase(c, on).Label(label(o.Label, text)).Disabled(o.Disabled)
|
||||
return ui.ToggleBase(c, &v).Label(label(o.Label, text)).Disabled(o.Disabled)
|
||||
})
|
||||
changed := v != *on && !base.IsDisabled()
|
||||
if changed {
|
||||
*on = v
|
||||
}
|
||||
lead := 0
|
||||
if o.Icon != nil {
|
||||
lead = o.Icon.W
|
||||
@@ -44,7 +50,7 @@ func Toggle(c *ui.Context, text string, on *bool, o ToggleOptions) ToggleResult
|
||||
g.Text(pad+lead, top+dy, text, ink, sc)
|
||||
g.Finish(st)
|
||||
})
|
||||
res := ToggleResult{Element: shell, Changed: base.Changed()}
|
||||
res := ToggleResult{Element: shell, Changed: changed}
|
||||
if res.Changed {
|
||||
slog.Debug("8bit: toggle", "label", label(o.Label, text), "on", *on)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ func ToggleGroup(c *ui.Context, labels []string, on []bool, o ToggleGroupOptions
|
||||
res := ToggleGroupResult{}
|
||||
res.Element = ui.Row(c).Role(ui.RoleToolbar).Label(o.Label).FocusGroup(ui.Horizontal).Children(func() {
|
||||
for i, l := range labels {
|
||||
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ToggleBase(c, &on[i]).Label(l).Disabled(o.Disabled) })
|
||||
// The base turns before it learns it is off.
|
||||
v := on[i]
|
||||
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ToggleBase(c, &v).Label(l).Disabled(o.Disabled) })
|
||||
if i > 0 {
|
||||
shell.Margin(0, 0, 0, k.D(-1))
|
||||
}
|
||||
@@ -42,8 +44,8 @@ func ToggleGroup(c *ui.Context, labels []string, on []bool, o ToggleGroupOptions
|
||||
g.Text(pad, top+dy, l, ink, sc)
|
||||
g.Finish(st)
|
||||
})
|
||||
if base.Changed() {
|
||||
res.Changed = true
|
||||
if v != on[i] && !base.IsDisabled() {
|
||||
on[i], res.Changed = v, true
|
||||
if o.Single && on[i] {
|
||||
for j := range on {
|
||||
on[j] = j == i
|
||||
|
||||
+6
-4
@@ -34,6 +34,8 @@ func TokenField(c *ui.Context, text string, tokens *[]string, o TokenFieldOption
|
||||
e := ui.Row(c).Wrap().Width(k.D(pixel.Or("TokenField Width", o.Width, 200))).Padding(k.D(3), k.D(4)).Gap(k.D(3)).AlignItems(ui.Center)
|
||||
e.Role(ui.RoleGroup).Label(text).Description(strings.Join(*tokens, ", "))
|
||||
draft := ui.Local(e, tokenKey{}, func() string { return "" })
|
||||
// The row stays enabled, so off is checked here.
|
||||
off := o.Disabled || pixel.Current(c).Inert
|
||||
res := TokenFieldResult{Element: e}
|
||||
add := func(s string) {
|
||||
if s = strings.TrimSpace(s); s != "" && !slices.Contains(*tokens, s) {
|
||||
@@ -41,17 +43,17 @@ func TokenField(c *ui.Context, text string, tokens *[]string, o TokenFieldOption
|
||||
}
|
||||
}
|
||||
// A comma typed since the last frame cuts the draft.
|
||||
if parts := strings.Split(*draft, ","); len(parts) > 1 {
|
||||
if parts := strings.Split(*draft, ","); len(parts) > 1 && !off {
|
||||
for _, p := range parts[:len(parts)-1] {
|
||||
add(p)
|
||||
}
|
||||
*draft = parts[len(parts)-1]
|
||||
}
|
||||
if e.Shortcut(0, ui.KeyEnter) {
|
||||
if !off && pixel.Shortcut(e, 0, ui.KeyEnter) {
|
||||
add(*draft)
|
||||
*draft = ""
|
||||
}
|
||||
if *draft == "" && len(*tokens) > 0 && e.Shortcut(0, ui.KeyBackspace) {
|
||||
if !off && *draft == "" && len(*tokens) > 0 && pixel.Shortcut(e, 0, ui.KeyBackspace) {
|
||||
*tokens, res.Changed = (*tokens)[:len(*tokens)-1], true
|
||||
}
|
||||
var ed pixel.Edit
|
||||
@@ -73,7 +75,7 @@ func TokenField(c *ui.Context, text string, tokens *[]string, o TokenFieldOption
|
||||
focus := ed.Focused
|
||||
e.Draw(func(p *ui.Painter, r ui.Rect) { well(k.Canvas(p, r), pixel.Edge, focus) })
|
||||
if res.Changed {
|
||||
slog.Debug("8bit: tokens", "label", text, "tokens", *tokens)
|
||||
slog.Debug("8bit: tokens", "label", text, "tokens", len(*tokens))
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package input_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
)
|
||||
|
||||
// turnCase builds a control on fresh state, and reads it.
|
||||
type turnCase func() (build func(c *ui.Context), value func() string)
|
||||
|
||||
func TestKeysInAVeilsFirstFrameTurnNothing(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
mk turnCase
|
||||
tabs int
|
||||
key ui.Key
|
||||
}{
|
||||
"Toggle": {func() (func(c *ui.Context), func() string) {
|
||||
on := false
|
||||
return func(c *ui.Context) { input.Toggle(c, "Mute", &on, input.ToggleOptions{}) }, func() string { return fmt.Sprint(on) }
|
||||
}, 1, ui.KeySpace},
|
||||
"Switch": {func() (func(c *ui.Context), func() string) {
|
||||
on := false
|
||||
return func(c *ui.Context) { input.Switch(c, "Sound", &on, input.SwitchOptions{}) }, func() string { return fmt.Sprint(on) }
|
||||
}, 1, ui.KeySpace},
|
||||
"Checkbox": {func() (func(c *ui.Context), func() string) {
|
||||
on := false
|
||||
return func(c *ui.Context) { input.Checkbox(c, "Hard", &on, input.CheckboxOptions{}) }, func() string { return fmt.Sprint(on) }
|
||||
}, 1, ui.KeySpace},
|
||||
"Radio": {func() (func(c *ui.Context), func() string) {
|
||||
v := "A"
|
||||
return func(c *ui.Context) {
|
||||
input.Radio(c, "A", "A", &v, input.RadioOptions{})
|
||||
input.Radio(c, "B", "B", &v, input.RadioOptions{})
|
||||
}, func() string { return v }
|
||||
}, 2, ui.KeySpace},
|
||||
"RadioGroup": {func() (func(c *ui.Context), func() string) {
|
||||
v := 0
|
||||
return func(c *ui.Context) { input.RadioGroup(c, []string{"A", "B", "C"}, &v, input.RadioGroupOptions{}) }, func() string { return fmt.Sprint(v) }
|
||||
}, 1, ui.KeyDown},
|
||||
"Rating": {func() (func(c *ui.Context), func() string) {
|
||||
v := 2
|
||||
return func(c *ui.Context) { input.Rating(c, "Stars", &v, input.RatingOptions{}) }, func() string { return fmt.Sprint(v) }
|
||||
}, 1, ui.KeyRight},
|
||||
"CheckboxGroup": {func() (func(c *ui.Context), func() string) {
|
||||
on := []bool{false, false}
|
||||
return func(c *ui.Context) {
|
||||
input.CheckboxGroup(c, "All", []string{"A", "B"}, on, input.CheckboxGroupOptions{})
|
||||
}, func() string { return fmt.Sprint(on) }
|
||||
}, 1, ui.KeySpace},
|
||||
"ToggleGroup": {func() (func(c *ui.Context), func() string) {
|
||||
on := []bool{false, false}
|
||||
return func(c *ui.Context) { input.ToggleGroup(c, []string{"B", "I"}, on, input.ToggleGroupOptions{}) }, func() string { return fmt.Sprint(on) }
|
||||
}, 1, ui.KeySpace},
|
||||
"Segmented": {func() (func(c *ui.Context), func() string) {
|
||||
v := 0
|
||||
return func(c *ui.Context) { input.Segmented(c, "View", []string{"A", "B"}, &v, input.SegmentedOptions{}) }, func() string { return fmt.Sprint(v) }
|
||||
}, 1, ui.KeyRight},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
for _, veil := range []bool{false, true} {
|
||||
build, value := tc.mk()
|
||||
inert := false
|
||||
tt := underVeil(veilCase{build: build}, &inert)
|
||||
for range tc.tabs {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
before := value()
|
||||
inert = veil
|
||||
tt.Key(0, tc.key)
|
||||
if turned := value() != before; turned == veil {
|
||||
t.Errorf("%s: veiled %v, the key turned %s to %s", name, veil, before, value())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package input_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
)
|
||||
|
||||
func TestLogsCountWhatWasTyped(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
defer slog.SetDefault(prev)
|
||||
name, tags, hero := "", []string{}, "Hero"
|
||||
tt := view(500, 400, nil, func(c *ui.Context) {
|
||||
input.NameEntry(c, "Initials", &name, input.NameEntryOptions{})
|
||||
input.TokenField(c, "Tags", &tags, input.TokenFieldOptions{})
|
||||
input.EditableText(c, "Name", &hero, input.EditableTextOptions{})
|
||||
})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
for range 4 {
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Type("zork")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Type("Quux")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if name != "BBB" || len(tags) != 1 || !strings.HasSuffix(hero, "Quux") {
|
||||
t.Fatalf("typed %q, %q, %q", name, tags, hero)
|
||||
}
|
||||
for _, s := range []string{"BBB", "zork", "Quux"} {
|
||||
if strings.Contains(buf.String(), s) {
|
||||
t.Errorf("the log holds %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutocompleteLogsNoWords(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
defer slog.SetDefault(prev)
|
||||
q := ""
|
||||
tt := view(400, 200, nil, func(c *ui.Context) {
|
||||
input.Autocomplete(c, "Find", &q, []string{"QuuxSecret"}, input.AutocompleteOptions{})
|
||||
})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Type("Quu")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if q != "QuuxSecret" || strings.Contains(buf.String(), "Secret") {
|
||||
t.Fatalf("value %q; the log has the word: %v", q, strings.Contains(buf.String(), "Secret"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestComboboxLogsNoWords(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
defer slog.SetDefault(prev)
|
||||
v := ""
|
||||
tt := view(400, 300, nil, func(c *ui.Context) { input.Combobox(c, "Find", &v, []string{"QuuxSecret"}, input.ComboboxOptions{}) })
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Type("QuuxSecret")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if v != "QuuxSecret" || strings.Contains(buf.String(), "QuuxSecret") {
|
||||
t.Fatalf("value %q; the log has the word: %v", v, strings.Contains(buf.String(), "QuuxSecret"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package input_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
func TestMultiSelectGivesPopupFocusBackAfterAVeil(t *testing.T) {
|
||||
chosen, inert := []string{}, false
|
||||
tt := view(400, 300, &clock{time.Unix(3_000_000, 0)}, func(c *ui.Context) {
|
||||
s := pixel.Current(c)
|
||||
s.Inert = inert
|
||||
pixel.Scope(c, s, func() {
|
||||
input.MultiSelect(c, "Party", &chosen, []string{"Mage", "Rogue"}, input.MultiSelectOptions{})
|
||||
})
|
||||
pixel.Text(c, "Elsewhere", pixel.TextOptions{})
|
||||
})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
// Focus on Mage, not the root that holds everything.
|
||||
if !tt.Focused("Mage") || tt.Focused("Elsewhere") {
|
||||
t.Fatalf("focus is not in the popup: %q", tt.Texts())
|
||||
}
|
||||
inert = true
|
||||
tt.Frame()
|
||||
inert = false
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if !tt.HasText("Rogue") {
|
||||
t.Fatal("the field did not get its focus back after the veil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package input_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// veilCase is a control, its value, key and drag.
|
||||
type veilCase struct {
|
||||
label string
|
||||
build func(c *ui.Context)
|
||||
value func() string
|
||||
key ui.Key
|
||||
// dx, dy move the pointer; zeros skip the drag.
|
||||
dx, dy float32
|
||||
}
|
||||
|
||||
func veilCases() map[string]func() veilCase {
|
||||
return map[string]func() veilCase{
|
||||
"Knob": func() veilCase {
|
||||
v := 50.0
|
||||
return veilCase{"Gain", func(c *ui.Context) { input.Knob(c, "Gain", &v, input.KnobOptions{Max: 100}) },
|
||||
func() string { return fmt.Sprint(v) }, ui.KeyUp, 0, -20}
|
||||
},
|
||||
"RangeSlider": func() veilCase {
|
||||
lo, hi := 20.0, 80.0
|
||||
return veilCase{"Band low", func(c *ui.Context) {
|
||||
input.RangeSlider(c, "Band", &lo, &hi, input.RangeSliderOptions{Max: 100})
|
||||
}, func() string { return fmt.Sprint(lo, hi) }, ui.KeyRight, 20, 0}
|
||||
},
|
||||
"SlotPicker": func() veilCase {
|
||||
v := 1
|
||||
return veilCase{"Reel", func(c *ui.Context) {
|
||||
input.SlotPicker(c, "Reel", &v, []string{"A", "B", "C", "D"}, input.SlotPickerOptions{})
|
||||
}, func() string { return fmt.Sprint(v) }, ui.KeyDown, 0, -40}
|
||||
},
|
||||
"NumberInput": func() veilCase {
|
||||
v := 5.0
|
||||
return veilCase{"Lives", func(c *ui.Context) { input.NumberInput(c, "Lives", &v, input.NumberInputOptions{Max: 9}) },
|
||||
func() string { return fmt.Sprint(v) }, ui.KeyUp, 0, 0}
|
||||
},
|
||||
"Joystick": func() veilCase {
|
||||
var r input.JoystickResult
|
||||
return veilCase{"Stick", func(c *ui.Context) { r = input.Joystick(c, "Stick", input.JoystickOptions{}) },
|
||||
func() string { return fmt.Sprintf("%.2f %.2f", r.X, r.Y) }, ui.KeyRight, 12, 0}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// underVeil builds tc where the test raises a veil.
|
||||
func underVeil(tc veilCase, inert *bool) *ui.Tester {
|
||||
return view(400, 300, nil, func(c *ui.Context) {
|
||||
s := pixel.Current(c)
|
||||
s.Inert = *inert
|
||||
pixel.Scope(c, s, func() { tc.build(c) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestVeilsStopDragsAndKeys(t *testing.T) {
|
||||
for name, mk := range veilCases() {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if tc := mk(); tc.dx != 0 || tc.dy != 0 {
|
||||
inert := false
|
||||
tt := underVeil(tc, &inert)
|
||||
r := find(t, tt, tc.label)
|
||||
x, y := r.X+r.W/2, r.Y+r.H/2
|
||||
start := tc.value()
|
||||
tt.Press(x, y)
|
||||
tt.Move(x+tc.dx, y+tc.dy)
|
||||
moved := tc.value()
|
||||
if moved == start {
|
||||
t.Fatalf("the drag did nothing to %s", start)
|
||||
}
|
||||
inert = true
|
||||
tt.Move(x+2*tc.dx, y+2*tc.dy)
|
||||
// A stick let go is centered; values stay.
|
||||
if got := tc.value(); got != moved && got != "0.00 0.00" {
|
||||
t.Errorf("a drag under the veil moved %s to %s", moved, got)
|
||||
}
|
||||
tt.Release(x+2*tc.dx, y+2*tc.dy)
|
||||
}
|
||||
tc, inert := mk(), false
|
||||
tt := underVeil(tc, &inert)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
before := tc.value()
|
||||
inert = true
|
||||
tt.Key(0, tc.key)
|
||||
if got := tc.value(); got != before {
|
||||
t.Errorf("a key in the veil's first frame moved %s to %s", before, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotPickerWheelStopsUnderAVeil(t *testing.T) {
|
||||
v, inert := 1, true
|
||||
tt := underVeil(veilCase{build: func(c *ui.Context) {
|
||||
input.SlotPicker(c, "Reel", &v, []string{"A", "B", "C", "D"}, input.SlotPickerOptions{})
|
||||
}}, &inert)
|
||||
r := find(t, tt, "Reel")
|
||||
tt.Scroll(r.X+r.W/2, r.Y+r.H/2, 0, 100)
|
||||
if v != 1 {
|
||||
t.Fatalf("the wheel turned a veiled reel to %d", v)
|
||||
}
|
||||
inert = false
|
||||
tt.Frame()
|
||||
tt.Scroll(r.X+r.W/2, r.Y+r.H/2, 0, 100)
|
||||
if v == 1 {
|
||||
t.Fatal("the wheel did not turn the reel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVeilStopsHoldsAndPicks(t *testing.T) {
|
||||
// HoldButton: Space repeats, and the veil falls with the last.
|
||||
clk, inert, confirmed := &clock{time.Unix(3_000_000, 0)}, false, 0
|
||||
tt := view(400, 200, clk, func(c *ui.Context) {
|
||||
s := pixel.Current(c)
|
||||
s.Inert = inert
|
||||
pixel.Scope(c, s, func() {
|
||||
if input.HoldButton(c, "ERASE", input.HoldButtonOptions{}).Confirmed {
|
||||
confirmed++
|
||||
}
|
||||
})
|
||||
})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
for i := range 3 {
|
||||
inert = i == 2
|
||||
tt.Key(0, ui.KeySpace)
|
||||
clk.t = clk.t.Add(500 * time.Millisecond)
|
||||
}
|
||||
tt.Frame()
|
||||
if confirmed != 0 {
|
||||
t.Fatal("a hold finished under the veil")
|
||||
}
|
||||
// Autocomplete and Combobox: Tab or Enter as the veil falls.
|
||||
for name, build := range map[string]func(c *ui.Context, v *string){
|
||||
"Autocomplete": func(c *ui.Context, v *string) {
|
||||
input.Autocomplete(c, "Hero", v, []string{"Quux"}, input.AutocompleteOptions{})
|
||||
},
|
||||
"Combobox": func(c *ui.Context, v *string) {
|
||||
input.Combobox(c, "Hero", v, []string{"Quux"}, input.ComboboxOptions{})
|
||||
},
|
||||
} {
|
||||
v, inert := "", false
|
||||
tt := view(400, 300, nil, func(c *ui.Context) {
|
||||
s := pixel.Current(c)
|
||||
s.Inert = inert
|
||||
pixel.Scope(c, s, func() { build(c, &v) })
|
||||
})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Type("Qu")
|
||||
inert = true
|
||||
tt.Key(0, map[string]ui.Key{"Autocomplete": ui.KeyTab, "Combobox": ui.KeyEnter}[name])
|
||||
if v != "Qu" {
|
||||
t.Errorf("%s: a key as the veil fell made %q", name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVeilStopsTyping(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
build func(c *ui.Context, v *string, tags *[]string)
|
||||
// last is the key or text coming with the veil.
|
||||
last string
|
||||
}{
|
||||
// The editor turns the typed letter away.
|
||||
"TextInput": {func(c *ui.Context, v *string, _ *[]string) { input.TextInput(c, "Name", v, input.TextInputOptions{}) }, "Z"},
|
||||
"TokenField": {func(c *ui.Context, v *string, tags *[]string) {
|
||||
input.TokenField(c, "Tags", tags, input.TokenFieldOptions{})
|
||||
}, "enter"},
|
||||
"TokenField comma": {func(c *ui.Context, v *string, tags *[]string) {
|
||||
input.TokenField(c, "Tags", tags, input.TokenFieldOptions{})
|
||||
}, ","},
|
||||
"EditableText": {func(c *ui.Context, v *string, _ *[]string) {
|
||||
input.EditableText(c, "Hero", v, input.EditableTextOptions{})
|
||||
}, "enter"},
|
||||
} {
|
||||
v, tags, inert := "before", []string{}, false
|
||||
tt := view(400, 200, nil, func(c *ui.Context) {
|
||||
s := pixel.Current(c)
|
||||
s.Inert = inert
|
||||
pixel.Scope(c, s, func() { tc.build(c, &v, &tags) })
|
||||
})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if name == "EditableText" {
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
}
|
||||
tt.Type("ab")
|
||||
inert = true
|
||||
if tc.last == "enter" {
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
} else {
|
||||
tt.Type(tc.last)
|
||||
}
|
||||
if want := map[string]string{"TextInput": "beforeab", "EditableText": "before"}[name]; want != "" && v != want || len(tags) != 0 {
|
||||
t.Errorf("%s: a key as the veil fell left %q, tags %q", name, v, tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadioGroupSpaceAsAVeilFallsTurnsNothing(t *testing.T) {
|
||||
sel, inert := 0, false
|
||||
tt := underVeil(veilCase{build: func(c *ui.Context) {
|
||||
input.RadioGroup(c, []string{"A", "B", "C"}, &sel, input.RadioGroupOptions{})
|
||||
}}, &inert)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
// Focus stays on B as the caller sets A again.
|
||||
sel = 0
|
||||
tt.Frame()
|
||||
inert = true
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if sel != 0 {
|
||||
t.Fatalf("Space as the veil fell chose %d", sel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorKeepsTheCallersValueAsAVeilFalls(t *testing.T) {
|
||||
v, inert := "before", false
|
||||
tt := underVeil(veilCase{build: func(c *ui.Context) { input.TextInput(c, "Text", &v, input.TextInputOptions{}) }}, &inert)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
// The caller writes as the veil falls on a letter.
|
||||
v, inert = "server", true
|
||||
tt.Type("x")
|
||||
if v != "server" {
|
||||
t.Fatalf("the caller's value became %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersActNotAsAVeilFalls(t *testing.T) {
|
||||
// PinInput: a digit typed as the veil or Disabled falls.
|
||||
for _, kind := range []string{"inert", "disabled"} {
|
||||
v, inert, off, complete := "123", false, false, 0
|
||||
tt := underVeil(veilCase{build: func(c *ui.Context) {
|
||||
if input.PinInput(c, "PIN", &v, input.PinInputOptions{Disabled: off}).Complete {
|
||||
complete++
|
||||
}
|
||||
}}, &inert)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
inert, off = kind == "inert", kind == "disabled"
|
||||
tt.Type("4")
|
||||
if v != "123" || complete != 0 {
|
||||
t.Errorf("PinInput %s: %q, complete %d", kind, v, complete)
|
||||
}
|
||||
}
|
||||
// CheatCode: the code's last key comes with the veil.
|
||||
inert, wins := false, 0
|
||||
tt := underVeil(veilCase{build: func(c *ui.Context) {
|
||||
if input.CheatCode(c, "Code", input.CheatCodeOptions{Sequence: []ui.Key{ui.KeyB, ui.KeyA}}).Unlocked {
|
||||
wins++
|
||||
}
|
||||
}}, &inert)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyB)
|
||||
inert = true
|
||||
tt.Key(0, ui.KeyA)
|
||||
if wins != 0 {
|
||||
t.Errorf("the code unlocked under the veil %d times", wins)
|
||||
}
|
||||
// SlotPicker: a wheel notch comes with the veil.
|
||||
slot := 0
|
||||
inert = false
|
||||
tt = underVeil(veilCase{build: func(c *ui.Context) {
|
||||
input.SlotPicker(c, "Slots", &slot, []string{"A", "B", "C"}, input.SlotPickerOptions{})
|
||||
}}, &inert)
|
||||
r := find(t, tt, "Slots")
|
||||
inert = true
|
||||
tt.Scroll(r.X+r.W/2, r.Y+r.H/2, 0, 40)
|
||||
if slot != 0 {
|
||||
t.Errorf("the wheel turned a veiled reel to %d", slot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComboboxPicksNothingAsAVeilFalls(t *testing.T) {
|
||||
for _, kind := range []string{"inert", "disabled"} {
|
||||
inert, off, v, chosen := false, false, "", 0
|
||||
tt := underVeil(veilCase{build: func(c *ui.Context) {
|
||||
if input.Combobox(c, "Combo", &v, []string{"Alpha", "Beta"}, input.ComboboxOptions{Disabled: off}).Chosen {
|
||||
chosen++
|
||||
}
|
||||
}}, &inert)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
r := find(t, tt, "Beta")
|
||||
// The press comes before the veil, the release after.
|
||||
tt.Press(r.X+r.W/2, r.Y+r.H/2)
|
||||
inert, off = kind == "inert", kind == "disabled"
|
||||
tt.Release(r.X+r.W/2, r.Y+r.H/2)
|
||||
if v != "" || chosen != 0 {
|
||||
t.Errorf("%s: picked %q, %d times", kind, v, chosen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepperHoldNeverRunsBackward(t *testing.T) {
|
||||
clk, v := &clock{time.Unix(1_700_000_000, 0)}, 50
|
||||
tt := view(400, 100, clk, func(c *ui.Context) { input.Stepper(c, "Step", &v, input.StepperOptions{Max: 100}) })
|
||||
r := find(t, tt, "Step")
|
||||
tt.Press(r.X+r.W+12, r.Y+r.H/2)
|
||||
clk.t = clk.t.Add(time.Second)
|
||||
tt.Frame()
|
||||
before := v
|
||||
clk.t = clk.t.Add(-time.Hour)
|
||||
tt.Frame()
|
||||
if before <= 51 || v < before {
|
||||
t.Fatalf("a held + went %d, then %d", before, v)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user