Add pixel text editor and built-in messages
This commit is contained in:
11 files changed
+1069
-11
No files matched your search
+409
@@ -0,0 +1,409 @@
|
||||
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
|
||||
}
|
||||
|
||||
type Edit struct {
|
||||
Element *ui.Element
|
||||
Changed, Submitted, Focused bool
|
||||
}
|
||||
|
||||
type editKey struct{}
|
||||
|
||||
type editLine struct{ start, end int }
|
||||
|
||||
type editEnv struct {
|
||||
multi 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))
|
||||
}
|
||||
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{} })
|
||||
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, maxLen: o.MaxLength, mask: o.Mask, width: width, dot: k.dot, read: c.ReadClipboard, write: c.WriteClipboard}
|
||||
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 {
|
||||
took := s.handle(ev, env)
|
||||
if s.changed {
|
||||
*value = string(s.text)
|
||||
}
|
||||
return took
|
||||
})
|
||||
res := Edit{Element: shell, Changed: s.changed, Submitted: s.submitted, Focused: base.Focused()}
|
||||
if s.submitted {
|
||||
slog.Debug("8bit: editor submit", "label", o.Label)
|
||||
}
|
||||
s.changed, s.submitted = false, false
|
||||
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(2)%2 == 0 {
|
||||
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 now.Sub(s.pressAt) < 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:
|
||||
if !e.multi || m != 0 {
|
||||
s.submitted = true
|
||||
return true
|
||||
}
|
||||
s.replace([]rune{'\n'}, e.maxLen)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user