Add input components 086-105; park focus off disabled controls

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 20:13:21 -04:00
1 parent 095eab2b4c
commit 03b789f533
34 files changed
+3058 -43

No files matched your search

+5 -1
View File
@@ -57,7 +57,11 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en
- `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`.
- Containers take content as a final `func()`; interactive results are `NameResult`.
- A Clicked, Shortcut, Changed or drag during the build makes MyGo build the frame again, so what they change shows at once. State changed any other way after its element was built (`Pressed`, `Edit.Changed`) is read at paint time.
- Never call `Selected`: MyGo paints it in the accent color. Radios are `CheckboxBase` with `RoleRadio`.
- Never call `Selected`: MyGo paints it in the accent color.
- A press focuses the nearest focusable element under it, or nothing; a closing overlay hands the focus back to its opener. While a press is on, `Hovered` is false for every other element: ask `Pressed`.
- Enter and Space click a focused clickable element unless a `Shortcut` takes them. Take them wherever a click reads the pointer's position.
- Elements are known by their place among siblings. Wrap one whose earlier siblings come and go in `ui.Box(c).Key(k)`, or it loses focus and state.
- Hosts send a key's release with its press, so tell a held key by its repeats.
- Clip only boxes that sit on whole dots; split widths with whole-dot shares, not equal `Grow`. `Grow` starts from zero size, so never use it on a content-sized child.
- Row builders that MyGo runs during layout (`List`, `GridView`, `Outline`) go through `pixel.Lazy(c, row)`.
- Single-key shortcuts only while `pixel.Typing(c)` is false; MyGo routes shortcuts before a focused field.
+32
View File
@@ -218,3 +218,35 @@ func TestInputDemosLogEveryClick(t *testing.T) {
}
}
}
func TestControlDemosLogTheirEvents(t *testing.T) {
for slug, act := range map[string]func(tt *ui.Tester){
"088-combobox": func(tt *ui.Tester) { click(t, tt, "Item"); tt.Type("p") },
"089-autocomplete": func(tt *ui.Tester) { click(t, tt, "Spell"); tt.Type("f") },
"094-pininput": func(tt *ui.Tester) { click(t, tt, "Vault code"); tt.Type("1") },
"095-nameentry": func(tt *ui.Tester) { click(t, tt, "Initials") },
"096-virtualkeyboard": func(tt *ui.Tester) { click(t, tt, "Message") },
"103-joystick": func(tt *ui.Tester) { click(t, tt, "Stick"); tt.Key(0, ui.KeyRight) },
"105-slotpicker": func(tt *ui.Tester) { click(t, tt, "Reel 1"); tt.Key(0, ui.KeyDown) },
"101-fieldset": func(tt *ui.Tester) { click(t, tt, "Guild name"); tt.Type("a") },
} {
a, tt := open("/c/" + slug)
act(tt)
if len(a.logs[slug]) == 0 {
t.Errorf("%s logged nothing", slug)
}
}
}
func TestFieldDemoLogsSubmit(t *testing.T) {
a, tt := open("/c/100-field")
// The group shares its name; the input sits below.
r, _ := tt.Find("Call sign")
tt.ClickAt(r.X+20, r.Y+58)
tt.Type("Ace")
n := len(a.logs["100-field"])
tt.Key(0, ui.KeyEnter)
if len(a.logs["100-field"]) == n {
t.Fatal("Enter in the field logged nothing")
}
}
+291
View File
@@ -0,0 +1,291 @@
package gallery
import (
"fmt"
"slices"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var controlEntries = []Entry{
{86, "Select", "下拉选择", "input", "The choice and ▼; the list opens in a window with a hand pointing. Arrows and Enter pick.", demoSelect},
{87, "MultiSelect", "多选", "input", "A popup of checkboxes; what you pick rides in the trigger as tags.", demoMultiSelect},
{88, "Combobox", "组合框", "input", "Type to narrow the list below; Enter or a click takes one.", demoCombobox},
{89, "Autocomplete", "自动补全", "input", "The rest of the word waits in grey. Tab or Enter takes it.", demoAutocomplete},
{90, "TokenField", "标记输入", "input", "Tags before the caret. Enter or a comma adds; Backspace on empty takes the last back.", demoTokenField},
{91, "Segmented", "分段控件", "input", "Joined segments, one lit. Arrows move the light.", demoSegmented},
{92, "Rating", "评分", "input", "Stars or hearts. Click or arrows to rate; click the same mark to clear.", demoRating},
{93, "Knob", "旋钮", "input", "A rotary knob that clicks through 16 angles. Drag up or down, or use the arrows.", demoKnob},
{94, "PinInput", "验证码", "input", "One box per digit: typing moves on, Backspace steps back, paste fills them all.", demoPinInput},
{95, "NameEntry", "姓名登记", "input", "HIGH SCORE! Three initials from the letter grid, then END.", demoNameEntry},
{96, "VirtualKeyboard", "屏幕键盘", "input", "A keyboard on screen, for pads and touch: letters, digits, space, erase, enter.", demoVirtualKeyboard},
{97, "DPad", "方向键", "input", "A cross pad. Press an arm or use the arrow keys.", demoDPad},
{98, "HoldButton", "长按按钮", "input", "Hold it down while the bar fills in eight steps. Let go and it starts over.", demoHoldButton},
{99, "EditableText", "可编辑文本", "input", "Double click or press Enter to rename. Enter keeps it; Escape takes it back.", demoEditableText},
{100, "Field", "表单项", "input", "A label above, a hint below, and a red ! when something is wrong.", demoField},
{101, "Fieldset", "字段组", "input", "A framed group of fields under a legend.", demoFieldset},
{102, "ColorSlot", "色槽选择", "input", "Pick one of the sixteen palette slots. It follows the palette.", demoColorSlot},
{103, "Joystick", "摇杆", "input", "Drag the stick inside its ring; it springs home. Arrows nudge it.", demoJoystick},
{104, "CheatCode", "秘籍", "input", "Focus it and enter ↑↑↓↓←→←→BA. You know the one.", demoCheatCode},
{105, "SlotPicker", "滚轮选择", "input", "Reels that spin by drag, wheel or arrows. Line up three for the jackpot.", demoSlotPicker},
}
type controlDemo struct {
class, item, spell, hero, call, guild string
party, tags []string
mode, banner, stars, lives int
reel1, reel2, reel3 int
volume, bass float64
pin, initials, message string
cape pixel.Slot
dir input.Direction
}
func controlState(c *ui.Context) *controlDemo {
return ui.Local(c.Root(), demoKey("controls"), func() controlDemo {
return controlDemo{class: "Knight", hero: "Pixel Knight", tags: []string{"retro", "pixel"}, mode: 1, stars: 4, lives: 3,
volume: 0.7, bass: 0.3, cape: pixel.Accent, reel2: 1, reel3: 2}
})
}
func demoSelect(c *ui.Context, log func(string)) {
s := controlState(c)
if input.Select(c, "Class", &s.class, []string{"Knight", "Mage", "Thief", "Bard"}, input.SelectOptions{}).Changed {
log("class " + s.class)
}
}
func demoMultiSelect(c *ui.Context, log func(string)) {
s := controlState(c)
if input.MultiSelect(c, "Party", &s.party, []string{"Warrior", "Healer", "Rogue", "Wizard"}, input.MultiSelectOptions{}).Changed {
log(fmt.Sprintf("party %v", s.party))
}
}
func demoCombobox(c *ui.Context, log func(string)) {
s := controlState(c)
items := []string{"Potion", "Hi-Potion", "Ether", "Elixir", "Antidote", "Phoenix Down"}
r := input.Combobox(c, "Item", &s.item, items, input.ComboboxOptions{Placeholder: "Use an item…", Width: 140})
if r.Changed {
log("item " + s.item)
}
if r.Chosen {
log("used " + s.item)
}
}
func demoAutocomplete(c *ui.Context, log func(string)) {
s := controlState(c)
spells := []string{"Fire", "Fira", "Firaga", "Blizzard", "Thunder", "Cure", "Curaga"}
r := input.Autocomplete(c, "Spell", &s.spell, spells, input.AutocompleteOptions{Placeholder: "Cast…"})
if r.Changed {
log("spell " + s.spell)
}
if r.Accepted {
log("cast " + s.spell)
}
if r.Submitted {
log("fizzle: " + s.spell)
}
}
func demoTokenField(c *ui.Context, log func(string)) {
s := controlState(c)
if input.TokenField(c, "Tags", &s.tags, input.TokenFieldOptions{Placeholder: "add tag"}).Changed {
log(fmt.Sprintf("tags %v", s.tags))
}
}
func demoSegmented(c *ui.Context, log func(string)) {
s := controlState(c)
modes := []string{"EASY", "NORMAL", "HARD"}
if input.Segmented(c, "Difficulty", modes, &s.mode, input.SegmentedOptions{}).Changed {
log("difficulty " + modes[s.mode])
}
}
func demoRating(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := controlState(c)
ui.Column(c).Gap(k.D(6)).Children(func() {
if input.Rating(c, "Dungeon", &s.stars, input.RatingOptions{}).Changed {
log(fmt.Sprintf("%d stars", s.stars))
}
if input.Rating(c, "Lives", &s.lives, input.RatingOptions{Max: 3, Hearts: true}).Changed {
log(fmt.Sprintf("%d lives", s.lives))
}
})
}
func demoKnob(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := controlState(c)
ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() {
for _, kn := range []struct {
name string
v *float64
}{{"VOL", &s.volume}, {"BASS", &s.bass}} {
ui.Column(c).Gap(k.D(2)).AlignItems(ui.Center).Children(func() {
if input.Knob(c, kn.name, kn.v, input.KnobOptions{Step: 0.05}).Changed {
log(fmt.Sprintf("%s %.2f", kn.name, *kn.v))
}
pixel.Text(c, fmt.Sprintf("%s %d", kn.name, int(*kn.v*100+0.5)), pixel.TextOptions{Slot: pixel.Faint})
})
}
})
}
func demoPinInput(c *ui.Context, log func(string)) {
s := controlState(c)
r := input.PinInput(c, "Vault code", &s.pin, input.PinInputOptions{})
if r.Changed {
log(fmt.Sprintf("%d digits", len(s.pin)))
}
if r.Complete {
if s.pin == "0451" {
log("ACCESS GRANTED")
} else {
log("WRONG CODE " + s.pin)
}
}
}
func demoNameEntry(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := controlState(c)
ui.Column(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
pixel.Text(c, "NEW HIGH SCORE 99999", pixel.TextOptions{Slot: pixel.Accent})
r := input.NameEntry(c, "Initials", &s.initials, input.NameEntryOptions{})
if r.Changed {
log("initials " + s.initials)
}
if r.Confirmed {
log("1ST " + s.initials + " 99999")
}
})
}
func demoVirtualKeyboard(c *ui.Context, log func(string)) {
s := controlState(c)
r := input.VirtualKeyboard(c, "Message", &s.message, input.VirtualKeyboardOptions{})
if r.Changed {
log("message " + s.message)
}
if r.Submitted {
log("sent " + s.message)
s.message = ""
}
}
func demoDPad(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := controlState(c)
ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() {
if d := input.DPad(c, "Pad", input.DPadOptions{}).Pressed; d != input.None {
s.dir = d
log(d.String())
}
pixel.Text(c, "last: "+s.dir.String(), pixel.TextOptions{Slot: pixel.Faint})
})
}
func demoHoldButton(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(6)).Children(func() {
if input.HoldButton(c, "SKIP CUTSCENE", input.HoldButtonOptions{}).Confirmed {
log("cutscene skipped")
}
if input.HoldButton(c, "DELETE SAVE", input.HoldButtonOptions{Duration: 2 * time.Second, Danger: true}).Confirmed {
log("save deleted (not really)")
}
})
}
func demoEditableText(c *ui.Context, log func(string)) {
s := controlState(c)
if input.EditableText(c, "Hero name", &s.hero, input.EditableTextOptions{Placeholder: "Nameless"}).Changed {
log("renamed to " + s.hero)
}
}
func demoField(c *ui.Context, log func(string)) {
s := controlState(c)
o := input.FieldOptions{Description: "Shown on the leaderboard."}
if s.call == "" {
o.Error = "A hero needs a call sign!"
}
input.Field(c, "Call sign", o, func() {
r := input.TextInput(c, "Call sign", &s.call, input.TextInputOptions{MaxLength: 10})
if r.Changed {
log("call sign " + s.call)
}
if r.Submitted {
log("signed " + s.call)
}
})
}
func demoFieldset(c *ui.Context, log func(string)) {
s := controlState(c)
input.Fieldset(c, "Guild", input.FieldsetOptions{}, func() {
input.Field(c, "Name", input.FieldOptions{}, func() {
r := input.TextInput(c, "Guild name", &s.guild, input.TextInputOptions{Placeholder: "The Dot Matrix"})
if r.Changed {
log("guild " + s.guild)
}
if r.Submitted {
log("founded " + s.guild)
}
})
banners := []string{"RED", "BLUE", "GOLD"}
if input.Segmented(c, "Banner", banners, &s.banner, input.SegmentedOptions{}).Changed {
log("banner " + banners[s.banner])
}
})
}
func demoColorSlot(c *ui.Context, log func(string)) {
s := controlState(c)
if input.ColorSlot(c, "Cape", &s.cape, input.ColorSlotOptions{}).Changed {
log("cape " + s.cape.String())
}
}
func demoJoystick(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() {
r := input.Joystick(c, "Stick", input.JoystickOptions{})
if r.Moved {
log(fmt.Sprintf("x %+.2f y %+.2f", r.X, r.Y))
}
pixel.Text(c, fmt.Sprintf("X %+.2f Y %+.2f", r.X, r.Y), pixel.TextOptions{Slot: pixel.Faint})
})
}
func demoCheatCode(c *ui.Context, log func(string)) {
if input.CheatCode(c, "Cheat", input.CheatCodeOptions{}).Unlocked {
log("30 LIVES!")
}
}
func demoSlotPicker(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := controlState(c)
fruit := []string{"Cherry", "Bell", "Seven", "Melon", "BAR"}
ui.Row(c).Gap(k.D(4)).Children(func() {
changed := false
for i, v := range []*int{&s.reel1, &s.reel2, &s.reel3} {
if input.SlotPicker(c, fmt.Sprintf("Reel %d", i+1), v, fruit, input.SlotPickerOptions{}).Changed {
changed = true
log(fmt.Sprintf("reel %d %s", i+1, fruit[*v]))
}
}
if all := []int{s.reel1, s.reel2, s.reel3}; changed && slices.Max(all) == slices.Min(all) {
log("JACKPOT! " + fruit[s.reel1])
}
})
}
+1 -1
View File
@@ -56,7 +56,7 @@ var Categories = []Category{
{"tabletop", "Tabletop", "桌游", icon.Crown},
}
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries)
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries)
func Entries() []Entry { return entries }
+63
View File
@@ -0,0 +1,63 @@
package input
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type AutocompleteOptions struct {
Placeholder string
// Width is in dots; zero means 120.
Width int
Disabled bool
}
type AutocompleteResult struct {
Element *ui.Element
// Accepted reports a completion taken by Tab or Enter.
Changed, Accepted, Submitted bool
}
// Autocomplete ghosts the first completion; Tab or Enter takes it.
func Autocomplete(c *ui.Context, text string, value *string, suggestions []string, o AutocompleteOptions) AutocompleteResult {
k := pixel.K(c)
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)
if tab || enter {
*value, res.Changed, res.Accepted = best, true, true
slog.Debug("8bit: autocomplete accepted", "label", text, "value", best)
return res
}
if best != "" {
ed.Input.Description(best)
typed := *value
ed.Element.DrawOver(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
rest := string([]rune(best)[len([]rune(typed)):])
if x := pixel.Measure(typed); x+pixel.Measure(rest) <= g.W {
g.Text(x, 0, rest, pixel.Faint, 1)
}
})
}
return res
}
// completion is the first suggestion extending v, ignoring case.
func completion(suggestions []string, v string) string {
if v == "" {
return ""
}
n := len([]rune(v))
for _, s := range suggestions {
if r := []rune(s); len(r) > n && strings.EqualFold(string(r[:n]), v) {
return s
}
}
return ""
}
+118
View File
@@ -0,0 +1,118 @@
package input
import (
"fmt"
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CheatCodeOptions struct {
// Sequence is the code; nil means ↑↑↓↓←→←→BA.
Sequence []ui.Key
Disabled bool
}
type CheatCodeResult struct {
Element *ui.Element
Unlocked bool
}
var konami = []ui.Key{ui.KeyUp, ui.KeyUp, ui.KeyDown, ui.KeyDown, ui.KeyLeft, ui.KeyRight, ui.KeyLeft, ui.KeyRight, ui.KeyB, ui.KeyA}
type cheatKey struct{}
// cheat is the key trail and an unreported win.
type cheat struct {
trail []ui.Key
won, fresh bool
}
// CheatCode watches keys for a sequence and shows the trail.
func CheatCode(c *ui.Context, text string, o CheatCodeOptions) CheatCodeResult {
k := pixel.K(c)
seq := o.Sequence
if seq == nil {
seq = konami
}
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled)
})
s := ui.Local(base, cheatKey{}, func() cheat { return 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 {
return false
}
s.won, s.trail = false, append(s.trail, ev.Key)
s.trail = s.trail[max(0, len(s.trail)-len(seq)):]
if slices.Equal(s.trail, seq) {
s.won, s.fresh, s.trail = true, true, nil
}
// Other keys break the code yet pass on, like Tab.
return slices.Contains(seq, ev.Key)
})
res := CheatCodeResult{Element: shell, Unlocked: s.fresh}
s.fresh = false
if res.Unlocked {
slog.Debug("8bit: cheat unlocked", "label", text)
}
got := matched(s.trail, seq)
base.Description(fmt.Sprintf("%d/%d", got, len(seq)))
trail, won := s.trail, s.won
shell.Size(k.D(len(seq)*10+4), k.D(14+pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, 14, pixel.Sunken, pixel.Well)
// With no trail yet, the code itself waits in Shade.
keys, ink := trail, pixel.Faint
switch {
case won:
keys, ink = seq, pixel.Good
g.Text(2, 14, k.Msg("lives"), pixel.Good, 1)
case len(trail) == 0:
keys, ink = seq, pixel.Shade
}
for i, key := range keys {
kink := ink
if len(trail) > 0 && i >= len(trail)-got {
kink = pixel.Accent
}
glyph(g, 2+i*10, 4, key, kink)
}
finish(g, st)
})
return res
}
// matched is the longest end of trail that starts seq.
func matched(trail, seq []ui.Key) int {
for n := min(len(trail), len(seq)); n > 0; n-- {
if slices.Equal(trail[len(trail)-n:], seq[:n]) {
return n
}
}
return 0
}
// glyph draws a key as an arrow or micro letter.
func glyph(g pixel.Canvas, x, y int, key ui.Key, ink pixel.Slot) {
switch {
case key == ui.KeyUp:
g.Sprite(x+2, y+1, tinyUp, 0, ink, 1)
case key == ui.KeyDown:
g.Sprite(x+2, y+1, tinyDown, 0, ink, 1)
case key == ui.KeyLeft:
g.Sprite(x+3, y, tinyLeft, 0, ink, 1)
case key == ui.KeyRight:
g.Sprite(x+3, y, tinyRight, 0, ink, 1)
case key >= ui.KeyA && key <= ui.KeyZ:
g.Micro(x+2, y, string(rune('A'+key-ui.KeyA)), ink, 1)
case key >= ui.Key0 && key <= ui.Key9:
g.Micro(x+2, y, string(rune('0'+key-ui.Key0)), ink, 1)
default:
g.Rect(x+2, y+1, 3, 3, ink)
}
}
+45
View File
@@ -0,0 +1,45 @@
package input
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ColorSlotOptions struct {
Disabled bool
}
type ColorSlotResult struct {
Element *ui.Element
Changed bool
}
// ColorSlot picks one of 16 palette slots from a grid.
func ColorSlot(c *ui.Context, text string, value *pixel.Slot, o ColorSlotOptions) ColorSlotResult {
k := pixel.K(c)
cur := int(*value)
shell, base, st, _ := pad(c, text, 8, pixel.Slots, 12, 12, &cur, o.Disabled)
res := ColorSlotResult{Element: shell, Changed: pixel.Slot(cur) != *value}
*value = pixel.Slot(cur)
base.Description(value.String())
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range pixel.Slots {
x, y := i%8*12, i/8*12
if pixel.Slot(i) == *value {
g.Rect(x, y, 12, 12, pixel.Ink)
g.Rect(x+1, y+1, 10, 10, pixel.Lit)
}
g.Rect(x+2, y+2, 8, 8, pixel.Edge)
g.Rect(x+3, y+3, 6, 6, pixel.Slot(i))
}
finish(g, st)
})
if res.Changed {
slog.Debug("8bit: color slot", "label", text, "slot", *value)
}
return res
}
+156
View File
@@ -0,0 +1,156 @@
package input
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ComboboxOptions struct {
Placeholder string
// Width is in dots; zero means 120.
Width int
Disabled bool
}
type ComboboxResult struct {
Element *ui.Element
// Changed reports a new *value; Chosen, an option taken.
Changed, Chosen bool
}
type comboKey struct{}
type combo struct {
open, typed bool
// lit is the lit option; ptr, the last pointer spot.
lit int
ptr [2]float32
// top is the first row shown; wheel, unspent scroll.
top int
wheel float32
}
// comboRows is how many options show at once.
const comboRows = 8
// Combobox lists matching options; Enter or a click takes one.
func Combobox(c *ui.Context, text string, value *string, options []string, o ComboboxOptions) ComboboxResult {
k := pixel.K(c)
ed, e := field(c, value, pixel.EditorOptions{Label: text, Placeholder: o.Placeholder, Disabled: o.Disabled}, o.Width, nil, nil, nil)
ed.Input.Role(ui.RoleComboBox)
st := ui.Local(e, comboKey{}, func() combo { return combo{} })
res := ComboboxResult{Element: e, Changed: ed.Changed}
take := func(opt string) {
if *value != opt {
*value, res.Changed = opt, true
}
st.open, st.typed, res.Chosen = false, false, true
}
if ed.Changed {
st.open, st.typed, st.lit = true, true, 0
}
if e.Shortcut(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) {
st.lit--
}
shown := options
if st.typed {
shown = matching(options, *value)
}
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) {
take(shown[st.lit])
}
show, held := st.open && len(shown) > 0, false
panel := ui.PopoverBase(c, e, &show, func(panel *ui.Element) {
panel.MinWidth(e.Bounds().W)
panel.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind != ui.InputScroll {
return false
}
st.wheel += ev.DY
turn(&st.wheel, k.D(pixel.LineHeight), func(d int) { st.lit = max(0, min(len(shown)-1, st.lit+d)) })
return true
})
end := min(len(shown), st.top+comboRows)
panel.DrawOver(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
if st.top > 0 {
g.Sprite(g.W-8, 2, tinyUp, 0, pixel.Faint, 1)
}
if end < len(shown) {
g.Sprite(g.W-8, g.H-5, tinyDown, 0, pixel.Faint, 1)
}
})
// Only a pointer that moves lights the option under it.
px, py, over := panel.PointerPosition()
moved := over && [2]float32{px, py} != st.ptr
st.ptr = [2]float32{px, py}
menu(c, panel, func() {
items := make([]*ui.Element, len(shown))
for i := st.top; i < end; i++ {
opt := shown[i]
items[i] = ui.Row(c).Role(ui.RoleListItem).Label(opt)
if items[i].Hovered() && moved {
st.lit = i
}
if items[i].Pressed() {
held = true
}
if items[i].Clicked() {
take(opt)
}
}
for i := st.top; i < end; i++ {
row(k, items[i], shown[i], i == st.lit, shown[i] == *value)
}
})
})
if st.open && len(shown) > 0 && !show {
st.open = false
}
// Losing focus closes it, unless pressing on the list.
if panel != nil && panel.Pressed() {
held = true
}
if panel != nil && st.open && !ed.Focused && !held {
st.open = false
// This frame built the list: hide it, rebuild without it.
panel.Invisible()
c.Invalidate()
}
if res.Chosen {
slog.Debug("8bit: combobox chosen", "label", text, "value", *value)
}
return res
}
// matching are options holding q, prefix matches first.
func matching(options []string, q string) []string {
q = strings.ToLower(strings.TrimSpace(q))
if q == "" {
return options
}
var starts, holds []string
for _, opt := range options {
switch l := strings.ToLower(opt); {
case strings.HasPrefix(l, q):
starts = append(starts, opt)
case strings.Contains(l, q):
holds = append(holds, opt)
}
}
return append(starts, holds...)
}
+171
View File
@@ -0,0 +1,171 @@
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"
)
// Each control, disabled after taking focus, ignores every input.
func TestDisabledHoldButtonIgnoresAHeldPress(t *testing.T) {
clk := &clock{time.Unix(3_000_000, 0)}
confirmed := 0
tt := view(400, 200, clk, func(c *ui.Context) {
if input.HoldButton(c, "HOLD", input.HoldButtonOptions{Disabled: true}).Confirmed {
confirmed++
}
})
r := find(t, tt, "HOLD")
tt.Press(r.X+r.W/2, r.Y+r.H/2)
clk.t = clk.t.Add(2 * time.Second)
tt.Frame()
if confirmed != 0 {
t.Fatal("a disabled hold button confirmed")
}
}
func TestHoldButtonForgetsAHoldWhenDisabled(t *testing.T) {
clk := &clock{time.Unix(3_000_000, 0)}
confirmed, off := 0, false
tt := view(400, 200, clk, func(c *ui.Context) {
input.Button(c, "before", input.ButtonOptions{})
if input.HoldButton(c, "HOLD", input.HoldButtonOptions{Disabled: off}).Confirmed {
confirmed++
}
})
hold := func(d time.Duration) {
for end := clk.t.Add(d); clk.t.Before(end); clk.t = clk.t.Add(50 * time.Millisecond) {
tt.Key(0, ui.KeySpace)
}
}
click(t, tt, "before")
tt.Key(0, ui.KeyTab)
hold(500 * time.Millisecond)
off = true
clk.t = clk.t.Add(100 * time.Millisecond)
tt.Frame()
off = false
tt.Frame()
click(t, tt, "before")
tt.Key(0, ui.KeyTab)
hold(600 * time.Millisecond)
if confirmed != 0 {
t.Fatal("a hold from before disabling counted")
}
}
func TestDisabledWhileFocusedTakesNoInput(t *testing.T) {
type state struct {
s, list string
n, events int
f float64
tags []string
slot pixel.Slot
}
cases := []struct {
name, label string
build func(c *ui.Context, v *state, off bool)
}{
{"Select", "Class", func(c *ui.Context, v *state, off bool) {
input.Select(c, "Class", &v.s, []string{"A", "B"}, input.SelectOptions{Disabled: off})
}},
{"MultiSelect", "Gear", func(c *ui.Context, v *state, off bool) {
input.MultiSelect(c, "Gear", &v.tags, []string{"A", "B"}, input.MultiSelectOptions{Disabled: off})
}},
{"Combobox", "Fruit", func(c *ui.Context, v *state, off bool) {
input.Combobox(c, "Fruit", &v.s, []string{"Apple"}, input.ComboboxOptions{Disabled: off})
}},
{"Autocomplete", "Spell", func(c *ui.Context, v *state, off bool) {
input.Autocomplete(c, "Spell", &v.s, []string{"Fire"}, input.AutocompleteOptions{Disabled: off})
}},
{"TokenField", "Tags", func(c *ui.Context, v *state, off bool) {
input.TokenField(c, "Tags", &v.tags, input.TokenFieldOptions{Disabled: off})
}},
{"Segmented", "B", func(c *ui.Context, v *state, off bool) {
input.Segmented(c, "Mode", []string{"A", "B", "C"}, &v.n, input.SegmentedOptions{Disabled: off})
}},
{"Rating", "2/5", func(c *ui.Context, v *state, off bool) {
input.Rating(c, "Stars", &v.n, input.RatingOptions{Disabled: off})
}},
{"Knob", "Gain", func(c *ui.Context, v *state, off bool) {
input.Knob(c, "Gain", &v.f, input.KnobOptions{Disabled: off})
}},
{"PinInput", "PIN", func(c *ui.Context, v *state, off bool) {
input.PinInput(c, "PIN", &v.s, input.PinInputOptions{Disabled: off})
}},
{"NameEntry", "Initials", func(c *ui.Context, v *state, off bool) {
input.NameEntry(c, "Initials", &v.s, input.NameEntryOptions{Disabled: off})
}},
{"VirtualKeyboard", "Keys", func(c *ui.Context, v *state, off bool) {
input.VirtualKeyboard(c, "Keys", &v.s, input.VirtualKeyboardOptions{Disabled: off})
}},
{"DPad", "Pad", func(c *ui.Context, v *state, off bool) {
if input.DPad(c, "Pad", input.DPadOptions{Disabled: off}).Pressed != input.None {
v.events++
}
}},
{"HoldButton", "HOLD", func(c *ui.Context, v *state, off bool) {
if input.HoldButton(c, "HOLD", input.HoldButtonOptions{Duration: time.Millisecond, Disabled: off}).Confirmed {
v.events++
}
}},
{"EditableText", "Name", func(c *ui.Context, v *state, off bool) {
input.EditableText(c, "Name", &v.s, input.EditableTextOptions{Disabled: off})
}},
{"ColorSlot", "Paint", func(c *ui.Context, v *state, off bool) {
input.ColorSlot(c, "Paint", &v.slot, input.ColorSlotOptions{Disabled: off})
}},
{"Joystick", "Stick", func(c *ui.Context, v *state, off bool) {
if input.Joystick(c, "Stick", input.JoystickOptions{Disabled: off}).Moved {
v.events++
}
}},
{"CheatCode", "Cheat", func(c *ui.Context, v *state, off bool) {
if input.CheatCode(c, "Cheat", input.CheatCodeOptions{Sequence: []ui.Key{ui.KeyUp}, Disabled: off}).Unlocked {
v.events++
}
}},
{"SlotPicker", "Reel", func(c *ui.Context, v *state, off bool) {
input.SlotPicker(c, "Reel", &v.n, []string{"A", "B", "C"}, input.SlotPickerOptions{Disabled: off})
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var v state
off := false
tt := view(400, 300, nil, func(c *ui.Context) {
tc.build(c, &v, off)
ui.Box(c).Size(4, 4).Label("elsewhere")
})
click(t, tt, tc.label)
if tc.name == "EditableText" {
tt.Key(0, ui.KeyEnter)
}
off = true
tt.Frame()
if _, open := tt.Find("A"); open && tc.name != "Segmented" && tc.name != "SlotPicker" {
t.Fatal("a disabled control's popup stayed open")
}
// Focused counts ancestors: only a shared one may hold it.
if tt.Focused(tc.label) && !tt.Focused("elsewhere") {
t.Fatal("a disabled control kept the focus")
}
before := fmt.Sprint(v)
tt.Type("1a,")
tt.SetClipboard("12")
tt.Command("paste")
for _, k := range []ui.Key{ui.KeyUp, ui.KeyDown, ui.KeyLeft, ui.KeyRight, ui.KeyHome, ui.KeySpace, ui.KeyEnter, ui.KeyBackspace} {
tt.Key(0, k)
}
click(t, tt, tc.label)
if now := fmt.Sprint(v); now != before {
t.Fatalf("disabled control changed: %s to %s", before, now)
}
})
}
}
+116
View File
@@ -0,0 +1,116 @@
package input
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Direction is a d-pad arm.
type Direction uint8
const (
None Direction = iota
Up
Down
Left
Right
)
func (d Direction) String() string { return [...]string{"none", "up", "down", "left", "right"}[d] }
type DPadOptions struct {
Disabled bool
}
type DPadResult struct {
Element *ui.Element
// Pressed is the arm pressed this frame.
Pressed Direction
}
type dpadKey struct{}
// flash is the arm a key lit, and when.
type flash struct {
arm Direction
at time.Time
}
// DPad is a cross pad reporting arrows and pressed arms.
func DPad(c *ui.Context, text string, o DPadOptions) DPadResult {
k := pixel.K(c)
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled)
})
lit := ui.Local(base, dpadKey{}, func() flash { return flash{} })
res := DPadResult{Element: shell}
for _, m := range []struct {
key ui.Key
arm Direction
}{{ui.KeyUp, Up}, {ui.KeyDown, Down}, {ui.KeyLeft, Left}, {ui.KeyRight, Right}} {
if base.Shortcut(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)
at := func() Direction {
x, y, _ := base.PointerPosition()
return armAt(int(x/k.Dot()), int(y/k.Dot()))
}
if base.Clicked() {
res.Pressed = at()
}
if res.Pressed != None {
*lit = flash{res.Pressed, k.Now()}
slog.Debug("8bit: dpad", "label", text, "arm", res.Pressed)
}
down := None
if k.Now().Sub(lit.at) < 150*time.Millisecond {
down = lit.arm
k.Tick(c, 10)
}
if st.Press {
down = at()
}
shell.Size(k.D(36), k.D(36)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(12, 0, 12, 36, pixel.Edge)
g.Rect(0, 12, 36, 12, pixel.Edge)
for _, a := range []struct {
arm Direction
x, y, w, h int
sp *pixel.Sprite
}{{Up, 13, 1, 10, 11, tinyUp}, {Down, 13, 24, 10, 11, tinyDown}, {Left, 1, 13, 11, 10, tinyLeft}, {Right, 24, 13, 11, 10, tinyRight}} {
fill, dy := pixel.Face, 0
if down == a.arm {
fill, dy = pixel.Shade, 1
}
g.Rect(a.x, a.y, a.w, a.h, fill)
g.Sprite(a.x+(a.w-a.sp.W)/2, a.y+(a.h-a.sp.H)/2+dy, a.sp, 0, pixel.Ink, 1)
}
g.Rect(13, 13, 10, 10, pixel.Shade)
finish(g, st)
})
return res
}
// armAt is the arm under a dot of the pad.
func armAt(x, y int) Direction {
switch {
case x >= 12 && x < 24 && y < 12:
return Up
case x >= 12 && x < 24 && y >= 24:
return Down
case y >= 12 && y < 24 && x < 12:
return Left
case y >= 12 && y < 24 && x >= 24:
return Right
}
return None
}
+83
View File
@@ -0,0 +1,83 @@
package input
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type EditableTextOptions struct {
Placeholder string
// Width is in dots; zero means 120.
Width int
Disabled bool
}
type EditableTextResult struct {
Element *ui.Element
Changed bool
}
type editableKey struct{}
type editable struct {
on, refocus bool
draft string
}
// EditableText edits on double click or Enter; Escape reverts.
func EditableText(c *ui.Context, text string, value *string, o EditableTextOptions) EditableTextResult {
k := pixel.K(c)
w := orDef("EditableText Width", o.Width, 120)
box := ui.Box(c)
s := ui.Local(box, editableKey{}, func() editable { return editable{} })
res := EditableTextResult{Element: box}
if o.Disabled {
s.on = false
}
box.Children(func() {
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)
if enter {
res.Changed = *value != s.draft
*value = s.draft
}
if enter || escape {
s.on, s.refocus = false, true
}
return
}
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleButton).Label(text).Description(*value).Disabled(o.Disabled)
})
if s.refocus {
base.Focus()
s.refocus = false
}
enter, double := base.Shortcut(0, ui.KeyEnter), base.DoubleClicked()
if enter || double {
s.on, s.draft = true, *value
}
shell.Size(k.D(w), k.D(pixel.LineHeight+6)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
if st.Hover {
g.Rect(0, 0, g.W, g.H, pixel.Lit)
}
shown, ink := *value, pixel.Ink
if shown == "" {
shown, ink = o.Placeholder, pixel.Faint
}
g.Text(4, 3, shown, ink, 1)
g.Veil(0, g.H-1, g.W, 1, pixel.Faint, 8)
finish(g, st)
})
})
if res.Changed {
slog.Debug("8bit: text edited", "label", text, "value", *value)
}
return res
}
+34
View File
@@ -0,0 +1,34 @@
package input
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type FieldOptions struct {
// Description shows below the control in Faint.
Description string
// Error shows below it in Bad, with a ! sign.
Error string
}
// Field puts a label above a control, notes below.
func Field(c *ui.Context, label string, o FieldOptions, control func()) *ui.Element {
k := pixel.K(c)
return ui.Column(c).Gap(k.D(2)).Role(ui.RoleGroup).Label(label).Children(func() {
pixel.Text(c, label, pixel.TextOptions{})
control()
if o.Description != "" {
pixel.Text(c, o.Description, pixel.TextOptions{Slot: pixel.Faint, Wrap: true})
}
if o.Error != "" {
ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Children(func() {
display.Icon(c, icon.Warning, display.IconOptions{Slot: pixel.Bad})
pixel.Text(c, o.Error, pixel.TextOptions{Slot: pixel.Bad, Wrap: true}).Grow(1)
})
}
})
}
+22
View File
@@ -0,0 +1,22 @@
package input
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type FieldsetOptions struct{}
// Fieldset frames a group of fields under a legend.
func Fieldset(c *ui.Context, legend string, o FieldsetOptions, content func()) *ui.Element {
k := pixel.K(c)
lw := pixel.Measure(legend)
return ui.Column(c).Padding(k.D(pixel.LineHeight), k.D(8), k.D(8), k.D(8)).Gap(k.D(6)).Role(ui.RoleGroup).Label(legend).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
top := pixel.LineHeight / 2
g.Frame(0, top, g.W, g.H-top, pixel.Double, pixel.Face)
g.Rect(6, top-1, lw+6, 4, pixel.Face)
g.Text(9, 0, legend, pixel.Accent, 1)
}).Children(content)
}
+223
View File
@@ -0,0 +1,223 @@
package input_test
import (
"math"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestEditableTextEnterKeepsEscapeReverts(t *testing.T) {
hero, changes := "Hero", 0
tt := view(400, 200, nil, func(c *ui.Context) {
input.Button(c, "before", input.ButtonOptions{})
if input.EditableText(c, "Name", &hero, input.EditableTextOptions{}).Changed {
changes++
}
})
click(t, tt, "before")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if _, ok := tt.TextCaret(); !ok {
t.Fatal("Enter did not open a field")
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("Zed")
tt.Key(0, ui.KeyEnter)
if hero != "Zed" || changes != 1 || !tt.Focused("Name") {
t.Fatalf("Enter kept %q after %d changes, focus back %v", hero, changes, tt.Focused("Name"))
}
if _, ok := tt.TextCaret(); ok {
t.Fatal("the field stayed open")
}
r := find(t, tt, "Name")
tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
if _, ok := tt.TextCaret(); !ok {
t.Fatal("a double click did not open the field")
}
tt.Type("ox")
tt.Key(0, ui.KeyEscape)
if hero != "Zed" || changes != 1 {
t.Fatalf("Escape kept %q", hero)
}
if _, ok := tt.TextCaret(); ok {
t.Fatal("a double click did not open the field, or Escape left it open")
}
}
func TestFieldAndFieldsetLayOutTheirNotes(t *testing.T) {
name := ""
tt := view(400, 300, nil, func(c *ui.Context) {
input.Fieldset(c, "Hero", input.FieldsetOptions{}, func() {
input.Field(c, "Call sign", input.FieldOptions{Description: "Shown on the board.", Error: "Taken!"}, func() {
input.TextInput(c, "sign", &name, input.TextInputOptions{})
})
})
})
label, field, note, bad := find(t, tt, "Call sign"), find(t, tt, "sign"), find(t, tt, "Shown on the board."), find(t, tt, "Taken!")
if !(label.Y < field.Y && field.Y < note.Y && note.Y < bad.Y) {
t.Fatal("not label, control, description, error from top down")
}
if !has(slots(t, tt.Image(), ui.Rect{X: bad.X - 30, Y: bad.Y, W: 30, H: bad.H}), pixel.Bad) {
t.Fatal("the error has no Bad sign")
}
set := find(t, tt, "Hero")
if !has(slots(t, tt.Image(), ui.Rect{X: set.X, Y: set.Y, W: set.W, H: 2 * pixel.LineHeight}), pixel.Accent) || label.X <= set.X {
t.Fatal("the fieldset has no legend, or its fields sit on its edge")
}
}
func TestColorSlotPicks(t *testing.T) {
slot, changes := pixel.Slot(3), 0
tt := view(400, 200, nil, func(c *ui.Context) {
if input.ColorSlot(c, "Paint", &slot, input.ColorSlotOptions{}).Changed {
changes++
}
})
at(t, tt, "Paint", 6, 12+6)
if slot != pixel.Slot(8) {
t.Fatalf("a click on the ninth cell gave %v", slot)
}
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyRight)
if slot != pixel.Slot(1) || changes != 3 {
t.Fatalf("Up, Right gave %v after %d changes", slot, changes)
}
}
func TestJoystickDragsNudgesAndSprings(t *testing.T) {
clk := &clock{time.Unix(3_000_000, 0)}
var x, y float64
tt := view(400, 200, clk, func(c *ui.Context) {
r := input.Joystick(c, "Stick", input.JoystickOptions{})
x, y = r.X, r.Y
})
r := find(t, tt, "Stick")
cx, cy := r.X+r.W/2, r.Y+r.H/2
tt.Press(cx, cy)
tt.Move(cx+60, cy)
if math.Abs(x-1) > 1e-9 || y != 0 {
t.Fatalf("dragging far right gave %v, %v", x, y)
}
tt.Move(cx, cy-12)
if math.Abs(y+0.5) > 1e-9 {
t.Fatalf("dragging up half way gave y %v", y)
}
tt.Release(cx, cy-12)
if x != 0 || y != 0 {
t.Fatal("letting go did not spring back")
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
if y != 0.5 {
t.Fatalf("two nudges gave y %v", y)
}
clk.t = clk.t.Add(time.Second)
tt.Frame()
if y != 0 {
t.Fatal("a nudge did not spring back")
}
}
func TestCheatCodeUnlocks(t *testing.T) {
unlocked := 0
tt := view(400, 200, nil, func(c *ui.Context) {
if input.CheatCode(c, "Cheat", input.CheatCodeOptions{}).Unlocked {
unlocked++
}
})
click(t, tt, "Cheat")
r := find(t, tt, "Cheat")
if has(slots(t, tt.Image(), r), pixel.Accent) {
t.Fatal("an empty trail is lit")
}
for _, k := range []ui.Key{ui.KeyUp, ui.KeyUp, ui.KeyUp, ui.KeyDown, ui.KeyDown, ui.KeyLeft, ui.KeyRight, ui.KeyLeft, ui.KeyRight, ui.KeyB} {
tt.Key(0, k)
}
if unlocked != 0 || !has(slots(t, tt.Image(), r), pixel.Accent) {
t.Fatal("unlocked early, or the trail is not lit")
}
tt.Key(0, ui.KeyA)
if unlocked != 1 || !has(slots(t, tt.Image(), r), pixel.Good) {
t.Fatal("the code did not unlock")
}
}
func TestCheatCodeBreaksOnOtherKeys(t *testing.T) {
unlocked := 0
tt := view(400, 200, nil, func(c *ui.Context) {
if input.CheatCode(c, "Cheat", input.CheatCodeOptions{Sequence: []ui.Key{ui.KeyA, ui.KeyB}}).Unlocked {
unlocked++
}
})
click(t, tt, "Cheat")
r := find(t, tt, "Cheat")
tt.Key(0, ui.KeyA)
tt.Key(0, ui.KeyX)
if !has(slots(t, tt.Image(), r), pixel.Faint) {
t.Fatal("the wrong key is not in the trail")
}
tt.Key(0, ui.KeyB)
if unlocked != 0 {
t.Fatal("A, X, B unlocked A, B")
}
tt.Key(0, ui.KeyA)
tt.Key(0, ui.KeyB)
if unlocked != 1 {
t.Fatal("A, B did not unlock")
}
}
func TestSlotPickerSpins(t *testing.T) {
reel, changes := 0, 0
tt := view(400, 200, nil, func(c *ui.Context) {
if input.SlotPicker(c, "Reel", &reel, []string{"Cherry", "Bell", "Seven"}, input.SlotPickerOptions{}).Changed {
changes++
}
})
click(t, tt, "Reel")
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyUp)
if reel != 2 || changes != 3 {
t.Fatalf("Down, Up, Up gave %d after %d changes", reel, changes)
}
r := find(t, tt, "Reel")
tt.Scroll(r.X+r.W/2, r.Y+r.H/2, 0, 2*2*pixel.LineHeight)
if reel != 1 {
t.Fatalf("two rows of wheel gave %d", reel)
}
tt.Press(r.X+r.W/2, r.Y+r.H/2)
tt.Move(r.X+r.W/2, r.Y+r.H/2+2*pixel.LineHeight+4)
tt.Release(r.X+r.W/2, r.Y+r.H/2+2*pixel.LineHeight+4)
if reel != 0 {
t.Fatalf("dragging a row down gave %d", reel)
}
}
func TestGridCopyFollowsTheLocale(t *testing.T) {
render := func(l pixel.Locale) (end, banner []pixel.Slot) {
name := ""
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Locale: l})
input.NameEntry(c, "Initials", &name, input.NameEntryOptions{})
input.CheatCode(c, "Cheat", input.CheatCodeOptions{Sequence: []ui.Key{ui.KeyA}})
}, 400, 300)
click(t, tt, "Cheat")
tt.Key(0, ui.KeyA)
g, ch := find(t, tt, "Initials"), find(t, tt, "Cheat")
end = slots(t, tt.Image(), ui.Rect{X: g.X + g.W - 28, Y: g.Y + g.H - 32, W: 28, H: 32})
banner = slots(t, tt.Image(), ui.Rect{X: ch.X, Y: ch.Y + 28, W: ch.W, H: ch.H - 28})
return end, banner
}
enEnd, enBanner := render(pixel.En)
zhEnd, zhBanner := render(pixel.Zh)
if equal(enEnd, zhEnd) || equal(enBanner, zhBanner) {
t.Fatal("END or the unlock banner ignores the locale")
}
}
+99
View File
@@ -0,0 +1,99 @@
package input
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type HoldButtonOptions struct {
// Duration is the hold needed; zero means one second.
Duration time.Duration
// Danger fills in Bad instead of Accent.
Danger bool
Disabled bool
}
type HoldButtonResult struct {
Element *ui.Element
Confirmed bool
}
type holdButtonKey struct{}
// holding is a hold's start and its last key repeat.
type holding struct {
since, key time.Time
done bool
}
// repeatGap outlasts a key's delay before it repeats.
const repeatGap = 600 * time.Millisecond
// HoldButton confirms only when held; its fill grows stepwise.
func HoldButton(c *ui.Context, text string, o HoldButtonOptions) HoldButtonResult {
k := pixel.K(c)
d := o.Duration
if d < 0 {
panic(fmt.Sprintf("input: HoldButton Duration %v", d))
}
if d == 0 {
d = time.Second
}
shell, base, st := pixel.Shell(c, func() *ui.Element {
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 {
*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)
if enter || space {
if now.Sub(h.key) > repeatGap {
h.since = now
}
h.key = now
}
keyed := now.Sub(h.key) <= repeatGap
var held time.Duration
switch {
case st.Press:
if h.since.IsZero() {
h.since = now
}
held = now.Sub(h.since)
case keyed:
held = h.key.Sub(h.since)
default:
*h = holding{}
}
res := HoldButtonResult{Element: shell}
if held >= d && !h.done {
h.done, res.Confirmed = true, true
slog.Debug("8bit: hold confirmed", "label", text)
}
if st.Press || keyed {
k.Tick(c, 30)
}
steps := min(8, int(held*8/d))
fill := pixel.Accent
if o.Danger {
fill = pixel.Bad
}
shell.Size(k.D(pixel.Measure(text)+16), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
ink, dy := face(g, Normal, st, false)
w := (g.W - 2) * steps / 8
g.Rect(1, 1+dy, w, g.H-2-dy, fill)
g.Text(8, 2+dy, text, ink, 1)
g.Clip(1, 0, w, g.H, func() { g.Text(8, 2+dy, text, pixel.AccentInk, 1) })
finish(g, st)
})
return res
}
+85
View File
@@ -0,0 +1,85 @@
package input
import (
"fmt"
"math"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type JoystickOptions struct {
Disabled bool
}
type JoystickResult struct {
Element *ui.Element
// X and Y are in −1…1; Y grows downward.
X, Y float64
Moved bool
}
type stickKey struct{}
// stick is the knob and its last key nudge.
type stick struct {
x, y float64
key time.Time
}
const (
stickSize = 40
// stickReach is how far the knob travels, in dots.
stickReach = 12
)
// Joystick drags a knob in a ring; arrows nudge it.
func Joystick(c *ui.Context, text string, o JoystickOptions) JoystickResult {
k := pixel.K(c)
shell, base, st := pixel.Shell(c, func() *ui.Element {
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()
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) {
s.x, s.y, s.key = s.x+m.dx, s.y+m.dy, now
}
}
_, _, held := base.Dragged()
switch {
case held && !o.Disabled:
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:
s.x, s.y = 0, 0
default:
k.Tick(c, 10)
}
if l := math.Hypot(s.x, s.y); l > 1 {
s.x, s.y = s.x/l, s.y/l
}
base.Description(fmt.Sprintf("%.2f, %.2f", s.x, s.y))
x, y := stickSize/2+int(math.Round(s.x*stickReach)), stickSize/2+int(math.Round(s.y*stickReach))
shell.Size(k.D(stickSize), k.D(stickSize)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
const m = stickSize / 2
g.Disc(m, m, m-1, pixel.Edge)
g.Disc(m, m, m-2, pixel.Well)
g.Dither(m-2, 4, 4, stickSize-8, pixel.Well, pixel.Shade, 4)
g.Dither(4, m-2, stickSize-8, 4, pixel.Well, pixel.Shade, 4)
g.Disc(x+1, y+1, 7, pixel.Shade)
g.Disc(x, y, 7, pixel.Edge)
g.Disc(x, y, 6, pixel.Accent)
g.Disc(x-2, y-2, 2, pixel.AccentLit)
finish(g, st)
})
return JoystickResult{Element: shell, X: s.x, Y: s.y, Moved: s.x != ox || s.y != oy}
}
+55
View File
@@ -0,0 +1,55 @@
package input
import (
"math"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type KnobOptions struct {
// Min and Max are zero for 0 to 1.
Min, Max float64
// Step snaps the value; zero means none.
Step float64
Disabled bool
}
type KnobResult = SliderResult
type knobKey struct{}
// Knob turns through 16 angles by vertical drag or arrows.
func Knob(c *ui.Context, text string, value *float64, o KnobOptions) KnobResult {
k := pixel.K(c)
lo, hi := bounds(o.Min, o.Max)
r := rail{lo: lo, hi: hi, step: o.Step}
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(text).Description(fmtValue(*value)).Disabled(o.Disabled)
})
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 _, dy, held := base.Dragged(); held {
*raw = max(lo, min(hi, *raw-float64(dy/k.Dot())*(hi-lo)/64))
set(*raw)
} else {
*raw = *value
}
r.keys(base, value, set)
}
shell.Size(k.D(24), k.D(24)).Draw(func(p *ui.Painter, rc ui.Rect) {
g := k.Canvas(p, rc)
g.Disc(12, 12, 11, pixel.Edge)
g.Disc(12, 12, 10, pixel.Shade)
g.Disc(11, 11, 9, pixel.Face)
a := (-135 + 18*math.Round((*value-lo)/(hi-lo)*15)) * math.Pi / 180
x, y := 12+int(math.Round(7*math.Sin(a))), 12-int(math.Round(7*math.Cos(a)))
g.Line(12, 12, x, y, pixel.Accent)
g.Dot(x, y, pixel.Ink)
finish(g, st)
})
return report(shell, *value != old, "knob", text, *value)
}
+106
View File
@@ -0,0 +1,106 @@
package input
import (
"log/slog"
"slices"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type MultiSelectOptions struct {
// Width is in dots; zero means 160.
Width int
Disabled bool
}
type MultiSelectResult struct {
Element *ui.Element
Changed bool
}
type multiKey struct{}
// 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 })
if o.Disabled {
// Closing on disable must not return focus to it.
if *open {
c.Root().Focus()
}
*open = false
}
if base.Clicked() || !*open && base.Shortcut(0, ui.KeyDown) {
*open = !*open
}
shell.Size(k.D(orDef("MultiSelect Width", o.Width, 160)), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
ink, dy := face(g, Normal, st, *open)
if len(*chosen) == 0 {
g.Text(6, 2+dy, text, pixel.Faint, 1)
}
tags(g, *chosen, 3, 2+dy, g.W-14)
g.Sprite(g.W-10, (g.H-tinyDown.H)/2+dy, tinyDown, 0, ink, 1)
finish(g, st)
})
res := MultiSelectResult{Element: shell}
ui.PopoverBase(c, base, open, func(panel *ui.Element) {
menu(c, panel, func() {
col := ui.Column(c).Gap(k.D(1)).FocusGroup(ui.Vertical)
col.Children(func() {
for i, opt := range options {
on := slices.Contains(*chosen, opt)
item, cb, cst := pixel.Shell(c, func() *ui.Element { return ui.CheckboxBase(c, &on).Label(opt) })
if i == 0 {
cb.AutoFocus()
}
if cb.Changed() {
res.Changed = true
if on {
*chosen = append(*chosen, opt)
} else {
*chosen = slices.DeleteFunc(*chosen, func(s string) bool { return s == opt })
}
}
item.Size(k.D(14+pixel.Measure(opt)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
var mark *pixel.Sprite
if on {
mark = check
}
box(k.Canvas(p, r), opt, mark, cst)
})
}
})
})
})
if res.Changed {
slog.Debug("8bit: multiselect", "label", text, "chosen", *chosen)
}
return res
}
// tags draws chips left to right; +N counts the overflow.
func tags(g pixel.Canvas, all []string, x, y, right int) {
for i, t := range all {
w := pixel.Measure(t) + 6
room := right
if i < len(all)-1 {
room -= pixel.MicroWidth("+"+strconv.Itoa(len(all)-i-1)) + 2
}
if x+w > room {
g.Micro(x, y+5, "+"+strconv.Itoa(len(all)-i), pixel.Ink, 1)
return
}
g.Frame(x, y, w, pixel.LineHeight, pixel.Notched, pixel.Lit)
g.Text(x+3, y, t, pixel.Ink, 1)
x += w + 2
}
}
+97
View File
@@ -0,0 +1,97 @@
package input
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type NameEntryOptions struct {
Disabled bool
}
type NameEntryResult struct {
Element *ui.Element
Changed, Confirmed bool
}
type nameKey struct{}
const (
nameLen = 3
// The grid ends with DEL and END after A–Z.
nameDel, nameEnd = 26, 27
)
// NameEntry spells arcade initials from a letter grid; END confirms.
func NameEntry(c *ui.Context, text string, name *string, o NameEntryOptions) NameEntryResult {
k := pixel.K(c)
col := ui.Column(c).Gap(k.D(6)).AlignItems(ui.Center)
cur := ui.Local(col, nameKey{}, func() int { return 0 })
res := NameEntryResult{Element: col}
col.Children(func() {
ui.Box(c).Size(k.D(nameLen*16-4), k.D(16)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range nameLen {
x := i * 16
if i < len(*name) {
l := (*name)[i : i+1]
g.Text(x+(12-pixel.Measure(l))/2, 0, l, pixel.Accent, 1)
}
ink := pixel.Edge
if i == len(*name) {
ink = pixel.Accent
}
g.Rect(x, 14, 12, 2, ink)
}
})
shell, base, st, picked := pad(c, text, 7, nameEnd+1, 14, pixel.LineHeight, cur, o.Disabled)
switch {
case picked == nameDel && len(*name) > 0:
*name, res.Changed = (*name)[:len(*name)-1], true
case picked == nameEnd && len(*name) > 0:
res.Confirmed = true
case picked >= 0 && picked < nameDel && len(*name) < nameLen:
*name, res.Changed = *name+string(rune('A'+picked)), true
if len(*name) == nameLen {
*cur = nameEnd
}
}
base.Description(*name)
focused := base.Focused()
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range nameEnd + 1 {
x, y := i%7*14, i/7*pixel.LineHeight
if focused && i == *cur {
cursor(g, x, y, 14, pixel.LineHeight)
}
switch i {
case nameDel:
caption(g, x, y, k.Msg("del"), pixel.Faint)
case nameEnd:
caption(g, x, y, k.Msg("end"), pixel.Accent)
default:
l := string(rune('A' + i))
g.Text(x+(14-pixel.Measure(l))/2, y, l, pixel.Ink, 1)
}
}
finish(g, st)
})
})
if res.Confirmed {
slog.Debug("8bit: name entered", "label", text, "name", *name)
}
return res
}
// caption centers a cell's word, in micro when possible.
func caption(g pixel.Canvas, x, y int, s string, ink pixel.Slot) {
if pixel.MicroOK(s) {
g.Micro(x+(14-pixel.MicroWidth(s))/2, y+5, s, ink, 1)
return
}
g.Text(x+(14-pixel.Measure(s))/2, y, s, ink, 1)
}
+47
View File
@@ -0,0 +1,47 @@
package input
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// pad is a focusable grid returning the picked cell.
func pad(c *ui.Context, text string, cols, n, cw, ch int, cur *int, disabled bool) (shell, base *ui.Element, st pixel.State, picked int) {
k := pixel.K(c)
shell, base, st = pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(disabled)
})
shell.Size(k.D(cols*cw), k.D((n+cols-1)/cols*ch))
for _, m := range []struct {
key ui.Key
to func() int
}{
{ui.KeyLeft, func() int { return *cur - 1 }}, {ui.KeyRight, func() int { return *cur + 1 }},
{ui.KeyUp, func() int { return *cur - cols }}, {ui.KeyDown, func() int { return *cur + cols }},
{ui.KeyHome, func() int { return 0 }}, {ui.KeyEnd, func() int { return n - 1 }},
} {
if to := m.to(); base.Shortcut(0, m.key) && to >= 0 && to < n {
*cur = to
}
}
picked = -1
enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace)
if enter || space {
picked = *cur
}
// Enter and Space are taken, so clicks come from pointers.
if base.Clicked() {
x, y, _ := base.PointerPosition()
if col, i := int(x/k.D(cw)), int(y/k.D(ch))*cols+int(x/k.D(cw)); x >= 0 && y >= 0 && col < cols && i < n {
*cur, picked = i, i
}
}
return shell, base, st, picked
}
// cursor lights a cell with an Accent ring.
func cursor(g pixel.Canvas, x, y, w, h int) {
g.Rect(x, y, w, h, pixel.Accent)
g.Rect(x+1, y+1, w-2, h-2, pixel.Lit)
}
+290
View File
@@ -0,0 +1,290 @@
package input_test
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestRadioGroupArrowsChooseAfterAClick(t *testing.T) {
season := 0
tt := view(400, 200, nil, func(c *ui.Context) {
input.RadioGroup(c, []string{"Spring", "Summer", "Autumn"}, &season, input.RadioGroupOptions{})
})
click(t, tt, "Autumn")
tt.Key(0, ui.KeyUp)
if season != 1 {
t.Fatalf("Up after a click chose %d, want 1", season)
}
// A press dragged off a radio chooses nothing.
r := find(t, tt, "Spring")
tt.Press(r.X+4, r.Y+r.H/2)
tt.Move(r.X+4, r.Y+200)
tt.Release(r.X+4, r.Y+200)
if season != 1 {
t.Fatalf("a press dragged off chose %d", season)
}
}
func TestSelectPicksByClickAndKeys(t *testing.T) {
class, changes, disabled := "Knight", 0, false
tt := view(400, 300, nil, func(c *ui.Context) {
if input.Select(c, "Class", &class, []string{"Knight", "Mage", "Thief"}, input.SelectOptions{Disabled: disabled}).Changed {
changes++
}
})
click(t, tt, "Class")
mage := find(t, tt, "Mage")
if !has(slots(t, tt.Image(), find(t, tt, "Knight")), pixel.Accent) {
t.Fatal("the chosen option is not marked")
}
tt.Move(mage.X+mage.W/2, mage.Y+mage.H/2)
if !has(slots(t, tt.Image(), find(t, tt, "Mage")), pixel.Lit) {
t.Fatal("the option under the pointer is not lit")
}
click(t, tt, "Mage")
if class != "Mage" || changes != 1 || tt.HasText("Thief") {
t.Fatalf("click chose %q with %d changes; popup open %v", class, changes, tt.HasText("Thief"))
}
if _, ok := tt.Find("Thief"); ok {
t.Fatal("the popup stayed open")
}
tt.Key(0, ui.KeyTab)
tt.Key(ui.Shift, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if class != "Thief" || changes != 2 {
t.Fatalf("Down, Down, Enter chose %q", class)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEscape)
if _, ok := tt.Find("Mage"); ok || !tt.Focused("Class") {
t.Fatal("Escape left the popup open or the focus elsewhere")
}
disabled = true
tt.Frame()
click(t, tt, "Class")
if _, ok := tt.Find("Mage"); ok {
t.Fatal("a disabled select opened")
}
}
func TestComboboxReachesEveryOption(t *testing.T) {
v := ""
letters := []string{"A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9"}
tt := view(400, 400, nil, func(c *ui.Context) {
input.Combobox(c, "Pick", &v, letters, input.ComboboxOptions{})
})
click(t, tt, "Pick")
tt.Key(0, ui.KeyDown)
if _, ok := tt.Find("A9"); ok {
t.Fatal("more than eight rows show")
}
// A resting pointer must not steal the light.
a1 := find(t, tt, "A1")
tt.Move(a1.X+a1.W/2, a1.Y+a1.H/2)
for range 8 {
tt.Key(0, ui.KeyDown)
}
_, first := tt.Find("A1")
if _, last := tt.Find("A9"); first || !last {
t.Fatal("the rows did not follow the lit option")
}
tt.Key(0, ui.KeyEnter)
if v != "A9" {
t.Fatalf("eight Downs and Enter took %q", v)
}
tt.Key(0, ui.KeyDown)
// With the pointer on the first row, the wheel moves.
r := find(t, tt, "A1")
tt.Move(r.X+r.W/2, r.Y+r.H/2)
tt.Scroll(r.X+r.W/2, r.Y+r.H/2, 0, 4*2*pixel.LineHeight)
tt.Key(0, ui.KeyEnter)
if v != "A5" {
t.Fatalf("four rows of wheel then Enter took %q", v)
}
}
func TestBadOptionsPanic(t *testing.T) {
for name, build := range map[string]func(c *ui.Context){
"Select Width": func(c *ui.Context) {
s := "A"
input.Select(c, "S", &s, []string{"A"}, input.SelectOptions{Width: -1})
},
"Knob Step": func(c *ui.Context) {
v := 0.5
input.Knob(c, "K", &v, input.KnobOptions{Step: -0.1})
},
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: a negative value did not panic", name)
}
}()
view(200, 100, nil, build)
}()
}
}
func TestMultiSelectChecksAndShowsTags(t *testing.T) {
var gear []string
changes := 0
tt := view(400, 300, nil, func(c *ui.Context) {
if input.MultiSelect(c, "Gear", &gear, []string{"Sword", "Shield", "Potion"}, input.MultiSelectOptions{}).Changed {
changes++
}
})
trigger := find(t, tt, "Gear")
empty := slots(t, tt.Image(), trigger)
click(t, tt, "Gear")
click(t, tt, "Shield")
click(t, tt, "Potion")
click(t, tt, "Shield")
if !slices.Equal(gear, []string{"Potion"}) || changes != 3 {
t.Fatalf("checks left %q after %d changes", gear, changes)
}
tt.Key(0, ui.KeyEscape)
if _, ok := tt.Find("Sword"); ok || !tt.Focused("Gear") {
t.Fatal("Escape left the popup open or the focus in it")
}
if equal(empty, slots(t, tt.Image(), trigger)) {
t.Fatal("the chosen option shows no tag")
}
tt.Key(0, ui.KeyDown)
if !tt.Focused("Sword") {
t.Fatal("opening did not focus the first option")
}
tt.Key(0, ui.KeySpace)
if !slices.Contains(gear, "Sword") {
t.Fatalf("Space left %q", gear)
}
}
func TestComboboxFiltersAndChooses(t *testing.T) {
fruit := ""
chosen := 0
tt := view(400, 300, nil, func(c *ui.Context) {
if input.Combobox(c, "Fruit", &fruit, []string{"Apple", "Apricot", "Banana", "Cherry"}, input.ComboboxOptions{}).Chosen {
chosen++
}
input.Button(c, "after", input.ButtonOptions{})
})
click(t, tt, "Fruit")
tt.Type("ap")
if _, ok := tt.Find("Banana"); ok {
t.Fatal("Banana does not match ap")
}
find(t, tt, "Apricot")
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if fruit != "Apricot" || chosen != 1 {
t.Fatalf("Down, Enter took %q", fruit)
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("b")
click(t, tt, "Banana")
if fruit != "Banana" || !tt.Focused("Fruit") {
t.Fatalf("a click took %q; focus kept %v", fruit, tt.Focused("Fruit"))
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("c")
find(t, tt, "Cherry")
tt.Key(0, ui.KeyEscape)
if _, ok := tt.Find("Cherry"); ok {
t.Fatal("Escape left the list open")
}
tt.Type("h")
find(t, tt, "Cherry")
tt.Key(0, ui.KeyTab)
if _, ok := tt.Find("Cherry"); ok || !tt.Focused("after") {
t.Fatal("leaving the field left the list open")
}
}
func TestAutocompleteGhostsAndAccepts(t *testing.T) {
monster, accepted := "", 0
tt := view(400, 200, nil, func(c *ui.Context) {
if input.Autocomplete(c, "Monster", &monster, []string{"Dragon", "Drake", "Dungeon"}, input.AutocompleteOptions{}).Accepted {
accepted++
}
input.Button(c, "after", input.ButtonOptions{})
})
click(t, tt, "Monster")
tt.Type("zz")
if has(slots(t, tt.Image(), find(t, tt, "Monster")), pixel.Faint) {
t.Fatal("a ghost shows without a completion")
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("dra")
if !has(slots(t, tt.Image(), find(t, tt, "Monster")), pixel.Faint) {
t.Fatal("no ghost completion")
}
tt.Key(0, ui.KeyTab)
if monster != "Dragon" || accepted != 1 || !tt.Focused("Monster") {
t.Fatalf("Tab took %q, focus kept %v", monster, tt.Focused("Monster"))
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("dun")
tt.Key(0, ui.KeyEnter)
if monster != "Dungeon" || accepted != 2 {
t.Fatalf("Enter took %q", monster)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("after") {
t.Fatal("Tab without a completion kept the focus")
}
}
func TestTokenFieldAddsAndRemoves(t *testing.T) {
var tags []string
tt := view(400, 200, nil, func(c *ui.Context) {
input.TokenField(c, "Tags", &tags, input.TokenFieldOptions{})
})
click(t, tt, "Tags")
tt.Type("fire")
tt.Key(0, ui.KeyEnter)
tt.Type("ice,wind,")
tt.Type("fire")
tt.Key(0, ui.KeyEnter)
if !slices.Equal(tags, []string{"fire", "ice", "wind"}) {
t.Fatalf("Enter and commas gave %q", tags)
}
tt.Type("x")
tt.Key(0, ui.KeyBackspace)
tt.Key(0, ui.KeyBackspace)
if !slices.Equal(tags, []string{"fire", "ice"}) {
t.Fatalf("Backspace on empty gave %q", tags)
}
click(t, tt, "REMOVE fire")
if !slices.Equal(tags, []string{"ice"}) {
t.Fatalf("the × gave %q", tags)
}
}
func TestSegmentedClicksAndArrows(t *testing.T) {
mode, changes := 0, 0
tt := view(400, 200, nil, func(c *ui.Context) {
if input.Segmented(c, "Mode", []string{"EASY", "NORMAL", "HARD"}, &mode, input.SegmentedOptions{}).Changed {
changes++
}
})
click(t, tt, "HARD")
tt.Key(0, ui.KeyLeft)
if mode != 1 || changes != 2 {
t.Fatalf("click and Left gave %d after %d changes", mode, changes)
}
// One dot in from each segment's corner is its fill.
fill := func(l string) pixel.Slot {
r := find(t, tt, l)
return slots(t, tt.Image(), ui.Rect{X: r.X + 4, Y: r.Y + 4, W: 2, H: 2})[0]
}
if fill("NORMAL") != pixel.Accent || fill("HARD") == pixel.Accent {
t.Fatal("the chosen segment is not the Accent one")
}
}
+82
View File
@@ -0,0 +1,82 @@
package input
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PinInputOptions struct {
// Length is the digit count; zero means 4.
Length int
Disabled bool
}
type PinInputResult struct {
Element *ui.Element
// Complete reports the last box filled.
Changed, Complete bool
}
type pinKey struct{}
const pinBox, pinGap = 12, 4
// PinInput types digits into boxes; Backspace goes back, paste fills.
func PinInput(c *ui.Context, text string, value *string, o PinInputOptions) PinInputResult {
k := pixel.K(c)
n := orDef("PinInput Length", o.Length, 4)
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 })
put := func(s string) {
for _, r := range s {
if r >= '0' && r <= '9' && len(*value) < n {
*value += string(r)
*changed = true
}
}
}
base.HandleInput(func(ev ui.InputEvent) bool {
switch {
case ev.Kind == ui.InputText:
put(ev.Text)
case ev.Kind == ui.InputCommand && ev.Text == "paste":
put(c.ReadClipboard())
case ev.Kind == ui.InputKeyDown && ev.Key == ui.KeyBackspace && ev.Mods == 0:
if len(*value) > 0 {
*value, *changed = (*value)[:len(*value)-1], true
}
default:
return false
}
return true
})
h := pixel.LineHeight + 4
base.TextCaret(ui.Rect{X: k.D(len(*value) * (pinBox + pinGap)), W: k.D(1), H: k.D(h)})
res := PinInputResult{Element: shell, Changed: *changed, Complete: *changed && len(*value) == n}
*changed = false
focused := base.Focused()
shell.Size(k.D(n*(pinBox+pinGap)-pinGap), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range n {
x := i * (pinBox + pinGap)
g.Frame(x, 0, pinBox, h, pixel.Sunken, pixel.Well)
switch {
case i < len(*value):
d := (*value)[i : i+1]
g.Text(x+(pinBox-pixel.Measure(d))/2, 2, d, pixel.Ink, 1)
case i == len(*value) && focused:
g.HLine(x+2, h-4, pinBox-4, pixel.Accent)
}
}
finish(g, st)
})
if res.Complete {
slog.Debug("8bit: pin complete", "label", text)
}
return res
}
+266
View File
@@ -0,0 +1,266 @@
package input_test
import (
"math"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// at clicks dot x, y of the element labeled l.
func at(t *testing.T, tt *ui.Tester, l string, x, y int) {
t.Helper()
r := find(t, tt, l)
tt.ClickAt(r.X+float32(2*x), r.Y+float32(2*y))
}
func TestRatingClicksArrowsAndClears(t *testing.T) {
stars, hearts, changes := 0, 0, 0
tt := view(400, 200, nil, func(c *ui.Context) {
if input.Rating(c, "Stars", &stars, input.RatingOptions{}).Changed {
changes++
}
input.Rating(c, "Love", &hearts, input.RatingOptions{Max: 3, Hearts: true})
})
click(t, tt, "3/5")
click(t, tt, "3/5")
if stars != 0 {
t.Fatalf("clicking the rating again left %d", stars)
}
click(t, tt, "4/5")
tt.Key(0, ui.KeyRight)
if stars != 5 || changes != 4 {
t.Fatalf("click and Right gave %d after %d changes", stars, changes)
}
tt.Key(0, ui.KeyHome)
if stars != 1 {
t.Fatalf("Home gave %d", stars)
}
// Only a filled mark has an Accent middle dot.
lit := func(l string) bool {
r := find(t, tt, l)
return slots(t, tt.Image(), ui.Rect{X: r.X + r.W/2 - 1, Y: r.Y + r.H/2 - 1, W: 2, H: 2})[0] == pixel.Accent
}
if !lit("1/5") || lit("2/5") {
t.Fatal("marks do not show the rating")
}
faint := func(l string) (out []bool) {
for _, s := range slots(t, tt.Image(), find(t, tt, l)) {
out = append(out, s == pixel.Faint)
}
return out
}
if equal(faint("3/5"), faint("3/3")) {
t.Fatal("hearts look like stars")
}
}
func TestRatingNavigationNeverClears(t *testing.T) {
one, five := 1, 1
tt := view(400, 200, nil, func(c *ui.Context) {
input.Rating(c, "One", &one, input.RatingOptions{Max: 1})
input.Rating(c, "Five", &five, input.RatingOptions{})
})
click(t, tt, "1/1")
click(t, tt, "1/1")
tt.Key(0, ui.KeyRight)
if one != 1 {
t.Fatalf("Right on a lone mark left %d", one)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyHome)
if five != 1 {
t.Fatalf("Home onto the chosen mark left %d", five)
}
}
func TestKnobDragsKeysAndSnapsToSixteenAngles(t *testing.T) {
v, w := 0.5, 0.52
tt := view(400, 200, nil, func(c *ui.Context) {
input.Knob(c, "Gain", &v, input.KnobOptions{Step: 0.05})
input.Knob(c, "Twin", &w, input.KnobOptions{})
})
if !equal(ink(t, tt.Image(), find(t, tt, "Gain")), ink(t, tt.Image(), find(t, tt, "Twin"))) {
t.Fatal("0.5 and 0.52 share an angle but look apart")
}
r := find(t, tt, "Gain")
x, y := r.X+r.W/2, r.Y+r.H/2
tt.Press(x, y)
// Each one-dot move is under half a step.
for i := 1; i <= 16; i++ {
tt.Move(x, y-float32(2*i))
}
tt.Release(x, y-32)
if math.Abs(v-0.75) > 1e-9 {
t.Fatalf("dragging up 16 dots gave %v, want 0.75", v)
}
tt.Key(0, ui.KeyDown)
if math.Abs(v-0.7) > 1e-9 {
t.Fatalf("Down gave %v", v)
}
tt.Key(0, ui.KeyEnd)
if v != 1 || equal(ink(t, tt.Image(), find(t, tt, "Gain")), ink(t, tt.Image(), find(t, tt, "Twin"))) {
t.Fatalf("End gave %v, or the pointer did not turn", v)
}
}
func TestPinInputTypesBacksAndPastes(t *testing.T) {
pin, complete := "", 0
tt := view(400, 200, nil, func(c *ui.Context) {
if input.PinInput(c, "PIN", &pin, input.PinInputOptions{}).Complete {
complete++
}
})
click(t, tt, "PIN")
tt.Type("12a3")
tt.Key(0, ui.KeyBackspace)
if pin != "12" {
t.Fatalf("typing and Backspace left %q", pin)
}
tt.SetClipboard("98765")
tt.Command("paste")
if pin != "1298" || complete != 1 {
t.Fatalf("paste left %q, complete %d times", pin, complete)
}
if _, ok := tt.TextCaret(); !ok {
t.Fatal("the boxes take no text from input methods")
}
}
func TestNameEntrySpellsAndConfirms(t *testing.T) {
name, confirmed := "", 0
tt := view(400, 300, nil, func(c *ui.Context) {
if input.NameEntry(c, "Initials", &name, input.NameEntryOptions{}).Confirmed {
confirmed++
}
})
at(t, tt, "Initials", 3*14+7, 7)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeySpace)
if name != "DEL" || confirmed != 0 {
t.Fatalf("spelled %q", name)
}
tt.Key(0, ui.KeyEnter)
if confirmed != 1 {
t.Fatal("the cursor did not jump to END after three letters")
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyEnter)
if name != "DE" {
t.Fatalf("DEL left %q", name)
}
}
func TestVirtualKeyboardTypes(t *testing.T) {
text, sent := "", 0
tt := view(400, 300, nil, func(c *ui.Context) {
if input.VirtualKeyboard(c, "Keys", &text, input.VirtualKeyboardOptions{}).Submitted {
sent++
}
})
at(t, tt, "Keys", 7, 14+7)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if text != "QW" {
t.Fatalf("typed %q", text)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if sent != 1 {
t.Fatal("enter did not submit")
}
}
func TestDPadReportsArms(t *testing.T) {
clk := &clock{time.Unix(3_000_000, 0)}
var got []input.Direction
tt := view(400, 200, clk, func(c *ui.Context) {
if d := input.DPad(c, "Pad", input.DPadOptions{}).Pressed; d != input.None {
got = append(got, d)
}
})
at(t, tt, "Pad", 18, 18)
at(t, tt, "Pad", 18, 4)
tt.Key(0, ui.KeyRight)
// The pointer rests on Up; Enter must not click it.
tt.Key(0, ui.KeyEnter)
if !equal(got, []input.Direction{input.Up, input.Right}) {
t.Fatalf("got %v, want up then right", got)
}
r := find(t, tt, "Pad")
arm := ui.Rect{X: r.X + 2*26, Y: r.Y + 2*14, W: 2, H: 2}
if slots(t, tt.Image(), arm)[0] != pixel.Shade {
t.Fatal("a key press did not light its arm")
}
clk.t = clk.t.Add(200 * time.Millisecond)
tt.Frame()
if slots(t, tt.Image(), arm)[0] != pixel.Face {
t.Fatal("the arm stayed down")
}
}
func TestHoldButtonNeedsTheHold(t *testing.T) {
clk := &clock{time.Unix(3_000_000, 0)}
confirmed := 0
tt := view(400, 200, clk, func(c *ui.Context) {
if input.HoldButton(c, "ERASE", input.HoldButtonOptions{}).Confirmed {
confirmed++
}
})
r := find(t, tt, "ERASE")
step := func(d time.Duration) {
clk.t = clk.t.Add(d)
tt.Frame()
}
tt.Press(r.X+r.W/2, r.Y+r.H/2)
step(500 * time.Millisecond)
if !has(slots(t, tt.Image(), r), pixel.Accent) {
t.Fatal("half a hold shows no fill")
}
tt.Release(r.X+r.W/2, r.Y+r.H/2)
tt.Press(r.X+r.W/2, r.Y+r.H/2)
step(900 * time.Millisecond)
if confirmed != 0 {
t.Fatal("letting go did not restart the hold")
}
step(200 * time.Millisecond)
step(200 * time.Millisecond)
tt.Release(r.X+r.W/2, r.Y+r.H/2)
if confirmed != 1 {
t.Fatalf("a full hold confirmed %d times", confirmed)
}
// Keys: repeats every 50 ms after a 500 ms delay.
tt.Key(0, ui.KeySpace)
step(500 * time.Millisecond)
for range 6 {
tt.Key(0, ui.KeySpace)
step(50 * time.Millisecond)
}
// Released at 750 ms; frames continue past one second.
for range 12 {
step(100 * time.Millisecond)
}
if confirmed != 1 {
t.Fatal("keys let go early still confirmed")
}
tt.Key(0, ui.KeySpace)
for range 25 {
step(50 * time.Millisecond)
tt.Key(0, ui.KeySpace)
}
if confirmed != 2 {
t.Fatalf("a held key confirmed %d times in all", confirmed)
}
}
+3 -3
View File
@@ -41,11 +41,11 @@ func RadioGroup(c *ui.Context, labels []string, selected *int, o RadioGroupOptio
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.CheckboxBase(c, &on).Role(ui.RoleRadio).Label(l).Disabled(o.Disabled)
})
if base.FocusVisible() {
if base.Focused() {
focus = i
}
// Focus moved by arrows chooses; entering by Tab does not.
moved := focus == i && *last >= 0 && *last != i
// Arrow moves show focus and choose; Tab entry does not.
moved := base.FocusVisible() && *last >= 0 && *last != i
if !was && (base.Changed() || moved) {
*selected, res.Changed = i, true
}
+74
View File
@@ -0,0 +1,74 @@
package input
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type RatingOptions struct {
// Max is the number of marks; zero means 5.
Max int
// Hearts draws hearts instead of stars.
Hearts bool
Disabled bool
}
type RatingResult struct {
Element *ui.Element
Changed bool
}
type ratingKey struct{}
// Rating sets *value by click or arrows; reclicking clears.
func Rating(c *ui.Context, text string, value *int, o RatingOptions) RatingResult {
k := pixel.K(c)
n := orDef("Rating Max", o.Max, 5)
full, empty := icon.Star, icon.StarEmpty
if o.Hearts {
full, empty = icon.Heart, icon.HeartEmpty
}
e := ui.Row(c).Gap(k.D(1)).FocusGroup(ui.Horizontal).Role(ui.RoleRadioGroup).Label(text)
last := ui.Local(e, ratingKey{}, func() int { return -1 })
focus := -1
res := RatingResult{Element: e}
e.Children(func() {
for i := 1; i <= n; i++ {
was := *value == i
on := was
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.CheckboxBase(c, &on).Role(ui.RoleRadio).Label(fmt.Sprintf("%d/%d", i, n)).Disabled(o.Disabled)
})
if base.Focused() {
focus = i
}
// Arrow moves show focus and choose; clicks toggle.
switch {
case base.Changed() && was:
*value, res.Changed = 0, true
case base.Changed(), base.FocusVisible() && *last >= 0 && *last != i && !was:
*value, res.Changed = i, true
}
shell.Size(k.D(full.W+2), k.D(full.H+2)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
sp, ink := empty, pixel.Faint
if i <= *value {
sp, ink = full, pixel.Accent
}
g.Sprite(1, 1, sp, 0, ink, 1)
finish(g, st)
})
}
})
*last = focus
e.Description(fmt.Sprintf("%d/%d", *value, n))
if res.Changed {
slog.Debug("8bit: rating", "label", text, "value", *value)
}
return res
}
+57
View File
@@ -0,0 +1,57 @@
package input
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SegmentedOptions struct {
Disabled bool
}
type SegmentedResult struct {
Element *ui.Element
Changed bool
}
// 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))
w := 0
for _, l := range labels {
w += pixel.Measure(l) + 12
}
p.Track.Width(k.D(w)).Label(text).Draw(func(pt *ui.Painter, r ui.Rect) {
g := k.Canvas(pt, r)
g.Frame(0, 0, g.W, g.H, pixel.Notched, pixel.Face)
}).Children(func() {
for i, l := range labels {
shell, _, st := pixel.Shell(c, func() *ui.Element { return p.Segment(i).Label(l).Disabled(o.Disabled) })
shell.Size(k.D(pixel.Measure(l)+12), k.D(pixel.LineHeight+4)).Draw(func(pt *ui.Painter, r ui.Rect) {
g := k.Canvas(pt, r)
ink := pixel.Ink
switch {
case *selected == i:
g.Rect(1, 1, g.W-2, g.H-2, pixel.Accent)
ink = pixel.AccentInk
case st.Hover:
g.Rect(1, 1, g.W-2, g.H-2, pixel.Lit)
}
if i > 0 {
g.VLine(0, 1, g.H-2, pixel.Edge)
}
g.Text(6, 2, l, ink, 1)
finish(g, st)
})
}
})
res := SegmentedResult{Element: p.Track, Changed: p.Track.Changed()}
if res.Changed {
slog.Debug("8bit: segmented", "label", text, "selected", *selected)
}
return res
}
+88
View File
@@ -0,0 +1,88 @@
package input
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SelectOptions struct {
// Width is in dots; zero fits the longest option.
Width int
Disabled bool
}
type SelectResult struct {
Element *ui.Element
Changed bool
}
// Select picks one option from a popup list.
func Select(c *ui.Context, text string, value *string, options []string, o SelectOptions) SelectResult {
k := pixel.K(c)
var sel *ui.SelectParts[string]
shell, base, st := pixel.Shell(c, func() *ui.Element {
sel = ui.SelectBase(c, value)
return sel.Trigger.Label(text).Disabled(o.Disabled)
})
shell.Size(k.D(orDef("Select Width", o.Width, widest(options)+22)), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
ink, dy := face(g, Normal, st, sel.Open() && !o.Disabled)
g.Text(6, 2+dy, *value, ink, 1)
g.Sprite(g.W-10, (g.H-tinyDown.H)/2+dy, tinyDown, 0, ink, 1)
finish(g, st)
})
if !o.Disabled {
sel.Popup(func(panel *ui.Element) {
menu(c, panel, func() {
for _, opt := range options {
item := sel.Item(opt).Label(opt)
row(k, item, opt, item.Highlighted(), opt == *value)
}
})
})
}
res := SelectResult{Element: shell, Changed: base.Changed()}
if res.Changed {
slog.Debug("8bit: select", "label", text, "value", *value)
}
return res
}
// widest is the widest option's width in dots.
func widest(options []string) int {
w := 0
for _, opt := range options {
w = max(w, pixel.Measure(opt))
}
return w
}
// menu frames a popup panel as a game window.
func menu(c *ui.Context, panel *ui.Element, fn func()) {
k := pixel.K(c)
panel.Padding(k.D(3)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
}).Children(func() {
ui.Column(c).AlignItems(ui.Stretch).Children(fn)
})
}
// row draws an option; a hand marks the lit one.
func row(k pixel.Kit, item *ui.Element, text string, lit, chosen bool) {
item.Height(k.D(pixel.LineHeight)).MinWidth(k.D(pixel.Measure(text) + 16)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
ink := pixel.Ink
if chosen {
ink = pixel.Accent
}
if lit {
g.Rect(0, 0, g.W, g.H, pixel.Lit)
g.Sprite(1, (g.H-pixel.Hand.H)/2, pixel.Hand, 0, pixel.Edge, 1)
}
g.Text(13, 0, text, ink, 1)
})
}
+39 -26
View File
@@ -53,16 +53,7 @@ func slide(c *ui.Context, v *float64, r rail) (*ui.Element, pixel.State, bool) {
return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(r.label).Description(fmtValue(*v)).Disabled(r.disabled)
})
old := *v
top := r.hi
if r.step > 0 {
top = r.lo + math.Floor((r.hi-r.lo)/r.step+1e-9)*r.step
}
set := func(x float64) {
if r.step > 0 {
x = r.lo + math.Round((x-r.lo)/r.step)*r.step
}
*v = max(r.lo, min(top, x))
}
set := r.snap(v)
if !r.disabled {
if _, _, held := base.Dragged(); held {
px, py, _ := base.PointerPosition()
@@ -73,26 +64,48 @@ func slide(c *ui.Context, v *float64, r rail) (*ui.Element, pixel.State, bool) {
}
set(r.lo + frac*(r.hi-r.lo))
}
step := r.step
if step == 0 {
step = (r.hi - r.lo) / 20
}
for _, kd := range []struct {
key ui.Key
to func() float64
}{
{ui.KeyLeft, func() float64 { return *v - step }}, {ui.KeyDown, func() float64 { return *v - step }},
{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) {
set(kd.to())
}
}
r.keys(base, v, set)
}
return shell, st, *v != old
}
// snap sets *v rounded to steps and kept in range.
func (r rail) snap(v *float64) func(float64) {
if r.step < 0 {
panic(fmt.Sprintf("input: Step %v is negative", r.step))
}
top := r.hi
if r.step > 0 {
top = r.lo + math.Floor((r.hi-r.lo)/r.step+1e-9)*r.step
}
return func(x float64) {
if r.step > 0 {
x = r.lo + math.Round((x-r.lo)/r.step)*r.step
}
*v = max(r.lo, min(top, x))
}
}
// keys step *v by arrows; Home and End jump.
func (r rail) keys(base *ui.Element, v *float64, set func(float64)) {
step := r.step
if step == 0 {
step = (r.hi - r.lo) / 20
}
for _, kd := range []struct {
key ui.Key
to func() float64
}{
{ui.KeyLeft, func() float64 { return *v - step }}, {ui.KeyDown, func() float64 { return *v - step }},
{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) {
set(kd.to())
}
}
}
// bounds are lo and hi; zeros mean 0 to 1.
func bounds(lo, hi float64) (float64, float64) {
if lo == 0 && hi == 0 {
+102
View File
@@ -0,0 +1,102 @@
package input
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SlotPickerOptions struct {
Disabled bool
}
type SlotPickerResult struct {
Element *ui.Element
Changed bool
}
type reelKey struct{}
// reel holds a drag's and the wheel's leftover DIPs.
type reel struct {
drag, wheel float32
changed bool
}
// SlotPicker spins a looping reel; the centered option is chosen.
func SlotPicker(c *ui.Context, text string, value *int, options []string, o SlotPickerOptions) SlotPickerResult {
k := pixel.K(c)
n := len(options)
if *value < 0 || *value >= n {
panic(fmt.Sprintf("input: SlotPicker value %d outside %d options", *value, n))
}
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(text).Description(options[*value]).Disabled(o.Disabled)
})
s := ui.Local(base, reelKey{}, func() reel { return reel{} })
spin := func(d int) {
*value = ((*value+d)%n + n) % n
s.changed = true
}
row := k.D(pixel.LineHeight)
base.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind != ui.InputScroll || o.Disabled {
return false
}
s.wheel += ev.DY
turn(&s.wheel, row, spin)
return true
})
if base.Shortcut(0, ui.KeyDown) {
spin(1)
}
if base.Shortcut(0, ui.KeyUp) {
spin(-1)
}
if _, dy, held := base.Dragged(); held && !o.Disabled {
s.drag += dy
turn(&s.drag, row, func(d int) { spin(-d) })
} else {
s.drag = 0
}
res := SlotPickerResult{Element: shell, Changed: s.changed}
s.changed = false
if res.Changed {
slog.Debug("8bit: slot picker", "label", text, "value", options[*value])
}
off := int(s.drag / k.Dot())
shell.Size(k.D(widest(options)+28), k.D(3*pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
g.Clip(2, 2, g.W-4, g.H-4, func() {
for i := -2; i <= 2; i++ {
ink := pixel.Faint
if i == 0 {
ink = pixel.Ink
}
opt := options[((*value+i)%n+n)%n]
g.Text((g.W-pixel.Measure(opt))/2, 2+pixel.LineHeight*(1+i)+off, opt, ink, 1)
}
})
g.Veil(2, 2, g.W-4, 6, pixel.Shade, 4)
g.Veil(2, g.H-8, g.W-4, 6, pixel.Shade, 4)
mid := 2 + pixel.LineHeight + (pixel.LineHeight-tinyRight.H)/2
g.Sprite(3, mid, tinyRight, 0, pixel.Accent, 1)
g.Sprite(g.W-6, mid, tinyLeft, 0, pixel.Accent, 1)
finish(g, st)
})
return res
}
// turn spends whole rows of *left on step.
func turn(left *float32, row float32, step func(int)) {
for ; *left >= row; *left -= row {
step(1)
}
for ; *left <= -row; *left += row {
step(-1)
}
}
+13 -9
View File
@@ -47,23 +47,27 @@ func field(c *ui.Context, value *string, eo pixel.EditorOptions, width int, bad
})
focus := ed.Focused
e.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
edge := pixel.Edge
if bad != nil && bad() {
edge = pixel.Bad
}
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
g.HLine(1, 0, g.W-2, edge)
g.HLine(1, g.H-1, g.W-2, edge)
g.VLine(0, 1, g.H-2, edge)
g.VLine(g.W-1, 1, g.H-2, edge)
if focus {
g.Checker(0, g.H-2, g.W, 1, pixel.Accent)
}
well(k.Canvas(p, r), edge, focus)
})
return ed, e
}
// well is a field's sunken box; focus dots its foot.
func well(g pixel.Canvas, edge pixel.Slot, focus bool) {
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
g.HLine(1, 0, g.W-2, edge)
g.HLine(1, g.H-1, g.W-2, edge)
g.VLine(0, 1, g.H-2, edge)
g.VLine(g.W-1, 1, g.H-2, edge)
if focus {
g.Checker(0, g.H-2, g.W, 1, pixel.Accent)
}
}
// result wraps an editor's events and logs submits.
func result(e *ui.Element, ed pixel.Edit, label string) TextInputResult {
if ed.Submitted {
+79
View File
@@ -0,0 +1,79 @@
package input
import (
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TokenFieldOptions struct {
Placeholder string
// Width is in dots; zero means 200.
Width int
Disabled bool
}
type TokenFieldResult struct {
Element *ui.Element
Changed bool
}
type tokenKey struct{}
// editorSlot keys the editor so added tags keep its focus.
type editorSlot struct{}
// TokenField shows tokens as tags; Enter or comma adds.
func TokenField(c *ui.Context, text string, tokens *[]string, o TokenFieldOptions) TokenFieldResult {
k := pixel.K(c)
e := ui.Row(c).Wrap().Width(k.D(orDef("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 "" })
res := TokenFieldResult{Element: e}
add := func(s string) {
if s = strings.TrimSpace(s); s != "" && !slices.Contains(*tokens, s) {
*tokens, res.Changed = append(*tokens, s), true
}
}
// A comma typed since the last frame cuts the draft.
if parts := strings.Split(*draft, ","); len(parts) > 1 {
for _, p := range parts[:len(parts)-1] {
add(p)
}
*draft = parts[len(parts)-1]
}
if e.Shortcut(0, ui.KeyEnter) {
add(*draft)
*draft = ""
}
if *draft == "" && len(*tokens) > 0 && e.Shortcut(0, ui.KeyBackspace) {
*tokens, res.Changed = (*tokens)[:len(*tokens)-1], true
}
var ed pixel.Edit
e.Children(func() {
for i := 0; i < len(*tokens); i++ {
tok, removed := (*tokens)[i], false
ui.Box(c).Key(tok).Children(func() {
removed = display.Tag(c, tok, display.TagOptions{Removable: true, Disabled: o.Disabled}).Removed
})
if removed {
*tokens, res.Changed = slices.Delete(*tokens, i, i+1), true
i--
}
}
ui.Box(c).Key(editorSlot{}).Grow(1).MinWidth(k.D(40)).Children(func() {
ed = pixel.Editor(c, draft, pixel.EditorOptions{Label: text, Placeholder: o.Placeholder, Disabled: o.Disabled})
})
})
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)
}
return res
}
+105
View File
@@ -0,0 +1,105 @@
package input
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type VirtualKeyboardOptions struct {
Disabled bool
}
type VirtualKeyboardResult struct {
Element *ui.Element
Changed, Submitted bool
}
type keyboardKey struct{}
// caps are ten keys a row; \b erases, \n enters.
var caps = []rune("1234567890QWERTYUIOPASDFGHJKL\bZXCVBNM- \n")
var (
backKey = pixel.MustSprite(`
..#####
.#....#
#..#.##
.#....#
..#####
`)
enterKey = pixel.MustSprite(`
.....#
.#...#
##...#
######
##....
.#....
`)
)
// VirtualKeyboard types into *value from keys on screen.
func VirtualKeyboard(c *ui.Context, text string, value *string, o VirtualKeyboardOptions) VirtualKeyboardResult {
k := pixel.K(c)
col := ui.Column(c).Gap(k.D(4))
cur := ui.Local(col, keyboardKey{}, func() int { return 0 })
res := VirtualKeyboardResult{Element: col}
col.Children(func() {
ui.Box(c).Size(k.D(140), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
well(g, pixel.Edge, false)
rs := []rune(*value)
for len(rs) > 0 && pixel.Measure(string(rs))+10 > g.W {
rs = rs[1:]
}
g.Text(3, 2, string(rs), pixel.Ink, 1)
g.Rect(4+pixel.Measure(string(rs)), 4, 5, 12, pixel.Accent)
})
shell, base, st, picked := pad(c, text, 10, len(caps), 14, pixel.LineHeight, cur, o.Disabled)
if picked >= 0 {
switch r := caps[picked]; r {
case '\b':
if rs := []rune(*value); len(rs) > 0 {
*value, res.Changed = string(rs[:len(rs)-1]), true
}
case '\n':
res.Submitted = true
default:
*value, res.Changed = *value+string(r), true
}
}
base.Description(*value)
focused := base.Focused()
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, cp := range caps {
x, y := i%10*14, i/10*pixel.LineHeight
fill := pixel.Face
if focused && i == *cur {
fill = pixel.Lit
}
g.Frame(x, y, 13, pixel.LineHeight-1, pixel.Raised, fill)
switch cp {
case '\b':
g.Sprite(x+3, y+5, backKey, 0, pixel.Ink, 1)
case '\n':
g.Sprite(x+4, y+4, enterKey, 0, pixel.Accent, 1)
case ' ':
g.Rect(x+3, y+10, 7, 1, pixel.Ink)
g.Rect(x+3, y+8, 1, 2, pixel.Ink)
g.Rect(x+9, y+8, 1, 2, pixel.Ink)
default:
l := string(cp)
g.Text(x+(13-pixel.Measure(l))/2, y, l, pixel.Ink, 1)
}
}
finish(g, st)
})
})
if res.Submitted {
slog.Debug("8bit: keyboard entered", "label", text)
}
return res
}
+9 -3
View File
@@ -92,11 +92,17 @@ type State struct{ Hover, Press, Focus, Disabled bool }
func Shell(c *ui.Context, base func() *ui.Element) (shell, b *ui.Element, st State) {
shell = ui.Box(c)
shell.Children(func() { b = base().Fill().FocusRing(false) })
st = State{Hover: b.Hovered(), Press: b.Pressed(), Focus: b.FocusVisible(), Disabled: b.IsDisabled()}
off := b.IsDisabled()
// MyGo keeps focus and keys on a newly disabled element.
if off && b.Focused() {
c.Root().Focus()
}
st = State{Hover: b.Hovered(), Press: b.Pressed() && !off, Focus: b.FocusVisible(), Disabled: off}
return shell, b, st
}
var hand = MustSprite(`
// Hand is the pointing cursor of focus and menus.
var Hand = MustSprite(`
.eee......
elllleeee.
elllllllle
@@ -116,7 +122,7 @@ func (g Canvas) FocusMark(x, y, w, h int) {
g.ant(rx, ry+j)
g.ant(rx+rw-1, ry+j)
}
g.Sprite(rx-hand.W-1+g.Step(4)%2, y+(h-hand.H)/2, hand, 0, Edge, 1)
g.Sprite(rx-Hand.W-1+g.Step(4)%2, y+(h-Hand.H)/2, Hand, 0, Edge, 1)
}
func (g Canvas) ant(x, y int) {
+2
View File
@@ -12,6 +12,7 @@ var messages = map[Locale]map[string]string{
"remove": "REMOVE", "online": "ONLINE", "away": "AWAY", "busy": "BUSY", "offline": "OFFLINE",
"gold": "GOLD", "silver": "SILVER", "bronze": "BRONZE",
"common": "COMMON", "uncommon": "UNCOMMON", "rare": "RARE", "epic": "EPIC", "legendary": "LEGENDARY",
"hold": "HOLD TO CONFIRM", "del": "DEL", "end": "END", "lives": "+30 LIVES!",
},
Zh: {
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
@@ -22,6 +23,7 @@ var messages = map[Locale]map[string]string{
"remove": "移除", "online": "在线", "away": "离开", "busy": "忙碌", "offline": "离线",
"gold": "金", "silver": "银", "bronze": "铜",
"common": "普通", "uncommon": "优秀", "rare": "稀有", "epic": "史诗", "legendary": "传说",
"hold": "长按确认", "del": "删", "end": "完", "lives": "命数 +30!",
},
}