Files
gio/ui/text/editor.go
T
Elias Naur 86bc3e612d ui/text: use BlockOp as materials
Before this change, clients were expected to set up the current
material (color or image) before laying out Labels and Editors.
This was done to avoid garbage from a hypothetical material
interface covering both colors and images.

However, some widgets need more than one material: the Editor had
HintImage for the hint text material.

This change implements generalized materials through blocks:
anywhere a material is expected, a ui.BlockOp is is assumed to
contain the operation(s) needed for setting the desired material.

This way we avoid allocations (interfaces) and keep the
abstraction over material types.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2019-07-05 16:52:13 +02:00

589 lines
12 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/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)
if e.rr.len() > 0 {
e.Material.Add(ops)
} else {
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
}