Files
8bitMyGoUI/input/slider.go
T
ZacharyZhang-NY b4b4d67b92 Audit keyboard, focus, veils, motion and clocks across all 500
A cross-cutting pass, each fix with a test that failed first.

Veils: MyGo still hands a key to a base that turned off this frame,
so every element key now goes through pixel.Shortcut, which an
archtest enforces. Input handlers record and the build applies them
only while enabled; the editor turns away a veil-frame letter or
copy; value bases get a copy and keep only enabled turns. Popups
give a held focus back to their opener when the veil lifts.

Motion: S5 is enforced at the clock. Every Tick, Since, Step,
Toward and Shot runs at 4-15 fps; blinks keep 1 Hz on 4 fps steps,
fast animations step at 15, and drawn time is quantized to frames.

Clocks stepping back: Since holds at zero without overflow, and
k.Elapsed moves a stored start to now, so nothing goes negative,
runs backward or indexes out of range.

Also: logs count typed text instead of holding it, gallery names
use glyphs the font has, removable rows are keyed, components take
their own Options (archtest), and sweeps over all 508 demos check
veiled keys, clicks, wheels, the clock stepping back and names.
2026-10-12 16:03:04 -04:00

153 lines
4.1 KiB
Go

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 := pixel.Or("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)
g.Finish(st)
})
return report(shell, changed, "slider", text, *value)
}
// RailOptions shape a SlideBase.
type RailOptions struct {
// Zeros mean 0 to 1; a zero Step glides.
Min, Max, Step float64
Vertical bool
Disabled bool
}
// SlideBase is an undrawn slider: pointer and keys set *value.
func SlideBase(c *ui.Context, text string, value *float64, o RailOptions) (shell *ui.Element, st pixel.State, changed bool) {
lo, hi := bounds(o.Min, o.Max)
return slide(c, value, rail{lo: lo, hi: hi, step: o.Step, vertical: o.Vertical, label: text, disabled: o.Disabled})
}
// 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
set := r.snap(v)
// The base, not the option: Inert disables it too.
if !base.IsDisabled() {
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))
}
r.keys(base, v, set)
}
return shell, st, *v != old
}
// snap sets *v rounded to steps and kept in range.
func (r rail) snap(v *float64) func(float64) {
if r.step < 0 {
panic(fmt.Sprintf("input: Step %v is negative", r.step))
}
top := r.hi
if r.step > 0 {
top = r.lo + math.Floor((r.hi-r.lo)/r.step+1e-9)*r.step
}
return 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))
}
}
// keys step *v by arrows; Home and End jump.
func (r rail) keys(base *ui.Element, v *float64, set func(float64)) {
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 pixel.Shortcut(base, 0, kd.key) {
set(kd.to())
}
}
}
// 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}
}