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:
ZacharyZhang-NY committed 2026-10-12 16:03:04 -04:00
1 parent 81003b21ee
commit b4b4d67b92
198 files changed
+2708 -422

No files matched your search

+5 -2
View File
@@ -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
View File
@@ -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)
}
+4
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+6 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+3 -3
View File
@@ -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)
}
}
+1
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+27
View File
@@ -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
View File
@@ -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)
}
+5 -3
View File
@@ -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
View File
@@ -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
}
+79
View File
@@ -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())
}
}
}
}
+77
View File
@@ -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"))
}
}
+37
View File
@@ -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")
}
}
+314
View File
@@ -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)
}
}