From 03b789f533a33b4817534fd4f7601e87daf467f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 20:13:21 -0400 Subject: [PATCH] Add input components 086-105; park focus off disabled controls --- AGENTS.md | 6 +- gallery/app_test.go | 32 +++++ gallery/demos_controls.go | 291 ++++++++++++++++++++++++++++++++++++++ gallery/registry.go | 2 +- input/autocomplete.go | 63 +++++++++ input/cheatcode.go | 118 ++++++++++++++++ input/colorslot.go | 45 ++++++ input/combobox.go | 156 ++++++++++++++++++++ input/disabled_test.go | 171 ++++++++++++++++++++++ input/dpad.go | 116 +++++++++++++++ input/editabletext.go | 83 +++++++++++ input/field.go | 34 +++++ input/fieldset.go | 22 +++ input/form_test.go | 223 +++++++++++++++++++++++++++++ input/holdbutton.go | 99 +++++++++++++ input/joystick.go | 85 +++++++++++ input/knob.go | 55 +++++++ input/multiselect.go | 106 ++++++++++++++ input/nameentry.go | 97 +++++++++++++ input/pad.go | 47 ++++++ input/pick_test.go | 290 +++++++++++++++++++++++++++++++++++++ input/pininput.go | 82 +++++++++++ input/play_test.go | 266 ++++++++++++++++++++++++++++++++++ input/radiogroup.go | 6 +- input/rating.go | 74 ++++++++++ input/segmented.go | 57 ++++++++ input/select.go | 88 ++++++++++++ input/slider.go | 65 +++++---- input/slotpicker.go | 102 +++++++++++++ input/textinput.go | 22 +-- input/tokenfield.go | 79 +++++++++++ input/virtualkeyboard.go | 105 ++++++++++++++ pixel/element.go | 12 +- pixel/messages.go | 2 + 34 files changed, 3058 insertions(+), 43 deletions(-) create mode 100644 gallery/demos_controls.go create mode 100644 input/autocomplete.go create mode 100644 input/cheatcode.go create mode 100644 input/colorslot.go create mode 100644 input/combobox.go create mode 100644 input/disabled_test.go create mode 100644 input/dpad.go create mode 100644 input/editabletext.go create mode 100644 input/field.go create mode 100644 input/fieldset.go create mode 100644 input/form_test.go create mode 100644 input/holdbutton.go create mode 100644 input/joystick.go create mode 100644 input/knob.go create mode 100644 input/multiselect.go create mode 100644 input/nameentry.go create mode 100644 input/pad.go create mode 100644 input/pick_test.go create mode 100644 input/pininput.go create mode 100644 input/play_test.go create mode 100644 input/rating.go create mode 100644 input/segmented.go create mode 100644 input/select.go create mode 100644 input/slotpicker.go create mode 100644 input/tokenfield.go create mode 100644 input/virtualkeyboard.go diff --git a/AGENTS.md b/AGENTS.md index 22838fe..c45f361 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/gallery/app_test.go b/gallery/app_test.go index b8c7ebd..0aee118 100644 --- a/gallery/app_test.go +++ b/gallery/app_test.go @@ -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") + } +} diff --git a/gallery/demos_controls.go b/gallery/demos_controls.go new file mode 100644 index 0000000..623f652 --- /dev/null +++ b/gallery/demos_controls.go @@ -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]) + } + }) +} diff --git a/gallery/registry.go b/gallery/registry.go index 635843e..91e3193 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -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 } diff --git a/input/autocomplete.go b/input/autocomplete.go new file mode 100644 index 0000000..9d00357 --- /dev/null +++ b/input/autocomplete.go @@ -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 "" +} diff --git a/input/cheatcode.go b/input/cheatcode.go new file mode 100644 index 0000000..52e9683 --- /dev/null +++ b/input/cheatcode.go @@ -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) + } +} diff --git a/input/colorslot.go b/input/colorslot.go new file mode 100644 index 0000000..0af449b --- /dev/null +++ b/input/colorslot.go @@ -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 +} diff --git a/input/combobox.go b/input/combobox.go new file mode 100644 index 0000000..a087d93 --- /dev/null +++ b/input/combobox.go @@ -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...) +} diff --git a/input/disabled_test.go b/input/disabled_test.go new file mode 100644 index 0000000..b3da53c --- /dev/null +++ b/input/disabled_test.go @@ -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) + } + }) + } +} diff --git a/input/dpad.go b/input/dpad.go new file mode 100644 index 0000000..1a75686 --- /dev/null +++ b/input/dpad.go @@ -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 +} diff --git a/input/editabletext.go b/input/editabletext.go new file mode 100644 index 0000000..64fcd5d --- /dev/null +++ b/input/editabletext.go @@ -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 +} diff --git a/input/field.go b/input/field.go new file mode 100644 index 0000000..476af8c --- /dev/null +++ b/input/field.go @@ -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) + }) + } + }) +} diff --git a/input/fieldset.go b/input/fieldset.go new file mode 100644 index 0000000..bfbc7cf --- /dev/null +++ b/input/fieldset.go @@ -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) +} diff --git a/input/form_test.go b/input/form_test.go new file mode 100644 index 0000000..ba38a57 --- /dev/null +++ b/input/form_test.go @@ -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") + } +} diff --git a/input/holdbutton.go b/input/holdbutton.go new file mode 100644 index 0000000..b3f2cdc --- /dev/null +++ b/input/holdbutton.go @@ -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 +} diff --git a/input/joystick.go b/input/joystick.go new file mode 100644 index 0000000..100ba31 --- /dev/null +++ b/input/joystick.go @@ -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} +} diff --git a/input/knob.go b/input/knob.go new file mode 100644 index 0000000..a18352f --- /dev/null +++ b/input/knob.go @@ -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) +} diff --git a/input/multiselect.go b/input/multiselect.go new file mode 100644 index 0000000..f993d46 --- /dev/null +++ b/input/multiselect.go @@ -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 + } +} diff --git a/input/nameentry.go b/input/nameentry.go new file mode 100644 index 0000000..2c11ccb --- /dev/null +++ b/input/nameentry.go @@ -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) +} diff --git a/input/pad.go b/input/pad.go new file mode 100644 index 0000000..1c38b12 --- /dev/null +++ b/input/pad.go @@ -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) +} diff --git a/input/pick_test.go b/input/pick_test.go new file mode 100644 index 0000000..3cf4475 --- /dev/null +++ b/input/pick_test.go @@ -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") + } +} diff --git a/input/pininput.go b/input/pininput.go new file mode 100644 index 0000000..5dbb3c7 --- /dev/null +++ b/input/pininput.go @@ -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 +} diff --git a/input/play_test.go b/input/play_test.go new file mode 100644 index 0000000..319a024 --- /dev/null +++ b/input/play_test.go @@ -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) + } +} diff --git a/input/radiogroup.go b/input/radiogroup.go index ddc6b77..8ae8034 100644 --- a/input/radiogroup.go +++ b/input/radiogroup.go @@ -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 } diff --git a/input/rating.go b/input/rating.go new file mode 100644 index 0000000..78a1700 --- /dev/null +++ b/input/rating.go @@ -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 +} diff --git a/input/segmented.go b/input/segmented.go new file mode 100644 index 0000000..f6cd439 --- /dev/null +++ b/input/segmented.go @@ -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 +} diff --git a/input/select.go b/input/select.go new file mode 100644 index 0000000..74cd4d0 --- /dev/null +++ b/input/select.go @@ -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) + }) +} diff --git a/input/slider.go b/input/slider.go index 6dc9fb8..bddbf73 100644 --- a/input/slider.go +++ b/input/slider.go @@ -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 { diff --git a/input/slotpicker.go b/input/slotpicker.go new file mode 100644 index 0000000..db019a5 --- /dev/null +++ b/input/slotpicker.go @@ -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) + } +} diff --git a/input/textinput.go b/input/textinput.go index 9ee24b9..0a897bf 100644 --- a/input/textinput.go +++ b/input/textinput.go @@ -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 { diff --git a/input/tokenfield.go b/input/tokenfield.go new file mode 100644 index 0000000..6ea999d --- /dev/null +++ b/input/tokenfield.go @@ -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 +} diff --git a/input/virtualkeyboard.go b/input/virtualkeyboard.go new file mode 100644 index 0000000..a145dd1 --- /dev/null +++ b/input/virtualkeyboard.go @@ -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 +} diff --git a/pixel/element.go b/pixel/element.go index 6302f76..7147196 100644 --- a/pixel/element.go +++ b/pixel/element.go @@ -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) { diff --git a/pixel/messages.go b/pixel/messages.go index d989103..47eb388 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -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!", }, }