Files
8bitMyGoUI/pixel/editor.go
T
ZacharyZhang-NY 455f5f2a05 Add chat components 321-345; named List rows, inert scroll views
Speech bubbles, a chat log that follows its end, a composer that sends
on Enter, typing dots, chat and channel lists, read receipts,
reactions, emoji and sticker pickers, mentions, date and unread rules,
system lines, member lists, user cards, a mailbox, mail reader and
letter, a notification bell, call controls, a voice meter, attachment
chips, link previews and polls.

pixel's editor takes EnterSends; display's Bubble takes a Fill and
exports EmojiSprite. data.List names its rows, which also lets typing
choose one. layout.ScrollArea and input.TextArea hold still under
Inert, and an inert scroll view leaves the Tab order until it wakes.
2026-10-12 05:02:36 -04:00

423 lines
9.8 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{} })
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: 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, Input: base, Changed: s.changed, Submitted: s.submitted, Focused: base.Focused(), Line: cl}
if res.Focused {
frameOf(c).typing = true
}
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:
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
}