diff --git a/AGENTS.md b/AGENTS.md index 1454b57..22838fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,8 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en - Signatures and results: PRD §5.1. Zero options give the default look. Sizes in options are dots. - `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`. - Containers take content as a final `func()`; interactive results are `NameResult`. +- A Clicked, Shortcut, Changed or drag during the build makes MyGo build the frame again, so what they change shows at once. State changed any other way after its element was built (`Pressed`, `Edit.Changed`) is read at paint time. +- Never call `Selected`: MyGo paints it in the accent color. Radios are `CheckboxBase` with `RoleRadio`. - Clip only boxes that sit on whole dots; split widths with whole-dot shares, not equal `Grow`. `Grow` starts from zero size, so never use it on a content-sized child. - Row builders that MyGo runs during layout (`List`, `GridView`, `Outline`) go through `pixel.Lazy(c, row)`. - Single-key shortcuts only while `pixel.Typing(c)` is false; MyGo routes shortcuts before a focused field. diff --git a/gallery/app.go b/gallery/app.go index 1eabd57..85b8069 100644 --- a/gallery/app.go +++ b/gallery/app.go @@ -6,6 +6,7 @@ import ( "github.com/egoist/mygo/ui" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" ) @@ -19,7 +20,7 @@ type App struct { locale pixel.Locale started bool query string - search pixel.Edit + search input.TextInputResult logs map[string][]string } @@ -61,9 +62,6 @@ func (a *App) View(c *ui.Context) { // keys leaves single keys to a field being typed in. func (a *App) keys(c *ui.Context) { - if a.search.Focused && c.Shortcut(0, ui.KeyEscape) { - a.query = "" - } if pixel.Typing(c) { return } diff --git a/gallery/app_test.go b/gallery/app_test.go index 4ed0f60..b8c7ebd 100644 --- a/gallery/app_test.go +++ b/gallery/app_test.go @@ -167,3 +167,54 @@ func TestShortcutsYieldToFields(t *testing.T) { t.Fatalf("typing changed palette %d or path %q", a.palette, a.Path()) } } + +func TestFoundationTilesShowFocus(t *testing.T) { + for path, tile := range map[string]string{"/f/palettes": "Spring Day", "/f/sprites": "slime"} { + _, tt := open(path) + r, _ := tt.Find(tile) + area := func() []uint32 { + img := tt.Image() + var out []uint32 + for y := int(r.Y) - 6; y < int(r.Y+r.H)+6; y++ { + for x := int(r.X) - 30; x < int(r.X+r.W)+6; x++ { + c := img.RGBAAt(x, y) + out = append(out, uint32(c.R)<<16|uint32(c.G)<<8|uint32(c.B)) + } + } + return out + } + before := area() + for range 40 { + if tt.Focused(tile) { + break + } + tt.Key(0, ui.KeyTab) + } + if !tt.Focused(tile) || slices.Equal(before, area()) { + t.Fatalf("%s: focus on %s left no mark", path, tile) + } + } +} + +func TestInputDemosLogEveryClick(t *testing.T) { + a, tt := open("/c/066-button") + for _, l := range []string{"Small", "Save", "BIG"} { + click(t, tt, l) + if !slices.Contains(a.logs["066-button"], l) { + t.Fatalf("clicking %s logged %q", l, a.logs["066-button"]) + } + } + a, tt = open("/c/067-iconbutton") + click(t, tt, "Delete") + if !slices.Contains(a.logs["067-iconbutton"], "Delete") { + t.Fatalf("Delete logged %q", a.logs["067-iconbutton"]) + } + for slug, field := range map[string]string{"082-textinput": "Hero name", "083-passwordinput": "Password", "085-searchfield": "Find items"} { + a, tt = open("/c/" + slug) + click(t, tt, field) + tt.Type("ab") + if len(a.logs[slug]) == 0 { + t.Fatalf("typing into %s logged nothing", slug) + } + } +} diff --git a/gallery/boot.go b/gallery/boot.go index 9ece291..c7a85cb 100644 --- a/gallery/boot.go +++ b/gallery/boot.go @@ -25,7 +25,7 @@ func (a *App) boot(c *ui.Context) { g := k.Canvas(p, r) w := pixel.Measure(logo) * 4 x, y := (g.W-w)/2, g.H/2-40 - shadowText(g, x, y, logo, pixel.Accent, 4) + shadow(g, x, y, logo, pixel.Accent, 4) g.Veil(x, y, w+4, 68, pixel.Back, 16-shown) if start { s := "PRESS START" diff --git a/gallery/chrome.go b/gallery/chrome.go index 60dd675..5f82873 100644 --- a/gallery/chrome.go +++ b/gallery/chrome.go @@ -7,6 +7,8 @@ import ( "github.com/egoist/mygo/ui" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" ) @@ -25,29 +27,21 @@ func (a *App) topBar(c *ui.Context) { if st.Hover { ink = pixel.AccentLit } - shadowText(g, 0, 1, logo, ink, 1) + shadow(g, 0, 1, logo, ink, 1) }) { a.Go("/") } - ui.Column(c).Width(k.D(150)).Padding(k.D(2), k.D(4)).Draw(func(p *ui.Painter, r ui.Rect) { - g := k.Canvas(p, r) - g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well) - }).Children(func() { - a.search = pixel.Editor(c, &a.query, pixel.EditorOptions{Placeholder: "/ search 搜索", Label: "Search components"}) - if m := matches(a.query); a.search.Submitted && len(m) > 0 { - a.Go(m[0].Path()) - } - }) + a.search = input.SearchField(c, "Search components", &a.query, input.SearchFieldOptions{Placeholder: "/ search 搜索", Width: 150}) + if m := matches(a.query); a.search.Submitted && len(m) > 0 { + a.Go(m[0].Path()) + } ui.Spacer(c) a.paletteControl(c) other, mark := pixel.Zh, "中" if a.locale == pixel.Zh { other, mark = pixel.En, "EN" } - if press(c, 20, 16, "language", func(g pixel.Canvas, st pixel.State) { - g.Frame(0, 0, g.W, g.H, pixel.Notched, pixel.Face) - g.Text((g.W-pixel.Measure(mark))/2, 0, mark, pixel.Ink, 1) - }) { + if input.Button(c, mark, input.ButtonOptions{Size: input.Small, Label: "language"}).Clicked { a.locale = other } }) @@ -55,9 +49,8 @@ func (a *App) topBar(c *ui.Context) { func (a *App) paletteControl(c *ui.Context) { pal := pixel.Palettes[a.palette] - if press(c, 12, 16, "previous palette", func(g pixel.Canvas, st pixel.State) { - g.Sprite(0, 2, icon.ChevronLeft, 0, pixel.Ink, 1) - }) { + arrows := input.IconButtonOptions{Variant: input.Ghost, Size: input.Small} + if input.IconButton(c, icon.ChevronLeft, "previous palette", arrows).Clicked { a.setPalette(a.palette - 1) } name := strings.ToUpper(pal.Name) + " " + pal.Zh @@ -68,9 +61,7 @@ func (a *App) paletteControl(c *ui.Context) { } g.Text(4*pixel.Slots+6, 0, name, pixel.Ink, 1) }) - if press(c, 12, 16, "next palette", func(g pixel.Canvas, st pixel.State) { - g.Sprite(0, 2, icon.ChevronRight, 0, pixel.Ink, 1) - }) { + if input.IconButton(c, icon.ChevronRight, "next palette", arrows).Clicked { a.setPalette(a.palette + 1) } } @@ -113,17 +104,30 @@ 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()))) +} + +// press is a custom-drawn chrome tile; true when clicked. +func press(c *ui.Context, w, h int, name string, draw func(g pixel.Canvas, st pixel.State)) bool { k := pixel.K(c) - hints := "/ search · P palette · [ ] prev next" - total := fmt.Sprintf("%d ENTRIES", len(Entries())) - ui.Row(c).Height(k.D(18)).Draw(func(p *ui.Painter, r ui.Rect) { + shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(name) }) + if w > 0 { + shell.Width(k.D(w)) + } + shell.Height(k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) - g.Rect(0, 0, g.W, g.H, pixel.Face) - g.HLine(0, 0, g.W, pixel.Edge) - g.HLine(0, 1, g.W, pixel.Lit) - g.Text(6, 2, hints, pixel.Faint, 1) - g.Micro(g.W-pixel.MicroWidth(total)-6, 8, total, pixel.Faint, 1) + draw(g, st) + if st.Focus { + g.FocusMark(0, 0, g.W, g.H) + } }) + return base.Clicked() +} + +// shadow draws s with a shadow one scaled dot down-right. +func shadow(g pixel.Canvas, x, y int, s string, ink pixel.Slot, scale int) { + g.Text(x+scale, y+scale, s, pixel.Shade, scale) + g.Text(x, y, s, ink, scale) } func count(cat string) int { diff --git a/gallery/demos_display.go b/gallery/demos_display.go index 9702110..906c9e6 100644 --- a/gallery/demos_display.go +++ b/gallery/demos_display.go @@ -10,6 +10,7 @@ import ( "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" ) @@ -125,7 +126,7 @@ func demoTag(c *ui.Context, log func(string)) { } display.Tag(c, "locked", display.TagOptions{Icon: icon.Lock, Removable: true, Disabled: true}) }) - if button(c, "RESET") { + if input.Button(c, "RESET", input.ButtonOptions{}).Clicked { *tags = []string{"fire", "ice", "thunder", "wind"} log("tags reset") } @@ -138,7 +139,7 @@ func demoStatusDot(c *ui.Context, log func(string)) { for _, s := range []display.Status{display.Online, display.Away, display.Busy, display.Offline} { ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() { display.StatusDot(c, s, display.StatusDotOptions{Live: true}) - label(c, []string{"Player 1", "Player 2", "Player 3", "Player 4"}[s], pixel.Ink) + pixel.Text(c, []string{"Player 1", "Player 2", "Player 3", "Player 4"}[s], pixel.TextOptions{}) }) } }) @@ -227,7 +228,7 @@ func demoRarityFrame(c *ui.Context, log func(string)) { items := []string{"Stick", "Bow", "Rapier", "Dragon Lance", "Excalibur"} ui.Row(c).Wrap().Gap(k.D(6)).Children(func() { for r := display.Common; r <= display.Legendary; r++ { - display.RarityFrame(c, r, display.RarityFrameOptions{}, func() { label(c, items[r], pixel.Ink) }) + display.RarityFrame(c, r, display.RarityFrameOptions{}, func() { pixel.Text(c, items[r], pixel.TextOptions{}) }) } }) } @@ -237,7 +238,7 @@ func demoBubble(c *ui.Context, log func(string)) { lines := []string{"Hey!", "Look up.", "Psst.", "Over here."} ui.Row(c).Wrap().Gap(k.D(8)).AlignItems(ui.Center).Children(func() { for i, side := range []display.Side{display.Bottom, display.Top, display.Left, display.Right} { - display.Bubble(c, display.BubbleOptions{Tail: side}, func() { label(c, lines[i], pixel.Ink) }) + display.Bubble(c, display.BubbleOptions{Tail: side}, func() { pixel.Text(c, lines[i], pixel.TextOptions{}) }) } }) } diff --git a/gallery/demos_foundations.go b/gallery/demos_foundations.go index e9155e4..f79e1c3 100644 --- a/gallery/demos_foundations.go +++ b/gallery/demos_foundations.go @@ -6,6 +6,8 @@ import ( "github.com/egoist/mygo/ui" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" ) @@ -25,7 +27,10 @@ func demoPalettes(c *ui.Context, log func(string)) { ui.Grid(c).Columns(2).Gap(k.D(6)).Children(func() { for _, pal := range pixel.Palettes { pixel.Scope(c, pixel.Settings{Palette: pal}, func() { - if press(c, 0, 52, pal.Name, func(g pixel.Canvas, st pixel.State) { + 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) { + g := kp.Canvas(p, r) g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face) g.Text(6, 4, pal.Name+" "+pal.Zh, pixel.Ink, 1) g.Rect(5, 23, pixel.Slots*5+2, 10, pixel.Edge) @@ -36,7 +41,11 @@ func demoPalettes(c *ui.Context, log func(string)) { g.Sprite(g.W-32, 4, icon.Star, 0, pixel.Warn, 1) g.Sprite(g.W-18, 4, icon.Coin, 0, pixel.Ink, 1) g.Text(6, 34, "HP 99 · 你好", pixel.Accent, 1) - }) { + if st.Focus { + g.FocusMark(0, 0, g.W, g.H) + } + }) + if base.Clicked() { log("palette " + pal.Name) } }) @@ -101,12 +110,7 @@ func demoIcons(c *ui.Context, log func(string)) { k := pixel.K(c) ui.Row(c).Wrap().Gap(k.D(2)).Children(func() { for _, n := range icon.All { - if press(c, 16, 16, n.Name, func(g pixel.Canvas, st pixel.State) { - if st.Hover { - g.Rect(0, 0, 16, 16, pixel.Lit) - } - g.Sprite(2, 2, n.Sprite, 0, pixel.Ink, 1) - }) { + if input.IconButton(c, n.Sprite, n.Name, input.IconButtonOptions{Variant: input.Ghost}).Clicked { log("icon " + n.Name) } } @@ -151,12 +155,19 @@ func demoSprites(c *ui.Context, log func(string)) { if *left { sp = slimeLeft } - if press(c, 120, 48, "slime", func(g pixel.Canvas, st pixel.State) { + k := pixel.K(c) + shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label("slime") }) + shell.Size(k.D(120), k.D(48)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) g.Rect(0, 40, g.W, 8, pixel.Good) g.Dither(0, 40, g.W, 8, pixel.Good, pixel.Shade, 4) step := g.Step(4) g.Sprite(8+step%8*2, 16, sp, step/2, pixel.Ink, 2) - }) { + if st.Focus { + g.FocusMark(0, 0, g.W, g.H) + } + }) + if base.Clicked() { *left = !*left log(fmt.Sprintf("turned, facing left %v", *left)) } @@ -168,7 +179,7 @@ func demoEditor(c *ui.Context, log func(string)) { k := pixel.K(c) st := ui.Local(c.Root(), demoKey("editor"), func() editorDemo { return editorDemo{notes: "Two lines\nof notes"} }) field := func(v *string, o pixel.EditorOptions) { - panel(c, pixel.Sunken, pixel.Well, 3).Children(func() { + layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well, Padding: 1}, func() { e := pixel.Editor(c, v, o) if e.Changed { log(o.Label + " changed") @@ -189,16 +200,10 @@ func demoFocus(c *ui.Context, log func(string)) { k := pixel.K(c) ui.Row(c).Gap(k.D(16)).Padding(0, k.D(14)).Children(func() { for _, name := range []string{"ATTACK", "MAGIC", "ITEM"} { - if button(c, name) { + if input.Button(c, name, input.ButtonOptions{}).Clicked { log(name) } } - shell, _, _ := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label("RUN").Disabled(true) }) - shell.Size(k.D(pixel.Measure("RUN")+12), k.D(16)).Draw(func(p *ui.Painter, r ui.Rect) { - g := k.Canvas(p, r) - g.Frame(0, 0, g.W, g.H-1, pixel.Raised, pixel.Face) - g.Text(6, 0, "RUN", pixel.Faint, 1) - g.Checker(0, 0, g.W, g.H-1, pixel.Face) - }) + input.Button(c, "RUN", input.ButtonOptions{Disabled: true}) }) } diff --git a/gallery/demos_input.go b/gallery/demos_input.go new file mode 100644 index 0000000..3adafcf --- /dev/null +++ b/gallery/demos_input.go @@ -0,0 +1,260 @@ +package gallery + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var inputEntries = []Entry{ + {66, "Button", "按钮", "input", "Primary, normal, ghost and danger in three sizes. Pressing sinks the face a dot; loading spins and waits.", demoButton}, + {67, "IconButton", "图标按钮", "input", "A square button with an icon and a name for screen readers.", demoIconButton}, + {68, "ButtonGroup", "按钮组", "input", "Buttons joined edge to edge: one Tab stop, arrows move between them.", demoButtonGroup}, + {69, "Toggle", "开关按钮", "input", "A button that stays sunken while it is on.", demoToggle}, + {70, "ToggleGroup", "开关组", "input", "Joined toggles, for many choices or just one.", demoToggleGroup}, + {71, "Checkbox", "复选框", "input", "An 8×8 box with a hand-placed check. Space toggles it.", demoCheckbox}, + {72, "CheckboxGroup", "复选组", "input", "A parent box over children: mixed while some are checked, all or none on a click.", demoCheckboxGroup}, + {73, "Radio", "单选", "input", "A pixel radio dot that picks its value.", demoRadio}, + {74, "RadioGroup", "单选组", "input", "Radios as one Tab stop; the arrows choose.", demoRadioGroup}, + {75, "Switch", "拨杆", "input", "A lever on an OFF/ON plate. Space flips it.", demoSwitch}, + {76, "Slider", "滑块", "input", "A Well track, an Accent fill and a beveled knob: drag, arrows, Home, End.", demoSlider}, + {77, "StepSlider", "刻度滑块", "input", "A slider that snaps to steps, with tick dots and its value.", demoStepSlider}, + {78, "RangeSlider", "区间滑块", "input", "Two knobs for low and high that never cross.", demoRangeSlider}, + {79, "Fader", "推子", "input", "A mixing-desk fader with a cap and ticks.", demoFader}, + {80, "Stepper", "步进器", "input", "◀ value ▶ within bounds. Hold an arrow to repeat; keys step it too.", demoStepper}, + {81, "NumberInput", "数字框", "input", "Type a number or step it with ▲▼ and the arrow keys; bad text turns the edge red.", demoNumberInput}, + {82, "TextInput", "文本框", "input", "A one-line pixel field: caret, selection, input methods, clipboard. Enter submits.", demoTextInput}, + {83, "PasswordInput", "密码框", "input", "Masked with ★; the eye shows the text.", demoPasswordInput}, + {84, "TextArea", "多行文本", "input", "Many lines in its own scroll, following the caret, with a character count.", demoTextArea}, + {85, "SearchField", "搜索框", "input", "A field with a magnifier. Escape clears it, Enter searches.", demoSearchField}, +} + +type inputDemo struct { + toggle, music, sound, check, lever bool + toggles, mode, party []bool + radio string + choice, level, count int + volume, steps, low, high, fader float64 + price float64 + name, secret, notes, query string +} + +func inputState(c *ui.Context) *inputDemo { + return ui.Local(c.Root(), demoKey("input"), func() inputDemo { + return inputDemo{music: true, toggles: []bool{true, false, false}, mode: []bool{false, true, false}, party: []bool{true, false, true}, + radio: "knight", choice: 1, level: 3, count: 5, volume: 0.6, steps: 40, low: 20, high: 70, fader: 0.3, price: 9.5} + }) +} + +func demoButton(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + for i, v := range []input.Variant{input.Primary, input.Normal, input.Ghost, input.Danger} { + name := []string{"PRIMARY", "NORMAL", "GHOST", "DANGER"}[i] + if input.Button(c, name, input.ButtonOptions{Variant: v}).Clicked { + log(name) + } + } + }) + ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + for _, b := range []struct { + name string + o input.ButtonOptions + }{{"Small", input.ButtonOptions{Size: input.Small}}, {"Save", input.ButtonOptions{Icon: icon.Save}}, {"BIG", input.ButtonOptions{Size: input.Large, Variant: input.Primary}}} { + if input.Button(c, b.name, b.o).Clicked { + log(b.name) + } + } + input.Button(c, "Saving", input.ButtonOptions{Loading: true}) + input.Button(c, "Off", input.ButtonOptions{Disabled: true}) + }) + }) +} + +func demoIconButton(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(4)).Children(func() { + for _, n := range []icon.Named{{Name: "Play", Sprite: icon.Play}, {Name: "Pause", Sprite: icon.Pause}, {Name: "Stop", Sprite: icon.Stop}} { + if input.IconButton(c, n.Sprite, n.Name, input.IconButtonOptions{}).Clicked { + log(n.Name) + } + } + if input.IconButton(c, icon.Trash, "Delete", input.IconButtonOptions{Variant: input.Danger}).Clicked { + log("Delete") + } + }) +} + +func demoButtonGroup(c *ui.Context, log func(string)) { + labels := []string{"FIGHT", "MAGIC", "ITEM", "RUN"} + if r := input.ButtonGroup(c, labels, input.ButtonGroupOptions{Label: "Command"}); r.Clicked >= 0 { + log(labels[r.Clicked]) + } +} + +func demoToggle(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := inputState(c) + ui.Row(c).Gap(k.D(4)).Children(func() { + if input.Toggle(c, "MUSIC", &s.music, input.ToggleOptions{Icon: icon.Music}).Changed { + log(fmt.Sprintf("music %v", s.music)) + } + if input.Toggle(c, "SOUND", &s.sound, input.ToggleOptions{Icon: icon.Volume}).Changed { + log(fmt.Sprintf("sound %v", s.sound)) + } + }) +} + +func demoToggleGroup(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := inputState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + if input.ToggleGroup(c, []string{"B", "I", "U"}, s.toggles, input.ToggleGroupOptions{Label: "Style"}).Changed { + log(fmt.Sprintf("style %v", s.toggles)) + } + if input.ToggleGroup(c, []string{"EASY", "NORMAL", "HARD"}, s.mode, input.ToggleGroupOptions{Single: true, Label: "Mode"}).Changed { + log(fmt.Sprintf("mode %v", s.mode)) + } + }) +} + +func demoCheckbox(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := inputState(c) + ui.Column(c).Gap(k.D(2)).Children(func() { + if input.Checkbox(c, "Remember me", &s.check, input.CheckboxOptions{}).Changed { + log(fmt.Sprintf("remember %v", s.check)) + } + locked := true + input.Checkbox(c, "Hard mode (locked)", &locked, input.CheckboxOptions{Disabled: true}) + }) +} + +func demoCheckboxGroup(c *ui.Context, log func(string)) { + s := inputState(c) + if input.CheckboxGroup(c, "Party", []string{"Knight", "Mage", "Thief"}, s.party, input.CheckboxGroupOptions{}).Changed { + log(fmt.Sprintf("party %v", s.party)) + } +} + +func demoRadio(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := inputState(c) + ui.Column(c).Gap(k.D(2)).Children(func() { + for _, class := range []string{"knight", "mage", "thief"} { + if input.Radio(c, class, class, &s.radio, input.RadioOptions{}).Changed { + log("class " + class) + } + } + }) +} + +func demoRadioGroup(c *ui.Context, log func(string)) { + labels := []string{"Spring", "Summer", "Autumn", "Winter"} + s := inputState(c) + if input.RadioGroup(c, labels, &s.choice, input.RadioGroupOptions{Row: true, Label: "Season"}).Changed { + log("season " + labels[s.choice]) + } +} + +func demoSwitch(c *ui.Context, log func(string)) { + s := inputState(c) + if input.Switch(c, "Power", &s.lever, input.SwitchOptions{}).Changed { + log(fmt.Sprintf("power %v", s.lever)) + } +} + +func demoSlider(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := inputState(c) + ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + if input.Slider(c, "Volume", &s.volume, input.SliderOptions{Width: 120}).Changed { + log(fmt.Sprintf("volume %.2f", s.volume)) + } + pixel.Text(c, fmt.Sprintf("%3.0f%%", s.volume*100), pixel.TextOptions{}) + }) +} + +func demoStepSlider(c *ui.Context, log func(string)) { + s := inputState(c) + if input.StepSlider(c, "Speed", &s.steps, input.StepSliderOptions{Min: 0, Max: 100, Step: 20, Width: 120}).Changed { + log(fmt.Sprintf("speed %g", s.steps)) + } +} + +func demoRangeSlider(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := inputState(c) + ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + if input.RangeSlider(c, "Level range", &s.low, &s.high, input.RangeSliderOptions{Min: 1, Max: 99, Width: 140}).Changed { + log(fmt.Sprintf("levels %.0f–%.0f", s.low, s.high)) + } + pixel.Text(c, fmt.Sprintf("LV %.0f–%.0f", s.low, s.high), pixel.TextOptions{}) + }) +} + +func demoFader(c *ui.Context, log func(string)) { + s := inputState(c) + if input.Fader(c, "Master", &s.fader, input.FaderOptions{}).Changed { + log(fmt.Sprintf("master %.2f", s.fader)) + } +} + +func demoStepper(c *ui.Context, log func(string)) { + s := inputState(c) + if input.Stepper(c, "Lives", &s.count, input.StepperOptions{Min: 1, Max: 9}).Changed { + log(fmt.Sprintf("lives %d", s.count)) + } +} + +func demoNumberInput(c *ui.Context, log func(string)) { + s := inputState(c) + if input.NumberInput(c, "Price", &s.price, input.NumberInputOptions{Min: 0, Max: 999, Step: 0.5, Width: 80}).Changed { + log(fmt.Sprintf("price %g", s.price)) + } +} + +func demoTextInput(c *ui.Context, log func(string)) { + s := inputState(c) + r := input.TextInput(c, "Hero name", &s.name, input.TextInputOptions{Placeholder: "Your name 你的名字", MaxLength: 12}) + if r.Changed { + log("name " + s.name) + } + if r.Submitted { + log("submitted " + s.name) + } +} + +func demoPasswordInput(c *ui.Context, log func(string)) { + s := inputState(c) + r := input.PasswordInput(c, "Password", &s.secret, input.PasswordInputOptions{Placeholder: "Password"}) + if r.Changed { + log(fmt.Sprintf("%d characters", len([]rune(s.secret)))) + } + if r.Submitted { + log("signed in") + } +} + +func demoTextArea(c *ui.Context, log func(string)) { + s := inputState(c) + if input.TextArea(c, "Notes", &s.notes, input.TextAreaOptions{Placeholder: "Dear diary...", MaxLength: 140, Width: 200, Lines: 4}).Changed { + log(fmt.Sprintf("%d characters", len([]rune(s.notes)))) + } +} + +func demoSearchField(c *ui.Context, log func(string)) { + s := inputState(c) + r := input.SearchField(c, "Find items", &s.query, input.SearchFieldOptions{Placeholder: "Potion, sword...", Width: 160}) + if r.Changed { + log("query " + s.query) + } + if r.Submitted { + log("search " + s.query) + } +} diff --git a/gallery/demos_layout.go b/gallery/demos_layout.go index 1e0edc9..08cf296 100644 --- a/gallery/demos_layout.go +++ b/gallery/demos_layout.go @@ -7,6 +7,7 @@ import ( "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" ) @@ -42,11 +43,11 @@ func demoScreen(c *ui.Context, log func(string)) { ui.Column(c).Gap(k.D(6)).Children(func() { ui.Box(c).FillWidth().Height(k.D(96)).Children(func() { layout.Screen(c, layout.ScreenOptions{Vignette: *on, Padding: 12}, func() { - label(c, "A NEW SAVE FILE", pixel.Ink) - label(c, "Day 1. The cartridge starts.", pixel.Faint) + pixel.Text(c, "A NEW SAVE FILE", pixel.TextOptions{}) + pixel.Text(c, "Day 1. The cartridge starts.", pixel.TextOptions{Slot: pixel.Faint}) }) }) - if button(c, "VIGNETTE") { + if input.Button(c, "VIGNETTE", input.ButtonOptions{}).Clicked { *on = !*on log(fmt.Sprintf("vignette %v", *on)) } @@ -58,12 +59,12 @@ func demoWindow(c *ui.Context, log func(string)) { ui.Row(c).Gap(k.D(8)).AlignItems(ui.Start).Children(func() { layout.Window(c, layout.WindowOptions{Title: "ITEMS"}, func() { for _, s := range []string{"Potion x3", "Ether x1", "Key x2"} { - label(c, s, pixel.Ink) + pixel.Text(c, s, pixel.TextOptions{}) } }) layout.Window(c, layout.WindowOptions{}, func() { - label(c, "HP 32/40", pixel.Ink) - label(c, "MP 12/18", pixel.Ink) + pixel.Text(c, "HP 32/40", pixel.TextOptions{}) + pixel.Text(c, "MP 12/18", pixel.TextOptions{}) }) }) } @@ -72,7 +73,7 @@ func demoPanel(c *ui.Context, log func(string)) { k := pixel.K(c) ui.Row(c).Wrap().Gap(k.D(6)).Children(func() { for i, name := range []string{"RAISED", "SUNKEN", "FLAT", "DOUBLE", "WINDOW", "NOTCHED"} { - layout.Panel(c, layout.PanelOptions{Style: pixel.FrameStyle(i)}, func() { label(c, name, pixel.Ink) }) + layout.Panel(c, layout.PanelOptions{Style: pixel.FrameStyle(i)}, func() { pixel.Text(c, name, pixel.TextOptions{}) }) } }) } @@ -81,15 +82,15 @@ func demoCard(c *ui.Context, log func(string)) { k := pixel.K(c) ui.Box(c).Width(k.D(200)).Children(func() { layout.Card(c, layout.CardOptions{Title: "QUEST", Icon: icon.Scroll, Footer: func() { - if button(c, "ACCEPT") { + if input.Button(c, "ACCEPT", input.ButtonOptions{}).Clicked { log("quest accepted") } - if button(c, "LATER") { + if input.Button(c, "LATER", input.ButtonOptions{}).Clicked { log("quest saved for later") } }}, func() { - label(c, "Find the lost cartridge.", pixel.Ink) - label(c, "Reward: 300 G", pixel.Faint) + pixel.Text(c, "Find the lost cartridge.", pixel.TextOptions{}) + pixel.Text(c, "Reward: 300 G", pixel.TextOptions{Slot: pixel.Faint}) }) }) } @@ -98,8 +99,8 @@ func demoSection(c *ui.Context, log func(string)) { k := pixel.K(c) ui.Box(c).Width(k.D(220)).Children(func() { layout.Section(c, "Sound", layout.SectionOptions{}, func() { - label(c, "Music 8", pixel.Ink) - label(c, "Effects 5", pixel.Ink) + pixel.Text(c, "Music 8", pixel.TextOptions{}) + pixel.Text(c, "Effects 5", pixel.TextOptions{}) }) }) } @@ -110,7 +111,7 @@ func demoDivider(c *ui.Context, log func(string)) { ui.Row(c).Gap(k.D(12)).AlignItems(ui.Stretch).Children(func() { ui.Column(c).Grow(1).Gap(k.D(4)).Children(func() { for i, n := range names { - label(c, n, pixel.Faint) + pixel.Text(c, n, pixel.TextOptions{Slot: pixel.Faint}) layout.Divider(c, layout.DividerOptions{Style: layout.DividerStyle(i)}) } }) @@ -126,14 +127,14 @@ func demoScrollArea(c *ui.Context, log func(string)) { ui.Column(c).Gap(k.D(4)).Children(func() { r := layout.ScrollArea(c, s, layout.ScrollAreaOptions{Label: "Bestiary"}, func() { for i := range 40 { - label(c, fmt.Sprintf("No.%03d %s", i+1, monsters[i%len(monsters)]), pixel.Ink) + pixel.Text(c, fmt.Sprintf("No.%03d %s", i+1, monsters[i%len(monsters)]), pixel.TextOptions{}) } }) r.Element.Size(k.D(200), k.D(112)) if r.Changed { log(fmt.Sprintf("scrolled to %d dots", int(s.Y/k.Dot()))) } - label(c, fmt.Sprintf("Y %d of %d dots", int(s.Y/k.Dot()), int(s.MaxY/k.Dot())), pixel.Faint) + pixel.Text(c, fmt.Sprintf("Y %d of %d dots", int(s.Y/k.Dot()), int(s.MaxY/k.Dot())), pixel.TextOptions{Slot: pixel.Faint}) }) } @@ -142,7 +143,7 @@ func demoSplit(c *ui.Context, log func(string)) { size := ui.Local(c.Root(), demoKey("split"), func() int { return 120 }) pane := func(name string) func() { return func() { - layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well}, func() { label(c, name, pixel.Ink) }).Fill() + layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well}, func() { pixel.Text(c, name, pixel.TextOptions{}) }).Fill() } } ui.Box(c).FillWidth().Height(k.D(96)).Children(func() { @@ -214,7 +215,7 @@ func demoLayers(c *ui.Context, log func(string)) { }, func() { ui.Box(c).Fill().Center().Children(func() { display.Icon(c, icon.Chest, display.IconOptions{Scale: 3}) }) }, func() { - ui.Box(c).Padding(k.D(3)).Children(func() { label(c, "LAYER 3", pixel.Ink) }) + ui.Box(c).Padding(k.D(3)).Children(func() { pixel.Text(c, "LAYER 3", pixel.TextOptions{}) }) }).Size(k.D(120), k.D(64)) } @@ -259,7 +260,7 @@ func demoResizable(c *ui.Context, log func(string)) { h := ui.Local(c.Root(), demoKey("resize-h"), func() int { return 56 }) r := layout.Resizable(c, w, h, layout.ResizableOptions{MinW: 48, MinH: 32, MaxW: 240, MaxH: 120}, func() { layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well}, func() { - label(c, fmt.Sprintf("%d×%d", *w, *h), pixel.Ink) + pixel.Text(c, fmt.Sprintf("%d×%d", *w, *h), pixel.TextOptions{}) }).Fill() }) if r.Changed { @@ -297,8 +298,8 @@ func demoCRTFrame(c *ui.Context, log func(string)) { k := pixel.K(c) layout.CRTFrame(c, layout.CRTFrameOptions{Brand: "8BIT CRT"}, func() { ui.Column(c).Size(k.D(180), k.D(72)).Padding(k.D(4)).Children(func() { - label(c, "READY.", pixel.Good) - label(c, "10 PRINT \"HI\"", pixel.Good) + pixel.Text(c, "READY.", pixel.TextOptions{Slot: pixel.Good}) + pixel.Text(c, "10 PRINT \"HI\"", pixel.TextOptions{Slot: pixel.Good}) }) }).AlignSelf(ui.Start) } @@ -307,17 +308,17 @@ func demoAppFrame(c *ui.Context, log func(string)) { k := pixel.K(c) ui.Box(c).FillWidth().Height(k.D(150)).Children(func() { layout.AppFrame(c, layout.AppFrameOptions{SidebarWidth: 72}, func() { - label(c, "8BIT MAIL", pixel.Ink) + pixel.Text(c, "8BIT MAIL", pixel.TextOptions{}) }, func() { for _, s := range []string{"Inbox", "Sent", "Spam"} { - if button(c, s) { + if input.Button(c, s, input.ButtonOptions{}).Clicked { log("open " + s) } } }, func() { - label(c, "No new mail. Go outside.", pixel.Ink) + pixel.Text(c, "No new mail. Go outside.", pixel.TextOptions{}) }, func() { - label(c, "3 folders", pixel.Faint) + pixel.Text(c, "3 folders", pixel.TextOptions{Slot: pixel.Faint}) }) }) } diff --git a/gallery/demos_text.go b/gallery/demos_text.go index af8d998..f89d8ab 100644 --- a/gallery/demos_text.go +++ b/gallery/demos_text.go @@ -5,6 +5,7 @@ import ( "github.com/egoist/mygo/ui" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" ) @@ -97,7 +98,7 @@ func demoTypewriter(c *ui.Context, log func(string)) { if r.Done { log(fmt.Sprintf("line %d done", *at+1)) } - if button(c, "NEXT") { + if input.Button(c, "NEXT", input.ButtonOptions{}).Clicked { *at = (*at + 1) % len(lines) log("next line") } @@ -133,9 +134,9 @@ func demoKbd(c *ui.Context, log func(string)) { func demoCode(c *ui.Context, log func(string)) { k := pixel.K(c) ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() { - label(c, "Call", pixel.Ink) + pixel.Text(c, "Call", pixel.TextOptions{}) text.Code(c, "pixel.Use(c, s)", text.CodeOptions{}) - label(c, "once per frame.", pixel.Ink) + pixel.Text(c, "once per frame.", pixel.TextOptions{}) }) } @@ -169,11 +170,11 @@ func demoOdometer(c *ui.Context, log func(string)) { score := ui.Local(c.Root(), demoKey("odometer"), func() int { return 1990 }) ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() { text.Odometer(c, *score, text.OdometerOptions{Digits: 6}) - if button(c, "+15") { + if input.Button(c, "+15", input.ButtonOptions{}).Clicked { *score += 15 log(fmt.Sprintf("score %d", *score)) } - if button(c, "+500") { + if input.Button(c, "+500", input.ButtonOptions{}).Clicked { *score += 500 log(fmt.Sprintf("score %d", *score)) } @@ -202,7 +203,7 @@ func demoScrambleText(c *ui.Context, log func(string)) { at := ui.Local(c.Root(), demoKey("scramble"), func() int { return 0 }) ui.Column(c).Gap(k.D(6)).Children(func() { text.ScrambleText(c, words[*at], text.ScrambleTextOptions{Slot: pixel.Good, Scale: 2}) - if button(c, "AGAIN") { + if input.Button(c, "AGAIN", input.ButtonOptions{}).Clicked { *at = (*at + 1) % len(words) log("scramble " + words[*at]) } diff --git a/gallery/pages.go b/gallery/pages.go index ce28265..a7f35d9 100644 --- a/gallery/pages.go +++ b/gallery/pages.go @@ -8,7 +8,10 @@ import ( "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" ) func (a *App) content(c *ui.Context) { @@ -46,14 +49,12 @@ func page(c *ui.Context, fn func()) { func (a *App) home(c *ui.Context) { k := pixel.K(c) - ui.Box(c).Height(k.D(52)).Draw(func(p *ui.Painter, r ui.Rect) { - shadowText(k.Canvas(p, r), 0, 0, logo, pixel.Accent, 3) - }) + text.ShadowText(c, logo, text.ShadowTextOptions{Slot: pixel.Accent, Scale: 3}) pixel.Text(c, "Pixel components for MyGo. Every dot sits on the grid; every color comes from sixteen.", pixel.TextOptions{Wrap: true}) pixel.Text(c, "为 MyGo 打造的像素组件。每个点落在点阵上,每种颜色来自十六格调色板。", pixel.TextOptions{Wrap: true, Slot: pixel.Faint}) comps := len(Entries()) - count("foundations") stats := fmt.Sprintf("%d COMPONENTS %d CATEGORIES %d PALETTES", comps, len(Categories)-1, len(pixel.Palettes)) - ui.Box(c).Height(k.D(7)).Draw(func(p *ui.Painter, r ui.Rect) { k.Canvas(p, r).Micro(0, 1, stats, pixel.Accent, 1) }) + text.MicroText(c, stats, text.MicroTextOptions{Slot: pixel.Accent}) ui.Grid(c).Columns(3).Gap(k.D(4)).Children(func() { for _, cat := range Categories { n := count(cat.Key) @@ -87,11 +88,10 @@ func (a *App) category(c *ui.Context, key string) { return } k := pixel.K(c) - ui.Box(c).Height(k.D(36)).Draw(func(p *ui.Painter, r ui.Rect) { - g := k.Canvas(p, r) - g.Sprite(0, 4, cat.Icon, 0, pixel.Accent, 2) - shadowText(g, 30, 0, cat.Name, pixel.Ink, 2) - g.Text(30+pixel.Measure(cat.Name)*2+8, 14, cat.Zh, pixel.Faint, 1) + ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + display.Icon(c, cat.Icon, display.IconOptions{Slot: pixel.Accent, Scale: 2}) + text.ShadowText(c, cat.Name, text.ShadowTextOptions{Scale: 2}) + pixel.Text(c, cat.Zh, pixel.TextOptions{Slot: pixel.Faint}) }) ui.Grid(c).Columns(3).Gap(k.D(4)).Children(func() { for _, e := range Entries() { @@ -121,40 +121,37 @@ func (a *App) card(c *ui.Context, e Entry) { func (a *App) entry(c *ui.Context, e Entry) { k := pixel.K(c) - ui.Box(c).Height(k.D(36)).Draw(func(p *ui.Painter, r ui.Rect) { - g := k.Canvas(p, r) - x := 0 + ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { if e.ID > 0 { - g.Micro(0, 2, fmt.Sprintf("%03d", e.ID), pixel.Accent, 2) - x = 30 + text.MicroText(c, fmt.Sprintf("%03d", e.ID), text.MicroTextOptions{Slot: pixel.Accent, Scale: 2}) } - shadowText(g, x, 0, e.Name, pixel.Ink, 2) - g.Text(x+pixel.Measure(e.Name)*2+8, 14, e.Zh, pixel.Faint, 1) + text.ShadowText(c, e.Name, text.ShadowTextOptions{Scale: 2}) + pixel.Text(c, e.Zh, pixel.TextOptions{Slot: pixel.Faint}) }) pixel.Text(c, e.Summary, pixel.TextOptions{Wrap: true}) - panel(c, pixel.Window, pixel.Face, 10).MinHeight(k.D(80)).Children(func() { e.Demo(c, a.logger(e)) }) + layout.Panel(c, layout.PanelOptions{Style: pixel.Window, Padding: 8}, func() { e.Demo(c, a.logger(e)) }).MinHeight(k.D(80)) code := strings.ReplaceAll(e.Code(), "\t", " ") ui.Row(c).Gap(k.D(4)).AlignItems(ui.Start).Children(func() { - panel(c, pixel.Sunken, pixel.Well, 4).Grow(1).Children(func() { + layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well, Padding: 2}, func() { ui.ScrollBoth(c).Height(k.D(160)).Children(func() { codeBlock(c, code) }) - }) + }).Grow(1) ui.Column(c).Width(k.D(140)).Gap(k.D(4)).Children(func() { - if button(c, "COPY CODE") { + if input.Button(c, "COPY CODE", input.ButtonOptions{}).Clicked { c.WriteClipboard(code) a.logger(e)("code copied") } - panel(c, pixel.Sunken, pixel.Well, 4).Height(k.D(140)).Children(func() { + layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well, Padding: 2}, func() { for _, l := range a.logs[e.Slug()] { pixel.Text(c, "> "+l, pixel.TextOptions{Slot: pixel.Faint, MaxLines: 1}) } - }) + }).Height(k.D(140)) }) }) ui.Row(c).Gap(k.D(4)).Children(func() { - if button(c, "◀ PREV") { + if input.Button(c, "◀ PREV", input.ButtonOptions{}).Clicked { a.step(-1) } - if button(c, "NEXT ▶") { + if input.Button(c, "NEXT ▶", input.ButtonOptions{}).Clicked { a.step(1) } }) @@ -203,11 +200,11 @@ func GameOver(c *ui.Context, why string) bool { cont := false ui.Column(c).Gap(k.D(8)).AlignItems(ui.Center).Padding(k.D(24)).Children(func() { ui.Box(c).Size(k.D(pixel.Measure(k.Msg("game_over"))*3), k.D(48)).Draw(func(p *ui.Painter, r ui.Rect) { - shadowText(k.Canvas(p, r), 0, 0, k.Msg("game_over"), pixel.Bad, 3) + shadow(k.Canvas(p, r), 0, 0, k.Msg("game_over"), pixel.Bad, 3) }) pixel.Text(c, why, pixel.TextOptions{Slot: pixel.Faint, Wrap: true, MaxLines: 6}).Width(k.D(min(pixel.Measure(why), 280))) display.Icon(c, icon.Skull, display.IconOptions{Scale: 3}) - cont = button(c, k.Msg("continue")) + cont = input.Button(c, k.Msg("continue"), input.ButtonOptions{}).Clicked }) return cont } diff --git a/gallery/registry.go b/gallery/registry.go index 087ddde..635843e 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -56,7 +56,7 @@ var Categories = []Category{ {"tabletop", "Tabletop", "桌游", icon.Crown}, } -var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries) +var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries) func Entries() []Entry { return entries } diff --git a/gallery/widgets.go b/gallery/widgets.go deleted file mode 100644 index 19aeb64..0000000 --- a/gallery/widgets.go +++ /dev/null @@ -1,56 +0,0 @@ -package gallery - -import ( - "github.com/egoist/mygo/ui" - - "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" -) - -// press is a clickable box; w ≤ 0 stretches. -func press(c *ui.Context, w, h int, label string, draw func(g pixel.Canvas, st pixel.State)) bool { - k := pixel.K(c) - shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(label) }) - if w > 0 { - shell.Width(k.D(w)) - } - shell.Height(k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { - g := k.Canvas(p, r) - draw(g, st) - if st.Focus { - g.FocusMark(0, 0, g.W, g.H) - } - }) - return base.Clicked() -} - -// button is a raised pixel button with a centered label. -func button(c *ui.Context, label string) bool { - return press(c, pixel.Measure(label)+12, 16, label, func(g pixel.Canvas, st pixel.State) { - y := 0 - if st.Press { - y = 1 - } - g.Frame(0, y, g.W, g.H-1, pixel.Raised, pixel.Face) - g.Text((g.W-pixel.Measure(label))/2, y, label, pixel.Ink, 1) - }) -} - -// panel is a framed box with padding for children. -func panel(c *ui.Context, style pixel.FrameStyle, fill pixel.Slot, pad int) *ui.Element { - k := pixel.K(c) - return ui.Column(c).Padding(k.D(pad)).Draw(func(p *ui.Painter, r ui.Rect) { - g := k.Canvas(p, r) - g.Frame(0, 0, g.W, g.H, style, fill) - }) -} - -// shadowText draws text with a one-dot shadow. -func shadowText(g pixel.Canvas, x, y int, s string, ink pixel.Slot, scale int) { - g.Text(x+scale, y+scale, s, pixel.Shade, scale) - g.Text(x, y, s, ink, scale) -} - -// label is non-wrapping pixel text. -func label(c *ui.Context, s string, ink pixel.Slot) *ui.Element { - return pixel.Text(c, s, pixel.TextOptions{Slot: ink}) -} diff --git a/input/button.go b/input/button.go new file mode 100644 index 0000000..de990eb --- /dev/null +++ b/input/button.go @@ -0,0 +1,92 @@ +package input + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ButtonOptions struct { + Variant Variant + Size Size + Icon *pixel.Sprite + // Loading shows a spinner and takes no clicks. + Loading bool + Disabled bool + // Label names it for assistive technology; zero uses text. + Label string +} + +type ButtonResult struct { + Element *ui.Element + Clicked bool +} + +// Button is a beveled button whose face sinks when pressed. +func Button(c *ui.Context, text string, o ButtonOptions) ButtonResult { + k := pixel.K(c) + h, pad, top, sc := o.Size.metrics() + name := label(o.Label, text) + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.ButtonBase(c).Label(name).Disabled(o.Disabled || o.Loading) + }) + w := pixel.Measure(text) * sc + lead := 0 + if o.Icon != nil || o.Loading { + lead = 3 + if o.Icon != nil { + lead = o.Icon.W + } + if text != "" { + lead += 3 + } + } + if text == "" && o.Icon != nil { + w, pad = 0, (h-o.Icon.W)/2 + } + shell.Size(k.D(w+lead+2*pad), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + ink, dy := face(g, o.Variant, st, false) + x := pad + switch { + case o.Loading: + cy := (h-3)/2 + dy + for i, d := range [4][2]int{{1, 0}, {2, 1}, {1, 2}, {0, 1}} { + if g.Step(8)%4 != i { + g.Dot(x+d[0], cy+d[1], ink) + } + } + x += lead + case o.Icon != nil: + g.Sprite(x, (h-o.Icon.H)/2+dy, o.Icon, 0, ink, 1) + x += lead + } + if text != "" { + g.Text(x, top+dy, text, ink, sc) + } + finish(g, st) + }) + res := ButtonResult{Element: shell, Clicked: base.Clicked()} + if res.Clicked { + slog.Debug("8bit: button", "label", name) + } + return res +} + +type IconButtonOptions struct { + Variant Variant + Size Size + Disabled bool +} + +type IconButtonResult = ButtonResult + +// IconButton is a square button named for assistive technology. +func IconButton(c *ui.Context, icon *pixel.Sprite, name string, o IconButtonOptions) IconButtonResult { + if name == "" { + panic("input: IconButton needs a name") + } + return Button(c, "", ButtonOptions{Variant: o.Variant, Size: o.Size, Icon: icon, Disabled: o.Disabled, Label: name}) +} diff --git a/input/buttongroup.go b/input/buttongroup.go new file mode 100644 index 0000000..55e8ed4 --- /dev/null +++ b/input/buttongroup.go @@ -0,0 +1,52 @@ +package input + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ButtonGroupOptions struct { + Variant Variant + Size Size + Disabled bool + // Label names the group for assistive technology. + Label string +} + +type ButtonGroupResult struct { + Element *ui.Element + // Clicked is the button clicked this frame, or -1. + Clicked int +} + +// ButtonGroup joins buttons as one Tab stop; arrows move. +func ButtonGroup(c *ui.Context, labels []string, o ButtonGroupOptions) ButtonGroupResult { + k := pixel.K(c) + h, pad, top, sc := o.Size.metrics() + res := ButtonGroupResult{Clicked: -1} + 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.ButtonBase(c).Label(l).Disabled(o.Disabled) }) + w := pixel.Measure(l)*sc + 2*pad + if i > 0 { + shell.Margin(0, 0, 0, k.D(-1)) + } + shell.Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + ink, dy := face(g, o.Variant, st, false) + g.Text(pad, top+dy, l, ink, sc) + finish(g, st) + }) + if base.Clicked() { + res.Clicked = i + } + } + }) + if res.Clicked >= 0 { + slog.Debug("8bit: button group", "label", labels[res.Clicked]) + } + return res +} diff --git a/input/checkbox.go b/input/checkbox.go new file mode 100644 index 0000000..94d49a3 --- /dev/null +++ b/input/checkbox.go @@ -0,0 +1,68 @@ +package input + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var ( + check = pixel.MustSprite(` + .....# + ....## + #..##. + ####.. + .##... + `) + dash = pixel.MustSprite(` + #### + `) +) + +type CheckboxOptions struct { + Disabled bool +} + +type CheckboxResult struct { + Element *ui.Element + Changed bool +} + +// Checkbox is a labeled 8×8 box with a hand-placed check. +func Checkbox(c *ui.Context, text string, checked *bool, o CheckboxOptions) CheckboxResult { + k := pixel.K(c) + shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.CheckboxBase(c, checked).Label(text).Disabled(o.Disabled) }) + mark := check + if !*checked { + mark = nil + } + shell.Size(k.D(12+pixel.Measure(text)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + box(k.Canvas(p, r), text, mark, st) + }) + res := CheckboxResult{Element: shell, Changed: base.Changed()} + if res.Changed { + slog.Debug("8bit: checkbox", "label", text, "checked", *checked) + } + return res +} + +// box draws the 8×8 box, its mark and the label. +func box(g pixel.Canvas, text string, mark *pixel.Sprite, st pixel.State) { + y := (pixel.LineHeight - 8) / 2 + fill := pixel.Well + if st.Hover { + fill = pixel.Lit + } + g.Frame(0, y, 8, 8, pixel.Sunken, fill) + if mark != nil { + g.Sprite((8-mark.W)/2, y+(8-mark.H)/2, mark, 0, pixel.Accent, 1) + } + ink := pixel.Ink + if st.Disabled { + ink = pixel.Faint + } + g.Text(12, 0, text, ink, 1) + finish(g, st) +} diff --git a/input/checkboxgroup.go b/input/checkboxgroup.go new file mode 100644 index 0000000..36cc1e6 --- /dev/null +++ b/input/checkboxgroup.go @@ -0,0 +1,72 @@ +package input + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type CheckboxGroupOptions struct { + Disabled bool +} + +type CheckboxGroupResult struct { + Element *ui.Element + Changed bool +} + +// count is how many boxes are checked. +func count(checked []bool) int { + n := 0 + for _, v := range checked { + if v { + n++ + } + } + return n +} + +// CheckboxGroup puts a parent box over children; partial shows mixed. +func CheckboxGroup(c *ui.Context, text string, items []string, checked []bool, o CheckboxGroupOptions) CheckboxGroupResult { + if len(items) != len(checked) { + panic(fmt.Sprintf("input: CheckboxGroup has %d items and %d states", len(items), len(checked))) + } + k := pixel.K(c) + all := count(checked) == len(checked) && len(checked) > 0 + res := CheckboxGroupResult{} + res.Element = ui.Column(c).Gap(k.D(2)).Role(ui.RoleGroup).Children(func() { + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.CheckboxBase(c, &all).Label(text).Disabled(o.Disabled) + }) + shell.Size(k.D(12+pixel.Measure(text)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + var mark *pixel.Sprite + switch n := count(checked); { + case n == len(checked) && n > 0: + mark = check + case n > 0: + mark = dash + } + box(k.Canvas(p, r), text, mark, st) + }) + if base.Changed() { + for i := range checked { + checked[i] = all + } + res.Changed = true + } + ui.Column(c).Gap(k.D(2)).Padding(0, 0, 0, k.D(12)).Children(func() { + for i, it := range items { + if Checkbox(c, it, &checked[i], CheckboxOptions{Disabled: o.Disabled}).Changed { + res.Changed = true + } + } + }) + }) + if res.Changed { + slog.Debug("8bit: checkbox group", "label", text, "checked", checked) + } + return res +} diff --git a/input/fader.go b/input/fader.go new file mode 100644 index 0000000..0d35131 --- /dev/null +++ b/input/fader.go @@ -0,0 +1,39 @@ +package input + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type FaderOptions struct { + Min, Max float64 + // Height is the travel in dots; zero means 64. + Height int + Disabled bool +} + +type FaderResult = SliderResult + +// Fader is a vertical slider with a cap and ticks. +func Fader(c *ui.Context, text string, value *float64, o FaderOptions) FaderResult { + k := pixel.K(c) + lo, hi := bounds(o.Min, o.Max) + h := orDef("Fader Height", o.Height, 64) + shell, st, changed := slide(c, value, rail{lo: lo, hi: hi, vertical: true, label: text, disabled: o.Disabled}) + shell.Size(k.D(16), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for t := 0; t <= 4; t++ { + y := 3 + t*(g.H-7)/4 + g.HLine(0, y, 3, pixel.Faint) + g.HLine(13, y, 3, pixel.Faint) + } + g.Frame(6, 0, 4, g.H, pixel.Sunken, pixel.Well) + y := g.H - 7 - knobAt(*value, lo, hi, g.H-7) + g.Rect(7, y+3, 2, g.H-y-4, pixel.Accent) + g.Frame(2, y, 12, 7, pixel.Raised, pixel.Face) + g.HLine(4, y+3, 8, pixel.Edge) + finish(g, st) + }) + return report(shell, changed, "fader", text, *value) +} diff --git a/input/input.go b/input/input.go new file mode 100644 index 0000000..275f865 --- /dev/null +++ b/input/input.go @@ -0,0 +1,138 @@ +// Package input takes the player's choices: buttons, boxes, fields. +package input + +import ( + "fmt" + "strconv" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type Variant uint8 + +const ( + Normal Variant = iota + Primary + Ghost + Danger +) + +type Size uint8 + +const ( + Medium Size = iota + Small + Large +) + +// metrics are a size's height, padding, text top and scale. +func (s Size) metrics() (h, pad, top, scale int) { + switch s { + case Small: + return pixel.LineHeight, 4, 0, 1 + case Medium: + return pixel.LineHeight + 4, 8, 2, 1 + case Large: + return 2*pixel.LineHeight + 4, 12, 2, 2 + } + panic(fmt.Sprintf("input: unknown Size %d", s)) +} + +// face paints a button body, returning ink and press shift. +func face(g pixel.Canvas, v Variant, st pixel.State, on bool) (ink pixel.Slot, dy int) { + fill, ink := pixel.Face, pixel.Ink + switch v { + case Primary: + fill, ink = pixel.Accent, pixel.AccentInk + case Danger: + fill, ink = pixel.Bad, pixel.AccentInk + case Normal, Ghost: + default: + panic(fmt.Sprintf("input: unknown Variant %d", v)) + } + if st.Press || on { + dy = 1 + } + if g.W < 3 || g.H < 3 { + return ink, dy + } + if v == Ghost && !st.Hover && !st.Press && !on { + return ink, dy + } + if v == Ghost { + fill = pixel.Lit + } + if on { + fill = pixel.Shade + } + g.Frame(0, dy, g.W, g.H-dy, pixel.Notched, fill) + switch { + case on || st.Press: + g.HLine(1, dy+1, g.W-2, pixel.Edge) + default: + g.Bevel(1, 1, g.W-2, g.H-2, pixel.Lit, pixel.Shade) + if st.Hover { + g.Veil(2, 2, g.W-4, g.H-4, pixel.Lit, 4) + } + } + return ink, dy +} + +// finish dithers out a disabled control and marks focus. +func finish(g pixel.Canvas, st pixel.State) { + if st.Disabled { + g.Checker(0, 0, g.W, g.H, pixel.Face) + } + if st.Focus { + g.FocusMark(0, 0, g.W, g.H) + } +} + +// orDef is v, or def for zero; negative panics. +func orDef(name string, v, def int) int { + if v < 0 { + panic(fmt.Sprintf("input: %s %d is negative", name, v)) + } + if v == 0 { + return def + } + return v +} + +// label is s, or the fallback when s is empty. +func label(s, fallback string) string { + if s != "" { + return s + } + return fallback +} + +// fmtValue prints a number without trailing zeros. +func fmtValue(v float64) string { return strconv.FormatFloat(v, 'g', 4, 64) } + +var ( + tinyUp = pixel.MustSprite(` + ..#.. + .###. + ##### + `) + tinyDown = pixel.MustSprite(` + ##### + .###. + ..#.. + `) + tinyLeft = pixel.MustSprite(` + ..# + .## + ### + .## + ..# + `) + tinyRight = pixel.MustSprite(` + #.. + ##. + ### + ##. + #.. + `) +) diff --git a/input/input_test.go b/input/input_test.go new file mode 100644 index 0000000..ac8f1fe --- /dev/null +++ b/input/input_test.go @@ -0,0 +1,493 @@ +package input_test + +import ( + "image" + "math" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type clock struct{ t time.Time } + +func (c *clock) now() time.Time { return c.t } + +func view(w, h int, clk *clock, build func(c *ui.Context)) *ui.Tester { + if clk == nil { + clk = &clock{time.Unix(3_000_000, 0)} + } + return ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now}) + build(c) + }, w, h) +} + +func find(t *testing.T, tt *ui.Tester, label string) ui.Rect { + t.Helper() + r, ok := tt.Find(label) + if !ok { + t.Fatalf("no %q in %q", label, tt.Texts()) + } + return r +} + +func click(t *testing.T, tt *ui.Tester, label string) { + t.Helper() + if err := tt.Click(label); err != nil { + t.Fatal(err) + } +} + +// slots are the palette slots of a box's dots. +func slots(t *testing.T, img *image.RGBA, r ui.Rect) []pixel.Slot { + t.Helper() + var out []pixel.Slot + for y := int(r.Y); y < int(r.Y+r.H); y += 2 { + for x := int(r.X); x < int(r.X+r.W); x += 2 { + c, found := img.RGBAAt(x, y), false + for s, pc := range pixel.SpringDay.Colors { + if pc.R == c.R && pc.G == c.G && pc.B == c.B { + out, found = append(out, pixel.Slot(s)), true + break + } + } + if !found { + t.Fatalf("pixel %d,%d off the palette", x, y) + } + } + } + return out +} + +// ink is where a box holds Ink, ignoring its background. +func ink(t *testing.T, img *image.RGBA, r ui.Rect) []bool { + t.Helper() + var out []bool + for _, s := range slots(t, img, r) { + out = append(out, s == pixel.Ink) + } + return out +} + +func has(s []pixel.Slot, want pixel.Slot) bool { + for _, v := range s { + if v == want { + return true + } + } + return false +} + +func TestButtonClicksKeysLoadingAndSizes(t *testing.T) { + clicks, opts := 0, input.ButtonOptions{Variant: input.Primary} + tt := view(400, 200, nil, func(c *ui.Context) { + if input.Button(c, "GO", opts).Clicked { + clicks++ + } + input.Button(c, "S", input.ButtonOptions{Size: input.Small}) + input.Button(c, "L", input.ButtonOptions{Size: input.Large}) + }) + click(t, tt, "GO") + tt.Key(0, ui.KeyEnter) + if clicks != 2 { + t.Fatalf("click and Enter gave %d clicks", clicks) + } + if !has(slots(t, tt.Image(), find(t, tt, "GO")), pixel.Accent) { + t.Fatal("a primary button has no Accent face") + } + if s, l := find(t, tt, "S"), find(t, tt, "L"); s.H != 2*pixel.LineHeight || l.H != 2*(2*pixel.LineHeight+4) { + t.Fatalf("small %v, large %v", s.H, l.H) + } + r := find(t, tt, "GO") + top := func() pixel.Slot { return slots(t, tt.Image(), ui.Rect{X: r.X + r.W/2, Y: r.Y, W: 2, H: 2})[0] } + up := top() + tt.Press(r.X+r.W/2, r.Y+r.H/2) + if top() == up { + t.Fatal("pressing did not sink the face") + } + tt.Release(r.X+r.W/2, r.Y+r.H/2) + opts.Loading = true + tt.Frame() + click(t, tt, "GO") + opts.Loading, opts.Disabled = false, true + tt.Frame() + click(t, tt, "GO") + if clicks != 3 { + t.Fatalf("loading or disabled clicked: %d clicks", clicks) + } +} + +func TestIconButtonNeedsAName(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("a nameless icon button did not panic") + } + }() + view(100, 100, nil, func(c *ui.Context) { input.IconButton(c, icon.Star, "", input.IconButtonOptions{}) }) +} + +func TestButtonGroupIsOneTabStop(t *testing.T) { + got := -1 + tt := view(400, 100, nil, func(c *ui.Context) { + if r := input.ButtonGroup(c, []string{"A", "B", "C"}, input.ButtonGroupOptions{}); r.Clicked >= 0 { + got = r.Clicked + } + input.Button(c, "after", input.ButtonOptions{}) + }) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if got != 1 { + t.Fatalf("Tab, Right, Enter clicked %d", got) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("after") { + t.Fatal("Tab did not leave the group") + } +} + +func TestTogglesChecksAndSwitch(t *testing.T) { + var on, checked, lever bool + modes := []bool{true, false, false} + tt := view(500, 200, nil, func(c *ui.Context) { + input.Toggle(c, "MUSIC", &on, input.ToggleOptions{}) + input.Checkbox(c, "Remember", &checked, input.CheckboxOptions{}) + input.Switch(c, "Power", &lever, input.SwitchOptions{}) + input.ToggleGroup(c, []string{"E", "N", "H"}, modes, input.ToggleGroupOptions{Single: true}) + }) + click(t, tt, "MUSIC") + click(t, tt, "Remember") + tt.Key(0, ui.KeySpace) + click(t, tt, "Power") + click(t, tt, "H") + if !on || checked || !lever || modes[0] || !modes[2] { + t.Fatalf("toggle %v, checkbox %v after Space, switch %v, modes %v", on, checked, lever, modes) + } + defer func() { + if recover() == nil { + t.Fatal("a short state slice did not panic") + } + }() + view(100, 100, nil, func(c *ui.Context) { + input.ToggleGroup(c, []string{"a", "b"}, []bool{true}, input.ToggleGroupOptions{}) + }) +} + +func TestCheckboxGroupParent(t *testing.T) { + checked := []bool{true, false} + tt := view(300, 200, nil, func(c *ui.Context) { + input.CheckboxGroup(c, "Party", []string{"Knight", "Mage"}, checked, input.CheckboxGroupOptions{}) + }) + if !has(slots(t, tt.Image(), find(t, tt, "Party")), pixel.Accent) { + t.Fatal("a half-checked group shows no mixed mark") + } + click(t, tt, "Party") + if !checked[0] || !checked[1] { + t.Fatalf("parent on a mixed group checked %v", checked) + } + click(t, tt, "Party") + if checked[0] || checked[1] { + t.Fatalf("parent on a full group left %v", checked) + } +} + +func TestRadiosChoose(t *testing.T) { + class, season := "knight", 0 + tt := view(400, 200, nil, func(c *ui.Context) { + input.Radio(c, "knight", "knight", &class, input.RadioOptions{}) + input.Radio(c, "mage", "mage", &class, input.RadioOptions{}) + input.Button(c, "before", input.ButtonOptions{}) + input.RadioGroup(c, []string{"Spring", "Summer", "Autumn"}, &season, input.RadioGroupOptions{}) + }) + click(t, tt, "mage") + click(t, tt, "mage") + if class != "mage" { + t.Fatalf("radio left %q", class) + } + click(t, tt, "Autumn") + if season != 2 { + t.Fatalf("click chose %d", season) + } + click(t, tt, "before") + tt.Key(0, ui.KeyTab) + if !tt.Focused("Autumn") || season != 2 { + t.Fatal("Tab entered elsewhere than the chosen radio, or chose") + } + tt.Key(0, ui.KeyUp) + if season != 1 { + t.Fatalf("Up chose %d, want 1", season) + } +} + +func TestSlidersDragKeysAndSteps(t *testing.T) { + v, s, low, high, f := 0.0, 0.0, 20.0, 60.0, 0.0 + disabled := false + tt := view(500, 300, nil, func(c *ui.Context) { + ui.Column(c).Gap(10).Children(func() { + input.Slider(c, "vol", &v, input.SliderOptions{Width: 100, Disabled: disabled}) + input.StepSlider(c, "speed", &s, input.StepSliderOptions{Min: 0, Max: 100, Step: 20, Width: 100}) + input.RangeSlider(c, "lv", &low, &high, input.RangeSliderOptions{Min: 0, Max: 100, Width: 100}) + input.Fader(c, "master", &f, input.FaderOptions{Height: 50}) + }) + }) + r := find(t, tt, "vol") + tt.ClickAt(r.X+r.W*0.75, r.Y+r.H/2) + if v < 0.7 || v > 0.8 { + t.Fatalf("a click three quarters along set %v", v) + } + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyRight) + if v != 0.05 { + t.Fatalf("Home then Right gave %v, want 0.05", v) + } + sr := find(t, tt, "speed") + tt.ClickAt(sr.X+100*2*0.33, sr.Y+sr.H/2) + if s != 40 { + t.Fatalf("a third along snapped to %v, want 40", s) + } + tt.Key(0, ui.KeyRight) + if _, shown := tt.Find("60"); s != 60 || !shown { + t.Fatalf("Right stepped to %v, shown %v, want 60", s, shown) + } + lowKnob := find(t, tt, "lv low") + tt.Press(lowKnob.X+lowKnob.W/2, lowKnob.Y+lowKnob.H/2) + tt.Move(lowKnob.X+190, lowKnob.Y+lowKnob.H/2) + tt.Release(lowKnob.X+190, lowKnob.Y+lowKnob.H/2) + if low != high { + t.Fatalf("dragging low past high left %v and %v", low, high) + } + fr := find(t, tt, "master") + tt.ClickAt(fr.X+fr.W/2, fr.Y+2) + if f < 0.9 { + t.Fatalf("a click at the fader's top set %v", f) + } + disabled, v = true, 0.5 + tt.Frame() + tt.ClickAt(r.X+2, r.Y+r.H/2) + if v != 0.5 { + t.Fatal("a disabled slider moved") + } +} + +func TestStepperRepeatsWhileHeld(t *testing.T) { + clk := &clock{time.Unix(3_000_000, 0)} + n := 5 + tt := view(300, 100, clk, func(c *ui.Context) { + input.Stepper(c, "Lives", &n, input.StepperOptions{Min: 0, Max: 20}) + }) + r := find(t, tt, "Lives") + right := r.X + r.W + 4 + 12 + tt.Press(right, r.Y+r.H/2) + if n != 6 { + t.Fatalf("a press stepped to %d", n) + } + clk.t = clk.t.Add(300 * time.Millisecond) + tt.Frame() + if n != 6 { + t.Fatalf("repeat began early: %d", n) + } + clk.t = clk.t.Add(400 * time.Millisecond) + tt.Frame() + tt.Release(right, r.Y+r.H/2) + if n != 9 { + t.Fatalf("700 ms held gave %d, want 9", n) + } + click(t, tt, "Lives") + tt.Key(0, ui.KeyDown) + if n != 8 { + t.Fatalf("Down stepped to %d", n) + } + n = 30 + tt.Key(0, ui.KeyUp) + if n != 20 { + t.Fatalf("Up past the top gave %d", n) + } +} + +func TestNumberInputParsesAndSteps(t *testing.T) { + v := 5.0 + tt := view(300, 100, nil, func(c *ui.Context) { + input.NumberInput(c, "Price", &v, input.NumberInputOptions{Min: 0, Max: 10}) + }) + click(t, tt, "Price") + tt.Key(ui.Cmd, ui.KeyA) + tt.Type("7.5") + if v != 7.5 { + t.Fatalf("typing 7.5 left %v", v) + } + border := func() []pixel.Slot { + r := find(t, tt, "Price") + return slots(t, tt.Image(), ui.Rect{X: r.X, Y: r.Y - 6, W: r.W, H: 2}) + } + tt.Type("x") + if v != 7.5 || !has(border(), pixel.Bad) { + t.Fatalf("bad text changed the value to %v, or the edge stayed: %v", v, border()[:3]) + } + tt.Key(ui.Cmd, ui.KeyA) + tt.Type("9") + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyUp) + if v != 10 { + t.Fatalf("Up twice from 9 gave %v, want the max 10", v) + } +} + +func TestFieldsTypeSubmitClear(t *testing.T) { + name, query, secret := "", "slime", "abc" + submits := 0 + tt := view(500, 300, nil, func(c *ui.Context) { + if input.TextInput(c, "Name", &name, input.TextInputOptions{MaxLength: 3}).Submitted { + submits++ + } + input.SearchField(c, "Find", &query, input.SearchFieldOptions{}) + input.PasswordInput(c, "Secret", &secret, input.PasswordInputOptions{}) + }) + click(t, tt, "Name") + tt.Type("Link!") + tt.Key(0, ui.KeyEnter) + if name != "Lin" || submits != 1 { + t.Fatalf("name %q, submits %d", name, submits) + } + click(t, tt, "Find") + tt.Key(0, ui.KeyEscape) + if query != "" { + t.Fatalf("Escape left %q", query) + } + secretInk := func() []bool { + r := find(t, tt, "Secret") + return ink(t, tt.Image(), ui.Rect{X: r.X, Y: r.Y, W: r.W, H: r.H}) + } + masked := secretInk() + click(t, tt, "Show") + if equal(secretInk(), masked) { + t.Fatal("the eye did not reveal the password in the same frame") + } + click(t, tt, "Find") + query = "potion" + tt.Frame() + tt.Key(0, ui.KeyEscape) + r := find(t, tt, "Find") + shown := ink(t, tt.Image(), r) + tt.Frame() + if query != "" || !equal(shown, ink(t, tt.Image(), r)) { + t.Fatalf("Escape left %q, or showed the old text a frame", query) + } +} + +func TestTextAreaFollowsTheCaret(t *testing.T) { + notes := "" + tt := view(400, 300, nil, func(c *ui.Context) { + input.TextArea(c, "Notes", ¬es, input.TextAreaOptions{Lines: 2, MaxLength: 50}) + pixel.Text(c, "F", pixel.TextOptions{}) + }) + click(t, tt, "Notes") + tt.Type("A\nB\nC\nD\nE\nF") + if _, ok := tt.Find("11/50"); !ok { + t.Fatalf("no character count in %q", tt.Texts()) + } + area, ref := find(t, tt, "Notes"), find(t, tt, "F") + img := tt.Image() + glyph := ink(t, img, ui.Rect{X: ref.X, Y: ref.Y, W: ref.W, H: ref.H}) + last := ink(t, img, ui.Rect{X: area.X, Y: area.Y + area.H - ref.H, W: ref.W, H: ref.H}) + if !equal(glyph, last) { + t.Fatal("the caret's line F is not the bottom line in view") + } +} + +func equal[T comparable](a, b []T) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestStepSliderStaysOnStepsWithinMax(t *testing.T) { + s := 0.0 + tt := view(300, 100, nil, func(c *ui.Context) { + input.StepSlider(c, "odd", &s, input.StepSliderOptions{Min: 0, Max: 10, Step: 6}) + }) + click(t, tt, "odd") + tt.Key(0, ui.KeyEnd) + if s != 6 { + t.Fatalf("End went to %v, want the last step 6", s) + } + f := 0.0 + tt = view(300, 100, nil, func(c *ui.Context) { + input.StepSlider(c, "tenths", &f, input.StepSliderOptions{Min: 0, Max: 0.3, Step: 0.1}) + }) + click(t, tt, "tenths") + tt.Key(0, ui.KeyEnd) + if math.Abs(f-0.3) > 1e-9 { + t.Fatalf("End over tenths went to %v, want 0.3", f) + } +} + +func TestNumberInputRejectsNaNAndSyncsAtOnce(t *testing.T) { + v, changes := 5.0, 0 + tt := view(300, 100, nil, func(c *ui.Context) { + if input.NumberInput(c, "N", &v, input.NumberInputOptions{Min: 0, Max: 10}).Changed { + changes++ + } + }) + r := find(t, tt, "N") + border := func() []pixel.Slot { return slots(t, tt.Image(), ui.Rect{X: r.X, Y: r.Y - 6, W: r.W, H: 2}) } + field := func() []bool { return ink(t, tt.Image(), r) } + eight := 8.0 + ref := view(300, 100, nil, func(c *ui.Context) { + input.NumberInput(c, "N", &eight, input.NumberInputOptions{Min: 0, Max: 10}) + }) + shows8 := func() bool { return equal(field(), ink(t, ref.Image(), r)) } + v = 8 + tt.Frame() + if !shows8() { + t.Fatal("an outside change did not show at once") + } + click(t, tt, "N") + v = 3 + tt.Frame() + eight = 3 + ref.Frame() + click(t, ref, "N") + if !shows8() { + t.Fatal("a focused field ignored an outside change") + } + click(t, tt, "N") + tt.Key(ui.Cmd, ui.KeyA) + tt.Type("NaN") + if v != 3 || !has(border(), pixel.Bad) { + t.Fatalf("NaN set %v or left the edge plain", v) + } + tt.Key(ui.Cmd, ui.KeyA) + tt.Type("10") + changes = 0 + tt.Key(0, ui.KeyUp) + if v != 10 || changes != 0 { + t.Fatalf("Up at the max gave %v with %d changes", v, changes) + } +} + +func TestDisabledTextAreaHoldsStill(t *testing.T) { + notes, disabled := "A\nB\nC\nD\nE\nF", false + tt := view(400, 300, nil, func(c *ui.Context) { + input.TextArea(c, "Notes", ¬es, input.TextAreaOptions{Lines: 2, Disabled: disabled}) + }) + disabled = true + tt.Frame() + r := find(t, tt, "Notes") + before := ink(t, tt.Image(), r) + // The caret's line keeps it at the bottom; wheel up. + tt.Scroll(r.X+10, r.Y+10, 0, -64) + if !equal(before, ink(t, tt.Image(), find(t, tt, "Notes"))) { + t.Fatal("a disabled text area scrolled") + } +} diff --git a/input/numberinput.go b/input/numberinput.go new file mode 100644 index 0000000..9a3f0ad --- /dev/null +++ b/input/numberinput.go @@ -0,0 +1,100 @@ +package input + +import ( + "log/slog" + "math" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type NumberInputOptions struct { + Min, Max float64 + // Step is zero for 1. + Step float64 + Width int + Disabled bool +} + +type numberKey struct{} + +type numberState struct { + text string + focused bool + // seen is the value last shown, to notice the caller's. + seen float64 +} + +type NumberInputResult = TextInputResult + +// NumberInput edits *value as text; bad text reddens the edge. +func NumberInput(c *ui.Context, label string, value *float64, o NumberInputOptions) NumberInputResult { + lo, hi := bounds(o.Min, o.Max) + step := o.Step + if step <= 0 { + step = 1 + } + wrap := ui.Box(c) + st := ui.Local(wrap, numberKey{}, func() numberState { return numberState{text: fmtValue(*value), seen: *value} }) + var res TextInputResult + wrap.Children(func() { + k := pixel.K(c) + set := func(v float64) { + if v = max(lo, min(hi, v)); v != *value { + *value, res.Changed = v, true + } + st.text, st.seen = fmtValue(*value), *value + } + bad := func() bool { + v, err := strconv.ParseFloat(st.text, 64) + return err != nil || math.IsNaN(v) || math.IsInf(v, 0) || v < lo || v > hi + } + if *value != st.seen || !st.focused && !bad() && st.text != fmtValue(*value) { + st.text, st.seen = fmtValue(*value), *value + } + var ed pixel.Edit + ed, res.Element = field(c, &st.text, pixel.EditorOptions{Label: label, Disabled: o.Disabled}, o.Width, bad, nil, func() { + ui.Column(c).Children(func() { + if arrowBox(c, k, tinyUp, o.Disabled) { + set(*value + step) + } + if arrowBox(c, k, tinyDown, o.Disabled) { + set(*value - step) + } + }) + }) + if v, err := strconv.ParseFloat(st.text, 64); ed.Changed && !bad() && err == nil && v != *value { + *value, st.seen, res.Changed = v, v, true + } + st.focused = ed.Focused + if ed.Input.Shortcut(0, ui.KeyUp) { + set(*value + step) + } + if ed.Input.Shortcut(0, ui.KeyDown) { + set(*value - step) + } + res.Input, res.Submitted, res.Focused = ed.Input, ed.Submitted, ed.Focused + }) + if res.Changed { + slog.Debug("8bit: number input", "label", label, "value", *value) + } + res.Element = wrap + return res +} + +// arrowBox is a half-height step button the pointer presses. +func arrowBox(c *ui.Context, k pixel.Kit, sp *pixel.Sprite, disabled bool) bool { + b := ui.Box(c).Size(k.D(10), k.D(pixel.LineHeight/2)).Role(ui.RoleNone) + down := b.Pressed() + b.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + dy := 0 + if down { + dy = 1 + } + g.Sprite((g.W-sp.W)/2, (g.H-sp.H)/2+dy, sp, 0, pixel.Faint, 1) + }) + return b.Clicked() && !disabled +} diff --git a/input/passwordinput.go b/input/passwordinput.go new file mode 100644 index 0000000..09af2a5 --- /dev/null +++ b/input/passwordinput.go @@ -0,0 +1,46 @@ +package input + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PasswordInputOptions struct { + Placeholder string + MaxLength int + Width int + Disabled bool + // RevealLabel names the eye button; zero means "Show". + RevealLabel string +} + +type revealKey struct{} + +type PasswordInputResult = TextInputResult + +// PasswordInput masks with ★; the eye button shows the text. +func PasswordInput(c *ui.Context, text string, value *string, o PasswordInputOptions) PasswordInputResult { + wrap := ui.Box(c) + shown := ui.Local(wrap, revealKey{}, func() bool { return false }) + var res TextInputResult + wrap.Children(func() { + mask := '★' + if *shown { + mask = 0 + } + ed, e := field(c, value, pixel.EditorOptions{Label: text, Placeholder: o.Placeholder, MaxLength: o.MaxLength, Mask: mask, Disabled: o.Disabled}, o.Width, nil, nil, func() { + eye := icon.Eye + if *shown { + eye = icon.EyeOff + } + if IconButton(c, eye, label(o.RevealLabel, "Show"), IconButtonOptions{Variant: Ghost, Size: Small, Disabled: o.Disabled}).Clicked { + *shown = !*shown + } + }) + res = result(e, ed, text) + }) + res.Element = wrap + return res +} diff --git a/input/radio.go b/input/radio.go new file mode 100644 index 0000000..821dde4 --- /dev/null +++ b/input/radio.go @@ -0,0 +1,62 @@ +package input + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type RadioOptions struct { + Disabled bool +} + +type RadioResult struct { + Element *ui.Element + Changed bool +} + +// Radio sets *selected to value when chosen. +func Radio[T comparable](c *ui.Context, text string, value T, selected *T, o RadioOptions) RadioResult { + k := pixel.K(c) + was := *selected == value + on := was + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.CheckboxBase(c, &on).Role(ui.RoleRadio).Label(text).Disabled(o.Disabled) + }) + res := RadioResult{Element: shell} + if base.Changed() && !was { + *selected, res.Changed = value, true + slog.Debug("8bit: radio", "label", text) + } + chosen := *selected == value + shell.Size(k.D(12+pixel.Measure(text)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + dot(k.Canvas(p, r), text, chosen, st) + }) + return res +} + +// dot draws the radio, filled when on, and the label. +func dot(g pixel.Canvas, text string, on bool, st pixel.State) { + y := (pixel.LineHeight - 8) / 2 + fill := pixel.Well + if st.Hover { + fill = pixel.Lit + } + g.Rect(2, y, 4, 8, pixel.Edge) + g.Rect(0, y+2, 8, 4, pixel.Edge) + g.Rect(1, y+1, 6, 6, pixel.Edge) + g.Rect(2, y+1, 4, 6, fill) + g.Rect(1, y+2, 6, 4, fill) + if on { + g.Rect(3, y+2, 2, 4, pixel.Accent) + g.Rect(2, y+3, 4, 2, pixel.Accent) + } + ink := pixel.Ink + if st.Disabled { + ink = pixel.Faint + } + g.Text(12, 0, text, ink, 1) + finish(g, st) +} diff --git a/input/radiogroup.go b/input/radiogroup.go new file mode 100644 index 0000000..ddc6b77 --- /dev/null +++ b/input/radiogroup.go @@ -0,0 +1,62 @@ +package input + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type RadioGroupOptions struct { + // Row lays the radios out across. + Row bool + Disabled bool + Label string +} + +type radioKey struct{} + +type RadioGroupResult struct { + Element *ui.Element + Changed bool +} + +// RadioGroup chooses *selected; one Tab stop, arrows choose. +func RadioGroup(c *ui.Context, labels []string, selected *int, o RadioGroupOptions) RadioGroupResult { + k := pixel.K(c) + var e *ui.Element + if o.Row { + e = ui.Row(c).Gap(k.D(10)).FocusGroup(ui.Horizontal) + } else { + e = ui.Column(c).Gap(k.D(2)).FocusGroup(ui.Vertical) + } + res := RadioGroupResult{Element: e} + last := ui.Local(e, radioKey{}, func() int { return -1 }) + focus := -1 + e.Role(ui.RoleRadioGroup).Label(o.Label).Children(func() { + for i, l := range labels { + was := *selected == i + on := was + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.CheckboxBase(c, &on).Role(ui.RoleRadio).Label(l).Disabled(o.Disabled) + }) + if base.FocusVisible() { + focus = i + } + // Focus moved by arrows chooses; entering by Tab does not. + moved := focus == i && *last >= 0 && *last != i + if !was && (base.Changed() || moved) { + *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) { + dot(k.Canvas(p, r), l, *selected == i, st) + }) + } + }) + *last = focus + if res.Changed { + slog.Debug("8bit: radio group", "selected", *selected) + } + return res +} diff --git a/input/rangeslider.go b/input/rangeslider.go new file mode 100644 index 0000000..ed8b516 --- /dev/null +++ b/input/rangeslider.go @@ -0,0 +1,71 @@ +package input + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type RangeSliderOptions struct { + Min, Max float64 + Width int + Disabled bool +} + +type RangeSliderResult = SliderResult + +// RangeSlider sets *low and *high with two uncrossing knobs. +func RangeSlider(c *ui.Context, text string, low, high *float64, o RangeSliderOptions) RangeSliderResult { + k := pixel.K(c) + lo, hi := bounds(o.Min, o.Max) + w := orDef("RangeSlider Width", o.Width, 96) + was := [2]float64{*low, *high} + e := ui.Box(c).Size(k.D(w), k.D(12)).Role(ui.RoleGroup).Label(text) + e.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + a, b := knobAt(*low, lo, hi, g.W-6), knobAt(*high, lo, hi, g.W-6) + g.Frame(0, 4, g.W, 4, pixel.Sunken, pixel.Well) + g.Rect(a+3, 5, b-a, 2, pixel.Accent) + }).Children(func() { + knob(c, k, e, low, rail{lo: lo, hi: *high, label: text + " low", disabled: o.Disabled}, lo, hi, w) + knob(c, k, e, high, rail{lo: *low, hi: hi, label: text + " high", disabled: o.Disabled}, lo, hi, w) + }) + if *low > *high { + panic(fmt.Sprintf("input: RangeSlider low %v above high %v", *low, *high)) + } + changed := was != [2]float64{*low, *high} + if changed { + slog.Debug("8bit: range slider", "label", text, "low", *low, "high", *high) + } + return SliderResult{Element: e, Changed: changed} +} + +// knob is one draggable end, fenced by r.lo and r.hi. +func knob(c *ui.Context, k pixel.Kit, track *ui.Element, v *float64, r rail, lo, hi float64, w int) { + shell, base, st := pixel.Shell(c, func() *ui.Element { + 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 _, _, 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) { + set(*v - step) + } + if base.Shortcut(0, ui.KeyRight) || base.Shortcut(0, ui.KeyUp) { + set(*v + step) + } + } + shell.Absolute().Left(k.D(knobAt(*v, lo, hi, w-6))).Top(k.D(1)).Size(k.D(6), k.D(10)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, 6, 10, pixel.Raised, pixel.Face) + finish(g, st) + }) +} diff --git a/input/searchfield.go b/input/searchfield.go new file mode 100644 index 0000000..f1abfe3 --- /dev/null +++ b/input/searchfield.go @@ -0,0 +1,25 @@ +package input + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SearchFieldOptions struct { + Placeholder string + Width int + Disabled bool +} + +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 != "" { + *value, ed.Changed = "", true + } + return result(e, ed, label) +} diff --git a/input/slider.go b/input/slider.go new file mode 100644 index 0000000..6dc9fb8 --- /dev/null +++ b/input/slider.go @@ -0,0 +1,124 @@ +package input + +import ( + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SliderOptions struct { + // Min and Max are zero for 0 to 1. + Min, Max float64 + // Width is the track in dots; zero means 96. + Width int + Disabled bool +} + +type SliderResult struct { + Element *ui.Element + Changed bool +} + +// Slider sets *value by drag, arrows, Home and End. +func Slider(c *ui.Context, text string, value *float64, o SliderOptions) SliderResult { + k := pixel.K(c) + lo, hi := bounds(o.Min, o.Max) + w := orDef("Slider Width", o.Width, 96) + shell, st, changed := slide(c, value, rail{lo: lo, hi: hi, label: text, disabled: o.Disabled}) + shell.Size(k.D(w), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + x := knobAt(*value, lo, hi, g.W-6) + groove(g, x+3, 4) + g.Frame(x, 1, 6, 10, pixel.Raised, pixel.Face) + finish(g, st) + }) + return report(shell, changed, "slider", text, *value) +} + +// rail maps a slide's pointer and keys to a value. +type rail struct { + lo, hi, step float64 + vertical bool + label string + disabled bool +} + +// slide is a focusable track; pointer and keys set *v. +func slide(c *ui.Context, v *float64, r rail) (*ui.Element, pixel.State, bool) { + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(r.label).Description(fmtValue(*v)).Disabled(r.disabled) + }) + old := *v + top := r.hi + if r.step > 0 { + top = r.lo + math.Floor((r.hi-r.lo)/r.step+1e-9)*r.step + } + set := func(x float64) { + if r.step > 0 { + x = r.lo + math.Round((x-r.lo)/r.step)*r.step + } + *v = max(r.lo, min(top, x)) + } + if !r.disabled { + if _, _, held := base.Dragged(); held { + px, py, _ := base.PointerPosition() + b := base.Bounds() + frac := float64(px / max(1, b.W)) + if r.vertical { + frac = 1 - float64(py/max(1, b.H)) + } + set(r.lo + frac*(r.hi-r.lo)) + } + step := r.step + if step == 0 { + step = (r.hi - r.lo) / 20 + } + for _, kd := range []struct { + key ui.Key + to func() float64 + }{ + {ui.KeyLeft, func() float64 { return *v - step }}, {ui.KeyDown, func() float64 { return *v - step }}, + {ui.KeyRight, func() float64 { return *v + step }}, {ui.KeyUp, func() float64 { return *v + step }}, + {ui.KeyHome, func() float64 { return r.lo }}, {ui.KeyEnd, func() float64 { return r.hi }}, + } { + if base.Shortcut(0, kd.key) { + set(kd.to()) + } + } + } + return shell, st, *v != old +} + +// bounds are lo and hi; zeros mean 0 to 1. +func bounds(lo, hi float64) (float64, float64) { + if lo == 0 && hi == 0 { + return 0, 1 + } + if hi <= lo { + panic(fmt.Sprintf("input: range %v to %v is empty", lo, hi)) + } + return lo, hi +} + +// knobAt is the knob's offset for v along span dots. +func knobAt(v, lo, hi float64, span int) int { + return int(math.Round((v - lo) / (hi - lo) * float64(span))) +} + +// groove is the Well track, filled with Accent to x. +func groove(g pixel.Canvas, x, y int) { + g.Frame(0, y, g.W, 4, pixel.Sunken, pixel.Well) + g.Rect(1, y+1, x-1, 2, pixel.Accent) +} + +// report logs a change and wraps the result. +func report(e *ui.Element, changed bool, kind, text string, v float64) SliderResult { + if changed { + slog.Debug("8bit: "+kind, "label", text, "value", v) + } + return SliderResult{Element: e, Changed: changed} +} diff --git a/input/stepper.go b/input/stepper.go new file mode 100644 index 0000000..716679c --- /dev/null +++ b/input/stepper.go @@ -0,0 +1,103 @@ +package input + +import ( + "fmt" + "log/slog" + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type StepperOptions struct { + Min, Max int + // Step is zero for 1. + Step int + Disabled bool +} + +type StepperResult struct { + Element *ui.Element + Changed bool +} + +// Stepper is ◀ value ▶; held arrows repeat, keys step. +func Stepper(c *ui.Context, text string, value *int, o StepperOptions) StepperResult { + if o.Max < o.Min { + panic(fmt.Sprintf("input: Stepper range %d to %d is empty", o.Min, o.Max)) + } + k := pixel.K(c) + step := orDef("Stepper Step", o.Step, 1) + old := *value + set := func(v int) { *value = max(o.Min, min(o.Max, v)) } + w := max(pixel.Measure(strconv.Itoa(o.Min)), pixel.Measure(strconv.Itoa(o.Max))) + 8 + e := ui.Row(c).Gap(k.D(2)).AlignItems(ui.Center).Children(func() { + set(*value - step*held(c, k, tinyLeft, o.Disabled)) + shell, base, st := pixel.Shell(c, func() *ui.Element { + 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) { + set(*value - step) + } + if base.Shortcut(0, ui.KeyRight) || base.Shortcut(0, ui.KeyUp) { + set(*value + step) + } + } + shell.Size(k.D(w), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + shown := strconv.Itoa(*value) + g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well) + g.Text((g.W-pixel.Measure(shown))/2, 2, shown, pixel.Ink, 1) + finish(g, st) + }) + set(*value + step*held(c, k, tinyRight, o.Disabled)) + base.Description(strconv.Itoa(*value)) + }) + res := StepperResult{Element: e, Changed: *value != old} + if res.Changed { + slog.Debug("8bit: stepper", "label", text, "value", *value) + } + return res +} + +type holdKey struct{} + +type hold struct { + since time.Time + n int +} + +// held counts an arrow's steps: one, then ten a second. +func held(c *ui.Context, k pixel.Kit, sp *pixel.Sprite, disabled bool) int { + b := ui.Box(c).Size(k.D(12), k.D(pixel.LineHeight+4)).Role(ui.RoleNone) + down := b.Pressed() && !disabled + h := ui.Local(b, holdKey{}, func() hold { return hold{} }) + b.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + style, dy := pixel.Raised, 0 + if down { + style, dy = pixel.Sunken, 1 + } + g.Frame(0, 0, g.W, g.H, style, pixel.Face) + g.Sprite((g.W-sp.W)/2, (g.H-sp.H)/2+dy, sp, 0, pixel.Ink, 1) + if disabled { + g.Checker(0, 0, g.W, g.H, pixel.Face) + } + }) + if !down { + *h = hold{} + return 0 + } + k.Tick(c, 10) + if h.since.IsZero() { + *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 + return n +} diff --git a/input/stepslider.go b/input/stepslider.go new file mode 100644 index 0000000..d30121e --- /dev/null +++ b/input/stepslider.go @@ -0,0 +1,46 @@ +package input + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type StepSliderOptions struct { + Min, Max float64 + // Step is zero for a tenth of the range. + Step float64 + Width int + Disabled bool +} + +type StepSliderResult = SliderResult + +// StepSlider snaps to steps, with ticks and its value. +func StepSlider(c *ui.Context, text string, value *float64, o StepSliderOptions) StepSliderResult { + k := pixel.K(c) + lo, hi := bounds(o.Min, o.Max) + step := o.Step + if step <= 0 { + step = (hi - lo) / 10 + } + w := orDef("StepSlider Width", o.Width, 96) + var res SliderResult + row := ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + shell, st, changed := slide(c, value, rail{lo: lo, hi: hi, step: step, label: text, disabled: o.Disabled}) + shell.Size(k.D(w), k.D(16)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + x := knobAt(*value, lo, hi, w-6) + groove(g, x+3, 4) + for v := lo; v <= hi+step/2; v += step { + g.Dot(knobAt(v, lo, hi, w-6)+3, 13, pixel.Faint) + } + g.Frame(x, 1, 6, 10, pixel.Raised, pixel.Face) + finish(g, st) + }) + res = report(shell, changed, "step slider", text, *value) + pixel.Text(c, fmtValue(*value), pixel.TextOptions{}) + }) + res.Element = row + return res +} diff --git a/input/switch.go b/input/switch.go new file mode 100644 index 0000000..62bf6d5 --- /dev/null +++ b/input/switch.go @@ -0,0 +1,49 @@ +package input + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SwitchOptions struct { + Disabled bool +} + +type SwitchResult struct { + Element *ui.Element + Changed bool +} + +// 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(text).Disabled(o.Disabled) }) + 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) + g.Micro(3, 2, "OFF", pixel.Faint, 1) + g.Micro(17, 2, "ON", pixel.Faint, 1) + x, lx, lit := 6, 3, "OFF" + if *on { + x, lx, lit = 19, 17, "ON" + } + g.Micro(lx, 2, lit, pixel.Ink, 1) + g.Rect(x, 8, 3, 5, pixel.Edge) + g.Rect(x-1, 8, 5, 3, pixel.Edge) + g.Rect(x, 9, 3, 1, pixel.Lit) + ink := pixel.Ink + if st.Disabled { + ink = pixel.Faint + } + g.Text(32, 0, text, ink, 1) + finish(g, st) + }) + res := SwitchResult{Element: shell, Changed: base.Changed()} + if res.Changed { + slog.Debug("8bit: switch", "label", text, "on", *on) + } + return res +} diff --git a/input/textarea.go b/input/textarea.go new file mode 100644 index 0000000..810c187 --- /dev/null +++ b/input/textarea.go @@ -0,0 +1,67 @@ +package input + +import ( + "fmt" + "unicode/utf8" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type TextAreaOptions struct { + Placeholder string + // MaxLength caps runes and shows a count. + MaxLength int + // Width is zero for 160 dots, Lines zero for 4. + Width, Lines int + Disabled bool +} + +type areaKey struct{} + +type areaState struct { + scroll ui.ScrollState + line int + text string + // held is last frame's offset, kept while disabled. + held float32 +} + +type TextAreaResult = TextInputResult + +// TextArea wraps lines in its own scroll, following the caret. +func TextArea(c *ui.Context, text string, value *string, o TextAreaOptions) TextAreaResult { + k := pixel.K(c) + lines := orDef("TextArea Lines", o.Lines, 4) + col := ui.Column(c).Width(k.D(orDef("TextArea Width", o.Width, 160))).Gap(k.D(2)) + st := ui.Local(col, areaKey{}, func() areaState { return areaState{line: -1} }) + if o.Disabled { + st.scroll.Y = st.held + } + var ed pixel.Edit + col.Children(func() { + view := ui.Scroll(c).TrackScroll(&st.scroll).Height(k.D(lines*pixel.LineHeight+6)).Padding(k.D(3), k.D(4)) + view.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well) + }).Children(func() { + ed = pixel.Editor(c, value, pixel.EditorOptions{Multiline: true, Label: text, Placeholder: o.Placeholder, MaxLength: o.MaxLength, Disabled: o.Disabled}) + }) + // Keep the caret's line in view after it moves. + if ed.Line != st.line || *value != st.text { + top, bottom := k.D(ed.Line*pixel.LineHeight), k.D((ed.Line+1)*pixel.LineHeight) + if y := max(min(st.scroll.Y, top), bottom-k.D(lines*pixel.LineHeight)); y != st.scroll.Y { + st.scroll.Y = y + view.TrackScroll(&st.scroll) + } + st.line, st.text = ed.Line, *value + } + st.held = st.scroll.Y + if o.MaxLength > 0 { + count := fmt.Sprintf("%d/%d", utf8.RuneCountInString(*value), o.MaxLength) + ui.Row(c).Justify(ui.End).Children(func() { pixel.Text(c, count, pixel.TextOptions{Slot: pixel.Faint}) }) + } + }) + return result(col, ed, text) +} diff --git a/input/textinput.go b/input/textinput.go new file mode 100644 index 0000000..9ee24b9 --- /dev/null +++ b/input/textinput.go @@ -0,0 +1,73 @@ +package input + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type TextInputOptions struct { + Placeholder string + MaxLength int + // Width is in dots; zero means 120. + Width int + Disabled bool +} + +type TextInputResult struct { + // Element is laid out; Input takes the focus. + Element, Input *ui.Element + Changed, Submitted, Focused bool +} + +// TextInput is a one-line pixel field; Enter reports Submitted. +func TextInput(c *ui.Context, label string, value *string, o TextInputOptions) TextInputResult { + ed, e := field(c, value, pixel.EditorOptions{Label: label, Placeholder: o.Placeholder, MaxLength: o.MaxLength, Disabled: o.Disabled}, o.Width, nil, nil, nil) + return result(e, ed, label) +} + +// field frames an editor in a well with extras. +func field(c *ui.Context, value *string, eo pixel.EditorOptions, width int, bad func() bool, icon *pixel.Sprite, after func()) (pixel.Edit, *ui.Element) { + k := pixel.K(c) + var ed pixel.Edit + e := ui.Row(c).Width(k.D(orDef("Width", width, 120))).Height(k.D(pixel.LineHeight+6)).Padding(0, k.D(4)).Gap(k.D(3)).AlignItems(ui.Center) + e.Children(func() { + if icon != nil { + ui.Box(c).Size(k.D(icon.W), k.D(icon.H)).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Sprite(0, 0, icon, 0, pixel.Faint, 1) + }) + } + ed = pixel.Editor(c, value, eo) + ed.Element.Grow(1) + if after != nil { + after() + } + }) + focus := ed.Focused + e.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + edge := pixel.Edge + if bad != nil && bad() { + edge = pixel.Bad + } + g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well) + g.HLine(1, 0, g.W-2, edge) + g.HLine(1, g.H-1, g.W-2, edge) + g.VLine(0, 1, g.H-2, edge) + g.VLine(g.W-1, 1, g.H-2, edge) + if focus { + g.Checker(0, g.H-2, g.W, 1, pixel.Accent) + } + }) + return ed, e +} + +// result wraps an editor's events and logs submits. +func result(e *ui.Element, ed pixel.Edit, label string) TextInputResult { + if ed.Submitted { + slog.Debug("8bit: field submitted", "label", label) + } + return TextInputResult{Element: e, Input: ed.Input, Changed: ed.Changed, Submitted: ed.Submitted, Focused: ed.Focused} +} diff --git a/input/toggle.go b/input/toggle.go new file mode 100644 index 0000000..2d55fff --- /dev/null +++ b/input/toggle.go @@ -0,0 +1,52 @@ +package input + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ToggleOptions struct { + Size Size + Icon *pixel.Sprite + Disabled bool + // Label names it for assistive technology; zero uses text. + Label string +} + +type ToggleResult struct { + Element *ui.Element + Changed bool +} + +// Toggle is a button that stays sunken while *on. +func Toggle(c *ui.Context, text string, on *bool, o ToggleOptions) ToggleResult { + k := pixel.K(c) + h, pad, top, sc := o.Size.metrics() + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.ToggleBase(c, on).Label(label(o.Label, text)).Disabled(o.Disabled) + }) + lead := 0 + if o.Icon != nil { + lead = o.Icon.W + if text != "" { + lead += 3 + } + } + shell.Size(k.D(pixel.Measure(text)*sc+lead+2*pad), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + ink, dy := face(g, Normal, st, *on) + if o.Icon != nil { + g.Sprite(pad, (h-o.Icon.H)/2+dy, o.Icon, 0, ink, 1) + } + g.Text(pad+lead, top+dy, text, ink, sc) + finish(g, st) + }) + res := ToggleResult{Element: shell, Changed: base.Changed()} + if res.Changed { + slog.Debug("8bit: toggle", "label", label(o.Label, text), "on", *on) + } + return res +} diff --git a/input/togglegroup.go b/input/togglegroup.go new file mode 100644 index 0000000..f746d8e --- /dev/null +++ b/input/togglegroup.go @@ -0,0 +1,59 @@ +package input + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ToggleGroupOptions struct { + // Single keeps at most one toggle on. + Single bool + Size Size + Disabled bool + Label string +} + +type ToggleGroupResult struct { + Element *ui.Element + Changed bool +} + +// ToggleGroup joins toggles; on[i] holds each one's state. +func ToggleGroup(c *ui.Context, labels []string, on []bool, o ToggleGroupOptions) ToggleGroupResult { + if len(on) != len(labels) { + panic(fmt.Sprintf("input: ToggleGroup has %d labels and %d states", len(labels), len(on))) + } + k := pixel.K(c) + h, pad, top, sc := o.Size.metrics() + 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) }) + if i > 0 { + shell.Margin(0, 0, 0, k.D(-1)) + } + shell.Size(k.D(pixel.Measure(l)*sc+2*pad), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + ink, dy := face(g, Normal, st, on[i]) + g.Text(pad, top+dy, l, ink, sc) + finish(g, st) + }) + if base.Changed() { + res.Changed = true + if o.Single && on[i] { + for j := range on { + on[j] = j == i + } + } + } + } + }) + if res.Changed { + slog.Debug("8bit: toggle group", "on", on) + } + return res +} diff --git a/internal/archtest/archtest_test.go b/internal/archtest/archtest_test.go index 49619c7..262a8f2 100644 --- a/internal/archtest/archtest_test.go +++ b/internal/archtest/archtest_test.go @@ -39,7 +39,7 @@ var bannedFuncs = set("Text Textf RichText Button PrimaryButton Link MenuButton var bannedMethods = set("Background Border BorderColor BorderWidth BorderStyle Radius Shadow Gradient " + "LinearGradient Opacity Stripes Tooltip Menu ContextMenu Transition Animate AnimateWith Loop Toast " + "ToastAction AnimationFrame After FillPath StrokePath RichText Glyphs FillGradient Stroke StrokeDashed " + - "StrokePathGradient FillPathGradient Material Icon Image") + "StrokePathGradient FillPathGradient Material Icon Image Selected") func set(s string) map[string]bool { out := map[string]bool{} diff --git a/pixel/editor.go b/pixel/editor.go index f61fb18..a2bb4bc 100644 --- a/pixel/editor.go +++ b/pixel/editor.go @@ -23,6 +23,8 @@ type Edit struct { // Element is laid out; Input takes the focus. Element, Input *ui.Element Changed, Submitted, Focused bool + // Line is the caret's line, from 0. + Line int } type editKey struct{} @@ -79,7 +81,7 @@ 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()} + res := Edit{Element: shell, Input: base, Changed: s.changed, Submitted: s.submitted, Focused: base.Focused(), Line: cl} if res.Focused { frameOf(c).typing = true } diff --git a/pixel/editor_test.go b/pixel/editor_test.go index 7a88507..48fb824 100644 --- a/pixel/editor_test.go +++ b/pixel/editor_test.go @@ -128,8 +128,14 @@ func TestEditorMultiline(t *testing.T) { h.tt.Key(0, ui.KeyUp) h.tt.Type("X") h.want("abX\ncd") + if h.last.Line != 0 { + t.Fatalf("caret line %d on the first line", h.last.Line) + } h.tt.Key(0, ui.KeyEnter) h.want("abX\n\ncd") + if h.last.Line != 1 { + t.Fatalf("caret line %d after a newline", h.last.Line) + } h.tt.Key(ui.Cmd, ui.KeyEnter) if h.submits != 1 { t.Fatalf("Cmd+Enter submitted %d times", h.submits) diff --git a/web/deploy.sh b/web/deploy.sh index b8bb44b..2f824df 100755 --- a/web/deploy.sh +++ b/web/deploy.sh @@ -29,5 +29,8 @@ until curl -fs "$site/" | grep -q "$main"; do fi sleep 2 done +# A first fetch warms the edge before the browser tests. +wasm=$(sed -n "s#.*fetch('\\(a/app\\.[0-9a-f]*\\.wasm\\)').*#\\1#p" dist/a/main.*.js) +curl -fs --max-time 120 -o /dev/null "$site/$wasm" BASE_URL=$site npx playwright test echo "deploy: $rev live at $site (version $id)"