Files
gio/widget/editor_test.go
T
Chris Waldon 6ab3ff40a6 font/opentype,text,widget{,/material}: [API] support bitmap glyph rendering
This commit supports rendering opentype glyphs containing bitmap data instead of
color data. In order to support returning the shaped bitmap glyphs from the Shaper's
Shape() method, it has gained a second return parameter, an op.CallOp. Adding
that CallOp immediately after or immediately before painting the returned path
will display the bitmap glyphs.

The consequences of supporting colored glyphs forced changes upon the widget APIs
for widgets that display text. Previously text always had a fixed paint material,
so we could rely upon the caller setting the material (e.g. adding a paint.ColorOp)
before painting the glyphs and everything would work. Now that we display image-
based glyphs, we end up changing the painting material to an image midway through
displaying text. This is an awkward consequence of how we currently manage the
painting material, and to work around it widgets now accept an op.CallOp that
is expected to set the proper paint material. Text widgets will use that op.CallOp
before painting text (or other paint operations) to ensure that they are painting
with the proper materials.

This, in turn, changed the APIs for laying out widget.Editor, widget.Label, and
widget.Selectable, and eliminated the need for them to accept a callback (the
callback was only really to set the colors). Dropping that callback function
allowed me to consolidate widget.Label to only need one exported Layout method,
and allowed me to unexport the PaintText, PaintCaret, and PaintSelection methods
from widget.Editor and widget.Selectable. Those methods are useless in the public
API now that they don't need to be invoked after applying a color operation.

Callers of the raw text shaper API will need to make the following changes:

- Where before you used:

	var ops *op.Ops // Assume we have an operation list.
	var shaper *text.Shaper // Assume we have a shaper.
	var col color.NRGBA // Assume we have a text color.
	var glyphs []text.Glyph // Assume we have already filled a slice of glyphs.

	shape := shaper.Shape(glyphs)
	paint.FillShape(ops, col, clip.Outline{Path:shape}.Op())

- Now you should do:

	shape, call := shaper.Shape(glyphs)
	paint.FillShape(ops, col, clip.Outline{Path:shape}.Op())
	call.Add(ops)

Callers of the widget.{Label,Selectable,Editor} APIs will need to make the
following changes:

- Where before you used:

	var gtx layout.Context // Assume we have an operation list.
	var shaper *text.Shaper // Assume we have a shaper.
	var textCol color.NRGBA // Assume we have a text color.
	var selectCol color.NRGBA // Assume we have a selection color.
	var ed widget.Editor // Assume we have an editor.
	var sel widget.Selectable // Assume we have a selectable.

	// Lay out an editor.
	ed.Layout(gtx, shaper, text.Font{}, unit.Sp(30), func(layout.Context) layout.Dimensions {
		// Paint the editor.
	})
	// Lay out a selectable.
	sel.Layout(gtx, shaper, text.Font{}, unit.Sp(30), func(layout.Context) layout.Dimensions {
		// Paint the selectable.
	})
	// Lay out an interactive label.
	widget.Label{}.LayoutSelectable(gtx, shaper, text.Font{}, unit.Sp(30), "hello", func(layout.Context) layout.Dimensions {
		// Paint the label.
	})
	// Lay out a non-interactive label.
	widget.Label{}.Layout(gtx, shaper, text.Font{}, unit.Sp(30), "hello")

- Now you should do:

	// Capture setting the text paint material in a macro.
	textColMacro := op.Record(gtx.Ops)
	paint.ColorOp{Color: textCol}.Add(gtx.Ops)
	textMaterial := textColMacro.Stop()
	// Capture setting the selection paint material in a macro.
	selectColMacro := op.Record(gtx.Ops)
	paint.ColorOp{Color: selectCol}.Add(gtx.Ops)
	selectMaterial := selectColMacro.Stop()

	// Lay out an editor.
	ed.Layout(gtx, shaper, text.Font{}, unit.Sp(30), textMaterial, selectMaterial)
	// Lay out a selectable.
	sel.Layout(gtx, shaper, text.Font{}, unit.Sp(30), textMaterial, selectMaterial)
	// Lay out a label (no difference between interactive and non-interactive)
	widget.Label{}.Layout(gtx, shaper, text.Font{}, unit.Sp(30), "hello", textMaterial, selectMaterial)

Callers of the material package API do not need to make any changes.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-03-28 09:25:15 -06:00

