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.
437 lines
10 KiB
Go
437 lines
10 KiB
Go
package pixel
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
)
|
|
|
|
type EditorOptions struct {
|
|
Multiline bool
|
|
MaxLength int
|
|
// Mask hides each rune behind Mask and turns copy off.
|
|
Mask rune
|
|
Placeholder string
|
|
Label string
|
|
Disabled bool
|
|
// EnterSends submits a multi-line field on Enter; Shift+Enter breaks lines.
|
|
EnterSends bool
|
|
}
|
|
|
|
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{}
|
|
|
|
type editLine struct{ start, end int }
|
|
|
|
type editEnv struct {
|
|
multi bool
|
|
sends bool
|
|
maxLen int
|
|
mask rune
|
|
width int
|
|
dot float32
|
|
read func() string
|
|
write func(string)
|
|
}
|
|
|
|
// Editor edits *value in the pixel font.
|
|
func Editor(c *ui.Context, value *string, o EditorOptions) Edit {
|
|
k := K(c)
|
|
if o.MaxLength < 0 {
|
|
panic(fmt.Sprintf("pixel: MaxLength %d", o.MaxLength))
|
|
}
|
|
if o.EnterSends && !o.Multiline {
|
|
panic("pixel: EnterSends needs a multi-line field")
|
|
}
|
|
shell, base, _ := Shell(c, func() *ui.Element {
|
|
return ui.Box(c).Focusable().Role(ui.RoleTextField).Label(o.Label).Disabled(o.Disabled)
|
|
})
|
|
s := ui.Local(base, editKey{}, func() editState { return editState{} })
|
|
// Copies wait here, so a veil's first frame copies nothing.
|
|
if s.clipped && !base.IsDisabled() {
|
|
c.WriteClipboard(s.clip)
|
|
}
|
|
s.clip, s.clipped = "", false
|
|
// A veil's first frame types nothing; callers' writes win.
|
|
if s.changed && base.IsDisabled() && *value == string(s.text) {
|
|
*value = s.kept
|
|
}
|
|
if string(s.text) != *value {
|
|
s.reset(*value)
|
|
}
|
|
if o.Mask == 0 {
|
|
base.Description(*value)
|
|
}
|
|
width, check := WrapWidth(c, shell)
|
|
env := editEnv{multi: o.Multiline, sends: o.EnterSends, maxLen: o.MaxLength, mask: o.Mask, width: width, dot: k.dot, read: c.ReadClipboard, write: func(v string) { s.clip, s.clipped = v, true }}
|
|
shown := s.view(o.Mask, true)
|
|
caret := s.caret + s.composeCaret
|
|
lines := editLayout(shown, width, o.Multiline)
|
|
cx, cl := caretAt(lines, shown, caret)
|
|
if o.Multiline {
|
|
shell.Height(k.D(len(lines) * LineHeight))
|
|
} else {
|
|
shell.Height(k.D(LineHeight))
|
|
s.keepVisible(cx, Measure(string(shown)), width)
|
|
}
|
|
scroll, sa, sb := s.scroll, s.caret, s.anchor
|
|
sa, sb = min(sa, sb), max(sa, sb)
|
|
ca, cb := s.caret, s.caret+len(s.compose)
|
|
composing := cb > ca
|
|
base.TextCaret(ui.Rect{X: k.D(cx - scroll), Y: k.D(cl * LineHeight), W: k.dot, H: k.D(LineHeight)})
|
|
base.HandleInput(func(ev ui.InputEvent) bool {
|
|
// The caller may have changed the value since this frame.
|
|
if string(s.text) != *value {
|
|
s.reset(*value)
|
|
}
|
|
took := s.handle(ev, env)
|
|
if s.changed {
|
|
*value = string(s.text)
|
|
}
|
|
return took
|
|
})
|
|
// A send from the frame a veil falls is dropped.
|
|
res := Edit{Element: shell, Input: base, Changed: s.changed, Submitted: s.submitted && !base.IsDisabled(), Focused: base.Focused(), Line: cl}
|
|
if res.Focused {
|
|
frameOf(c).typing = true
|
|
}
|
|
if res.Submitted {
|
|
slog.Debug("8bit: editor submit", "label", o.Label)
|
|
}
|
|
s.changed, s.submitted, s.kept = false, false, *value
|
|
shell.Draw(func(p *ui.Painter, r ui.Rect) {
|
|
check(r)
|
|
g := k.Canvas(p, r)
|
|
g.Clip(0, 0, g.W, g.H, func() {
|
|
if len(shown) == 0 {
|
|
g.Text(0, 0, o.Placeholder, Faint, 1)
|
|
}
|
|
ink := Ink
|
|
if o.Disabled {
|
|
ink = Faint
|
|
}
|
|
for li, l := range lines {
|
|
y, x0 := li*LineHeight, -scroll
|
|
g.Text(x0, y, string(shown[l.start:l.end]), ink, 1)
|
|
if a, b := max(sa, l.start), min(sb, l.end); a < b && !composing {
|
|
xa := x0 + Measure(string(shown[l.start:a]))
|
|
g.Rect(xa, y, Measure(string(shown[a:b])), LineHeight, Accent)
|
|
g.Text(xa, y, string(shown[a:b]), AccentInk, 1)
|
|
}
|
|
if a, b := max(ca, l.start), min(cb, l.end); a < b {
|
|
xa := x0 + Measure(string(shown[l.start:a]))
|
|
for i := range Measure(string(shown[a:b])) {
|
|
if i%2 == 0 {
|
|
g.Dot(xa+i, y+LineHeight-1, Accent)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if res.Focused && !o.Disabled && g.Step(4)%4 < 2 {
|
|
g.VLine(cx-scroll, cl*LineHeight+1, LineHeight-2, Ink)
|
|
}
|
|
})
|
|
})
|
|
return res
|
|
}
|
|
|
|
// view is the shown runes: masked, with any composition.
|
|
func (s *editState) view(mask rune, composing bool) []rune {
|
|
out := make([]rune, 0, len(s.text)+len(s.compose))
|
|
for _, r := range s.text {
|
|
if mask != 0 && r != '\n' {
|
|
r = mask
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
if composing {
|
|
c := s.compose
|
|
if mask != 0 {
|
|
c = slices.Repeat([]rune{mask}, len(c))
|
|
}
|
|
out = slices.Insert(out, s.caret, c...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *editState) keepVisible(cx, total, width int) {
|
|
if width <= 0 {
|
|
s.scroll = 0
|
|
return
|
|
}
|
|
if cx-s.scroll > width-1 {
|
|
s.scroll = cx - width + 1
|
|
}
|
|
s.scroll = max(0, min(s.scroll, cx, total-width+1))
|
|
}
|
|
|
|
// editLayout wraps a dot short, leaving the caret its column.
|
|
func editLayout(rs []rune, width int, multi bool) []editLine {
|
|
if !multi {
|
|
return []editLine{{0, len(rs)}}
|
|
}
|
|
width = max(0, width-1)
|
|
var out []editLine
|
|
start := 0
|
|
for i := 0; i <= len(rs); i++ {
|
|
if i == len(rs) || rs[i] == '\n' {
|
|
for _, r := range wrapRanges(rs[start:i], width) {
|
|
out = append(out, editLine{start + r[0], start + r[1]})
|
|
}
|
|
start = i + 1
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// caretAt is the caret's x in dots and its line.
|
|
func caretAt(lines []editLine, rs []rune, idx int) (int, int) {
|
|
li := 0
|
|
for i, l := range lines {
|
|
if l.start <= idx {
|
|
li = i
|
|
}
|
|
}
|
|
l := lines[li]
|
|
return Measure(string(rs[l.start:min(idx, l.end)])), li
|
|
}
|
|
|
|
// hitLine is the rune boundary nearest x on line li.
|
|
func hitLine(lines []editLine, rs []rune, li, x int) int {
|
|
l := lines[li]
|
|
pos := 0
|
|
for i := l.start; i < l.end; i++ {
|
|
adv := glyphOf(rs[i]).adv
|
|
if x < pos+adv/2 {
|
|
return i
|
|
}
|
|
pos += adv
|
|
}
|
|
return lineEnd(lines, li)
|
|
}
|
|
|
|
// lineEnd keeps a wrapped line's caret on that line.
|
|
func lineEnd(lines []editLine, li int) int {
|
|
l := lines[li]
|
|
if li+1 < len(lines) && lines[li+1].start == l.end && l.end > l.start {
|
|
return l.end - 1
|
|
}
|
|
return l.end
|
|
}
|
|
|
|
func (s *editState) hit(e editEnv, x, y float32) int {
|
|
rs := s.view(e.mask, false)
|
|
lines := editLayout(rs, e.width, e.multi)
|
|
li := max(0, min(int(y/e.dot)/LineHeight, len(lines)-1))
|
|
return hitLine(lines, rs, li, int(x/e.dot)+s.scroll)
|
|
}
|
|
|
|
func (s *editState) handle(ev ui.InputEvent, e editEnv) bool {
|
|
switch ev.Kind {
|
|
case ui.InputText:
|
|
s.compose, s.composeCaret = nil, 0
|
|
s.replace(clean(ev.Text, e.multi), e.maxLen)
|
|
return true
|
|
case ui.InputCompose:
|
|
if ev.Text == "" {
|
|
s.compose, s.composeCaret = nil, 0
|
|
return true
|
|
}
|
|
if a, b := s.span(); len(s.compose) == 0 && a != b {
|
|
s.erase(a, b)
|
|
}
|
|
s.compose = []rune(ev.Text)
|
|
s.composeCaret = max(0, min(ev.Caret, len(s.compose)))
|
|
return true
|
|
case ui.InputCommand:
|
|
return s.command(ev.Text, e)
|
|
case ui.InputKeyDown:
|
|
return s.key(ev, e)
|
|
case ui.InputPointerDown:
|
|
if ev.Button != 0 {
|
|
return false
|
|
}
|
|
s.compose, s.composeCaret = nil, 0
|
|
s.press(ev, e)
|
|
return true
|
|
case ui.InputPointerMove:
|
|
if !s.dragging || ev.Button != 0 {
|
|
return false
|
|
}
|
|
s.move(s.hit(e, ev.X, ev.Y), true)
|
|
return true
|
|
case ui.InputPointerUp:
|
|
was := s.dragging
|
|
s.dragging = false
|
|
return was
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *editState) press(ev ui.InputEvent, e editEnv) {
|
|
now := time.Now()
|
|
if d := now.Sub(s.pressAt); d >= 0 && d < 500*time.Millisecond && abs32(ev.X-s.pressX) < 5 && abs32(ev.Y-s.pressY) < 5 {
|
|
s.clicks++
|
|
} else {
|
|
s.clicks = 1
|
|
}
|
|
s.pressAt, s.pressX, s.pressY = now, ev.X, ev.Y
|
|
i := s.hit(e, ev.X, ev.Y)
|
|
switch clicks := max(ev.Clicks, s.clicks); {
|
|
case clicks == 2:
|
|
s.anchor, s.caret = wordAt(s.text, i)
|
|
case clicks >= 3:
|
|
s.anchor, s.caret = 0, len(s.text)
|
|
default:
|
|
s.move(i, ev.Mods&ui.Shift != 0)
|
|
s.dragging = true
|
|
}
|
|
}
|
|
|
|
func (s *editState) command(name string, e editEnv) bool {
|
|
switch name {
|
|
case "copy":
|
|
if a, b := s.span(); e.mask == 0 && a != b {
|
|
e.write(s.selected())
|
|
}
|
|
case "cut":
|
|
if a, b := s.span(); e.mask == 0 && a != b {
|
|
e.write(s.selected())
|
|
s.erase(a, b)
|
|
}
|
|
case "paste":
|
|
s.replace(clean(e.read(), e.multi), e.maxLen)
|
|
case "selectAll":
|
|
s.anchor, s.caret = 0, len(s.text)
|
|
case "undo":
|
|
s.restore(&s.undo, &s.redo)
|
|
case "redo":
|
|
s.restore(&s.redo, &s.undo)
|
|
case "delete":
|
|
a, b := s.span()
|
|
s.erase(a, b)
|
|
default:
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func abs32(v float32) float32 {
|
|
if v < 0 {
|
|
return -v
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (s *editState) key(ev ui.InputEvent, e editEnv) bool {
|
|
m, shift := ev.Mods&^ui.Shift, ev.Mods&ui.Shift != 0
|
|
if m == ui.Cmd {
|
|
switch ev.Key {
|
|
case ui.KeyA:
|
|
return s.command("selectAll", e)
|
|
case ui.KeyC:
|
|
return s.command("copy", e)
|
|
case ui.KeyX:
|
|
return s.command("cut", e)
|
|
case ui.KeyV:
|
|
return s.command("paste", e)
|
|
case ui.KeyZ:
|
|
if shift {
|
|
return s.command("redo", e)
|
|
}
|
|
return s.command("undo", e)
|
|
case ui.KeyY:
|
|
return s.command("redo", e)
|
|
case ui.KeyEnter:
|
|
s.submitted = true
|
|
return true
|
|
}
|
|
}
|
|
word, line := m&(ui.Alt|ui.Ctrl) != 0 && m&ui.Super == 0, m == ui.Super
|
|
rs := s.view(e.mask, false)
|
|
lines := editLayout(rs, e.width, e.multi)
|
|
x, li := caretAt(lines, rs, s.caret)
|
|
a, b := s.span()
|
|
switch ev.Key {
|
|
case ui.KeyLeft:
|
|
switch {
|
|
case a != b && !shift && m == 0:
|
|
s.move(a, false)
|
|
case word:
|
|
s.move(wordLeft(s.text, s.caret), shift)
|
|
case line:
|
|
s.move(lines[li].start, shift)
|
|
default:
|
|
s.move(s.caret-1, shift)
|
|
}
|
|
case ui.KeyRight:
|
|
switch {
|
|
case a != b && !shift && m == 0:
|
|
s.move(b, false)
|
|
case word:
|
|
s.move(wordRight(s.text, s.caret), shift)
|
|
case line:
|
|
s.move(lineEnd(lines, li), shift)
|
|
default:
|
|
s.move(s.caret+1, shift)
|
|
}
|
|
case ui.KeyUp, ui.KeyDown:
|
|
to, end := li+1, len(s.text)
|
|
if ev.Key == ui.KeyUp {
|
|
to, end = li-1, 0
|
|
}
|
|
if !e.multi || line || to < 0 || to >= len(lines) {
|
|
s.move(end, shift)
|
|
} else {
|
|
s.move(hitLine(lines, rs, to, x), shift)
|
|
}
|
|
case ui.KeyHome:
|
|
to := lines[li].start
|
|
if m&ui.Ctrl != 0 {
|
|
to = 0
|
|
}
|
|
s.move(to, shift)
|
|
case ui.KeyEnd:
|
|
to := lineEnd(lines, li)
|
|
if m&ui.Ctrl != 0 {
|
|
to = len(s.text)
|
|
}
|
|
s.move(to, shift)
|
|
case ui.KeyBackspace:
|
|
switch {
|
|
case word:
|
|
s.eraseOr(wordLeft(s.text, s.caret), s.caret)
|
|
case line:
|
|
s.eraseOr(lines[li].start, s.caret)
|
|
default:
|
|
s.eraseOr(s.caret-1, s.caret)
|
|
}
|
|
case ui.KeyDelete:
|
|
if word {
|
|
s.eraseOr(s.caret, wordRight(s.text, s.caret))
|
|
} else {
|
|
s.eraseOr(s.caret, s.caret+1)
|
|
}
|
|
case ui.KeyEnter:
|
|
brk := e.multi && m == 0 && (shift || !e.sends)
|
|
if !brk {
|
|
s.submitted = true
|
|
return true
|
|
}
|
|
s.replace([]rune{'\n'}, e.maxLen)
|
|
default:
|
|
return false
|
|
}
|
|
return true
|
|
}
|