From b4b4d67b9223e06d97913c0b7874e859b16204f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 16:03:04 -0400 Subject: [PATCH] Audit keyboard, focus, veils, motion and clocks across all 500 A cross-cutting pass, each fix with a test that failed first. Veils: MyGo still hands a key to a base that turned off this frame, so every element key now goes through pixel.Shortcut, which an archtest enforces. Input handlers record and the build applies them only while enabled; the editor turns away a veil-frame letter or copy; value bases get a copy and keep only enabled turns. Popups give a held focus back to their opener when the veil lifts. Motion: S5 is enforced at the clock. Every Tick, Since, Step, Toward and Shot runs at 4-15 fps; blinks keep 1 Hz on 4 fps steps, fast animations step at 15, and drawn time is quantized to frames. Clocks stepping back: Since holds at zero without overflow, and k.Elapsed moves a stored start to now, so nothing goes negative, runs backward or indexes out of range. Also: logs count typed text instead of holding it, gallery names use glyphs the font has, removable rows are keyed, components take their own Options (archtest), and sweeps over all 508 demos check veiled keys, clicks, wheels, the clock stepping back and names. --- AGENTS.md | 15 +- account/about.go | 7 +- account/keybindings.go | 10 +- account/optioncycler.go | 4 +- account/passwordgrid.go | 2 +- account/players_test.go | 4 +- account/rowkeys_test.go | 38 ++++ account/sessionlist.go | 11 +- chart/clock_back_test.go | 45 +++++ chart/legend.go | 4 +- chart/spectrumbars.go | 8 +- chart/turns_test.go | 30 +++ chat/callcontrols.go | 12 +- chat/reactions.go | 7 +- chat/turns_test.go | 57 ++++++ code/clock_back_test.go | 23 +++ code/codeblock.go | 2 +- code/glyphmap.go | 4 +- code/spritesheet.go | 2 +- code/terminal.go | 2 +- data/carousel.go | 2 +- data/collapsible.go | 8 +- data/gridview.go | 5 +- data/outline.go | 2 +- data/sortable.go | 8 +- data/tree.go | 19 +- data/turns_test.go | 25 +++ data/veil_input_test.go | 99 +++++++++ datetime/analogclock.go | 4 +- datetime/digitalclock.go | 6 +- datetime/grid.go | 4 +- datetime/seasonwheel.go | 8 +- datetime/stopwatch.go | 2 +- datetime/sundial.go | 4 +- datetime/taken_keys_test.go | 75 +++++++ datetime/timeinput.go | 12 +- datetime/timer.go | 2 +- datetime/weekstrip.go | 8 +- desk/appwindow.go | 2 +- desk/calculator.go | 4 +- desk/desktopicon.go | 2 +- desk/minesweeper.go | 2 +- desk/rundialog.go | 2 +- desk/snake.go | 4 +- desk/startmenu.go | 7 +- desk/veil_focus_test.go | 29 +++ display/display_test.go | 4 +- display/rarityframe.go | 2 +- display/sprite.go | 2 +- display/statusdot.go | 2 +- feedback/clock_back_test.go | 51 +++++ feedback/continueprompt.go | 2 +- feedback/shake.go | 2 +- feedback/states.go | 4 +- feedback/toasts.go | 2 +- files/sharepanel.go | 2 +- fx/palettecycle.go | 2 +- gallery/app.go | 3 +- gallery/chat_log_test.go | 35 ++++ gallery/chrome.go | 2 +- gallery/clock_back_test.go | 84 ++++++++ gallery/demo_slog_test.go | 44 ++++ gallery/demos_chart.go | 4 +- gallery/demos_chat.go | 12 +- gallery/demos_code.go | 2 +- gallery/demos_controls.go | 8 +- gallery/demos_desk.go | 2 +- gallery/demos_display.go | 10 +- gallery/demos_feedback.go | 8 +- gallery/demos_files.go | 2 +- gallery/demos_foundations.go | 4 +- gallery/demos_fx.go | 4 +- gallery/demos_game.go | 2 +- gallery/demos_layout.go | 10 +- gallery/demos_loot.go | 4 +- gallery/demos_market.go | 6 +- gallery/demos_media.go | 5 +- gallery/demos_navigation.go | 6 +- gallery/demos_overlay.go | 2 +- gallery/demos_player.go | 2 +- gallery/demos_popups.go | 4 +- gallery/demos_project.go | 2 +- gallery/demos_records.go | 6 +- gallery/demos_rpg.go | 2 +- gallery/demos_screenfx.go | 4 +- gallery/demos_studio.go | 2 +- gallery/demos_tabletop.go | 4 +- gallery/demos_text.go | 2 +- gallery/glyphs_test.go | 27 +++ gallery/names_test.go | 37 ++++ gallery/pages.go | 4 +- gallery/veiled_keys_test.go | 41 ++++ gallery/veiled_pointer_test.go | 32 +++ game/credits.go | 2 +- game/damagenumbers.go | 8 +- game/equipment.go | 2 +- game/game.go | 3 +- game/healthbar.go | 6 +- game/hotbar.go | 25 ++- game/interactprompt.go | 2 +- game/keypair_test.go | 70 +++++++ game/letterbox.go | 2 +- game/lootpopup.go | 2 +- game/map_test.go | 5 +- game/menus_test.go | 89 ++++++++ game/optionsmenu.go | 12 +- game/pressstart.go | 2 +- game/skilltree.go | 2 +- game/treasurechest.go | 2 +- input/autocomplete.go | 7 +- input/button.go | 4 +- input/cheatcode.go | 4 + input/checkboxgroup.go | 2 +- input/combobox.go | 14 +- input/dpad.go | 8 +- input/editabletext.go | 9 +- input/holdbutton.go | 14 +- input/joystick.go | 8 +- input/knob.go | 2 +- input/multiselect.go | 18 +- input/nameentry.go | 3 +- input/numberinput.go | 4 +- input/pininput.go | 16 +- input/radio.go | 2 +- input/radiogroup.go | 2 +- input/rangeslider.go | 6 +- input/rating.go | 1 + input/searchfield.go | 2 +- input/segmented.go | 7 +- input/slider.go | 2 +- input/slotpicker.go | 15 +- input/stepper.go | 11 +- input/switch.go | 10 +- input/taken_keys_test.go | 27 +++ input/toggle.go | 10 +- input/togglegroup.go | 8 +- input/tokenfield.go | 10 +- input/turns_test.go | 79 ++++++++ input/typed_log_test.go | 77 +++++++ input/veil_focus_test.go | 37 ++++ input/veil_input_test.go | 314 +++++++++++++++++++++++++++++ internal/archtest/archtest_test.go | 62 ++++++ layout/layers.go | 4 +- layout/layout_test.go | 6 +- layout/resizable.go | 2 +- layout/split.go | 4 +- layout/statusbar.go | 4 +- media/clock_back_test.go | 44 ++++ media/media.go | 3 +- media/notes.go | 4 +- media/recordbutton.go | 6 +- media/stepsequencer.go | 8 +- media/tracker.go | 2 +- navigation/commandmenu.go | 8 +- navigation/commandpalette.go | 6 +- navigation/contextmenu.go | 2 +- navigation/documenttabs.go | 56 +++-- navigation/dropdown.go | 7 +- navigation/menubar.go | 7 +- navigation/navigation_test.go | 15 +- navigation/pagination.go | 6 +- navigation/rail.go | 22 +- navigation/sidebar.go | 12 +- navigation/tabs.go | 28 ++- navigation/taken_keys_test.go | 55 +++++ navigation/toc.go | 11 +- navigation/turns_test.go | 93 +++++++++ navigation/veil_focus_test.go | 46 +++++ navigation/worldmap.go | 4 +- overlay/alertdialog.go | 2 +- overlay/dialoguebox.go | 9 +- overlay/lightbox.go | 2 +- overlay/notification.go | 2 +- overlay/overlay.go | 11 +- overlay/panels_test.go | 47 +++++ overlay/pausemenu.go | 2 +- overlay/popconfirm.go | 22 +- overlay/promptdialog.go | 2 +- overlay/veil_focus_test.go | 73 +++++++ pixel/clock.go | 21 +- pixel/edit.go | 22 +- pixel/editor.go | 22 +- pixel/editor_test.go | 33 +++ pixel/element.go | 6 + pixel/motion_test.go | 77 +++++++ pixel/pad.go | 4 +- project/pomodoro.go | 2 +- project/questboard.go | 4 +- shop/coupon.go | 2 +- shop/money_test.go | 17 ++ shop/quantity.go | 12 +- tabletop/cardhand.go | 4 +- text/blinktext.go | 6 +- text/marquee.go | 4 +- text/s5_test.go | 68 +++++++ text/scrambletext.go | 4 +- text/text_test.go | 9 +- text/typewriter.go | 12 +- 198 files changed, 2708 insertions(+), 422 deletions(-) create mode 100644 account/rowkeys_test.go create mode 100644 chart/clock_back_test.go create mode 100644 chart/turns_test.go create mode 100644 chat/turns_test.go create mode 100644 code/clock_back_test.go create mode 100644 data/turns_test.go create mode 100644 data/veil_input_test.go create mode 100644 datetime/taken_keys_test.go create mode 100644 desk/veil_focus_test.go create mode 100644 feedback/clock_back_test.go create mode 100644 gallery/chat_log_test.go create mode 100644 gallery/clock_back_test.go create mode 100644 gallery/demo_slog_test.go create mode 100644 gallery/glyphs_test.go create mode 100644 gallery/names_test.go create mode 100644 gallery/veiled_keys_test.go create mode 100644 gallery/veiled_pointer_test.go create mode 100644 game/keypair_test.go create mode 100644 input/taken_keys_test.go create mode 100644 input/turns_test.go create mode 100644 input/typed_log_test.go create mode 100644 input/veil_focus_test.go create mode 100644 input/veil_input_test.go create mode 100644 media/clock_back_test.go create mode 100644 navigation/taken_keys_test.go create mode 100644 navigation/turns_test.go create mode 100644 navigation/veil_focus_test.go create mode 100644 overlay/veil_focus_test.go create mode 100644 text/s5_test.go 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 {