Add input components 066-085; gallery chrome on real components; deploy warms the edge

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 19:05:42 -04:00
1 parent 0247a130f5
commit 095eab2b4c
38 files changed
+2340 -172

No files matched your search

+2
View File
@@ -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.
+2 -4
View File
@@ -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
}
+51
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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"
+32 -28
View File
@@ -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 {
+5 -4
View File
@@ -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{}) })
}
})
}
+24 -19
View File
@@ -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})
})
}
+260
View File
@@ -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)
}
}
+26 -25
View File
@@ -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})
})
})
}
+7 -6
View File
@@ -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])
}
+23 -26
View File
@@ -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
}
+1 -1
View File
@@ -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 }
-56
View File
@@ -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})
}
+92
View File
@@ -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})
}
+52
View File
@@ -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
}
+68
View File
@@ -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)
}
+72
View File
@@ -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
}
+39
View File
@@ -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)
}
+138
View File
@@ -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(`
#..
##.
###
##.
#..
`)
)
+493
View File
@@ -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", &notes, 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", &notes, 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")
}
}
+100
View File
@@ -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
}
+46
View File
@@ -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
}
+62
View File
@@ -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)
}
+62
View File
@@ -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
}
+71
View File
@@ -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)
})
}
+25
View File
@@ -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)
}
+124
View File
@@ -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}
}
+103
View File
@@ -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
}
+46
View File
@@ -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
}
+49
View File
@@ -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
}
+67
View File
@@ -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)
}
+73
View File
@@ -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}
}
+52
View File
@@ -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
}
+59
View File
@@ -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
}
+1 -1
View File
@@ -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{}
+3 -1
View File
@@ -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
}
+6
View File
@@ -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)
+3
View File
@@ -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)"