Add input components 066-085; gallery chrome on real components; deploy warms the edge
This commit is contained in:
38 files changed
+2340
-172
No files matched your search
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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(`
|
||||
#..
|
||||
##.
|
||||
###
|
||||
##.
|
||||
#..
|
||||
`)
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user