Add input components 086-105; park focus off disabled controls
This commit is contained in:
34 files changed
+3058
-43
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var controlEntries = []Entry{
|
||||
{86, "Select", "下拉选择", "input", "The choice and ▼; the list opens in a window with a hand pointing. Arrows and Enter pick.", demoSelect},
|
||||
{87, "MultiSelect", "多选", "input", "A popup of checkboxes; what you pick rides in the trigger as tags.", demoMultiSelect},
|
||||
{88, "Combobox", "组合框", "input", "Type to narrow the list below; Enter or a click takes one.", demoCombobox},
|
||||
{89, "Autocomplete", "自动补全", "input", "The rest of the word waits in grey. Tab or Enter takes it.", demoAutocomplete},
|
||||
{90, "TokenField", "标记输入", "input", "Tags before the caret. Enter or a comma adds; Backspace on empty takes the last back.", demoTokenField},
|
||||
{91, "Segmented", "分段控件", "input", "Joined segments, one lit. Arrows move the light.", demoSegmented},
|
||||
{92, "Rating", "评分", "input", "Stars or hearts. Click or arrows to rate; click the same mark to clear.", demoRating},
|
||||
{93, "Knob", "旋钮", "input", "A rotary knob that clicks through 16 angles. Drag up or down, or use the arrows.", demoKnob},
|
||||
{94, "PinInput", "验证码", "input", "One box per digit: typing moves on, Backspace steps back, paste fills them all.", demoPinInput},
|
||||
{95, "NameEntry", "姓名登记", "input", "HIGH SCORE! Three initials from the letter grid, then END.", demoNameEntry},
|
||||
{96, "VirtualKeyboard", "屏幕键盘", "input", "A keyboard on screen, for pads and touch: letters, digits, space, erase, enter.", demoVirtualKeyboard},
|
||||
{97, "DPad", "方向键", "input", "A cross pad. Press an arm or use the arrow keys.", demoDPad},
|
||||
{98, "HoldButton", "长按按钮", "input", "Hold it down while the bar fills in eight steps. Let go and it starts over.", demoHoldButton},
|
||||
{99, "EditableText", "可编辑文本", "input", "Double click or press Enter to rename. Enter keeps it; Escape takes it back.", demoEditableText},
|
||||
{100, "Field", "表单项", "input", "A label above, a hint below, and a red ! when something is wrong.", demoField},
|
||||
{101, "Fieldset", "字段组", "input", "A framed group of fields under a legend.", demoFieldset},
|
||||
{102, "ColorSlot", "色槽选择", "input", "Pick one of the sixteen palette slots. It follows the palette.", demoColorSlot},
|
||||
{103, "Joystick", "摇杆", "input", "Drag the stick inside its ring; it springs home. Arrows nudge it.", demoJoystick},
|
||||
{104, "CheatCode", "秘籍", "input", "Focus it and enter ↑↑↓↓←→←→BA. You know the one.", demoCheatCode},
|
||||
{105, "SlotPicker", "滚轮选择", "input", "Reels that spin by drag, wheel or arrows. Line up three for the jackpot.", demoSlotPicker},
|
||||
}
|
||||
|
||||
type controlDemo struct {
|
||||
class, item, spell, hero, call, guild string
|
||||
party, tags []string
|
||||
mode, banner, stars, lives int
|
||||
reel1, reel2, reel3 int
|
||||
volume, bass float64
|
||||
pin, initials, message string
|
||||
cape pixel.Slot
|
||||
dir input.Direction
|
||||
}
|
||||
|
||||
func controlState(c *ui.Context) *controlDemo {
|
||||
return ui.Local(c.Root(), demoKey("controls"), func() controlDemo {
|
||||
return controlDemo{class: "Knight", hero: "Pixel Knight", tags: []string{"retro", "pixel"}, mode: 1, stars: 4, lives: 3,
|
||||
volume: 0.7, bass: 0.3, cape: pixel.Accent, reel2: 1, reel3: 2}
|
||||
})
|
||||
}
|
||||
|
||||
func demoSelect(c *ui.Context, log func(string)) {
|
||||
s := controlState(c)
|
||||
if input.Select(c, "Class", &s.class, []string{"Knight", "Mage", "Thief", "Bard"}, input.SelectOptions{}).Changed {
|
||||
log("class " + s.class)
|
||||
}
|
||||
}
|
||||
|
||||
func demoMultiSelect(c *ui.Context, log func(string)) {
|
||||
s := controlState(c)
|
||||
if input.MultiSelect(c, "Party", &s.party, []string{"Warrior", "Healer", "Rogue", "Wizard"}, input.MultiSelectOptions{}).Changed {
|
||||
log(fmt.Sprintf("party %v", s.party))
|
||||
}
|
||||
}
|
||||
|
||||
func demoCombobox(c *ui.Context, log func(string)) {
|
||||
s := controlState(c)
|
||||
items := []string{"Potion", "Hi-Potion", "Ether", "Elixir", "Antidote", "Phoenix Down"}
|
||||
r := input.Combobox(c, "Item", &s.item, items, input.ComboboxOptions{Placeholder: "Use an item…", Width: 140})
|
||||
if r.Changed {
|
||||
log("item " + s.item)
|
||||
}
|
||||
if r.Chosen {
|
||||
log("used " + s.item)
|
||||
}
|
||||
}
|
||||
|
||||
func demoAutocomplete(c *ui.Context, log func(string)) {
|
||||
s := controlState(c)
|
||||
spells := []string{"Fire", "Fira", "Firaga", "Blizzard", "Thunder", "Cure", "Curaga"}
|
||||
r := input.Autocomplete(c, "Spell", &s.spell, spells, input.AutocompleteOptions{Placeholder: "Cast…"})
|
||||
if r.Changed {
|
||||
log("spell " + s.spell)
|
||||
}
|
||||
if r.Accepted {
|
||||
log("cast " + s.spell)
|
||||
}
|
||||
if r.Submitted {
|
||||
log("fizzle: " + s.spell)
|
||||
}
|
||||
}
|
||||
|
||||
func demoTokenField(c *ui.Context, log func(string)) {
|
||||
s := controlState(c)
|
||||
if input.TokenField(c, "Tags", &s.tags, input.TokenFieldOptions{Placeholder: "add tag"}).Changed {
|
||||
log(fmt.Sprintf("tags %v", s.tags))
|
||||
}
|
||||
}
|
||||
|
||||
func demoSegmented(c *ui.Context, log func(string)) {
|
||||
s := controlState(c)
|
||||
modes := []string{"EASY", "NORMAL", "HARD"}
|
||||
if input.Segmented(c, "Difficulty", modes, &s.mode, input.SegmentedOptions{}).Changed {
|
||||
log("difficulty " + modes[s.mode])
|
||||
}
|
||||
}
|
||||
|
||||
func demoRating(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := controlState(c)
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
if input.Rating(c, "Dungeon", &s.stars, input.RatingOptions{}).Changed {
|
||||
log(fmt.Sprintf("%d stars", s.stars))
|
||||
}
|
||||
if input.Rating(c, "Lives", &s.lives, input.RatingOptions{Max: 3, Hearts: true}).Changed {
|
||||
log(fmt.Sprintf("%d lives", s.lives))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoKnob(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := controlState(c)
|
||||
ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() {
|
||||
for _, kn := range []struct {
|
||||
name string
|
||||
v *float64
|
||||
}{{"VOL", &s.volume}, {"BASS", &s.bass}} {
|
||||
ui.Column(c).Gap(k.D(2)).AlignItems(ui.Center).Children(func() {
|
||||
if input.Knob(c, kn.name, kn.v, input.KnobOptions{Step: 0.05}).Changed {
|
||||
log(fmt.Sprintf("%s %.2f", kn.name, *kn.v))
|
||||
}
|
||||
pixel.Text(c, fmt.Sprintf("%s %d", kn.name, int(*kn.v*100+0.5)), pixel.TextOptions{Slot: pixel.Faint})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoPinInput(c *ui.Context, log func(string)) {
|
||||
s := controlState(c)
|
||||
r := input.PinInput(c, "Vault code", &s.pin, input.PinInputOptions{})
|
||||
if r.Changed {
|
||||
log(fmt.Sprintf("%d digits", len(s.pin)))
|
||||
}
|
||||
if r.Complete {
|
||||
if s.pin == "0451" {
|
||||
log("ACCESS GRANTED")
|
||||
} else {
|
||||
log("WRONG CODE " + s.pin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func demoNameEntry(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := controlState(c)
|
||||
ui.Column(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
|
||||
pixel.Text(c, "NEW HIGH SCORE 99999", pixel.TextOptions{Slot: pixel.Accent})
|
||||
r := input.NameEntry(c, "Initials", &s.initials, input.NameEntryOptions{})
|
||||
if r.Changed {
|
||||
log("initials " + s.initials)
|
||||
}
|
||||
if r.Confirmed {
|
||||
log("1ST " + s.initials + " 99999")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoVirtualKeyboard(c *ui.Context, log func(string)) {
|
||||
s := controlState(c)
|
||||
r := input.VirtualKeyboard(c, "Message", &s.message, input.VirtualKeyboardOptions{})
|
||||
if r.Changed {
|
||||
log("message " + s.message)
|
||||
}
|
||||
if r.Submitted {
|
||||
log("sent " + s.message)
|
||||
s.message = ""
|
||||
}
|
||||
}
|
||||
|
||||
func demoDPad(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := controlState(c)
|
||||
ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() {
|
||||
if d := input.DPad(c, "Pad", input.DPadOptions{}).Pressed; d != input.None {
|
||||
s.dir = d
|
||||
log(d.String())
|
||||
}
|
||||
pixel.Text(c, "last: "+s.dir.String(), pixel.TextOptions{Slot: pixel.Faint})
|
||||
})
|
||||
}
|
||||
|
||||
func demoHoldButton(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Row(c).Gap(k.D(6)).Children(func() {
|
||||
if input.HoldButton(c, "SKIP CUTSCENE", input.HoldButtonOptions{}).Confirmed {
|
||||
log("cutscene skipped")
|
||||
}
|
||||
if input.HoldButton(c, "DELETE SAVE", input.HoldButtonOptions{Duration: 2 * time.Second, Danger: true}).Confirmed {
|
||||
log("save deleted (not really)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoEditableText(c *ui.Context, log func(string)) {
|
||||
s := controlState(c)
|
||||
if input.EditableText(c, "Hero name", &s.hero, input.EditableTextOptions{Placeholder: "Nameless"}).Changed {
|
||||
log("renamed to " + s.hero)
|
||||
}
|
||||
}
|
||||
|
||||
func demoField(c *ui.Context, log func(string)) {
|
||||
s := controlState(c)
|
||||
o := input.FieldOptions{Description: "Shown on the leaderboard."}
|
||||
if s.call == "" {
|
||||
o.Error = "A hero needs a call sign!"
|
||||
}
|
||||
input.Field(c, "Call sign", o, func() {
|
||||
r := input.TextInput(c, "Call sign", &s.call, input.TextInputOptions{MaxLength: 10})
|
||||
if r.Changed {
|
||||
log("call sign " + s.call)
|
||||
}
|
||||
if r.Submitted {
|
||||
log("signed " + s.call)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoFieldset(c *ui.Context, log func(string)) {
|
||||
s := controlState(c)
|
||||
input.Fieldset(c, "Guild", input.FieldsetOptions{}, func() {
|
||||
input.Field(c, "Name", input.FieldOptions{}, func() {
|
||||
r := input.TextInput(c, "Guild name", &s.guild, input.TextInputOptions{Placeholder: "The Dot Matrix"})
|
||||
if r.Changed {
|
||||
log("guild " + s.guild)
|
||||
}
|
||||
if r.Submitted {
|
||||
log("founded " + s.guild)
|
||||
}
|
||||
})
|
||||
banners := []string{"RED", "BLUE", "GOLD"}
|
||||
if input.Segmented(c, "Banner", banners, &s.banner, input.SegmentedOptions{}).Changed {
|
||||
log("banner " + banners[s.banner])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoColorSlot(c *ui.Context, log func(string)) {
|
||||
s := controlState(c)
|
||||
if input.ColorSlot(c, "Cape", &s.cape, input.ColorSlotOptions{}).Changed {
|
||||
log("cape " + s.cape.String())
|
||||
}
|
||||
}
|
||||
|
||||
func demoJoystick(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() {
|
||||
r := input.Joystick(c, "Stick", input.JoystickOptions{})
|
||||
if r.Moved {
|
||||
log(fmt.Sprintf("x %+.2f y %+.2f", r.X, r.Y))
|
||||
}
|
||||
pixel.Text(c, fmt.Sprintf("X %+.2f Y %+.2f", r.X, r.Y), pixel.TextOptions{Slot: pixel.Faint})
|
||||
})
|
||||
}
|
||||
|
||||
func demoCheatCode(c *ui.Context, log func(string)) {
|
||||
if input.CheatCode(c, "Cheat", input.CheatCodeOptions{}).Unlocked {
|
||||
log("30 LIVES!")
|
||||
}
|
||||
}
|
||||
|
||||
func demoSlotPicker(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := controlState(c)
|
||||
fruit := []string{"Cherry", "Bell", "Seven", "Melon", "BAR"}
|
||||
ui.Row(c).Gap(k.D(4)).Children(func() {
|
||||
changed := false
|
||||
for i, v := range []*int{&s.reel1, &s.reel2, &s.reel3} {
|
||||
if input.SlotPicker(c, fmt.Sprintf("Reel %d", i+1), v, fruit, input.SlotPickerOptions{}).Changed {
|
||||
changed = true
|
||||
log(fmt.Sprintf("reel %d %s", i+1, fruit[*v]))
|
||||
}
|
||||
}
|
||||
if all := []int{s.reel1, s.reel2, s.reel3}; changed && slices.Max(all) == slices.Min(all) {
|
||||
log("JACKPOT! " + fruit[s.reel1])
|
||||
}
|
||||
})
|
||||
}
|
||||
+1
-1
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package input_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// Each control, disabled after taking focus, ignores every input.
|
||||
func TestDisabledHoldButtonIgnoresAHeldPress(t *testing.T) {
|
||||
clk := &clock{time.Unix(3_000_000, 0)}
|
||||
confirmed := 0
|
||||
tt := view(400, 200, clk, func(c *ui.Context) {
|
||||
if input.HoldButton(c, "HOLD", input.HoldButtonOptions{Disabled: true}).Confirmed {
|
||||
confirmed++
|
||||
}
|
||||
})
|
||||
r := find(t, tt, "HOLD")
|
||||
tt.Press(r.X+r.W/2, r.Y+r.H/2)
|
||||
clk.t = clk.t.Add(2 * time.Second)
|
||||
tt.Frame()
|
||||
if confirmed != 0 {
|
||||
t.Fatal("a disabled hold button confirmed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoldButtonForgetsAHoldWhenDisabled(t *testing.T) {
|
||||
clk := &clock{time.Unix(3_000_000, 0)}
|
||||
confirmed, off := 0, false
|
||||
tt := view(400, 200, clk, func(c *ui.Context) {
|
||||
input.Button(c, "before", input.ButtonOptions{})
|
||||
if input.HoldButton(c, "HOLD", input.HoldButtonOptions{Disabled: off}).Confirmed {
|
||||
confirmed++
|
||||
}
|
||||
})
|
||||
hold := func(d time.Duration) {
|
||||
for end := clk.t.Add(d); clk.t.Before(end); clk.t = clk.t.Add(50 * time.Millisecond) {
|
||||
tt.Key(0, ui.KeySpace)
|
||||
}
|
||||
}
|
||||
click(t, tt, "before")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
hold(500 * time.Millisecond)
|
||||
off = true
|
||||
clk.t = clk.t.Add(100 * time.Millisecond)
|
||||
tt.Frame()
|
||||
off = false
|
||||
tt.Frame()
|
||||
click(t, tt, "before")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
hold(600 * time.Millisecond)
|
||||
if confirmed != 0 {
|
||||
t.Fatal("a hold from before disabling counted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledWhileFocusedTakesNoInput(t *testing.T) {
|
||||
type state struct {
|
||||
s, list string
|
||||
n, events int
|
||||
f float64
|
||||
tags []string
|
||||
slot pixel.Slot
|
||||
}
|
||||
cases := []struct {
|
||||
name, label string
|
||||
build func(c *ui.Context, v *state, off bool)
|
||||
}{
|
||||
{"Select", "Class", func(c *ui.Context, v *state, off bool) {
|
||||
input.Select(c, "Class", &v.s, []string{"A", "B"}, input.SelectOptions{Disabled: off})
|
||||
}},
|
||||
{"MultiSelect", "Gear", func(c *ui.Context, v *state, off bool) {
|
||||
input.MultiSelect(c, "Gear", &v.tags, []string{"A", "B"}, input.MultiSelectOptions{Disabled: off})
|
||||
}},
|
||||
{"Combobox", "Fruit", func(c *ui.Context, v *state, off bool) {
|
||||
input.Combobox(c, "Fruit", &v.s, []string{"Apple"}, input.ComboboxOptions{Disabled: off})
|
||||
}},
|
||||
{"Autocomplete", "Spell", func(c *ui.Context, v *state, off bool) {
|
||||
input.Autocomplete(c, "Spell", &v.s, []string{"Fire"}, input.AutocompleteOptions{Disabled: off})
|
||||
}},
|
||||
{"TokenField", "Tags", func(c *ui.Context, v *state, off bool) {
|
||||
input.TokenField(c, "Tags", &v.tags, input.TokenFieldOptions{Disabled: off})
|
||||
}},
|
||||
{"Segmented", "B", func(c *ui.Context, v *state, off bool) {
|
||||
input.Segmented(c, "Mode", []string{"A", "B", "C"}, &v.n, input.SegmentedOptions{Disabled: off})
|
||||
}},
|
||||
{"Rating", "2/5", func(c *ui.Context, v *state, off bool) {
|
||||
input.Rating(c, "Stars", &v.n, input.RatingOptions{Disabled: off})
|
||||
}},
|
||||
{"Knob", "Gain", func(c *ui.Context, v *state, off bool) {
|
||||
input.Knob(c, "Gain", &v.f, input.KnobOptions{Disabled: off})
|
||||
}},
|
||||
{"PinInput", "PIN", func(c *ui.Context, v *state, off bool) {
|
||||
input.PinInput(c, "PIN", &v.s, input.PinInputOptions{Disabled: off})
|
||||
}},
|
||||
{"NameEntry", "Initials", func(c *ui.Context, v *state, off bool) {
|
||||
input.NameEntry(c, "Initials", &v.s, input.NameEntryOptions{Disabled: off})
|
||||
}},
|
||||
{"VirtualKeyboard", "Keys", func(c *ui.Context, v *state, off bool) {
|
||||
input.VirtualKeyboard(c, "Keys", &v.s, input.VirtualKeyboardOptions{Disabled: off})
|
||||
}},
|
||||
{"DPad", "Pad", func(c *ui.Context, v *state, off bool) {
|
||||
if input.DPad(c, "Pad", input.DPadOptions{Disabled: off}).Pressed != input.None {
|
||||
v.events++
|
||||
}
|
||||
}},
|
||||
{"HoldButton", "HOLD", func(c *ui.Context, v *state, off bool) {
|
||||
if input.HoldButton(c, "HOLD", input.HoldButtonOptions{Duration: time.Millisecond, Disabled: off}).Confirmed {
|
||||
v.events++
|
||||
}
|
||||
}},
|
||||
{"EditableText", "Name", func(c *ui.Context, v *state, off bool) {
|
||||
input.EditableText(c, "Name", &v.s, input.EditableTextOptions{Disabled: off})
|
||||
}},
|
||||
{"ColorSlot", "Paint", func(c *ui.Context, v *state, off bool) {
|
||||
input.ColorSlot(c, "Paint", &v.slot, input.ColorSlotOptions{Disabled: off})
|
||||
}},
|
||||
{"Joystick", "Stick", func(c *ui.Context, v *state, off bool) {
|
||||
if input.Joystick(c, "Stick", input.JoystickOptions{Disabled: off}).Moved {
|
||||
v.events++
|
||||
}
|
||||
}},
|
||||
{"CheatCode", "Cheat", func(c *ui.Context, v *state, off bool) {
|
||||
if input.CheatCode(c, "Cheat", input.CheatCodeOptions{Sequence: []ui.Key{ui.KeyUp}, Disabled: off}).Unlocked {
|
||||
v.events++
|
||||
}
|
||||
}},
|
||||
{"SlotPicker", "Reel", func(c *ui.Context, v *state, off bool) {
|
||||
input.SlotPicker(c, "Reel", &v.n, []string{"A", "B", "C"}, input.SlotPickerOptions{Disabled: off})
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var v state
|
||||
off := false
|
||||
tt := view(400, 300, nil, func(c *ui.Context) {
|
||||
tc.build(c, &v, off)
|
||||
ui.Box(c).Size(4, 4).Label("elsewhere")
|
||||
})
|
||||
click(t, tt, tc.label)
|
||||
if tc.name == "EditableText" {
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
}
|
||||
off = true
|
||||
tt.Frame()
|
||||
if _, open := tt.Find("A"); open && tc.name != "Segmented" && tc.name != "SlotPicker" {
|
||||
t.Fatal("a disabled control's popup stayed open")
|
||||
}
|
||||
// Focused counts ancestors: only a shared one may hold it.
|
||||
if tt.Focused(tc.label) && !tt.Focused("elsewhere") {
|
||||
t.Fatal("a disabled control kept the focus")
|
||||
}
|
||||
before := fmt.Sprint(v)
|
||||
tt.Type("1a,")
|
||||
tt.SetClipboard("12")
|
||||
tt.Command("paste")
|
||||
for _, k := range []ui.Key{ui.KeyUp, ui.KeyDown, ui.KeyLeft, ui.KeyRight, ui.KeyHome, ui.KeySpace, ui.KeyEnter, ui.KeyBackspace} {
|
||||
tt.Key(0, k)
|
||||
}
|
||||
click(t, tt, tc.label)
|
||||
if now := fmt.Sprint(v); now != before {
|
||||
t.Fatalf("disabled control changed: %s to %s", before, now)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+116
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package input_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// at clicks dot x, y of the element labeled l.
|
||||
func at(t *testing.T, tt *ui.Tester, l string, x, y int) {
|
||||
t.Helper()
|
||||
r := find(t, tt, l)
|
||||
tt.ClickAt(r.X+float32(2*x), r.Y+float32(2*y))
|
||||
}
|
||||
|
||||
func TestRatingClicksArrowsAndClears(t *testing.T) {
|
||||
stars, hearts, changes := 0, 0, 0
|
||||
tt := view(400, 200, nil, func(c *ui.Context) {
|
||||
if input.Rating(c, "Stars", &stars, input.RatingOptions{}).Changed {
|
||||
changes++
|
||||
}
|
||||
input.Rating(c, "Love", &hearts, input.RatingOptions{Max: 3, Hearts: true})
|
||||
})
|
||||
click(t, tt, "3/5")
|
||||
click(t, tt, "3/5")
|
||||
if stars != 0 {
|
||||
t.Fatalf("clicking the rating again left %d", stars)
|
||||
}
|
||||
click(t, tt, "4/5")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if stars != 5 || changes != 4 {
|
||||
t.Fatalf("click and Right gave %d after %d changes", stars, changes)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if stars != 1 {
|
||||
t.Fatalf("Home gave %d", stars)
|
||||
}
|
||||
// Only a filled mark has an Accent middle dot.
|
||||
lit := func(l string) bool {
|
||||
r := find(t, tt, l)
|
||||
return slots(t, tt.Image(), ui.Rect{X: r.X + r.W/2 - 1, Y: r.Y + r.H/2 - 1, W: 2, H: 2})[0] == pixel.Accent
|
||||
}
|
||||
if !lit("1/5") || lit("2/5") {
|
||||
t.Fatal("marks do not show the rating")
|
||||
}
|
||||
faint := func(l string) (out []bool) {
|
||||
for _, s := range slots(t, tt.Image(), find(t, tt, l)) {
|
||||
out = append(out, s == pixel.Faint)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if equal(faint("3/5"), faint("3/3")) {
|
||||
t.Fatal("hearts look like stars")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRatingNavigationNeverClears(t *testing.T) {
|
||||
one, five := 1, 1
|
||||
tt := view(400, 200, nil, func(c *ui.Context) {
|
||||
input.Rating(c, "One", &one, input.RatingOptions{Max: 1})
|
||||
input.Rating(c, "Five", &five, input.RatingOptions{})
|
||||
})
|
||||
click(t, tt, "1/1")
|
||||
click(t, tt, "1/1")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if one != 1 {
|
||||
t.Fatalf("Right on a lone mark left %d", one)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if five != 1 {
|
||||
t.Fatalf("Home onto the chosen mark left %d", five)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnobDragsKeysAndSnapsToSixteenAngles(t *testing.T) {
|
||||
v, w := 0.5, 0.52
|
||||
tt := view(400, 200, nil, func(c *ui.Context) {
|
||||
input.Knob(c, "Gain", &v, input.KnobOptions{Step: 0.05})
|
||||
input.Knob(c, "Twin", &w, input.KnobOptions{})
|
||||
})
|
||||
if !equal(ink(t, tt.Image(), find(t, tt, "Gain")), ink(t, tt.Image(), find(t, tt, "Twin"))) {
|
||||
t.Fatal("0.5 and 0.52 share an angle but look apart")
|
||||
}
|
||||
r := find(t, tt, "Gain")
|
||||
x, y := r.X+r.W/2, r.Y+r.H/2
|
||||
tt.Press(x, y)
|
||||
// Each one-dot move is under half a step.
|
||||
for i := 1; i <= 16; i++ {
|
||||
tt.Move(x, y-float32(2*i))
|
||||
}
|
||||
tt.Release(x, y-32)
|
||||
if math.Abs(v-0.75) > 1e-9 {
|
||||
t.Fatalf("dragging up 16 dots gave %v, want 0.75", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if math.Abs(v-0.7) > 1e-9 {
|
||||
t.Fatalf("Down gave %v", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if v != 1 || equal(ink(t, tt.Image(), find(t, tt, "Gain")), ink(t, tt.Image(), find(t, tt, "Twin"))) {
|
||||
t.Fatalf("End gave %v, or the pointer did not turn", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinInputTypesBacksAndPastes(t *testing.T) {
|
||||
pin, complete := "", 0
|
||||
tt := view(400, 200, nil, func(c *ui.Context) {
|
||||
if input.PinInput(c, "PIN", &pin, input.PinInputOptions{}).Complete {
|
||||
complete++
|
||||
}
|
||||
})
|
||||
click(t, tt, "PIN")
|
||||
tt.Type("12a3")
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
if pin != "12" {
|
||||
t.Fatalf("typing and Backspace left %q", pin)
|
||||
}
|
||||
tt.SetClipboard("98765")
|
||||
tt.Command("paste")
|
||||
if pin != "1298" || complete != 1 {
|
||||
t.Fatalf("paste left %q, complete %d times", pin, complete)
|
||||
}
|
||||
if _, ok := tt.TextCaret(); !ok {
|
||||
t.Fatal("the boxes take no text from input methods")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameEntrySpellsAndConfirms(t *testing.T) {
|
||||
name, confirmed := "", 0
|
||||
tt := view(400, 300, nil, func(c *ui.Context) {
|
||||
if input.NameEntry(c, "Initials", &name, input.NameEntryOptions{}).Confirmed {
|
||||
confirmed++
|
||||
}
|
||||
})
|
||||
at(t, tt, "Initials", 3*14+7, 7)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if name != "DEL" || confirmed != 0 {
|
||||
t.Fatalf("spelled %q", name)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if confirmed != 1 {
|
||||
t.Fatal("the cursor did not jump to END after three letters")
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if name != "DE" {
|
||||
t.Fatalf("DEL left %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualKeyboardTypes(t *testing.T) {
|
||||
text, sent := "", 0
|
||||
tt := view(400, 300, nil, func(c *ui.Context) {
|
||||
if input.VirtualKeyboard(c, "Keys", &text, input.VirtualKeyboardOptions{}).Submitted {
|
||||
sent++
|
||||
}
|
||||
})
|
||||
at(t, tt, "Keys", 7, 14+7)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if text != "QW" {
|
||||
t.Fatalf("typed %q", text)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if sent != 1 {
|
||||
t.Fatal("enter did not submit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDPadReportsArms(t *testing.T) {
|
||||
clk := &clock{time.Unix(3_000_000, 0)}
|
||||
var got []input.Direction
|
||||
tt := view(400, 200, clk, func(c *ui.Context) {
|
||||
if d := input.DPad(c, "Pad", input.DPadOptions{}).Pressed; d != input.None {
|
||||
got = append(got, d)
|
||||
}
|
||||
})
|
||||
at(t, tt, "Pad", 18, 18)
|
||||
at(t, tt, "Pad", 18, 4)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
// The pointer rests on Up; Enter must not click it.
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !equal(got, []input.Direction{input.Up, input.Right}) {
|
||||
t.Fatalf("got %v, want up then right", got)
|
||||
}
|
||||
r := find(t, tt, "Pad")
|
||||
arm := ui.Rect{X: r.X + 2*26, Y: r.Y + 2*14, W: 2, H: 2}
|
||||
if slots(t, tt.Image(), arm)[0] != pixel.Shade {
|
||||
t.Fatal("a key press did not light its arm")
|
||||
}
|
||||
clk.t = clk.t.Add(200 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if slots(t, tt.Image(), arm)[0] != pixel.Face {
|
||||
t.Fatal("the arm stayed down")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoldButtonNeedsTheHold(t *testing.T) {
|
||||
clk := &clock{time.Unix(3_000_000, 0)}
|
||||
confirmed := 0
|
||||
tt := view(400, 200, clk, func(c *ui.Context) {
|
||||
if input.HoldButton(c, "ERASE", input.HoldButtonOptions{}).Confirmed {
|
||||
confirmed++
|
||||
}
|
||||
})
|
||||
r := find(t, tt, "ERASE")
|
||||
step := func(d time.Duration) {
|
||||
clk.t = clk.t.Add(d)
|
||||
tt.Frame()
|
||||
}
|
||||
tt.Press(r.X+r.W/2, r.Y+r.H/2)
|
||||
step(500 * time.Millisecond)
|
||||
if !has(slots(t, tt.Image(), r), pixel.Accent) {
|
||||
t.Fatal("half a hold shows no fill")
|
||||
}
|
||||
tt.Release(r.X+r.W/2, r.Y+r.H/2)
|
||||
tt.Press(r.X+r.W/2, r.Y+r.H/2)
|
||||
step(900 * time.Millisecond)
|
||||
if confirmed != 0 {
|
||||
t.Fatal("letting go did not restart the hold")
|
||||
}
|
||||
step(200 * time.Millisecond)
|
||||
step(200 * time.Millisecond)
|
||||
tt.Release(r.X+r.W/2, r.Y+r.H/2)
|
||||
if confirmed != 1 {
|
||||
t.Fatalf("a full hold confirmed %d times", confirmed)
|
||||
}
|
||||
// Keys: repeats every 50 ms after a 500 ms delay.
|
||||
tt.Key(0, ui.KeySpace)
|
||||
step(500 * time.Millisecond)
|
||||
for range 6 {
|
||||
tt.Key(0, ui.KeySpace)
|
||||
step(50 * time.Millisecond)
|
||||
}
|
||||
// Released at 750 ms; frames continue past one second.
|
||||
for range 12 {
|
||||
step(100 * time.Millisecond)
|
||||
}
|
||||
if confirmed != 1 {
|
||||
t.Fatal("keys let go early still confirmed")
|
||||
}
|
||||
tt.Key(0, ui.KeySpace)
|
||||
for range 25 {
|
||||
step(50 * time.Millisecond)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
}
|
||||
if confirmed != 2 {
|
||||
t.Fatalf("a held key confirmed %d times in all", confirmed)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type SelectOptions struct {
|
||||
// Width is in dots; zero fits the longest option.
|
||||
Width int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type SelectResult struct {
|
||||
Element *ui.Element
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// Select picks one option from a popup list.
|
||||
func Select(c *ui.Context, text string, value *string, options []string, o SelectOptions) SelectResult {
|
||||
k := pixel.K(c)
|
||||
var sel *ui.SelectParts[string]
|
||||
shell, base, st := pixel.Shell(c, func() *ui.Element {
|
||||
sel = ui.SelectBase(c, value)
|
||||
return sel.Trigger.Label(text).Disabled(o.Disabled)
|
||||
})
|
||||
shell.Size(k.D(orDef("Select Width", o.Width, widest(options)+22)), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
ink, dy := face(g, Normal, st, sel.Open() && !o.Disabled)
|
||||
g.Text(6, 2+dy, *value, ink, 1)
|
||||
g.Sprite(g.W-10, (g.H-tinyDown.H)/2+dy, tinyDown, 0, ink, 1)
|
||||
finish(g, st)
|
||||
})
|
||||
if !o.Disabled {
|
||||
sel.Popup(func(panel *ui.Element) {
|
||||
menu(c, panel, func() {
|
||||
for _, opt := range options {
|
||||
item := sel.Item(opt).Label(opt)
|
||||
row(k, item, opt, item.Highlighted(), opt == *value)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
res := SelectResult{Element: shell, Changed: base.Changed()}
|
||||
if res.Changed {
|
||||
slog.Debug("8bit: select", "label", text, "value", *value)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// widest is the widest option's width in dots.
|
||||
func widest(options []string) int {
|
||||
w := 0
|
||||
for _, opt := range options {
|
||||
w = max(w, pixel.Measure(opt))
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// menu frames a popup panel as a game window.
|
||||
func menu(c *ui.Context, panel *ui.Element, fn func()) {
|
||||
k := pixel.K(c)
|
||||
panel.Padding(k.D(3)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
|
||||
}).Children(func() {
|
||||
ui.Column(c).AlignItems(ui.Stretch).Children(fn)
|
||||
})
|
||||
}
|
||||
|
||||
// row draws an option; a hand marks the lit one.
|
||||
func row(k pixel.Kit, item *ui.Element, text string, lit, chosen bool) {
|
||||
item.Height(k.D(pixel.LineHeight)).MinWidth(k.D(pixel.Measure(text) + 16)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
ink := pixel.Ink
|
||||
if chosen {
|
||||
ink = pixel.Accent
|
||||
}
|
||||
if lit {
|
||||
g.Rect(0, 0, g.W, g.H, pixel.Lit)
|
||||
g.Sprite(1, (g.H-pixel.Hand.H)/2, pixel.Hand, 0, pixel.Edge, 1)
|
||||
}
|
||||
g.Text(13, 0, text, ink, 1)
|
||||
})
|
||||
}
|
||||
+39
-26
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type SlotPickerOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type SlotPickerResult struct {
|
||||
Element *ui.Element
|
||||
Changed bool
|
||||
}
|
||||
|
||||
type reelKey struct{}
|
||||
|
||||
// reel holds a drag's and the wheel's leftover DIPs.
|
||||
type reel struct {
|
||||
drag, wheel float32
|
||||
changed bool
|
||||
}
|
||||
|
||||
// SlotPicker spins a looping reel; the centered option is chosen.
|
||||
func SlotPicker(c *ui.Context, text string, value *int, options []string, o SlotPickerOptions) SlotPickerResult {
|
||||
k := pixel.K(c)
|
||||
n := len(options)
|
||||
if *value < 0 || *value >= n {
|
||||
panic(fmt.Sprintf("input: SlotPicker value %d outside %d options", *value, n))
|
||||
}
|
||||
shell, base, st := pixel.Shell(c, func() *ui.Element {
|
||||
return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(text).Description(options[*value]).Disabled(o.Disabled)
|
||||
})
|
||||
s := ui.Local(base, reelKey{}, func() reel { return reel{} })
|
||||
spin := func(d int) {
|
||||
*value = ((*value+d)%n + n) % n
|
||||
s.changed = true
|
||||
}
|
||||
row := k.D(pixel.LineHeight)
|
||||
base.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if ev.Kind != ui.InputScroll || o.Disabled {
|
||||
return false
|
||||
}
|
||||
s.wheel += ev.DY
|
||||
turn(&s.wheel, row, spin)
|
||||
return true
|
||||
})
|
||||
if base.Shortcut(0, ui.KeyDown) {
|
||||
spin(1)
|
||||
}
|
||||
if base.Shortcut(0, ui.KeyUp) {
|
||||
spin(-1)
|
||||
}
|
||||
if _, dy, held := base.Dragged(); held && !o.Disabled {
|
||||
s.drag += dy
|
||||
turn(&s.drag, row, func(d int) { spin(-d) })
|
||||
} else {
|
||||
s.drag = 0
|
||||
}
|
||||
res := SlotPickerResult{Element: shell, Changed: s.changed}
|
||||
s.changed = false
|
||||
if res.Changed {
|
||||
slog.Debug("8bit: slot picker", "label", text, "value", options[*value])
|
||||
}
|
||||
off := int(s.drag / k.Dot())
|
||||
shell.Size(k.D(widest(options)+28), k.D(3*pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
|
||||
g.Clip(2, 2, g.W-4, g.H-4, func() {
|
||||
for i := -2; i <= 2; i++ {
|
||||
ink := pixel.Faint
|
||||
if i == 0 {
|
||||
ink = pixel.Ink
|
||||
}
|
||||
opt := options[((*value+i)%n+n)%n]
|
||||
g.Text((g.W-pixel.Measure(opt))/2, 2+pixel.LineHeight*(1+i)+off, opt, ink, 1)
|
||||
}
|
||||
})
|
||||
g.Veil(2, 2, g.W-4, 6, pixel.Shade, 4)
|
||||
g.Veil(2, g.H-8, g.W-4, 6, pixel.Shade, 4)
|
||||
mid := 2 + pixel.LineHeight + (pixel.LineHeight-tinyRight.H)/2
|
||||
g.Sprite(3, mid, tinyRight, 0, pixel.Accent, 1)
|
||||
g.Sprite(g.W-6, mid, tinyLeft, 0, pixel.Accent, 1)
|
||||
finish(g, st)
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// turn spends whole rows of *left on step.
|
||||
func turn(left *float32, row float32, step func(int)) {
|
||||
for ; *left >= row; *left -= row {
|
||||
step(1)
|
||||
}
|
||||
for ; *left <= -row; *left += row {
|
||||
step(-1)
|
||||
}
|
||||
}
|
||||
+13
-9
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type VirtualKeyboardOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type VirtualKeyboardResult struct {
|
||||
Element *ui.Element
|
||||
Changed, Submitted bool
|
||||
}
|
||||
|
||||
type keyboardKey struct{}
|
||||
|
||||
// caps are ten keys a row; \b erases, \n enters.
|
||||
var caps = []rune("1234567890QWERTYUIOPASDFGHJKL\bZXCVBNM- \n")
|
||||
|
||||
var (
|
||||
backKey = pixel.MustSprite(`
|
||||
..#####
|
||||
.#....#
|
||||
#..#.##
|
||||
.#....#
|
||||
..#####
|
||||
`)
|
||||
enterKey = pixel.MustSprite(`
|
||||
.....#
|
||||
.#...#
|
||||
##...#
|
||||
######
|
||||
##....
|
||||
.#....
|
||||
`)
|
||||
)
|
||||
|
||||
// VirtualKeyboard types into *value from keys on screen.
|
||||
func VirtualKeyboard(c *ui.Context, text string, value *string, o VirtualKeyboardOptions) VirtualKeyboardResult {
|
||||
k := pixel.K(c)
|
||||
col := ui.Column(c).Gap(k.D(4))
|
||||
cur := ui.Local(col, keyboardKey{}, func() int { return 0 })
|
||||
res := VirtualKeyboardResult{Element: col}
|
||||
col.Children(func() {
|
||||
ui.Box(c).Size(k.D(140), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
well(g, pixel.Edge, false)
|
||||
rs := []rune(*value)
|
||||
for len(rs) > 0 && pixel.Measure(string(rs))+10 > g.W {
|
||||
rs = rs[1:]
|
||||
}
|
||||
g.Text(3, 2, string(rs), pixel.Ink, 1)
|
||||
g.Rect(4+pixel.Measure(string(rs)), 4, 5, 12, pixel.Accent)
|
||||
})
|
||||
shell, base, st, picked := pad(c, text, 10, len(caps), 14, pixel.LineHeight, cur, o.Disabled)
|
||||
if picked >= 0 {
|
||||
switch r := caps[picked]; r {
|
||||
case '\b':
|
||||
if rs := []rune(*value); len(rs) > 0 {
|
||||
*value, res.Changed = string(rs[:len(rs)-1]), true
|
||||
}
|
||||
case '\n':
|
||||
res.Submitted = true
|
||||
default:
|
||||
*value, res.Changed = *value+string(r), true
|
||||
}
|
||||
}
|
||||
base.Description(*value)
|
||||
focused := base.Focused()
|
||||
shell.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for i, cp := range caps {
|
||||
x, y := i%10*14, i/10*pixel.LineHeight
|
||||
fill := pixel.Face
|
||||
if focused && i == *cur {
|
||||
fill = pixel.Lit
|
||||
}
|
||||
g.Frame(x, y, 13, pixel.LineHeight-1, pixel.Raised, fill)
|
||||
switch cp {
|
||||
case '\b':
|
||||
g.Sprite(x+3, y+5, backKey, 0, pixel.Ink, 1)
|
||||
case '\n':
|
||||
g.Sprite(x+4, y+4, enterKey, 0, pixel.Accent, 1)
|
||||
case ' ':
|
||||
g.Rect(x+3, y+10, 7, 1, pixel.Ink)
|
||||
g.Rect(x+3, y+8, 1, 2, pixel.Ink)
|
||||
g.Rect(x+9, y+8, 1, 2, pixel.Ink)
|
||||
default:
|
||||
l := string(cp)
|
||||
g.Text(x+(13-pixel.Measure(l))/2, y, l, pixel.Ink, 1)
|
||||
}
|
||||
}
|
||||
finish(g, st)
|
||||
})
|
||||
})
|
||||
if res.Submitted {
|
||||
slog.Debug("8bit: keyboard entered", "label", text)
|
||||
}
|
||||
return res
|
||||
}
|
||||
+9
-3
@@ -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) {
|
||||
|
||||
@@ -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!",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user