mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 15:45:38 +00:00
593 lines
12 KiB
Go
593 lines
12 KiB
Go
// SPDX-License-Identifier: Unlicense OR MIT
|
|
|
|
package text
|
|
|
|
import (
|
|
"image"
|
|
"image/color"
|
|
"math"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"gioui.org/ui"
|
|
"gioui.org/ui/draw"
|
|
"gioui.org/ui/gesture"
|
|
"gioui.org/ui/input"
|
|
"gioui.org/ui/key"
|
|
"gioui.org/ui/layout"
|
|
"gioui.org/ui/pointer"
|
|
|
|
"golang.org/x/image/math/fixed"
|
|
)
|
|
|
|
type Editor struct {
|
|
Config *ui.Config
|
|
Inputs input.Events
|
|
Face Face
|
|
Alignment Alignment
|
|
SingleLine bool
|
|
Submit bool
|
|
|
|
Material ui.BlockOp
|
|
Hint string
|
|
HintMaterial ui.BlockOp
|
|
|
|
oldCfg ui.Config
|
|
blinkStart time.Time
|
|
focused bool
|
|
rr editBuffer
|
|
maxWidth int
|
|
viewSize image.Point
|
|
valid bool
|
|
lines []Line
|
|
dims layout.Dimens
|
|
padTop, padBottom int
|
|
padLeft, padRight int
|
|
requestFocus bool
|
|
|
|
it lineIterator
|
|
|
|
// carXOff is the offset to the current caret
|
|
// position when moving between lines.
|
|
carXOff fixed.Int26_6
|
|
|
|
scroller gesture.Scroll
|
|
scrollOff image.Point
|
|
|
|
clicker gesture.Click
|
|
}
|
|
|
|
type EditorEvent interface {
|
|
isEditorEvent()
|
|
}
|
|
|
|
type ChangeEvent struct{}
|
|
type SubmitEvent struct{}
|
|
|
|
const (
|
|
blinksPerSecond = 1
|
|
maxBlinkDuration = 10 * time.Second
|
|
)
|
|
|
|
func (s ChangeEvent) isEditorEvent() {}
|
|
func (s SubmitEvent) isEditorEvent() {}
|
|
|
|
func (e *Editor) Next() (EditorEvent, bool) {
|
|
if cfg := *e.Config; cfg != e.oldCfg {
|
|
e.invalidate()
|
|
e.oldCfg = cfg
|
|
}
|
|
sbounds := e.scrollBounds()
|
|
var smin, smax int
|
|
var axis gesture.Axis
|
|
if e.SingleLine {
|
|
axis = gesture.Horizontal
|
|
smin, smax = sbounds.Min.X, sbounds.Max.X
|
|
} else {
|
|
axis = gesture.Vertical
|
|
smin, smax = sbounds.Min.Y, sbounds.Max.Y
|
|
}
|
|
sdist := e.scroller.Scroll(e.Config, e.Inputs, axis)
|
|
var soff int
|
|
if e.SingleLine {
|
|
e.scrollOff.X += sdist
|
|
soff = e.scrollOff.X
|
|
} else {
|
|
e.scrollOff.Y += sdist
|
|
soff = e.scrollOff.Y
|
|
}
|
|
scrollTo := false
|
|
for {
|
|
evt, ok := e.clicker.Next(e.Inputs)
|
|
if !ok {
|
|
break
|
|
}
|
|
switch {
|
|
case evt.Type == gesture.TypePress && evt.Source == pointer.Mouse,
|
|
evt.Type == gesture.TypeClick && evt.Source == pointer.Touch:
|
|
scrollTo = true
|
|
e.blinkStart = e.Config.Now
|
|
e.moveCoord(image.Point{
|
|
X: int(math.Round(float64(evt.Position.X))),
|
|
Y: int(math.Round(float64(evt.Position.Y))),
|
|
})
|
|
e.requestFocus = true
|
|
}
|
|
}
|
|
stop := (sdist > 0 && soff >= smax) || (sdist < 0 && soff <= smin)
|
|
for {
|
|
ke, ok := e.Inputs.Next(e)
|
|
if !ok {
|
|
break
|
|
}
|
|
e.blinkStart = e.Config.Now
|
|
switch ke := ke.(type) {
|
|
case key.FocusEvent:
|
|
e.focused = ke.Focus
|
|
case key.ChordEvent:
|
|
if !e.focused {
|
|
break
|
|
}
|
|
if e.Submit && (ke.Name == key.NameReturn || ke.Name == key.NameEnter) {
|
|
if !ke.Modifiers.Contain(key.ModShift) {
|
|
return SubmitEvent{}, true
|
|
}
|
|
}
|
|
if e.command(ke) {
|
|
stop = true
|
|
scrollTo = true
|
|
}
|
|
case key.EditEvent:
|
|
stop = true
|
|
scrollTo = true
|
|
e.append(ke.Text)
|
|
}
|
|
if e.rr.Changed() {
|
|
return ChangeEvent{}, true
|
|
}
|
|
}
|
|
if sdist == 0 && scrollTo {
|
|
e.scrollToCaret()
|
|
}
|
|
if stop {
|
|
e.scroller.Stop()
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (e *Editor) caretWidth() fixed.Int26_6 {
|
|
oneDp := int(e.Config.Val(ui.Dp(1)) + .5)
|
|
return fixed.Int26_6(oneDp * 64)
|
|
}
|
|
|
|
func (e *Editor) Layout(ops *ui.Ops, cs layout.Constraints) layout.Dimens {
|
|
for {
|
|
if _, ok := e.Next(); !ok {
|
|
break
|
|
}
|
|
}
|
|
twoDp := int(e.Config.Val(ui.Dp(2)) + 0.5)
|
|
e.padLeft, e.padRight = twoDp, twoDp
|
|
maxWidth := cs.Width.Max
|
|
if e.SingleLine {
|
|
maxWidth = ui.Inf
|
|
}
|
|
if maxWidth != ui.Inf {
|
|
maxWidth -= e.padLeft + e.padRight
|
|
}
|
|
if maxWidth != e.maxWidth {
|
|
e.maxWidth = maxWidth
|
|
e.invalidate()
|
|
}
|
|
|
|
e.layout()
|
|
lines, size := e.lines, e.dims.Size
|
|
e.viewSize = cs.Constrain(size)
|
|
|
|
carLine, _, carX, carY := e.layoutCaret()
|
|
|
|
off := image.Point{
|
|
X: -e.scrollOff.X + e.padLeft,
|
|
Y: -e.scrollOff.Y + e.padTop,
|
|
}
|
|
clip := image.Rectangle{
|
|
Min: image.Point{X: 0, Y: 0},
|
|
Max: image.Point{X: e.viewSize.X, Y: e.viewSize.Y},
|
|
}
|
|
key.HandlerOp{Key: e, Focus: e.requestFocus}.Add(ops)
|
|
e.requestFocus = false
|
|
e.it = lineIterator{
|
|
Lines: lines,
|
|
Clip: clip,
|
|
Alignment: e.Alignment,
|
|
Width: e.viewWidth(),
|
|
Offset: off,
|
|
}
|
|
ui.PushOp{}.Add(ops)
|
|
// Apply material. Set a default color in case the material is empty.
|
|
if e.rr.len() > 0 {
|
|
draw.ColorOp{Color: color.RGBA{A: 0xff}}.Add(ops)
|
|
e.Material.Add(ops)
|
|
} else {
|
|
draw.ColorOp{Color: color.RGBA{A: 0xaa}}.Add(ops)
|
|
e.HintMaterial.Add(ops)
|
|
}
|
|
for {
|
|
str, lineOff, ok := e.it.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
ui.PushOp{}.Add(ops)
|
|
ui.TransformOp{Transform: ui.Offset(lineOff)}.Add(ops)
|
|
e.Face.Path(str).Add(ops)
|
|
draw.DrawOp{Rect: toRectF(clip).Sub(lineOff)}.Add(ops)
|
|
ui.PopOp{}.Add(ops)
|
|
}
|
|
if e.focused {
|
|
now := e.Config.Now
|
|
dt := now.Sub(e.blinkStart)
|
|
blinking := dt < maxBlinkDuration
|
|
const timePerBlink = time.Second / blinksPerSecond
|
|
nextBlink := now.Add(timePerBlink/2 - dt%(timePerBlink/2))
|
|
on := !blinking || dt%timePerBlink < timePerBlink/2
|
|
if on {
|
|
carWidth := e.caretWidth()
|
|
carX -= carWidth / 2
|
|
carAsc, carDesc := -lines[carLine].Bounds.Min.Y, lines[carLine].Bounds.Max.Y
|
|
carRect := image.Rectangle{
|
|
Min: image.Point{X: carX.Ceil(), Y: carY - carAsc.Ceil()},
|
|
Max: image.Point{X: carX.Ceil() + carWidth.Ceil(), Y: carY + carDesc.Ceil()},
|
|
}
|
|
carRect = carRect.Add(image.Point{
|
|
X: -e.scrollOff.X + e.padLeft,
|
|
Y: -e.scrollOff.Y + e.padTop,
|
|
})
|
|
carRect = clip.Intersect(carRect)
|
|
if !carRect.Empty() {
|
|
draw.DrawOp{Rect: toRectF(carRect)}.Add(ops)
|
|
}
|
|
}
|
|
if blinking {
|
|
redraw := ui.InvalidateOp{At: nextBlink}
|
|
redraw.Add(ops)
|
|
}
|
|
}
|
|
ui.PopOp{}.Add(ops)
|
|
|
|
baseline := e.padTop + e.dims.Baseline
|
|
pointer.AreaRect(e.viewSize).Add(ops)
|
|
e.scroller.Add(ops)
|
|
e.clicker.Add(ops)
|
|
return layout.Dimens{Size: e.viewSize, Baseline: baseline}
|
|
}
|
|
|
|
func (e *Editor) layout() {
|
|
e.adjustScroll()
|
|
if e.valid {
|
|
return
|
|
}
|
|
e.layoutText()
|
|
e.valid = true
|
|
}
|
|
|
|
func (e *Editor) scrollBounds() image.Rectangle {
|
|
var b image.Rectangle
|
|
if e.SingleLine {
|
|
if len(e.lines) > 0 {
|
|
b.Min.X = align(e.Alignment, e.lines[0].Width, e.viewWidth()).Floor()
|
|
if b.Min.X > 0 {
|
|
b.Min.X = 0
|
|
}
|
|
}
|
|
b.Max.X = e.dims.Size.X + b.Min.X - e.viewSize.X
|
|
} else {
|
|
b.Max.Y = e.dims.Size.Y - e.viewSize.Y
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (e *Editor) adjustScroll() {
|
|
b := e.scrollBounds()
|
|
if e.scrollOff.X > b.Max.X {
|
|
e.scrollOff.X = b.Max.X
|
|
}
|
|
if e.scrollOff.X < b.Min.X {
|
|
e.scrollOff.X = b.Min.X
|
|
}
|
|
if e.scrollOff.Y > b.Max.Y {
|
|
e.scrollOff.Y = b.Max.Y
|
|
}
|
|
if e.scrollOff.Y < b.Min.Y {
|
|
e.scrollOff.Y = b.Min.Y
|
|
}
|
|
}
|
|
|
|
func (e *Editor) moveCoord(pos image.Point) {
|
|
e.layout()
|
|
var (
|
|
prevDesc fixed.Int26_6
|
|
carLine int
|
|
y int
|
|
)
|
|
for _, l := range e.lines {
|
|
y += (prevDesc + l.Ascent).Ceil()
|
|
prevDesc = l.Descent
|
|
if y+prevDesc.Ceil() >= pos.Y+e.scrollOff.Y-e.padTop {
|
|
break
|
|
}
|
|
carLine++
|
|
}
|
|
x := fixed.I(pos.X + e.scrollOff.X - e.padLeft)
|
|
e.moveToLine(x, carLine)
|
|
}
|
|
|
|
func (e *Editor) layoutText() {
|
|
s := e.rr.String()
|
|
if s == "" {
|
|
s = e.Hint
|
|
}
|
|
textLayout := e.Face.Layout(s, LayoutOptions{SingleLine: e.SingleLine, MaxWidth: e.maxWidth})
|
|
lines := textLayout.Lines
|
|
dims := linesDimens(lines)
|
|
for i := 0; i < len(lines)-1; i++ {
|
|
s := lines[i].Text.String
|
|
// To avoid layout flickering while editing, assume a soft newline takes
|
|
// up all available space.
|
|
if len(s) > 0 {
|
|
r, _ := utf8.DecodeLastRuneInString(s)
|
|
if !IsNewline(r) {
|
|
dims.Size.X = e.maxWidth
|
|
break
|
|
}
|
|
}
|
|
}
|
|
padTop, padBottom := textPadding(lines)
|
|
dims.Size.Y += padTop + padBottom
|
|
dims.Size.X += e.padLeft + e.padRight
|
|
e.padTop = padTop
|
|
e.padBottom = padBottom
|
|
e.lines, e.dims = lines, dims
|
|
}
|
|
|
|
func (e *Editor) viewWidth() int {
|
|
return e.viewSize.X - e.padLeft - e.padRight
|
|
}
|
|
|
|
func (e *Editor) layoutCaret() (carLine, carCol int, x fixed.Int26_6, y int) {
|
|
e.layout()
|
|
var idx int
|
|
var prevDesc fixed.Int26_6
|
|
loop:
|
|
for carLine = 0; carLine < len(e.lines); carLine++ {
|
|
l := e.lines[carLine]
|
|
y += (prevDesc + l.Ascent).Ceil()
|
|
prevDesc = l.Descent
|
|
if carLine == len(e.lines)-1 || idx+len(l.Text.String) > e.rr.caret {
|
|
str := l.Text.String
|
|
for _, adv := range l.Text.Advances {
|
|
if idx == e.rr.caret {
|
|
break loop
|
|
}
|
|
x += adv
|
|
_, s := utf8.DecodeRuneInString(str)
|
|
idx += s
|
|
str = str[s:]
|
|
carCol++
|
|
}
|
|
break
|
|
}
|
|
idx += len(l.Text.String)
|
|
}
|
|
x += align(e.Alignment, e.lines[carLine].Width, e.viewWidth())
|
|
return
|
|
}
|
|
|
|
func (e *Editor) invalidate() {
|
|
e.valid = false
|
|
}
|
|
|
|
func (e *Editor) deleteRune() {
|
|
e.rr.deleteRune()
|
|
e.carXOff = 0
|
|
e.invalidate()
|
|
}
|
|
|
|
func (e *Editor) deleteRuneForward() {
|
|
e.rr.deleteRuneForward()
|
|
e.carXOff = 0
|
|
e.invalidate()
|
|
}
|
|
|
|
func (e *Editor) Text() string {
|
|
return e.rr.String()
|
|
}
|
|
|
|
func (e *Editor) SetText(s string) {
|
|
e.rr = editBuffer{}
|
|
e.carXOff = 0
|
|
e.prepend(s)
|
|
}
|
|
|
|
func (e *Editor) append(s string) {
|
|
e.prepend(s)
|
|
e.rr.caret += len(s)
|
|
}
|
|
|
|
func (e *Editor) prepend(s string) {
|
|
e.rr.prepend(s)
|
|
e.carXOff = 0
|
|
e.invalidate()
|
|
}
|
|
|
|
func (e *Editor) movePages(pages int) {
|
|
e.layout()
|
|
_, _, carX, carY := e.layoutCaret()
|
|
y := carY + pages*e.viewSize.Y
|
|
var (
|
|
prevDesc fixed.Int26_6
|
|
carLine2 int
|
|
)
|
|
y2 := e.lines[0].Ascent.Ceil()
|
|
for i := 1; i < len(e.lines); i++ {
|
|
if y2 >= y {
|
|
break
|
|
}
|
|
l := e.lines[i]
|
|
h := (prevDesc + l.Ascent).Ceil()
|
|
prevDesc = l.Descent
|
|
if y2+h-y >= y-y2 {
|
|
break
|
|
}
|
|
y2 += h
|
|
carLine2++
|
|
}
|
|
e.carXOff = e.moveToLine(carX+e.carXOff, carLine2)
|
|
}
|
|
|
|
func (e *Editor) moveToLine(carX fixed.Int26_6, carLine2 int) fixed.Int26_6 {
|
|
e.layout()
|
|
carLine, carCol, _, _ := e.layoutCaret()
|
|
if carLine2 < 0 {
|
|
carLine2 = 0
|
|
}
|
|
if carLine2 >= len(e.lines) {
|
|
carLine2 = len(e.lines) - 1
|
|
}
|
|
// Move to start of line.
|
|
for i := carCol - 1; i >= 0; i-- {
|
|
_, s := e.rr.runeBefore(e.rr.caret)
|
|
e.rr.caret -= s
|
|
}
|
|
if carLine2 != carLine {
|
|
// Move to start of line2.
|
|
if carLine2 > carLine {
|
|
for i := carLine; i < carLine2; i++ {
|
|
e.rr.caret += len(e.lines[i].Text.String)
|
|
}
|
|
} else {
|
|
for i := carLine - 1; i >= carLine2; i-- {
|
|
e.rr.caret -= len(e.lines[i].Text.String)
|
|
}
|
|
}
|
|
}
|
|
l2 := e.lines[carLine2]
|
|
carX2 := align(e.Alignment, l2.Width, e.viewWidth())
|
|
// Only move past the end of the last line
|
|
end := 0
|
|
if carLine2 < len(e.lines)-1 {
|
|
end = 1
|
|
}
|
|
// Move to rune closest to previous horizontal position.
|
|
for i := 0; i < len(l2.Text.Advances)-end; i++ {
|
|
adv := l2.Text.Advances[i]
|
|
if carX2 >= carX {
|
|
break
|
|
}
|
|
if carX2+adv-carX >= carX-carX2 {
|
|
break
|
|
}
|
|
carX2 += adv
|
|
_, s := e.rr.runeAt(e.rr.caret)
|
|
e.rr.caret += s
|
|
}
|
|
return carX - carX2
|
|
}
|
|
|
|
func (e *Editor) moveLeft() {
|
|
e.rr.moveLeft()
|
|
e.carXOff = 0
|
|
}
|
|
|
|
func (e *Editor) moveRight() {
|
|
e.rr.moveRight()
|
|
e.carXOff = 0
|
|
}
|
|
|
|
func (e *Editor) moveStart() {
|
|
carLine, carCol, x, _ := e.layoutCaret()
|
|
advances := e.lines[carLine].Text.Advances
|
|
for i := carCol - 1; i >= 0; i-- {
|
|
_, s := e.rr.runeBefore(e.rr.caret)
|
|
e.rr.caret -= s
|
|
x -= advances[i]
|
|
}
|
|
e.carXOff = -x
|
|
}
|
|
|
|
func (e *Editor) moveEnd() {
|
|
carLine, carCol, x, _ := e.layoutCaret()
|
|
l := e.lines[carLine]
|
|
// Only move past the end of the last line
|
|
end := 0
|
|
if carLine < len(e.lines)-1 {
|
|
end = 1
|
|
}
|
|
for i := carCol; i < len(l.Text.Advances)-end; i++ {
|
|
adv := l.Text.Advances[i]
|
|
_, s := e.rr.runeAt(e.rr.caret)
|
|
e.rr.caret += s
|
|
x += adv
|
|
}
|
|
a := align(e.Alignment, l.Width, e.viewWidth())
|
|
e.carXOff = l.Width + a - x
|
|
}
|
|
|
|
func (e *Editor) scrollToCaret() {
|
|
carWidth := e.caretWidth()
|
|
carLine, _, x, y := e.layoutCaret()
|
|
l := e.lines[carLine]
|
|
if e.SingleLine {
|
|
minx := (x - carWidth/2).Ceil()
|
|
if d := minx - e.scrollOff.X + e.padLeft; d < 0 {
|
|
e.scrollOff.X += d
|
|
}
|
|
maxx := (x + carWidth/2).Ceil()
|
|
if d := maxx - (e.scrollOff.X + e.viewSize.X - e.padRight); d > 0 {
|
|
e.scrollOff.X += d
|
|
}
|
|
} else {
|
|
miny := y + l.Bounds.Min.Y.Floor()
|
|
if d := miny - e.scrollOff.Y + e.padTop; d < 0 {
|
|
e.scrollOff.Y += d
|
|
}
|
|
maxy := y + l.Bounds.Max.Y.Ceil()
|
|
if d := maxy - (e.scrollOff.Y + e.viewSize.Y - e.padBottom); d > 0 {
|
|
e.scrollOff.Y += d
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *Editor) command(k key.ChordEvent) bool {
|
|
switch k.Name {
|
|
case key.NameReturn, key.NameEnter:
|
|
if !e.SingleLine {
|
|
e.append("\n")
|
|
}
|
|
case key.NameDeleteBackward:
|
|
e.deleteRune()
|
|
case key.NameDeleteForward:
|
|
e.deleteRuneForward()
|
|
case key.NameUpArrow:
|
|
line, _, carX, _ := e.layoutCaret()
|
|
e.carXOff = e.moveToLine(carX+e.carXOff, line-1)
|
|
case key.NameDownArrow:
|
|
line, _, carX, _ := e.layoutCaret()
|
|
e.carXOff = e.moveToLine(carX+e.carXOff, line+1)
|
|
case key.NameLeftArrow:
|
|
e.moveLeft()
|
|
case key.NameRightArrow:
|
|
e.moveRight()
|
|
case key.NamePageUp:
|
|
e.movePages(-1)
|
|
case key.NamePageDown:
|
|
e.movePages(+1)
|
|
case key.NameHome:
|
|
e.moveStart()
|
|
case key.NameEnd:
|
|
e.moveEnd()
|
|
default:
|
|
return false
|
|
}
|
|
return true
|
|
}
|