diff --git a/AGENTS.md b/AGENTS.md index 53d8b40..4b402d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,28 +53,29 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en ## Component rules -- Signatures and results: PRD §5.1. Zero options give the default look. Sizes in options are dots. +- Signatures and results: PRD §5.1, held by archtest `TestComponentsTakeTheirOptions`: `Name` takes `NameOptions` and returns `NameResult`. Zero options give the default look; required values are parameters. Sizes in options are dots. - `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`. Integer options default through `pixel.Or`. -- Custom-drawn controls end their Draw with `g.Finish(st)`. Grids of cells moved by arrows use `pixel.Pad`; custom-drawn sliders use `input.SlideBase`; one-shot timing steps with `k.Since`; a decorative one-shot on a cue steps with `k.Shot`, which Reduce Motion spends whole; a view that changes at a known moment asks `k.At`; a two-way slide (bars, fades, wipes) steps with `k.Toward`, which turns from where it is. +- Custom-drawn controls end their Draw with `g.Finish(st)`. Grids of cells moved by arrows use `pixel.Pad`; custom-drawn sliders use `input.SlideBase`; every clock runs at S5's 4–15 fps (`checkFPS` panics otherwise): a blink is `g.Step(4)%4 < 2`, a timer counts quarter seconds, and a view that changes on whole seconds asks `k.At`; one-shot timing steps with `k.Since`, which holds at zero when the clock steps back; a stored start is read through `k.Elapsed(&t)`, which moves it to now when the clock steps back; drawing from time quantizes it to whole frames (`Truncate(time.Second / 15)`); a decorative one-shot on a cue steps with `k.Shot`, which Reduce Motion spends whole; a view that changes at a known moment asks `k.At`; a two-way slide (bars, fades, wipes) steps with `k.Toward`, which turns from where it is. - A one-shot effect inside a lasting view starts when its cue changes (a `Cue` option read through `k.Cue`, or a value that rises), never on its first frame. A state screen (`ErrorState`, `SuccessState`) starts when it appears. Decorative motion jumps to its end under Reduce Motion; countdowns and loaders keep time. - 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`, a drag's release) is read at paint time, or set on the element after its children. - Never call `Selected`. The pixel theme keeps MyGo's row highlights clear, since scrolled rows sit between dots: draw chosen rows yourself. - A disabled or inert scroll view must not move: cover it, so the wheel stops above it. -- Read pointers and keys only while `base.IsDisabled()` is false, never by the `Disabled` option alone: `pixel.Shell` disables the base under Inert too, and a drag begun before stays pressed. +- Read pointers and keys only while `base.IsDisabled()` is false, never by the `Disabled` option alone: `pixel.Shell` disables the base under Inert too, and a drag begun before stays pressed. MyGo still hands a key to a base that turned off this frame, so keys go through `pixel.Shortcut` (archtest bans an element's own `Shortcut`); a container that stays enabled, or a wrapper around an editor, also checks its own off state. `pixel.Editor` turns away text typed in a veil's first frame; read an edited value after building its editor, or gate the read. A `HandleInput` handler runs before the build learns the base is off: it records, and the build applies only while the base is enabled. +- MyGo's value bases (`ToggleBase`, `SwitchBase`, `CheckboxBase`, `RadioBase`, `SegmentedBase`, `TabsBase`, `CollapsibleBase`) turn their value while built, before `Disabled` applies, so a key in a veil's first frame lands. Hand them a copy; keep only turns of an enabled base. - MyGo paints a disabled element and all inside it at half opacity, and only a disabled element reads as disabled to assistive technology. Disable only drawless bases, as `pixel.Shell` does. A container with drawn content stays enabled, parks its focus, ignores input and says `DISABLED` in its description. - A press focuses the nearest focusable element under it, or nothing, and a focusable element keeps the click: clicks never reach its ancestors. A control that acts on a press is that focusable element. A `DialogBase` moves the focus inside as it opens and keeps Tab there, but only onto something focusable: make its panel focusable, ring off, and build its children in `pixel.Harbor(c, panel, …)` so controls turning off park their focus there and get it back. 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. -- Under `pixel.Settings.Inert` (a busy veil) `pixel.Shell` disables every base and gives the focus back after. A popup sits above any veil: a control closes its popup when disabled or inert. -- 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. +- Under `pixel.Settings.Inert` (a busy veil) `pixel.Shell` disables every base and gives the focus back after. A popup sits above any veil: a control closes its popup when disabled or inert. If the popup held the focus, focus the opener and `pixel.Park` it in that frame; tell it by `Focused` on the popup's controls, since MyGo links parents only after a frame. +- 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. Rows the caller removes are keyed by a name that must be unique: panic on twins. - 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. - `List` row builders run during layout: wrap them in `pixel.Lazy(c, row)`. A click met there lands after the build; keep it in `Local` state, invalidate, and report it next frame. - Single-key shortcuts only while `pixel.Typing(c)` is false; MyGo routes shortcuts before a focused field. -- Copy shown by a component comes from `k.Msg`; business copy comes from the caller. Ark Pixel 12px lacks some hanzi: `TestMessagesHaveGlyphs` holds every message to the font. +- Copy shown by a component comes from `k.Msg`; business copy comes from the caller. Ark Pixel 12px lacks some hanzi: `TestMessagesHaveGlyphs` holds every message to the font, `TestGalleryTextHasGlyphs` every gallery name and summary. - Item slots look alike everywhere: draw them with `data.DrawSlot` or `data.ItemSlot`. - Fail fast: panic on programmer errors such as bad options. Never swallow an error. -- Composites log state changes: `slog.Debug("8bit: ", …)`. Never log secrets. +- Composites log state changes: `slog.Debug("8bit: ", …)`. Never log secrets, nor anything typed or copied: log counts. The gallery logs which demo spoke, not what it said. - No cross-frame `*ui.Element` caching. No goroutines touching elements. - Files under 500 lines. Comments: one line, ten words at most, only when needed. No dead code, no versioned APIs. diff --git a/account/about.go b/account/about.go index 81de394..11e87ce 100644 --- a/account/about.go +++ b/account/about.go @@ -13,19 +13,18 @@ type Credit struct { } type AboutOptions struct { - Version string Credits []Credit } // About shows the title as a logo, version, then credits. -func About(c *ui.Context, title string, o AboutOptions) *ui.Element { +func About(c *ui.Context, title, version string, o AboutOptions) *ui.Element { k := pixel.K(c) - if o.Version == "" { + if version == "" { panic("account: About needs a version") } return ui.Column(c).Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(title).Children(func() { text.OutlineText(c, title, text.OutlineTextOptions{Slot: pixel.Accent, Scale: 3}) - pixel.Text(c, k.Msg("version")+" "+o.Version, pixel.TextOptions{Slot: pixel.Faint}) + pixel.Text(c, k.Msg("version")+" "+version, pixel.TextOptions{Slot: pixel.Faint}) if len(o.Credits) == 0 { return } diff --git a/account/keybindings.go b/account/keybindings.go index 455ad3d..3cbb72b 100644 --- a/account/keybindings.go +++ b/account/keybindings.go @@ -75,23 +75,23 @@ func KeyBindings(c *ui.Context, text string, binds []Binding, o KeyBindingsOptio kb.waiting = false case kb.waiting: // Waiting, every key binds, arrows too; Escape gives up. - if base.Shortcut(0, ui.KeyEscape) { + if pixel.Shortcut(base, 0, ui.KeyEscape) { kb.waiting = false } for key := range caps { - if base.Shortcut(0, key) && kb.waiting { + if pixel.Shortcut(base, 0, key) && kb.waiting { binds[kb.cur].Key, kb.waiting, res.Changed = key, false, true slog.Debug("8bit: key bindings", "label", text, "action", binds[kb.cur].Action, "key", caps[key]) } } default: - if base.Shortcut(0, ui.KeyUp) { + if pixel.Shortcut(base, 0, ui.KeyUp) { kb.cur = max(0, kb.cur-1) } - if base.Shortcut(0, ui.KeyDown) { + if pixel.Shortcut(base, 0, ui.KeyDown) { kb.cur = min(len(binds)-1, kb.cur+1) } - if base.Shortcut(0, ui.KeyEnter) || base.Shortcut(0, ui.KeySpace) { + if pixel.Shortcut(base, 0, ui.KeyEnter) || pixel.Shortcut(base, 0, ui.KeySpace) { kb.waiting = true } if base.Clicked() { diff --git a/account/optioncycler.go b/account/optioncycler.go index b6aa297..44d6fd1 100644 --- a/account/optioncycler.go +++ b/account/optioncycler.go @@ -34,11 +34,11 @@ func OptionCycler(c *ui.Context, text string, options []string, selected *int, o n, old := len(options), *selected if !base.IsDisabled() { step := 0 - if base.Shortcut(0, ui.KeyLeft) { + if pixel.Shortcut(base, 0, ui.KeyLeft) { step-- } // Enter and Space step on; taken, clicks come from pointers. - right, enter, space := base.Shortcut(0, ui.KeyRight), base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace) + right, enter, space := pixel.Shortcut(base, 0, ui.KeyRight), pixel.Shortcut(base, 0, ui.KeyEnter), pixel.Shortcut(base, 0, ui.KeySpace) if right || enter || space { step++ } diff --git a/account/passwordgrid.go b/account/passwordgrid.go index edbb4af..ddb3f24 100644 --- a/account/passwordgrid.go +++ b/account/passwordgrid.go @@ -59,7 +59,7 @@ func PasswordGrid(c *ui.Context, text string, password *string, o PasswordGridOp if picked >= 0 && len(*password) < n { *password += symbols[picked : picked+1] } - if !base.IsDisabled() && base.Shortcut(0, ui.KeyBackspace) && *password != "" { + if !base.IsDisabled() && pixel.Shortcut(base, 0, ui.KeyBackspace) && *password != "" { *password = (*password)[:len(*password)-1] } base.Description(symbols[*cur : *cur+1]) diff --git a/account/players_test.go b/account/players_test.go index 13db048..5606692 100644 --- a/account/players_test.go +++ b/account/players_test.go @@ -145,7 +145,7 @@ func TestPlayerSelectChooses(t *testing.T) { func TestAboutAndPatchNotesRead(t *testing.T) { tt := view(500, 400, newWorld(), func(c *ui.Context) { ui.Row(c).Children(func() { - account.About(c, "GAME", account.AboutOptions{Version: "1.2", Credits: []account.Credit{{Role: "Art", Name: "Ann"}}}) + account.About(c, "GAME", "1.2", account.AboutOptions{Credits: []account.Credit{{Role: "Art", Name: "Ann"}}}) account.PatchNotes(c, "Notes", []account.Patch{{Version: "v2", Added: []string{"Boats"}, Fixed: []string{"Rain"}}}, account.PatchNotesOptions{Width: 120}) }) }) @@ -157,6 +157,6 @@ func TestAboutAndPatchNotesRead(t *testing.T) { t.Fatal("the ADDED tag is not Good") } mustPanic(t, "no version", func() { - view(500, 400, newWorld(), func(c *ui.Context) { account.About(c, "G", account.AboutOptions{}) }) + view(500, 400, newWorld(), func(c *ui.Context) { account.About(c, "G", "", account.AboutOptions{}) }) }) } diff --git a/account/rowkeys_test.go b/account/rowkeys_test.go new file mode 100644 index 0000000..c0a1a3b --- /dev/null +++ b/account/rowkeys_test.go @@ -0,0 +1,38 @@ +package account_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/account" +) + +func TestSessionFocusFollowsItsRow(t *testing.T) { + wd := newWorld() + ss := []account.Session{{Device: "Laptop", Seen: wd.clk.t}, {Device: "Phone", Seen: wd.clk.t}, {Device: "Tablet", Seen: wd.clk.t}} + gone := "" + tt := view(600, 400, wd, func(c *ui.Context) { + if r := account.SessionList(c, "Sessions", ss, account.SessionListOptions{}); r.SignedOut >= 0 { + gone = ss[r.SignedOut].Device + } + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + // The laptop leaves; the focus stays on the phone's button. + ss = ss[1:] + tt.Frame() + tt.Key(0, ui.KeyEnter) + if gone != "Phone" { + t.Fatalf("Enter after the laptop left signed out %q", gone) + } + defer func() { + if r := fmt.Sprint(recover()); !strings.HasPrefix(r, "account: SessionList") { + t.Fatalf("two phones gave %q", r) + } + }() + twins := []account.Session{{Device: "Phone"}, {Device: "Phone"}} + view(600, 400, newWorld(), func(c *ui.Context) { account.SessionList(c, "S", twins, account.SessionListOptions{}) }) +} diff --git a/account/sessionlist.go b/account/sessionlist.go index 12abc8e..86b68d6 100644 --- a/account/sessionlist.go +++ b/account/sessionlist.go @@ -1,6 +1,7 @@ package account import ( + "fmt" "log/slog" "time" @@ -34,10 +35,18 @@ type SessionListResult struct { // SessionList lists signed-in devices, each with SIGN OUT. func SessionList(c *ui.Context, text string, sessions []Session, o SessionListOptions) SessionListResult { k := pixel.K(c) + // Rows are keyed by device, so focus follows its row. + seen := map[string]bool{} + for _, s := range sessions { + if seen[s.Device] { + panic(fmt.Sprintf("account: SessionList has %q twice", s.Device)) + } + seen[s.Device] = true + } res := SessionListResult{SignedOut: -1} res.Element = ui.Column(c).Width(k.D(pixel.Or("SessionList Width", o.Width, 240))).Gap(k.D(4)).Role(ui.RoleList).Label(text).Children(func() { for i, s := range sessions { - ui.Row(c).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() { + ui.Row(c).Key(s.Device).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() { if s.Icon != nil { ui.Box(c).Size(k.D(s.Icon.W), k.D(s.Icon.H)).Draw(func(p *ui.Painter, r ui.Rect) { k.Canvas(p, r).Sprite(0, 0, s.Icon, 0, pixel.Ink, 1) diff --git a/chart/clock_back_test.go b/chart/clock_back_test.go new file mode 100644 index 0000000..81e355e --- /dev/null +++ b/chart/clock_back_test.go @@ -0,0 +1,45 @@ +package chart_test + +import ( + "bytes" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chart" +) + +func TestSpectrumPeaksStayWhenTheClockStepsBack(t *testing.T) { + clk, levels := newClock(), []float64{1} + tt := view(200, 100, clk, func(c *ui.Context) { chart.SpectrumBars(c, "EQ", levels, chart.SpectrumBarsOptions{}) }) + levels[0] = 0 + clk.t = clk.t.Add(200 * time.Millisecond) + tt.Frame() + before := bytes.Clone(tt.Image().Pix) + clk.t = clk.t.Add(-time.Hour) + tt.Frame() + // A peak flung an hour high would leave the box. + if !bytes.Equal(before, tt.Image().Pix) { + t.Fatal("the falling peak moved as the clock stepped back") + } +} + +func TestSpectrumPeaksFallInWholeFrames(t *testing.T) { + clk, levels := newClock(), []float64{1} + tt := view(200, 400, clk, func(c *ui.Context) { + chart.SpectrumBars(c, "Peaks", levels, chart.SpectrumBarsOptions{Segments: 100}) + }) + levels[0] = 0 + tt.Frame() + seen := map[string]bool{} + for range 7 { + seen[string(tt.Image().Pix)] = true + clk.t = clk.t.Add(10 * time.Millisecond) + tt.Frame() + } + // S5: 60 ms holds two pictures at most. + if len(seen) > 2 { + t.Fatalf("%d pictures in 60 ms", len(seen)) + } +} diff --git a/chart/legend.go b/chart/legend.go index fb7f253..50d5628 100644 --- a/chart/legend.go +++ b/chart/legend.go @@ -30,10 +30,12 @@ func Legend(c *ui.Context, text string, series []Series, hidden []bool, o Legend for i, s := range series { shown := !hidden[i] shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ToggleBase(c, &shown).Label(s.Name).Disabled(o.Disabled) }) - if base.Changed() { + // The base turns before it learns it is off. + if base.Changed() && !base.IsDisabled() { hidden[i], res.Changed = !shown, true slog.Debug("8bit: legend", "label", text, "series", s.Name, "shown", shown) } + shown = !hidden[i] ink := pick(s.Slot, i) shell.Size(k.D(10+pixel.Measure(s.Name)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) diff --git a/chart/spectrumbars.go b/chart/spectrumbars.go index 1490ade..4fa730b 100644 --- a/chart/spectrumbars.go +++ b/chart/spectrumbars.go @@ -37,9 +37,9 @@ func SpectrumBars(c *ui.Context, text string, levels []float64, o SpectrumBarsOp if len(pk.at) != len(levels) { pk.at = append([]float64(nil), levels...) } - now := k.Now() - // A peak falls a bar a second; reduced, it rests. - fall := now.Sub(pk.last).Seconds() + // Peaks fall a bar a second, stepped; reduced, they rest. + now := k.Now().Truncate(time.Second / 15) + fall := max(0, now.Sub(pk.last).Seconds()) switch { case pk.last.IsZero() || k.Reduce(): fall = 1 @@ -53,7 +53,7 @@ func SpectrumBars(c *ui.Context, text string, levels []float64, o SpectrumBarsOp pk.moving = pk.moving || pk.at[i] > l } if pk.moving { - k.Tick(c, 20) + k.Tick(c, 15) } shown := append([]float64(nil), pk.at...) return e.Draw(func(p *ui.Painter, r ui.Rect) { diff --git a/chart/turns_test.go b/chart/turns_test.go new file mode 100644 index 0000000..165be8d --- /dev/null +++ b/chart/turns_test.go @@ -0,0 +1,30 @@ +package chart_test + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chart" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestLegendKeyInAVeilsFirstFrameHidesNothing(t *testing.T) { + for _, veil := range []bool{false, true} { + hidden, inert := []bool{false, false}, false + tt := view(400, 200, newClock(), func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = inert + pixel.Scope(c, s, func() { + chart.Legend(c, "Key", []chart.Series{{Name: "HP"}, {Name: "MP"}}, hidden, chart.LegendOptions{}) + }) + }) + tt.Key(0, ui.KeyTab) + inert = veil + tt.Key(0, ui.KeySpace) + if hidden[0] == veil { + t.Errorf("veiled %v, Space left %v", veil, fmt.Sprint(hidden)) + } + } +} diff --git a/chat/callcontrols.go b/chat/callcontrols.go index adad40c..121d0e9 100644 --- a/chat/callcontrols.go +++ b/chat/callcontrols.go @@ -34,15 +34,17 @@ func CallControls(c *ui.Context, text string, muted, camera *bool, o CallControl } g.Finish(st) }) - return b.Changed() || b.Clicked() + // The bases turn before they learn they are off. + return (b.Changed() || b.Clicked()) && !b.IsDisabled() } + m, cam := *muted, *camera res.Element = ui.Row(c).Gap(k.D(4)).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(text).Children(func() { - if button(func() *ui.Element { return ui.ToggleBase(c, muted).Label(k.Msg("mute")) }, icon.Mic, pixel.Face, pixel.Ink, *muted) { - res.Changed = true + if button(func() *ui.Element { return ui.ToggleBase(c, &m).Label(k.Msg("mute")) }, icon.Mic, pixel.Face, pixel.Ink, *muted) { + *muted, res.Changed = m, true slog.Debug("8bit: call mute", "label", text, "muted", *muted) } - if button(func() *ui.Element { return ui.ToggleBase(c, camera).Label(k.Msg("camera")) }, icon.Camera, pixel.Face, pixel.Ink, !*camera) { - res.Changed = true + if button(func() *ui.Element { return ui.ToggleBase(c, &cam).Label(k.Msg("camera")) }, icon.Camera, pixel.Face, pixel.Ink, !*camera) { + *camera, res.Changed = cam, true slog.Debug("8bit: call camera", "label", text, "on", *camera) } if button(func() *ui.Element { return ui.ButtonBase(c).Label(k.Msg("hang_up")) }, icon.Phone, pixel.Bad, pixel.AccentInk, false) { diff --git a/chat/reactions.go b/chat/reactions.go index 73e6e53..f9a1208 100644 --- a/chat/reactions.go +++ b/chat/reactions.go @@ -38,10 +38,13 @@ func Reactions(c *ui.Context, text string, rs []Reaction, o ReactionsOptions) Re panic(fmt.Sprintf("chat: reaction %q counts %d, mine %v", r.Emoji, r.Count, r.Mine)) } art := emoji(r.Emoji) + // The base turns before it learns it is off. + v := r.Mine shell, base, st := pixel.Shell(c, func() *ui.Element { - return ui.ToggleBase(c, &r.Mine).Label(fmt.Sprintf("%s %d", r.Emoji, r.Count)).Disabled(o.Disabled).FocusRing(false) + return ui.ToggleBase(c, &v).Label(fmt.Sprintf("%s %d", r.Emoji, r.Count)).Disabled(o.Disabled).FocusRing(false) }) - if base.Changed() { + if v != r.Mine && !base.IsDisabled() { + r.Mine = v if r.Mine { r.Count++ } else { diff --git a/chat/turns_test.go b/chat/turns_test.go new file mode 100644 index 0000000..7eb328a --- /dev/null +++ b/chat/turns_test.go @@ -0,0 +1,57 @@ +package chat_test + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chat" +) + +func TestKeysInAVeilsFirstFrameTurnNothing(t *testing.T) { + cases := map[string]func(c *ui.Context, v *string){ + "Reactions": func(c *ui.Context, v *string) { + rs := []chat.Reaction{{Emoji: "love", Count: 1}} + fmt.Sscan(*v, &rs[0].Count) + chat.Reactions(c, "Reactions", rs, chat.ReactionsOptions{}) + *v = fmt.Sprint(rs[0].Count) + }, + "CallControls": func(c *ui.Context, v *string) { + muted := *v == "true" + chat.CallControls(c, "Call", &muted, new(bool), chat.CallControlsOptions{}) + *v = fmt.Sprint(muted) + }, + } + for name, build := range cases { + for _, veil := range []bool{false, true} { + start := map[string]string{"Reactions": "1", "CallControls": "false"}[name] + v, wd := start, &world{clk: newClock()} + tt := view(400, 200, wd, func(c *ui.Context) { build(c, &v) }) + tt.Key(0, ui.KeyTab) + wd.inert = veil + tt.Key(0, ui.KeySpace) + if (v != start) == veil { + t.Errorf("%s: veiled %v, Space made %s", name, veil, v) + } + } + } +} + +func TestComposerSendsNothingInAVeilsFirstFrame(t *testing.T) { + for _, veil := range []bool{false, true} { + draft, sent, wd := "", "", &world{clk: newClock()} + tt := view(400, 200, wd, func(c *ui.Context) { + if r := chat.Composer(c, "Say", &draft, chat.ComposerOptions{}); r.Sent != "" { + sent = r.Sent + } + }) + tt.Key(0, ui.KeyTab) + tt.Type("hi") + wd.inert = veil + tt.Key(0, ui.KeyEnter) + if (sent == "hi") == veil { + t.Errorf("veiled %v, Enter sent %q", veil, sent) + } + } +} diff --git a/code/clock_back_test.go b/code/clock_back_test.go new file mode 100644 index 0000000..86b0817 --- /dev/null +++ b/code/clock_back_test.go @@ -0,0 +1,23 @@ +package code_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/code" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestSpriteSheetHoldsAFrameWhenTheClockStepsBack(t *testing.T) { + wd, frame, playing := newWorld(), 0, true + sp := pixel.MustSprite("#\n\na") + tt := view(300, 200, wd, func(c *ui.Context) { code.SpriteSheet(c, "Frames", sp, &frame, &playing, code.SpriteSheetOptions{}) }) + wd.clk.t = wd.clk.t.Add(-200 * time.Millisecond) + tt.Frame() + tt.Frame() + if frame < 0 { + t.Fatalf("frame %d", frame) + } +} diff --git a/code/codeblock.go b/code/codeblock.go index 9a8b04a..fbb1551 100644 --- a/code/codeblock.go +++ b/code/codeblock.go @@ -38,7 +38,7 @@ func CodeBlock(c *ui.Context, text, src string, o CodeBlockOptions) CodeBlockRes at := ui.Local(e, copiedKey{}, func() time.Time { return time.Time{} }) // The button says COPIED! for two seconds. label := k.Msg("copy") - if k.Since(c, *at, 1, 2) < 2 { + if k.Since(c, *at, 4, 8) < 8 { label = k.Msg("copied") } e.Children(func() { diff --git a/code/glyphmap.go b/code/glyphmap.go index 60adb81..edd39c2 100644 --- a/code/glyphmap.go +++ b/code/glyphmap.go @@ -35,10 +35,10 @@ func GlyphMap(c *ui.Context, text string, at *rune, o GlyphMapOptions) GlyphMapR *at = page + rune(cur) // PageUp and PageDown turn a page, keeping the column. if !base.IsDisabled() { - if base.Shortcut(0, ui.KeyPageUp) && *at >= 128 { + if pixel.Shortcut(base, 0, ui.KeyPageUp) && *at >= 128 { *at -= 128 } - if base.Shortcut(0, ui.KeyPageDown) && *at+128 <= unicode.MaxRune { + if pixel.Shortcut(base, 0, ui.KeyPageDown) && *at+128 <= unicode.MaxRune { *at += 128 } } diff --git a/code/spritesheet.go b/code/spritesheet.go index 1a7c747..68d941e 100644 --- a/code/spritesheet.go +++ b/code/spritesheet.go @@ -59,7 +59,7 @@ func SpriteSheet(c *ui.Context, text string, sp *pixel.Sprite, frame *int, playi } if *playing { per := time.Second / time.Duration(fps) - m := int(k.Now().Sub(sh.since) / per) + m := int(k.Elapsed(&sh.since) / per) *frame = (sh.from + m) % n k.At(c, sh.since.Add(time.Duration(m+1)*per)) } diff --git a/code/terminal.go b/code/terminal.go index 5b31e34..d6d23db 100644 --- a/code/terminal.go +++ b/code/terminal.go @@ -71,7 +71,7 @@ func Terminal(c *ui.Context, text string, lines []string, input *string, o Termi } }) // Up and Down are history here, taken from the editor. - if up, down := row.Shortcut(0, ui.KeyUp), row.Shortcut(0, ui.KeyDown); (up || down) && !off { + if up, down := pixel.Shortcut(row, 0, ui.KeyUp), pixel.Shortcut(row, 0, ui.KeyDown); (up || down) && !off { res.Changed = recall(tm, input, up) || res.Changed } }) diff --git a/data/carousel.go b/data/carousel.go index b55859f..9b09096 100644 --- a/data/carousel.go +++ b/data/carousel.go @@ -39,7 +39,7 @@ func Carousel(c *ui.Context, text string, pages int, page *int, o CarouselOption wait := every == 0 || o.Disabled || pixel.Current(c).Inert || k.Reduce() || col.Hovered() || col.FocusWithin() if wait { *since = k.Now() - } else if k.Since(c, *since, 1, every) == every { + } else if k.Since(c, *since, 4, 4*every) == 4*every { turn(1) } col.Children(func() { diff --git a/data/collapsible.go b/data/collapsible.go index 1ad3e6e..94987a1 100644 --- a/data/collapsible.go +++ b/data/collapsible.go @@ -23,10 +23,14 @@ func Collapsible(c *ui.Context, title string, open *bool, o CollapsibleOptions, col := ui.Column(c).AlignItems(ui.Stretch) res := CollapsibleResult{Element: col} col.Children(func() { + // The base turns before it learns it is off. + v := *open shell, base, st := pixel.Shell(c, func() *ui.Element { - return ui.CollapsibleBase(c, open).Trigger.Label(title).Disabled(o.Disabled) + return ui.CollapsibleBase(c, &v).Trigger.Label(title).Disabled(o.Disabled) }) - res.Changed = base.Changed() + if res.Changed = v != *open && !base.IsDisabled(); res.Changed { + *open = v + } shown := *open shell.Height(k.D(pixel.LineHeight + 6)).MinWidth(k.D(pixel.Measure(title) + 20)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) diff --git a/data/gridview.go b/data/gridview.go index 3060095..65a5d06 100644 --- a/data/gridview.go +++ b/data/gridview.go @@ -86,7 +86,8 @@ func GridView(c *ui.Context, text string, n int, selected *int, o GridViewOption {ui.KeyLeft, old - 1}, {ui.KeyRight, old + 1}, {ui.KeyUp, old - cols}, {ui.KeyDown, old + cols}, {ui.KeyHome, 0}, {ui.KeyEnd, n - 1}, } { - if box.Shortcut(0, m.key) && m.to >= 0 && m.to < n { + // The grid stays enabled, so off is checked here. + if !off && pixel.Shortcut(box, 0, m.key) && m.to >= 0 && m.to < n { *selected = m.to } } @@ -94,7 +95,7 @@ func GridView(c *ui.Context, text string, n int, selected *int, o GridViewOption res.Changed = true s.rows.ScrollIntoView(*selected / cols) } - if box.Shortcut(0, ui.KeyEnter) { + if !off && pixel.Shortcut(box, 0, ui.KeyEnter) { res.Submitted = true } if off { diff --git a/data/outline.go b/data/outline.go index d4dfebe..05f0833 100644 --- a/data/outline.go +++ b/data/outline.go @@ -80,7 +80,7 @@ func Outline[K comparable](c *ui.Context, text string, roots []K, children func( pixel.Text(c, label(r.key), pixel.TextOptions{Slot: ink}) }) })).Height(k.D(pixel.Or("Outline Height", o.Height, 140))).Label(text) - right, left := e.Shortcut(0, ui.KeyRight), e.Shortcut(0, ui.KeyLeft) + right, left := pixel.Shortcut(e, 0, ui.KeyRight), pixel.Shortcut(e, 0, ui.KeyLeft) if (right || left) && st.at >= 0 && !off { at, toggled := steer(rows, st.at, st.opened, right) if toggled { diff --git a/data/sortable.go b/data/sortable.go index 8f6e448..feffa83 100644 --- a/data/sortable.go +++ b/data/sortable.go @@ -50,16 +50,16 @@ func Sortable(c *ui.Context, text string, items *[]string, o SortableOptions) So *items = slices.Insert(slices.Delete(*items, from, from+1), to, it) s.cur, res.Changed = to, true } - if base.Shortcut(0, ui.KeyDown) { + if pixel.Shortcut(base, 0, ui.KeyDown) { s.cur = min(s.cur+1, n-1) } - if base.Shortcut(0, ui.KeyUp) { + if pixel.Shortcut(base, 0, ui.KeyUp) { s.cur = max(s.cur-1, 0) } - if base.Shortcut(ui.Alt, ui.KeyDown) { + if pixel.Shortcut(base, ui.Alt, ui.KeyDown) { move(s.cur, s.cur+1) } - if base.Shortcut(ui.Alt, ui.KeyUp) { + if pixel.Shortcut(base, ui.Alt, ui.KeyUp) { move(s.cur, s.cur-1) } // A press takes a row; dragging carries it. diff --git a/data/tree.go b/data/tree.go index 2174656..74a01ec 100644 --- a/data/tree.go +++ b/data/tree.go @@ -71,7 +71,8 @@ func Tree(c *ui.Context, text string, roots []TreeNode, selected *string, o Tree if len(rows) == 0 { return TreeResult{Element: shell} } - at := 0 + // An unknown choice stays, unlit, until a key or click. + at := -1 for i, r := range rows { if r.key == *selected { at = i @@ -79,23 +80,23 @@ func Tree(c *ui.Context, text string, roots []TreeNode, selected *string, o Tree } old := *selected res := TreeResult{Element: shell} - if base.Shortcut(0, ui.KeyDown) { + if pixel.Shortcut(base, 0, ui.KeyDown) { at = min(at+1, len(rows)-1) } - if base.Shortcut(0, ui.KeyUp) { + if pixel.Shortcut(base, 0, ui.KeyUp) { at = max(at-1, 0) } - right, left := base.Shortcut(0, ui.KeyRight), base.Shortcut(0, ui.KeyLeft) - if right || left { + right, left := pixel.Shortcut(base, 0, ui.KeyRight), pixel.Shortcut(base, 0, ui.KeyLeft) + if (right || left) && at >= 0 { at, res.Toggled = steer(rows, at, *opened, right) } - enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace) + enter, space := pixel.Shortcut(base, 0, ui.KeyEnter), pixel.Shortcut(base, 0, ui.KeySpace) toggle := func(i int) { if key := rows[i].key; rows[i].branch { (*opened)[key], res.Toggled = !(*opened)[key], true } } - if enter || space { + if (enter || space) && at >= 0 { toggle(at) } // Enter and Space are taken, so clicks come from pointers. @@ -112,7 +113,9 @@ func Tree(c *ui.Context, text string, roots []TreeNode, selected *string, o Tree rows = flatten(tops, children, *opened) at = min(at, len(rows)-1) } - *selected = rows[at].key + if at >= 0 { + *selected = rows[at].key + } res.Changed = *selected != old base.Description(strings.TrimSpace(nodes[*selected].Label + " " + nodes[*selected].Value)) shown := rows diff --git a/data/turns_test.go b/data/turns_test.go new file mode 100644 index 0000000..1cde925 --- /dev/null +++ b/data/turns_test.go @@ -0,0 +1,25 @@ +package data_test + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestCollapsibleKeyInAVeilsFirstFrameOpensNothing(t *testing.T) { + for _, veil := range []bool{false, true} { + open, inert := false, false + tt := underVeil(&inert, func(c *ui.Context) { + data.Collapsible(c, "Quests", &open, data.CollapsibleOptions{}, func() { pixel.Text(c, "Slay", pixel.TextOptions{}) }) + }) + tt.Key(0, ui.KeyTab) + inert = veil + tt.Key(0, ui.KeySpace) + if open == veil { + t.Errorf("veiled %v, Space left it open %v", veil, open) + } + } +} diff --git a/data/veil_input_test.go b/data/veil_input_test.go new file mode 100644 index 0000000..0f10503 --- /dev/null +++ b/data/veil_input_test.go @@ -0,0 +1,99 @@ +package data_test + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// underVeil builds fn where the test raises a veil. +func underVeil(inert *bool, fn func(c *ui.Context)) *ui.Tester { + return view(400, 300, newClock(), false, func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = *inert + pixel.Scope(c, s, func() { fn(c) }) + }) +} + +var tree = []data.TreeNode{{Label: "Maps", Children: []data.TreeNode{{Label: "Cave"}}}, {Label: "Saves"}} + +func TestVeilsStopKeysInTheirFirstFrame(t *testing.T) { + for name, tc := range map[string]struct { + build func(c *ui.Context, v *string) + mods ui.Modifiers + key ui.Key + }{ + "Sortable": {func(c *ui.Context, v *string) { + items := []string{"A", "B", "C"} + if r := data.Sortable(c, "List", &items, data.SortableOptions{}); r.Changed { + *v = fmt.Sprint(items) + } + }, ui.Alt, ui.KeyDown}, + "Tree": {func(c *ui.Context, v *string) { data.Tree(c, "Files", tree, v, data.TreeOptions{}) }, 0, ui.KeyDown}, + "Inventory": {func(c *ui.Context, v *string) { + at := 0 + fmt.Sscan(*v, &at) + bag := []data.Item{{Sprite: icon.Sword, Name: "Sword"}, {Sprite: icon.Gem, Name: "Gem"}} + data.Inventory(c, "Bag", bag, &at, data.InventoryOptions{}) + *v = fmt.Sprint(at) + }, 0, ui.KeyRight}, + } { + v, inert := "0", false + tt := underVeil(&inert, func(c *ui.Context) { tc.build(c, &v) }) + tt.Key(0, ui.KeyTab) + inert = true + tt.Key(tc.mods, tc.key) + if v != "0" { + t.Errorf("%s: a key in the veil's first frame made %s", name, v) + } + } +} + +func TestDragsStopUnderAVeil(t *testing.T) { + items, inert := []string{"A", "B", "C"}, false + tt := underVeil(&inert, func(c *ui.Context) { data.Sortable(c, "List", &items, data.SortableOptions{}) }) + r := find(t, tt, "List") + row := r.H / 3 + tt.Press(r.X+10, r.Y+row/2) + tt.Move(r.X+10, r.Y+row*3/2) + moved := fmt.Sprint(items) + inert = true + tt.Move(r.X+10, r.Y+row*5/2) + tt.Release(r.X+10, r.Y+row*5/2) + if moved != "[B A C]" || fmt.Sprint(items) != moved { + t.Errorf("Sortable: %s before the veil, %v under it", moved, items) + } + bag, at := []data.Item{{Sprite: icon.Sword, Name: "Sword"}, {Sprite: icon.Gem, Name: "Gem"}}, 0 + inert = false + tt = underVeil(&inert, func(c *ui.Context) { data.Inventory(c, "Bag", bag, &at, data.InventoryOptions{}) }) + r = find(t, tt, "Bag") + tt.Press(r.X+20, r.Y+20) + tt.Move(r.X+64, r.Y+20) + inert = true + tt.Release(r.X+64, r.Y+20) + if bag[0].Name != "Sword" { + t.Error("Inventory swapped under the veil") + } +} + +func TestTreeLeavesAnUnknownChoiceAlone(t *testing.T) { + chosen, changed := "9/9", 0 + tt := view(400, 300, newClock(), false, func(c *ui.Context) { + if data.Tree(c, "Files", tree, &chosen, data.TreeOptions{}).Changed { + changed++ + } + }) + if chosen != "9/9" || changed != 0 { + t.Fatalf("an unknown choice became %q, %d changes", chosen, changed) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if chosen != "0" || changed != 1 { + t.Fatalf("Down chose %q, %d changes", chosen, changed) + } +} diff --git a/datetime/analogclock.go b/datetime/analogclock.go index 5476942..4e57228 100644 --- a/datetime/analogclock.go +++ b/datetime/analogclock.go @@ -2,6 +2,7 @@ package datetime import ( "math" + "time" "github.com/egoist/mygo/ui" @@ -17,8 +18,9 @@ type AnalogClockOptions struct { func AnalogClock(c *ui.Context, o AnalogClockOptions) *ui.Element { k := pixel.K(c) size := pixel.Or("AnalogClock Size", o.Size, 48) - k.Tick(c, 1) + // Wake on the next whole second. now := k.Now() + k.At(c, now.Truncate(time.Second).Add(time.Second)) h, m, s := now.Clock() // hand ends at turn (0–1 from twelve) and length r. hand := func(cx, cy int, turn float64, r int) (int, int) { diff --git a/datetime/digitalclock.go b/datetime/digitalclock.go index e84fea3..c5e91b9 100644 --- a/datetime/digitalclock.go +++ b/datetime/digitalclock.go @@ -2,6 +2,7 @@ package datetime import ( "fmt" + "time" "github.com/egoist/mygo/ui" @@ -21,13 +22,14 @@ func DigitalClock(c *ui.Context, o DigitalClockOptions) *ui.Element { if sc > 4 { panic(fmt.Sprintf("datetime: DigitalClock Scale %d outside 1–4", sc)) } - k.Tick(c, 1) + // Wake on the next whole second. now := k.Now() + k.At(c, now.Truncate(time.Second).Add(time.Second)) colon := func() { ui.Box(c).Size(k.D(3*sc), k.D(11*sc)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) // The colon blinks each half second, unless reduced. - if k.Reduce() || g.Step(2)%2 == 0 { + if k.Reduce() || g.Step(4)%4 < 2 { g.Rect(sc, 3*sc, sc, sc, pixel.Bad) g.Rect(sc, 7*sc, sc, sc, pixel.Bad) } diff --git a/datetime/grid.go b/datetime/grid.go index c04b6f1..b78859e 100644 --- a/datetime/grid.go +++ b/datetime/grid.go @@ -71,11 +71,11 @@ func month(c *ui.Context, text string, cur *time.Time, disabled, focus bool, mar {ui.KeyPageUp, addMonths(*cur, -1)}, {ui.KeyPageDown, addMonths(*cur, 1)}, {ui.KeyHome, cur.AddDate(0, 0, 1-d)}, {ui.KeyEnd, addMonths(cur.AddDate(0, 0, 1-d), 1).AddDate(0, 0, -1)}, } { - if base.Shortcut(0, mv.key) { + if pixel.Shortcut(base, 0, mv.key) { *cur = mv.to } } - enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace) + enter, space := pixel.Shortcut(base, 0, ui.KeyEnter), pixel.Shortcut(base, 0, ui.KeySpace) picked = enter || space first := time.Date(y, m, 1, 0, 0, 0, 0, cur.Location()) first = first.AddDate(0, 0, -int(first.Weekday())) diff --git a/datetime/seasonwheel.go b/datetime/seasonwheel.go index 806eb89..548b36f 100644 --- a/datetime/seasonwheel.go +++ b/datetime/seasonwheel.go @@ -35,15 +35,15 @@ func SeasonWheel(c *ui.Context, text string, season *pixel.Season, o SeasonWheel shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.Box(c).Focusable().Role(ui.RoleRadioGroup).Label(text).Disabled(o.Disabled) }) - if base.Shortcut(0, ui.KeyRight) || base.Shortcut(0, ui.KeyDown) { + if pixel.Shortcut(base, 0, ui.KeyRight) || pixel.Shortcut(base, 0, ui.KeyDown) { *season = (*season + 1) % 4 } - if base.Shortcut(0, ui.KeyLeft) || base.Shortcut(0, ui.KeyUp) { + if pixel.Shortcut(base, 0, ui.KeyLeft) || pixel.Shortcut(base, 0, ui.KeyUp) { *season = (*season + 3) % 4 } // Enter and Space are taken, so clicks come from pointers. - base.Shortcut(0, ui.KeyEnter) - base.Shortcut(0, ui.KeySpace) + pixel.Shortcut(base, 0, ui.KeyEnter) + pixel.Shortcut(base, 0, ui.KeySpace) if base.Clicked() { x, y, _ := base.PointerPosition() *season = pixel.Season(quarter(int(x/k.Dot())-wheel/2, int(y/k.Dot())-wheel/2)) diff --git a/datetime/stopwatch.go b/datetime/stopwatch.go index bd441ff..a26eec4 100644 --- a/datetime/stopwatch.go +++ b/datetime/stopwatch.go @@ -43,7 +43,7 @@ func Stopwatch(c *ui.Context, text string, o StopwatchOptions) StopwatchResult { running := !w.since.IsZero() elapsed := w.banked if running { - elapsed += now.Sub(w.since) + elapsed += k.Elapsed(&w.since) k.Tick(c, 10) } res := StopwatchResult{Element: col} diff --git a/datetime/sundial.go b/datetime/sundial.go index 9bd49e9..cadda74 100644 --- a/datetime/sundial.go +++ b/datetime/sundial.go @@ -33,10 +33,10 @@ func SunDial(c *ui.Context, text string, hour *int, o SunDialOptions) SunDialRes shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(text).Disabled(o.Disabled) }) - if base.Shortcut(0, ui.KeyLeft) { + if pixel.Shortcut(base, 0, ui.KeyLeft) { *hour = (*hour + 23) % 24 } - if base.Shortcut(0, ui.KeyRight) { + if pixel.Shortcut(base, 0, ui.KeyRight) { *hour = (*hour + 1) % 24 } // The width spans the whole day, midnight to midnight. diff --git a/datetime/taken_keys_test.go b/datetime/taken_keys_test.go new file mode 100644 index 0000000..65317da --- /dev/null +++ b/datetime/taken_keys_test.go @@ -0,0 +1,75 @@ +package datetime_test + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/datetime" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestKeysDoNotClickWhereThePointerRests(t *testing.T) { + day := time.Date(2026, 10, 12, 9, 30, 0, 0, time.UTC) + for name, tc := range map[string]struct { + build func(c *ui.Context, v *string) + // fx is where, across the control, the pointer rests. + fx float32 + after ui.Key + }{ + "WeekStrip": {func(c *ui.Context, v *string) { + d := day + datetime.WeekStrip(c, "Week", &d, datetime.WeekStripOptions{}) + if !d.Equal(day) { + *v = d.Format("Jan 2") + } + }, 0.95, 0}, + "SeasonWheel": {func(c *ui.Context, v *string) { + s := pixel.Spring + datetime.SeasonWheel(c, "Week", &s, datetime.SeasonWheelOptions{}) + if s != pixel.Spring { + *v = "turned" + } + }, 0.9, 0}, + "TimeInput": {func(c *ui.Context, v *string) { + tm := day + datetime.TimeInput(c, "Week", &tm, datetime.TimeInputOptions{}) + // Up after the keys must step the hours. + if tm.Minute() != day.Minute() { + *v = tm.Format("15:04") + } + }, 0.9, ui.KeyUp}, + } { + got := "" + tt := view(400, 300, newClock(), false, func(c *ui.Context) { tc.build(c, &got) }) + r := find(t, tt, "Week") + tt.Move(r.X+r.W*tc.fx, r.Y+r.H/2) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyEnter) + if tc.after != 0 { + tt.Key(0, tc.after) + } + if got != "" { + t.Errorf("%s: Space and Enter clicked where the pointer rests: %s", name, got) + } + } +} + +func TestStopwatchHoldsWhenTheClockStepsBack(t *testing.T) { + clk := newClock() + tt := view(400, 200, clk, false, func(c *ui.Context) { datetime.Stopwatch(c, "Watch", datetime.StopwatchOptions{}) }) + if err := tt.Click("START"); err != nil { + t.Fatal(err) + } + clk.t = clk.t.Add(-1200 * time.Millisecond) + tt.Frame() + if err := tt.Click("STOP"); err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(tt.Texts(), " "), "-") { + t.Fatalf("a negative time: %q", tt.Texts()) + } +} diff --git a/datetime/timeinput.go b/datetime/timeinput.go index 9d73dca..72a373a 100644 --- a/datetime/timeinput.go +++ b/datetime/timeinput.go @@ -47,22 +47,22 @@ func TimeInput(c *ui.Context, text string, tm *time.Time, o TimeInputOptions) Ti } s.first = -1 } - if base.Shortcut(0, ui.KeyUp) { + if pixel.Shortcut(base, 0, ui.KeyUp) { step(1) } - if base.Shortcut(0, ui.KeyDown) { + if pixel.Shortcut(base, 0, ui.KeyDown) { step(-1) } for _, mv := range []struct { key ui.Key seg int }{{ui.KeyLeft, 0}, {ui.KeyRight, 1}} { - if base.Shortcut(0, mv.key) { + if pixel.Shortcut(base, 0, mv.key) { s.seg, s.first = mv.seg, -1 } } for d := range 10 { - if !base.Shortcut(0, ui.Key0+ui.Key(d)) { + if !pixel.Shortcut(base, 0, ui.Key0+ui.Key(d)) { continue } top := 23 @@ -86,8 +86,8 @@ func TimeInput(c *ui.Context, text string, tm *time.Time, o TimeInputOptions) Ti } } // Enter and Space are taken, so clicks come from pointers. - base.Shortcut(0, ui.KeyEnter) - base.Shortcut(0, ui.KeySpace) + pixel.Shortcut(base, 0, ui.KeyEnter) + pixel.Shortcut(base, 0, ui.KeySpace) if base.Clicked() { x, _, _ := base.PointerPosition() s.seg, s.first = 0, -1 diff --git a/datetime/timer.go b/datetime/timer.go index d58ef97..cfe160d 100644 --- a/datetime/timer.go +++ b/datetime/timer.go @@ -50,7 +50,7 @@ func Timer(c *ui.Context, text string, set *time.Duration, o TimerOptions) Timer } } // It flashes for three seconds after ringing. - flash := !t.rang.IsZero() && now.Sub(t.rang) < 3*time.Second + flash := !t.rang.IsZero() && k.Elapsed(&t.rang) < 3*time.Second if flash { k.Tick(c, 4) } diff --git a/datetime/weekstrip.go b/datetime/weekstrip.go index fee70f3..2474e99 100644 --- a/datetime/weekstrip.go +++ b/datetime/weekstrip.go @@ -29,16 +29,16 @@ func WeekStrip(c *ui.Context, text string, date *time.Time, o WeekStripOptions) shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled) }) - if base.Shortcut(0, ui.KeyLeft) { + if pixel.Shortcut(base, 0, ui.KeyLeft) { *date = date.AddDate(0, 0, -1) } - if base.Shortcut(0, ui.KeyRight) { + if pixel.Shortcut(base, 0, ui.KeyRight) { *date = date.AddDate(0, 0, 1) } first := date.AddDate(0, 0, -int(date.Weekday())) // Enter and Space are taken, so clicks come from pointers. - base.Shortcut(0, ui.KeyEnter) - base.Shortcut(0, ui.KeySpace) + pixel.Shortcut(base, 0, ui.KeyEnter) + pixel.Shortcut(base, 0, ui.KeySpace) if base.Clicked() { x, _, _ := base.PointerPosition() if i := int(x / k.D(dayW)); x >= 0 && i < 7 { diff --git a/desk/appwindow.go b/desk/appwindow.go index 98a3895..211e99b 100644 --- a/desk/appwindow.go +++ b/desk/appwindow.go @@ -77,7 +77,7 @@ func AppWindow(c *ui.Context, title string, x, y *int, o AppWindowOptions, conte key ui.Key dx, dy int }{{ui.KeyLeft, -4, 0}, {ui.KeyRight, 4, 0}, {ui.KeyUp, 0, -4}, {ui.KeyDown, 0, 4}} { - if base.Shortcut(0, m.key) { + if pixel.Shortcut(base, 0, m.key) { *x, *y = *x+m.dx, *y+m.dy } } diff --git a/desk/calculator.go b/desk/calculator.go index 777dc23..8cf874b 100644 --- a/desk/calculator.go +++ b/desk/calculator.go @@ -54,12 +54,12 @@ func Calculator(c *ui.Context, text string, o CalculatorOptions) CalculatorResul // Digits and signs typed on the keyboard press their keys. if picked < 0 && !base.IsDisabled() && !pixel.Typing(c) { for i := range 10 { - if base.Shortcut(0, ui.Key0+ui.Key(i)) { + if pixel.Shortcut(base, 0, ui.Key0+ui.Key(i)) { picked = indexOf(strconv.Itoa(i)) } } for _, s := range signs { - if base.Shortcut(s.mods, s.key) { + if pixel.Shortcut(base, s.mods, s.key) { picked = indexOf(s.sign) } } diff --git a/desk/desktopicon.go b/desk/desktopicon.go index 4f2d2c4..2e98477 100644 --- a/desk/desktopicon.go +++ b/desk/desktopicon.go @@ -34,7 +34,7 @@ func DesktopIcon(c *ui.Context, label string, icon *pixel.Sprite, o DesktopIconO }) res := DesktopIconResult{Element: shell} if !base.IsDisabled() { - res.Opened = base.Shortcut(0, ui.KeyEnter) || base.DoubleClicked() + res.Opened = pixel.Shortcut(base, 0, ui.KeyEnter) || base.DoubleClicked() res.Clicked = base.Clicked() } if res.Clicked || res.Opened { diff --git a/desk/minesweeper.go b/desk/minesweeper.go index b495265..6330b2d 100644 --- a/desk/minesweeper.go +++ b/desk/minesweeper.go @@ -79,7 +79,7 @@ func Minesweeper(c *ui.Context, label string, o MinesweeperOptions) MinesweeperR }) shell, base, st, picked := pixel.Pad(c, k.Msg("minefield"), cols, cols*rows, mcell, mcell, &f.cur, o.Disabled) if !base.IsDisabled() && !f.over { - if !pixel.Typing(c) && base.Shortcut(0, ui.KeyF) { + if !pixel.Typing(c) && pixel.Shortcut(base, 0, ui.KeyF) { res.Flagged = f.toggle(f.cur) } if base.RightClicked() { diff --git a/desk/rundialog.go b/desk/rundialog.go index 8006e4a..1ac1362 100644 --- a/desk/rundialog.go +++ b/desk/rundialog.go @@ -70,7 +70,7 @@ func RunDialog(c *ui.Context, text string, value *string, o RunDialogOptions) Ru }) }) // Escape quits from anywhere inside, as CANCEL does. - if !o.Disabled && !pixel.Current(c).Inert && win.Shortcut(0, ui.KeyEscape) { + if !o.Disabled && !pixel.Current(c).Inert && pixel.Shortcut(win, 0, ui.KeyEscape) { res.Canceled = true } res.Element = win diff --git a/desk/snake.go b/desk/snake.go index d47e39b..ac0b6b2 100644 --- a/desk/snake.go +++ b/desk/snake.go @@ -61,13 +61,13 @@ func Snake(c *ui.Context, text string, o SnakeOptions) SnakeResult { d [2]int }{{ui.KeyUp, [2]int{0, -1}}, {ui.KeyDown, [2]int{0, 1}}, {ui.KeyLeft, [2]int{-1, 0}}, {ui.KeyRight, [2]int{1, 0}}} { // A turn back onto its own neck is ignored. - if base.Shortcut(0, m.key) && m.d != [2]int{-s.dir[0], -s.dir[1]} && !s.dead { + if pixel.Shortcut(base, 0, m.key) && m.d != [2]int{-s.dir[0], -s.dir[1]} && !s.dead { res.Started = res.Started || !s.playing s.next, s.playing = m.d, true } } // Enter, Space or a click starts it, or CONTINUEs. - enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace) + enter, space := pixel.Shortcut(base, 0, ui.KeyEnter), pixel.Shortcut(base, 0, ui.KeySpace) if enter || space || base.Clicked() { if s.dead { s.reset(cols, rows, o.Seed, uint64(k.Now().UnixNano())) diff --git a/desk/startmenu.go b/desk/startmenu.go index f17298c..fdb94d8 100644 --- a/desk/startmenu.go +++ b/desk/startmenu.go @@ -48,11 +48,8 @@ func StartMenu(c *ui.Context, text string, programs []Program, o StartMenuOption off := o.Disabled || pixel.Current(c).Inert b := input.Button(c, k.Msg("start"), input.ButtonOptions{Icon: icon.Gamepad, Label: text, Disabled: o.Disabled}) m := ui.Local(b.Element, startKey{}, func() start { return start{} }) + // START keeps the focus and parks it while off. if off { - // Closing on disable must not return focus to it. - if m.open { - c.Root().Focus() - } m.open = false } res := StartMenuResult{Element: b.Element} @@ -60,7 +57,7 @@ func StartMenu(c *ui.Context, text string, programs []Program, o StartMenuOption switch { case b.Clicked && m.open: m.open = false - case b.Clicked, !m.open && !off && b.Element.Shortcut(0, ui.KeyUp): + case b.Clicked, !m.open && !off && pixel.Shortcut(b.Element, 0, ui.KeyUp): m.open, m.path = true, []int{0} } if m.open { diff --git a/desk/veil_focus_test.go b/desk/veil_focus_test.go new file mode 100644 index 0000000..7b98795 --- /dev/null +++ b/desk/veil_focus_test.go @@ -0,0 +1,29 @@ +package desk_test + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/desk" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" +) + +func TestStartMenuGetsFocusBackAfterAVeil(t *testing.T) { + wd := newWorld() + programs := []desk.Program{{Name: "Paint", Icon: icon.File}, {Name: "Notes", Icon: icon.File}} + tt := view(400, 300, wd, func(c *ui.Context) { desk.StartMenu(c, "Start", programs, desk.StartMenuOptions{}) }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + if !tt.HasText("Notes") { + t.Fatalf("the menu did not open: %q", tt.Texts()) + } + wd.inert = true + tt.Frame() + wd.inert = false + tt.Frame() + tt.Key(0, ui.KeyUp) + if !tt.HasText("Notes") { + t.Fatal("START did not get its focus back after the veil") + } +} diff --git a/display/display_test.go b/display/display_test.go index f8b8b53..8281d0d 100644 --- a/display/display_test.go +++ b/display/display_test.go @@ -92,7 +92,7 @@ func TestSpritePlaysAndMirrors(t *testing.T) { sp := pixel.MustSprite("r.\n..\n\n.r\n..") tt := view(100, 100, e, func(c *ui.Context) { ui.Column(c).Children(func() { - display.Sprite(c, sp, display.SpriteOptions{FPS: 1, Label: "s"}) + display.Sprite(c, sp, display.SpriteOptions{FPS: 4, Label: "s"}) display.Sprite(c, sp, display.SpriteOptions{Mirror: true, Label: "m"}) }) }) @@ -101,7 +101,7 @@ func TestSpritePlaysAndMirrors(t *testing.T) { if f0[0] != pixel.Bad || at("m")[1] != pixel.Bad { t.Fatalf("frame 0 %v, mirrored %v", f0, at("m")) } - e.clk.t = e.clk.t.Add(time.Second) + e.clk.t = e.clk.t.Add(time.Second / 4) tt.Frame() if at("s")[1] != pixel.Bad { t.Fatal("the sprite did not play its second frame") diff --git a/display/rarityframe.go b/display/rarityframe.go index 6994e50..69d1a66 100644 --- a/display/rarityframe.go +++ b/display/rarityframe.go @@ -41,7 +41,7 @@ func RarityFrame(c *ui.Context, r Rarity, o RarityFrameOptions, children func()) return } per := 2*(g.W+g.H) - 4 - at := g.Step(20) * 2 % per + at := g.Step(10) * 4 % per for i := range 6 { x, y := around(g.W, g.H, (at+i)%per) g.Dot(x, y, pixel.Lit) diff --git a/display/sprite.go b/display/sprite.go index 8e019cd..be3bf2c 100644 --- a/display/sprite.go +++ b/display/sprite.go @@ -9,7 +9,7 @@ import ( type SpriteOptions struct { Slot pixel.Slot Scale int - // FPS plays the frames; zero shows the first. + // FPS plays the frames at 4–15; zero shows the first. FPS int Mirror bool Label string diff --git a/display/statusdot.go b/display/statusdot.go index e0f8c3b..f777481 100644 --- a/display/statusdot.go +++ b/display/statusdot.go @@ -40,7 +40,7 @@ func StatusDot(c *ui.Context, s Status, o StatusDotOptions) *ui.Element { } return art(c, k, label, 7, 7, func(g pixel.Canvas) { g.Disc(3, 3, 3, pixel.Edge) - if !(o.Live && s == Online && g.Step(2)%2 == 1) { + if !(o.Live && s == Online && g.Step(4)%4 >= 2) { g.Disc(3, 3, 2, statusSlots[s]) g.Dot(2, 2, pixel.Lit) } diff --git a/feedback/clock_back_test.go b/feedback/clock_back_test.go new file mode 100644 index 0000000..3e2ea5d --- /dev/null +++ b/feedback/clock_back_test.go @@ -0,0 +1,51 @@ +package feedback_test + +import ( + "strconv" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback" +) + +func TestCountdownsHoldWhenTheClockStepsBack(t *testing.T) { + for name, tc := range map[string]struct { + build func(c *ui.Context) + start string + }{ + "ContinuePrompt": {func(c *ui.Context) { feedback.ContinuePrompt(c, feedback.ContinuePromptOptions{}) }, "10"}, + "ErrorState": {func(c *ui.Context) { feedback.ErrorState(c, "LOST", feedback.ErrorStateOptions{}) }, "9"}, + } { + clk := newClock() + tt := view(500, 400, clk, false, tc.build) + clk.t = clk.t.Add(-time.Hour) + tt.Frame() + // The count, a text's last word, never passes its start. + start, _ := strconv.Atoi(tc.start) + for _, s := range tt.Texts() { + f := strings.Fields(s) + if len(f) == 0 { + continue + } + if n, err := strconv.Atoi(f[len(f)-1]); err == nil && n > start { + t.Errorf("%s counts %d after the clock stepped back", name, n) + } + } + } +} + +func TestToastsExpireAfterTheClockStepsBack(t *testing.T) { + clk := newClock() + queue := []feedback.Toast{{Message: "SAVED"}} + tt := view(500, 300, clk, false, func(c *ui.Context) { feedback.Toasts(c, &queue, feedback.ToastsOptions{}) }) + clk.t = clk.t.Add(-time.Hour) + tt.Frame() + clk.t = clk.t.Add(4 * time.Second) + tt.Frame() + if len(queue) != 0 { + t.Fatalf("a toast outlived its three seconds: %d left", len(queue)) + } +} diff --git a/feedback/continueprompt.go b/feedback/continueprompt.go index be68f96..6b96276 100644 --- a/feedback/continueprompt.go +++ b/feedback/continueprompt.go @@ -37,7 +37,7 @@ func ContinuePrompt(c *ui.Context, o ContinuePromptOptions) ContinuePromptResult n := pixel.Or("ContinuePrompt Seconds", o.Seconds, 10) col := ui.Column(c).Gap(k.D(6)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(k.Msg("continue")) st := ui.Local(col, promptKey{}, func() prompt { return prompt{start: k.Now()} }) - left := max(0, n-int(k.Now().Sub(st.start)/time.Second)) + left := max(0, n-max(0, int(k.Now().Sub(st.start)/time.Second))) res := ContinuePromptResult{Element: col} if left == 0 && !st.over { st.over, res.TimedOut = true, true diff --git a/feedback/shake.go b/feedback/shake.go index 6c4588f..70a48c1 100644 --- a/feedback/shake.go +++ b/feedback/shake.go @@ -18,6 +18,6 @@ var shakes = [...]int{2, -2, 2, -2, 1, -1, 0} func Shake(c *ui.Context, o ShakeOptions, content func()) *ui.Element { k := pixel.K(c) e := ui.Box(c) - dx := shakes[k.Shot(c, e, o.Cue, 20, len(shakes)-1)] + dx := shakes[k.Shot(c, e, o.Cue, 15, len(shakes)-1)] return e.Padding(0, k.D(2-dx), 0, k.D(2+dx)).Children(content) } diff --git a/feedback/states.go b/feedback/states.go index 1dc1323..68041d2 100644 --- a/feedback/states.go +++ b/feedback/states.go @@ -71,7 +71,7 @@ func ErrorState(c *ui.Context, message string, o ErrorStateOptions) ErrorStateRe k := pixel.K(c) col := ui.Column(c).Gap(k.D(8)).Padding(k.D(16)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(k.Msg("game_over")) start := ui.Local(col, startKey{}, func() time.Time { return k.Now() }) - left := max(0, 9-int(k.Now().Sub(*start)/time.Second)) + left := max(0, 9-max(0, int(k.Now().Sub(*start)/time.Second))) if left > 0 { k.Tick(c, 10) } @@ -103,7 +103,7 @@ func SuccessState(c *ui.Context, message string, o SuccessStateOptions) *ui.Elem // Stars come four a second; Reduce Motion shows them all. shown := n + 1 if !k.Reduce() { - shown = min(n+1, int(k.Now().Sub(*start)*4/time.Second)) + shown = min(n+1, max(0, int(k.Now().Sub(*start)*4/time.Second))) if shown <= n { k.Tick(c, 8) } diff --git a/feedback/toasts.go b/feedback/toasts.go index e8bac30..1c32c64 100644 --- a/feedback/toasts.go +++ b/feedback/toasts.go @@ -51,7 +51,7 @@ func Toasts(c *ui.Context, queue *[]Toast, o ToastsOptions) ToastsResult { if life == 0 { life = 3 * time.Second } - if t.shown.IsZero() || now.Sub(t.shown) < life { + if t.shown.IsZero() || k.Elapsed(&t.shown) < life { kept = append(kept, t) } } diff --git a/files/sharepanel.go b/files/sharepanel.go index 8e29f17..f74479e 100644 --- a/files/sharepanel.go +++ b/files/sharepanel.go @@ -57,7 +57,7 @@ func SharePanel(c *ui.Context, text, link string, access *Access, o SharePanelOp at := ui.Local(e, shareKey{}, func() time.Time { return time.Time{} }) // The button says COPIED! for two seconds. label := k.Msg("copy") - if k.Since(c, *at, 1, 2) < 2 { + if k.Since(c, *at, 4, 8) < 8 { label = k.Msg("copied") } e.Children(func() { diff --git a/fx/palettecycle.go b/fx/palettecycle.go index a5034cd..7e899f8 100644 --- a/fx/palettecycle.go +++ b/fx/palettecycle.go @@ -9,7 +9,7 @@ import ( ) type PaletteCycleOptions struct { - // FPS is steps a second; zero means 8. + // FPS is 4–15 steps a second; zero means 8. FPS int // Slot inks the sprite's # dots. Slot pixel.Slot diff --git a/gallery/app.go b/gallery/app.go index 85b8069..333c403 100644 --- a/gallery/app.go +++ b/gallery/app.go @@ -99,7 +99,8 @@ func (a *App) step(d int) { // logger records a demo's events for the log panel. func (a *App) logger(e Entry) func(string) { return func(ev string) { - slog.Debug("8bit: demo", "entry", e.Slug(), "event", ev) + // The panel shows what was typed; logs never do. + slog.Debug("8bit: demo", "entry", e.Slug()) l := append(a.logs[e.Slug()], ev) a.logs[e.Slug()] = l[max(0, len(l)-8):] } diff --git a/gallery/chat_log_test.go b/gallery/chat_log_test.go new file mode 100644 index 0000000..3275ef1 --- /dev/null +++ b/gallery/chat_log_test.go @@ -0,0 +1,35 @@ +package gallery + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestChatDemosLogNoTypedText(t *testing.T) { + for _, tc := range []struct{ path, field string }{{"/c/323-composer", "Message"}, {"/c/329-mentioninput", "Mention"}} { + a, tt := open(tc.path) + click(t, tt, tc.field) + tt.Type("xyzzy") + tt.Key(0, ui.KeyEnter) + logs := a.logs[tc.path[3:]] + if len(logs) == 0 || strings.Contains(strings.Join(logs, "\n"), "xyzzy") { + t.Errorf("%s logged %q", tc.path, logs) + } + } +} + +func TestPaletteInUseSpeaksTheLocale(t *testing.T) { + a, tt := open("/") + click(t, tt, "language") + if a.locale != pixel.Zh { + t.Fatal("no Chinese") + } + name := pixel.Palettes[a.palette].Name + ", 使用中" + if _, ok := tt.Find(name); !ok { + t.Fatalf("no %q in %q", name, tt.Texts()) + } +} diff --git a/gallery/chrome.go b/gallery/chrome.go index 40da1fd..bb15d52 100644 --- a/gallery/chrome.go +++ b/gallery/chrome.go @@ -104,7 +104,7 @@ func (a *App) sidebar(c *ui.Context) { } func (a *App) statusBar(c *ui.Context) { - layout.StatusBar(c, "/ search · P palette · [ ] prev next", fmt.Sprintf("%d entries", len(Entries()))) + layout.StatusBar(c, []string{"/ search · P palette · [ ] prev next", fmt.Sprintf("%d entries", len(Entries()))}, layout.StatusBarOptions{}) } // press is a custom-drawn chrome tile; true when clicked. diff --git a/gallery/clock_back_test.go b/gallery/clock_back_test.go new file mode 100644 index 0000000..9202260 --- /dev/null +++ b/gallery/clock_back_test.go @@ -0,0 +1,84 @@ +package gallery + +import ( + "regexp" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestDemosSurviveTheClockSteppingBack(t *testing.T) { + negative := regexp.MustCompile(`(^|[\s(:])-\d`) + for _, e := range Entries() { + at := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }}) + ui.Column(c).Fill().Children(func() { e.Demo(c, func(string) {}) }) + }, 640, 480) + // Press up to three buttons, run, then fall back. + for _, b := range buttons(tt, 3) { + if r, ok := tt.Find(b); ok { + tt.ClickAt(r.X+r.W/2, r.Y+r.H/2) + } + } + at = at.Add(500 * time.Millisecond) + tt.Frame() + // Negative numbers shown before are content, not faults. + before := map[string]bool{} + for _, s := range tt.Texts() { + before[s] = true + } + for _, d := range []time.Duration{-time.Hour, 300 * time.Millisecond} { + at = at.Add(d) + tt.Frame() + } + for _, s := range tt.Texts() { + if negative.MatchString(s) && !before[s] { + t.Errorf("%s shows %q after the clock stepped back", e.Slug(), s) + } + } + } +} + +// buttons names up to n pressable words on screen. +func buttons(tt *ui.Tester, n int) []string { + var out []string + for _, s := range tt.Texts() { + if len(out) < n && len(s) > 1 && s == upper(s) && strings.ContainsAny(s, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { + out = append(out, s) + } + } + return out +} + +func upper(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'a' && c <= 'z' { + b[i] = c - 32 + } + } + return string(b) +} + +func TestPlayingMediaSurvivesTheClockSteppingBack(t *testing.T) { + for _, slug := range []string{"346-audioplayer", "353-cassette", "360-videoframe"} { + e, _ := find(slug) + at := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }}) + ui.Column(c).Fill().Children(func() { e.Demo(c, func(string) {}) }) + }, 640, 480) + if err := tt.Click("PLAY"); err != nil { + t.Fatalf("%s: %v", slug, err) + } + for _, d := range []time.Duration{500 * time.Millisecond, -time.Second, 200 * time.Millisecond} { + at = at.Add(d) + tt.Frame() + } + } +} diff --git a/gallery/demo_slog_test.go b/gallery/demo_slog_test.go new file mode 100644 index 0000000..b1bdbfb --- /dev/null +++ b/gallery/demo_slog_test.go @@ -0,0 +1,44 @@ +package gallery + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestDemoEventsKeepTypedTextOutOfLogs(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(prev) + // A wrong vault code: four digits, then WRONG CODE. + a, tt := open("/c/094-pininput") + click(t, tt, "Vault code") + tt.Type("9876") + shown := strings.Join(a.logs["094-pininput"], "\n") + if !strings.Contains(shown, "WRONG CODE") || !strings.Contains(buf.String(), "8bit: demo") { + t.Fatalf("no demo event logged: %q", shown) + } + if strings.Contains(buf.String(), "9876") || strings.Contains(shown, "9876") { + t.Fatalf("the code reached a log: %q", shown) + } +} + +func TestDemoPanelsShowWhatLogsDoNot(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(prev) + a, tt := open("/c/099-editabletext") + click(t, tt, "Hero name") + tt.Key(0, ui.KeyEnter) + tt.Type("Zork") + tt.Key(0, ui.KeyEnter) + shown := strings.Join(a.logs["099-editabletext"], "\n") + if !strings.Contains(shown, "Zork") || strings.Contains(buf.String(), "Zork") { + t.Fatalf("panel %q; the log has the name: %v", shown, strings.Contains(buf.String(), "Zork")) + } +} diff --git a/gallery/demos_chart.go b/gallery/demos_chart.go index dffc363..95e32e3 100644 --- a/gallery/demos_chart.go +++ b/gallery/demos_chart.go @@ -26,8 +26,8 @@ var chartEntries = []Entry{ {225, "Histogram", "直方图", "chart", "Damage rolls in bins. ROLL throws ten more.", demoHistogram}, {226, "ScatterPlot", "散点图", "chart", "Every hit by distance and damage, one 2×2 dot each.", demoScatterPlot}, {227, "BubbleChart", "气泡图", "chart", "Towns by trade and danger, sized by how many live there.", demoBubbleChart}, - {228, "Heatmap", "热力图", "chart", "Raids by day and hour. Hot cells dither red.", demoHeatmap}, - {229, "CalendarHeatmap", "日历热力图", "chart", "A year of play, a square a day. A commit graph for slimes.", demoCalendarHeatmap}, + {228, "Heatmap", "密度图", "chart", "Raids by day and hour. Hot cells dither red.", demoHeatmap}, + {229, "CalendarHeatmap", "日历密度图", "chart", "A year of play, a square a day. A commit graph for slimes.", demoCalendarHeatmap}, {230, "Gauge", "仪表", "chart", "Push the throttle; the needle follows.", demoGauge}, } diff --git a/gallery/demos_chat.go b/gallery/demos_chat.go index 1dc55eb..37a0990 100644 --- a/gallery/demos_chat.go +++ b/gallery/demos_chat.go @@ -3,6 +3,7 @@ package gallery import ( "fmt" "time" + "unicode/utf8" "github.com/egoist/mygo/ui" @@ -16,7 +17,7 @@ var chatEntries = []Entry{ {323, "Composer", "输入栏", "chat", "Enter sends, Shift+Enter breaks the line. SEND ▶ works too.", demoComposer}, {324, "TypingIndicator", "正在输入", "chat", "Three dots hop while someone types. Suspense!", demoTypingIndicator}, {325, "ChatList", "会话列表", "chat", "Who said what last, and how much you missed.", demoChatList}, - {326, "ReadReceipt", "已读回执", "chat", "One check: sent. Two checks: seen. No excuses.", demoReadReceipt}, + {326, "ReadReceipt", "已读标记", "chat", "One check: sent. Two checks: seen. No excuses.", demoReadReceipt}, {327, "Reactions", "表情回应", "chat", "Click a chip to add your face to the pile, again to take it back.", demoReactions}, {328, "EmojiPicker", "表情选择", "chat", "Faces, the world and loot, eight to a row.", demoEmojiPicker}, {329, "MentionInput", "提及输入", "chat", "Type @ and a few letters; Tab finishes the name.", demoMentionInput}, @@ -92,10 +93,11 @@ func demoComposer(c *ui.Context, log func(string)) { s := chatState(c) r := chat.Composer(c, "Message", &s.draft, chat.ComposerOptions{Placeholder: "Say something…"}) if r.Changed { - log(fmt.Sprintf("draft %q", s.draft)) + log(fmt.Sprintf("draft of %d letters", utf8.RuneCountInString(s.draft))) } + // Counts, not words: logs never hold what was typed. if r.Sent != "" { - log("sent: " + r.Sent) + log(fmt.Sprintf("sent %d letters", utf8.RuneCountInString(r.Sent))) } } @@ -149,13 +151,13 @@ func demoMentionInput(c *ui.Context, log func(string)) { s := chatState(c) r := chat.MentionInput(c, "Mention", &s.mention, []string{"Aria", "Arthur", "Brom", "Mittens"}, chat.MentionInputOptions{Placeholder: "Try @ar"}) if r.Changed { - log(fmt.Sprintf("text %q", s.mention)) + log(fmt.Sprintf("text of %d letters", utf8.RuneCountInString(s.mention))) } if r.Mentioned != "" { log("mentioned " + r.Mentioned) } if r.Submitted { - log("sent: " + s.mention) + log(fmt.Sprintf("sent %d letters", utf8.RuneCountInString(s.mention))) } } diff --git a/gallery/demos_code.go b/gallery/demos_code.go index b017d86..e423d5d 100644 --- a/gallery/demos_code.go +++ b/gallery/demos_code.go @@ -189,7 +189,7 @@ func demoFPSMeter(c *ui.Context, log func(string)) { } } if !k.Reduce() { - if t := k.Tick(c, 12); s.last.IsZero() || k.Now().Sub(s.last) >= time.Second/12 { + if t := k.Tick(c, 12); s.last.IsZero() || k.Elapsed(&s.last) >= time.Second/12 { s.last = k.Now() add(t) } diff --git a/gallery/demos_controls.go b/gallery/demos_controls.go index 623f652..426e1d0 100644 --- a/gallery/demos_controls.go +++ b/gallery/demos_controls.go @@ -19,7 +19,7 @@ var controlEntries = []Entry{ {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}, + {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}, @@ -29,9 +29,9 @@ var controlEntries = []Entry{ {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}, + {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}, + {105, "SlotPicker", "转轮选择", "input", "Reels that spin by drag, wheel or arrows. Line up three for the jackpot.", demoSlotPicker}, } type controlDemo struct { @@ -149,7 +149,7 @@ func demoPinInput(c *ui.Context, log func(string)) { if s.pin == "0451" { log("ACCESS GRANTED") } else { - log("WRONG CODE " + s.pin) + log("WRONG CODE") } } } diff --git a/gallery/demos_desk.go b/gallery/demos_desk.go index d177581..94ecb52 100644 --- a/gallery/demos_desk.go +++ b/gallery/demos_desk.go @@ -16,7 +16,7 @@ var deskEntries = []Entry{ {467, "DesktopIcon", "桌面图标", "desk", "Click to choose and it inverts; double-click or Enter opens it.", demoDesktopIcon}, {468, "Taskbar", "任务栏", "desk", "START, a button for each open window, the tray and the clock.", demoTaskbar}, {469, "StartMenu", "开始菜单", "desk", "Programs in cascading menus. Arrows walk in and out; Enter runs one.", demoStartMenu}, - {470, "AppWindow", "应用窗口", "desk", "Drag a window by its title bar, or focus the bar and use the arrows.", demoAppWindow}, + {470, "AppWindow", "应用框体", "desk", "Drag a window by its title bar, or focus the bar and use the arrows.", demoAppWindow}, {471, "Calculator", "计算器", "desk", "It really adds up. Divide by zero for a GAME OVER; C continues.", demoCalculator}, {472, "Notepad", "记事本", "desk", "A text area under FILE and EDIT. TIME/DATE stamps the moment.", demoNotepad}, {473, "Minesweeper", "扫雷", "desk", "Open with a click or Enter, flag with a right click or F. The face starts over.", demoMinesweeper}, diff --git a/gallery/demos_display.go b/gallery/demos_display.go index 906c9e6..2c0e51e 100644 --- a/gallery/demos_display.go +++ b/gallery/demos_display.go @@ -20,11 +20,11 @@ var displayEntries = []Entry{ {43, "Picture", "像素图", "display", "Any image reduced to the palette with Bayer dither. Press P: it recolors.", demoPicture}, {44, "Avatar", "头像", "display", "A face built from a name: skin, hair, eyes, mouth and background.", demoAvatar}, {45, "AvatarGroup", "头像组", "display", "Overlapping faces and a chip for the rest.", demoAvatarGroup}, - {46, "Badge", "徽标", "display", "A count or a word in a notched pill; counts stop at 99+.", demoBadge}, + {46, "Badge", "角标", "display", "A count or a word in a notched pill; counts stop at 99+.", demoBadge}, {47, "Tag", "标签", "display", "A chip with text, an icon and a remove box.", demoTag}, {48, "StatusDot", "状态点", "display", "Online, away, busy or offline; a live online dot blinks.", demoStatusDot}, - {49, "Medal", "奖牌", "display", "Gold, silver and bronze on ribbons, rank on the face.", demoMedal}, - {50, "Trophy", "奖杯", "display", "A gold cup over an engraved plaque.", demoTrophy}, + {49, "Medal", "勋章", "display", "Gold, silver and bronze on ribbons, rank on the face.", demoMedal}, + {50, "Trophy", "金杯", "display", "A gold cup over an engraved plaque.", demoTrophy}, {51, "Stat", "数据", "display", "A big value, its label and a change up or down.", demoStat}, {52, "KeyValue", "键值", "display", "A label and a value joined by a dotted leader.", demoKeyValue}, {53, "Portrait", "立绘框", "display", "A character in a frame over a nameplate.", demoPortrait}, @@ -73,8 +73,8 @@ func demoIcon(c *ui.Context, log func(string)) { func demoSprite(c *ui.Context, log func(string)) { k := pixel.K(c) ui.Row(c).Gap(k.D(12)).Children(func() { - display.Sprite(c, slime, display.SpriteOptions{Scale: 3, FPS: 2, Label: "slime"}) - display.Sprite(c, slime, display.SpriteOptions{Scale: 3, FPS: 2, Mirror: true, Label: "slime, mirrored"}) + display.Sprite(c, slime, display.SpriteOptions{Scale: 3, FPS: 4, Label: "slime"}) + display.Sprite(c, slime, display.SpriteOptions{Scale: 3, FPS: 4, Mirror: true, Label: "slime, mirrored"}) }) } diff --git a/gallery/demos_feedback.go b/gallery/demos_feedback.go index e5b70bd..67377ae 100644 --- a/gallery/demos_feedback.go +++ b/gallery/demos_feedback.go @@ -14,7 +14,7 @@ import ( var feedbackEntries = []Entry{ {106, "Progress", "进度条", "feedback", "A bar that fills chunk by chunk. Below zero, a block runs back and forth.", demoProgress}, - {107, "Spinner", "旋转器", "feedback", "Eight frames of beads going round. It keeps turning under Reduce Motion: it means work.", demoSpinner}, + {107, "Spinner", "转圈器", "feedback", "Eight frames of beads going round. It keeps turning under Reduce Motion: it means work.", demoSpinner}, {108, "Meter", "计量条", "feedback", "A level that turns Warn past a mark and Bad past the next.", demoMeter}, {109, "Skeleton", "骨架屏", "feedback", "Dithered stand-ins with a shine sweeping across in steps.", demoSkeleton}, {110, "Alert", "提示框", "feedback", "A framed callout: info, success, warning or danger, with an optional ×.", demoAlert}, @@ -27,15 +27,15 @@ var feedbackEntries = []Entry{ {117, "BarsLoader", "柱状加载", "feedback", "Five bars rising and falling, a step apart.", demoBarsLoader}, {118, "Hourglass", "沙漏", "feedback", "Sand falls in steps, then the glass flips.", demoHourglass}, {119, "Toast", "吐司", "feedback", "Short messages at the bottom, three at a time, each for a few seconds. Some can be undone.", demoToast}, - {120, "InlineMessage", "行内提示", "feedback", "A small ! or ✓ note under a field.", demoInlineMessage}, + {120, "InlineMessage", "行内提示", "feedback", "A small warning or check note under a field.", demoInlineMessage}, {121, "CountUp", "计数", "feedback", "Numbers count up in steps, the way scores should.", demoCountUp}, {122, "Pulse", "脉冲", "feedback", "A dithered ring grows around what wants attention.", demoPulse}, {123, "LevelUp", "升级", "feedback", "LEVEL UP! slides in with sparkles when the level rises.", demoLevelUp}, {124, "Achievement", "成就", "feedback", "A trophy card slides in, waits, and slides away.", demoAchievement}, {125, "ComboCounter", "连击", "feedback", "×12 COMBO! It pops a dot bigger with every hit.", demoComboCounter}, {126, "ContinuePrompt", "继续?", "feedback", "CONTINUE? YES or NO before the count runs out.", demoContinuePrompt}, - {127, "SaveIndicator", "存档指示", "feedback", "A floppy spins while saving, then SAVED ✓.", demoSaveIndicator}, - {128, "BusyOverlay", "忙碌遮罩", "feedback", "A checker veil and spinner over busy content; it takes the pointer and the focus.", demoBusyOverlay}, + {127, "SaveIndicator", "存档指示", "feedback", "A floppy spins while saving, then SAVED with a check.", demoSaveIndicator}, + {128, "BusyOverlay", "加载遮罩", "feedback", "A checker veil and spinner over busy content; it takes the pointer and the focus.", demoBusyOverlay}, {129, "ReadyGo", "预备开始", "feedback", "3, 2, 1, GO! in big digits, a second each.", demoReadyGo}, {130, "Shake", "抖动", "feedback", "Jolts its child side to side, for a wrong answer.", demoShake}, } diff --git a/gallery/demos_files.go b/gallery/demos_files.go index d7a4964..95e29e7 100644 --- a/gallery/demos_files.go +++ b/gallery/demos_files.go @@ -20,7 +20,7 @@ var filesEntries = []Entry{ {409, "PathBar", "路径栏", "files", "Where you are, folder by folder. Click one to climb back up.", demoPathBar}, {410, "MemoryCard", "记忆卡", "files", "Fifteen blocks, like the grey card of old. DELETE frees one.", demoMemoryCard}, {411, "DiskUsage", "磁盘占用", "files", "Where the room went, stacked in one bar.", demoDiskUsage}, - {412, "UploadQueue", "上传队列", "files", "Files on their way up. ✕ calls one back.", demoUploadQueue}, + {412, "UploadQueue", "上传队列", "files", "Files on their way up. × calls one back.", demoUploadQueue}, {413, "Trash", "回收站", "files", "A bin that looks full when it is. EMPTY takes it out.", demoTrash}, } diff --git a/gallery/demos_foundations.go b/gallery/demos_foundations.go index f79e1c3..7805adc 100644 --- a/gallery/demos_foundations.go +++ b/gallery/demos_foundations.go @@ -26,7 +26,9 @@ func demoPalettes(c *ui.Context, log func(string)) { k := pixel.K(c) ui.Grid(c).Columns(2).Gap(k.D(6)).Children(func() { for _, pal := range pixel.Palettes { - pixel.Scope(c, pixel.Settings{Palette: pal}, func() { + s := pixel.Current(c) + s.Palette = pal + pixel.Scope(c, s, func() { kp := pixel.K(c) shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(pal.Name) }) shell.Height(kp.D(52)).Draw(func(p *ui.Painter, r ui.Rect) { diff --git a/gallery/demos_fx.go b/gallery/demos_fx.go index a485675..cb197b5 100644 --- a/gallery/demos_fx.go +++ b/gallery/demos_fx.go @@ -61,7 +61,7 @@ func paletteOf(c *ui.Context, s pixel.Season, night bool) pixel.Settings { // over stacks an effect on a same-sized sky. func over(c *ui.Context, w, h int, effect func()) { - layout.Layers(c, func() { fx.Sky(c, fx.SkyOptions{Width: w, Height: h}) }, effect) + layout.Layers(c, layout.LayersOptions{}, func() { fx.Sky(c, fx.SkyOptions{Width: w, Height: h}) }, effect) } // portalBand rings the portal, outside in. @@ -154,7 +154,7 @@ func demoLava(c *ui.Context, log func(string)) { func demoAurora(c *ui.Context, log func(string)) { pixel.Scope(c, paletteOf(c, pixel.K(c).Palette().Season, true), func() { - layout.Layers(c, func() { fx.Sky(c, fx.SkyOptions{Width: 140, Height: 80}) }, + layout.Layers(c, layout.LayersOptions{}, func() { fx.Sky(c, fx.SkyOptions{Width: 140, Height: 80}) }, func() { fx.Aurora(c, fx.AuroraOptions{Width: 140, Height: 80, Label: "aurora"}) }, func() { fx.Mountains(c, fx.MountainsOptions{Width: 140, Height: 80}) }) }) diff --git a/gallery/demos_game.go b/gallery/demos_game.go index 3f1c265..eb5e3ff 100644 --- a/gallery/demos_game.go +++ b/gallery/demos_game.go @@ -178,7 +178,7 @@ func demoTimerHUD(c *ui.Context, log func(string)) { s := gameState(c) left := max(0, s.deadline.Sub(k.Now())) if left > 0 { - k.Tick(c, 2) + k.Tick(c, 4) } ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() { game.TimerHUD(c, left, game.TimerHUDOptions{}) diff --git a/gallery/demos_layout.go b/gallery/demos_layout.go index 08cf296..c67a162 100644 --- a/gallery/demos_layout.go +++ b/gallery/demos_layout.go @@ -14,12 +14,12 @@ import ( var layoutEntries = []Entry{ {1, "Screen", "屏幕", "layout", "The root of a screen: Back fills the window, with an optional dithered vignette.", demoScreen}, - {2, "Window", "窗口", "layout", "A beveled window with a title tab, the shape of every RPG menu.", demoWindow}, + {2, "Window", "框体", "layout", "A beveled window with a title tab, the shape of every RPG menu.", demoWindow}, {3, "Panel", "面板", "layout", "A box framed raised, sunken, flat, double, window or notched.", demoPanel}, {4, "Card", "卡片", "layout", "A raised panel with a header strip, a body and a footer.", demoCard}, {5, "Section", "分节", "layout", "A titled group inside a dotted rule.", demoSection}, {6, "Divider", "分隔线", "layout", "Rules across or down: solid, dotted, dashed, double, ornament.", demoDivider}, - {7, "ScrollArea", "滚动区", "layout", "A scroll view with a pixel scrollbar: arrows step, the track pages, the thumb drags.", demoScrollArea}, + {7, "ScrollArea", "卷动区", "layout", "A scroll view with a pixel scrollbar: arrows step, the track pages, the thumb drags.", demoScrollArea}, {8, "Split", "分栏", "layout", "Two panes and a grip. Drag it, or focus it and use the arrow keys.", demoSplit}, {9, "Grid", "网格", "layout", "Equal columns with solid or dotted lines between the cells.", demoGrid}, {10, "Tiles", "平铺", "layout", "Equal tiles that wrap into rows as the width allows.", demoTiles}, @@ -207,7 +207,7 @@ func demoAspectBox(c *ui.Context, log func(string)) { func demoLayers(c *ui.Context, log func(string)) { k := pixel.K(c) - layout.Layers(c, func() { + layout.Layers(c, layout.LayersOptions{}, func() { ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) g.Dither(0, 0, g.W, g.H, pixel.Well, pixel.Info, 6) @@ -251,7 +251,7 @@ func demoTitleBar(c *ui.Context, log func(string)) { func demoStatusBar(c *ui.Context, log func(string)) { k := pixel.K(c) ui.Box(c).Width(k.D(260)).Children(func() { - layout.StatusBar(c, "Ready", "Ln 1, Col 1", "UTF-8") + layout.StatusBar(c, []string{"Ready", "Ln 1, Col 1", "UTF-8"}, layout.StatusBarOptions{}) }) } @@ -275,7 +275,7 @@ func demoHandheld(c *ui.Context, log func(string)) { ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) g.Rect(0, 0, g.W, g.H, pixel.Well) - g.Sprite(at[0], at[1], slime, g.Step(2), pixel.Ink, 1) + g.Sprite(at[0], at[1], slime, g.Step(4)/2, pixel.Ink, 1) }) }) moves := map[string][2]int{"up": {0, -4}, "down": {0, 4}, "left": {-4, 0}, "right": {4, 0}} diff --git a/gallery/demos_loot.go b/gallery/demos_loot.go index 49eb6e6..c50246a 100644 --- a/gallery/demos_loot.go +++ b/gallery/demos_loot.go @@ -18,7 +18,7 @@ var lootEntries = []Entry{ {281, "Crafting", "合成台", "game", "Click a cell to swap ingredients. Herb over water makes a potion.", demoCrafting}, {282, "LootPopup", "获得物品", "game", "YOU GOT: something nice! The item rises out of the box.", demoLootPopup}, {283, "TreasureChest", "宝箱", "game", "Press the chest. It creaks open; the gem floats up.", demoTreasureChest}, - {284, "GachaReel", "抽奖转轮", "game", "SPIN: three reels, stopping one by one. No real money involved.", demoGachaReel}, + {284, "GachaReel", "扭蛋转轮", "game", "SPIN: three reels, stopping one by one. No real money involved.", demoGachaReel}, {285, "InteractPrompt", "互动提示", "game", "Walk up to the mayor and a TALK bubble bobs above him.", demoInteractPrompt}, {286, "TurnOrder", "行动顺序", "game", "Who acts next. The active one stands raised.", demoTurnOrder}, {287, "BattleLog", "战斗日志", "game", "Combat lines roll up as the fight goes on.", demoBattleLog}, @@ -222,7 +222,7 @@ func demoStaminaBar(c *ui.Context, log func(string)) { s := lootState(c) now := k.Now() // Sprinting drains a quarter a second; rest refills a tenth. - if dt := now.Sub(s.last).Seconds(); s.sprint { + if dt := max(0, now.Sub(s.last).Seconds()); s.sprint { s.stamina = max(0, s.stamina-dt/4) } else { s.stamina = min(1, s.stamina+dt/10) diff --git a/gallery/demos_market.go b/gallery/demos_market.go index b4ced53..6ad93e6 100644 --- a/gallery/demos_market.go +++ b/gallery/demos_market.go @@ -16,10 +16,10 @@ import ( var marketEntries = []Entry{ {444, "Quantity", "数量", "shop", "- and + within limits. The side at its limit fades.", demoQuantity}, {445, "PricingTiers", "价格方案", "shop", "Bronze, silver and gold. Gold comes with a hat.", demoPricingTiers}, - {446, "Coupon", "优惠券", "shop", "A ticket with notched edges. COPY takes the code.", demoCoupon}, - {447, "DailyReward", "每日奖励", "shop", "Seven days, seven chests. Come back daily; today's is ready.", demoDailyReward}, + {446, "Coupon", "优惠码", "shop", "A ticket with notched edges. COPY takes the code.", demoCoupon}, + {447, "DailyReward", "每日福利", "shop", "Seven days, seven chests. Come back daily; today's is ready.", demoDailyReward}, {448, "BidTicker", "竞价", "shop", "The latest bids on top; the highest wears the crown.", demoBidTicker}, - {449, "TradeWindow", "交易窗口", "shop", "Two offers. Both must confirm, and any change takes the confirms back.", demoTradeWindow}, + {449, "TradeWindow", "交易框", "shop", "Two offers. Both must confirm, and any change takes the confirms back.", demoTradeWindow}, {450, "VendingMachine", "自动售货机", "shop", "Type a code like B3 and press OK. Credit first, snacks after.", demoVendingMachine}, } diff --git a/gallery/demos_media.go b/gallery/demos_media.go index 763d3cc..c9e295e 100644 --- a/gallery/demos_media.go +++ b/gallery/demos_media.go @@ -15,7 +15,7 @@ import ( var mediaEntries = []Entry{ {346, "AudioPlayer", "音乐播放器", "media", "A whole deck in one box. Stop rewinds to the top.", demoAudioPlayer}, {347, "Transport", "播放控制", "media", "Back, play or pause, stop, skip: the four buttons every deck agrees on.", demoTransport}, - {348, "Scrubber", "进度拖动", "media", "Drag to seek, or nudge with arrows. Time at both ends.", demoScrubber}, + {348, "Scrubber", "进度定位", "media", "Drag to seek, or nudge with arrows. Time at both ends.", demoScrubber}, {349, "Volume", "音量", "media", "Eight bars; click one or use arrows. The speaker mutes.", demoVolume}, {350, "Equalizer", "均衡器", "media", "Five faders, ±12 dB. Bass boost at your own risk.", demoEqualizer}, {351, "Visualizer", "频谱", "media", "Bars bounce out from the middle, hottest at the tips.", demoVisualizer}, @@ -76,7 +76,8 @@ func beat(s string) [16]bool { func spin(c *ui.Context, last *time.Time, playing bool, at *time.Duration, length time.Duration) { k := pixel.K(c) if playing && !last.IsZero() { - *at = (*at + k.Now().Sub(*last)) % length + // A clock stepping back plays nothing backward. + *at = (*at + max(0, k.Now().Sub(*last))) % length } *last = time.Time{} if playing { diff --git a/gallery/demos_navigation.go b/gallery/demos_navigation.go index 3a3e7d5..8e334ce 100644 --- a/gallery/demos_navigation.go +++ b/gallery/demos_navigation.go @@ -16,14 +16,14 @@ var navigationEntries = []Entry{ {131, "Tabs", "标签页", "navigation", "Folder tabs; the chosen one joins its panel. Arrows move.", demoTabs}, {132, "VerticalTabs", "竖排标签", "navigation", "Tabs down the left edge, for settings screens.", demoVerticalTabs}, {133, "Sidebar", "侧边栏", "navigation", "Sections of items with icons and badges. Up and Down choose; one Tab stop.", demoSidebar}, - {134, "Breadcrumbs", "面包屑", "navigation", "Where you are, split by ›. Every part but the last walks back.", demoBreadcrumbs}, + {134, "Breadcrumbs", "面包屑", "navigation", "Where you are, split by pointers. Every part but the last walks back.", demoBreadcrumbs}, {135, "CommandMenu", "指令菜单", "navigation", "FIGHT, MAGIC, ITEM, RUN, with a bobbing ▶. Enter picks; Escape backs out.", demoCommandMenu}, {136, "MenuBar", "菜单栏", "navigation", "A bar of menus. Click one, then the arrows go across and down.", demoMenuBar}, {137, "DropdownMenu", "下拉菜单", "navigation", "A button with a menu: icons, rules and shortcut names.", demoDropdownMenu}, {138, "ContextMenu", "右键菜单", "navigation", "Right click, or the menu key, opens a menu at the pointer.", demoContextMenu}, {139, "Pagination", "分页", "navigation", "◀ 1 … 4 5 6 … 12 ▶. The current page is pressed in.", demoPagination}, {140, "PageDots", "页码点", "navigation", "A dot per page; click one, or use the arrows.", demoPageDots}, - {141, "Steps", "关卡步骤", "navigation", "Flags along a trail: cleared, waving where you are, locked ahead.", demoSteps}, + {141, "Steps", "关卡阶段", "navigation", "Flags along a trail: cleared, waving where you are, locked ahead.", demoSteps}, {142, "NavRail", "导航栏", "navigation", "A rail of icons. Hover or focus one and it says its name.", demoNavRail}, {143, "BottomNav", "底部导航", "navigation", "Icons over labels along the bottom, for small screens.", demoBottomNav}, {144, "CommandPalette", "命令面板", "navigation", "Cmd+K, type a few letters, press Enter.", demoCommandPalette}, @@ -68,7 +68,7 @@ func demoTabs(c *ui.Context, log func(string)) { func demoVerticalTabs(c *ui.Context, log func(string)) { s := navState(c) names := []string{"Video", "Audio", "Controls"} - if navigation.VerticalTabs(c, "Settings", names, &s.vtab, navigation.TabsOptions{}, func() { + if navigation.VerticalTabs(c, "Settings", names, &s.vtab, navigation.VerticalTabsOptions{}, func() { pixel.Text(c, names[s.vtab]+" settings go here.", pixel.TextOptions{Slot: pixel.Faint}) }).Changed { log(names[s.vtab]) diff --git a/gallery/demos_overlay.go b/gallery/demos_overlay.go index 15a850a..0266501 100644 --- a/gallery/demos_overlay.go +++ b/gallery/demos_overlay.go @@ -16,7 +16,7 @@ import ( var overlayEntries = []Entry{ {176, "Dialog", "对话框", "overlay", "A titled window over a checkered backdrop. Escape or a click outside closes it.", demoDialog}, - {177, "AlertDialog", "警告框", "overlay", "Icon, title, message, buttons. Enter presses the highlighted one.", demoAlertDialog}, + {177, "AlertDialog", "提醒框", "overlay", "Icon, title, message, buttons. Enter presses the highlighted one.", demoAlertDialog}, {178, "ConfirmDialog", "确认框", "overlay", "ARE YOU SURE? The hand starts on NO, to be safe.", demoConfirmDialog}, {179, "Popover", "弹出层", "overlay", "A panel under its button, notched toward it. It flips above near the bottom.", demoPopover}, {180, "Tooltip", "提示气泡", "overlay", "Rest the pointer half a second, or Tab in, and a bubble says what it does.", demoTooltip}, diff --git a/gallery/demos_player.go b/gallery/demos_player.go index 92dd48e..28c8172 100644 --- a/gallery/demos_player.go +++ b/gallery/demos_player.go @@ -111,7 +111,7 @@ func demoTwoFactor(c *ui.Context, log func(string)) { } func demoAbout(c *ui.Context, log func(string)) { - account.About(c, "8BIT", account.AboutOptions{Version: "1.0.0", Credits: []account.Credit{ + account.About(c, "8BIT", "1.0.0", account.AboutOptions{Credits: []account.Credit{ {Role: "Pixels and code", Name: "The 8bit team"}, {Role: "Native UI", Name: "MyGo"}, {Role: "Font", Name: "Ark Pixel"}, }}) } diff --git a/gallery/demos_popups.go b/gallery/demos_popups.go index f5a164c..b393ab4 100644 --- a/gallery/demos_popups.go +++ b/gallery/demos_popups.go @@ -16,7 +16,7 @@ import ( var popupEntries = []Entry{ {186, "ChoicePrompt", "选项提示", "overlay", "A question and a hand. Arrows and Enter, or press 1–9.", demoChoicePrompt}, {187, "Coachmark", "新手引导", "overlay", "A guided tour: a bubble points at each button in turn.", demoCoachmark}, - {188, "Spotlight", "聚光灯", "overlay", "Everything but one button sinks into dither.", demoSpotlight}, + {188, "Spotlight", "探照灯", "overlay", "Everything but one button sinks into dither.", demoSpotlight}, {189, "Notification", "通知", "overlay", "Cards stack at the top right and leave on their own. × sends one away.", demoNotification}, {190, "Popconfirm", "气泡确认", "overlay", "A small YES / NO bubble before the button acts.", demoPopconfirm}, {191, "PauseMenu", "暂停菜单", "overlay", "PAUSED blinks over the game. Escape resumes.", demoPauseMenu}, @@ -173,7 +173,7 @@ func demoKeyHints(c *ui.Context, log func(string)) { func demoTitleCard(c *ui.Context, log func(string)) { k := pixel.K(c) s := overlayState(c) - layout.Layers(c, func() { + layout.Layers(c, layout.LayersOptions{}, func() { ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) g.Dither(0, 0, g.W, g.H, pixel.Well, pixel.Info, 4) diff --git a/gallery/demos_project.go b/gallery/demos_project.go index d082279..342888c 100644 --- a/gallery/demos_project.go +++ b/gallery/demos_project.go @@ -15,7 +15,7 @@ var projectEntries = []Entry{ {422, "TaskItem", "任务项", "project", "A box to tick, a title, how urgent, and who.", demoTaskItem}, {423, "TaskList", "任务列表", "project", "Tasks with OPEN, DONE and URGENT chips to narrow them down.", demoTaskList}, {424, "Milestones", "里程碑", "project", "Flags along the road. The little hero stands at today.", demoMilestones}, - {425, "Burndown", "燃尽图", "project", "The straight line is the plan. The other one is what happened.", demoBurndown}, + {425, "Burndown", "冲刺进度", "project", "The straight line is the plan. The other one is what happened.", demoBurndown}, {426, "Pomodoro", "番茄钟", "project", "Twenty-five minutes of work, five of rest. The tomato naps on the break.", demoPomodoro}, {427, "Priority", "优先级", "project", "LOW, MEDIUM, HIGH and, for the worst, CRITICAL: it is on fire.", demoPriority}, {428, "AssigneePicker", "指派", "project", "The party lines up. Pick who takes the quest, or nobody.", demoAssigneePicker}, diff --git a/gallery/demos_records.go b/gallery/demos_records.go index 34ab26c..9809def 100644 --- a/gallery/demos_records.go +++ b/gallery/demos_records.go @@ -17,13 +17,13 @@ import ( var recordEntries = []Entry{ {164, "StatCompare", "属性对比", "data", "What a new sword does to you: ▲ in green, ▼ in red.", demoStatCompare}, - {165, "Bestiary", "图鉴", "data", "Monsters you met, and ??? shadows of those you have not.", demoBestiary}, + {165, "Bestiary", "怪物图录", "data", "Monsters you met, and ??? shadows of those you have not.", demoBestiary}, {166, "PropertyGrid", "属性表", "data", "Edit text, numbers and switches row by row.", demoPropertyGrid}, {167, "Pager", "分页器", "data", "Pick a page size; ◀ ▶ step through 312 items.", demoPager}, {168, "Feed", "动态流", "data", "What happened, newest first, with icons and times.", demoFeed}, {169, "Matrix", "矩阵", "data", "Who knows which spell. Arrows move; Space flips.", demoMatrix}, {170, "Sortable", "可排序列表", "data", "Drag a quest by its grip, or move it with Alt and the arrows.", demoSortable}, - {171, "Transfer", "穿梭框", "data", "Pick a hero, then → or ← to swap benches.", demoTransfer}, + {171, "Transfer", "双栏选择", "data", "Pick a hero, then → or ← to swap benches.", demoTransfer}, {172, "InfiniteList", "无限列表", "data", "Scroll to the end; it loads twenty more.", demoInfiniteList}, {173, "FilterBar", "筛选栏", "data", "Chips turn filters on; CLEAR turns them all off.", demoFilterBar}, {174, "SearchResults", "搜索结果", "data", "Type a spell name; matches light up.", demoSearchResults}, @@ -100,7 +100,7 @@ func demoInfiniteList(c *ui.Context, log func(string)) { k := pixel.K(c) s := dataState(c) // A pretend server answers after one second. - if !s.loading.IsZero() && k.Since(c, s.loading, 1, 1) == 1 { + if !s.loading.IsZero() && k.Since(c, s.loading, 4, 4) == 4 { s.logs, s.loading = s.logs+20, time.Time{} log(fmt.Sprintf("loaded %d rows", s.logs)) } diff --git a/gallery/demos_rpg.go b/gallery/demos_rpg.go index 60b4f00..015bce0 100644 --- a/gallery/demos_rpg.go +++ b/gallery/demos_rpg.go @@ -140,7 +140,7 @@ func demoDamageNumbers(c *ui.Context, log func(string)) { s, dice := rpgState(c), gameState(c).dice ui.Column(c).Gap(k.D(4)).Children(func() { // The numbers float over a slime having a bad day. - layout.Layers(c, func() { + layout.Layers(c, layout.LayersOptions{}, func() { ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) g.Sprite((g.W-3*slime.W)/2, g.H-3*slime.H, slime, 0, pixel.Ink, 3) diff --git a/gallery/demos_screenfx.go b/gallery/demos_screenfx.go index 52d352e..4e29e73 100644 --- a/gallery/demos_screenfx.go +++ b/gallery/demos_screenfx.go @@ -127,7 +127,7 @@ func demoGlitch(c *ui.Context, log func(string)) { func demoSparkle(c *ui.Context, log func(string)) { k := pixel.K(c) - layout.Layers(c, func() { + layout.Layers(c, layout.LayersOptions{}, func() { ui.Box(c).Size(k.D(48), k.D(48)).AlignItems(ui.Center).Justify(ui.Center).Children(func() { display.Gem(c, display.Legendary, display.GemOptions{Scale: 2}) }) @@ -148,7 +148,7 @@ func demoExplosion(c *ui.Context, log func(string)) { func demoConfetti(c *ui.Context, log func(string)) { k := pixel.K(c) - layout.Layers(c, func() { + layout.Layers(c, layout.LayersOptions{}, func() { ui.Box(c).Size(k.D(140), k.D(64)).AlignItems(ui.Center).Justify(ui.Center).Children(func() { pixel.Text(c, "YOU WIN!", pixel.TextOptions{Scale: 2, Slot: pixel.Accent}) }) diff --git a/gallery/demos_studio.go b/gallery/demos_studio.go index f6e87cc..6594944 100644 --- a/gallery/demos_studio.go +++ b/gallery/demos_studio.go @@ -20,7 +20,7 @@ var studioEntries = []Entry{ {359, "Waveform", "波形", "media", "Every sample a line, up or down from zero.", demoWaveform}, {360, "VideoFrame", "视频框", "media", "A 16:9 screen with play and a scrubber. The slime does the rest.", demoVideoFrame}, {361, "ImageCompare", "对比滑块", "media", "Drag the grip: sketch on the left, paint on the right.", demoImageCompare}, - {362, "Gallery", "画廊", "media", "Arrows walk the thumbnails; the big picture follows.", demoGallery}, + {362, "Gallery", "相册", "media", "Arrows walk the thumbnails; the big picture follows.", demoGallery}, {363, "Filmstrip", "胶片条", "media", "A sprite's frames on film, sprocket holes and all.", demoFilmstrip}, {364, "RecordButton", "录制按钮", "media", "The dot blinks while it records, and the time runs.", demoRecordButton}, {365, "Tuner", "调频器", "media", "Drag the needle across FM. A few stations are on the air.", demoTuner}, diff --git a/gallery/demos_tabletop.go b/gallery/demos_tabletop.go index 0e7dde8..2764be0 100644 --- a/gallery/demos_tabletop.go +++ b/gallery/demos_tabletop.go @@ -12,8 +12,8 @@ import ( ) var tabletopEntries = []Entry{ - {486, "Dice", "骰子", "tabletop", "A d6 with pips. ROLL and it tumbles before it lands.", demoDice}, - {487, "DiceTray", "骰盘", "tabletop", "Several dice on felt, and what they add up to.", demoDiceTray}, + {486, "Dice", "色子", "tabletop", "A d6 with pips. ROLL and it tumbles before it lands.", demoDice}, + {487, "DiceTray", "色子盘", "tabletop", "Several dice on felt, and what they add up to.", demoDiceTray}, {488, "PlayingCard", "扑克牌", "tabletop", "Rank and suit, court cards with their pieces, or the back.", demoPlayingCard}, {489, "CardHand", "手牌", "tabletop", "A fanned hand. The card you point at or choose lifts; a click plays it.", demoCardHand}, {490, "Deck", "牌堆", "tabletop", "A face-down pile with its count. Click to draw.", demoDeck}, diff --git a/gallery/demos_text.go b/gallery/demos_text.go index f89d8ab..2227db3 100644 --- a/gallery/demos_text.go +++ b/gallery/demos_text.go @@ -113,7 +113,7 @@ func demoBlinkText(c *ui.Context, log func(string)) { k := pixel.K(c) ui.Row(c).Gap(k.D(12)).Children(func() { text.BlinkText(c, "PRESS START", text.BlinkTextOptions{Scale: 2}) - text.BlinkText(c, "LOW HP", text.BlinkTextOptions{Slot: pixel.Bad, FPS: 6}) + text.BlinkText(c, "LOW HP", text.BlinkTextOptions{Slot: pixel.Bad, FPS: 12}) }) } diff --git a/gallery/glyphs_test.go b/gallery/glyphs_test.go new file mode 100644 index 0000000..eea1e03 --- /dev/null +++ b/gallery/glyphs_test.go @@ -0,0 +1,27 @@ +package gallery + +import ( + "testing" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestGalleryTextHasGlyphs(t *testing.T) { + // Every visible word comes from the font, never tofu. + check := func(where, s string) { + for _, r := range s { + if r != ' ' && !pixel.Has(r) { + t.Errorf("%s: the font has no %q in %q", where, r, s) + } + } + } + for _, e := range Entries() { + check(e.Slug(), e.Name+e.Zh+e.Summary) + } + for _, path := range []string{"/", "/cat/foundations", "/nowhere"} { + _, tt := open(path) + for _, s := range tt.Texts() { + check(path, s) + } + } +} diff --git a/gallery/names_test.go b/gallery/names_test.go new file mode 100644 index 0000000..6b3991a --- /dev/null +++ b/gallery/names_test.go @@ -0,0 +1,37 @@ +package gallery + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestEveryFocusHasAName(t *testing.T) { + for _, e := range Entries() { + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay}) + // A named start, so Tab always lands and laps. + input.Button(c, "SENTINEL", input.ButtonOptions{}) + ui.Column(c).Fill().Children(func() { e.Demo(c, func(string) {}) }) + }, 640, 480) + tt.Key(0, ui.KeyTab) + for i := range 12 { + tt.Key(0, ui.KeyTab) + if tt.Focused("SENTINEL") { + break + } + // Focused counts ancestors, so a named focus has a hit. + named := false + for _, s := range tt.Texts() { + named = named || tt.Focused(s) + } + if !named { + t.Errorf("%s: Tab %d focuses something with no name", e.Slug(), i+1) + break + } + } + } +} diff --git a/gallery/pages.go b/gallery/pages.go index a7101db..45f7173 100644 --- a/gallery/pages.go +++ b/gallery/pages.go @@ -52,7 +52,7 @@ func (a *App) home(c *ui.Context) { k := pixel.K(c) p := k.Palette() // The banner lives in the palette's own season and hour. - layout.Layers(c, func() { + layout.Layers(c, layout.LayersOptions{}, func() { fx.SeasonScene(c, p.Season, hourOf(p), fx.SeasonSceneOptions{Height: 96, Label: p.Name + " scene"}).FillWidth() }, func() { ui.Column(c).Padding(k.D(10)).Children(func() { @@ -94,7 +94,7 @@ func (a *App) palettes(c *ui.Context) { for i, pal := range pixel.Palettes { name := pal.Name if i == a.palette { - name += ", in use" + name += ", " + strings.ToLower(k.Msg("in_use")) } set := pixel.Current(c) set.Palette = pal diff --git a/gallery/veiled_keys_test.go b/gallery/veiled_keys_test.go new file mode 100644 index 0000000..d2625a7 --- /dev/null +++ b/gallery/veiled_keys_test.go @@ -0,0 +1,41 @@ +package gallery + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestVeiledKeysDoNothing(t *testing.T) { + // KeyUnknown stands for typed text, "a1". + keys := []ui.Key{ui.KeyEnter, ui.KeySpace, ui.KeyUp, ui.KeyDown, ui.KeyLeft, ui.KeyRight, ui.Key1, ui.KeyUnknown} + for _, e := range Entries() { + for i := range 3 * len(keys) { + stop, key := i/len(keys), keys[i%len(keys)] + at, inert, logs := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC), false, []string{} + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }, Inert: inert}) + ui.Column(c).Fill().Children(func() { e.Demo(c, func(s string) { logs = append(logs, s) }) }) + }, 640, 480) + // Each key at each of the first three Tab stops. + for range 1 + stop { + tt.Key(0, ui.KeyTab) + } + before := len(logs) + // The veil falls in the frame the key comes. + inert = true + if key == ui.KeyUnknown { + tt.Type("a1") + } else { + tt.Key(0, key) + } + if len(logs) > before { + t.Errorf("%s: %v under a falling veil logged %q", e.Slug(), key, logs[before:]) + break + } + } + } +} diff --git a/gallery/veiled_pointer_test.go b/gallery/veiled_pointer_test.go new file mode 100644 index 0000000..c9304c7 --- /dev/null +++ b/gallery/veiled_pointer_test.go @@ -0,0 +1,32 @@ +package gallery + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestVeiledPointersDoNothing(t *testing.T) { + for _, e := range Entries() { + for _, at := range [][2]float32{{30, 20}, {120, 40}, {240, 60}, {60, 120}, {200, 160}, {360, 100}, {100, 260}, {300, 320}} { + now, inert, logs := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC), false, []string{} + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return now }, Inert: inert}) + ui.Column(c).Fill().Children(func() { e.Demo(c, func(s string) { logs = append(logs, s) }) }) + }, 640, 480) + tt.Move(at[0], at[1]) + before := len(logs) + // The veil falls with the click, then with a notch. + inert = true + tt.ClickAt(at[0], at[1]) + tt.Scroll(at[0], at[1], 0, 40) + if len(logs) > before { + t.Errorf("%s: a click or wheel at %v under a falling veil logged %q", e.Slug(), at, logs[before:]) + break + } + } + } +} diff --git a/game/credits.go b/game/credits.go index f626a51..9092ab1 100644 --- a/game/credits.go +++ b/game/credits.go @@ -55,7 +55,7 @@ func Credits(c *ui.Context, text string, credits []Credit, o CreditsOptions) Cre end, roll := h/2+full-pixel.LineHeight/2, start(k, e, o.Cue) top, step := 0, end if !k.Reduce() { - step = k.Since(c, roll.at, 16, end) + step = k.Since(c, roll.at, 15, end) top = h - step } else { h = full diff --git a/game/damagenumbers.go b/game/damagenumbers.go index 9d3e8c9..bf84dac 100644 --- a/game/damagenumbers.go +++ b/game/damagenumbers.go @@ -31,13 +31,15 @@ const life = 800 * time.Millisecond func DamageNumbers(c *ui.Context, text string, hits *[]Hit, o DamageNumbersOptions) *ui.Element { k := pixel.K(c) w, h := pixel.Or("DamageNumbers Width", o.Width, 160), pixel.Or("DamageNumbers Height", o.Height, 80) - now := k.Now() + // S5: hits rise in whole frames, fifteen a second. + now := k.Now().Truncate(time.Second / 15) live := (*hits)[:0] for _, hit := range *hits { if hit.X < 0 || hit.X >= w || hit.Y < 0 || hit.Y >= h { panic(fmt.Sprintf("game: hit at %d,%d is off the %d×%d box", hit.X, hit.Y, w, h)) } - if hit.at.IsZero() { + // A clock stepping back starts a hit again. + if hit.at.IsZero() || now.Before(hit.at) { hit.at = now } if now.Sub(hit.at) < life { @@ -54,7 +56,7 @@ func DamageNumbers(c *ui.Context, text string, hits *[]Hit, o DamageNumbersOptio } } if len(shown) > 0 { - k.Tick(c, 20) + k.Tick(c, 15) } return ui.Box(c).Role(ui.RoleImage).Label(text).Description(strings.Join(parts, ", ")).Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) diff --git a/game/equipment.go b/game/equipment.go index f6e4368..71a0b53 100644 --- a/game/equipment.go +++ b/game/equipment.go @@ -66,7 +66,7 @@ func Equipment(c *ui.Context, text string, gear []data.Item, selected *int, o Eq }) arrows(base, spots, selected) res := EquipmentResult{Element: shell, Picked: -1} - if base.Shortcut(0, ui.KeyEnter) || base.Shortcut(0, ui.KeySpace) { + if pixel.Shortcut(base, 0, ui.KeyEnter) || pixel.Shortcut(base, 0, ui.KeySpace) { res.Picked = *selected } left := (w - (3*pitch - 4)) / 2 diff --git a/game/game.go b/game/game.go index be82c36..7dd6177 100644 --- a/game/game.go +++ b/game/game.go @@ -93,7 +93,7 @@ func arrows(base *ui.Element, spots []Spot, cur *int) { key ui.Key dx, dy int }{{ui.KeyLeft, -1, 0}, {ui.KeyRight, 1, 0}, {ui.KeyUp, 0, -1}, {ui.KeyDown, 0, 1}} { - if base.Shortcut(0, m.key) { + if pixel.Shortcut(base, 0, m.key) { *cur = nearest(spots, *cur, m.dx, m.dy) } } @@ -120,5 +120,6 @@ func start(k pixel.Kit, e *ui.Element, cue int) begun { if s.cue != cue { s.cue, s.at, s.run = cue, k.Now(), s.run+1 } + k.Elapsed(&s.at) return *s } diff --git a/game/healthbar.go b/game/healthbar.go index 5db86b1..72e5e2b 100644 --- a/game/healthbar.go +++ b/game/healthbar.go @@ -40,7 +40,9 @@ func HealthBar(c *ui.Context, text string, hp, full int, o HealthBarOptions) *ui now := k.Now() // end is the trail's end: held, then draining fast. end := func() float64 { - return max(float64(tr.seen), tr.from-max(0, now.Sub(tr.at)-hold).Seconds()*float64(full)) + // S5: the drain moves in whole frames, fifteen a second. + drained := max(0, now.Sub(tr.at)-hold).Truncate(time.Second / 15) + return max(float64(tr.seen), tr.from-drained.Seconds()*float64(full)) } if hp < tr.seen { tr.from, tr.at = end(), now @@ -54,7 +56,7 @@ func HealthBar(c *ui.Context, text string, hp, full int, o HealthBarOptions) *ui shown = float64(hp) } if shown > float64(hp) { - k.Tick(c, 30) + k.Tick(c, 15) } // Divide, not multiply: huge HP must not overflow. ink := pixel.Good diff --git a/game/hotbar.go b/game/hotbar.go index a8d6327..2500b65 100644 --- a/game/hotbar.go +++ b/game/hotbar.go @@ -40,27 +40,32 @@ func Hotbar(c *ui.Context, text string, items []data.Item, selected *int, o Hotb old := *selected shell, base, st, used := pixel.Pad(c, text, n, n, data.SlotSize, data.SlotSize, selected, o.Disabled) for i := range n { - if base.Shortcut(0, ui.Key1+ui.Key(i)) { + if pixel.Shortcut(base, 0, ui.Key1+ui.Key(i)) { *selected = i } } wh := ui.Local(base, wheelKey{}, func() wheel { return wheel{} }) notch := k.D(pixel.LineHeight) + // The handler runs before this frame knows it is off. base.HandleInput(func(ev ui.InputEvent) bool { if ev.Kind != ui.InputScroll { return false } - // A notch moves one slot; the bar wraps around. - for wh.left += ev.DY; wh.left >= notch || wh.left <= -notch; { - d := 1 - if wh.left < 0 { - d = -1 - } - wh.left -= float32(d) * notch - *selected, wh.moved = ((*selected+d)%n+n)%n, true - } + wh.left += ev.DY return true }) + if base.IsDisabled() { + wh.left = 0 + } + // A notch moves one slot; the bar wraps around. + for wh.left >= notch || wh.left <= -notch { + d := 1 + if wh.left < 0 { + d = -1 + } + wh.left -= float32(d) * notch + *selected, wh.moved = ((*selected+d)%n+n)%n, true + } res := HotbarResult{Element: shell, Changed: *selected != old || wh.moved, Used: used} wh.moved = false base.Description(items[*selected].String()) diff --git a/game/interactprompt.go b/game/interactprompt.go index d9496b7..6c14c78 100644 --- a/game/interactprompt.go +++ b/game/interactprompt.go @@ -31,7 +31,7 @@ func InteractPrompt(c *ui.Context, target *ui.Element, action string, o Interact at := max(3, int((b.X+b.W/2-x)/k.Dot())) bob := 0 if !k.Reduce() { - bob = k.Tick(c, 2) % 2 + bob = k.Tick(c, 4) / 2 % 2 } var e *ui.Element ui.Overlay(c, func() { diff --git a/game/keypair_test.go b/game/keypair_test.go new file mode 100644 index 0000000..e868df6 --- /dev/null +++ b/game/keypair_test.go @@ -0,0 +1,70 @@ +package game_test + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" +) + +func TestSpaceAfterEnterPicksTheCursor(t *testing.T) { + gear := []data.Item{{Sprite: icon.Crown, Name: "Crown"}, {}, {Sprite: icon.Sword, Name: "Sword"}, {}, {}} + sel, picks := game.Head, []int{} + tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { + if r := game.Equipment(c, "Gear", gear, &sel, game.EquipmentOptions{}); r.Picked >= 0 { + picks = append(picks, r.Picked) + } + }) + r := find(t, tt, "Gear") + // The pointer rests over the doll's lower row. + tt.Move(r.X+r.W/2, r.Y+2*(26+11)) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeySpace) + if len(picks) != 2 || picks[0] != game.Head || picks[1] != game.Head || sel != game.Head { + t.Fatalf("Enter then Space picked %v, cursor %d", picks, sel) + } +} + +func TestSpaceLearnsTheCursorNotThePointer(t *testing.T) { + skills := []game.Skill{ + {Name: "Root", Sprite: icon.Star}, + {Name: "Slash", Sprite: icon.Sword, At: game.Spot{X: 1}, Needs: []int{0}}, + } + cur, points, learned := 0, 2, -1 + tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { + if r := game.SkillTree(c, "Skills", skills, &cur, &points, game.SkillTreeOptions{}); r.Learned >= 0 { + learned = r.Learned + } + }) + r := find(t, tt, "Skills") + // The pointer rests on Slash; the cursor is on Root. + tt.Move(r.X+2*(28+10), r.Y+2*(20+10)) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if learned != 0 || cur != 0 { + t.Fatalf("Space learned %d, cursor %d", learned, cur) + } +} + +func TestHotbarWheelAsAVeilFallsMovesNothing(t *testing.T) { + wd, slot := &world{clk: newClock()}, 0 + tt := view(400, 200, wd, func(c *ui.Context) { + game.Hotbar(c, "Slots", []data.Item{{Name: "A"}, {Name: "B"}}, &slot, game.HotbarOptions{}) + }) + r := find(t, tt, "Slots") + wd.inert = true + tt.Scroll(r.X+r.W/2, r.Y+r.H/2, 0, 40) + if slot != 0 { + t.Fatalf("the wheel moved a veiled bar to %d", slot) + } + wd.inert = false + tt.Frame() + tt.Scroll(r.X+r.W/2, r.Y+r.H/2, 0, 40) + if slot != 1 { + t.Fatalf("the wheel did not move the bar: %d", slot) + } +} diff --git a/game/letterbox.go b/game/letterbox.go index 9296857..5b79218 100644 --- a/game/letterbox.go +++ b/game/letterbox.go @@ -17,7 +17,7 @@ func Letterbox(c *ui.Context, on bool, o LetterboxOptions, content func()) *ui.E k := pixel.K(c) bar := pixel.Or("Letterbox Bar", o.Bar, 16) var cover *ui.Element - e := layout.Layers(c, content, func() { cover = ui.Box(c).Fill().PassThrough() }) + e := layout.Layers(c, layout.LayersOptions{}, content, func() { cover = ui.Box(c).Fill().PassThrough() }) h := bar * k.Toward(c, e, on, 8, 4) / 4 cover.Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) diff --git a/game/lootpopup.go b/game/lootpopup.go index 6346f34..cbc1e5e 100644 --- a/game/lootpopup.go +++ b/game/lootpopup.go @@ -34,7 +34,7 @@ func LootPopup(c *ui.Context, it data.Item, open *bool, o LootPopupOptions) Loot // The item rises 8 dots; reduced, it starts up. rise := 8 if !k.Reduce() { - rise = k.Since(c, start(k, e, 0).at, 16, 8) + rise = k.Since(c, start(k, e, 0).at, 15, 8) } e.Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) diff --git a/game/map_test.go b/game/map_test.go index 819d47d..5076f50 100644 --- a/game/map_test.go +++ b/game/map_test.go @@ -166,8 +166,9 @@ func TestDamageNumbersRiseAndExpire(t *testing.T) { t0, crit := top(pixel.Ink), top(pixel.Warn) wd.clk.t = wd.clk.t.Add(400 * time.Millisecond) tt.Frame() - // Halfway through its life it has risen 6 dots. - if rose := t0 - top(pixel.Ink); t0 < 0 || crit < 0 || (rose == 6) == reduce || rose != 6 && rose != 0 { + // Halfway through its life: 6 dots, or 5 stepped. + rose := t0 - top(pixel.Ink) + if moved := rose == 5 || rose == 6; t0 < 0 || crit < 0 || moved == reduce || !moved && rose != 0 { t.Fatalf("reduced %v: the number rose %d from %d", reduce, rose, t0) } wd.clk.t = wd.clk.t.Add(500 * time.Millisecond) diff --git a/game/menus_test.go b/game/menus_test.go index f028623..7a2f0e7 100644 --- a/game/menus_test.go +++ b/game/menus_test.go @@ -6,7 +6,9 @@ import ( "github.com/egoist/mygo/ui" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" ) @@ -256,3 +258,90 @@ func TestLoadingTipsTurnEveryFourSeconds(t *testing.T) { t.Fatalf("tips did not turn: %q", tt.Texts()) } } + +func TestInteractPromptBobsOnceASecond(t *testing.T) { + wd := &world{clk: newClock()} + tt := view(300, 200, wd, func(c *ui.Context) { + var target *ui.Element + ui.Column(c).Padding(60).Children(func() { target = ui.Box(c).Size(20, 20) }) + game.InteractPrompt(c, target, "OPEN", game.InteractPromptOptions{}) + }) + start := wd.clk.t + var ys []float32 + for _, at := range []time.Duration{100, 350, 600} { + wd.clk.t = start.Add(at * time.Millisecond) + tt.Frame() + // The bottom edge bobs; the top may sit clipped. + r := find(t, tt, "A OPEN") + ys = append(ys, r.Y+r.H) + } + if ys[0] != ys[1] || ys[1] == ys[2] { + t.Fatalf("the prompt sat at %v at .1, .35 and .6 s", ys) + } +} + +func TestHealthBarTrailStepsAtFifteen(t *testing.T) { + wd, hp := &world{clk: newClock()}, 100 + tt := view(300, 100, wd, func(c *ui.Context) { game.HealthBar(c, "HP", hp, 100, game.HealthBarOptions{}) }) + hp = 50 + tt.Frame() + wd.clk.t = wd.clk.t.Add(350 * time.Millisecond) + // Frames each 10 ms show two steps at most. + seen := map[string]bool{} + for range 6 { + wd.clk.t = wd.clk.t.Add(10 * time.Millisecond) + tt.Frame() + seen[string(tt.Image().Pix)] = true + } + if len(seen) > 2 { + t.Fatalf("the trail drew %d pictures in 60 ms", len(seen)) + } +} + +func TestDamageNumbersRiseInWholeFrames(t *testing.T) { + wd := &world{clk: newClock()} + hits := []game.Hit{{Value: 999, X: 60, Y: 60}} + tt := view(300, 200, wd, func(c *ui.Context) { game.DamageNumbers(c, "Damage", &hits, game.DamageNumbersOptions{}) }) + wd.clk.t = wd.clk.t.Add(500 * time.Millisecond) + tt.Frame() + seen := map[string]bool{} + for range 7 { + seen[string(tt.Image().Pix)] = true + wd.clk.t = wd.clk.t.Add(10 * time.Millisecond) + tt.Frame() + } + if len(seen) > 2 { + t.Fatalf("%d pictures in 60 ms", len(seen)) + } +} + +func TestSpinsAndHitsRestartWhenTheClockStepsBack(t *testing.T) { + wd := &world{clk: newClock()} + faces := []data.Item{{Sprite: icon.Star, Name: "Star"}, {Sprite: icon.Coin, Name: "Coin"}} + spin, done := 0, 0 + tt := view(300, 100, wd, func(c *ui.Context) { + if game.GachaReel(c, "Reels", faces, [3]int{1, 1, 1}, spin, game.GachaReelOptions{}).Done { + done++ + } + }) + spin = 1 + tt.Frame() + // An hour back, the spin times from now and lands. + wd.clk.t = wd.clk.t.Add(-time.Hour) + tt.Frame() + for range 30 { + wd.clk.t = wd.clk.t.Add(200 * time.Millisecond) + tt.Frame() + } + if done != 1 { + t.Fatalf("the reels landed %d times", done) + } + hits := []game.Hit{{Value: 7, X: 60, Y: 40}} + tt = view(300, 200, wd, func(c *ui.Context) { game.DamageNumbers(c, "Damage", &hits, game.DamageNumbersOptions{}) }) + fresh := string(tt.Image().Pix) + wd.clk.t = wd.clk.t.Add(-time.Second) + tt.Frame() + if string(tt.Image().Pix) != fresh { + t.Fatal("a hit moved as the clock stepped back") + } +} diff --git a/game/optionsmenu.go b/game/optionsmenu.go index 66b7825..9e3bdac 100644 --- a/game/optionsmenu.go +++ b/game/optionsmenu.go @@ -73,21 +73,21 @@ func OptionsMenu(c *ui.Context, text string, settings []Setting, row *int, o Opt *s.Value, res.Changed = v, true } } - if base.Shortcut(0, ui.KeyUp) && *row > 0 { + if pixel.Shortcut(base, 0, ui.KeyUp) && *row > 0 { *row-- } - if base.Shortcut(0, ui.KeyDown) && *row < len(settings)-1 { + if pixel.Shortcut(base, 0, ui.KeyDown) && *row < len(settings)-1 { *row++ } - if base.Shortcut(0, ui.KeyLeft) { + if pixel.Shortcut(base, 0, ui.KeyLeft) { step(-1) } - if base.Shortcut(0, ui.KeyRight) { + if pixel.Shortcut(base, 0, ui.KeyRight) { step(1) } // Enter and Space are taken, so clicks come from pointers. - base.Shortcut(0, ui.KeyEnter) - base.Shortcut(0, ui.KeySpace) + pixel.Shortcut(base, 0, ui.KeyEnter) + pixel.Shortcut(base, 0, ui.KeySpace) aw := pixel.Measure("◀") ax := pixel.Hand.W + 4 + name + 10 vx := ax + aw + 3 diff --git a/game/pressstart.go b/game/pressstart.go index f9e4a66..773d8c4 100644 --- a/game/pressstart.go +++ b/game/pressstart.go @@ -29,7 +29,7 @@ func PressStart(c *ui.Context, o PressStartOptions) PressStartResult { shell.Size(k.D(pixel.Measure(s)+8), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) // It blinks at 1 Hz; reduced, it holds still. - if g.Step(2)%2 == 0 { + if g.Step(4)%4 < 2 { g.Text(4, 2, s, pixel.Ink, 1) } g.Finish(st) diff --git a/game/skilltree.go b/game/skilltree.go index 7e16925..76b7341 100644 --- a/game/skilltree.go +++ b/game/skilltree.go @@ -73,7 +73,7 @@ func SkillTree(c *ui.Context, text string, skills []Skill, cursor, points *int, return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled) }) arrows(base, spots, cursor) - learn := base.Shortcut(0, ui.KeyEnter) || base.Shortcut(0, ui.KeySpace) + learn := pixel.Shortcut(base, 0, ui.KeyEnter) || pixel.Shortcut(base, 0, ui.KeySpace) top := pixel.LineHeight + 4 // A click moves the cursor; clicking it again learns. if base.Clicked() { diff --git a/game/treasurechest.go b/game/treasurechest.go index 9ab653d..e325439 100644 --- a/game/treasurechest.go +++ b/game/treasurechest.go @@ -50,7 +50,7 @@ func TreasureChest(c *ui.Context, text string, it data.Item, open *bool, o Treas if *open { frame, rise = 2, 10 if !k.Reduce() { - frame, rise = k.Since(c, s.at, 8, 2), k.Since(c, s.at, 16, 10) + frame, rise = k.Since(c, s.at, 8, 2), k.Since(c, s.at, 15, 10) } } if *open { diff --git a/input/autocomplete.go b/input/autocomplete.go index 9d00357..ba7fed8 100644 --- a/input/autocomplete.go +++ b/input/autocomplete.go @@ -3,6 +3,7 @@ package input import ( "log/slog" "strings" + "unicode/utf8" "github.com/egoist/mygo/ui" @@ -28,10 +29,12 @@ func Autocomplete(c *ui.Context, text string, value *string, suggestions []strin ed, e := field(c, value, pixel.EditorOptions{Label: text, Placeholder: o.Placeholder, Disabled: o.Disabled}, o.Width, nil, nil, nil) res := AutocompleteResult{Element: e, Changed: ed.Changed, Submitted: ed.Submitted} best := completion(suggestions, *value) - tab, enter := best != "" && e.Shortcut(0, ui.KeyTab), best != "" && e.Shortcut(0, ui.KeyEnter) + // Keys reach the wrapper; the editor's state gates them. + on := best != "" && !ed.Input.IsDisabled() + tab, enter := on && pixel.Shortcut(e, 0, ui.KeyTab), on && pixel.Shortcut(e, 0, ui.KeyEnter) if tab || enter { *value, res.Changed, res.Accepted = best, true, true - slog.Debug("8bit: autocomplete accepted", "label", text, "value", best) + slog.Debug("8bit: autocomplete accepted", "label", text, "runes", utf8.RuneCountInString(best)) return res } if best != "" { diff --git a/input/button.go b/input/button.go index c866928..7976b3a 100644 --- a/input/button.go +++ b/input/button.go @@ -21,6 +21,8 @@ type ButtonOptions struct { type ButtonResult struct { Element *ui.Element + // Base is the focusable inside Element. + Base *ui.Element Clicked bool } @@ -68,7 +70,7 @@ func Button(c *ui.Context, text string, o ButtonOptions) ButtonResult { } g.Finish(st) }) - res := ButtonResult{Element: shell, Clicked: base.Clicked()} + res := ButtonResult{Element: shell, Base: base, Clicked: base.Clicked()} if res.Clicked { slog.Debug("8bit: button", "label", name) } diff --git a/input/cheatcode.go b/input/cheatcode.go index 21c74df..bf7c27b 100644 --- a/input/cheatcode.go +++ b/input/cheatcode.go @@ -42,6 +42,10 @@ func CheatCode(c *ui.Context, text string, o CheatCodeOptions) CheatCodeResult { return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled) }) s := ui.Local(base, cheatKey{}, func() cheat { return cheat{} }) + // The handler runs before this frame knows it is off. + if base.IsDisabled() { + *s = cheat{} + } // Keys come in order here, however fast they are typed. base.HandleInput(func(ev ui.InputEvent) bool { if ev.Kind != ui.InputKeyDown || ev.Mods != 0 { diff --git a/input/checkboxgroup.go b/input/checkboxgroup.go index 36cc1e6..ca7bbad 100644 --- a/input/checkboxgroup.go +++ b/input/checkboxgroup.go @@ -51,7 +51,7 @@ func CheckboxGroup(c *ui.Context, text string, items []string, checked []bool, o } box(k.Canvas(p, r), text, mark, st) }) - if base.Changed() { + if base.Changed() && !base.IsDisabled() { for i := range checked { checked[i] = all } diff --git a/input/combobox.go b/input/combobox.go index a087d93..e7acfa8 100644 --- a/input/combobox.go +++ b/input/combobox.go @@ -3,6 +3,7 @@ package input import ( "log/slog" "strings" + "unicode/utf8" "github.com/egoist/mygo/ui" @@ -53,14 +54,16 @@ func Combobox(c *ui.Context, text string, value *string, options []string, o Com if ed.Changed { st.open, st.typed, st.lit = true, true, 0 } - if e.Shortcut(0, ui.KeyDown) { + // Keys reach the wrapper; the editor's state gates them. + on := !ed.Input.IsDisabled() + if on && pixel.Shortcut(e, 0, ui.KeyDown) { if st.open { st.lit++ } else { st.open, st.typed, st.lit = true, false, 0 } } - if st.open && e.Shortcut(0, ui.KeyUp) { + if st.open && on && pixel.Shortcut(e, 0, ui.KeyUp) { st.lit-- } shown := options @@ -70,9 +73,12 @@ func Combobox(c *ui.Context, text string, value *string, options []string, o Com st.lit = max(0, min(st.lit, len(shown)-1)) // The rows shown follow the lit option. st.top = max(0, min(st.top, st.lit, len(shown)-comboRows), st.lit-comboRows+1) - if st.open && len(shown) > 0 && e.Shortcut(0, ui.KeyEnter) { + if st.open && on && len(shown) > 0 && pixel.Shortcut(e, 0, ui.KeyEnter) { take(shown[st.lit]) } + if !on { + st.open = false + } show, held := st.open && len(shown) > 0, false panel := ui.PopoverBase(c, e, &show, func(panel *ui.Element) { panel.MinWidth(e.Bounds().W) @@ -132,7 +138,7 @@ func Combobox(c *ui.Context, text string, value *string, options []string, o Com c.Invalidate() } if res.Chosen { - slog.Debug("8bit: combobox chosen", "label", text, "value", *value) + slog.Debug("8bit: combobox chosen", "label", text, "runes", utf8.RuneCountInString(*value)) } return res } diff --git a/input/dpad.go b/input/dpad.go index 618fd6e..03949bb 100644 --- a/input/dpad.go +++ b/input/dpad.go @@ -52,13 +52,13 @@ func DPad(c *ui.Context, text string, o DPadOptions) DPadResult { key ui.Key arm Direction }{{ui.KeyUp, Up}, {ui.KeyDown, Down}, {ui.KeyLeft, Left}, {ui.KeyRight, Right}} { - if base.Shortcut(0, m.key) { + if pixel.Shortcut(base, 0, m.key) { res.Pressed = m.arm } } // Taking Enter and Space keeps keys from clicking. - base.Shortcut(0, ui.KeyEnter) - base.Shortcut(0, ui.KeySpace) + pixel.Shortcut(base, 0, ui.KeyEnter) + pixel.Shortcut(base, 0, ui.KeySpace) at := func() Direction { x, y, _ := base.PointerPosition() return armAt(int(x/k.Dot()), int(y/k.Dot())) @@ -71,7 +71,7 @@ func DPad(c *ui.Context, text string, o DPadOptions) DPadResult { slog.Debug("8bit: dpad", "label", text, "arm", res.Pressed) } down := None - if k.Now().Sub(lit.at) < 150*time.Millisecond { + if k.Elapsed(&lit.at) < 150*time.Millisecond { down = lit.arm k.Tick(c, 10) } diff --git a/input/editabletext.go b/input/editabletext.go index 2868124..a792042 100644 --- a/input/editabletext.go +++ b/input/editabletext.go @@ -2,6 +2,7 @@ package input import ( "log/slog" + "unicode/utf8" "github.com/egoist/mygo/ui" @@ -41,7 +42,9 @@ func EditableText(c *ui.Context, text string, value *string, o EditableTextOptio if s.on { ed, e := field(c, &s.draft, pixel.EditorOptions{Label: text, Placeholder: o.Placeholder}, w, nil, nil, nil) ed.Input.AutoFocus() - enter, escape := e.Shortcut(0, ui.KeyEnter), e.Shortcut(0, ui.KeyEscape) + // Keys reach the wrapper; the editor's state gates them. + on := !ed.Input.IsDisabled() + enter, escape := on && pixel.Shortcut(e, 0, ui.KeyEnter), on && pixel.Shortcut(e, 0, ui.KeyEscape) if enter { res.Changed = *value != s.draft *value = s.draft @@ -58,7 +61,7 @@ func EditableText(c *ui.Context, text string, value *string, o EditableTextOptio base.Focus() s.refocus = false } - enter, double := base.Shortcut(0, ui.KeyEnter), base.DoubleClicked() + enter, double := pixel.Shortcut(base, 0, ui.KeyEnter), base.DoubleClicked() if enter || double { s.on, s.draft = true, *value } @@ -77,7 +80,7 @@ func EditableText(c *ui.Context, text string, value *string, o EditableTextOptio }) }) if res.Changed { - slog.Debug("8bit: text edited", "label", text, "value", *value) + slog.Debug("8bit: text edited", "label", text, "runes", utf8.RuneCountInString(*value)) } return res } diff --git a/input/holdbutton.go b/input/holdbutton.go index bf410c6..5d569e7 100644 --- a/input/holdbutton.go +++ b/input/holdbutton.go @@ -48,28 +48,28 @@ func HoldButton(c *ui.Context, text string, o HoldButtonOptions) HoldButtonResul return ui.ButtonBase(c).Label(text).Description(k.Msg("hold")).Disabled(o.Disabled) }) h := ui.Local(base, holdButtonKey{}, func() holding { return holding{} }) - if o.Disabled { + if base.IsDisabled() { *h = holding{} } now := k.Now() // Held keys repeat; each repeat moves the hold on. - enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace) + enter, space := pixel.Shortcut(base, 0, ui.KeyEnter), pixel.Shortcut(base, 0, ui.KeySpace) if enter || space { - if now.Sub(h.key) > repeatGap { + if k.Elapsed(&h.key) > repeatGap { h.since = now } h.key = now } - keyed := now.Sub(h.key) <= repeatGap + keyed := k.Elapsed(&h.key) <= repeatGap var held time.Duration switch { case st.Press: if h.since.IsZero() { h.since = now } - held = now.Sub(h.since) + held = k.Elapsed(&h.since) case keyed: - held = h.key.Sub(h.since) + held = max(0, h.key.Sub(h.since)) default: *h = holding{} } @@ -79,7 +79,7 @@ func HoldButton(c *ui.Context, text string, o HoldButtonOptions) HoldButtonResul slog.Debug("8bit: hold confirmed", "label", text) } if st.Press || keyed { - k.Tick(c, 30) + k.Tick(c, 15) } steps := min(8, int(held*8/d)) fill := pixel.Accent diff --git a/input/joystick.go b/input/joystick.go index 7f37883..53159f9 100644 --- a/input/joystick.go +++ b/input/joystick.go @@ -42,23 +42,23 @@ func Joystick(c *ui.Context, text string, o JoystickOptions) JoystickResult { return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled) }) s := ui.Local(base, stickKey{}, func() stick { return stick{} }) - ox, oy, now := s.x, s.y, k.Now() + ox, oy, now, on := s.x, s.y, k.Now(), !base.IsDisabled() for _, m := range []struct { key ui.Key dx, dy float64 }{{ui.KeyLeft, -0.25, 0}, {ui.KeyRight, 0.25, 0}, {ui.KeyUp, 0, -0.25}, {ui.KeyDown, 0, 0.25}} { - if base.Shortcut(0, m.key) { + if pixel.Shortcut(base, 0, m.key) { s.x, s.y, s.key = s.x+m.dx, s.y+m.dy, now } } _, _, held := base.Dragged() switch { - case held && !o.Disabled: + case held && on: px, py, _ := base.PointerPosition() s.x = (float64(px/k.Dot()) - stickSize/2) / stickReach s.y = (float64(py/k.Dot()) - stickSize/2) / stickReach s.key = time.Time{} - case now.Sub(s.key) > repeatGap: + case k.Elapsed(&s.key) > repeatGap: s.x, s.y = 0, 0 default: k.Tick(c, 10) diff --git a/input/knob.go b/input/knob.go index 0df8ba4..4cecec6 100644 --- a/input/knob.go +++ b/input/knob.go @@ -31,7 +31,7 @@ func Knob(c *ui.Context, text string, value *float64, o KnobOptions) KnobResult old, set := *value, r.snap(value) // raw is the unrounded value a drag carries. raw := ui.Local(base, knobKey{}, func() float64 { return *value }) - if !o.Disabled { + if !base.IsDisabled() { if _, dy, held := base.Dragged(); held { *raw = max(lo, min(hi, *raw-float64(dy/k.Dot())*(hi-lo)/64)) set(*raw) diff --git a/input/multiselect.go b/input/multiselect.go index 977233d..2b4e296 100644 --- a/input/multiselect.go +++ b/input/multiselect.go @@ -24,21 +24,26 @@ type MultiSelectResult struct { type multiKey struct{} +// popup is open, and held the focus last frame. +type popup struct{ open, held bool } + // MultiSelect checks many options in a popup; tags show them. func MultiSelect(c *ui.Context, text string, chosen *[]string, options []string, o MultiSelectOptions) MultiSelectResult { k := pixel.K(c) shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Role(ui.RolePopUpButton).Label(text).Description(strings.Join(*chosen, ", ")).Disabled(o.Disabled) }) - open := ui.Local(base, multiKey{}, func() bool { return false }) + pop := ui.Local(base, multiKey{}, func() popup { return popup{} }) + open := &pop.open if o.Disabled || pixel.Current(c).Inert { - // Closing on disable must not return focus to it. - if *open { - c.Root().Focus() + // A focus in the closing popup parks on the field. + if *open && pop.held { + base.Focus() + pixel.Park(c, base, true) } *open = false } - if base.Clicked() || !*open && base.Shortcut(0, ui.KeyDown) { + if base.Clicked() || !*open && pixel.Shortcut(base, 0, ui.KeyDown) { *open = !*open } shell.Size(k.D(pixel.Or("MultiSelect Width", o.Width, 160)), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) { @@ -52,6 +57,7 @@ func MultiSelect(c *ui.Context, text string, chosen *[]string, options []string, g.Finish(st) }) res := MultiSelectResult{Element: shell} + pop.held = false ui.PopoverBase(c, base, open, func(panel *ui.Element) { menu(c, panel, func() { col := ui.Column(c).Gap(k.D(1)).FocusGroup(ui.Vertical) @@ -62,6 +68,8 @@ func MultiSelect(c *ui.Context, text string, chosen *[]string, options []string, if i == 0 { cb.AutoFocus() } + // Focused, not FocusWithin: MyGo links parents after frames. + pop.held = pop.held || cb.Focused() if cb.Changed() { res.Changed = true if on { diff --git a/input/nameentry.go b/input/nameentry.go index 391a3d2..d612718 100644 --- a/input/nameentry.go +++ b/input/nameentry.go @@ -2,6 +2,7 @@ package input import ( "log/slog" + "unicode/utf8" "github.com/egoist/mygo/ui" @@ -82,7 +83,7 @@ func NameEntry(c *ui.Context, text string, name *string, o NameEntryOptions) Nam }) }) if res.Confirmed { - slog.Debug("8bit: name entered", "label", text, "name", *name) + slog.Debug("8bit: name entered", "label", text, "runes", utf8.RuneCountInString(*name)) } return res } diff --git a/input/numberinput.go b/input/numberinput.go index efcb0ab..6054be7 100644 --- a/input/numberinput.go +++ b/input/numberinput.go @@ -69,10 +69,10 @@ func NumberInput(c *ui.Context, label string, value *float64, o NumberInputOptio *value, st.seen, res.Changed = v, v, true } st.focused = ed.Focused - if ed.Input.Shortcut(0, ui.KeyUp) { + if pixel.Shortcut(ed.Input, 0, ui.KeyUp) { set(*value + step) } - if ed.Input.Shortcut(0, ui.KeyDown) { + if pixel.Shortcut(ed.Input, 0, ui.KeyDown) { set(*value - step) } res.Input, res.Submitted, res.Focused = ed.Input, ed.Submitted, ed.Focused diff --git a/input/pininput.go b/input/pininput.go index 4bdb366..f87dc89 100644 --- a/input/pininput.go +++ b/input/pininput.go @@ -22,6 +22,12 @@ type PinInputResult struct { type pinKey struct{} +// pin: a change since last frame, and the prior value. +type pin struct { + changed bool + kept string +} + const pinBox, pinGap = 12, 4 // PinInput types digits into boxes; Backspace goes back, paste fills. @@ -31,7 +37,12 @@ func PinInput(c *ui.Context, text string, value *string, o PinInputOptions) PinI shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.Box(c).Focusable().Role(ui.RoleTextField).Label(text).Description(*value).Disabled(o.Disabled) }) - changed := ui.Local(base, pinKey{}, func() bool { return false }) + p := ui.Local(base, pinKey{}, func() pin { return pin{} }) + changed := &p.changed + // The handler runs before this frame knows it is off. + if p.changed && base.IsDisabled() { + *value, p.changed = p.kept, false + } put := func(s string) { for _, r := range s { if r >= '0' && r <= '9' && len(*value) < n { @@ -41,6 +52,9 @@ func PinInput(c *ui.Context, text string, value *string, o PinInputOptions) PinI } } base.HandleInput(func(ev ui.InputEvent) bool { + if !p.changed { + p.kept = *value + } switch { case ev.Kind == ui.InputText: put(ev.Text) diff --git a/input/radio.go b/input/radio.go index 001824c..46ea6be 100644 --- a/input/radio.go +++ b/input/radio.go @@ -26,7 +26,7 @@ func Radio[T comparable](c *ui.Context, text string, value T, selected *T, o Rad return ui.CheckboxBase(c, &on).Role(ui.RoleRadio).Label(text).Disabled(o.Disabled) }) res := RadioResult{Element: shell} - if base.Changed() && !was { + if base.Changed() && !was && !base.IsDisabled() { *selected, res.Changed = value, true slog.Debug("8bit: radio", "label", text) } diff --git a/input/radiogroup.go b/input/radiogroup.go index 8ae8034..582e3d7 100644 --- a/input/radiogroup.go +++ b/input/radiogroup.go @@ -46,7 +46,7 @@ func RadioGroup(c *ui.Context, labels []string, selected *int, o RadioGroupOptio } // Arrow moves show focus and choose; Tab entry does not. moved := base.FocusVisible() && *last >= 0 && *last != i - if !was && (base.Changed() || moved) { + if !was && (base.Changed() || moved) && !base.IsDisabled() { *selected, res.Changed = i, true } shell.Size(k.D(12+pixel.Measure(l)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { diff --git a/input/rangeslider.go b/input/rangeslider.go index 4fa2b8c..a856756 100644 --- a/input/rangeslider.go +++ b/input/rangeslider.go @@ -49,17 +49,17 @@ func knob(c *ui.Context, k pixel.Kit, track *ui.Element, v *float64, r rail, lo, return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(r.label).Description(fmtValue(*v)).Disabled(r.disabled) }) set := func(x float64) { *v = max(r.lo, min(r.hi, x)) } - if !r.disabled { + if !base.IsDisabled() { if _, _, held := base.Dragged(); held { px, _, _ := base.PointerPosition() t := track.Bounds() set(lo + float64((base.Bounds().X+px-t.X)/max(1, t.W))*(hi-lo)) } step := (hi - lo) / 20 - if base.Shortcut(0, ui.KeyLeft) || base.Shortcut(0, ui.KeyDown) { + if pixel.Shortcut(base, 0, ui.KeyLeft) || pixel.Shortcut(base, 0, ui.KeyDown) { set(*v - step) } - if base.Shortcut(0, ui.KeyRight) || base.Shortcut(0, ui.KeyUp) { + if pixel.Shortcut(base, 0, ui.KeyRight) || pixel.Shortcut(base, 0, ui.KeyUp) { set(*v + step) } } diff --git a/input/rating.go b/input/rating.go index 459dcca..eb6afa7 100644 --- a/input/rating.go +++ b/input/rating.go @@ -49,6 +49,7 @@ func Rating(c *ui.Context, text string, value *int, o RatingOptions) RatingResul } // Arrow moves show focus and choose; clicks toggle. switch { + case base.IsDisabled(): case base.Changed() && was: *value, res.Changed = 0, true case base.Changed(), base.FocusVisible() && *last >= 0 && *last != i && !was: diff --git a/input/searchfield.go b/input/searchfield.go index f1abfe3..a9fc364 100644 --- a/input/searchfield.go +++ b/input/searchfield.go @@ -18,7 +18,7 @@ type SearchFieldResult = TextInputResult // SearchField has a magnifier; Escape clears, Enter submits. func SearchField(c *ui.Context, label string, value *string, o SearchFieldOptions) SearchFieldResult { ed, e := field(c, value, pixel.EditorOptions{Label: label, Placeholder: o.Placeholder, Disabled: o.Disabled}, o.Width, nil, icon.Search, nil) - if ed.Input.Shortcut(0, ui.KeyEscape) && *value != "" { + if pixel.Shortcut(ed.Input, 0, ui.KeyEscape) && *value != "" { *value, ed.Changed = "", true } return result(e, ed, label) diff --git a/input/segmented.go b/input/segmented.go index 59177ab..75e4951 100644 --- a/input/segmented.go +++ b/input/segmented.go @@ -20,7 +20,9 @@ type SegmentedResult struct { // Segmented joins segments choosing one; arrows move. func Segmented(c *ui.Context, text string, labels []string, selected *int, o SegmentedOptions) SegmentedResult { k := pixel.K(c) - p := ui.SegmentedBase(c, selected, len(labels)) + // The base turns before it learns it is off. + v := *selected + p := ui.SegmentedBase(c, &v, len(labels)) w := 0 for _, l := range labels { w += pixel.Measure(l) + 12 @@ -49,8 +51,9 @@ func Segmented(c *ui.Context, text string, labels []string, selected *int, o Seg }) } }) - res := SegmentedResult{Element: p.Track, Changed: p.Track.Changed()} + res := SegmentedResult{Element: p.Track, Changed: v != *selected && !o.Disabled && !pixel.Current(c).Inert} if res.Changed { + *selected = v slog.Debug("8bit: segmented", "label", text, "selected", *selected) } return res diff --git a/input/slider.go b/input/slider.go index f04acb4..2a8029b 100644 --- a/input/slider.go +++ b/input/slider.go @@ -115,7 +115,7 @@ func (r rail) keys(base *ui.Element, v *float64, set func(float64)) { {ui.KeyRight, func() float64 { return *v + step }}, {ui.KeyUp, func() float64 { return *v + step }}, {ui.KeyHome, func() float64 { return r.lo }}, {ui.KeyEnd, func() float64 { return r.hi }}, } { - if base.Shortcut(0, kd.key) { + if pixel.Shortcut(base, 0, kd.key) { set(kd.to()) } } diff --git a/input/slotpicker.go b/input/slotpicker.go index 1addc62..08cbd7c 100644 --- a/input/slotpicker.go +++ b/input/slotpicker.go @@ -42,21 +42,26 @@ func SlotPicker(c *ui.Context, text string, value *int, options []string, o Slot s.changed = true } row := k.D(pixel.LineHeight) + // The handler runs before this frame knows it is off. base.HandleInput(func(ev ui.InputEvent) bool { - if ev.Kind != ui.InputScroll || o.Disabled { + if ev.Kind != ui.InputScroll { return false } s.wheel += ev.DY - turn(&s.wheel, row, spin) return true }) - if base.Shortcut(0, ui.KeyDown) { + if base.IsDisabled() { + s.wheel = 0 + } + turn(&s.wheel, row, spin) + on := !base.IsDisabled() + if pixel.Shortcut(base, 0, ui.KeyDown) { spin(1) } - if base.Shortcut(0, ui.KeyUp) { + if pixel.Shortcut(base, 0, ui.KeyUp) { spin(-1) } - if _, dy, held := base.Dragged(); held && !o.Disabled { + if _, dy, held := base.Dragged(); held && on { s.drag += dy turn(&s.drag, row, func(d int) { spin(-d) }) } else { diff --git a/input/stepper.go b/input/stepper.go index 4c478d1..a384444 100644 --- a/input/stepper.go +++ b/input/stepper.go @@ -39,10 +39,10 @@ func Stepper(c *ui.Context, text string, value *int, o StepperOptions) StepperRe return ui.Box(c).Focusable().Role(ui.RoleStepper).Label(text).Description(strconv.Itoa(*value)).Disabled(o.Disabled) }) if !o.Disabled { - if base.Shortcut(0, ui.KeyLeft) || base.Shortcut(0, ui.KeyDown) { + if pixel.Shortcut(base, 0, ui.KeyLeft) || pixel.Shortcut(base, 0, ui.KeyDown) { set(*value - step) } - if base.Shortcut(0, ui.KeyRight) || base.Shortcut(0, ui.KeyUp) { + if pixel.Shortcut(base, 0, ui.KeyRight) || pixel.Shortcut(base, 0, ui.KeyUp) { set(*value + step) } } @@ -96,8 +96,9 @@ func held(c *ui.Context, k pixel.Kit, sp *pixel.Sprite, disabled bool) int { *h = hold{since: k.Now(), n: 1} return 1 } - want := 1 + max(0, int((k.Now().Sub(h.since)-400*time.Millisecond)/(100*time.Millisecond))) - n := want - h.n - h.n = want + want := 1 + max(0, int((k.Elapsed(&h.since)-400*time.Millisecond)/(100*time.Millisecond))) + // A clock stepping back restarts the count, never reverses it. + n := max(0, want-h.n) + h.n = max(h.n, want) return n } diff --git a/input/switch.go b/input/switch.go index 2cc299a..a93b21b 100644 --- a/input/switch.go +++ b/input/switch.go @@ -22,7 +22,13 @@ type SwitchResult struct { // Switch is a lever on an OFF/ON plate. func Switch(c *ui.Context, text string, on *bool, o SwitchOptions) SwitchResult { k := pixel.K(c) - shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.SwitchBase(c, on).Label(label(o.Label, text)).Disabled(o.Disabled) }) + // The base turns before it learns it is off. + v := *on + shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.SwitchBase(c, &v).Label(label(o.Label, text)).Disabled(o.Disabled) }) + changed := v != *on && !base.IsDisabled() + if changed { + *on = v + } shell.Size(k.D(32+pixel.Measure(text)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) g.Frame(0, 1, 27, 14, pixel.Raised, pixel.Shade) @@ -43,7 +49,7 @@ func Switch(c *ui.Context, text string, on *bool, o SwitchOptions) SwitchResult g.Text(32, 0, text, ink, 1) g.Finish(st) }) - res := SwitchResult{Element: shell, Changed: base.Changed()} + res := SwitchResult{Element: shell, Changed: changed} if res.Changed { slog.Debug("8bit: switch", "label", text, "on", *on) } diff --git a/input/taken_keys_test.go b/input/taken_keys_test.go new file mode 100644 index 0000000..1c38d09 --- /dev/null +++ b/input/taken_keys_test.go @@ -0,0 +1,27 @@ +package input_test + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" +) + +func TestDPadKeysDoNotClickWhereThePointerRests(t *testing.T) { + var pressed []input.Direction + tt := view(300, 200, nil, func(c *ui.Context) { + if r := input.DPad(c, "Pad", input.DPadOptions{}); r.Pressed != input.None { + pressed = append(pressed, r.Pressed) + } + }) + r := find(t, tt, "Pad") + // The pointer rests on the up arm. + tt.Move(r.X+r.W/2, r.Y+r.H/6) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyEnter) + if len(pressed) != 0 { + t.Fatalf("Space and Enter pressed %v", pressed) + } +} diff --git a/input/toggle.go b/input/toggle.go index 5d5cb41..5b3045b 100644 --- a/input/toggle.go +++ b/input/toggle.go @@ -25,9 +25,15 @@ type ToggleResult struct { func Toggle(c *ui.Context, text string, on *bool, o ToggleOptions) ToggleResult { k := pixel.K(c) h, pad, top, sc := o.Size.metrics() + // The base turns before it learns it is off. + v := *on shell, base, st := pixel.Shell(c, func() *ui.Element { - return ui.ToggleBase(c, on).Label(label(o.Label, text)).Disabled(o.Disabled) + return ui.ToggleBase(c, &v).Label(label(o.Label, text)).Disabled(o.Disabled) }) + changed := v != *on && !base.IsDisabled() + if changed { + *on = v + } lead := 0 if o.Icon != nil { lead = o.Icon.W @@ -44,7 +50,7 @@ func Toggle(c *ui.Context, text string, on *bool, o ToggleOptions) ToggleResult g.Text(pad+lead, top+dy, text, ink, sc) g.Finish(st) }) - res := ToggleResult{Element: shell, Changed: base.Changed()} + res := ToggleResult{Element: shell, Changed: changed} if res.Changed { slog.Debug("8bit: toggle", "label", label(o.Label, text), "on", *on) } diff --git a/input/togglegroup.go b/input/togglegroup.go index 1db0aa3..57ef892 100644 --- a/input/togglegroup.go +++ b/input/togglegroup.go @@ -32,7 +32,9 @@ func ToggleGroup(c *ui.Context, labels []string, on []bool, o ToggleGroupOptions res := ToggleGroupResult{} res.Element = ui.Row(c).Role(ui.RoleToolbar).Label(o.Label).FocusGroup(ui.Horizontal).Children(func() { for i, l := range labels { - shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ToggleBase(c, &on[i]).Label(l).Disabled(o.Disabled) }) + // The base turns before it learns it is off. + v := on[i] + shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ToggleBase(c, &v).Label(l).Disabled(o.Disabled) }) if i > 0 { shell.Margin(0, 0, 0, k.D(-1)) } @@ -42,8 +44,8 @@ func ToggleGroup(c *ui.Context, labels []string, on []bool, o ToggleGroupOptions g.Text(pad, top+dy, l, ink, sc) g.Finish(st) }) - if base.Changed() { - res.Changed = true + if v != on[i] && !base.IsDisabled() { + on[i], res.Changed = v, true if o.Single && on[i] { for j := range on { on[j] = j == i diff --git a/input/tokenfield.go b/input/tokenfield.go index a43524e..0a9e28d 100644 --- a/input/tokenfield.go +++ b/input/tokenfield.go @@ -34,6 +34,8 @@ func TokenField(c *ui.Context, text string, tokens *[]string, o TokenFieldOption e := ui.Row(c).Wrap().Width(k.D(pixel.Or("TokenField Width", o.Width, 200))).Padding(k.D(3), k.D(4)).Gap(k.D(3)).AlignItems(ui.Center) e.Role(ui.RoleGroup).Label(text).Description(strings.Join(*tokens, ", ")) draft := ui.Local(e, tokenKey{}, func() string { return "" }) + // The row stays enabled, so off is checked here. + off := o.Disabled || pixel.Current(c).Inert res := TokenFieldResult{Element: e} add := func(s string) { if s = strings.TrimSpace(s); s != "" && !slices.Contains(*tokens, s) { @@ -41,17 +43,17 @@ func TokenField(c *ui.Context, text string, tokens *[]string, o TokenFieldOption } } // A comma typed since the last frame cuts the draft. - if parts := strings.Split(*draft, ","); len(parts) > 1 { + if parts := strings.Split(*draft, ","); len(parts) > 1 && !off { for _, p := range parts[:len(parts)-1] { add(p) } *draft = parts[len(parts)-1] } - if e.Shortcut(0, ui.KeyEnter) { + if !off && pixel.Shortcut(e, 0, ui.KeyEnter) { add(*draft) *draft = "" } - if *draft == "" && len(*tokens) > 0 && e.Shortcut(0, ui.KeyBackspace) { + if !off && *draft == "" && len(*tokens) > 0 && pixel.Shortcut(e, 0, ui.KeyBackspace) { *tokens, res.Changed = (*tokens)[:len(*tokens)-1], true } var ed pixel.Edit @@ -73,7 +75,7 @@ func TokenField(c *ui.Context, text string, tokens *[]string, o TokenFieldOption focus := ed.Focused e.Draw(func(p *ui.Painter, r ui.Rect) { well(k.Canvas(p, r), pixel.Edge, focus) }) if res.Changed { - slog.Debug("8bit: tokens", "label", text, "tokens", *tokens) + slog.Debug("8bit: tokens", "label", text, "tokens", len(*tokens)) } return res } diff --git a/input/turns_test.go b/input/turns_test.go new file mode 100644 index 0000000..91c6f04 --- /dev/null +++ b/input/turns_test.go @@ -0,0 +1,79 @@ +package input_test + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" +) + +// turnCase builds a control on fresh state, and reads it. +type turnCase func() (build func(c *ui.Context), value func() string) + +func TestKeysInAVeilsFirstFrameTurnNothing(t *testing.T) { + cases := map[string]struct { + mk turnCase + tabs int + key ui.Key + }{ + "Toggle": {func() (func(c *ui.Context), func() string) { + on := false + return func(c *ui.Context) { input.Toggle(c, "Mute", &on, input.ToggleOptions{}) }, func() string { return fmt.Sprint(on) } + }, 1, ui.KeySpace}, + "Switch": {func() (func(c *ui.Context), func() string) { + on := false + return func(c *ui.Context) { input.Switch(c, "Sound", &on, input.SwitchOptions{}) }, func() string { return fmt.Sprint(on) } + }, 1, ui.KeySpace}, + "Checkbox": {func() (func(c *ui.Context), func() string) { + on := false + return func(c *ui.Context) { input.Checkbox(c, "Hard", &on, input.CheckboxOptions{}) }, func() string { return fmt.Sprint(on) } + }, 1, ui.KeySpace}, + "Radio": {func() (func(c *ui.Context), func() string) { + v := "A" + return func(c *ui.Context) { + input.Radio(c, "A", "A", &v, input.RadioOptions{}) + input.Radio(c, "B", "B", &v, input.RadioOptions{}) + }, func() string { return v } + }, 2, ui.KeySpace}, + "RadioGroup": {func() (func(c *ui.Context), func() string) { + v := 0 + return func(c *ui.Context) { input.RadioGroup(c, []string{"A", "B", "C"}, &v, input.RadioGroupOptions{}) }, func() string { return fmt.Sprint(v) } + }, 1, ui.KeyDown}, + "Rating": {func() (func(c *ui.Context), func() string) { + v := 2 + return func(c *ui.Context) { input.Rating(c, "Stars", &v, input.RatingOptions{}) }, func() string { return fmt.Sprint(v) } + }, 1, ui.KeyRight}, + "CheckboxGroup": {func() (func(c *ui.Context), func() string) { + on := []bool{false, false} + return func(c *ui.Context) { + input.CheckboxGroup(c, "All", []string{"A", "B"}, on, input.CheckboxGroupOptions{}) + }, func() string { return fmt.Sprint(on) } + }, 1, ui.KeySpace}, + "ToggleGroup": {func() (func(c *ui.Context), func() string) { + on := []bool{false, false} + return func(c *ui.Context) { input.ToggleGroup(c, []string{"B", "I"}, on, input.ToggleGroupOptions{}) }, func() string { return fmt.Sprint(on) } + }, 1, ui.KeySpace}, + "Segmented": {func() (func(c *ui.Context), func() string) { + v := 0 + return func(c *ui.Context) { input.Segmented(c, "View", []string{"A", "B"}, &v, input.SegmentedOptions{}) }, func() string { return fmt.Sprint(v) } + }, 1, ui.KeyRight}, + } + for name, tc := range cases { + for _, veil := range []bool{false, true} { + build, value := tc.mk() + inert := false + tt := underVeil(veilCase{build: build}, &inert) + for range tc.tabs { + tt.Key(0, ui.KeyTab) + } + before := value() + inert = veil + tt.Key(0, tc.key) + if turned := value() != before; turned == veil { + t.Errorf("%s: veiled %v, the key turned %s to %s", name, veil, before, value()) + } + } + } +} diff --git a/input/typed_log_test.go b/input/typed_log_test.go new file mode 100644 index 0000000..b79531b --- /dev/null +++ b/input/typed_log_test.go @@ -0,0 +1,77 @@ +package input_test + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" +) + +func TestLogsCountWhatWasTyped(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(prev) + name, tags, hero := "", []string{}, "Hero" + tt := view(500, 400, nil, func(c *ui.Context) { + input.NameEntry(c, "Initials", &name, input.NameEntryOptions{}) + input.TokenField(c, "Tags", &tags, input.TokenFieldOptions{}) + input.EditableText(c, "Name", &hero, input.EditableTextOptions{}) + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + for range 4 { + tt.Key(0, ui.KeyEnter) + } + tt.Key(0, ui.KeyTab) + tt.Type("zork") + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Type("Quux") + tt.Key(0, ui.KeyEnter) + if name != "BBB" || len(tags) != 1 || !strings.HasSuffix(hero, "Quux") { + t.Fatalf("typed %q, %q, %q", name, tags, hero) + } + for _, s := range []string{"BBB", "zork", "Quux"} { + if strings.Contains(buf.String(), s) { + t.Errorf("the log holds %q", s) + } + } +} + +func TestAutocompleteLogsNoWords(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(prev) + q := "" + tt := view(400, 200, nil, func(c *ui.Context) { + input.Autocomplete(c, "Find", &q, []string{"QuuxSecret"}, input.AutocompleteOptions{}) + }) + tt.Key(0, ui.KeyTab) + tt.Type("Quu") + tt.Key(0, ui.KeyTab) + if q != "QuuxSecret" || strings.Contains(buf.String(), "Secret") { + t.Fatalf("value %q; the log has the word: %v", q, strings.Contains(buf.String(), "Secret")) + } +} + +func TestComboboxLogsNoWords(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(prev) + v := "" + tt := view(400, 300, nil, func(c *ui.Context) { input.Combobox(c, "Find", &v, []string{"QuuxSecret"}, input.ComboboxOptions{}) }) + tt.Key(0, ui.KeyTab) + tt.Type("QuuxSecret") + tt.Key(0, ui.KeyEnter) + if v != "QuuxSecret" || strings.Contains(buf.String(), "QuuxSecret") { + t.Fatalf("value %q; the log has the word: %v", v, strings.Contains(buf.String(), "QuuxSecret")) + } +} diff --git a/input/veil_focus_test.go b/input/veil_focus_test.go new file mode 100644 index 0000000..2712067 --- /dev/null +++ b/input/veil_focus_test.go @@ -0,0 +1,37 @@ +package input_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestMultiSelectGivesPopupFocusBackAfterAVeil(t *testing.T) { + chosen, inert := []string{}, false + tt := view(400, 300, &clock{time.Unix(3_000_000, 0)}, func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = inert + pixel.Scope(c, s, func() { + input.MultiSelect(c, "Party", &chosen, []string{"Mage", "Rogue"}, input.MultiSelectOptions{}) + }) + pixel.Text(c, "Elsewhere", pixel.TextOptions{}) + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + // Focus on Mage, not the root that holds everything. + if !tt.Focused("Mage") || tt.Focused("Elsewhere") { + t.Fatalf("focus is not in the popup: %q", tt.Texts()) + } + inert = true + tt.Frame() + inert = false + tt.Frame() + tt.Key(0, ui.KeyDown) + if !tt.HasText("Rogue") { + t.Fatal("the field did not get its focus back after the veil") + } +} diff --git a/input/veil_input_test.go b/input/veil_input_test.go new file mode 100644 index 0000000..d6c6b12 --- /dev/null +++ b/input/veil_input_test.go @@ -0,0 +1,314 @@ +package input_test + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// veilCase is a control, its value, key and drag. +type veilCase struct { + label string + build func(c *ui.Context) + value func() string + key ui.Key + // dx, dy move the pointer; zeros skip the drag. + dx, dy float32 +} + +func veilCases() map[string]func() veilCase { + return map[string]func() veilCase{ + "Knob": func() veilCase { + v := 50.0 + return veilCase{"Gain", func(c *ui.Context) { input.Knob(c, "Gain", &v, input.KnobOptions{Max: 100}) }, + func() string { return fmt.Sprint(v) }, ui.KeyUp, 0, -20} + }, + "RangeSlider": func() veilCase { + lo, hi := 20.0, 80.0 + return veilCase{"Band low", func(c *ui.Context) { + input.RangeSlider(c, "Band", &lo, &hi, input.RangeSliderOptions{Max: 100}) + }, func() string { return fmt.Sprint(lo, hi) }, ui.KeyRight, 20, 0} + }, + "SlotPicker": func() veilCase { + v := 1 + return veilCase{"Reel", func(c *ui.Context) { + input.SlotPicker(c, "Reel", &v, []string{"A", "B", "C", "D"}, input.SlotPickerOptions{}) + }, func() string { return fmt.Sprint(v) }, ui.KeyDown, 0, -40} + }, + "NumberInput": func() veilCase { + v := 5.0 + return veilCase{"Lives", func(c *ui.Context) { input.NumberInput(c, "Lives", &v, input.NumberInputOptions{Max: 9}) }, + func() string { return fmt.Sprint(v) }, ui.KeyUp, 0, 0} + }, + "Joystick": func() veilCase { + var r input.JoystickResult + return veilCase{"Stick", func(c *ui.Context) { r = input.Joystick(c, "Stick", input.JoystickOptions{}) }, + func() string { return fmt.Sprintf("%.2f %.2f", r.X, r.Y) }, ui.KeyRight, 12, 0} + }, + } +} + +// underVeil builds tc where the test raises a veil. +func underVeil(tc veilCase, inert *bool) *ui.Tester { + return view(400, 300, nil, func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = *inert + pixel.Scope(c, s, func() { tc.build(c) }) + }) +} + +func TestVeilsStopDragsAndKeys(t *testing.T) { + for name, mk := range veilCases() { + t.Run(name, func(t *testing.T) { + if tc := mk(); tc.dx != 0 || tc.dy != 0 { + inert := false + tt := underVeil(tc, &inert) + r := find(t, tt, tc.label) + x, y := r.X+r.W/2, r.Y+r.H/2 + start := tc.value() + tt.Press(x, y) + tt.Move(x+tc.dx, y+tc.dy) + moved := tc.value() + if moved == start { + t.Fatalf("the drag did nothing to %s", start) + } + inert = true + tt.Move(x+2*tc.dx, y+2*tc.dy) + // A stick let go is centered; values stay. + if got := tc.value(); got != moved && got != "0.00 0.00" { + t.Errorf("a drag under the veil moved %s to %s", moved, got) + } + tt.Release(x+2*tc.dx, y+2*tc.dy) + } + tc, inert := mk(), false + tt := underVeil(tc, &inert) + tt.Key(0, ui.KeyTab) + before := tc.value() + inert = true + tt.Key(0, tc.key) + if got := tc.value(); got != before { + t.Errorf("a key in the veil's first frame moved %s to %s", before, got) + } + }) + } +} + +func TestSlotPickerWheelStopsUnderAVeil(t *testing.T) { + v, inert := 1, true + tt := underVeil(veilCase{build: func(c *ui.Context) { + input.SlotPicker(c, "Reel", &v, []string{"A", "B", "C", "D"}, input.SlotPickerOptions{}) + }}, &inert) + r := find(t, tt, "Reel") + tt.Scroll(r.X+r.W/2, r.Y+r.H/2, 0, 100) + if v != 1 { + t.Fatalf("the wheel turned a veiled reel to %d", v) + } + inert = false + tt.Frame() + tt.Scroll(r.X+r.W/2, r.Y+r.H/2, 0, 100) + if v == 1 { + t.Fatal("the wheel did not turn the reel") + } +} + +func TestVeilStopsHoldsAndPicks(t *testing.T) { + // HoldButton: Space repeats, and the veil falls with the last. + clk, inert, confirmed := &clock{time.Unix(3_000_000, 0)}, false, 0 + tt := view(400, 200, clk, func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = inert + pixel.Scope(c, s, func() { + if input.HoldButton(c, "ERASE", input.HoldButtonOptions{}).Confirmed { + confirmed++ + } + }) + }) + tt.Key(0, ui.KeyTab) + for i := range 3 { + inert = i == 2 + tt.Key(0, ui.KeySpace) + clk.t = clk.t.Add(500 * time.Millisecond) + } + tt.Frame() + if confirmed != 0 { + t.Fatal("a hold finished under the veil") + } + // Autocomplete and Combobox: Tab or Enter as the veil falls. + for name, build := range map[string]func(c *ui.Context, v *string){ + "Autocomplete": func(c *ui.Context, v *string) { + input.Autocomplete(c, "Hero", v, []string{"Quux"}, input.AutocompleteOptions{}) + }, + "Combobox": func(c *ui.Context, v *string) { + input.Combobox(c, "Hero", v, []string{"Quux"}, input.ComboboxOptions{}) + }, + } { + v, inert := "", false + tt := view(400, 300, nil, func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = inert + pixel.Scope(c, s, func() { build(c, &v) }) + }) + tt.Key(0, ui.KeyTab) + tt.Type("Qu") + inert = true + tt.Key(0, map[string]ui.Key{"Autocomplete": ui.KeyTab, "Combobox": ui.KeyEnter}[name]) + if v != "Qu" { + t.Errorf("%s: a key as the veil fell made %q", name, v) + } + } +} + +func TestVeilStopsTyping(t *testing.T) { + for name, tc := range map[string]struct { + build func(c *ui.Context, v *string, tags *[]string) + // last is the key or text coming with the veil. + last string + }{ + // The editor turns the typed letter away. + "TextInput": {func(c *ui.Context, v *string, _ *[]string) { input.TextInput(c, "Name", v, input.TextInputOptions{}) }, "Z"}, + "TokenField": {func(c *ui.Context, v *string, tags *[]string) { + input.TokenField(c, "Tags", tags, input.TokenFieldOptions{}) + }, "enter"}, + "TokenField comma": {func(c *ui.Context, v *string, tags *[]string) { + input.TokenField(c, "Tags", tags, input.TokenFieldOptions{}) + }, ","}, + "EditableText": {func(c *ui.Context, v *string, _ *[]string) { + input.EditableText(c, "Hero", v, input.EditableTextOptions{}) + }, "enter"}, + } { + v, tags, inert := "before", []string{}, false + tt := view(400, 200, nil, func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = inert + pixel.Scope(c, s, func() { tc.build(c, &v, &tags) }) + }) + tt.Key(0, ui.KeyTab) + if name == "EditableText" { + tt.Key(0, ui.KeyEnter) + } + tt.Type("ab") + inert = true + if tc.last == "enter" { + tt.Key(0, ui.KeyEnter) + } else { + tt.Type(tc.last) + } + if want := map[string]string{"TextInput": "beforeab", "EditableText": "before"}[name]; want != "" && v != want || len(tags) != 0 { + t.Errorf("%s: a key as the veil fell left %q, tags %q", name, v, tags) + } + } +} + +func TestRadioGroupSpaceAsAVeilFallsTurnsNothing(t *testing.T) { + sel, inert := 0, false + tt := underVeil(veilCase{build: func(c *ui.Context) { + input.RadioGroup(c, []string{"A", "B", "C"}, &sel, input.RadioGroupOptions{}) + }}, &inert) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + // Focus stays on B as the caller sets A again. + sel = 0 + tt.Frame() + inert = true + tt.Key(0, ui.KeySpace) + if sel != 0 { + t.Fatalf("Space as the veil fell chose %d", sel) + } +} + +func TestEditorKeepsTheCallersValueAsAVeilFalls(t *testing.T) { + v, inert := "before", false + tt := underVeil(veilCase{build: func(c *ui.Context) { input.TextInput(c, "Text", &v, input.TextInputOptions{}) }}, &inert) + tt.Key(0, ui.KeyTab) + // The caller writes as the veil falls on a letter. + v, inert = "server", true + tt.Type("x") + if v != "server" { + t.Fatalf("the caller's value became %q", v) + } +} + +func TestHandlersActNotAsAVeilFalls(t *testing.T) { + // PinInput: a digit typed as the veil or Disabled falls. + for _, kind := range []string{"inert", "disabled"} { + v, inert, off, complete := "123", false, false, 0 + tt := underVeil(veilCase{build: func(c *ui.Context) { + if input.PinInput(c, "PIN", &v, input.PinInputOptions{Disabled: off}).Complete { + complete++ + } + }}, &inert) + tt.Key(0, ui.KeyTab) + inert, off = kind == "inert", kind == "disabled" + tt.Type("4") + if v != "123" || complete != 0 { + t.Errorf("PinInput %s: %q, complete %d", kind, v, complete) + } + } + // CheatCode: the code's last key comes with the veil. + inert, wins := false, 0 + tt := underVeil(veilCase{build: func(c *ui.Context) { + if input.CheatCode(c, "Code", input.CheatCodeOptions{Sequence: []ui.Key{ui.KeyB, ui.KeyA}}).Unlocked { + wins++ + } + }}, &inert) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyB) + inert = true + tt.Key(0, ui.KeyA) + if wins != 0 { + t.Errorf("the code unlocked under the veil %d times", wins) + } + // SlotPicker: a wheel notch comes with the veil. + slot := 0 + inert = false + tt = underVeil(veilCase{build: func(c *ui.Context) { + input.SlotPicker(c, "Slots", &slot, []string{"A", "B", "C"}, input.SlotPickerOptions{}) + }}, &inert) + r := find(t, tt, "Slots") + inert = true + tt.Scroll(r.X+r.W/2, r.Y+r.H/2, 0, 40) + if slot != 0 { + t.Errorf("the wheel turned a veiled reel to %d", slot) + } +} + +func TestComboboxPicksNothingAsAVeilFalls(t *testing.T) { + for _, kind := range []string{"inert", "disabled"} { + inert, off, v, chosen := false, false, "", 0 + tt := underVeil(veilCase{build: func(c *ui.Context) { + if input.Combobox(c, "Combo", &v, []string{"Alpha", "Beta"}, input.ComboboxOptions{Disabled: off}).Chosen { + chosen++ + } + }}, &inert) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + r := find(t, tt, "Beta") + // The press comes before the veil, the release after. + tt.Press(r.X+r.W/2, r.Y+r.H/2) + inert, off = kind == "inert", kind == "disabled" + tt.Release(r.X+r.W/2, r.Y+r.H/2) + if v != "" || chosen != 0 { + t.Errorf("%s: picked %q, %d times", kind, v, chosen) + } + } +} + +func TestStepperHoldNeverRunsBackward(t *testing.T) { + clk, v := &clock{time.Unix(1_700_000_000, 0)}, 50 + tt := view(400, 100, clk, func(c *ui.Context) { input.Stepper(c, "Step", &v, input.StepperOptions{Max: 100}) }) + r := find(t, tt, "Step") + tt.Press(r.X+r.W+12, r.Y+r.H/2) + clk.t = clk.t.Add(time.Second) + tt.Frame() + before := v + clk.t = clk.t.Add(-time.Hour) + tt.Frame() + if before <= 51 || v < before { + t.Fatalf("a held + went %d, then %d", before, v) + } +} diff --git a/internal/archtest/archtest_test.go b/internal/archtest/archtest_test.go index 93318fe..1bd5560 100644 --- a/internal/archtest/archtest_test.go +++ b/internal/archtest/archtest_test.go @@ -162,6 +162,10 @@ func TestNoBannedCalls(t *testing.T) { if pkg && isUI(sel.X, "", imported) && bannedFuncs[sel.Sel.Name] || !pkg && bannedMethods[sel.Sel.Name] { t.Errorf("%s: banned call %s", f.path, sel.Sel.Name) } + // An element's keys go through pixel.Shortcut; c's are the window's. + if id, ok := sel.X.(*ast.Ident); sel.Sel.Name == "Shortcut" && !pkg && !(ok && id.Name == "c") { + t.Errorf("%s: %s.Shortcut skips the enabled check; use pixel.Shortcut", f.path, receiver(sel.X)) + } } return true }) @@ -269,3 +273,61 @@ func TestWebScripts(t *testing.T) { t.Fatalf("found %d web scripts; the walk is broken", seen) } } + +// TestComponentsTakeTheirOptions holds PRD 5.1's shape: Name takes NameOptions. +func TestComponentsTakeTheirOptions(t *testing.T) { + for _, f := range files(t) { + tier, ok := tiers[f.pkg] + if !ok || tier < 2 || tier > 6 || strings.HasSuffix(f.path, "_test.go") { + continue + } + for _, d := range f.ast.Decls { + fn, ok := d.(*ast.FuncDecl) + // Undrawn bases, as MyGo's own, are parts, not components. + if !ok || fn.Recv != nil || !fn.Name.IsExported() || strings.HasSuffix(fn.Name.Name, "Base") || len(fn.Type.Params.List) == 0 || !isContext(fn.Type.Params.List[0].Type) { + continue + } + want := fn.Name.Name + "Options" + if !slices.ContainsFunc(fn.Type.Params.List, func(p *ast.Field) bool { return typeName(p.Type) == want }) { + t.Errorf("%s: %s takes no %s", f.path, fn.Name.Name, want) + } + if r := fn.Type.Results; r != nil && len(r.List) == 1 && strings.HasSuffix(typeName(r.List[0].Type), "Result") && typeName(r.List[0].Type) != fn.Name.Name+"Result" { + t.Errorf("%s: %s returns %s", f.path, fn.Name.Name, typeName(r.List[0].Type)) + } + } + } +} + +// isContext reports whether x is *ui.Context. +func isContext(x ast.Expr) bool { + star, ok := x.(*ast.StarExpr) + if !ok { + return false + } + sel, ok := star.X.(*ast.SelectorExpr) + return ok && sel.Sel.Name == "Context" +} + +// typeName is a plain or generic type's name, else "". +func typeName(x ast.Expr) string { + switch x := x.(type) { + case *ast.Ident: + return x.Name + case *ast.IndexExpr: + return typeName(x.X) + case *ast.IndexListExpr: + return typeName(x.X) + } + return "" +} + +// receiver names a selector's receiver for a message. +func receiver(x ast.Expr) string { + switch x := x.(type) { + case *ast.Ident: + return x.Name + case *ast.SelectorExpr: + return receiver(x.X) + "." + x.Sel.Name + } + return "an element" +} diff --git a/layout/layers.go b/layout/layers.go index 46c70b3..579937e 100644 --- a/layout/layers.go +++ b/layout/layers.go @@ -2,8 +2,10 @@ package layout import "github.com/egoist/mygo/ui" +type LayersOptions struct{} + // Layers stacks layers in a box; the first sizes it. -func Layers(c *ui.Context, layers ...func()) *ui.Element { +func Layers(c *ui.Context, o LayersOptions, layers ...func()) *ui.Element { if len(layers) == 0 { panic("layout: Layers needs a layer") } diff --git a/layout/layout_test.go b/layout/layout_test.go index c9a4bef..e9195b8 100644 --- a/layout/layout_test.go +++ b/layout/layout_test.go @@ -328,8 +328,8 @@ func TestLayersSizes(t *testing.T) { tt := ui.NewTester(func(c *ui.Context) { use(c) ui.Column(c).AlignItems(ui.Start).Children(func() { - auto = layout.Layers(c, func() { ui.Box(c).Size(50, 30) }, func() { ui.Box(c).Fill() }) - sized = layout.Layers(c, func() { fill = ui.Box(c).Fill() }).Size(120, 64) + auto = layout.Layers(c, layout.LayersOptions{}, func() { ui.Box(c).Size(50, 30) }, func() { ui.Box(c).Fill() }) + sized = layout.Layers(c, layout.LayersOptions{}, func() { fill = ui.Box(c).Fill() }).Size(120, 64) }) }, 400, 300) tt.Frame() @@ -352,7 +352,7 @@ func TestTitledContainersAndOrder(t *testing.T) { layout.Card(c, layout.CardOptions{Title: "QUEST", Footer: func() { pixel.Text(c, "FOOT", pixel.TextOptions{}) }}, func() { pixel.Text(c, "BODY", pixel.TextOptions{}) }) - layout.StatusBar(c, "Ready", "Ln 1") + layout.StatusBar(c, []string{"Ready", "Ln 1"}, layout.StatusBarOptions{}) }, 400, 400) got := tt.Texts() for _, l := range []string{"ITEMS", "Sound", "QUEST", "Ready", "Ln 1"} { diff --git a/layout/resizable.go b/layout/resizable.go index 12e4829..c13cdb6 100644 --- a/layout/resizable.go +++ b/layout/resizable.go @@ -48,7 +48,7 @@ func Resizable(c *ui.Context, w, h *int, o ResizableOptions, children func()) Re *w, *h = *w+dx, *h+dy } for key, d := range map[ui.Key][2]int{ui.KeyLeft: {-4, 0}, ui.KeyRight: {4, 0}, ui.KeyUp: {0, -4}, ui.KeyDown: {0, 4}} { - if !o.Disabled && base.Shortcut(0, key) { + if !o.Disabled && pixel.Shortcut(base, 0, key) { *w, *h = *w+d[0], *h+d[1] } } diff --git a/layout/split.go b/layout/split.go index d4c9910..4baee5d 100644 --- a/layout/split.go +++ b/layout/split.go @@ -60,10 +60,10 @@ func Split(c *ui.Context, size *int, o SplitOptions, first, second func()) Split } *size += dx } - if !o.Disabled && base.Shortcut(0, back) { + if !o.Disabled && pixel.Shortcut(base, 0, back) { *size -= 4 } - if !o.Disabled && base.Shortcut(0, fwd) { + if !o.Disabled && pixel.Shortcut(base, 0, fwd) { *size += 4 } *size = limit(*size) diff --git a/layout/statusbar.go b/layout/statusbar.go index 193db00..37773e2 100644 --- a/layout/statusbar.go +++ b/layout/statusbar.go @@ -6,8 +6,10 @@ import ( "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" ) +type StatusBarOptions struct{} + // StatusBar shows segments split by sunken dividers; the first grows. -func StatusBar(c *ui.Context, segments ...string) *ui.Element { +func StatusBar(c *ui.Context, segments []string, o StatusBarOptions) *ui.Element { k := pixel.K(c) return ui.Row(c).FillWidth().Height(k.D(pixel.LineHeight + 3)).AlignItems(ui.Stretch).Role(ui.RoleStatus).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) diff --git a/media/clock_back_test.go b/media/clock_back_test.go new file mode 100644 index 0000000..e32b9e5 --- /dev/null +++ b/media/clock_back_test.go @@ -0,0 +1,44 @@ +package media_test + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/media" +) + +func TestMediaClocksHoldWhenTheClockStepsBack(t *testing.T) { + wd := newWorld() + var r media.StepSequencerResult + tt := view(400, 300, wd, func(c *ui.Context) { + r = media.StepSequencer(c, "Sequence", []string{"Kick"}, [][16]bool{{}}, media.StepSequencerOptions{Playing: true}) + }) + wd.clk.t = wd.clk.t.Add(-200 * time.Millisecond) + tt.Frame() + if r.Step < 0 { + t.Errorf("the playhead sits at step %d", r.Step) + } + rec := true + tt = view(400, 300, wd, func(c *ui.Context) { media.RecordButton(c, "Record", &rec, media.RecordButtonOptions{}) }) + wd.clk.t = wd.clk.t.Add(-1200 * time.Millisecond) + tt.Frame() + if strings.Contains(strings.Join(tt.Texts(), " "), "-") { + t.Errorf("a negative take: %q", tt.Texts()) + } +} + +func TestStepSequencerTempoStaysUnderS5(t *testing.T) { + defer func() { + if r := fmt.Sprint(recover()); !strings.HasPrefix(r, "media: StepSequencer Tempo") { + t.Fatalf("Tempo 300 gave %q", r) + } + }() + // 225 BPM is fifteen steps a second; 300 is twenty. + view(400, 300, newWorld(), func(c *ui.Context) { + media.StepSequencer(c, "Seq", []string{"Kick"}, [][16]bool{{}}, media.StepSequencerOptions{Tempo: 300}) + }) +} diff --git a/media/media.go b/media/media.go index 2c7ad18..11b70ef 100644 --- a/media/media.go +++ b/media/media.go @@ -80,7 +80,8 @@ func turned(c *ui.Context, k pixel.Kit, e *ui.Element, playing bool, fps int) in on := playing && !k.Reduce() if on { n := k.Tick(c, fps) - if !s.playing { + // A clock stepping back rebases, so the count never falls. + if !s.playing || n-s.from < s.held { s.from = n - s.held } s.held = n - s.from diff --git a/media/notes.go b/media/notes.go index bc15640..861fef0 100644 --- a/media/notes.go +++ b/media/notes.go @@ -4,6 +4,8 @@ import ( "fmt" "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" ) // keyboard plays C4–C5: A–K white, WETYU black. @@ -26,7 +28,7 @@ func note(semi int) string { func played(e *ui.Element) []int { var out []int for semi, kb := range keyboard { - if e.Shortcut(0, kb.key) { + if pixel.Shortcut(e, 0, kb.key) { out = append(out, semi) } } diff --git a/media/recordbutton.go b/media/recordbutton.go index e5d51ac..97ad048 100644 --- a/media/recordbutton.go +++ b/media/recordbutton.go @@ -59,13 +59,13 @@ func RecordButton(c *ui.Context, text string, rec *bool, o RecordButtonOptions) case *rec && tk.since.IsZero(): tk.since, tk.took = now, 0 case !*rec && !tk.since.IsZero(): - tk.since, tk.took = time.Time{}, now.Sub(tk.since) + tk.since, tk.took = time.Time{}, k.Elapsed(&tk.since) } took, dot := tk.took, recDot if *rec { - took = now.Sub(tk.since) + took = k.Elapsed(&tk.since) k.At(c, tk.since.Add(took.Truncate(time.Second)+time.Second)) - if !k.Reduce() && k.Tick(c, 2)%2 == 1 { + if !k.Reduce() && k.Tick(c, 4)%4 >= 2 { dot = recOff } } diff --git a/media/stepsequencer.go b/media/stepsequencer.go index 593f970..33be927 100644 --- a/media/stepsequencer.go +++ b/media/stepsequencer.go @@ -11,7 +11,7 @@ import ( ) type StepSequencerOptions struct { - // Tempo is BPM, four steps a beat; zero means 120. + // Tempo is 1–225 BPM, four steps a beat; zero: 120. Tempo int Playing bool Disabled bool @@ -42,6 +42,10 @@ func StepSequencer(c *ui.Context, text string, tracks []string, steps [][16]bool panic(fmt.Sprintf("media: StepSequencer has %d tracks and %d rows", len(tracks), len(steps))) } tempo := pixel.Or("StepSequencer Tempo", o.Tempo, 120) + // S5 caps the playhead at fifteen steps a second. + if tempo > 225 { + panic(fmt.Sprintf("media: StepSequencer Tempo %d over 225", tempo)) + } res := StepSequencerResult{Step: -1} labels := 0 for _, t := range tracks { @@ -59,7 +63,7 @@ func StepSequencer(c *ui.Context, text string, tracks []string, steps [][16]bool // The playhead is the music; Reduce Motion keeps it. if o.Playing { per := time.Minute / time.Duration(4*tempo) - n := int(k.Now().Sub(sq.since) / per) + n := int(k.Elapsed(&sq.since) / per) res.Step = n % 16 k.At(c, sq.since.Add(time.Duration(n+1)*per)) } diff --git a/media/tracker.go b/media/tracker.go index 805ca5f..69f1de0 100644 --- a/media/tracker.go +++ b/media/tracker.go @@ -49,7 +49,7 @@ func Tracker(c *ui.Context, text string, rows []Row, cur *int, o TrackerOptions) slog.Debug("8bit: tracker", "label", text, "row", *cur, "note", rows[*cur].Note) *cur = min(len(rows)-1, *cur+1) } - if base.Shortcut(0, ui.KeyDelete) || base.Shortcut(0, ui.KeyBackspace) { + if pixel.Shortcut(base, 0, ui.KeyDelete) || pixel.Shortcut(base, 0, ui.KeyBackspace) { rows[*cur], res.Changed = Row{}, true slog.Debug("8bit: tracker", "label", text, "row", *cur, "note", "") } diff --git a/navigation/commandmenu.go b/navigation/commandmenu.go index d910055..b674f31 100644 --- a/navigation/commandmenu.go +++ b/navigation/commandmenu.go @@ -41,17 +41,17 @@ func CommandMenu(c *ui.Context, text string, items []MenuItem, o CommandMenuOpti res.Chosen = i } } - if base.Shortcut(0, ui.KeyDown) { + if pixel.Shortcut(base, 0, ui.KeyDown) { *cur = (*cur + 1) % n } - if base.Shortcut(0, ui.KeyUp) { + if pixel.Shortcut(base, 0, ui.KeyUp) { *cur = (*cur + n - 1) % n } - enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace) + enter, space := pixel.Shortcut(base, 0, ui.KeyEnter), pixel.Shortcut(base, 0, ui.KeySpace) if enter || space { pick(*cur) } - res.Canceled = base.Shortcut(0, ui.KeyEscape) + res.Canceled = pixel.Shortcut(base, 0, ui.KeyEscape) // Enter and Space are taken, so clicks come from pointers. if base.Clicked() { _, y, _ := base.PointerPosition() diff --git a/navigation/commandpalette.go b/navigation/commandpalette.go index ddcf8d4..c1e7fec 100644 --- a/navigation/commandpalette.go +++ b/navigation/commandpalette.go @@ -68,16 +68,16 @@ func CommandPalette(c *ui.Context, text string, commands []MenuItem, open *bool, if f.Changed { st.lit = 0 } - if f.Element.Shortcut(0, ui.KeyDown) { + if pixel.Shortcut(f.Element, 0, ui.KeyDown) { st.lit++ } - if f.Element.Shortcut(0, ui.KeyUp) { + if pixel.Shortcut(f.Element, 0, ui.KeyUp) { st.lit-- } st.lit = max(0, min(st.lit, len(shown)-1)) st.top = max(0, min(st.top, st.lit, len(shown)-paletteRows), st.lit-paletteRows+1) // A Shortcut, not Submitted: the frame builds again closed. - if f.Element.Shortcut(0, ui.KeyEnter) && len(shown) > 0 { + if pixel.Shortcut(f.Element, 0, ui.KeyEnter) && len(shown) > 0 { run(shown[st.lit]) } ui.Column(c).AlignItems(ui.Stretch).Children(func() { diff --git a/navigation/contextmenu.go b/navigation/contextmenu.go index dfb3504..103d9fe 100644 --- a/navigation/contextmenu.go +++ b/navigation/contextmenu.go @@ -47,7 +47,7 @@ func ContextMenu(c *ui.Context, text string, items []MenuItem, o ContextMenuOpti s.x, s.y, _ = area.PointerPosition() s.m.show(items) } - key, shiftF10 := area.Shortcut(0, ui.KeyContextMenu), area.Shortcut(ui.Shift, ui.KeyF10) + key, shiftF10 := pixel.Shortcut(area, 0, ui.KeyContextMenu), pixel.Shortcut(area, ui.Shift, ui.KeyF10) if (key || shiftF10) && !off { // At the pointer when over the area, else its corner. x, y, over := area.PointerPosition() diff --git a/navigation/documenttabs.go b/navigation/documenttabs.go index 72a1fbf..2046798 100644 --- a/navigation/documenttabs.go +++ b/navigation/documenttabs.go @@ -1,6 +1,7 @@ package navigation import ( + "fmt" "log/slog" "github.com/egoist/mygo/ui" @@ -30,35 +31,48 @@ type DocumentTabsResult struct { // DocumentTabs are closable tabs; × or Delete reports Closed. func DocumentTabs(c *ui.Context, text string, titles []string, selected *int, o DocumentTabsOptions) DocumentTabsResult { k := pixel.K(c) + seen := map[string]bool{} + for _, t := range titles { + if seen[t] { + panic(fmt.Sprintf("navigation: DocumentTabs has %q twice", t)) + } + seen[t] = true + } res := DocumentTabsResult{Closed: -1} - p := ui.TabsBase(c, selected, len(titles)) + // The base turns before it learns it is off. + v := *selected + p := ui.TabsBase(c, &v, len(titles)) p.List.Label(text).Children(func() { for i, t := range titles { - var x *ui.Element - shell, base, st := pixel.Shell(c, func() *ui.Element { - tab := p.Tab(i).Label(t).Disabled(o.Disabled) - // The × is no Tab stop; Delete closes by keyboard. - tab.Children(func() { - x = ui.Box(c).Absolute().Right(k.D(4)).Top(k.D(6)).Size(k.D(7), k.D(7)).Role(ui.RoleButton).Label(k.Msg("close") + " " + t) + // Keyed by title, so focus follows its tab. + ui.Box(c).Key(t).Children(func() { + var x *ui.Element + shell, base, st := pixel.Shell(c, func() *ui.Element { + tab := p.Tab(i).Label(t).Disabled(o.Disabled) + // The × is no Tab stop; Delete closes by keyboard. + tab.Children(func() { + x = ui.Box(c).Absolute().Right(k.D(4)).Top(k.D(6)).Size(k.D(7), k.D(7)).Role(ui.RoleButton).Label(k.Msg("close") + " " + t) + }) + return tab }) - return tab - }) - if x.Clicked() || base.Shortcut(0, ui.KeyDelete) { - res.Closed = i - } - hot := x.Hovered() - shell.Size(k.D(pixel.Measure(t)+24), k.D(pixel.LineHeight+6)).Draw(func(p *ui.Painter, r ui.Rect) { - g := k.Canvas(p, r) - folder(g, t, i == *selected, false, st) - ink := pixel.Faint - if hot { - ink = pixel.Bad + del := pixel.Shortcut(base, 0, ui.KeyDelete) + if x.Clicked() || del { + res.Closed = i } - g.Sprite(g.W-10, 7, cross, 0, ink, 1) + hot := x.Hovered() + shell.Size(k.D(pixel.Measure(t)+24), k.D(pixel.LineHeight+6)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + folder(g, t, i == *selected, false, st) + ink := pixel.Faint + if hot { + ink = pixel.Bad + } + g.Sprite(g.W-10, 7, cross, 0, ink, 1) + }) }) } }) - res.Element, res.Changed = p.List, p.List.Changed() + res.Element, res.Changed = p.List, keep(c, v, selected, o.Disabled) if res.Closed >= 0 { slog.Debug("8bit: tab closed", "label", text, "tab", titles[res.Closed]) } diff --git a/navigation/dropdown.go b/navigation/dropdown.go index 3cad1c8..830f135 100644 --- a/navigation/dropdown.go +++ b/navigation/dropdown.go @@ -24,18 +24,15 @@ func DropdownMenu(c *ui.Context, text string, items []MenuItem, o DropdownMenuOp off := o.Disabled || pixel.Current(c).Inert b := input.Button(c, text+" ▼", input.ButtonOptions{Label: text, Disabled: o.Disabled}) m := ui.Local(b.Element, menuKey{}, func() menu { return menu{lit: -1} }) + // The button keeps the focus and parks it while off. if off { - // Closing on disable must not return focus to it. - if m.open { - c.Root().Focus() - } m.open = false } res := DropdownMenuResult{Element: b.Element, Chosen: -1} switch { case b.Clicked && m.open: m.open = false - case b.Clicked, !m.open && !off && b.Element.Shortcut(0, ui.KeyDown): + case b.Clicked, !m.open && !off && pixel.Shortcut(b.Element, 0, ui.KeyDown): m.show(items) } res.Chosen = m.keys(b.Element.Shortcut, items) diff --git a/navigation/menubar.go b/navigation/menubar.go index 5dbcf69..30747fa 100644 --- a/navigation/menubar.go +++ b/navigation/menubar.go @@ -44,9 +44,8 @@ func MenuBar(c *ui.Context, text string, menus []Menu, o MenuBarOptions) MenuBar b.open = i b.m.show(menus[i].Items) } + // The title keeps the focus and parks it while off. if off && b.open >= 0 { - // Closing on disable must not return focus to it. - c.Root().Focus() b.open = -1 } if b.open >= 0 && !b.m.open { @@ -55,7 +54,7 @@ func MenuBar(c *ui.Context, text string, menus []Menu, o MenuBarOptions) MenuBar n := len(menus) // Left and Right hand an open menu to its neighbor. if b.open >= 0 { - left, right := row.Shortcut(0, ui.KeyLeft), row.Shortcut(0, ui.KeyRight) + left, right := pixel.Shortcut(row, 0, ui.KeyLeft), pixel.Shortcut(row, 0, ui.KeyRight) switch { case left: open((b.open + n - 1) % n) @@ -87,7 +86,7 @@ func MenuBar(c *ui.Context, text string, menus []Menu, o MenuBarOptions) MenuBar case t.Clicked: open(i) anchor = t.Element - case b.open < 0 && !off && t.Element.FocusWithin() && row.Shortcut(0, ui.KeyDown): + case b.open < 0 && !off && t.Element.FocusWithin() && pixel.Shortcut(row, 0, ui.KeyDown): open(i) anchor = t.Element } diff --git a/navigation/navigation_test.go b/navigation/navigation_test.go index b61dd95..c72befa 100644 --- a/navigation/navigation_test.go +++ b/navigation/navigation_test.go @@ -88,14 +88,15 @@ func TestTabsChooseAndShowTheirPanel(t *testing.T) { for _, side := range []bool{false, true} { tab, changes := 0, 0 tt := view(500, 300, newClock(), false, func(c *ui.Context) { - build := navigation.Tabs - if side { - build = navigation.VerticalTabs - } names := []string{"Items", "Magic", "Status"} - if build(c, "Menu", names, &tab, navigation.TabsOptions{}, func() { - pixel.Text(c, "panel "+names[tab], pixel.TextOptions{}) - }).Changed { + panel := func() { pixel.Text(c, "panel "+names[tab], pixel.TextOptions{}) } + changed := false + if side { + changed = navigation.VerticalTabs(c, "Menu", names, &tab, navigation.VerticalTabsOptions{}, panel).Changed + } else { + changed = navigation.Tabs(c, "Menu", names, &tab, navigation.TabsOptions{}, panel).Changed + } + if changed { changes++ } }) diff --git a/navigation/pagination.go b/navigation/pagination.go index 7a6d781..22d6504 100644 --- a/navigation/pagination.go +++ b/navigation/pagination.go @@ -109,13 +109,13 @@ func PageDots(c *ui.Context, text string, page *int, total int, o PageDotsOption {ui.KeyLeft, func() int { return *page - 1 }}, {ui.KeyRight, func() int { return *page + 1 }}, {ui.KeyHome, func() int { return 1 }}, {ui.KeyEnd, func() int { return total }}, } { - if base.Shortcut(0, m.key) { + if pixel.Shortcut(base, 0, m.key) { *page = max(1, min(total, m.to())) } } // Taking Enter and Space keeps keys from clicking. - base.Shortcut(0, ui.KeyEnter) - base.Shortcut(0, ui.KeySpace) + pixel.Shortcut(base, 0, ui.KeyEnter) + pixel.Shortcut(base, 0, ui.KeySpace) if base.Clicked() { x, _, _ := base.PointerPosition() *page = max(1, min(total, int(x/k.Dot())/8+1)) diff --git a/navigation/rail.go b/navigation/rail.go index da06c96..04b83ea 100644 --- a/navigation/rail.go +++ b/navigation/rail.go @@ -26,14 +26,13 @@ type NavRailResult struct { func NavRail(c *ui.Context, text string, items []NavItem, selected *int, o NavRailOptions) NavRailResult { k := pixel.K(c) res := NavRailResult{} + // The bases turn before they learn they are off. + v := *selected res.Element = ui.RadioGroup(c, func() { for i, it := range items { - shell, base, st := pixel.Shell(c, func() *ui.Element { - return ui.RadioBase(c, selected, i).Label(it.Label).Disabled(o.Disabled) + shell, _, st := pixel.Shell(c, func() *ui.Element { + return ui.RadioBase(c, &v, i).Label(it.Label).Disabled(o.Disabled) }) - if base.Changed() { - res.Changed = true - } shell.Size(k.D(22), k.D(22)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) ink := pixel.Ink @@ -56,7 +55,7 @@ func NavRail(c *ui.Context, text string, items []NavItem, selected *int, o NavRa g.Rect(0, 0, g.W, g.H, pixel.Face) g.VLine(g.W-1, 0, g.H, pixel.Edge) }) - if res.Changed { + if res.Changed = keep(c, v, selected, o.Disabled); res.Changed { slog.Debug("8bit: nav rail", "label", text, "selected", *selected) } return res @@ -88,14 +87,13 @@ type BottomNavResult struct { func BottomNav(c *ui.Context, text string, items []NavItem, selected *int, o BottomNavOptions) BottomNavResult { k := pixel.K(c) res := BottomNavResult{} + // The bases turn before they learn they are off. + v := *selected res.Element = ui.RadioGroup(c, func() { for i, it := range items { - shell, base, st := pixel.Shell(c, func() *ui.Element { - return ui.RadioBase(c, selected, i).Label(it.Label).Disabled(o.Disabled) + shell, _, st := pixel.Shell(c, func() *ui.Element { + return ui.RadioBase(c, &v, i).Label(it.Label).Disabled(o.Disabled) }) - if base.Changed() { - res.Changed = true - } w := max(pixel.Measure(it.Label), 12) + 12 shell.Size(k.D(w), k.D(16+pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) @@ -116,7 +114,7 @@ func BottomNav(c *ui.Context, text string, items []NavItem, selected *int, o Bot g.Rect(0, 0, g.W, g.H, pixel.Face) g.HLine(0, 0, g.W, pixel.Edge) }) - if res.Changed { + if res.Changed = keep(c, v, selected, o.Disabled); res.Changed { slog.Debug("8bit: bottom nav", "label", text, "selected", *selected) } return res diff --git a/navigation/sidebar.go b/navigation/sidebar.go index 4d248c8..4280d87 100644 --- a/navigation/sidebar.go +++ b/navigation/sidebar.go @@ -36,7 +36,8 @@ func Sidebar(c *ui.Context, text string, sections []SidebarSection, selected *in k := pixel.K(c) w := pixel.Or("Sidebar Width", o.Width, 140) res := SidebarResult{} - n := 0 + // The bases turn before they learn they are off. + n, v := 0, *selected res.Element = ui.RadioGroup(c, func() { for _, sec := range sections { if sec.Title != "" { @@ -45,19 +46,16 @@ func Sidebar(c *ui.Context, text string, sections []SidebarSection, selected *in for _, it := range sec.Items { i := n n++ - shell, base, st := pixel.Shell(c, func() *ui.Element { - return ui.RadioBase(c, selected, i).Label(it.Label).Disabled(o.Disabled) + shell, _, st := pixel.Shell(c, func() *ui.Element { + return ui.RadioBase(c, &v, i).Label(it.Label).Disabled(o.Disabled) }) - if base.Changed() { - res.Changed = true - } shell.Size(k.D(w), k.D(pixel.LineHeight+2)).Draw(func(p *ui.Painter, r ui.Rect) { entry(k.Canvas(p, r), it, i == *selected, st) }) } } }).Gap(k.D(1)).Label(text) - if res.Changed { + if res.Changed = keep(c, v, selected, o.Disabled); res.Changed { slog.Debug("8bit: sidebar", "label", text, "selected", *selected) } return res diff --git a/navigation/tabs.go b/navigation/tabs.go index 96d005a..6c42c94 100644 --- a/navigation/tabs.go +++ b/navigation/tabs.go @@ -23,9 +23,18 @@ func Tabs(c *ui.Context, text string, labels []string, selected *int, o TabsOpti return tabs(c, text, labels, selected, false, o, panel) } +type VerticalTabsOptions struct { + Disabled bool +} + +type VerticalTabsResult struct { + Element *ui.Element + Changed bool +} + // VerticalTabs are tabs down the panel's left edge. -func VerticalTabs(c *ui.Context, text string, labels []string, selected *int, o TabsOptions, panel func()) TabsResult { - return tabs(c, text, labels, selected, true, o, panel) +func VerticalTabs(c *ui.Context, text string, labels []string, selected *int, o VerticalTabsOptions, panel func()) VerticalTabsResult { + return VerticalTabsResult(tabs(c, text, labels, selected, true, TabsOptions(o), panel)) } // tabs lays tabs beside a panel; side means left. @@ -49,7 +58,9 @@ func tabs(c *ui.Context, text string, labels []string, selected *int, side bool, } res := TabsResult{Element: outer} outer.Children(func() { - p := ui.TabsBase(c, selected, len(labels)) + // The base turns before it learns it is off. + v := *selected + p := ui.TabsBase(c, &v, len(labels)) if side { p.List.Column().Width(k.D(tw)) } @@ -65,7 +76,7 @@ func tabs(c *ui.Context, text string, labels []string, selected *int, side bool, }) } }) - res.Changed = p.List.Changed() + res.Changed = keep(c, v, selected, o.Disabled) at := 0 for i := range *selected { at += size(i) @@ -123,3 +134,12 @@ func folder(g pixel.Canvas, l string, on, side bool, st pixel.State) { } g.Finish(st) } + +// keep takes v as *selected while the control is on. +func keep(c *ui.Context, v int, selected *int, disabled bool) bool { + if v == *selected || disabled || pixel.Current(c).Inert { + return false + } + *selected = v + return true +} diff --git a/navigation/taken_keys_test.go b/navigation/taken_keys_test.go new file mode 100644 index 0000000..887bd54 --- /dev/null +++ b/navigation/taken_keys_test.go @@ -0,0 +1,55 @@ +package navigation_test + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation" +) + +func TestPageDotsKeysDoNotClickWhereThePointerRests(t *testing.T) { + page := 1 + tt := view(400, 200, newClock(), false, func(c *ui.Context) { + navigation.PageDots(c, "Pages", &page, 9, navigation.PageDotsOptions{}) + }) + r := find(t, tt, "Pages") + tt.Move(r.X+r.W-4, r.Y+r.H/2) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyEnter) + if page != 1 { + t.Fatalf("Space and Enter turned to page %d", page) + } +} + +func TestSpaceTakesTheCursorNotThePointer(t *testing.T) { + chosen := -1 + tt := view(400, 300, newClock(), false, func(c *ui.Context) { + if r := navigation.CommandMenu(c, "Battle", []navigation.MenuItem{{Label: "FIGHT"}, {Label: "MAGIC"}, {Label: "RUN"}}, navigation.CommandMenuOptions{}); r.Chosen >= 0 { + chosen = r.Chosen + } + }) + r := find(t, tt, "Battle") + tt.Move(r.X+r.W/2, r.Y+r.H-6) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if chosen != 0 { + t.Fatalf("Space in the menu chose %d", chosen) + } + at, entered := 0, 0 + nodes := []navigation.MapNode{{Name: "Town", X: 20, Y: 20}, {Name: "Cave", X: 120, Y: 20}} + tt = view(600, 400, newClock(), false, func(c *ui.Context) { + if navigation.WorldMap(c, "Map", nodes, [][2]int{{0, 1}}, &at, navigation.WorldMapOptions{}).Entered { + entered++ + } + }) + r = find(t, tt, "Map") + // The pointer rests on the Cave; the hero is home. + tt.Move(r.X+2*120, r.Y+2*20) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if at != 0 || entered != 1 { + t.Fatalf("Space walked to %d, entered %d times", at, entered) + } +} diff --git a/navigation/toc.go b/navigation/toc.go index a6a48cc..08df796 100644 --- a/navigation/toc.go +++ b/navigation/toc.go @@ -38,14 +38,13 @@ func TableOfContents(c *ui.Context, text string, items []TocItem, current *int, k := pixel.K(c) w := pixel.Or("TableOfContents Width", o.Width, 160) res := TableOfContentsResult{} + // The bases turn before they learn they are off. + v := *current res.Element = ui.RadioGroup(c, func() { for i, it := range items { - shell, base, st := pixel.Shell(c, func() *ui.Element { - return ui.RadioBase(c, current, i).Label(it.Title).Disabled(o.Disabled) + shell, _, st := pixel.Shell(c, func() *ui.Element { + return ui.RadioBase(c, &v, i).Label(it.Title).Disabled(o.Disabled) }) - if base.Changed() { - res.Changed = true - } shell.Size(k.D(w), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) x, ink := 8+it.Level*10, pixel.Faint @@ -60,7 +59,7 @@ func TableOfContents(c *ui.Context, text string, items []TocItem, current *int, }) } }).Gap(0).Label(text) - if res.Changed { + if res.Changed = keep(c, v, current, o.Disabled); res.Changed { slog.Debug("8bit: toc", "label", text, "section", items[*current].Title) } return res diff --git a/navigation/turns_test.go b/navigation/turns_test.go new file mode 100644 index 0000000..ed0b750 --- /dev/null +++ b/navigation/turns_test.go @@ -0,0 +1,93 @@ +package navigation_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation" +) + +func TestKeysInAVeilsFirstFrameTurnNothing(t *testing.T) { + nav := []navigation.NavItem{{Icon: icon.Home, Label: "Home"}, {Icon: icon.Star, Label: "Stars"}} + cases := map[string]struct { + build func(c *ui.Context, v *int) + key ui.Key + }{ + "NavRail": {func(c *ui.Context, v *int) { navigation.NavRail(c, "Rail", nav, v, navigation.NavRailOptions{}) }, ui.KeyDown}, + "BottomNav": {func(c *ui.Context, v *int) { navigation.BottomNav(c, "Nav", nav, v, navigation.BottomNavOptions{}) }, ui.KeyRight}, + "TableOfContents": {func(c *ui.Context, v *int) { + navigation.TableOfContents(c, "Contents", []navigation.TocItem{{Title: "Intro"}, {Title: "Rules"}}, v, navigation.TableOfContentsOptions{}) + }, ui.KeyDown}, + "Sidebar": {func(c *ui.Context, v *int) { + navigation.Sidebar(c, "Side", []navigation.SidebarSection{{Title: "Game", Items: []navigation.SidebarItem{{Label: "Play"}, {Label: "Load"}}}}, v, navigation.SidebarOptions{}) + }, ui.KeyDown}, + "Tabs": {func(c *ui.Context, v *int) { + navigation.Tabs(c, "Tabs", []string{"Items", "Magic"}, v, navigation.TabsOptions{}, func() {}) + }, ui.KeyRight}, + "DocumentTabs": {func(c *ui.Context, v *int) { + navigation.DocumentTabs(c, "Docs", []string{"a.txt", "b.txt"}, v, navigation.DocumentTabsOptions{}) + }, ui.KeyRight}, + } + for name, tc := range cases { + for _, veil := range []bool{false, true} { + v, inert := 0, false + tt := veiled(&inert, func(c *ui.Context) { tc.build(c, &v) }) + tt.Key(0, ui.KeyTab) + inert = veil + tt.Key(0, tc.key) + if turned := v != 0; turned == veil { + t.Errorf("%s: veiled %v, the key made %s", name, veil, fmt.Sprint(v)) + } + } + } +} + +func TestDeleteInAVeilsFirstFrameClosesNoTab(t *testing.T) { + for _, veil := range []bool{false, true} { + v, closed, inert := 0, -1, false + tt := veiled(&inert, func(c *ui.Context) { + if r := navigation.DocumentTabs(c, "Docs", []string{"a.txt", "b.txt"}, &v, navigation.DocumentTabsOptions{}); r.Closed >= 0 { + closed = r.Closed + } + }) + tt.Key(0, ui.KeyTab) + inert = veil + tt.Key(0, ui.KeyDelete) + if (closed == 0) == veil { + t.Errorf("veiled %v, Delete closed %d", veil, closed) + } + } +} + +func TestDocumentTabFocusFollowsItsTab(t *testing.T) { + titles, sel, closed := []string{"a.txt", "b.txt", "c.txt"}, 1, "" + tt := view(600, 300, newClock(), false, func(c *ui.Context) { + if r := navigation.DocumentTabs(c, "Docs", titles, &sel, navigation.DocumentTabsOptions{}); r.Closed >= 0 { + closed = titles[r.Closed] + } + }) + tt.Key(0, ui.KeyTab) + // a.txt closes elsewhere; b.txt keeps its focus. + titles, sel = titles[1:], 0 + tt.Frame() + tt.Key(0, ui.KeyDelete) + if closed != "b.txt" { + t.Fatalf("Delete after a.txt left closed %q", closed) + } +} + +func TestTwinDocumentTabsPanic(t *testing.T) { + defer func() { + if r := fmt.Sprint(recover()); !strings.HasPrefix(r, "navigation: DocumentTabs") { + t.Fatalf("two a.txt tabs gave %q", r) + } + }() + sel := 0 + view(400, 200, newClock(), false, func(c *ui.Context) { + navigation.DocumentTabs(c, "Docs", []string{"a.txt", "a.txt"}, &sel, navigation.DocumentTabsOptions{}) + }) +} diff --git a/navigation/veil_focus_test.go b/navigation/veil_focus_test.go new file mode 100644 index 0000000..079a4aa --- /dev/null +++ b/navigation/veil_focus_test.go @@ -0,0 +1,46 @@ +package navigation_test + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// veiled builds under a veil the test raises and lifts. +func veiled(inert *bool, build func(c *ui.Context)) *ui.Tester { + return view(600, 400, newClock(), false, func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = *inert + pixel.Scope(c, s, func() { build(c) }) + }) +} + +func TestOpenMenusGiveFocusBackAfterAVeil(t *testing.T) { + items := []navigation.MenuItem{{Label: "One"}, {Label: "Two"}} + for name, build := range map[string]func(c *ui.Context){ + "dropdown": func(c *ui.Context) { navigation.DropdownMenu(c, "File", items, navigation.DropdownMenuOptions{}) }, + "menubar": func(c *ui.Context) { + navigation.MenuBar(c, "Bar", []navigation.Menu{{Title: "Edit", Items: items}}, navigation.MenuBarOptions{}) + }, + } { + inert := false + tt := veiled(&inert, build) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if !tt.HasText("Two") { + t.Fatalf("%s did not open: %q", name, tt.Texts()) + } + inert = true + tt.Frame() + inert = false + tt.Frame() + // Only focus on the opener lets Down open it again. + tt.Key(0, ui.KeyDown) + if !tt.HasText("Two") { + t.Errorf("%s did not get its focus back after the veil", name) + } + } +} diff --git a/navigation/worldmap.go b/navigation/worldmap.go index 1083aeb..c133029 100644 --- a/navigation/worldmap.go +++ b/navigation/worldmap.go @@ -80,13 +80,13 @@ func WorldMap(c *ui.Context, text string, nodes []MapNode, paths [][2]int, at *i key ui.Key dx, dy int }{{ui.KeyLeft, -1, 0}, {ui.KeyRight, 1, 0}, {ui.KeyUp, 0, -1}, {ui.KeyDown, 0, 1}} { - if base.Shortcut(0, m.key) { + if pixel.Shortcut(base, 0, m.key) { if j := toward(nodes, paths, *at, m.dx, m.dy); j >= 0 { walkTo(j) } } } - enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace) + enter, space := pixel.Shortcut(base, 0, ui.KeyEnter), pixel.Shortcut(base, 0, ui.KeySpace) res.Entered = enter || space // Enter and Space are taken, so clicks come from pointers. if base.Clicked() { diff --git a/overlay/alertdialog.go b/overlay/alertdialog.go index c394ed2..4a9acfb 100644 --- a/overlay/alertdialog.go +++ b/overlay/alertdialog.go @@ -49,7 +49,7 @@ func AlertDialog(c *ui.Context, title, message string, open *bool, o AlertDialog res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { dim(k, back, pixel.Back, 8) panel.Width(k.D(220)).Role(ui.RoleAlertDialog).Label(title).Description(message).Focusable().FocusRing(false) - if panel.Shortcut(0, ui.KeyEnter) && !o.Disabled && !pixel.Current(c).Inert { + if pixel.Shortcut(panel, 0, ui.KeyEnter) && !o.Disabled && !pixel.Current(c).Inert { press(o.Default) } panel.Children(func() { diff --git a/overlay/dialoguebox.go b/overlay/dialoguebox.go index 8e76a45..9e0e42d 100644 --- a/overlay/dialoguebox.go +++ b/overlay/dialoguebox.go @@ -50,6 +50,11 @@ func DialogueBox(c *ui.Context, pages []string, page *int, o DialogueBoxOptions) panic(fmt.Sprintf("overlay: DialogueBox page %d of %d", *page, len(pages))) } cps := pixel.Or("DialogueBox CPS", o.CPS, 30) + if cps > 1000 { + panic(fmt.Sprintf("overlay: DialogueBox CPS %d over 1000", cps)) + } + // S5 steps at 4–15 fps; fast typing shows more. + fps := max(4, min(cps, 15)) off := o.Disabled || pixel.Current(c).Inert box := ui.Box(c).Role(ui.RoleText).Label(strings.TrimSpace(o.Name + " " + pages[*page])).FocusRing(false) if !off { @@ -98,7 +103,7 @@ func DialogueBox(c *ui.Context, pages []string, page *int, o DialogueBoxOptions) } shown := total if !st.out && !k.Reduce() { - shown = k.Since(c, st.since, cps, total) + shown = min(total, k.Since(c, st.since, fps, (total*fps+cps-1)/cps)*cps/fps) } st.out = shown == total words.Height(k.D(max(2, len(lines)) * pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { @@ -112,7 +117,7 @@ func DialogueBox(c *ui.Context, pages []string, page *int, o DialogueBoxOptions) left -= len(lr) end = pixel.Measure(string(lr[:n])) } - if shown == total && g.Step(2)%2 == 0 { + if shown == total && g.Step(4)%4 < 2 { g.Sprite(end+3, len(lines)*pixel.LineHeight-6, more, 0, pixel.Accent, 1) } }) diff --git a/overlay/lightbox.go b/overlay/lightbox.go index bdb8233..b64845e 100644 --- a/overlay/lightbox.go +++ b/overlay/lightbox.go @@ -34,7 +34,7 @@ func Lightbox(c *ui.Context, text string, n int, at *int, open *bool, o Lightbox res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { deep, light := dark(k) dim(k, back, deep, 13) - left, right := panel.Shortcut(0, ui.KeyLeft), panel.Shortcut(0, ui.KeyRight) + left, right := pixel.Shortcut(panel, 0, ui.KeyLeft), pixel.Shortcut(panel, 0, ui.KeyRight) switch { case off: case left: diff --git a/overlay/notification.go b/overlay/notification.go index 51cf301..5ec0aa9 100644 --- a/overlay/notification.go +++ b/overlay/notification.go @@ -52,7 +52,7 @@ func Notifications(c *ui.Context, queue *[]Notification, o NotificationsOptions) if life == 0 { life = 5 * time.Second } - if n.shown.IsZero() || now.Sub(n.shown) < life { + if n.shown.IsZero() || k.Elapsed(&n.shown) < life { kept = append(kept, n) } } diff --git a/overlay/overlay.go b/overlay/overlay.go index 78b39ba..6c20ef7 100644 --- a/overlay/overlay.go +++ b/overlay/overlay.go @@ -40,18 +40,19 @@ func choose(c *ui.Context, text string, items []string, cur *int, digits, off bo if off { box.Description(k.Msg("disabled")) } - if box.Shortcut(0, ui.KeyDown) { + // The list stays enabled, so off is checked here. + if !off && pixel.Shortcut(box, 0, ui.KeyDown) { *cur = (*cur + 1) % n } - if box.Shortcut(0, ui.KeyUp) { + if !off && pixel.Shortcut(box, 0, ui.KeyUp) { *cur = (*cur + n - 1) % n } - enter, space := box.Shortcut(0, ui.KeyEnter), box.Shortcut(0, ui.KeySpace) - if enter || space { + enter, space := pixel.Shortcut(box, 0, ui.KeyEnter), pixel.Shortcut(box, 0, ui.KeySpace) + if (enter || space) && !off { picked = *cur } for i := range min(9, n) { - if digits && box.Shortcut(0, ui.Key1+ui.Key(i)) { + if digits && !off && pixel.Shortcut(box, 0, ui.Key1+ui.Key(i)) { *cur, picked = i, i } } diff --git a/overlay/panels_test.go b/overlay/panels_test.go index 59c78af..23da888 100644 --- a/overlay/panels_test.go +++ b/overlay/panels_test.go @@ -1,6 +1,8 @@ package overlay_test import ( + "fmt" + "strings" "testing" "time" @@ -348,3 +350,48 @@ func TestDisabledPanelsTakeNoClicks(t *testing.T) { t.Fatalf("disabled: at %d open %v saving %v next %d queue %d", at, open, saving, next, len(queue)) } } + +func TestDialogueBoxKeepsItsSpeedOnS5Steps(t *testing.T) { + for _, wait := range []time.Duration{900 * time.Millisecond, 1050 * time.Millisecond} { + clk, page := newClock(), 0 + // Thirty letters at the default thirty a second. + pages := []string{"ABCDEFGHIJKLMNOPQRSTUVWXYZ1234", "NEXT"} + tt := view(640, 400, clk, false, func(c *ui.Context) { + overlay.DialogueBox(c, pages, &page, overlay.DialogueBoxOptions{}) + }) + clk.t = clk.t.Add(wait) + tt.Frame() + r := find(t, tt, pages[0]) + tt.ClickAt(r.X+4, r.Y+4) + // A press on typed-out text turns the page. + if turned := page == 1; turned != (wait > time.Second) { + t.Errorf("after %v a press left page %d", wait, page) + } + } +} + +func TestPausedBlinksOnceASecond(t *testing.T) { + clk, paused := newClock(), true + tt := view(640, 400, clk, false, func(c *ui.Context) { overlay.PauseMenu(c, &paused, overlay.PauseMenuOptions{}) }) + var ink []int + for _, at := range []time.Duration{100, 350, 600} { + clk.t = time.Unix(3_000_000, 0).Add(at * time.Millisecond) + tt.Frame() + ink = append(ink, count(t, tt.Image(), find(t, tt, "PAUSED"), pixel.Back)) + } + if ink[0] != ink[1] || ink[1] == ink[2] { + t.Fatalf("PAUSED ink at .1, .35 and .6 s: %v", ink) + } +} + +func TestDialogueBoxSpeedHasACeiling(t *testing.T) { + defer func() { + if r := fmt.Sprint(recover()); !strings.HasPrefix(r, "overlay: DialogueBox CPS") { + t.Fatalf("CPS 1001 gave %q", r) + } + }() + page := 0 + view(640, 400, newClock(), false, func(c *ui.Context) { + overlay.DialogueBox(c, []string{"HI"}, &page, overlay.DialogueBoxOptions{CPS: 1001}) + }) +} diff --git a/overlay/pausemenu.go b/overlay/pausemenu.go index ad6c24f..275f50f 100644 --- a/overlay/pausemenu.go +++ b/overlay/pausemenu.go @@ -36,7 +36,7 @@ func PauseMenu(c *ui.Context, paused *bool, o PauseMenuOptions) PauseMenuResult pixel.Harbor(c, panel, func() { ui.Box(c).Size(k.D(pixel.Measure(title)*3), k.D(pixel.LineHeight*3)).Role(ui.RoleText).Label(title).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) - if g.Step(2)%2 == 0 || k.Reduce() { + if g.Step(4)%4 < 2 || k.Reduce() { g.Text(0, 0, title, light, 3) } }) diff --git a/overlay/popconfirm.go b/overlay/popconfirm.go index 044b388..f004ade 100644 --- a/overlay/popconfirm.go +++ b/overlay/popconfirm.go @@ -21,35 +21,45 @@ type PopconfirmResult struct { type popKey struct{} +// popping is open, and held the focus last frame. +type popping struct{ open, held bool } + // Popconfirm asks question in a bubble before its button acts. func Popconfirm(c *ui.Context, text, question string, o PopconfirmOptions) PopconfirmResult { k := pixel.K(c) wrap := ui.Box(c) - open := ui.Local(wrap, popKey{}, func() bool { return false }) + pop := ui.Local(wrap, popKey{}, func() popping { return popping{} }) + open := &pop.open var b input.ButtonResult wrap.Children(func() { b = input.Button(c, text, input.ButtonOptions{Disabled: o.Disabled}) }) if b.Clicked { *open = true } - // A popup above a veil closes with it, focus parked. if o.Disabled || pixel.Current(c).Inert { - if *open { - c.Root().Focus() + // A focus in the closing popup parks on the button. + if *open && pop.held { + b.Base.Focus() + pixel.Park(c, b.Base, true) } *open = false } was := *open res := PopconfirmResult{Element: wrap} + pop.held = false Popover(c, question, b.Element, open, PopoverOptions{}, func() { ui.Column(c).Gap(k.D(4)).Children(func() { pixel.Text(c, question, pixel.TextOptions{}) ui.Row(c).Gap(k.D(4)).Justify(ui.End).Children(func() { - if input.Button(c, k.Msg("no"), input.ButtonOptions{Size: input.Small}).Clicked { + no := input.Button(c, k.Msg("no"), input.ButtonOptions{Size: input.Small}) + yes := input.Button(c, k.Msg("yes"), input.ButtonOptions{Size: input.Small, Variant: input.Primary}) + if no.Clicked { *open = false } - if input.Button(c, k.Msg("yes"), input.ButtonOptions{Size: input.Small, Variant: input.Primary}).Clicked { + if yes.Clicked { res.Confirmed, *open = true, false } + // Focused, not FocusWithin: MyGo links parents after frames. + pop.held = no.Base.Focused() || yes.Base.Focused() }) }) }) diff --git a/overlay/promptdialog.go b/overlay/promptdialog.go index 52dbed0..d067b71 100644 --- a/overlay/promptdialog.go +++ b/overlay/promptdialog.go @@ -36,7 +36,7 @@ func PromptDialog(c *ui.Context, title string, value *string, open *bool, o Prom f := input.TextInput(c, title, value, input.TextInputOptions{Placeholder: o.Placeholder, MaxLength: o.MaxLength, Width: 196, Disabled: o.Disabled}) f.Input.AutoFocus() // A Shortcut, not Submitted: the frame builds again closed. - if f.Element.Shortcut(0, ui.KeyEnter) && !o.Disabled { + if pixel.Shortcut(f.Element, 0, ui.KeyEnter) && !o.Disabled { res.Submitted, *open = true, false } actions(c, []string{k.Msg("cancel"), k.Msg("ok")}, o.Disabled, func(i int) { diff --git a/overlay/veil_focus_test.go b/overlay/veil_focus_test.go new file mode 100644 index 0000000..0e00d37 --- /dev/null +++ b/overlay/veil_focus_test.go @@ -0,0 +1,73 @@ +package overlay_test + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/overlay" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestPopconfirmGivesPopupFocusBackAfterAVeil(t *testing.T) { + inert := false + tt := view(400, 300, newClock(), false, func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = inert + pixel.Scope(c, s, func() { overlay.Popconfirm(c, "DELETE", "Sure?", overlay.PopconfirmOptions{}) }) + pixel.Text(c, "Elsewhere", pixel.TextOptions{}) + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyTab) + // Focus in the popup, not the root that holds everything. + if !tt.Focused("NO") || tt.Focused("Elsewhere") { + t.Fatalf("focus is not in the popup: %q", tt.Texts()) + } + inert = true + tt.Frame() + inert = false + tt.Frame() + tt.Key(0, ui.KeyEnter) + if !tt.HasText("Sure?") { + t.Fatal("the button did not get its focus back after the veil") + } +} + +func TestSheetPullStopsUnderAVeil(t *testing.T) { + open, inert := true, false + tt := view(400, 400, newClock(), false, func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = inert + pixel.Scope(c, s, func() { + overlay.Sheet(c, "Bag", &open, overlay.SheetOptions{}, func() { pixel.Text(c, "Potion", pixel.TextOptions{}) }) + }) + }) + r := find(t, tt, "Bag") + tt.Press(r.X+r.W/2, r.Y+10) + tt.Move(r.X+r.W/2, r.Y+60) + inert = true + tt.Move(r.X+r.W/2, r.Y+200) + tt.Release(r.X+r.W/2, r.Y+200) + if !open { + t.Fatal("a pull under the veil closed the sheet") + } +} + +func TestPauseMenuPicksNothingAsAVeilFalls(t *testing.T) { + paused, inert, chose := true, false, -1 + tt := view(640, 400, newClock(), false, func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = inert + pixel.Scope(c, s, func() { + if r := overlay.PauseMenu(c, &paused, overlay.PauseMenuOptions{}); r.Chosen >= 0 { + chose = r.Chosen + } + }) + }) + inert = true + tt.Key(0, ui.KeyEnter) + if chose >= 0 { + t.Fatalf("Enter as the veil fell chose %d", chose) + } +} diff --git a/pixel/clock.go b/pixel/clock.go index ee05025..7845a18 100644 --- a/pixel/clock.go +++ b/pixel/clock.go @@ -62,12 +62,25 @@ func (k Kit) At(c *ui.Context, t time.Time) { ask(c.Now().Add(d).UnixNano()) } +// Elapsed: time since *t; clock steps back, *t becomes now. +func (k Kit) Elapsed(t *time.Time) time.Duration { + if k.now.Before(*t) { + *t = k.now + } + return k.now.Sub(*t) +} + // Since counts steps from t at fps, up to done. func (k Kit) Since(c *ui.Context, t time.Time, fps, done int) int { + checkFPS(fps) if t.IsZero() { return done } - n := int(k.now.Sub(t) * time.Duration(fps) / time.Second) + // A clock stepping back holds at zero; division cannot overflow. + n := 0 + if d := k.now.Sub(t); d > 0 { + n = int(min(d/(time.Second/time.Duration(fps)), time.Duration(done))) + } if n < done { k.Tick(c, fps) } @@ -125,6 +138,7 @@ type shot struct { // Shot steps a cued one-shot; Reduce Motion spends it whole. func (k Kit) Shot(c *ui.Context, e *ui.Element, cue, fps, done int) int { + checkFPS(fps) // Before any change the zero start reads as done. s := ui.Local(e, shotKey{}, func() shot { return shot{cue: cue} }) if cue != s.cue { @@ -165,8 +179,9 @@ func frameAt(t time.Time, fps int) (int, time.Duration) { return int(n), time.Duration((n+1)*period - ns) } +// checkFPS holds every clock to S5's 4–15 fps. func checkFPS(fps int) { - if fps < 1 || fps > 60 { - panic(fmt.Sprintf("pixel: fps %d outside 1–60", fps)) + if fps < 4 || fps > 15 { + panic(fmt.Sprintf("pixel: fps %d outside 4–15", fps)) } } diff --git a/pixel/edit.go b/pixel/edit.go index 3645782..a6fff35 100644 --- a/pixel/edit.go +++ b/pixel/edit.go @@ -14,13 +14,18 @@ type snapshot struct { // editState is one Editor's text, caret and history. type editState struct { - text []rune - caret, anchor int - compose []rune - composeCaret int - undo, redo []snapshot - changed bool - submitted bool + text []rune + caret, anchor int + compose []rune + composeCaret int + undo, redo []snapshot + changed bool + submitted bool + // kept is the value the last frame ended with. + kept string + // clip is a copy waiting for an enabled build. + clip string + clipped bool scroll int dragging bool pressAt time.Time @@ -28,8 +33,9 @@ type editState struct { clicks int } +// reset takes v as the text and the value kept. func (s *editState) reset(v string) { - *s = editState{text: []rune(v)} + *s = editState{text: []rune(v), kept: v} s.caret = len(s.text) s.anchor = s.caret } diff --git a/pixel/editor.go b/pixel/editor.go index c83a920..72008cc 100644 --- a/pixel/editor.go +++ b/pixel/editor.go @@ -57,6 +57,15 @@ func Editor(c *ui.Context, value *string, o EditorOptions) Edit { return ui.Box(c).Focusable().Role(ui.RoleTextField).Label(o.Label).Disabled(o.Disabled) }) s := ui.Local(base, editKey{}, func() editState { return editState{} }) + // Copies wait here, so a veil's first frame copies nothing. + if s.clipped && !base.IsDisabled() { + c.WriteClipboard(s.clip) + } + s.clip, s.clipped = "", false + // A veil's first frame types nothing; callers' writes win. + if s.changed && base.IsDisabled() && *value == string(s.text) { + *value = s.kept + } if string(s.text) != *value { s.reset(*value) } @@ -64,7 +73,7 @@ func Editor(c *ui.Context, value *string, o EditorOptions) Edit { base.Description(*value) } width, check := WrapWidth(c, shell) - env := editEnv{multi: o.Multiline, sends: o.EnterSends, maxLen: o.MaxLength, mask: o.Mask, width: width, dot: k.dot, read: c.ReadClipboard, write: c.WriteClipboard} + env := editEnv{multi: o.Multiline, sends: o.EnterSends, maxLen: o.MaxLength, mask: o.Mask, width: width, dot: k.dot, read: c.ReadClipboard, write: func(v string) { s.clip, s.clipped = v, true }} shown := s.view(o.Mask, true) caret := s.caret + s.composeCaret lines := editLayout(shown, width, o.Multiline) @@ -91,14 +100,15 @@ func Editor(c *ui.Context, value *string, o EditorOptions) Edit { } return took }) - res := Edit{Element: shell, Input: base, Changed: s.changed, Submitted: s.submitted, Focused: base.Focused(), Line: cl} + // A send from the frame a veil falls is dropped. + res := Edit{Element: shell, Input: base, Changed: s.changed, Submitted: s.submitted && !base.IsDisabled(), Focused: base.Focused(), Line: cl} if res.Focused { frameOf(c).typing = true } - if s.submitted { + if res.Submitted { slog.Debug("8bit: editor submit", "label", o.Label) } - s.changed, s.submitted = false, false + s.changed, s.submitted, s.kept = false, false, *value shell.Draw(func(p *ui.Painter, r ui.Rect) { check(r) g := k.Canvas(p, r) @@ -127,7 +137,7 @@ func Editor(c *ui.Context, value *string, o EditorOptions) Edit { } } } - if res.Focused && !o.Disabled && g.Step(2)%2 == 0 { + if res.Focused && !o.Disabled && g.Step(4)%4 < 2 { g.VLine(cx-scroll, cl*LineHeight+1, LineHeight-2, Ink) } }) @@ -270,7 +280,7 @@ func (s *editState) handle(ev ui.InputEvent, e editEnv) bool { func (s *editState) press(ev ui.InputEvent, e editEnv) { now := time.Now() - if now.Sub(s.pressAt) < 500*time.Millisecond && abs32(ev.X-s.pressX) < 5 && abs32(ev.Y-s.pressY) < 5 { + if d := now.Sub(s.pressAt); d >= 0 && d < 500*time.Millisecond && abs32(ev.X-s.pressX) < 5 && abs32(ev.Y-s.pressY) < 5 { s.clicks++ } else { s.clicks = 1 diff --git a/pixel/editor_test.go b/pixel/editor_test.go index 2d3db66..00c0e64 100644 --- a/pixel/editor_test.go +++ b/pixel/editor_test.go @@ -345,3 +345,36 @@ func TestEditorTakesAValueChangedBetweenFrames(t *testing.T) { h.tt.Type("x") h.want("x") } + +func TestEditorVeilKeepsCallersAndClipboard(t *testing.T) { + // The caller writes during the build the veil falls in. + v, inert, update := "old", false, false + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Inert: inert}) + if update { + v, update = "server", false + } + pixel.Editor(c, &v, pixel.EditorOptions{Label: "Edit"}).Element.Width(200) + }, 400, 100) + tt.Key(0, ui.KeyTab) + inert, update = true, true + tt.Type("x") + if v != "server" { + t.Fatalf("the caller's write became %q", v) + } + for _, command := range []string{"copy", "cut"} { + v, inert := "SecretValue", false + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Inert: inert}) + pixel.Editor(c, &v, pixel.EditorOptions{Label: "Edit"}).Element.Width(200) + }, 400, 100) + tt.Key(0, ui.KeyTab) + tt.Key(ui.Cmd, ui.KeyA) + tt.SetClipboard("original") + inert = true + tt.Command(command) + if got := tt.Clipboard(); got != "original" || v != "SecretValue" { + t.Errorf("%s as the veil fell: clipboard %q, value %q", command, got, v) + } + } +} diff --git a/pixel/element.go b/pixel/element.go index 1f9cd7c..b969301 100644 --- a/pixel/element.go +++ b/pixel/element.go @@ -102,6 +102,12 @@ func Shell(c *ui.Context, base func() *ui.Element) (shell, b *ui.Element, st Sta return shell, b, st } +// Shortcut is e's key this frame, while e is enabled. +func Shortcut(e *ui.Element, mods ui.Modifiers, key ui.Key) bool { + // MyGo still hands keys to a base just turned off. + return e.Shortcut(mods, key) && !e.IsDisabled() +} + type parkKey struct{} // Park moves focus off e while off, back once on. diff --git a/pixel/motion_test.go b/pixel/motion_test.go index 3bfe1f7..1ee5b5a 100644 --- a/pixel/motion_test.go +++ b/pixel/motion_test.go @@ -1,6 +1,7 @@ package pixel_test import ( + "fmt" "testing" "time" @@ -153,3 +154,79 @@ func TestTowardStepsAndTurnsWhereItIs(t *testing.T) { }, 10, 10) }) } + +func TestMotionStepsAtFourToFifteen(t *testing.T) { + at := time.Unix(1_700_000_000, 0) + run := func(fn func(c *ui.Context, k pixel.Kit)) { + ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }}) + fn(c, pixel.K(c)) + }, 10, 10) + } + // S5: whole frames at 4–15 fps, for every clock. + for _, fps := range []int{1, 2, 3, 16, 30} { + mustPanic(t, fmt.Sprint("Tick at ", fps), func() { run(func(c *ui.Context, k pixel.Kit) { k.Tick(c, fps) }) }) + mustPanic(t, fmt.Sprint("Since at ", fps), func() { run(func(c *ui.Context, k pixel.Kit) { k.Since(c, at, fps, 9) }) }) + mustPanic(t, fmt.Sprint("Toward at ", fps), func() { run(func(c *ui.Context, k pixel.Kit) { k.Toward(c, ui.Box(c), true, fps, 2) }) }) + mustPanic(t, fmt.Sprint("Since from zero at ", fps), func() { run(func(c *ui.Context, k pixel.Kit) { k.Since(c, time.Time{}, fps, 9) }) }) + mustPanic(t, fmt.Sprint("Shot at ", fps), func() { run(func(c *ui.Context, k pixel.Kit) { k.Shot(c, ui.Box(c), 0, fps, 9) }) }) + // Reduce Motion spends a shot without counting. + mustPanic(t, fmt.Sprint("a reduced Shot at ", fps), func() { + ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }, Reduce: true}) + pixel.K(c).Shot(c, ui.Box(c), 0, fps, 9) + }, 10, 10) + }) + mustPanic(t, fmt.Sprint("Step at ", fps), func() { + run(func(c *ui.Context, k pixel.Kit) { + ui.Box(c).Size(4, 4).Draw(func(p *ui.Painter, r ui.Rect) { k.Canvas(p, r).Step(fps) }) + }) + }) + } + for _, fps := range []int{4, 15} { + run(func(c *ui.Context, k pixel.Kit) { + k.Tick(c, fps) + k.Since(c, at, fps, 9) + k.Toward(c, ui.Box(c), true, fps, 2) + }) + } +} + +func TestSinceHoldsAtZeroWhenTheClockStepsBack(t *testing.T) { + at := time.Unix(1_700_000_000, 0) + got := -1 + ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }}) + got = pixel.K(c).Since(c, at.Add(time.Hour), 8, 5) + }, 10, 10) + if got != 0 { + t.Fatalf("a start an hour ahead counted %d steps", got) + } + // Thirty years ahead must not overflow into a full count. + ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }}) + got = pixel.K(c).Since(c, at.AddDate(30, 0, 0), 15, 5) + }, 10, 10) + if got != 0 { + t.Fatalf("a start thirty years ahead counted %d steps", got) + } + ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }}) + got = pixel.K(c).Since(c, at.AddDate(-30, 0, 0), 15, 5) + }, 10, 10) + if got != 5 { + t.Fatalf("a start thirty years back counted %d of 5 steps", got) + } +} + +func TestElapsedRebasesWhenTheClockStepsBack(t *testing.T) { + at := time.Unix(1_700_000_000, 0) + start, got := at.Add(time.Hour), time.Duration(-1) + ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }}) + got = pixel.K(c).Elapsed(&start) + }, 10, 10) + if got != 0 || !start.Equal(at) { + t.Fatalf("elapsed %v, start moved to %v", got, start) + } +} diff --git a/pixel/pad.go b/pixel/pad.go index 135d130..328e84a 100644 --- a/pixel/pad.go +++ b/pixel/pad.go @@ -22,11 +22,11 @@ func Pad(c *ui.Context, text string, cols, n, cw, ch int, cur *int, disabled boo {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 { + if to := m.to(); Shortcut(base, 0, m.key) && to >= 0 && to < n { *cur = to } } - enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace) + enter, space := Shortcut(base, 0, ui.KeyEnter), Shortcut(base, 0, ui.KeySpace) if enter || space { picked = *cur } diff --git a/project/pomodoro.go b/project/pomodoro.go index ff755e4..512395c 100644 --- a/project/pomodoro.go +++ b/project/pomodoro.go @@ -86,7 +86,7 @@ func Pomodoro(c *ui.Context, text string, o PomodoroOptions) PomodoroResult { now := k.Now() left, running := p.left, !p.since.IsZero() if running { - left -= now.Sub(p.since) + left -= k.Elapsed(&p.since) } var res PomodoroResult if running && left <= 0 { diff --git a/project/questboard.go b/project/questboard.go index 20108fc..ccc2a0b 100644 --- a/project/questboard.go +++ b/project/questboard.go @@ -80,11 +80,11 @@ func QuestBoard(c *ui.Context, text string, cols *[3][]string, o QuestBoardOptio key ui.Key dc, dr int }{{ui.KeyUp, 0, -1}, {ui.KeyDown, 0, 1}, {ui.KeyLeft, -1, 0}, {ui.KeyRight, 1, 0}} { - if base.Shortcut(0, m.key) { + if pixel.Shortcut(base, 0, m.key) { b.col, b.row = b.col+m.dc, b.row+m.dr fit() } - if base.Shortcut(ui.Alt, m.key) { + if pixel.Shortcut(base, ui.Alt, m.key) { move(b.col+m.dc, b.row+m.dr) } } diff --git a/shop/coupon.go b/shop/coupon.go index 84459c9..9907fd1 100644 --- a/shop/coupon.go +++ b/shop/coupon.go @@ -71,7 +71,7 @@ func Coupon(c *ui.Context, title, code string, o CouponOptions) CouponResult { at := ui.Local(e, couponKey{}, func() time.Time { return time.Time{} }) // The button says COPIED! for two seconds. label := k.Msg("copy") - if k.Since(c, *at, 1, 2) < 2 { + if k.Since(c, *at, 4, 8) < 8 { label = k.Msg("copied") } e.Children(func() { diff --git a/shop/money_test.go b/shop/money_test.go index 3a92fb5..92a42c4 100644 --- a/shop/money_test.go +++ b/shop/money_test.go @@ -3,6 +3,7 @@ package shop_test import ( "strings" "testing" + "time" "github.com/egoist/mygo/ui" @@ -165,3 +166,19 @@ func TestCouponCopiesQuietly(t *testing.T) { t.Fatalf("clipboard %q; log:\n%s", tt.Clipboard(), log) } } + +func TestCouponSaysCopiedForTwoSeconds(t *testing.T) { + wd := newWorld() + tt := view(400, 200, wd, func(c *ui.Context) { shop.Coupon(c, "10% OFF", "UPUP", shop.CouponOptions{}) }) + click(t, tt, "COPY") + for _, tc := range []struct { + at time.Duration + says string + }{{1500 * time.Millisecond, "COPIED!"}, {2100 * time.Millisecond, "COPY"}} { + wd.clk.t = wd.clk.t.Add(tc.at - map[string]time.Duration{"COPIED!": 0, "COPY": 1500 * time.Millisecond}[tc.says]) + tt.Frame() + if !tt.HasText(tc.says) { + t.Errorf("after %v the button does not say %s", tc.at, tc.says) + } + } +} diff --git a/shop/quantity.go b/shop/quantity.go index 0e856dd..30e333e 100644 --- a/shop/quantity.go +++ b/shop/quantity.go @@ -36,12 +36,12 @@ func Quantity(c *ui.Context, text string, value *int, o QuantityOptions) Quantit if !base.IsDisabled() { step := 0 // Enter and Space are taken, so clicks come from pointers. - base.Shortcut(0, ui.KeyEnter) - base.Shortcut(0, ui.KeySpace) - if base.Shortcut(0, ui.KeyLeft) || base.Shortcut(0, ui.KeyDown) { + pixel.Shortcut(base, 0, ui.KeyEnter) + pixel.Shortcut(base, 0, ui.KeySpace) + if pixel.Shortcut(base, 0, ui.KeyLeft) || pixel.Shortcut(base, 0, ui.KeyDown) { step-- } - if base.Shortcut(0, ui.KeyRight) || base.Shortcut(0, ui.KeyUp) { + if pixel.Shortcut(base, 0, ui.KeyRight) || pixel.Shortcut(base, 0, ui.KeyUp) { step++ } if base.Clicked() { @@ -58,10 +58,10 @@ func Quantity(c *ui.Context, text string, value *int, o QuantityOptions) Quantit for ; step < 0 && *value > lo; step++ { *value-- } - if base.Shortcut(0, ui.KeyHome) { + if pixel.Shortcut(base, 0, ui.KeyHome) { *value = lo } - if base.Shortcut(0, ui.KeyEnd) { + if pixel.Shortcut(base, 0, ui.KeyEnd) { *value = hi } } diff --git a/tabletop/cardhand.go b/tabletop/cardhand.go index 2da8896..4476d6f 100644 --- a/tabletop/cardhand.go +++ b/tabletop/cardhand.go @@ -73,12 +73,12 @@ func CardHand(c *ui.Context, text string, cards []Card, o CardHandOptions) CardH key ui.Key to int }{{ui.KeyLeft, *cur - 1}, {ui.KeyRight, *cur + 1}, {ui.KeyHome, 0}, {ui.KeyEnd, n - 1}} { - if base.Shortcut(0, m.key) { + if pixel.Shortcut(base, 0, m.key) { *cur = max(0, min(n-1, m.to)) } } // Enter and Space are taken, so clicks come from pointers. - if base.Shortcut(0, ui.KeyEnter) || base.Shortcut(0, ui.KeySpace) { + if pixel.Shortcut(base, 0, ui.KeyEnter) || pixel.Shortcut(base, 0, ui.KeySpace) { res.Played = *cur } x, y, over := base.PointerPosition() diff --git a/text/blinktext.go b/text/blinktext.go index d4561d4..32909b5 100644 --- a/text/blinktext.go +++ b/text/blinktext.go @@ -9,16 +9,16 @@ import ( type BlinkTextOptions struct { Slot pixel.Slot Scale int - // FPS is blinks per second, times two; zero means 2. + // FPS steps a four-step blink, 4–15; zero means 4. FPS int } // BlinkText blinks; under Reduce Motion it stays shown. func BlinkText(c *ui.Context, s string, o BlinkTextOptions) *ui.Element { k, sc := pixel.K(c), scale(o.Scale) - fps := pixel.Or("BlinkText FPS", o.FPS, 2) + fps := pixel.Or("BlinkText FPS", o.FPS, 4) return block(c, k, s, pixel.Measure(s)*sc, pixel.LineHeight*sc, func(g pixel.Canvas) { - if g.Step(fps)%2 == 0 { + if g.Step(fps)%4 < 2 { g.Text(0, 0, s, o.Slot, sc) } }) diff --git a/text/marquee.go b/text/marquee.go index b06c8e0..fe6957a 100644 --- a/text/marquee.go +++ b/text/marquee.go @@ -25,9 +25,9 @@ func Marquee(c *ui.Context, s string, o MarqueeOptions) *ui.Element { if k.Reduce() || e.Hovered() { ms.last = -1 } else { - step := k.Tick(c, 20) + step := k.Tick(c, 15) if ms.last >= 0 { - ms.offset += step - ms.last + ms.offset += max(0, step-ms.last) } ms.last = step } diff --git a/text/s5_test.go b/text/s5_test.go new file mode 100644 index 0000000..21d58ad --- /dev/null +++ b/text/s5_test.go @@ -0,0 +1,68 @@ +package text_test + +import ( + "bytes" + "fmt" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" +) + +func TestTypewriterKeepsItsSpeedOnS5Steps(t *testing.T) { + clk, done := newClock(), 0 + tt := view(400, 100, clk, func(c *ui.Context) { + // Twenty letters at the default twenty a second. + if text.Typewriter(c, "ABCDEFGHIJKLMNOPQRST", text.TypewriterOptions{}).Done { + done++ + } + }) + clk.add(900 * time.Millisecond) + tt.Frame() + if done != 0 { + t.Fatal("the text was out early") + } + clk.add(150 * time.Millisecond) + tt.Frame() + if done != 1 { + t.Fatal("the text was not out after a second") + } +} + +func TestTextAnimationsSurviveTheClockSteppingBack(t *testing.T) { + clk := newClock() + tt := view(400, 200, clk, func(c *ui.Context) { + text.Typewriter(c, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", text.TypewriterOptions{}) + text.ScrambleText(c, "SCRAMBLED EGGS", text.ScrambleTextOptions{}) + text.Marquee(c, "HELLO WORLD", text.MarqueeOptions{Width: 40}) + }) + for _, d := range []time.Duration{200 * time.Millisecond, -time.Hour, 200 * time.Millisecond} { + clk.add(d) + tt.Frame() + } +} + +func TestMarqueeHoldsWhenTheClockStepsBack(t *testing.T) { + clk := newClock() + tt := view(400, 100, clk, func(c *ui.Context) { text.Marquee(c, "HELLO WORLD", text.MarqueeOptions{Width: 60}) }) + clk.add(time.Second) + tt.Frame() + before := bytes.Clone(tt.Image().Pix) + clk.add(-time.Hour) + tt.Frame() + if !bytes.Equal(before, tt.Image().Pix) { + t.Fatal("the marquee ran backward as the clock stepped back") + } +} + +func TestTypingSpeedsHaveACeiling(t *testing.T) { + defer func() { + if r := fmt.Sprint(recover()); !strings.HasPrefix(r, "text: Typewriter CPS") { + t.Fatalf("CPS 1001 gave %q", r) + } + }() + view(400, 100, newClock(), func(c *ui.Context) { text.Typewriter(c, "HI", text.TypewriterOptions{CPS: 1001}) }) +} diff --git a/text/scrambletext.go b/text/scrambletext.go index 1e251e5..b8584c0 100644 --- a/text/scrambletext.go +++ b/text/scrambletext.go @@ -11,7 +11,7 @@ const scrambleRunes = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&*+?" type ScrambleTextOptions struct { Slot pixel.Slot Scale int - // FPS is zero for 30; two steps settle a character. + // FPS is 4–15, zero for 15; two steps settle one. FPS int } @@ -25,7 +25,7 @@ type scrambleState struct { // ScrambleText settles from random glyphs to s, left to right. func ScrambleText(c *ui.Context, s string, o ScrambleTextOptions) *ui.Element { k, sc := pixel.K(c), scale(o.Scale) - fps := pixel.Or("ScrambleText FPS", o.FPS, 30) + fps := pixel.Or("ScrambleText FPS", o.FPS, 15) rs := []rune(s) e := ui.Box(c).Size(k.D(pixel.Measure(s)*sc), k.D(pixel.LineHeight*sc)).Label(s).Role(ui.RoleText) st := ui.Local(e, scrambleKey{}, func() scrambleState { return scrambleState{start: -1} }) diff --git a/text/text_test.go b/text/text_test.go index b39dd75..ee2fa14 100644 --- a/text/text_test.go +++ b/text/text_test.go @@ -223,17 +223,18 @@ func TestMarqueeScrollsAndPauses(t *testing.T) { } return -1 } + // Steps come at 15 a second, a dot each. r0 := last() - clk.add(500 * time.Millisecond) + clk.add(10 * time.Second / 15) tt.Frame() r1 := last() tt.Move(10, 10) - clk.add(500 * time.Millisecond) + clk.add(10 * time.Second / 15) tt.Frame() r2 := last() tt.Move(300, 90) tt.Frame() - clk.add(250 * time.Millisecond) + clk.add(5 * time.Second / 15) tt.Frame() if r3 := last(); r0 < 0 || r0-r1 != 10 || r2 != r1 || r1-r3 != 5 { t.Fatalf("ends at %d, %d (10 steps), %d (hovered), %d (5 more)", r0, r1, r2, r3) @@ -371,7 +372,7 @@ func TestScrambleSettles(t *testing.T) { if same() { t.Fatal("scramble started settled") } - clk.add(200 * time.Millisecond) + clk.add(400 * time.Millisecond) tt.Frame() if !same() { t.Fatal("after 6 steps the text had not settled") diff --git a/text/typewriter.go b/text/typewriter.go index a4315ba..2e39535 100644 --- a/text/typewriter.go +++ b/text/typewriter.go @@ -1,6 +1,7 @@ package text import ( + "fmt" "log/slog" "github.com/egoist/mygo/ui" @@ -39,6 +40,11 @@ type typeState struct { func Typewriter(c *ui.Context, s string, o TypewriterOptions) TypewriterResult { k := pixel.K(c) cps := pixel.Or("Typewriter CPS", o.CPS, 20) + if cps > 1000 { + panic(fmt.Sprintf("text: Typewriter CPS %d over 1000", cps)) + } + // S5 steps at 4–15 fps; fast typing shows more. + fps := max(4, min(cps, 15)) shell, base, st := pixel.Shell(c, func() *ui.Element { e := ui.Box(c).Role(ui.RoleText).Label(s).Disabled(o.Disabled) if !o.Disabled { @@ -61,11 +67,11 @@ func Typewriter(c *ui.Context, s string, o TypewriterOptions) TypewriterResult { if !ts.done { ts.last = total if !k.Reduce() { - tick := k.Tick(c, cps) + tick := k.Tick(c, fps) if ts.start < 0 { ts.start = tick } - ts.last = min(total, tick-ts.start) + ts.last = min(total, (tick-ts.start)*cps/fps) } if base.Clicked() { ts.last = total @@ -90,7 +96,7 @@ func Typewriter(c *ui.Context, s string, o TypewriterOptions) TypewriterResult { left -= len(lr) end = pixel.Measure(string(lr[:n])) } - if shown == total && g.Step(2)%2 == 0 { + if shown == total && g.Step(4)%4 < 2 { g.Sprite(end+3, len(lines)*pixel.LineHeight-6, more, 0, pixel.Accent, 1) } if st.Focus {