mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 15:45:38 +00:00
a8bb3c2f14
With an interface instead of anonymous functions, amending an area's parameters can be done even after adding it to an OpHandler. This will be useful when we switch to serialized op lists. Signed-off-by: Elias Naur <mail@eliasnaur.com>
537 lines
11 KiB
Go
537 lines
11 KiB
Go
// SPDX-License-Identifier: Unlicense OR MIT
|
|
|
|
package text
|
|
|
|
import (
|
|
"image"
|
|
"math"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"gioui.org/ui"
|
|
"gioui.org/ui/draw"
|
|
"gioui.org/ui/f32"
|
|
"gioui.org/ui/gesture"
|
|
"gioui.org/ui/key"
|
|
"gioui.org/ui/layout"
|
|
"gioui.org/ui/pointer"
|
|
|
|
"golang.org/x/image/math/fixed"
|
|
)
|
|
|
|
type Editor struct {
|
|
Src image.Image
|
|
Face Face
|
|
Alignment Alignment
|
|
SingleLine bool
|
|
|
|
cfg *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
|
|
|
|
ops ui.Ops
|
|
}
|
|
|
|
type linePath struct {
|
|
path *draw.Path
|
|
off f32.Point
|
|
}
|
|
|
|
const (
|
|
blinksPerSecond = 1
|
|
maxBlinkDuration = 10 * time.Second
|
|
)
|
|
|
|
func (e *Editor) Update(c *ui.Config, pq pointer.Events, kq key.Events) {
|
|
e.cfg = c
|
|
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(c, pq, 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 := range e.clicker.Update(pq) {
|
|
switch evt.Type {
|
|
case gesture.TypePress:
|
|
scrollTo = true
|
|
e.blinkStart = c.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 := range kq.For(e) {
|
|
e.blinkStart = c.Now
|
|
switch ke := ke.(type) {
|
|
case key.Focus:
|
|
e.focused = ke.Focus
|
|
case key.Chord:
|
|
if e.Command(ke) {
|
|
stop = true
|
|
scrollTo = true
|
|
}
|
|
case key.Edit:
|
|
stop = true
|
|
scrollTo = true
|
|
e.append(ke.Text)
|
|
}
|
|
}
|
|
if sdist == 0 && scrollTo {
|
|
e.scrollToCaret()
|
|
}
|
|
if stop {
|
|
e.scroller.Stop()
|
|
}
|
|
}
|
|
|
|
func (e *Editor) caretWidth() fixed.Int26_6 {
|
|
oneDp := int(e.cfg.Pixels(ui.Dp(1)) + .5)
|
|
return fixed.Int26_6(oneDp * 64)
|
|
}
|
|
|
|
func (e *Editor) Layout(cs layout.Constraints) (ui.Op, layout.Dimens) {
|
|
twoDp := int(e.cfg.Pixels(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.valid = false
|
|
}
|
|
|
|
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},
|
|
}
|
|
e.ops = e.ops[:0]
|
|
e.ops = append(e.ops, key.OpHandler{Key: e, Focus: e.requestFocus})
|
|
e.requestFocus = false
|
|
e.it = lineIterator{
|
|
Lines: lines,
|
|
Clip: clip,
|
|
Alignment: e.Alignment,
|
|
Width: e.viewWidth(),
|
|
Offset: off,
|
|
}
|
|
for {
|
|
str, lineOff, ok := e.it.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
path := e.Face.Path(str)
|
|
e.ops = append(e.ops, ui.OpTransform{
|
|
Transform: ui.Offset(lineOff),
|
|
Op: draw.OpClip{Path: path, Op: draw.OpImage{Rect: toRectF(clip).Sub(lineOff), Src: e.Src, SrcRect: e.Src.Bounds()}},
|
|
})
|
|
}
|
|
if e.focused {
|
|
now := e.cfg.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() {
|
|
e.ops = append(e.ops, draw.OpImage{Src: e.Src, Rect: toRectF(carRect), SrcRect: e.Src.Bounds()})
|
|
}
|
|
}
|
|
if blinking {
|
|
e.ops = append(e.ops, ui.OpRedraw{At: nextBlink})
|
|
}
|
|
}
|
|
|
|
baseline := e.padTop + e.dims.Baseline
|
|
area := &gesture.Rect{e.viewSize}
|
|
e.ops = append(e.ops, e.scroller.Op(area), e.clicker.Op(area))
|
|
return e.ops, 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() {
|
|
textLayout := e.Face.Layout(e.rr.String(), e.SingleLine, 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) SetText(s string) {
|
|
e.rr = editBuffer{}
|
|
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.Chord) bool {
|
|
if !e.focused {
|
|
return false
|
|
}
|
|
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
|
|
}
|