1219 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: Unlicense OR MIT
package widget
import (
"bytes"
"fmt"
"image"
"io"
"math/rand"
"reflect"
"testing"
"testing/quick"
"time"
"unicode/utf8"
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
"eliasnaur.com/font/roboto/robotoregular"
"gioui.org/f32"
"gioui.org/font/gofont"
"gioui.org/font/opentype"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
)
var english = system.Locale{
Language: "EN",
Direction: system.LTR,
}
// TestEditorHistory ensures that undo and redo behave correctly.
func TestEditorHistory(t *testing.T) {
e := new(Editor)
// Insert some multi-byte unicode text.
e.SetText("안П你 hello 안П你")
assertContents(t, e, "안П你 hello 안П你", 0, 0)
// Overwrite all of the text with the empty string.
e.SetCaret(0, len([]rune("안П你 hello 안П你")))
e.Insert("")
assertContents(t, e, "", 0, 0)
// Ensure that undoing the overwrite succeeds.
e.undo()
assertContents(t, e, "안П你 hello 안П你", 13, 0)
// Ensure that redoing the overwrite succeeds.
e.redo()
assertContents(t, e, "", 0, 0)
// Insert some smaller text.
e.Insert("안П你 hello")
assertContents(t, e, "안П你 hello", 9, 9)
// Replace a region in the middle of the text.
e.SetCaret(1, 5)
e.Insert("П")
assertContents(t, e, "안Пello", 2, 2)
// Replace a second region in the middle.
e.SetCaret(3, 4)
e.Insert("П")
assertContents(t, e, "안ПeПlo", 4, 4)
// Ensure both operations undo successfully.
e.undo()
assertContents(t, e, "안Пello", 4, 3)
e.undo()
assertContents(t, e, "안П你 hello", 5, 1)
// Make a new modification.
e.Insert("Something New")
// Ensure that redo history is discarded now that
// we've diverged from the linear editing history.
// This redo() call should do nothing.
text := e.Text()
start, end := e.Selection()
e.redo()
assertContents(t, e, text, start, end)
}
func assertContents(t *testing.T, e *Editor, contents string, selectionStart, selectionEnd int) {
t.Helper()
actualContents := e.Text()
if actualContents != contents {
t.Errorf("expected editor to contain %s, got %s", contents, actualContents)
}
actualStart, actualEnd := e.Selection()
if actualStart != selectionStart {
t.Errorf("expected selection start to be %d, got %d", selectionStart, actualStart)
}
if actualEnd != selectionEnd {
t.Errorf("expected selection end to be %d, got %d", selectionEnd, actualEnd)
}
}
// TestEditorReadOnly ensures that mouse and keyboard interactions with readonly
// editors do nothing but manipulate the text selection.
func TestEditorReadOnly(t *testing.T) {
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{
Max: image.Pt(100, 100),
},
Locale: english,
}
gtx.Queue = &testQueue{
events: []event.Event{
key.FocusEvent{Focus: true},
},
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e := new(Editor)
e.ReadOnly = true
e.SetText("The quick brown fox jumps over the lazy dog. We just need a few lines of text in the editor so that it can adequately test a few different modes of selection. The quick brown fox jumps over the lazy dog. We just need a few lines of text in the editor so that it can adequately test a few different modes of selection.")
cStart, cEnd := e.Selection()
if cStart != cEnd {
t.Errorf("unexpected initial caret positions")
}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
// Select everything.
gtx.Ops.Reset()
gtx.Queue = &testQueue{events: []event.Event{key.Event{Name: "A", Modifiers: key.ModShortcut}}}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
textContent := e.Text()
cStart2, cEnd2 := e.Selection()
if cStart2 > cEnd2 {
cStart2, cEnd2 = cEnd2, cStart2
}
if cEnd2 != e.Len() {
t.Errorf("expected selection to contain %d runes, got %d", e.Len(), cEnd2)
}
if cStart2 != 0 {
t.Errorf("expected selection to start at rune 0, got %d", cStart2)
}
// Type some new characters.
gtx.Ops.Reset()
gtx.Queue = &testQueue{events: []event.Event{key.EditEvent{Range: key.Range{Start: cStart2, End: cEnd2}, Text: "something else"}}}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
textContent2 := e.Text()
if textContent2 != textContent {
t.Errorf("readonly editor modified by key.EditEvent")
}
// Try to delete selection.
gtx.Ops.Reset()
gtx.Queue = &testQueue{events: []event.Event{key.Event{Name: key.NameDeleteBackward}}}
dims := e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
textContent2 = e.Text()
if textContent2 != textContent {
t.Errorf("readonly editor modified by delete key.Event")
}
// Click and drag from the middle of the first line
// to the center.
gtx.Ops.Reset()
gtx.Queue = &testQueue{events: []event.Event{
pointer.Event{
Type: pointer.Press,
Buttons: pointer.ButtonPrimary,
Position: f32.Pt(float32(dims.Size.X)*.5, 5),
},
pointer.Event{
Type: pointer.Drag,
Buttons: pointer.ButtonPrimary,
Position: layout.FPt(dims.Size).Mul(.5),
},
pointer.Event{
Type: pointer.Release,
Buttons: pointer.ButtonPrimary,
Position: layout.FPt(dims.Size).Mul(.5),
},
}}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
cStart3, cEnd3 := e.Selection()
if cStart3 == cStart2 || cEnd3 == cEnd2 {
t.Errorf("expected mouse interaction to change selection.")
}
}
func TestEditorConfigurations(t *testing.T) {
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(300, 300)),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
sentence := "\n\n\n\n\n\n\n\n\n\n\n\nthe quick brown fox jumps over the lazy dog"
runes := len([]rune(sentence))
// Ensure that both ends of the text are reachable in all permutations
// of settings that influence layout.
for _, singleLine := range []bool{true, false} {
for _, alignment := range []text.Alignment{text.Start, text.Middle, text.End} {
for _, zeroMin := range []bool{true, false} {
t.Run(fmt.Sprintf("SingleLine: %v Alignment: %v ZeroMinConstraint: %v", singleLine, alignment, zeroMin), func(t *testing.T) {
defer func() {
if err := recover(); err != nil {
t.Error(err)
}
}()
if zeroMin {
gtx.Constraints.Min = image.Point{}
} else {
gtx.Constraints.Min = gtx.Constraints.Max
}
e := new(Editor)
e.SingleLine = singleLine
e.Alignment = alignment
e.SetText(sentence)
e.SetCaret(0, 0)
dims := e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
if dims.Size.X < gtx.Constraints.Min.X || dims.Size.Y < gtx.Constraints.Min.Y {
t.Errorf("expected min size %#+v, got %#+v", gtx.Constraints.Min, dims.Size)
}
coords := e.CaretCoords()
if halfway := float32(gtx.Constraints.Min.X) * .5; !singleLine && alignment == text.Middle && !zeroMin && coords.X != halfway {
t.Errorf("expected caret X to be %f, got %f", halfway, coords.X)
}
e.SetCaret(runes, runes)
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
coords = e.CaretCoords()
if int(coords.X) > gtx.Constraints.Max.X || int(coords.Y) > gtx.Constraints.Max.Y {
t.Errorf("caret coordinates %v exceed constraints %v", coords, gtx.Constraints.Max)
}
})
}
}
}
}
func TestEditor(t *testing.T) {
e := new(Editor)
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
// Regression test for bad in-cluster rune offset math.
e.SetText("æbc")
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
e.text.MoveEnd(selectionClear)
assertCaret(t, e, 0, 3, len("æbc"))
textSample := "æbc\naøå••"
e.SetCaret(0, 0) // shouldn't panic
assertCaret(t, e, 0, 0, 0)
e.SetText(textSample)
if got, exp := e.Len(), utf8.RuneCountInString(e.Text()); got != exp {
t.Errorf("got length %d, expected %d", got, exp)
}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
assertCaret(t, e, 0, 0, 0)
e.text.MoveEnd(selectionClear)
assertCaret(t, e, 0, 3, len("æbc"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 0, len("æbc\n"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 3, len("æbc"))
e.text.MoveLines(+1, selectionClear)
assertCaret(t, e, 1, 4, len("æbc\naøå•"))
e.text.MoveEnd(selectionClear)
assertCaret(t, e, 1, 5, len("æbc\naøå••"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 5, len("æbc\naøå••"))
e.text.MoveLines(3, selectionClear)
e.SetCaret(0, 0)
assertCaret(t, e, 0, 0, 0)
e.SetCaret(utf8.RuneCountInString("æ"), utf8.RuneCountInString("æ"))
assertCaret(t, e, 0, 1, 2)
e.SetCaret(utf8.RuneCountInString("æbc\naøå•"), utf8.RuneCountInString("æbc\naøå•"))
assertCaret(t, e, 1, 4, len("æbc\naøå•"))
// Ensure that password masking does not affect caret behavior
e.MoveCaret(-3, -3)
assertCaret(t, e, 1, 1, len("æbc\na"))
e.text.Mask = '*'
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
assertCaret(t, e, 1, 1, len("æbc\na"))
e.MoveCaret(-3, -3)
assertCaret(t, e, 0, 2, len("æb"))
/*
NOTE(whereswaldon): it isn't possible to check the raw glyph data
like this anymore. How should we handle this?
e.Mask = '\U0001F92B'
e.Layout(gtx, cache, font, fontSize, op.CallOp{},op.CallOp{})
e.moveEnd(selectionClear)
assertCaret(t, e, 0, 3, len("æbc"))
// When a password mask is applied, it should replace all visible glyphs
spaces := 0
for _, r := range textSample {
if unicode.IsSpace(r) {
spaces++
}
}
nonSpaces := len([]rune(textSample)) - spaces
glyphCounts := make(map[int]int)
// This loop assumes a single-run text, which we know is safe here.
for _, line := range e.lines {
for _, glyph := range line.Runs[0].Glyphs {
glyphCounts[int(glyph.ID)]++
}
}
if len(glyphCounts) > 2 {
t.Errorf("masked text contained glyphs other than mask and whitespace")
}
for gid, count := range glyphCounts {
if count != spaces && count != nonSpaces {
t.Errorf("glyph with id %d occurred %d times, expected either %d or %d", gid, count, spaces, nonSpaces)
}
}
*/
// Test that moveLine applies x offsets from previous moves.
e.SetText("long line\nshort")
e.SetCaret(0, 0)
e.text.MoveEnd(selectionClear)
e.text.MoveLines(+1, selectionClear)
e.text.MoveLines(-1, selectionClear)
assertCaret(t, e, 0, utf8.RuneCountInString("long line"), len("long line"))
}
var arabic = system.Locale{
Language: "AR",
Direction: system.RTL,
}
var arabicCollection = func() []text.FontFace {
parsed, _ := opentype.Parse(nsareg.TTF)
return []text.FontFace{{Font: text.Font{}, Face: parsed}}
}()
func TestEditorRTL(t *testing.T) {
e := new(Editor)
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: arabic,
}
cache := text.NewShaper(arabicCollection)
fontSize := unit.Sp(10)
font := text.Font{}
e.SetCaret(0, 0) // shouldn't panic
assertCaret(t, e, 0, 0, 0)
// Set the text to a single RTL word. The caret should start at 0 column
// zero, but this is the first column on the right.
e.SetText("الحب")
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
assertCaret(t, e, 0, 0, 0)
e.MoveCaret(+1, +1)
assertCaret(t, e, 0, 1, len("ا"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 0, 2, len("ال"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 0, 3, len("الح"))
// Move to the "end" of the line. This moves to the left edge of the line.
e.text.MoveEnd(selectionClear)
assertCaret(t, e, 0, 4, len("الحب"))
sentence := "الحب سماء لا\nتمط غير الأحلام"
e.SetText(sentence)
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
assertCaret(t, e, 0, 0, 0)
e.text.MoveEnd(selectionClear)
assertCaret(t, e, 0, 12, len("الحب سماء لا"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 0, len("الحب سماء لا\n"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 1, len("الحب سماء لا\nت"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 1, 0, len("الحب سماء لا\n"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 12, len("الحب سماء لا"))
e.text.MoveLines(+1, selectionClear)
assertCaret(t, e, 1, 14, len("الحب سماء لا\nتمط غير الأحلا"))
e.text.MoveEnd(selectionClear)
assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام"))
e.text.MoveLines(3, selectionClear)
assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام"))
e.SetCaret(utf8.RuneCountInString(sentence), 0)
assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام"))
if selection := e.SelectedText(); selection != sentence {
t.Errorf("expected selection %s, got %s", sentence, selection)
}
e.SetCaret(0, 0)
assertCaret(t, e, 0, 0, 0)
e.SetCaret(utf8.RuneCountInString("ا"), utf8.RuneCountInString("ا"))
assertCaret(t, e, 0, 1, len("ا"))
e.SetCaret(utf8.RuneCountInString("الحب سماء لا\nتمط غ"), utf8.RuneCountInString("الحب سماء لا\nتمط غ"))
assertCaret(t, e, 1, 5, len("الحب سماء لا\nتمط غ"))
}
func TestEditorLigature(t *testing.T) {
e := new(Editor)
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
face, err := opentype.Parse(robotoregular.TTF)
if err != nil {
t.Skipf("failed parsing test font: %v", err)
}
cache := text.NewShaper([]text.FontFace{
{
Font: text.Font{
Typeface: "Roboto",
},
Face: face,
},
})
fontSize := unit.Sp(10)
font := text.Font{}
/*
In this font, the following rune sequences form ligatures:
- ffi
- ffl
- fi
- fl
*/
e.SetCaret(0, 0) // shouldn't panic
assertCaret(t, e, 0, 0, 0)
e.SetText("fl") // just a ligature
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
e.text.MoveEnd(selectionClear)
assertCaret(t, e, 0, 2, len("fl"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 1, len("f"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 0, 0)
e.MoveCaret(+2, +2)
assertCaret(t, e, 0, 2, len("fl"))
e.SetText("flaffl•ffi\n•fflfi") // 3 ligatures on line 0, 2 on line 1
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
assertCaret(t, e, 0, 0, 0)
e.text.MoveEnd(selectionClear)
assertCaret(t, e, 0, 10, len("ffaffl•ffi"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 0, len("ffaffl•ffi\n"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 1, len("ffaffl•ffi\n•"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 2, len("ffaffl•ffi\n•f"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 3, len("ffaffl•ffi\n•ff"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 4, len("ffaffl•ffi\n•ffl"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 5, len("ffaffl•ffi\n•fflf"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 6, len("ffaffl•ffi\n•fflfi"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 1, 5, len("ffaffl•ffi\n•fflf"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 1, 4, len("ffaffl•ffi\n•ffl"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 1, 3, len("ffaffl•ffi\n•ff"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 1, 2, len("ffaffl•ffi\n•f"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 1, 1, len("ffaffl•ffi\n•"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 1, 0, len("ffaffl•ffi\n"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 10, len("ffaffl•ffi"))
e.MoveCaret(-2, -2)
assertCaret(t, e, 0, 8, len("ffaffl•f"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 7, len("ffaffl•"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 6, len("ffaffl"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 5, len("ffaff"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 4, len("ffaf"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 3, len("ffa"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 2, len("ff"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 1, len("f"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 0, 0)
gtx.Constraints = layout.Exact(image.Pt(50, 50))
e.SetText("fflffl fflffl fflffl fflffl") // Many ligatures broken across lines.
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
// Ensure that all runes in the final cluster of a line are properly
// decoded when moving to the end of the line. This is a regression test.
e.text.MoveEnd(selectionClear)
// The first line was broken by line wrapping, not a newline character. As such,
// the cursor can reach the position after the final glyph (a space).
assertCaret(t, e, 0, 14, len("fflffl fflffl "))
e.text.MoveLines(1, selectionClear)
assertCaret(t, e, 1, 13, len("fflffl fflffl fflffl fflffl"))
e.text.MoveLines(-1, selectionClear)
assertCaret(t, e, 0, 14, len("fflffl fflffl "))
// Absurdly narrow constraints to force each ligature onto its own line.
gtx.Constraints = layout.Exact(image.Pt(10, 10))
e.SetText("ffl ffl") // Two ligatures on separate lines.
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
assertCaret(t, e, 0, 0, 0)
e.MoveCaret(1, 1) // Move the caret into the first ligature.
assertCaret(t, e, 0, 1, len("f"))
e.MoveCaret(4, 4) // Move the caret several positions.
assertCaret(t, e, 1, 1, len("ffl f"))
}
func TestEditorDimensions(t *testing.T) {
e := new(Editor)
tq := &testQueue{
events: []event.Event{
key.EditEvent{Text: "A"},
},
}
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{Max: image.Pt(100, 100)},
Queue: tq,
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
dims := e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
if dims.Size.X == 0 {
t.Errorf("EditEvent was not reflected in Editor width")
}
}
// assertCaret asserts that the editor caret is at a particular line
// and column, and that the byte position matches as well.
func assertCaret(t *testing.T, e *Editor, line, col, bytes int) {
t.Helper()
gotLine, gotCol := e.CaretPos()
if gotLine != line || gotCol != col {
t.Errorf("caret at (%d, %d), expected (%d, %d)", gotLine, gotCol, line, col)
}
caretBytes := e.text.runeOffset(e.text.caret.start)
if bytes != caretBytes {
t.Errorf("caret at buffer position %d, expected %d", caretBytes, bytes)
}
// Ensure that SelectedText() does not panic no matter what the
// editor's state is.
_ = e.SelectedText()
}
type editMutation int
const (
setText editMutation = iota
moveRune
moveLine
movePage
moveStart
moveEnd
moveCoord
moveWord
deleteWord
moveLast // Mark end; never generated.
)
func TestEditorCaretConsistency(t *testing.T) {
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
for _, a := range []text.Alignment{text.Start, text.Middle, text.End} {
e := &Editor{}
e.Alignment = a
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
consistent := func() error {
t.Helper()
gotLine, gotCol := e.CaretPos()
gotCoords := e.CaretCoords()
// Blow away index to re-compute position from scratch.
e.text.invalidate()
want := e.text.closestToRune(e.text.caret.start)
wantCoords := f32.Pt(float32(want.x)/64, float32(want.y))
if want.lineCol.line != gotLine || int(want.lineCol.col) != gotCol || gotCoords != wantCoords {
return fmt.Errorf("caret (%d,%d) pos %s, want (%d,%d) pos %s",
gotLine, gotCol, gotCoords, want.lineCol.line, want.lineCol.col, wantCoords)
}
return nil
}
if err := consistent(); err != nil {
t.Errorf("initial editor inconsistency (alignment %s): %v", a, err)
}
move := func(mutation editMutation, str string, distance int8, x, y uint16) bool {
switch mutation {
case setText:
e.SetText(str)
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
case moveRune:
e.MoveCaret(int(distance), int(distance))
case moveLine:
e.text.MoveLines(int(distance), selectionClear)
case movePage:
e.text.MovePages(int(distance), selectionClear)
case moveStart:
e.text.MoveStart(selectionClear)
case moveEnd:
e.text.MoveEnd(selectionClear)
case moveCoord:
e.text.MoveCoord(image.Pt(int(x), int(y)))
case moveWord:
e.text.MoveWord(int(distance), selectionClear)
case deleteWord:
e.deleteWord(int(distance))
default:
return false
}
if err := consistent(); err != nil {
t.Error(err)
return false
}
return true
}
if err := quick.Check(move, nil); err != nil {
t.Errorf("editor inconsistency (alignment %s): %v", a, err)
}
}
}
func TestEditorMoveWord(t *testing.T) {
type Test struct {
Text string
Start int
Skip int
Want int
}
tests := []Test{
{"", 0, 0, 0},
{"", 0, -1, 0},
{"", 0, 1, 0},
{"hello", 0, -1, 0},
{"hello", 0, 1, 5},
{"hello world", 3, 1, 5},
{"hello world", 3, -1, 0},
{"hello world", 8, -1, 6},
{"hello world", 8, 1, 11},
{"hello world", 3, 1, 5},
{"hello world", 3, 2, 14},
{"hello world", 8, 1, 14},
{"hello world", 8, -1, 0},
{"hello brave new world", 0, 3, 15},
}
setup := func(t string) *Editor {
e := new(Editor)
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e.SetText(t)
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
return e
}
for ii, tt := range tests {
e := setup(tt.Text)
e.MoveCaret(tt.Start, tt.Start)
e.text.MoveWord(tt.Skip, selectionClear)
caretBytes := e.text.runeOffset(e.text.caret.start)
if caretBytes != tt.Want {
t.Fatalf("[%d] moveWord: bad caret position: got %d, want %d", ii, caretBytes, tt.Want)
}
}
}
func TestEditorInsert(t *testing.T) {
type Test struct {
Text string
Start int
Selection int
Insertion string
Result string
}
tests := []Test{
// Nothing inserted
{"", 0, 0, "", ""},
{"", 0, -1, "", ""},
{"", 0, 1, "", ""},
{"", 0, -2, "", ""},
{"", 0, 2, "", ""},
{"world", 0, 0, "", "world"},
{"world", 0, -1, "", "world"},
{"world", 0, 1, "", "orld"},
{"world", 2, 0, "", "world"},
{"world", 2, -1, "", "wrld"},
{"world", 2, 1, "", "wold"},
{"world", 5, 0, "", "world"},
{"world", 5, -1, "", "worl"},
{"world", 5, 1, "", "world"},
// One rune inserted
{"", 0, 0, "_", "_"},
{"", 0, -1, "_", "_"},
{"", 0, 1, "_", "_"},
{"", 0, -2, "_", "_"},
{"", 0, 2, "_", "_"},
{"world", 0, 0, "_", "_world"},
{"world", 0, -1, "_", "_world"},
{"world", 0, 1, "_", "_orld"},
{"world", 2, 0, "_", "wo_rld"},
{"world", 2, -1, "_", "w_rld"},
{"world", 2, 1, "_", "wo_ld"},
{"world", 5, 0, "_", "world_"},
{"world", 5, -1, "_", "worl_"},
{"world", 5, 1, "_", "world_"},
// More runes inserted
{"", 0, 0, "-3-", "-3-"},
{"", 0, -1, "-3-", "-3-"},
{"", 0, 1, "-3-", "-3-"},
{"", 0, -2, "-3-", "-3-"},
{"", 0, 2, "-3-", "-3-"},
{"world", 0, 0, "-3-", "-3-world"},
{"world", 0, -1, "-3-", "-3-world"},
{"world", 0, 1, "-3-", "-3-orld"},
{"world", 2, 0, "-3-", "wo-3-rld"},
{"world", 2, -1, "-3-", "w-3-rld"},
{"world", 2, 1, "-3-", "wo-3-ld"},
{"world", 5, 0, "-3-", "world-3-"},
{"world", 5, -1, "-3-", "worl-3-"},
{"world", 5, 1, "-3-", "world-3-"},
// Runes with length > 1 inserted
{"", 0, 0, "éêè", "éêè"},
{"", 0, -1, "éêè", "éêè"},
{"", 0, 1, "éêè", "éêè"},
{"", 0, -2, "éêè", "éêè"},
{"", 0, 2, "éêè", "éêè"},
{"world", 0, 0, "éêè", "éêèworld"},
{"world", 0, -1, "éêè", "éêèworld"},
{"world", 0, 1, "éêè", "éêèorld"},
{"world", 2, 0, "éêè", "woéêèrld"},
{"world", 2, -1, "éêè", "wéêèrld"},
{"world", 2, 1, "éêè", "woéêèld"},
{"world", 5, 0, "éêè", "worldéêè"},
{"world", 5, -1, "éêè", "worléêè"},
{"world", 5, 1, "éêè", "worldéêè"},
// Runes with length > 1 deleted from selection
{"élançé", 0, 1, "", "lançé"},
{"élançé", 0, 1, "-3-", "-3-lançé"},
{"élançé", 3, 2, "-3-", "éla-3-é"},
{"élançé", 3, 3, "-3-", "éla-3-"},
{"élançé", 3, 10, "-3-", "éla-3-"},
{"élançé", 5, -1, "-3-", "élan-3-é"},
{"élançé", 6, -1, "-3-", "élanç-3-"},
{"élançé", 6, -3, "-3-", "éla-3-"},
}
setup := func(t string) *Editor {
e := new(Editor)
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e.SetText(t)
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
return e
}
for ii, tt := range tests {
e := setup(tt.Text)
e.MoveCaret(tt.Start, tt.Start)
e.MoveCaret(0, tt.Selection)
e.Insert(tt.Insertion)
if e.Text() != tt.Result {
t.Fatalf("[%d] Insert: invalid result: got %q, want %q", ii, e.Text(), tt.Result)
}
}
}
func TestEditorDeleteWord(t *testing.T) {
type Test struct {
Text string
Start int
Selection int
Delete int
Want int
Result string
}
tests := []Test{
// No text selected
{"", 0, 0, 0, 0, ""},
{"", 0, 0, -1, 0, ""},
{"", 0, 0, 1, 0, ""},
{"", 0, 0, -2, 0, ""},
{"", 0, 0, 2, 0, ""},
{"hello", 0, 0, -1, 0, "hello"},
{"hello", 0, 0, 1, 0, ""},
// Document (imho) incorrect behavior w.r.t. deleting spaces following
// words.
{"hello world", 0, 0, 1, 0, " world"}, // Should be "world", if you ask me.
{"hello world", 0, 0, 2, 0, "world"}, // Should be "".
{"hello ", 0, 0, 1, 0, " "}, // Should be "".
{"hello world", 11, 0, -1, 6, "hello "}, // Should be "hello".
{"hello world", 11, 0, -2, 5, "hello"}, // Should be "".
{"hello ", 6, 0, -1, 0, ""}, // Correct result.
{"hello world", 3, 0, 1, 3, "hel world"},
{"hello world", 3, 0, -1, 0, "lo world"},
{"hello world", 8, 0, -1, 6, "hello rld"},
{"hello world", 8, 0, 1, 8, "hello wo"},
{"hello world", 3, 0, 1, 3, "hel world"},
{"hello world", 3, 0, 2, 3, "helworld"},
{"hello world", 8, 0, 1, 8, "hello "},
{"hello world", 8, 0, -1, 5, "hello world"},
{"hello brave new world", 0, 0, 3, 0, " new world"},
{"helléèçàô world", 3, 0, 1, 3, "hel world"}, // unicode char with length > 1 in deleted part
// Add selected text.
//
// Several permutations must be tested:
// - select from the left or right
// - Delete + or -
// - abs(Delete) == 1 or > 1
//
// "brave |" selected; caret at |
{"hello there brave new world", 12, 6, 1, 12, "hello there new world"}, // #16
{"hello there brave new world", 12, 6, 2, 12, "hello there world"}, // The two spaces after "there" are actually suboptimal, if you ask me. See also above cases.
{"hello there brave new world", 12, 6, -1, 12, "hello there new world"},
{"hello there brave new world", 12, 6, -2, 6, "hello new world"},
{"hello there b®âve new world", 12, 6, 1, 12, "hello there new world"}, // unicode chars with length > 1 in selection
{"hello there b®âve new world", 12, 6, 2, 12, "hello there world"}, // ditto
{"hello there b®âve new world", 12, 6, -1, 12, "hello there new world"}, // ditto
{"hello there b®âve new world", 12, 6, -2, 6, "hello new world"}, // ditto
// "|brave " selected
{"hello there brave new world", 18, -6, 1, 12, "hello there new world"}, // #20
{"hello there brave new world", 18, -6, 2, 12, "hello there world"}, // ditto
{"hello there brave new world", 18, -6, -1, 12, "hello there new world"},
{"hello there brave new world", 18, -6, -2, 6, "hello new world"},
{"hello there b®âve new world", 18, -6, 1, 12, "hello there new world"}, // unicode chars with length > 1 in selection
// Random edge cases
{"hello there brave new world", 12, 6, 99, 12, "hello there "},
{"hello there brave new world", 18, -6, -99, 0, "new world"},
}
setup := func(t string) *Editor {
e := new(Editor)
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e.SetText(t)
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
return e
}
for ii, tt := range tests {
e := setup(tt.Text)
e.MoveCaret(tt.Start, tt.Start)
e.MoveCaret(0, tt.Selection)
e.deleteWord(tt.Delete)
caretBytes := e.text.runeOffset(e.text.caret.start)
if caretBytes != tt.Want {
t.Fatalf("[%d] deleteWord: bad caret position: got %d, want %d", ii, caretBytes, tt.Want)
}
if e.Text() != tt.Result {
t.Fatalf("[%d] deleteWord: invalid result: got %q, want %q", ii, e.Text(), tt.Result)
}
}
}
func TestEditorNoLayout(t *testing.T) {
var e Editor
e.SetText("hi!\n")
e.MoveCaret(1, 1)
}
// Generate generates a value of itself, for testing/quick.
func (editMutation) Generate(rand *rand.Rand, size int) reflect.Value {
t := editMutation(rand.Intn(int(moveLast)))
return reflect.ValueOf(t)
}
// TestEditorSelect tests the selection code. It lays out an editor with several
// lines in it, selects some text, verifies the selection, resizes the editor
// to make it much narrower (which makes the lines in the editor reflow), and
// then verifies that the updated (col, line) positions of the selected text
// are where we expect.
func TestEditorSelect(t *testing.T) {
e := new(Editor)
e.SetText(`a 2 4 6 8 a
b 2 4 6 8 b
c 2 4 6 8 c
d 2 4 6 8 d
e 2 4 6 8 e
f 2 4 6 8 f
g 2 4 6 8 g
`)
gtx := layout.Context{
Ops: new(op.Ops),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
font := text.Font{}
fontSize := unit.Sp(10)
var tim time.Duration
selected := func(start, end int) string {
// Layout once with no events; populate e.lines.
gtx.Queue = nil
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
_ = e.Events() // throw away any events from this layout
// Build the selection events
startPos := e.text.closestToRune(start)
endPos := e.text.closestToRune(end)
tq := &testQueue{
events: []event.Event{
pointer.Event{
Buttons: pointer.ButtonPrimary,
Type: pointer.Press,
Source: pointer.Mouse,
Time: tim,
Position: f32.Pt(textWidth(e, startPos.lineCol.line, 0, startPos.lineCol.col), textBaseline(e, startPos.lineCol.line)),
},
pointer.Event{
Type: pointer.Release,
Source: pointer.Mouse,
Time: tim,
Position: f32.Pt(textWidth(e, endPos.lineCol.line, 0, endPos.lineCol.col), textBaseline(e, endPos.lineCol.line)),
},
},
}
tim += time.Second // Avoid multi-clicks.
gtx.Queue = tq
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
for _, evt := range e.Events() {
switch evt.(type) {
case SelectEvent:
return e.SelectedText()
}
}
return ""
}
type screenPos image.Point
logicalPosMatch := func(t *testing.T, n int, label string, expected screenPos, actual combinedPos) {
t.Helper()
if actual.lineCol.line != expected.Y || actual.lineCol.col != expected.X {
t.Errorf("Test %d: Expected %s %#v; got %#v",
n, label,
expected, actual)
}
}
type testCase struct {
// input text offsets
start, end int
// expected selected text
selection string
// expected line/col positions of selection after resize
startPos, endPos screenPos
}
for n, tst := range []testCase{
{0, 1, "a", screenPos{}, screenPos{Y: 0, X: 1}},
{0, 4, "a 2 ", screenPos{}, screenPos{Y: 0, X: 4}},
{0, 11, "a 2 4 6 8 a", screenPos{}, screenPos{Y: 1, X: 3}},
{6, 10, "6 8 ", screenPos{Y: 0, X: 6}, screenPos{Y: 1, X: 2}},
{41, 66, " 6 8 d\ne 2 4 6 8 e\nf 2 4 ", screenPos{Y: 6, X: 5}, screenPos{Y: 10, X: 6}},
} {
gtx.Constraints = layout.Exact(image.Pt(100, 100))
if got := selected(tst.start, tst.end); got != tst.selection {
t.Errorf("Test %d pt1: Expected %q, got %q", n, tst.selection, got)
continue
}
// Constrain the editor to roughly 6 columns wide and redraw
gtx.Constraints = layout.Exact(image.Pt(36, 36))
// Keep existing selection
gtx.Queue = nil
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
caretStart := e.text.closestToRune(e.text.caret.start)
caretEnd := e.text.closestToRune(e.text.caret.end)
logicalPosMatch(t, n, "start", tst.startPos, caretEnd)
logicalPosMatch(t, n, "end", tst.endPos, caretStart)
}
}
// Verify that an existing selection is dismissed when you press arrow keys.
func TestSelectMove(t *testing.T) {
e := new(Editor)
e.SetText(`0123456789`)
gtx := layout.Context{
Ops: new(op.Ops),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
font := text.Font{}
fontSize := unit.Sp(10)
// Layout once to populate e.lines and get focus.
gtx.Queue = newQueue(key.FocusEvent{Focus: true})
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
testKey := func(keyName string) {
// Select 345
e.SetCaret(3, 6)
if expected, got := "345", e.SelectedText(); expected != got {
t.Errorf("KeyName %s, expected %q, got %q", keyName, expected, got)
}
// Press the key
gtx.Queue = newQueue(key.Event{State: key.Press, Name: keyName})
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
if expected, got := "", e.SelectedText(); expected != got {
t.Errorf("KeyName %s, expected %q, got %q", keyName, expected, got)
}
}
testKey(key.NameLeftArrow)
testKey(key.NameRightArrow)
testKey(key.NameUpArrow)
testKey(key.NameDownArrow)
}
func TestEditor_Read(t *testing.T) {
s := "hello world"
buf := make([]byte, len(s))
e := new(Editor)
e.SetText(s)
_, err := e.Seek(0, io.SeekStart)
if err != nil {
t.Error(err)
}
n, err := io.ReadFull(e, buf)
if err != nil {
t.Error(err)
}
if got, want := n, len(s); got != want {
t.Errorf("got %d; want %d", got, want)
}
if got, want := string(buf), s; got != want {
t.Errorf("got %q; want %q", got, want)
}
}
func TestEditor_WriteTo(t *testing.T) {
s := "hello world"
var buf bytes.Buffer
e := new(Editor)
e.SetText(s)
n, err := io.Copy(&buf, e)
if err != nil {
t.Error(err)
}
if got, want := int(n), len(s); got != want {
t.Errorf("got %d; want %d", got, want)
}
if got, want := buf.String(), s; got != want {
t.Errorf("got %q; want %q", got, want)
}
}
func TestEditor_MaxLen(t *testing.T) {
e := new(Editor)
e.MaxLen = 8
e.SetText("123456789")
if got, want := e.Text(), "12345678"; got != want {
t.Errorf("editor failed to cap SetText")
}
e.SetText("2345678")
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(100, 100)),
Queue: newQueue(
key.EditEvent{Range: key.Range{Start: 0, End: 2}, Text: "1234"},
key.SelectionEvent{Start: 4, End: 4},
),
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
if got, want := e.Text(), "12345678"; got != want {
t.Errorf("editor failed to cap EditEvent")
}
if start, end := e.Selection(); start != 3 || end != 3 {
t.Errorf("editor failed to adjust SelectionEvent")
}
}
func TestEditor_Filter(t *testing.T) {
e := new(Editor)
e.Filter = "123456789"
e.SetText("abcde1234")
if got, want := e.Text(), "1234"; got != want {
t.Errorf("editor failed to filter SetText")
}
e.SetText("2345678")
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(100, 100)),
Queue: newQueue(
key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "ab1"},
key.SelectionEvent{Start: 4, End: 4},
),
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
if got, want := e.Text(), "12345678"; got != want {
t.Errorf("editor failed to filter EditEvent")
}
if start, end := e.Selection(); start != 2 || end != 2 {
t.Errorf("editor failed to adjust SelectionEvent")
}
}
func TestEditor_Submit(t *testing.T) {
e := new(Editor)
e.Submit = true
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(100, 100)),
Queue: newQueue(
key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "ab1\n"},
),
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := text.Font{}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
if got, want := e.Text(), "ab1"; got != want {
t.Errorf("editor failed to filter newline")
}
got := e.Events()
want := []EditorEvent{
ChangeEvent{},
SubmitEvent{Text: e.Text()},
}
if !reflect.DeepEqual(want, got) {
t.Errorf("editor failed to register submit")
}
}
// textWidth is a text helper for building simple selection events.
// It assumes single-run lines, which isn't safe with non-test text
// data.
func textWidth(e *Editor, lineNum, colStart, colEnd int) float32 {
start := e.text.closestToLineCol(lineNum, colStart)
end := e.text.closestToLineCol(lineNum, colEnd)
delta := start.x - end.x
if delta < 0 {
delta = -delta
}
return float32(delta.Round())
}
// testBaseline returns the y coordinate of the baseline for the
// given line number.
func textBaseline(e *Editor, lineNum int) float32 {
start := e.text.closestToLineCol(lineNum, 0)
return float32(start.y)
}
type testQueue struct {
events []event.Event
}
func newQueue(e ...event.Event) *testQueue {
return &testQueue{events: e}
}
func (q *testQueue) Events(_ event.Tag) []event.Event {
return q.events
}