From e78bd15564e2a07548f56e30c91496cb20a2421d Mon Sep 17 00:00:00 2001 From: Larry Clapp Date: Wed, 20 Jan 2021 20:36:00 -0500 Subject: [PATCH] widget: refactoring to prep for editor selection - Move caret from editBuffer.caret to Editor.caret.pos.ofs and related refactoring. Move other fields in Editor.caret into Editor.caret.pos. - Refactor several functions to change a position passed into them, rather than changing e.rr.caret directly. - Add editBuffer.Seek(). - Remove editBuffer.dump(). - Change Editor.Move to MoveCaret. - Add Editor.SetCaret. - Updated tests. Signed-off-by: Larry Clapp --- widget/buffer.go | 69 ++++---- widget/editor.go | 381 ++++++++++++++++++++++++++---------------- widget/editor_test.go | 62 ++++--- widget/label.go | 12 +- 4 files changed, 315 insertions(+), 209 deletions(-) diff --git a/widget/buffer.go b/widget/buffer.go index 691687d6..e658d56f 100644 --- a/widget/buffer.go +++ b/widget/buffer.go @@ -3,18 +3,13 @@ package widget import ( - "fmt" "io" "strings" "unicode/utf8" ) -const bufferDebug = false - // editBuffer implements a gap buffer for text editing. type editBuffer struct { - // caret is the caret position in bytes. - caret int // pos is the byte position for Read and ReadRune. pos int @@ -35,12 +30,12 @@ func (e *editBuffer) Changed() bool { return c } -func (e *editBuffer) deleteRunes(runes int) { - e.moveGap(0) +func (e *editBuffer) deleteRunes(caret, runes int) int { + e.moveGap(caret, 0) for ; runes < 0 && e.gapstart > 0; runes++ { _, s := utf8.DecodeLastRune(e.text[:e.gapstart]) e.gapstart -= s - e.caret -= s + caret -= s e.changed = e.changed || s > 0 } for ; runes > 0 && e.gapend < len(e.text); runes-- { @@ -48,12 +43,12 @@ func (e *editBuffer) deleteRunes(runes int) { e.gapend += s e.changed = e.changed || s > 0 } - e.dump() + return caret } // moveGap moves the gap to the caret position. After returning, // the gap is guaranteed to be at least space bytes long. -func (e *editBuffer) moveGap(space int) { +func (e *editBuffer) moveGap(caret, space int) { if e.gapLen() < space { if space < minSpace { space = minSpace @@ -62,29 +57,28 @@ func (e *editBuffer) moveGap(space int) { // Expand to capacity. txt = txt[:cap(txt)] gaplen := len(txt) - e.len() - if e.caret > e.gapstart { + if caret > e.gapstart { copy(txt, e.text[:e.gapstart]) - copy(txt[e.caret+gaplen:], e.text[e.caret:]) - copy(txt[e.gapstart:], e.text[e.gapend:e.caret+e.gapLen()]) + copy(txt[caret+gaplen:], e.text[caret:]) + copy(txt[e.gapstart:], e.text[e.gapend:caret+e.gapLen()]) } else { - copy(txt, e.text[:e.caret]) + copy(txt, e.text[:caret]) copy(txt[e.gapstart+gaplen:], e.text[e.gapend:]) - copy(txt[e.caret+gaplen:], e.text[e.caret:e.gapstart]) + copy(txt[caret+gaplen:], e.text[caret:e.gapstart]) } e.text = txt - e.gapstart = e.caret + e.gapstart = caret e.gapend = e.gapstart + gaplen } else { - if e.caret > e.gapstart { - copy(e.text[e.gapstart:], e.text[e.gapend:e.caret+e.gapLen()]) + if caret > e.gapstart { + copy(e.text[e.gapstart:], e.text[e.gapend:caret+e.gapLen()]) } else { - copy(e.text[e.caret+e.gapLen():], e.text[e.caret:e.gapstart]) + copy(e.text[caret+e.gapLen():], e.text[caret:e.gapstart]) } l := e.gapLen() - e.gapstart = e.caret + e.gapstart = caret e.gapend = e.gapstart + l } - e.dump() } func (e *editBuffer) len() int { @@ -96,7 +90,25 @@ func (e *editBuffer) gapLen() int { } func (e *editBuffer) Reset() { - e.pos = 0 + e.Seek(0, io.SeekStart) +} + +// Seek implements io.Seeker +func (e *editBuffer) Seek(offset int64, whence int) (ret int64, err error) { + switch whence { + case io.SeekStart: + e.pos = int(offset) + case io.SeekCurrent: + e.pos += int(offset) + case io.SeekEnd: + e.pos = e.len() - int(offset) + } + if e.pos < 0 { + e.pos = 0 + } else if e.pos > e.len() { + e.pos = e.len() + } + return int64(e.pos), nil } func (e *editBuffer) Read(p []byte) (int, error) { @@ -138,18 +150,11 @@ func (e *editBuffer) String() string { return b.String() } -func (e *editBuffer) prepend(s string) { - e.moveGap(len(s)) - copy(e.text[e.caret:], s) +func (e *editBuffer) prepend(caret int, s string) { + e.moveGap(caret, len(s)) + copy(e.text[caret:], s) e.gapstart += len(s) e.changed = e.changed || len(s) > 0 - e.dump() -} - -func (e *editBuffer) dump() { - if bufferDebug { - fmt.Printf("len(e.text) %d e.len() %d e.gapstart %d e.gapend %d e.caret %d txt:\n'%+x'<-%d->'%+x'\n", len(e.text), e.len(), e.gapstart, e.gapend, e.caret, e.text[:e.gapstart], e.gapLen(), e.text[e.gapend:]) - } } func (e *editBuffer) runeBefore(idx int) (rune, int) { diff --git a/widget/editor.go b/widget/editor.go index 7e04509e..b862acd7 100644 --- a/widget/editor.go +++ b/widget/editor.go @@ -9,6 +9,7 @@ import ( "io" "math" "runtime" + "sort" "strings" "time" "unicode" @@ -64,18 +65,8 @@ type Editor struct { caret struct { on bool scroll bool - - // xoff is the offset to the current caret - // position when moving between lines. - xoff fixed.Int26_6 - - // line is the caret line position as an index into lines. - line int - // col is the caret column measured in runes. - col int - // (x, y) are the caret coordinates. - x fixed.Int26_6 - y int + // pos is the current caret position. + pos combinedPos } scroller gesture.Scroll @@ -99,6 +90,23 @@ type maskReader struct { overflow []byte } +// combinedPos is a point in the editor. +type combinedPos struct { + // editorBuffer offset. The other three fields are based off of this one. + ofs int + + // lineCol.Y = line (offset into Editor.lines), and X = col (offset into + // Editor.lines[Y]) + lineCol screenPos + + // Pixel coordinates + x fixed.Int26_6 + y int + + // xoff is the offset to the current position when moving between lines. + xoff fixed.Int26_6 +} + func (m *maskReader) Reset(r io.RuneReader, mr rune) { m.rr = r n := utf8.EncodeRune(m.maskBuf[:], mr) @@ -177,16 +185,24 @@ func (e *Editor) processEvents(gtx layout.Context) { e.processKey(gtx) } -func (e *Editor) makeValid() { +func (e *Editor) makeValid(positions ...*combinedPos) { if e.valid { return } e.lines, e.dims = e.layoutText(e.shaper) - line, col, x, y := e.layoutCaret() - e.caret.line = line - e.caret.col = col - e.caret.x = x - e.caret.y = y + + // 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 } @@ -259,6 +275,7 @@ func (e *Editor) processKey(gtx layout.Context) { e.caret.scroll = true e.scroller.Stop() e.append(ke.Text) + // Complete a paste event, initiated by Shortcut-V in Editor.command(). case clipboard.Event: e.caret.scroll = true e.scroller.Stop() @@ -271,7 +288,7 @@ func (e *Editor) processKey(gtx layout.Context) { } func (e *Editor) moveLines(distance int) { - e.moveToLine(e.caret.x+e.caret.xoff, e.caret.line+distance) + e.caret.pos = e.movePosToLine(e.caret.pos, e.caret.pos.x+e.caret.pos.xoff, e.caret.pos.lineCol.Y+distance) } func (e *Editor) command(gtx layout.Context, k key.Event) bool { @@ -279,17 +296,18 @@ func (e *Editor) command(gtx layout.Context, k key.Event) bool { if runtime.GOOS == "darwin" { modSkip = key.ModAlt } + moveByWord := k.Modifiers.Contain(modSkip) switch k.Name { case key.NameReturn, key.NameEnter: e.append("\n") case key.NameDeleteBackward: - if k.Modifiers == modSkip { + if moveByWord { e.deleteWord(-1) } else { e.Delete(-1) } case key.NameDeleteForward: - if k.Modifiers == modSkip { + if moveByWord { e.deleteWord(1) } else { e.Delete(1) @@ -299,16 +317,16 @@ func (e *Editor) command(gtx layout.Context, k key.Event) bool { case key.NameDownArrow: e.moveLines(+1) case key.NameLeftArrow: - if k.Modifiers == modSkip { + if moveByWord { e.moveWord(-1) } else { - e.Move(-1) + e.MoveCaret(-1) } case key.NameRightArrow: - if k.Modifiers == modSkip { + if moveByWord { e.moveWord(1) } else { - e.Move(1) + e.MoveCaret(1) } case key.NamePageUp: e.movePages(-1) @@ -318,11 +336,14 @@ func (e *Editor) command(gtx layout.Context, k key.Event) bool { e.moveStart() case key.NameEnd: e.moveEnd() + // Initiate a paste operation, by requesting the clipboard contents; other + // half is in Editor.processKey() under clipboard.Event. case "V": if k.Modifiers != key.ModShortcut { return false } clipboard.ReadOp{Tag: &e.eventKey}.Add(gtx.Ops) + // Copy all text. case "C": if k.Modifiers != key.ModShortcut { return false @@ -466,12 +487,12 @@ func (e *Editor) PaintCaret(gtx layout.Context) { } e.makeValid() carWidth := fixed.I(gtx.Px(unit.Dp(1))) - carX := e.caret.x - carY := e.caret.y + carX := e.caret.pos.x + carY := e.caret.pos.y defer op.Save(gtx.Ops).Load() carX -= carWidth / 2 - carAsc, carDesc := -e.lines[e.caret.line].Bounds.Min.Y, e.lines[e.caret.line].Bounds.Max.Y + carAsc, carDesc := -e.lines[e.caret.pos.lineCol.Y].Bounds.Min.Y, e.lines[e.caret.pos.lineCol.Y].Bounds.Max.Y carRect := image.Rectangle{ Min: image.Point{X: carX.Ceil(), Y: carY - carAsc.Ceil()}, Max: image.Point{X: carX.Ceil() + carWidth.Ceil(), Y: carY + carDesc.Ceil()}, @@ -512,7 +533,7 @@ func (e *Editor) Text() string { // SetText replaces the contents of the editor. func (e *Editor) SetText(s string) { e.rr = editBuffer{} - e.caret.xoff = 0 + e.caret.pos = combinedPos{} e.prepend(s) } @@ -569,8 +590,8 @@ func (e *Editor) moveCoord(pos image.Point) { carLine++ } x := fixed.I(pos.X + e.scrollOff.X) - e.moveToLine(x, carLine) - e.caret.xoff = 0 + e.caret.pos = e.movePosToLine(e.caret.pos, x, carLine) + e.caret.pos.xoff = 0 } func (e *Editor) layoutText(s text.Shaper) ([]text.Line, layout.Dimensions) { @@ -604,42 +625,65 @@ func (e *Editor) layoutText(s text.Shaper) ([]text.Line, layout.Dimensions) { // CaretPos returns the line & column numbers of the caret. func (e *Editor) CaretPos() (line, col int) { e.makeValid() - return e.caret.line, e.caret.col + return e.caret.pos.lineCol.Y, e.caret.pos.lineCol.X } // CaretCoords returns the coordinates of the caret, relative to the // editor itself. func (e *Editor) CaretCoords() f32.Point { e.makeValid() - return f32.Pt(float32(e.caret.x)/64, float32(e.caret.y)) + return f32.Pt(float32(e.caret.pos.x)/64, float32(e.caret.pos.y)) } -func (e *Editor) layoutCaret() (line, col int, x fixed.Int26_6, y int) { - var idx int - var prevDesc fixed.Int26_6 -loop: - for { - x = 0 - col = 0 - l := e.lines[line] - y += (prevDesc + l.Ascent).Ceil() - prevDesc = l.Descent - for _, adv := range l.Layout.Advances { - if idx == e.rr.caret { - break loop +// offsetToScreenPos takes an offset into the editor text (e.g. +// e.caret.end.ofs) and returns a combinedPos that corresponds to its current +// screen position, as well as an iterator that lets you get the combinedPos +// of a later offset. The offsets given to offsetToScreenPos and to the +// returned iterator must be sorted, lowest first, and they must be valid (0 +// <= offset <= e.Len()). +// +// This function is written this way to take advantage of previous work done +// for offsets after the first. Otherwise you have to start from the top each +// time. +func (e *Editor) offsetToScreenPos(offset int) (combinedPos, func(int) combinedPos) { + var col, line, idx int + var x fixed.Int26_6 + + l := e.lines[line] + y := l.Ascent.Ceil() + prevDesc := l.Descent + + iter := func(offset int) combinedPos { + LOOP: + for { + for ; col < len(l.Layout.Advances); col++ { + if idx >= offset { + break LOOP + } + + x += l.Layout.Advances[col] + _, s := e.rr.runeAt(idx) + idx += s } - x += adv - _, s := e.rr.runeAt(idx) - idx += s - col++ + if lastLine := line == len(e.lines)-1; lastLine || idx > offset { + break LOOP + } + + line++ + x = 0 + col = 0 + l = e.lines[line] + y += (prevDesc + l.Ascent).Ceil() + prevDesc = l.Descent } - if line == len(e.lines)-1 || idx > e.rr.caret { - break + return combinedPos{ + lineCol: screenPos{Y: line, X: col}, + x: x + align(e.Alignment, e.lines[line].Width, e.viewSize.X), + y: y, + ofs: offset, } - line++ } - x += align(e.Alignment, e.lines[line].Width, e.viewSize.X) - return + return iter(offset), iter } func (e *Editor) invalidate() { @@ -649,8 +693,11 @@ func (e *Editor) invalidate() { // Delete runes from the caret position. The sign of runes specifies the // direction to delete: positive is forward, negative is backward. func (e *Editor) Delete(runes int) { - e.rr.deleteRunes(runes) - e.caret.xoff = 0 + if runes == 0 { + return + } + e.caret.pos.ofs = e.rr.deleteRunes(e.caret.pos.ofs, runes) + e.caret.pos.xoff = 0 e.invalidate() } @@ -658,26 +705,29 @@ func (e *Editor) Delete(runes int) { func (e *Editor) Insert(s string) { e.append(s) e.caret.scroll = true - e.invalidate() } +// append inserts s at the cursor, leaving the caret is at the end of s. +// xxx|yyy + append zzz => xxxzzz|yyy func (e *Editor) append(s string) { e.prepend(s) - e.rr.caret += len(s) + e.caret.pos.ofs += len(s) } +// prepend inserts s after the cursor; the caret does not change. +// xxx|yyy + prepend zzz => xxx|zzzyyy func (e *Editor) prepend(s string) { if e.SingleLine { s = strings.ReplaceAll(s, "\n", " ") } - e.rr.prepend(s) - e.caret.xoff = 0 + e.rr.prepend(e.caret.pos.ofs, s) + e.caret.pos.xoff = 0 e.invalidate() } func (e *Editor) movePages(pages int) { e.makeValid() - y := e.caret.y + pages*e.viewSize.Y + y := e.caret.pos.y + pages*e.viewSize.Y var ( prevDesc fixed.Int26_6 carLine2 int @@ -696,11 +746,11 @@ func (e *Editor) movePages(pages int) { y2 += h carLine2++ } - e.moveToLine(e.caret.x+e.caret.xoff, carLine2) + e.caret.pos = e.movePosToLine(e.caret.pos, e.caret.pos.x+e.caret.pos.xoff, carLine2) } -func (e *Editor) moveToLine(x fixed.Int26_6, line int) { - e.makeValid() +func (e *Editor) movePosToLine(pos combinedPos, x fixed.Int26_6, line int) combinedPos { + e.makeValid(&pos) if line < 0 { line = 0 } @@ -709,31 +759,31 @@ func (e *Editor) moveToLine(x fixed.Int26_6, line int) { } prevDesc := e.lines[line].Descent - for e.caret.line < line { - e.moveEnd() - l := e.lines[e.caret.line] - _, s := e.rr.runeAt(e.rr.caret) - e.rr.caret += s - e.caret.y += (prevDesc + l.Ascent).Ceil() - e.caret.col = 0 + for pos.lineCol.Y < line { + pos = e.movePosToEnd(pos) + l := e.lines[pos.lineCol.Y] + _, s := e.rr.runeAt(pos.ofs) + pos.ofs += s + pos.y += (prevDesc + l.Ascent).Ceil() + pos.lineCol.X = 0 prevDesc = l.Descent - e.caret.line++ + pos.lineCol.Y++ } - for e.caret.line > line { - e.moveStart() - l := e.lines[e.caret.line] - _, s := e.rr.runeBefore(e.rr.caret) - e.rr.caret -= s - e.caret.y -= (prevDesc + l.Ascent).Ceil() + for pos.lineCol.Y > line { + pos = e.movePosToStart(pos) + l := e.lines[pos.lineCol.Y] + _, s := e.rr.runeBefore(pos.ofs) + pos.ofs -= s + pos.y -= (prevDesc + l.Ascent).Ceil() prevDesc = l.Descent - e.caret.line-- - l = e.lines[e.caret.line] - e.caret.col = len(l.Layout.Advances) - 1 + pos.lineCol.Y-- + l = e.lines[pos.lineCol.Y] + pos.lineCol.X = len(l.Layout.Advances) - 1 } - e.moveStart() + pos = e.movePosToStart(pos) l := e.lines[line] - e.caret.x = align(e.Alignment, l.Width, e.viewSize.X) + pos.x = align(e.Alignment, l.Width, e.viewSize.X) // Only move past the end of the last line end := 0 if line < len(e.lines)-1 { @@ -742,86 +792,103 @@ func (e *Editor) moveToLine(x fixed.Int26_6, line int) { // Move to rune closest to x. for i := 0; i < len(l.Layout.Advances)-end; i++ { adv := l.Layout.Advances[i] - if e.caret.x >= x { + if pos.x >= x { break } - if e.caret.x+adv-x >= x-e.caret.x { + if pos.x+adv-x >= x-pos.x { break } - e.caret.x += adv - _, s := e.rr.runeAt(e.rr.caret) - e.rr.caret += s - e.caret.col++ + pos.x += adv + _, s := e.rr.runeAt(pos.ofs) + pos.ofs += s + pos.lineCol.X++ } - e.caret.xoff = x - e.caret.x + pos.xoff = x - pos.x + return pos } -// Move the caret: positive distance moves forward, negative distance moves -// backward. -func (e *Editor) Move(distance int) { +// MoveCaret moves the caret relative to its current position. Positive +// distance moves forward, negative distance moves backward. Distance is in +// runes. +func (e *Editor) MoveCaret(distance int) { e.makeValid() - for ; distance < 0 && e.rr.caret > 0; distance++ { - if e.caret.col == 0 { + e.caret.pos = e.movePos(e.caret.pos, distance) + e.caret.pos.xoff = 0 +} + +func (e *Editor) movePos(pos combinedPos, distance int) combinedPos { + for ; distance < 0 && pos.ofs > 0; distance++ { + if pos.lineCol.X == 0 { // Move to end of previous line. - e.moveToLine(fixed.I(e.maxWidth), e.caret.line-1) + pos = e.movePosToLine(pos, fixed.I(e.maxWidth), pos.lineCol.Y-1) continue } - l := e.lines[e.caret.line].Layout - _, s := e.rr.runeBefore(e.rr.caret) - e.rr.caret -= s - e.caret.col-- - e.caret.x -= l.Advances[e.caret.col] + l := e.lines[pos.lineCol.Y].Layout + _, s := e.rr.runeBefore(pos.ofs) + pos.ofs -= s + pos.lineCol.X-- + pos.x -= l.Advances[pos.lineCol.X] } - for ; distance > 0 && e.rr.caret < e.rr.len(); distance-- { - l := e.lines[e.caret.line].Layout + for ; distance > 0 && pos.ofs < e.rr.len(); distance-- { + l := e.lines[pos.lineCol.Y].Layout // Only move past the end of the last line end := 0 - if e.caret.line < len(e.lines)-1 { + if pos.lineCol.Y < len(e.lines)-1 { end = 1 } - if e.caret.col >= len(l.Advances)-end { + if pos.lineCol.X >= len(l.Advances)-end { // Move to start of next line. - e.moveToLine(0, e.caret.line+1) + pos = e.movePosToLine(pos, 0, pos.lineCol.Y+1) continue } - e.caret.x += l.Advances[e.caret.col] - _, s := e.rr.runeAt(e.rr.caret) - e.rr.caret += s - e.caret.col++ + pos.x += l.Advances[pos.lineCol.X] + _, s := e.rr.runeAt(pos.ofs) + pos.ofs += s + pos.lineCol.X++ } - e.caret.xoff = 0 + return pos } func (e *Editor) moveStart() { - e.makeValid() - layout := e.lines[e.caret.line].Layout - for i := e.caret.col - 1; i >= 0; i-- { - _, s := e.rr.runeBefore(e.rr.caret) - e.rr.caret -= s - e.caret.x -= layout.Advances[i] + e.caret.pos = e.movePosToStart(e.caret.pos) +} + +func (e *Editor) movePosToStart(pos combinedPos) combinedPos { + e.makeValid(&pos) + layout := e.lines[pos.lineCol.Y].Layout + for i := pos.lineCol.X - 1; i >= 0; i-- { + _, s := e.rr.runeBefore(pos.ofs) + pos.ofs -= s + pos.x -= layout.Advances[i] } - e.caret.col = 0 - e.caret.xoff = -e.caret.x + pos.lineCol.X = 0 + pos.xoff = -pos.x + return pos } func (e *Editor) moveEnd() { - e.makeValid() - l := e.lines[e.caret.line] + e.caret.pos = e.movePosToEnd(e.caret.pos) +} + +func (e *Editor) movePosToEnd(pos combinedPos) combinedPos { + e.makeValid(&pos) + l := e.lines[pos.lineCol.Y] // Only move past the end of the last line end := 0 - if e.caret.line < len(e.lines)-1 { + if pos.lineCol.Y < len(e.lines)-1 { end = 1 } layout := l.Layout - for i := e.caret.col; i < len(layout.Advances)-end; i++ { + for i := pos.lineCol.X; i < len(layout.Advances)-end; i++ { adv := layout.Advances[i] - _, s := e.rr.runeAt(e.rr.caret) - e.rr.caret += s - e.caret.x += adv - e.caret.col++ + _, s := e.rr.runeAt(pos.ofs) + pos.ofs += s + pos.x += adv + pos.lineCol.X++ } a := align(e.Alignment, l.Width, e.viewSize.X) - e.caret.xoff = l.Width + a - e.caret.x + pos.xoff = l.Width + a - pos.x + return pos } // moveWord moves the caret to the next word in the specified direction. @@ -837,33 +904,37 @@ func (e *Editor) moveWord(distance int) { } // atEnd if caret is at either side of the buffer. atEnd := func() bool { - return e.rr.caret == 0 || e.rr.caret == e.rr.len() + return e.caret.pos.ofs == 0 || e.caret.pos.ofs == e.rr.len() } // next returns the appropriate rune given the direction. next := func() (r rune) { if direction < 0 { - r, _ = e.rr.runeBefore(e.rr.caret) + r, _ = e.rr.runeBefore(e.caret.pos.ofs) } else { - r, _ = e.rr.runeAt(e.rr.caret) + r, _ = e.rr.runeAt(e.caret.pos.ofs) } return r } for ii := 0; ii < words; ii++ { for r := next(); unicode.IsSpace(r) && !atEnd(); r = next() { - e.Move(direction) + e.MoveCaret(direction) } - e.Move(direction) + e.MoveCaret(direction) for r := next(); !unicode.IsSpace(r) && !atEnd(); r = next() { - e.Move(direction) + e.MoveCaret(direction) } } } -// deleteWord 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. // Positive is forward, negative is backward. // Absolute values greater than one will delete that many words. func (e *Editor) deleteWord(distance int) { + if distance == 0 { + return + } + e.makeValid() // split the distance information into constituent parts to be // used independently. @@ -873,12 +944,12 @@ func (e *Editor) deleteWord(distance int) { } // atEnd if offset is at or beyond either side of the buffer. atEnd := func(offset int) bool { - idx := e.rr.caret + offset*direction + idx := e.caret.pos.ofs + offset*direction return idx <= 0 || idx >= e.rr.len() } // next returns the appropriate rune given the direction and offset. next := func(offset int) (r rune) { - idx := e.rr.caret + offset*direction + idx := e.caret.pos.ofs + offset*direction if idx < 0 { idx = 0 } else if idx > e.rr.len() { @@ -908,18 +979,18 @@ func (e *Editor) deleteWord(distance int) { func (e *Editor) scrollToCaret() { e.makeValid() - l := e.lines[e.caret.line] + l := e.lines[e.caret.pos.lineCol.Y] if e.SingleLine { var dist int - if d := e.caret.x.Floor() - e.scrollOff.X; d < 0 { + if d := e.caret.pos.x.Floor() - e.scrollOff.X; d < 0 { dist = d - } else if d := e.caret.x.Ceil() - (e.scrollOff.X + e.viewSize.X); d > 0 { + } else if d := e.caret.pos.x.Ceil() - (e.scrollOff.X + e.viewSize.X); d > 0 { dist = d } e.scrollRel(dist, 0) } else { - miny := e.caret.y - l.Ascent.Ceil() - maxy := e.caret.y + l.Descent.Ceil() + miny := e.caret.pos.y - l.Ascent.Ceil() + maxy := e.caret.pos.y + l.Descent.Ceil() var dist int if d := miny - e.scrollOff.Y; d < 0 { dist = d @@ -936,6 +1007,30 @@ func (e *Editor) NumLines() int { return len(e.lines) } +// SetCaret moves the caret to ofs. ofs is in bytes, and represent an offset +// into the editor text. ofs must be at a rune boundary. +func (e *Editor) SetCaret(ofs int) { + e.makeValid() + // Constrain ofs to [0, e.Len()]. + e.caret.pos, _ = e.offsetToScreenPos(max(min(ofs, e.Len()), 0)) + e.caret.scroll = true + e.scroller.Stop() +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + func nullLayout(r io.Reader) ([]text.Line, error) { rr := bufio.NewReader(r) var rerr error diff --git a/widget/editor_test.go b/widget/editor_test.go index bc9c2921..37386965 100644 --- a/widget/editor_test.go +++ b/widget/editor_test.go @@ -36,24 +36,31 @@ func TestEditor(t *testing.T) { assertCaret(t, e, 0, 0, 0) e.moveEnd() assertCaret(t, e, 0, 3, len("æbc")) - e.Move(+1) + e.MoveCaret(+1) assertCaret(t, e, 1, 0, len("æbc\n")) - e.Move(-1) + e.MoveCaret(-1) assertCaret(t, e, 0, 3, len("æbc")) e.moveLines(+1) assertCaret(t, e, 1, 3, len("æbc\naøå")) e.moveEnd() assertCaret(t, e, 1, 4, len("æbc\naøå•")) - e.Move(+1) + e.MoveCaret(+1) + assertCaret(t, e, 1, 4, len("æbc\naøå•")) + + e.SetCaret(0) + assertCaret(t, e, 0, 0, 0) + e.SetCaret(len("æ")) + assertCaret(t, e, 0, 1, 2) + e.SetCaret(len("æbc\naøå•")) assertCaret(t, e, 1, 4, len("æbc\naøå•")) // Ensure that password masking does not affect caret behavior - e.Move(-3) + e.MoveCaret(-3) assertCaret(t, e, 1, 1, len("æbc\na")) e.Mask = '*' e.Layout(gtx, cache, font, fontSize) assertCaret(t, e, 1, 1, len("æbc\na")) - e.Move(-3) + e.MoveCaret(-3) assertCaret(t, e, 0, 2, len("æb")) e.Mask = '\U0001F92B' e.Layout(gtx, cache, font, fontSize) @@ -91,14 +98,6 @@ func TestEditorDimensions(t *testing.T) { } } -type testQueue struct { - events []event.Event -} - -func (q *testQueue) Events(_ event.Tag) []event.Event { - return q.events -} - // 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) { @@ -107,8 +106,8 @@ func assertCaret(t *testing.T, e *Editor, line, col, bytes int) { if gotLine != line || gotCol != col { t.Errorf("caret at (%d, %d), expected (%d, %d)", gotLine, gotCol, line, col) } - if bytes != e.rr.caret { - t.Errorf("caret at buffer position %d, expected %d", e.rr.caret, bytes) + if bytes != e.caret.pos.ofs { + t.Errorf("caret at buffer position %d, expected %d", e.caret.pos.ofs, bytes) } } @@ -145,12 +144,13 @@ func TestEditorCaretConsistency(t *testing.T) { t.Helper() gotLine, gotCol := e.CaretPos() gotCoords := e.CaretCoords() - wantLine, wantCol, wantX, wantY := e.layoutCaret() - wantCoords := f32.Pt(float32(wantX)/64, float32(wantY)) - if wantLine == gotLine && wantCol == gotCol && gotCoords == wantCoords { + want, _ := e.offsetToScreenPos(e.caret.pos.ofs) + wantCoords := f32.Pt(float32(want.x)/64, float32(want.y)) + if want.lineCol.Y == gotLine && want.lineCol.X == gotCol && gotCoords == wantCoords { return nil } - return fmt.Errorf("caret (%d,%d) pos %s, want (%d,%d) pos %s", gotLine, gotCol, gotCoords, wantLine, wantCol, wantCoords) + return fmt.Errorf("caret (%d,%d) pos %s, want (%d,%d) pos %s", + gotLine, gotCol, gotCoords, want.lineCol.Y, want.lineCol.X, wantCoords) } if err := consistent(); err != nil { t.Errorf("initial editor inconsistency (alignment %s): %v", a, err) @@ -162,7 +162,7 @@ func TestEditorCaretConsistency(t *testing.T) { e.SetText(str) e.Layout(gtx, cache, font, fontSize) case moveRune: - e.Move(int(distance)) + e.MoveCaret(int(distance)) case moveLine: e.moveLines(int(distance)) case movePage: @@ -230,10 +230,10 @@ func TestEditorMoveWord(t *testing.T) { } for ii, tt := range tests { e := setup(tt.Text) - e.Move(tt.Start) + e.MoveCaret(tt.Start) e.moveWord(tt.Skip) - if e.rr.caret != tt.Want { - t.Fatalf("[%d] moveWord: bad caret position: got %d, want %d", ii, e.rr.caret, tt.Want) + if e.caret.pos.ofs != tt.Want { + t.Fatalf("[%d] moveWord: bad caret position: got %d, want %d", ii, e.caret.pos.ofs, tt.Want) } } } @@ -278,10 +278,10 @@ func TestEditorDeleteWord(t *testing.T) { } for ii, tt := range tests { e := setup(tt.Text) - e.Move(tt.Start) + e.MoveCaret(tt.Start) e.deleteWord(tt.Delete) - if e.rr.caret != tt.Want { - t.Fatalf("[%d] deleteWord: bad caret position: got %d, want %d", ii, e.rr.caret, tt.Want) + if e.caret.pos.ofs != tt.Want { + t.Fatalf("[%d] deleteWord: bad caret position: got %d, want %d", ii, e.caret.pos.ofs, tt.Want) } if e.Text() != tt.Result { t.Fatalf("[%d] deleteWord: invalid result: got %q, want %q", ii, e.Text(), tt.Result) @@ -292,7 +292,7 @@ func TestEditorDeleteWord(t *testing.T) { func TestEditorNoLayout(t *testing.T) { var e Editor e.SetText("hi!\n") - e.Move(1) + e.MoveCaret(1) } // Generate generates a value of itself, for testing/quick. @@ -300,3 +300,11 @@ func (editMutation) Generate(rand *rand.Rand, size int) reflect.Value { t := editMutation(rand.Intn(int(moveLast))) return reflect.ValueOf(t) } + +type testQueue struct { + events []event.Event +} + +func (q *testQueue) Events(_ event.Tag) []event.Event { + return q.events +} diff --git a/widget/label.go b/widget/label.go index bf01137b..d91cff9b 100644 --- a/widget/label.go +++ b/widget/label.go @@ -25,6 +25,10 @@ type Label struct { MaxLines int } +// screenPos describes a character position (in text line and column numbers, +// not pixels): Y = line number, X = rune column. +type screenPos image.Point + type lineIterator struct { Lines []text.Line Clip image.Rectangle @@ -33,7 +37,6 @@ type lineIterator struct { Offset image.Point y, prevDesc fixed.Int26_6 - txtOff int } const inf = 1e6 @@ -53,8 +56,6 @@ func (l *lineIterator) Next() (text.Layout, image.Point, bool) { break } layout := line.Layout - start := l.txtOff - l.txtOff += len(line.Layout.Text) if (off.Y + line.Bounds.Max.Y).Ceil() < l.Clip.Min.Y { continue } @@ -67,18 +68,15 @@ func (l *lineIterator) Next() (text.Layout, image.Point, bool) { off.X += adv layout.Text = layout.Text[n:] layout.Advances = layout.Advances[1:] - start += n } - end := start endx := off.X rune := 0 - for n, r := range layout.Text { + for n := range layout.Text { if (endx + line.Bounds.Min.X).Floor() > l.Clip.Max.X { layout.Advances = layout.Advances[:rune] layout.Text = layout.Text[:n] break } - end += utf8.RuneLen(r) endx += layout.Advances[rune] rune++ }