mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 07:35:40 +00:00
widget,widget/material: add selection to the editor
- Allow dragging to be on both horizontal and vertical axes at once. - Split Editor.caret.pos into caret.start and caret.stop. caret.start is the old caret.pos, and is both the position of the caret, and also the start of selected text. caret.end is the end of the selected text. Start can be after end, e.g. after after Shift-DownArrow. - Update caret.end after a mouse drag, and various shifted keys (Shift-UpArrow, Shift-DownArrow, etc). - Change Shortcut-C to copy only the selected text, not the whole editor text. - Add Shortcut-X to copy and delete selected text, and Shortcut-A to select all text. - The various Insert/Delete/etc functions now overwrite or delete the selection, as appropriate. - Change MoveCaret to accept a distance for selection end, as well. Change SetCaret to accept a selection end offset. - Add SelectionLen to get the selection length, Selection to get selection offsets, SelectedText to get the selected text, and ClearSelection to clear the selection. - Add a rudimentary selection unit test, and extend the deleteWord unit test with some text selection cases. - Add SelectionColor to material.EditorStyle, which defaults to Theme.Palette.ContrastBg. Signed-off-by: Larry Clapp <larry@theclapp.org>
This commit is contained in:
@@ -90,6 +90,7 @@ type Axis uint8
|
|||||||
const (
|
const (
|
||||||
Horizontal Axis = iota
|
Horizontal Axis = iota
|
||||||
Vertical
|
Vertical
|
||||||
|
Both
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -201,6 +202,8 @@ func (c *Click) Events(q event.Queue) []ClickEvent {
|
|||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ClickEvent) ImplementsEvent() {}
|
||||||
|
|
||||||
// Add the handler to the operation list to receive scroll events.
|
// Add the handler to the operation list to receive scroll events.
|
||||||
func (s *Scroll) Add(ops *op.Ops) {
|
func (s *Scroll) Add(ops *op.Ops) {
|
||||||
oph := pointer.InputOp{
|
oph := pointer.InputOp{
|
||||||
@@ -356,6 +359,8 @@ func (d *Drag) Events(cfg unit.Metric, q event.Queue, axis Axis) []pointer.Event
|
|||||||
e.Position.Y = d.start.Y
|
e.Position.Y = d.start.Y
|
||||||
case Vertical:
|
case Vertical:
|
||||||
e.Position.X = d.start.X
|
e.Position.X = d.start.X
|
||||||
|
case Both:
|
||||||
|
// Do nothing
|
||||||
}
|
}
|
||||||
if e.Priority < pointer.Grabbed {
|
if e.Priority < pointer.Grabbed {
|
||||||
diff := e.Position.Sub(d.start)
|
diff := e.Position.Sub(d.start)
|
||||||
|
|||||||
+347
-98
@@ -18,6 +18,7 @@ import (
|
|||||||
"gioui.org/f32"
|
"gioui.org/f32"
|
||||||
"gioui.org/gesture"
|
"gioui.org/gesture"
|
||||||
"gioui.org/io/clipboard"
|
"gioui.org/io/clipboard"
|
||||||
|
"gioui.org/io/event"
|
||||||
"gioui.org/io/key"
|
"gioui.org/io/key"
|
||||||
"gioui.org/io/pointer"
|
"gioui.org/io/pointer"
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
@@ -65,10 +66,17 @@ type Editor struct {
|
|||||||
caret struct {
|
caret struct {
|
||||||
on bool
|
on bool
|
||||||
scroll bool
|
scroll bool
|
||||||
// pos is the current caret position.
|
// start is the current caret position, and also the start position of
|
||||||
pos combinedPos
|
// selected text. end is the end positon of selected text. If start.ofs
|
||||||
|
// == end.ofs, then there's no selection. Note that it's possible (and
|
||||||
|
// common) that the caret (start) is after the end, e.g. after
|
||||||
|
// Shift-DownArrow.
|
||||||
|
start combinedPos
|
||||||
|
end combinedPos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dragging bool
|
||||||
|
dragger gesture.Drag
|
||||||
scroller gesture.Scroll
|
scroller gesture.Scroll
|
||||||
scrollOff image.Point
|
scrollOff image.Point
|
||||||
|
|
||||||
@@ -107,6 +115,13 @@ type combinedPos struct {
|
|||||||
xoff fixed.Int26_6
|
xoff fixed.Int26_6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type selectionAction int
|
||||||
|
|
||||||
|
const (
|
||||||
|
selectionExtend selectionAction = iota
|
||||||
|
selectionClear
|
||||||
|
)
|
||||||
|
|
||||||
func (m *maskReader) Reset(r io.RuneReader, mr rune) {
|
func (m *maskReader) Reset(r io.RuneReader, mr rune) {
|
||||||
m.rr = r
|
m.rr = r
|
||||||
n := utf8.EncodeRune(m.maskBuf[:], mr)
|
n := utf8.EncodeRune(m.maskBuf[:], mr)
|
||||||
@@ -153,9 +168,20 @@ type SubmitEvent struct {
|
|||||||
Text string
|
Text string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A SelectEvent is generated when the user selects some text, or changes the
|
||||||
|
// selection (e.g. with a shift-click), including if they remove the
|
||||||
|
// selection. The selected text is not part of the event, on the theory that
|
||||||
|
// it could be a relatively expensive operation (for a large editor), most
|
||||||
|
// applications won't actually care about it, and those that do can call
|
||||||
|
// Editor.SelectedText() (which can be empty).
|
||||||
|
type SelectEvent struct{}
|
||||||
|
|
||||||
type line struct {
|
type line struct {
|
||||||
offset image.Point
|
offset image.Point
|
||||||
clip op.CallOp
|
clip op.CallOp
|
||||||
|
selected bool
|
||||||
|
selectionYOffs int
|
||||||
|
selectionSize image.Point
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -181,8 +207,13 @@ func (e *Editor) processEvents(gtx layout.Context) {
|
|||||||
// Can't process events without a shaper.
|
// Can't process events without a shaper.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
oldStart, oldLen := min(e.caret.start.ofs, e.caret.end.ofs), e.SelectionLen()
|
||||||
e.processPointer(gtx)
|
e.processPointer(gtx)
|
||||||
e.processKey(gtx)
|
e.processKey(gtx)
|
||||||
|
// Queue a SelectEvent if the selection changed, including if it went away.
|
||||||
|
if newStart, newLen := min(e.caret.start.ofs, e.caret.end.ofs), e.SelectionLen(); oldStart != newStart || oldLen != newLen {
|
||||||
|
e.events = append(e.events, SelectEvent{})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Editor) makeValid(positions ...*combinedPos) {
|
func (e *Editor) makeValid(positions ...*combinedPos) {
|
||||||
@@ -190,19 +221,7 @@ func (e *Editor) makeValid(positions ...*combinedPos) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.lines, e.dims = e.layoutText(e.shaper)
|
e.lines, e.dims = e.layoutText(e.shaper)
|
||||||
|
e.makeValidCaret(positions...)
|
||||||
// Jump through some hoops to order the offsets given to offsetToScreenPos,
|
|
||||||
// but still be able to update them correctly with the results thereof.
|
|
||||||
positions = append(positions, &e.caret.pos)
|
|
||||||
sort.Slice(positions, func(i, j int) bool {
|
|
||||||
return positions[i].ofs < positions[j].ofs
|
|
||||||
})
|
|
||||||
var iter func(offset int) combinedPos
|
|
||||||
*positions[0], iter = e.offsetToScreenPos(positions[0].ofs)
|
|
||||||
for _, cp := range positions[1:] {
|
|
||||||
*cp = iter(cp.ofs)
|
|
||||||
}
|
|
||||||
|
|
||||||
e.valid = true
|
e.valid = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,26 +245,80 @@ func (e *Editor) processPointer(gtx layout.Context) {
|
|||||||
e.scrollRel(0, sdist)
|
e.scrollRel(0, sdist)
|
||||||
soff = e.scrollOff.Y
|
soff = e.scrollOff.Y
|
||||||
}
|
}
|
||||||
for _, evt := range e.clicker.Events(gtx) {
|
for _, evt := range e.clickDragEvents(gtx) {
|
||||||
switch {
|
switch evt := evt.(type) {
|
||||||
case evt.Type == gesture.TypePress && evt.Source == pointer.Mouse,
|
case gesture.ClickEvent:
|
||||||
evt.Type == gesture.TypeClick && evt.Source == pointer.Touch:
|
switch {
|
||||||
e.blinkStart = gtx.Now
|
case evt.Type == gesture.TypePress && evt.Source == pointer.Mouse,
|
||||||
e.moveCoord(image.Point{
|
evt.Type == gesture.TypeClick:
|
||||||
X: int(math.Round(float64(evt.Position.X))),
|
prevCaretPos := e.caret.start
|
||||||
Y: int(math.Round(float64(evt.Position.Y))),
|
e.blinkStart = gtx.Now
|
||||||
})
|
e.moveCoord(image.Point{
|
||||||
e.requestFocus = true
|
X: int(math.Round(float64(evt.Position.X))),
|
||||||
if e.scroller.State() != gesture.StateFlinging {
|
Y: int(math.Round(float64(evt.Position.Y))),
|
||||||
e.caret.scroll = true
|
})
|
||||||
|
e.requestFocus = true
|
||||||
|
if e.scroller.State() != gesture.StateFlinging {
|
||||||
|
e.caret.scroll = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if evt.Modifiers == key.ModShift {
|
||||||
|
// If they clicked closer to the end, then change the end to
|
||||||
|
// where the caret used to be (effectively swapping start & end).
|
||||||
|
if abs(e.caret.end.ofs-e.caret.start.ofs) < abs(e.caret.start.ofs-prevCaretPos.ofs) {
|
||||||
|
e.caret.end = prevCaretPos
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e.ClearSelection()
|
||||||
|
}
|
||||||
|
e.dragging = true
|
||||||
|
|
||||||
|
// Process a double-click.
|
||||||
|
if evt.NumClicks == 2 {
|
||||||
|
e.moveWord(-1, selectionClear)
|
||||||
|
e.moveWord(1, selectionExtend)
|
||||||
|
e.dragging = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case pointer.Event:
|
||||||
|
release := false
|
||||||
|
switch {
|
||||||
|
case evt.Type == pointer.Release && evt.Source == pointer.Mouse:
|
||||||
|
release = true
|
||||||
|
fallthrough
|
||||||
|
case evt.Type == pointer.Drag && evt.Source == pointer.Mouse:
|
||||||
|
if e.dragging {
|
||||||
|
e.blinkStart = gtx.Now
|
||||||
|
e.moveCoord(image.Point{
|
||||||
|
X: int(math.Round(float64(evt.Position.X))),
|
||||||
|
Y: int(math.Round(float64(evt.Position.Y))),
|
||||||
|
})
|
||||||
|
e.caret.scroll = true
|
||||||
|
|
||||||
|
if release {
|
||||||
|
e.dragging = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sdist > 0 && soff >= smax) || (sdist < 0 && soff <= smin) {
|
if (sdist > 0 && soff >= smax) || (sdist < 0 && soff <= smin) {
|
||||||
e.scroller.Stop()
|
e.scroller.Stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Editor) clickDragEvents(gtx layout.Context) []event.Event {
|
||||||
|
var combinedEvents []event.Event
|
||||||
|
for _, evt := range e.clicker.Events(gtx) {
|
||||||
|
combinedEvents = append(combinedEvents, evt)
|
||||||
|
}
|
||||||
|
for _, evt := range e.dragger.Events(gtx.Metric, gtx, gesture.Both) {
|
||||||
|
combinedEvents = append(combinedEvents, evt)
|
||||||
|
}
|
||||||
|
return combinedEvents
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Editor) processKey(gtx layout.Context) {
|
func (e *Editor) processKey(gtx layout.Context) {
|
||||||
if e.rr.Changed() {
|
if e.rr.Changed() {
|
||||||
e.events = append(e.events, ChangeEvent{})
|
e.events = append(e.events, ChangeEvent{})
|
||||||
@@ -287,8 +360,9 @@ func (e *Editor) processKey(gtx layout.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Editor) moveLines(distance int) {
|
func (e *Editor) moveLines(distance int, selAct selectionAction) {
|
||||||
e.caret.pos = e.movePosToLine(e.caret.pos, e.caret.pos.x+e.caret.pos.xoff, e.caret.pos.lineCol.Y+distance)
|
e.caret.start = e.movePosToLine(e.caret.start, e.caret.start.x+e.caret.start.xoff, e.caret.start.lineCol.Y+distance)
|
||||||
|
e.updateSelection(selAct)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Editor) command(gtx layout.Context, k key.Event) bool {
|
func (e *Editor) command(gtx layout.Context, k key.Event) bool {
|
||||||
@@ -297,6 +371,10 @@ func (e *Editor) command(gtx layout.Context, k key.Event) bool {
|
|||||||
modSkip = key.ModAlt
|
modSkip = key.ModAlt
|
||||||
}
|
}
|
||||||
moveByWord := k.Modifiers.Contain(modSkip)
|
moveByWord := k.Modifiers.Contain(modSkip)
|
||||||
|
selAct := selectionClear
|
||||||
|
if k.Modifiers.Contain(key.ModShift) {
|
||||||
|
selAct = selectionExtend
|
||||||
|
}
|
||||||
switch k.Name {
|
switch k.Name {
|
||||||
case key.NameReturn, key.NameEnter:
|
case key.NameReturn, key.NameEnter:
|
||||||
e.append("\n")
|
e.append("\n")
|
||||||
@@ -313,29 +391,35 @@ func (e *Editor) command(gtx layout.Context, k key.Event) bool {
|
|||||||
e.Delete(1)
|
e.Delete(1)
|
||||||
}
|
}
|
||||||
case key.NameUpArrow:
|
case key.NameUpArrow:
|
||||||
e.moveLines(-1)
|
e.moveLines(-1, selAct)
|
||||||
case key.NameDownArrow:
|
case key.NameDownArrow:
|
||||||
e.moveLines(+1)
|
e.moveLines(+1, selAct)
|
||||||
case key.NameLeftArrow:
|
case key.NameLeftArrow:
|
||||||
if moveByWord {
|
if moveByWord {
|
||||||
e.moveWord(-1)
|
e.moveWord(-1, selAct)
|
||||||
} else {
|
} else {
|
||||||
e.MoveCaret(-1)
|
if selAct == selectionClear {
|
||||||
|
e.ClearSelection()
|
||||||
|
}
|
||||||
|
e.MoveCaret(-1, -1*int(selAct))
|
||||||
}
|
}
|
||||||
case key.NameRightArrow:
|
case key.NameRightArrow:
|
||||||
if moveByWord {
|
if moveByWord {
|
||||||
e.moveWord(1)
|
e.moveWord(1, selAct)
|
||||||
} else {
|
} else {
|
||||||
e.MoveCaret(1)
|
if selAct == selectionClear {
|
||||||
|
e.ClearSelection()
|
||||||
|
}
|
||||||
|
e.MoveCaret(1, int(selAct))
|
||||||
}
|
}
|
||||||
case key.NamePageUp:
|
case key.NamePageUp:
|
||||||
e.movePages(-1)
|
e.movePages(-1, selAct)
|
||||||
case key.NamePageDown:
|
case key.NamePageDown:
|
||||||
e.movePages(+1)
|
e.movePages(+1, selAct)
|
||||||
case key.NameHome:
|
case key.NameHome:
|
||||||
e.moveStart()
|
e.moveStart(selAct)
|
||||||
case key.NameEnd:
|
case key.NameEnd:
|
||||||
e.moveEnd()
|
e.moveEnd(selAct)
|
||||||
// Initiate a paste operation, by requesting the clipboard contents; other
|
// Initiate a paste operation, by requesting the clipboard contents; other
|
||||||
// half is in Editor.processKey() under clipboard.Event.
|
// half is in Editor.processKey() under clipboard.Event.
|
||||||
case "V":
|
case "V":
|
||||||
@@ -343,12 +427,23 @@ func (e *Editor) command(gtx layout.Context, k key.Event) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
clipboard.ReadOp{Tag: &e.eventKey}.Add(gtx.Ops)
|
clipboard.ReadOp{Tag: &e.eventKey}.Add(gtx.Ops)
|
||||||
// Copy all text.
|
// Copy or Cut selection -- ignored if nothing selected.
|
||||||
case "C":
|
case "C", "X":
|
||||||
if k.Modifiers != key.ModShortcut {
|
if k.Modifiers != key.ModShortcut {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
clipboard.WriteOp{Text: e.Text()}.Add(gtx.Ops)
|
if text := e.SelectedText(); text != "" {
|
||||||
|
clipboard.WriteOp{Text: text}.Add(gtx.Ops)
|
||||||
|
if k.Name == "X" {
|
||||||
|
e.Delete(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Select all
|
||||||
|
case "A":
|
||||||
|
if k.Modifiers != key.ModShortcut {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
e.caret.end, e.caret.start = e.offsetToScreenPos2(0, e.Len())
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -418,7 +513,10 @@ func (e *Editor) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
clip := textPadding(e.lines)
|
clip := textPadding(e.lines)
|
||||||
clip.Max = clip.Max.Add(e.viewSize)
|
clip.Max = clip.Max.Add(e.viewSize)
|
||||||
it := lineIterator{
|
startSel, endSel := sortPoints(e.caret.start.lineCol, e.caret.end.lineCol)
|
||||||
|
it := segmentIterator{
|
||||||
|
startSel: startSel,
|
||||||
|
endSel: endSel,
|
||||||
Lines: e.lines,
|
Lines: e.lines,
|
||||||
Clip: clip,
|
Clip: clip,
|
||||||
Alignment: e.Alignment,
|
Alignment: e.Alignment,
|
||||||
@@ -427,12 +525,12 @@ func (e *Editor) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
e.shapes = e.shapes[:0]
|
e.shapes = e.shapes[:0]
|
||||||
for {
|
for {
|
||||||
layout, off, ok := it.Next()
|
layout, off, selected, yOffs, size, ok := it.Next()
|
||||||
if !ok {
|
if !ok {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
path := e.shaper.Shape(e.font, e.textSize, layout)
|
path := e.shaper.Shape(e.font, e.textSize, layout)
|
||||||
e.shapes = append(e.shapes, line{off, path})
|
e.shapes = append(e.shapes, line{off, path, selected, yOffs, size})
|
||||||
}
|
}
|
||||||
|
|
||||||
key.InputOp{Tag: &e.eventKey}.Add(gtx.Ops)
|
key.InputOp{Tag: &e.eventKey}.Add(gtx.Ops)
|
||||||
@@ -451,6 +549,7 @@ func (e *Editor) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
pointer.CursorNameOp{Name: pointer.CursorText}.Add(gtx.Ops)
|
pointer.CursorNameOp{Name: pointer.CursorText}.Add(gtx.Ops)
|
||||||
e.scroller.Add(gtx.Ops)
|
e.scroller.Add(gtx.Ops)
|
||||||
e.clicker.Add(gtx.Ops)
|
e.clicker.Add(gtx.Ops)
|
||||||
|
e.dragger.Add(gtx.Ops)
|
||||||
e.caret.on = false
|
e.caret.on = false
|
||||||
if e.focused {
|
if e.focused {
|
||||||
now := gtx.Now
|
now := gtx.Now
|
||||||
@@ -468,14 +567,33 @@ func (e *Editor) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
return layout.Dimensions{Size: e.viewSize, Baseline: e.dims.Baseline}
|
return layout.Dimensions{Size: e.viewSize, Baseline: e.dims.Baseline}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaintSelection paints the contrasting background for selected text.
|
||||||
|
func (e *Editor) PaintSelection(gtx layout.Context) {
|
||||||
|
cl := textPadding(e.lines)
|
||||||
|
cl.Max = cl.Max.Add(e.viewSize)
|
||||||
|
clip.Rect(cl).Add(gtx.Ops)
|
||||||
|
for _, shape := range e.shapes {
|
||||||
|
if !shape.selected {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stack := op.Save(gtx.Ops)
|
||||||
|
offset := shape.offset
|
||||||
|
offset.Y += shape.selectionYOffs
|
||||||
|
op.Offset(layout.FPt(offset)).Add(gtx.Ops)
|
||||||
|
clip.Rect(image.Rectangle{Max: shape.selectionSize}).Add(gtx.Ops)
|
||||||
|
paint.PaintOp{}.Add(gtx.Ops)
|
||||||
|
stack.Load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Editor) PaintText(gtx layout.Context) {
|
func (e *Editor) PaintText(gtx layout.Context) {
|
||||||
cl := textPadding(e.lines)
|
cl := textPadding(e.lines)
|
||||||
cl.Max = cl.Max.Add(e.viewSize)
|
cl.Max = cl.Max.Add(e.viewSize)
|
||||||
|
clip.Rect(cl).Add(gtx.Ops)
|
||||||
for _, shape := range e.shapes {
|
for _, shape := range e.shapes {
|
||||||
stack := op.Save(gtx.Ops)
|
stack := op.Save(gtx.Ops)
|
||||||
op.Offset(layout.FPt(shape.offset)).Add(gtx.Ops)
|
op.Offset(layout.FPt(shape.offset)).Add(gtx.Ops)
|
||||||
shape.clip.Add(gtx.Ops)
|
shape.clip.Add(gtx.Ops)
|
||||||
clip.Rect(cl.Sub(shape.offset)).Add(gtx.Ops)
|
|
||||||
paint.PaintOp{}.Add(gtx.Ops)
|
paint.PaintOp{}.Add(gtx.Ops)
|
||||||
stack.Load()
|
stack.Load()
|
||||||
}
|
}
|
||||||
@@ -487,12 +605,12 @@ func (e *Editor) PaintCaret(gtx layout.Context) {
|
|||||||
}
|
}
|
||||||
e.makeValid()
|
e.makeValid()
|
||||||
carWidth := fixed.I(gtx.Px(unit.Dp(1)))
|
carWidth := fixed.I(gtx.Px(unit.Dp(1)))
|
||||||
carX := e.caret.pos.x
|
carX := e.caret.start.x
|
||||||
carY := e.caret.pos.y
|
carY := e.caret.start.y
|
||||||
|
|
||||||
defer op.Save(gtx.Ops).Load()
|
defer op.Save(gtx.Ops).Load()
|
||||||
carX -= carWidth / 2
|
carX -= carWidth / 2
|
||||||
carAsc, carDesc := -e.lines[e.caret.pos.lineCol.Y].Bounds.Min.Y, e.lines[e.caret.pos.lineCol.Y].Bounds.Max.Y
|
carAsc, carDesc := -e.lines[e.caret.start.lineCol.Y].Bounds.Min.Y, e.lines[e.caret.start.lineCol.Y].Bounds.Max.Y
|
||||||
carRect := image.Rectangle{
|
carRect := image.Rectangle{
|
||||||
Min: image.Point{X: carX.Ceil(), Y: carY - carAsc.Ceil()},
|
Min: image.Point{X: carX.Ceil(), Y: carY - carAsc.Ceil()},
|
||||||
Max: image.Point{X: carX.Ceil() + carWidth.Ceil(), Y: carY + carDesc.Ceil()},
|
Max: image.Point{X: carX.Ceil() + carWidth.Ceil(), Y: carY + carDesc.Ceil()},
|
||||||
@@ -530,10 +648,11 @@ func (e *Editor) Text() string {
|
|||||||
return e.rr.String()
|
return e.rr.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetText replaces the contents of the editor.
|
// SetText replaces the contents of the editor, clearing any selection first.
|
||||||
func (e *Editor) SetText(s string) {
|
func (e *Editor) SetText(s string) {
|
||||||
e.rr = editBuffer{}
|
e.rr = editBuffer{}
|
||||||
e.caret.pos = combinedPos{}
|
e.caret.start = combinedPos{}
|
||||||
|
e.caret.end = combinedPos{}
|
||||||
e.prepend(s)
|
e.prepend(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,8 +709,8 @@ func (e *Editor) moveCoord(pos image.Point) {
|
|||||||
carLine++
|
carLine++
|
||||||
}
|
}
|
||||||
x := fixed.I(pos.X + e.scrollOff.X)
|
x := fixed.I(pos.X + e.scrollOff.X)
|
||||||
e.caret.pos = e.movePosToLine(e.caret.pos, x, carLine)
|
e.caret.start = e.movePosToLine(e.caret.start, x, carLine)
|
||||||
e.caret.pos.xoff = 0
|
e.caret.start.xoff = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Editor) layoutText(s text.Shaper) ([]text.Line, layout.Dimensions) {
|
func (e *Editor) layoutText(s text.Shaper) ([]text.Line, layout.Dimensions) {
|
||||||
@@ -625,14 +744,21 @@ func (e *Editor) layoutText(s text.Shaper) ([]text.Line, layout.Dimensions) {
|
|||||||
// CaretPos returns the line & column numbers of the caret.
|
// CaretPos returns the line & column numbers of the caret.
|
||||||
func (e *Editor) CaretPos() (line, col int) {
|
func (e *Editor) CaretPos() (line, col int) {
|
||||||
e.makeValid()
|
e.makeValid()
|
||||||
return e.caret.pos.lineCol.Y, e.caret.pos.lineCol.X
|
return e.caret.start.lineCol.Y, e.caret.start.lineCol.X
|
||||||
}
|
}
|
||||||
|
|
||||||
// CaretCoords returns the coordinates of the caret, relative to the
|
// CaretCoords returns the coordinates of the caret, relative to the
|
||||||
// editor itself.
|
// editor itself.
|
||||||
func (e *Editor) CaretCoords() f32.Point {
|
func (e *Editor) CaretCoords() f32.Point {
|
||||||
e.makeValid()
|
e.makeValid()
|
||||||
return f32.Pt(float32(e.caret.pos.x)/64, float32(e.caret.pos.y))
|
return f32.Pt(float32(e.caret.start.x)/64, float32(e.caret.start.y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// offsetToScreenPos2 is a utility function to shortcut the common case of
|
||||||
|
// wanting the positions of exactly two offsets.
|
||||||
|
func (e *Editor) offsetToScreenPos2(o1, o2 int) (combinedPos, combinedPos) {
|
||||||
|
cp1, iter := e.offsetToScreenPos(o1)
|
||||||
|
return cp1, iter(o2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// offsetToScreenPos takes an offset into the editor text (e.g.
|
// offsetToScreenPos takes an offset into the editor text (e.g.
|
||||||
@@ -692,42 +818,56 @@ func (e *Editor) invalidate() {
|
|||||||
|
|
||||||
// Delete runes from the caret position. The sign of runes specifies the
|
// Delete runes from the caret position. The sign of runes specifies the
|
||||||
// direction to delete: positive is forward, negative is backward.
|
// direction to delete: positive is forward, negative is backward.
|
||||||
|
//
|
||||||
|
// If there is a selection, it is deleted and counts as a single rune.
|
||||||
func (e *Editor) Delete(runes int) {
|
func (e *Editor) Delete(runes int) {
|
||||||
if runes == 0 {
|
if runes == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.caret.pos.ofs = e.rr.deleteRunes(e.caret.pos.ofs, runes)
|
|
||||||
e.caret.pos.xoff = 0
|
if l := e.caret.end.ofs - e.caret.start.ofs; l != 0 {
|
||||||
|
e.caret.start.ofs = e.rr.deleteRunes(e.caret.start.ofs, l)
|
||||||
|
runes -= sign(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.caret.start.ofs = e.rr.deleteRunes(e.caret.start.ofs, runes)
|
||||||
|
e.caret.start.xoff = 0
|
||||||
|
e.ClearSelection()
|
||||||
e.invalidate()
|
e.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert inserts text at the caret, moving the caret forward.
|
// Insert inserts text at the caret, moving the caret forward. If there is a
|
||||||
|
// selection, Insert overwrites it.
|
||||||
func (e *Editor) Insert(s string) {
|
func (e *Editor) Insert(s string) {
|
||||||
e.append(s)
|
e.append(s)
|
||||||
e.caret.scroll = true
|
e.caret.scroll = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// append inserts s at the cursor, leaving the caret is at the end of s.
|
// append inserts s at the cursor, leaving the caret is at the end of s. If
|
||||||
|
// there is a selection, append overwrites it.
|
||||||
// xxx|yyy + append zzz => xxxzzz|yyy
|
// xxx|yyy + append zzz => xxxzzz|yyy
|
||||||
func (e *Editor) append(s string) {
|
func (e *Editor) append(s string) {
|
||||||
e.prepend(s)
|
e.prepend(s)
|
||||||
e.caret.pos.ofs += len(s)
|
e.caret.start.ofs += len(s)
|
||||||
|
e.caret.end.ofs = e.caret.start.ofs
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepend inserts s after the cursor; the caret does not change.
|
// prepend inserts s after the cursor; the caret does not change. If there is
|
||||||
|
// a selection, prepend overwrites it.
|
||||||
// xxx|yyy + prepend zzz => xxx|zzzyyy
|
// xxx|yyy + prepend zzz => xxx|zzzyyy
|
||||||
func (e *Editor) prepend(s string) {
|
func (e *Editor) prepend(s string) {
|
||||||
if e.SingleLine {
|
if e.SingleLine {
|
||||||
s = strings.ReplaceAll(s, "\n", " ")
|
s = strings.ReplaceAll(s, "\n", " ")
|
||||||
}
|
}
|
||||||
e.rr.prepend(e.caret.pos.ofs, s)
|
e.caret.start.ofs = e.rr.deleteRunes(e.caret.start.ofs, e.caret.end.ofs-e.caret.start.ofs) // Delete any selection first.
|
||||||
e.caret.pos.xoff = 0
|
e.rr.prepend(e.caret.start.ofs, s)
|
||||||
|
e.caret.start.xoff = 0
|
||||||
e.invalidate()
|
e.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Editor) movePages(pages int) {
|
func (e *Editor) movePages(pages int, selAct selectionAction) {
|
||||||
e.makeValid()
|
e.makeValid()
|
||||||
y := e.caret.pos.y + pages*e.viewSize.Y
|
y := e.caret.start.y + pages*e.viewSize.Y
|
||||||
var (
|
var (
|
||||||
prevDesc fixed.Int26_6
|
prevDesc fixed.Int26_6
|
||||||
carLine2 int
|
carLine2 int
|
||||||
@@ -746,7 +886,8 @@ func (e *Editor) movePages(pages int) {
|
|||||||
y2 += h
|
y2 += h
|
||||||
carLine2++
|
carLine2++
|
||||||
}
|
}
|
||||||
e.caret.pos = e.movePosToLine(e.caret.pos, e.caret.pos.x+e.caret.pos.xoff, carLine2)
|
e.caret.start = e.movePosToLine(e.caret.start, e.caret.start.x+e.caret.start.xoff, carLine2)
|
||||||
|
e.updateSelection(selAct)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Editor) movePosToLine(pos combinedPos, x fixed.Int26_6, line int) combinedPos {
|
func (e *Editor) movePosToLine(pos combinedPos, x fixed.Int26_6, line int) combinedPos {
|
||||||
@@ -807,13 +948,22 @@ func (e *Editor) movePosToLine(pos combinedPos, x fixed.Int26_6, line int) combi
|
|||||||
return pos
|
return pos
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveCaret moves the caret relative to its current position. Positive
|
// MoveCaret moves the caret (aka selection start) and the selection end
|
||||||
// distance moves forward, negative distance moves backward. Distance is in
|
// relative to their current positions. Positive distances moves forward,
|
||||||
// runes.
|
// negative distances moves backward. Distances are in runes.
|
||||||
func (e *Editor) MoveCaret(distance int) {
|
func (e *Editor) MoveCaret(startDelta, endDelta int) {
|
||||||
e.makeValid()
|
e.makeValid()
|
||||||
e.caret.pos = e.movePos(e.caret.pos, distance)
|
keepSame := e.caret.start.ofs == e.caret.end.ofs && startDelta == endDelta
|
||||||
e.caret.pos.xoff = 0
|
e.caret.start = e.movePos(e.caret.start, startDelta)
|
||||||
|
e.caret.start.xoff = 0
|
||||||
|
// If they were in the same place, and we're moving them the same distance,
|
||||||
|
// just assign the new position, instead of recalculating it.
|
||||||
|
if keepSame {
|
||||||
|
e.caret.end = e.caret.start
|
||||||
|
} else {
|
||||||
|
e.caret.end = e.movePos(e.caret.end, endDelta)
|
||||||
|
e.caret.end.xoff = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Editor) movePos(pos combinedPos, distance int) combinedPos {
|
func (e *Editor) movePos(pos combinedPos, distance int) combinedPos {
|
||||||
@@ -849,8 +999,9 @@ func (e *Editor) movePos(pos combinedPos, distance int) combinedPos {
|
|||||||
return pos
|
return pos
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Editor) moveStart() {
|
func (e *Editor) moveStart(selAct selectionAction) {
|
||||||
e.caret.pos = e.movePosToStart(e.caret.pos)
|
e.caret.start = e.movePosToStart(e.caret.start)
|
||||||
|
e.updateSelection(selAct)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Editor) movePosToStart(pos combinedPos) combinedPos {
|
func (e *Editor) movePosToStart(pos combinedPos) combinedPos {
|
||||||
@@ -866,8 +1017,9 @@ func (e *Editor) movePosToStart(pos combinedPos) combinedPos {
|
|||||||
return pos
|
return pos
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Editor) moveEnd() {
|
func (e *Editor) moveEnd(selAct selectionAction) {
|
||||||
e.caret.pos = e.movePosToEnd(e.caret.pos)
|
e.caret.start = e.movePosToEnd(e.caret.start)
|
||||||
|
e.updateSelection(selAct)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Editor) movePosToEnd(pos combinedPos) combinedPos {
|
func (e *Editor) movePosToEnd(pos combinedPos) combinedPos {
|
||||||
@@ -894,7 +1046,7 @@ func (e *Editor) movePosToEnd(pos combinedPos) combinedPos {
|
|||||||
// moveWord moves the caret to the next word in the specified direction.
|
// moveWord moves the caret to the next word in the specified direction.
|
||||||
// Positive is forward, negative is backward.
|
// Positive is forward, negative is backward.
|
||||||
// Absolute values greater than one will skip that many words.
|
// Absolute values greater than one will skip that many words.
|
||||||
func (e *Editor) moveWord(distance int) {
|
func (e *Editor) moveWord(distance int, selAct selectionAction) {
|
||||||
e.makeValid()
|
e.makeValid()
|
||||||
// split the distance information into constituent parts to be
|
// split the distance information into constituent parts to be
|
||||||
// used independently.
|
// used independently.
|
||||||
@@ -904,38 +1056,49 @@ func (e *Editor) moveWord(distance int) {
|
|||||||
}
|
}
|
||||||
// atEnd if caret is at either side of the buffer.
|
// atEnd if caret is at either side of the buffer.
|
||||||
atEnd := func() bool {
|
atEnd := func() bool {
|
||||||
return e.caret.pos.ofs == 0 || e.caret.pos.ofs == e.rr.len()
|
return e.caret.start.ofs == 0 || e.caret.start.ofs == e.rr.len()
|
||||||
}
|
}
|
||||||
// next returns the appropriate rune given the direction.
|
// next returns the appropriate rune given the direction.
|
||||||
next := func() (r rune) {
|
next := func() (r rune) {
|
||||||
if direction < 0 {
|
if direction < 0 {
|
||||||
r, _ = e.rr.runeBefore(e.caret.pos.ofs)
|
r, _ = e.rr.runeBefore(e.caret.start.ofs)
|
||||||
} else {
|
} else {
|
||||||
r, _ = e.rr.runeAt(e.caret.pos.ofs)
|
r, _ = e.rr.runeAt(e.caret.start.ofs)
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
for ii := 0; ii < words; ii++ {
|
for ii := 0; ii < words; ii++ {
|
||||||
for r := next(); unicode.IsSpace(r) && !atEnd(); r = next() {
|
for r := next(); unicode.IsSpace(r) && !atEnd(); r = next() {
|
||||||
e.MoveCaret(direction)
|
e.MoveCaret(direction, 0)
|
||||||
}
|
}
|
||||||
e.MoveCaret(direction)
|
e.MoveCaret(direction, 0)
|
||||||
for r := next(); !unicode.IsSpace(r) && !atEnd(); r = next() {
|
for r := next(); !unicode.IsSpace(r) && !atEnd(); r = next() {
|
||||||
e.MoveCaret(direction)
|
e.MoveCaret(direction, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
e.updateSelection(selAct)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteWord deletes the next word(s) in the specified direction.
|
// deleteWord deletes the next word(s) in the specified direction.
|
||||||
// Unlike moveWord, deleteWord treats whitespace as a word itself.
|
// Unlike moveWord, deleteWord treats whitespace as a word itself.
|
||||||
// Positive is forward, negative is backward.
|
// Positive is forward, negative is backward.
|
||||||
// Absolute values greater than one will delete that many words.
|
// Absolute values greater than one will delete that many words.
|
||||||
|
// The selection counts as a single word.
|
||||||
func (e *Editor) deleteWord(distance int) {
|
func (e *Editor) deleteWord(distance int) {
|
||||||
if distance == 0 {
|
if distance == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e.makeValid()
|
e.makeValid()
|
||||||
|
|
||||||
|
if e.caret.start.ofs != e.caret.end.ofs {
|
||||||
|
e.Delete(1)
|
||||||
|
distance -= sign(distance)
|
||||||
|
}
|
||||||
|
if distance == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// split the distance information into constituent parts to be
|
// split the distance information into constituent parts to be
|
||||||
// used independently.
|
// used independently.
|
||||||
words, direction := distance, 1
|
words, direction := distance, 1
|
||||||
@@ -944,12 +1107,12 @@ func (e *Editor) deleteWord(distance int) {
|
|||||||
}
|
}
|
||||||
// atEnd if offset is at or beyond either side of the buffer.
|
// atEnd if offset is at or beyond either side of the buffer.
|
||||||
atEnd := func(offset int) bool {
|
atEnd := func(offset int) bool {
|
||||||
idx := e.caret.pos.ofs + offset*direction
|
idx := e.caret.start.ofs + offset*direction
|
||||||
return idx <= 0 || idx >= e.rr.len()
|
return idx <= 0 || idx >= e.rr.len()
|
||||||
}
|
}
|
||||||
// next returns the appropriate rune given the direction and offset.
|
// next returns the appropriate rune given the direction and offset.
|
||||||
next := func(offset int) (r rune) {
|
next := func(offset int) (r rune) {
|
||||||
idx := e.caret.pos.ofs + offset*direction
|
idx := e.caret.start.ofs + offset*direction
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
idx = 0
|
idx = 0
|
||||||
} else if idx > e.rr.len() {
|
} else if idx > e.rr.len() {
|
||||||
@@ -979,18 +1142,18 @@ func (e *Editor) deleteWord(distance int) {
|
|||||||
|
|
||||||
func (e *Editor) scrollToCaret() {
|
func (e *Editor) scrollToCaret() {
|
||||||
e.makeValid()
|
e.makeValid()
|
||||||
l := e.lines[e.caret.pos.lineCol.Y]
|
l := e.lines[e.caret.start.lineCol.Y]
|
||||||
if e.SingleLine {
|
if e.SingleLine {
|
||||||
var dist int
|
var dist int
|
||||||
if d := e.caret.pos.x.Floor() - e.scrollOff.X; d < 0 {
|
if d := e.caret.start.x.Floor() - e.scrollOff.X; d < 0 {
|
||||||
dist = d
|
dist = d
|
||||||
} else if d := e.caret.pos.x.Ceil() - (e.scrollOff.X + e.viewSize.X); d > 0 {
|
} else if d := e.caret.start.x.Ceil() - (e.scrollOff.X + e.viewSize.X); d > 0 {
|
||||||
dist = d
|
dist = d
|
||||||
}
|
}
|
||||||
e.scrollRel(dist, 0)
|
e.scrollRel(dist, 0)
|
||||||
} else {
|
} else {
|
||||||
miny := e.caret.pos.y - l.Ascent.Ceil()
|
miny := e.caret.start.y - l.Ascent.Ceil()
|
||||||
maxy := e.caret.pos.y + l.Descent.Ceil()
|
maxy := e.caret.start.y + l.Descent.Ceil()
|
||||||
var dist int
|
var dist int
|
||||||
if d := miny - e.scrollOff.Y; d < 0 {
|
if d := miny - e.scrollOff.Y; d < 0 {
|
||||||
dist = d
|
dist = d
|
||||||
@@ -1007,16 +1170,75 @@ func (e *Editor) NumLines() int {
|
|||||||
return len(e.lines)
|
return len(e.lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCaret moves the caret to ofs. ofs is in bytes, and represent an offset
|
// SelectionLen returns the length of the selection, in bytes; it is
|
||||||
// into the editor text. ofs must be at a rune boundary.
|
// equivalent to len(e.SelectedText()).
|
||||||
func (e *Editor) SetCaret(ofs int) {
|
func (e *Editor) SelectionLen() int {
|
||||||
e.makeValid()
|
return abs(e.caret.start.ofs - e.caret.end.ofs)
|
||||||
// Constrain ofs to [0, e.Len()].
|
}
|
||||||
e.caret.pos, _ = e.offsetToScreenPos(max(min(ofs, e.Len()), 0))
|
|
||||||
|
// Selection returns the start and end of the selection, as offsets into the
|
||||||
|
// editor text. start can be > end.
|
||||||
|
func (e *Editor) Selection() (start, end int) {
|
||||||
|
return e.caret.start.ofs, e.caret.end.ofs
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCaret moves the caret to start, and sets the selection end to end. start
|
||||||
|
// and end are in bytes, and represent offsets into the editor text. start and
|
||||||
|
// end must be at a rune boundary.
|
||||||
|
func (e *Editor) SetCaret(start, end int) {
|
||||||
|
// Constrain start and end to [0, e.Len()].
|
||||||
|
l := e.Len()
|
||||||
|
start = max(min(start, l), 0)
|
||||||
|
end = max(min(end, l), 0)
|
||||||
|
e.caret.start.ofs, e.caret.end.ofs = start, end
|
||||||
|
e.makeValidCaret()
|
||||||
e.caret.scroll = true
|
e.caret.scroll = true
|
||||||
e.scroller.Stop()
|
e.scroller.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Editor) makeValidCaret(positions ...*combinedPos) {
|
||||||
|
// Jump through some hoops to order the offsets given to offsetToScreenPos,
|
||||||
|
// but still be able to update them correctly with the results thereof.
|
||||||
|
positions = append(positions, &e.caret.start, &e.caret.end)
|
||||||
|
sort.Slice(positions, func(i, j int) bool {
|
||||||
|
return positions[i].ofs < positions[j].ofs
|
||||||
|
})
|
||||||
|
var iter func(offset int) combinedPos
|
||||||
|
*positions[0], iter = e.offsetToScreenPos(positions[0].ofs)
|
||||||
|
for _, cp := range positions[1:] {
|
||||||
|
*cp = iter(cp.ofs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectedText returns the currently selected text (if any) from the editor.
|
||||||
|
func (e *Editor) SelectedText() string {
|
||||||
|
l := e.SelectionLen()
|
||||||
|
if l == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
buf := make([]byte, l)
|
||||||
|
e.rr.Seek(int64(min(e.caret.start.ofs, e.caret.end.ofs)), io.SeekStart)
|
||||||
|
_, err := e.rr.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
// The only error that rr.Read can return is EOF, which just means no
|
||||||
|
// selection, but we've already made sure that shouldn't happen.
|
||||||
|
panic("impossible error because end is before e.rr.Len()")
|
||||||
|
}
|
||||||
|
return string(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Editor) updateSelection(selAct selectionAction) {
|
||||||
|
if selAct == selectionClear {
|
||||||
|
e.ClearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSelection clears the selection, by setting the selection end equal to
|
||||||
|
// the selection start.
|
||||||
|
func (e *Editor) ClearSelection() {
|
||||||
|
e.caret.end = e.caret.start
|
||||||
|
}
|
||||||
|
|
||||||
func max(a, b int) int {
|
func max(a, b int) int {
|
||||||
if a > b {
|
if a > b {
|
||||||
return a
|
return a
|
||||||
@@ -1031,6 +1253,32 @@ func min(a, b int) int {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func abs(n int) int {
|
||||||
|
if n < 0 {
|
||||||
|
return -n
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func sign(n int) int {
|
||||||
|
switch {
|
||||||
|
case n < 0:
|
||||||
|
return -1
|
||||||
|
case n > 0:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortPoints returns a and b sorted such that a2 <= b2.
|
||||||
|
func sortPoints(a, b screenPos) (a2, b2 screenPos) {
|
||||||
|
if b.Less(a) {
|
||||||
|
return b, a
|
||||||
|
}
|
||||||
|
return a, b
|
||||||
|
}
|
||||||
|
|
||||||
func nullLayout(r io.Reader) ([]text.Line, error) {
|
func nullLayout(r io.Reader) ([]text.Line, error) {
|
||||||
rr := bufio.NewReader(r)
|
rr := bufio.NewReader(r)
|
||||||
var rerr error
|
var rerr error
|
||||||
@@ -1057,3 +1305,4 @@ func nullLayout(r io.Reader) ([]text.Line, error) {
|
|||||||
|
|
||||||
func (s ChangeEvent) isEditorEvent() {}
|
func (s ChangeEvent) isEditorEvent() {}
|
||||||
func (s SubmitEvent) isEditorEvent() {}
|
func (s SubmitEvent) isEditorEvent() {}
|
||||||
|
func (s SelectEvent) isEditorEvent() {}
|
||||||
|
|||||||
+258
-46
@@ -7,6 +7,7 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/quick"
|
"testing/quick"
|
||||||
"unicode"
|
"unicode"
|
||||||
@@ -15,10 +16,12 @@ import (
|
|||||||
"gioui.org/font/gofont"
|
"gioui.org/font/gofont"
|
||||||
"gioui.org/io/event"
|
"gioui.org/io/event"
|
||||||
"gioui.org/io/key"
|
"gioui.org/io/key"
|
||||||
|
"gioui.org/io/pointer"
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/op"
|
"gioui.org/op"
|
||||||
"gioui.org/text"
|
"gioui.org/text"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEditor(t *testing.T) {
|
func TestEditor(t *testing.T) {
|
||||||
@@ -34,37 +37,37 @@ func TestEditor(t *testing.T) {
|
|||||||
e.SetText("æbc\naøå•")
|
e.SetText("æbc\naøå•")
|
||||||
e.Layout(gtx, cache, font, fontSize)
|
e.Layout(gtx, cache, font, fontSize)
|
||||||
assertCaret(t, e, 0, 0, 0)
|
assertCaret(t, e, 0, 0, 0)
|
||||||
e.moveEnd()
|
e.moveEnd(selectionClear)
|
||||||
assertCaret(t, e, 0, 3, len("æbc"))
|
assertCaret(t, e, 0, 3, len("æbc"))
|
||||||
e.MoveCaret(+1)
|
e.MoveCaret(+1, +1)
|
||||||
assertCaret(t, e, 1, 0, len("æbc\n"))
|
assertCaret(t, e, 1, 0, len("æbc\n"))
|
||||||
e.MoveCaret(-1)
|
e.MoveCaret(-1, -1)
|
||||||
assertCaret(t, e, 0, 3, len("æbc"))
|
assertCaret(t, e, 0, 3, len("æbc"))
|
||||||
e.moveLines(+1)
|
e.moveLines(+1, +1)
|
||||||
assertCaret(t, e, 1, 3, len("æbc\naøå"))
|
assertCaret(t, e, 1, 3, len("æbc\naøå"))
|
||||||
e.moveEnd()
|
e.moveEnd(selectionClear)
|
||||||
assertCaret(t, e, 1, 4, len("æbc\naøå•"))
|
assertCaret(t, e, 1, 4, len("æbc\naøå•"))
|
||||||
e.MoveCaret(+1)
|
e.MoveCaret(+1, +1)
|
||||||
assertCaret(t, e, 1, 4, len("æbc\naøå•"))
|
assertCaret(t, e, 1, 4, len("æbc\naøå•"))
|
||||||
|
|
||||||
e.SetCaret(0)
|
e.SetCaret(0, 0)
|
||||||
assertCaret(t, e, 0, 0, 0)
|
assertCaret(t, e, 0, 0, 0)
|
||||||
e.SetCaret(len("æ"))
|
e.SetCaret(len("æ"), len("æ"))
|
||||||
assertCaret(t, e, 0, 1, 2)
|
assertCaret(t, e, 0, 1, 2)
|
||||||
e.SetCaret(len("æbc\naøå•"))
|
e.SetCaret(len("æbc\naøå•"), len("æbc\naøå•"))
|
||||||
assertCaret(t, e, 1, 4, len("æbc\naøå•"))
|
assertCaret(t, e, 1, 4, len("æbc\naøå•"))
|
||||||
|
|
||||||
// Ensure that password masking does not affect caret behavior
|
// Ensure that password masking does not affect caret behavior
|
||||||
e.MoveCaret(-3)
|
e.MoveCaret(-3, -3)
|
||||||
assertCaret(t, e, 1, 1, len("æbc\na"))
|
assertCaret(t, e, 1, 1, len("æbc\na"))
|
||||||
e.Mask = '*'
|
e.Mask = '*'
|
||||||
e.Layout(gtx, cache, font, fontSize)
|
e.Layout(gtx, cache, font, fontSize)
|
||||||
assertCaret(t, e, 1, 1, len("æbc\na"))
|
assertCaret(t, e, 1, 1, len("æbc\na"))
|
||||||
e.MoveCaret(-3)
|
e.MoveCaret(-3, -3)
|
||||||
assertCaret(t, e, 0, 2, len("æb"))
|
assertCaret(t, e, 0, 2, len("æb"))
|
||||||
e.Mask = '\U0001F92B'
|
e.Mask = '\U0001F92B'
|
||||||
e.Layout(gtx, cache, font, fontSize)
|
e.Layout(gtx, cache, font, fontSize)
|
||||||
e.moveEnd()
|
e.moveEnd(selectionClear)
|
||||||
assertCaret(t, e, 0, 3, len("æbc"))
|
assertCaret(t, e, 0, 3, len("æbc"))
|
||||||
|
|
||||||
// When a password mask is applied, it should replace all visible glyphs
|
// When a password mask is applied, it should replace all visible glyphs
|
||||||
@@ -106,8 +109,8 @@ func assertCaret(t *testing.T, e *Editor, line, col, bytes int) {
|
|||||||
if gotLine != line || gotCol != col {
|
if gotLine != line || gotCol != col {
|
||||||
t.Errorf("caret at (%d, %d), expected (%d, %d)", gotLine, gotCol, line, col)
|
t.Errorf("caret at (%d, %d), expected (%d, %d)", gotLine, gotCol, line, col)
|
||||||
}
|
}
|
||||||
if bytes != e.caret.pos.ofs {
|
if bytes != e.caret.start.ofs {
|
||||||
t.Errorf("caret at buffer position %d, expected %d", e.caret.pos.ofs, bytes)
|
t.Errorf("caret at buffer position %d, expected %d", e.caret.start.ofs, bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +147,7 @@ func TestEditorCaretConsistency(t *testing.T) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
gotLine, gotCol := e.CaretPos()
|
gotLine, gotCol := e.CaretPos()
|
||||||
gotCoords := e.CaretCoords()
|
gotCoords := e.CaretCoords()
|
||||||
want, _ := e.offsetToScreenPos(e.caret.pos.ofs)
|
want, _ := e.offsetToScreenPos(e.caret.start.ofs)
|
||||||
wantCoords := f32.Pt(float32(want.x)/64, float32(want.y))
|
wantCoords := f32.Pt(float32(want.x)/64, float32(want.y))
|
||||||
if want.lineCol.Y == gotLine && want.lineCol.X == gotCol && gotCoords == wantCoords {
|
if want.lineCol.Y == gotLine && want.lineCol.X == gotCol && gotCoords == wantCoords {
|
||||||
return nil
|
return nil
|
||||||
@@ -162,19 +165,19 @@ func TestEditorCaretConsistency(t *testing.T) {
|
|||||||
e.SetText(str)
|
e.SetText(str)
|
||||||
e.Layout(gtx, cache, font, fontSize)
|
e.Layout(gtx, cache, font, fontSize)
|
||||||
case moveRune:
|
case moveRune:
|
||||||
e.MoveCaret(int(distance))
|
e.MoveCaret(int(distance), int(distance))
|
||||||
case moveLine:
|
case moveLine:
|
||||||
e.moveLines(int(distance))
|
e.moveLines(int(distance), selectionClear)
|
||||||
case movePage:
|
case movePage:
|
||||||
e.movePages(int(distance))
|
e.movePages(int(distance), selectionClear)
|
||||||
case moveStart:
|
case moveStart:
|
||||||
e.moveStart()
|
e.moveStart(selectionClear)
|
||||||
case moveEnd:
|
case moveEnd:
|
||||||
e.moveEnd()
|
e.moveEnd(selectionClear)
|
||||||
case moveCoord:
|
case moveCoord:
|
||||||
e.moveCoord(image.Pt(int(x), int(y)))
|
e.moveCoord(image.Pt(int(x), int(y)))
|
||||||
case moveWord:
|
case moveWord:
|
||||||
e.moveWord(int(distance))
|
e.moveWord(int(distance), selectionClear)
|
||||||
case deleteWord:
|
case deleteWord:
|
||||||
e.deleteWord(int(distance))
|
e.deleteWord(int(distance))
|
||||||
default:
|
default:
|
||||||
@@ -230,38 +233,72 @@ func TestEditorMoveWord(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for ii, tt := range tests {
|
for ii, tt := range tests {
|
||||||
e := setup(tt.Text)
|
e := setup(tt.Text)
|
||||||
e.MoveCaret(tt.Start)
|
e.MoveCaret(tt.Start, tt.Start)
|
||||||
e.moveWord(tt.Skip)
|
e.moveWord(tt.Skip, selectionClear)
|
||||||
if e.caret.pos.ofs != tt.Want {
|
if e.caret.start.ofs != tt.Want {
|
||||||
t.Fatalf("[%d] moveWord: bad caret position: got %d, want %d", ii, e.caret.pos.ofs, tt.Want)
|
t.Fatalf("[%d] moveWord: bad caret position: got %d, want %d", ii, e.caret.start.ofs, tt.Want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEditorDeleteWord(t *testing.T) {
|
func TestEditorDeleteWord(t *testing.T) {
|
||||||
type Test struct {
|
type Test struct {
|
||||||
Text string
|
Text string
|
||||||
Start int
|
Start int
|
||||||
Delete int
|
Selection int
|
||||||
|
Delete int
|
||||||
|
|
||||||
Want int
|
Want int
|
||||||
Result string
|
Result string
|
||||||
}
|
}
|
||||||
tests := []Test{
|
tests := []Test{
|
||||||
{"", 0, 0, 0, ""},
|
// No text selected
|
||||||
{"", 0, -1, 0, ""},
|
{"", 0, 0, 0, 0, ""},
|
||||||
{"", 0, 1, 0, ""},
|
{"", 0, 0, -1, 0, ""},
|
||||||
{"hello", 0, -1, 0, "hello"},
|
{"", 0, 0, 1, 0, ""},
|
||||||
{"hello", 0, 1, 0, ""},
|
{"", 0, 0, -2, 0, ""},
|
||||||
{"hello world", 3, 1, 3, "hel world"},
|
{"", 0, 0, 2, 0, ""},
|
||||||
{"hello world", 3, -1, 0, "lo world"},
|
{"hello", 0, 0, -1, 0, "hello"},
|
||||||
{"hello world", 8, -1, 6, "hello rld"},
|
{"hello", 0, 0, 1, 0, ""},
|
||||||
{"hello world", 8, 1, 8, "hello wo"},
|
|
||||||
{"hello world", 3, 1, 3, "hel world"},
|
// Document (imho) incorrect behavior w.r.t. deleting spaces following
|
||||||
{"hello world", 3, 2, 3, "helworld"},
|
// words.
|
||||||
{"hello world", 8, 1, 8, "hello "},
|
{"hello world", 0, 0, 1, 0, " world"}, // Should be "world", if you ask me.
|
||||||
{"hello world", 8, -1, 5, "hello world"},
|
{"hello world", 0, 0, 2, 0, "world"}, // Should be "".
|
||||||
{"hello brave new world", 0, 3, 0, " new world"},
|
{"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"},
|
||||||
|
// 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"},
|
||||||
|
// "|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"},
|
||||||
|
// 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 {
|
setup := func(t string) *Editor {
|
||||||
e := new(Editor)
|
e := new(Editor)
|
||||||
@@ -278,10 +315,11 @@ func TestEditorDeleteWord(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for ii, tt := range tests {
|
for ii, tt := range tests {
|
||||||
e := setup(tt.Text)
|
e := setup(tt.Text)
|
||||||
e.MoveCaret(tt.Start)
|
e.MoveCaret(tt.Start, tt.Start)
|
||||||
|
e.MoveCaret(0, tt.Selection)
|
||||||
e.deleteWord(tt.Delete)
|
e.deleteWord(tt.Delete)
|
||||||
if e.caret.pos.ofs != tt.Want {
|
if e.caret.start.ofs != tt.Want {
|
||||||
t.Fatalf("[%d] deleteWord: bad caret position: got %d, want %d", ii, e.caret.pos.ofs, tt.Want)
|
t.Fatalf("[%d] deleteWord: bad caret position: got %d, want %d", ii, e.caret.start.ofs, tt.Want)
|
||||||
}
|
}
|
||||||
if e.Text() != tt.Result {
|
if e.Text() != tt.Result {
|
||||||
t.Fatalf("[%d] deleteWord: invalid result: got %q, want %q", ii, e.Text(), tt.Result)
|
t.Fatalf("[%d] deleteWord: invalid result: got %q, want %q", ii, e.Text(), tt.Result)
|
||||||
@@ -292,7 +330,7 @@ func TestEditorDeleteWord(t *testing.T) {
|
|||||||
func TestEditorNoLayout(t *testing.T) {
|
func TestEditorNoLayout(t *testing.T) {
|
||||||
var e Editor
|
var e Editor
|
||||||
e.SetText("hi!\n")
|
e.SetText("hi!\n")
|
||||||
e.MoveCaret(1)
|
e.MoveCaret(1, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate generates a value of itself, for testing/quick.
|
// Generate generates a value of itself, for testing/quick.
|
||||||
@@ -301,10 +339,184 @@ func (editMutation) Generate(rand *rand.Rand, size int) reflect.Value {
|
|||||||
return reflect.ValueOf(t)
|
return reflect.ValueOf(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSelect 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 TestSelect(t *testing.T) {
|
||||||
|
e := new(Editor)
|
||||||
|
e.SetText(`a123456789a
|
||||||
|
b123456789b
|
||||||
|
c123456789c
|
||||||
|
d123456789d
|
||||||
|
e123456789e
|
||||||
|
f123456789f
|
||||||
|
g123456789g
|
||||||
|
`)
|
||||||
|
|
||||||
|
gtx := layout.Context{Ops: new(op.Ops)}
|
||||||
|
cache := text.NewCache(gofont.Collection())
|
||||||
|
font := text.Font{}
|
||||||
|
fontSize := unit.Px(10)
|
||||||
|
|
||||||
|
selected := func(start, end int) string {
|
||||||
|
// Layout once with no events; populate e.lines.
|
||||||
|
gtx.Queue = nil
|
||||||
|
e.Layout(gtx, cache, font, fontSize)
|
||||||
|
_ = e.Events() // throw away any events from this layout
|
||||||
|
|
||||||
|
// Build the selection events
|
||||||
|
startPos, endPos := e.offsetToScreenPos2(sortInts(start, end))
|
||||||
|
tq := &testQueue{
|
||||||
|
events: []event.Event{
|
||||||
|
pointer.Event{
|
||||||
|
Buttons: pointer.ButtonLeft,
|
||||||
|
Type: pointer.Press,
|
||||||
|
Source: pointer.Mouse,
|
||||||
|
Position: f32.Pt(textWidth(e, startPos.lineCol.Y, 0, startPos.lineCol.X), textHeight(e, startPos.lineCol.Y)),
|
||||||
|
},
|
||||||
|
pointer.Event{
|
||||||
|
Type: pointer.Release,
|
||||||
|
Source: pointer.Mouse,
|
||||||
|
Position: f32.Pt(textWidth(e, endPos.lineCol.Y, 0, endPos.lineCol.X), textHeight(e, endPos.lineCol.Y)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
gtx.Queue = tq
|
||||||
|
|
||||||
|
e.Layout(gtx, cache, font, fontSize)
|
||||||
|
for _, evt := range e.Events() {
|
||||||
|
switch evt.(type) {
|
||||||
|
case SelectEvent:
|
||||||
|
return e.SelectedText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "a123", screenPos{}, screenPos{Y: 0, X: 4}},
|
||||||
|
{0, 11, "a123456789a", screenPos{}, screenPos{Y: 1, X: 5}},
|
||||||
|
{2, 6, "2345", screenPos{Y: 0, X: 2}, screenPos{Y: 1, X: 0}},
|
||||||
|
{41, 66, "56789d\ne123456789e\nf12345", screenPos{Y: 6, X: 5}, screenPos{Y: 11, X: 0}},
|
||||||
|
} {
|
||||||
|
// printLines(e)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if e.caret.end.lineCol != tst.startPos || e.caret.start.lineCol != tst.endPos {
|
||||||
|
t.Errorf("Test %d pt2: Expected %#v, %#v; got %#v, %#v",
|
||||||
|
n,
|
||||||
|
e.caret.end.lineCol, e.caret.start.lineCol,
|
||||||
|
tst.startPos, tst.endPos)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// printLines(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)}
|
||||||
|
cache := text.NewCache(gofont.Collection())
|
||||||
|
font := text.Font{}
|
||||||
|
fontSize := unit.Px(10)
|
||||||
|
|
||||||
|
// Layout once to populate e.lines and get focus.
|
||||||
|
gtx.Queue = newQueue(key.FocusEvent{Focus: true})
|
||||||
|
e.Layout(gtx, cache, font, fontSize)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 textWidth(e *Editor, lineNum, colStart, colEnd int) float32 {
|
||||||
|
var w fixed.Int26_6
|
||||||
|
advances := e.lines[lineNum].Layout.Advances
|
||||||
|
if colEnd > len(advances) {
|
||||||
|
colEnd = len(advances)
|
||||||
|
}
|
||||||
|
for _, adv := range advances[colStart:colEnd] {
|
||||||
|
w += adv
|
||||||
|
}
|
||||||
|
return float32(w.Floor())
|
||||||
|
}
|
||||||
|
|
||||||
|
func textHeight(e *Editor, lineNum int) float32 {
|
||||||
|
var h fixed.Int26_6
|
||||||
|
for _, line := range e.lines[0:lineNum] {
|
||||||
|
h += line.Ascent + line.Descent
|
||||||
|
}
|
||||||
|
return float32(h.Floor() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
type testQueue struct {
|
type testQueue struct {
|
||||||
events []event.Event
|
events []event.Event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newQueue(e ...event.Event) *testQueue {
|
||||||
|
return &testQueue{events: e}
|
||||||
|
}
|
||||||
|
|
||||||
func (q *testQueue) Events(_ event.Tag) []event.Event {
|
func (q *testQueue) Events(_ event.Tag) []event.Event {
|
||||||
return q.events
|
return q.events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printLines(e *Editor) {
|
||||||
|
for n, line := range e.lines {
|
||||||
|
text := strings.TrimSuffix(line.Layout.Text, "\n")
|
||||||
|
fmt.Printf("%d: %s\n", n, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortInts returns a and b sorted such that a2 <= b2.
|
||||||
|
func sortInts(a, b int) (a2, b2 int) {
|
||||||
|
if b < a {
|
||||||
|
return b, a
|
||||||
|
}
|
||||||
|
return a, b
|
||||||
|
}
|
||||||
|
|||||||
+105
-37
@@ -29,61 +29,129 @@ type Label struct {
|
|||||||
// not pixels): Y = line number, X = rune column.
|
// not pixels): Y = line number, X = rune column.
|
||||||
type screenPos image.Point
|
type screenPos image.Point
|
||||||
|
|
||||||
type lineIterator struct {
|
type segmentIterator struct {
|
||||||
Lines []text.Line
|
Lines []text.Line
|
||||||
Clip image.Rectangle
|
Clip image.Rectangle
|
||||||
Alignment text.Alignment
|
Alignment text.Alignment
|
||||||
Width int
|
Width int
|
||||||
Offset image.Point
|
Offset image.Point
|
||||||
|
startSel screenPos
|
||||||
|
endSel screenPos
|
||||||
|
|
||||||
|
pos screenPos // current position
|
||||||
|
line text.Line // current line
|
||||||
|
layout text.Layout // current line's Layout
|
||||||
|
|
||||||
|
// pixel positions
|
||||||
|
off fixed.Point26_6
|
||||||
y, prevDesc fixed.Int26_6
|
y, prevDesc fixed.Int26_6
|
||||||
}
|
}
|
||||||
|
|
||||||
const inf = 1e6
|
const inf = 1e6
|
||||||
|
|
||||||
func (l *lineIterator) Next() (text.Layout, image.Point, bool) {
|
func (l *segmentIterator) Next() (text.Layout, image.Point, bool, int, image.Point, bool) {
|
||||||
for len(l.Lines) > 0 {
|
for l.pos.Y < len(l.Lines) {
|
||||||
line := l.Lines[0]
|
if l.pos.X == 0 {
|
||||||
l.Lines = l.Lines[1:]
|
l.line = l.Lines[l.pos.Y]
|
||||||
x := align(l.Alignment, line.Width, l.Width) + fixed.I(l.Offset.X)
|
|
||||||
l.y += l.prevDesc + line.Ascent
|
// Calculate X & Y pixel coordinates of left edge of line. We need y
|
||||||
l.prevDesc = line.Descent
|
// for the next line, so it's in l, but we only need x here, so it's
|
||||||
// Align baseline and line start to the pixel grid.
|
// not.
|
||||||
off := fixed.Point26_6{X: fixed.I(x.Floor()), Y: fixed.I(l.y.Ceil())}
|
x := align(l.Alignment, l.line.Width, l.Width) + fixed.I(l.Offset.X)
|
||||||
l.y = off.Y
|
l.y += l.prevDesc + l.line.Ascent
|
||||||
off.Y += fixed.I(l.Offset.Y)
|
l.prevDesc = l.line.Descent
|
||||||
if (off.Y + line.Bounds.Min.Y).Floor() > l.Clip.Max.Y {
|
// Align baseline and line start to the pixel grid.
|
||||||
break
|
l.off = fixed.Point26_6{X: fixed.I(x.Floor()), Y: fixed.I(l.y.Ceil())}
|
||||||
}
|
l.y = l.off.Y
|
||||||
layout := line.Layout
|
l.off.Y += fixed.I(l.Offset.Y)
|
||||||
if (off.Y + line.Bounds.Max.Y).Ceil() < l.Clip.Min.Y {
|
if (l.off.Y + l.line.Bounds.Min.Y).Floor() > l.Clip.Max.Y {
|
||||||
continue
|
|
||||||
}
|
|
||||||
for len(layout.Advances) > 0 {
|
|
||||||
_, n := utf8.DecodeRuneInString(layout.Text)
|
|
||||||
adv := layout.Advances[0]
|
|
||||||
if (off.X + adv + line.Bounds.Max.X - line.Width).Ceil() >= l.Clip.Min.X {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
off.X += adv
|
|
||||||
layout.Text = layout.Text[n:]
|
if (l.off.Y + l.line.Bounds.Max.Y).Ceil() < l.Clip.Min.Y {
|
||||||
layout.Advances = layout.Advances[1:]
|
// This line is outside/before the clip area; go on to the next line.
|
||||||
|
l.pos.Y++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the line's Layout, since we slice it up later.
|
||||||
|
l.layout = l.line.Layout
|
||||||
|
|
||||||
|
// Find the left edge of the text visible in the l.Clip clipping
|
||||||
|
// area.
|
||||||
|
for len(l.layout.Advances) > 0 {
|
||||||
|
_, n := utf8.DecodeRuneInString(l.layout.Text)
|
||||||
|
adv := l.layout.Advances[0]
|
||||||
|
if (l.off.X + adv + l.line.Bounds.Max.X - l.line.Width).Ceil() >= l.Clip.Min.X {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
l.off.X += adv
|
||||||
|
l.layout.Text = l.layout.Text[n:]
|
||||||
|
l.layout.Advances = l.layout.Advances[1:]
|
||||||
|
l.pos.X++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
endx := off.X
|
|
||||||
|
selected := l.inSelection()
|
||||||
|
endx := l.off.X
|
||||||
rune := 0
|
rune := 0
|
||||||
for n := range layout.Text {
|
nextLine := true
|
||||||
if (endx + line.Bounds.Min.X).Floor() > l.Clip.Max.X {
|
retLayout := l.layout
|
||||||
layout.Advances = layout.Advances[:rune]
|
for n := range l.layout.Text {
|
||||||
layout.Text = layout.Text[:n]
|
selChanged := selected != l.inSelection()
|
||||||
|
beyondClipEdge := (endx + l.line.Bounds.Min.X).Floor() > l.Clip.Max.X
|
||||||
|
if selChanged || beyondClipEdge {
|
||||||
|
retLayout.Advances = l.layout.Advances[:rune]
|
||||||
|
retLayout.Text = l.layout.Text[:n]
|
||||||
|
if selChanged {
|
||||||
|
// Save the rest of the line
|
||||||
|
l.layout.Advances = l.layout.Advances[rune:]
|
||||||
|
l.layout.Text = l.layout.Text[n:]
|
||||||
|
nextLine = false
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
endx += layout.Advances[rune]
|
endx += l.layout.Advances[rune]
|
||||||
rune++
|
rune++
|
||||||
|
l.pos.X++
|
||||||
}
|
}
|
||||||
offf := image.Point{X: off.X.Floor(), Y: off.Y.Floor()}
|
offFloor := image.Point{X: l.off.X.Floor(), Y: l.off.Y.Floor()}
|
||||||
return layout, offf, true
|
|
||||||
|
// Calculate the width & height if the returned text.
|
||||||
|
//
|
||||||
|
// If there's a better way to do this, I'm all ears.
|
||||||
|
var d fixed.Int26_6
|
||||||
|
for _, adv := range retLayout.Advances {
|
||||||
|
d += adv
|
||||||
|
}
|
||||||
|
size := image.Point{
|
||||||
|
X: d.Ceil(),
|
||||||
|
Y: (l.line.Ascent + l.line.Descent).Ceil(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if nextLine {
|
||||||
|
l.pos.Y++
|
||||||
|
l.pos.X = 0
|
||||||
|
} else {
|
||||||
|
l.off.X = endx
|
||||||
|
}
|
||||||
|
|
||||||
|
return retLayout, offFloor, selected, l.prevDesc.Ceil() - size.Y, size, true
|
||||||
}
|
}
|
||||||
return text.Layout{}, image.Point{}, false
|
return text.Layout{}, image.Point{}, false, 0, image.Point{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *segmentIterator) inSelection() bool {
|
||||||
|
return l.startSel.LessOrEqual(l.pos) &&
|
||||||
|
l.pos.Less(l.endSel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p1 screenPos) LessOrEqual(p2 screenPos) bool {
|
||||||
|
return p1.Y < p2.Y || (p1.Y == p2.Y && p1.X <= p2.X)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p1 screenPos) Less(p2 screenPos) bool {
|
||||||
|
return p1.Y < p2.Y || (p1.Y == p2.Y && p1.X < p2.X)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l Label) Layout(gtx layout.Context, s text.Shaper, font text.Font, size unit.Value, txt string) layout.Dimensions {
|
func (l Label) Layout(gtx layout.Context, s text.Shaper, font text.Font, size unit.Value, txt string) layout.Dimensions {
|
||||||
@@ -97,14 +165,14 @@ func (l Label) Layout(gtx layout.Context, s text.Shaper, font text.Font, size un
|
|||||||
dims.Size = cs.Constrain(dims.Size)
|
dims.Size = cs.Constrain(dims.Size)
|
||||||
cl := textPadding(lines)
|
cl := textPadding(lines)
|
||||||
cl.Max = cl.Max.Add(dims.Size)
|
cl.Max = cl.Max.Add(dims.Size)
|
||||||
it := lineIterator{
|
it := segmentIterator{
|
||||||
Lines: lines,
|
Lines: lines,
|
||||||
Clip: cl,
|
Clip: cl,
|
||||||
Alignment: l.Alignment,
|
Alignment: l.Alignment,
|
||||||
Width: dims.Size.X,
|
Width: dims.Size.X,
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
l, off, ok := it.Next()
|
l, off, _, _, _, ok := it.Next()
|
||||||
if !ok {
|
if !ok {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-12
@@ -23,19 +23,22 @@ type EditorStyle struct {
|
|||||||
Hint string
|
Hint string
|
||||||
// HintColor is the color of hint text.
|
// HintColor is the color of hint text.
|
||||||
HintColor color.NRGBA
|
HintColor color.NRGBA
|
||||||
Editor *widget.Editor
|
// SelectionColor is the color of the background for selected text.
|
||||||
|
SelectionColor color.NRGBA
|
||||||
|
Editor *widget.Editor
|
||||||
|
|
||||||
shaper text.Shaper
|
shaper text.Shaper
|
||||||
}
|
}
|
||||||
|
|
||||||
func Editor(th *Theme, editor *widget.Editor, hint string) EditorStyle {
|
func Editor(th *Theme, editor *widget.Editor, hint string) EditorStyle {
|
||||||
return EditorStyle{
|
return EditorStyle{
|
||||||
Editor: editor,
|
Editor: editor,
|
||||||
TextSize: th.TextSize,
|
TextSize: th.TextSize,
|
||||||
Color: th.Palette.Fg,
|
Color: th.Palette.Fg,
|
||||||
shaper: th.Shaper,
|
shaper: th.Shaper,
|
||||||
Hint: hint,
|
Hint: hint,
|
||||||
HintColor: f32color.MulAlpha(th.Palette.Fg, 0xbb),
|
HintColor: f32color.MulAlpha(th.Palette.Fg, 0xbb),
|
||||||
|
SelectionColor: th.Palette.ContrastBg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,11 +62,9 @@ func (e EditorStyle) Layout(gtx layout.Context) layout.Dimensions {
|
|||||||
dims = e.Editor.Layout(gtx, e.shaper, e.Font, e.TextSize)
|
dims = e.Editor.Layout(gtx, e.shaper, e.Font, e.TextSize)
|
||||||
disabled := gtx.Queue == nil
|
disabled := gtx.Queue == nil
|
||||||
if e.Editor.Len() > 0 {
|
if e.Editor.Len() > 0 {
|
||||||
textColor := e.Color
|
paint.ColorOp{Color: blendDisabledColor(disabled, e.SelectionColor)}.Add(gtx.Ops)
|
||||||
if disabled {
|
e.Editor.PaintSelection(gtx)
|
||||||
textColor = f32color.Disabled(textColor)
|
paint.ColorOp{Color: blendDisabledColor(disabled, e.Color)}.Add(gtx.Ops)
|
||||||
}
|
|
||||||
paint.ColorOp{Color: textColor}.Add(gtx.Ops)
|
|
||||||
e.Editor.PaintText(gtx)
|
e.Editor.PaintText(gtx)
|
||||||
} else {
|
} else {
|
||||||
call.Add(gtx.Ops)
|
call.Add(gtx.Ops)
|
||||||
@@ -74,3 +75,10 @@ func (e EditorStyle) Layout(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
return dims
|
return dims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func blendDisabledColor(disabled bool, c color.NRGBA) color.NRGBA {
|
||||||
|
if disabled {
|
||||||
|
return f32color.Disabled(c)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user