From e98c8955bb15ee8622906de24697430c3870e725 Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Fri, 23 Dec 2022 07:54:27 -0500 Subject: [PATCH] widget{,/material}: rebuild label and editor with textView This commit rebuilds the editor and label types on the common foundation provided by textView. This enables labels to have optional state that makes them selectable, and allows the two widgets to share the code for managing cursor positions, displaying selections, and soforth. Labels now have an additional Layout function which can be invoked if they have a Selectable. It accepts a layout.Widget used to paint their contents. Stateless labels should still use the old Layout method. Signed-off-by: Chris Waldon --- widget/buffer.go | 90 +---- widget/editor.go | 805 ++++++++++---------------------------- widget/editor_test.go | 92 ++--- widget/label.go | 20 +- widget/material/label.go | 26 +- widget/selectable.go | 343 ++++++++++++++++ widget/selectable_test.go | 120 ++++++ widget/text.go | 45 +-- 8 files changed, 780 insertions(+), 761 deletions(-) create mode 100644 widget/selectable.go create mode 100644 widget/selectable_test.go diff --git a/widget/buffer.go b/widget/buffer.go index 48ed72ed..c67317a3 100644 --- a/widget/buffer.go +++ b/widget/buffer.go @@ -4,7 +4,6 @@ package widget import ( "io" - "strings" "unicode/utf8" "golang.org/x/text/runes" @@ -24,6 +23,8 @@ type editBuffer struct { changed bool } +var _ textSource = (*editBuffer)(nil) + const minSpace = 5 func (e *editBuffer) Changed() bool { @@ -56,10 +57,10 @@ func (e *editBuffer) moveGap(caret, space int) { if space < minSpace { space = minSpace } - txt := make([]byte, e.len()+space) + txt := make([]byte, int(e.Size())+space) // Expand to capacity. txt = txt[:cap(txt)] - gaplen := len(txt) - e.len() + gaplen := len(txt) - int(e.Size()) if caret > e.gapstart { copy(txt, e.text[:e.gapstart]) copy(txt[caret+gaplen:], e.text[caret:]) @@ -84,83 +85,38 @@ func (e *editBuffer) moveGap(caret, space int) { } } -func (e *editBuffer) len() int { - return len(e.text) - e.gapLen() +func (e *editBuffer) Size() int64 { + return int64(len(e.text) - e.gapLen()) } func (e *editBuffer) gapLen() int { return e.gapend - e.gapstart } -func (e *editBuffer) Reset() { - 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) { +func (e *editBuffer) ReadAt(p []byte, offset int64) (int, error) { if len(p) == 0 { return 0, nil } - if e.pos == e.len() { + if offset == e.Size() { return 0, io.EOF } var total int - if e.pos < e.gapstart { - n := copy(p, e.text[e.pos:e.gapstart]) + if offset < int64(e.gapstart) { + n := copy(p, e.text[offset:e.gapstart]) p = p[n:] total += n - e.pos += n + offset += int64(n) } - if e.pos >= e.gapstart { - n := copy(p, e.text[e.pos+e.gapLen():]) + if offset >= int64(e.gapstart) { + n := copy(p, e.text[offset+int64(e.gapLen()):]) total += n - e.pos += n } return total, nil } -func (e *editBuffer) ReadRune() (rune, int, error) { - if e.pos == e.len() { - return 0, 0, io.EOF - } - r, s := e.runeAt(e.pos) - e.pos += s - return r, s, nil -} - -// WriteTo implements io.WriterTo. -func (e *editBuffer) WriteTo(w io.Writer) (int64, error) { - n1, err := w.Write(e.text[:e.gapstart]) - if err != nil || n1 < e.gapstart { - return int64(n1), err - } - n2, err := w.Write(e.text[e.gapend:]) - return int64(n1 + n2), err -} - -func (e *editBuffer) String() string { - var b strings.Builder - b.Grow(e.len()) - b.Write(e.text[:e.gapstart]) - b.Write(e.text[e.gapend:]) - return b.String() +func (e *editBuffer) ReplaceRunes(byteOffset, runeCount int64, s string) { + e.deleteRunes(int(byteOffset), int(runeCount)) + e.prepend(int(byteOffset), s) } func (e *editBuffer) prepend(caret int, s string) { @@ -173,17 +129,3 @@ func (e *editBuffer) prepend(caret int, s string) { e.gapstart += len(s) e.changed = e.changed || len(s) > 0 } - -func (e *editBuffer) runeBefore(idx int) (rune, int) { - if idx > e.gapstart { - idx += e.gapLen() - } - return utf8.DecodeLastRune(e.text[:idx]) -} - -func (e *editBuffer) runeAt(idx int) (rune, int) { - if idx >= e.gapstart { - idx += e.gapLen() - } - return utf8.DecodeRune(e.text[idx:]) -} diff --git a/widget/editor.go b/widget/editor.go index 9b0092d9..ad124da1 100644 --- a/widget/editor.go +++ b/widget/editor.go @@ -3,10 +3,10 @@ package widget import ( + "bufio" "image" "io" "math" - "sort" "strings" "time" "unicode" @@ -22,16 +22,18 @@ import ( "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" - "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" "golang.org/x/exp/constraints" - "golang.org/x/image/math/fixed" ) // Editor implements an editable and scrollable text area. type Editor struct { + // text manages the text buffer and provides shaping and cursor positioning + // services. + text textView + // Alignment controls the alignment of text within the editor. Alignment text.Alignment // SingleLine force the text to stay on a single line. // SingleLine also sets the scrolling direction to @@ -56,26 +58,11 @@ type Editor struct { // all characters are allowed. Filter string - eventKey int - font text.Font - shaper *text.Shaper - textSize fixed.Int26_6 - blinkStart time.Time - focused bool - rr editBuffer - maskReader maskReader - lastMask rune - maxWidth, minWidth int - viewSize image.Point - valid bool - regions []Region - dims layout.Dimensions - requestFocus bool - - // offIndex is an index of rune index to byte offsets. - offIndex []offEntry - - index glyphIndex + buffer *editBuffer + eventKey int + blinkStart time.Time + focused bool + requestFocus bool // ime tracks the state relevant to input methods. ime struct { @@ -83,24 +70,11 @@ type Editor struct { scratch []byte } - caret struct { - on bool - scroll bool - // xoff is the offset to the current position when moving between lines. - xoff fixed.Int26_6 - // start is the current caret position in runes, and also the start position of - // selected text. end is the end position of selected text. If start - // == end, 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 int - end int - } - - dragging bool - dragger gesture.Drag - scroller gesture.Scroll - scrollOff image.Point + dragging bool + dragger gesture.Drag + scroller gesture.Scroll + scrollCaret bool + showCaret bool clicker gesture.Click @@ -108,9 +82,6 @@ type Editor struct { events []EditorEvent // prevEvents is the number of events from the previous frame. prevEvents int - - locale system.Locale - // history contains undo history. history []modification // nextHistoryIdx is the index within the history of the next modification. This @@ -139,6 +110,8 @@ type maskReader struct { maskBuf [utf8.UTFMax]byte // mask is the utf-8 encoded mask rune. mask []byte + // overflow contains excess mask bytes left over after the last Read call. + overflow []byte } type selectionAction int @@ -148,26 +121,37 @@ const ( selectionClear ) -func (m *maskReader) Reset(r io.RuneReader, mr rune) { - m.rr = r +func (m *maskReader) Reset(r io.Reader, mr rune) { + m.rr = bufio.NewReader(r) n := utf8.EncodeRune(m.maskBuf[:], mr) m.mask = m.maskBuf[:n] } -// ReadRune reads a rune from the underlying reader and replaces every +// Read reads from the underlying reader and replaces every // rune with the mask rune. -func (m *maskReader) ReadRune() (r rune, n int, err error) { - r, _, err = m.rr.ReadRune() - if err != nil { - return +func (m *maskReader) Read(b []byte) (n int, err error) { + for len(b) > 0 { + var replacement []byte + if len(m.overflow) > 0 { + replacement = m.overflow + } else { + var r rune + r, _, err = m.rr.ReadRune() + if err != nil { + break + } + if r == '\n' { + replacement = []byte{'\n'} + } else { + replacement = m.mask + } + } + nn := copy(b, replacement) + m.overflow = replacement[nn:] + n += nn + b = b[nn:] } - if r != '\n' { - r, _ = utf8.DecodeRune(m.mask) - n = len(m.mask) - } else { - n = 1 - } - return + return n, err } type EditorEvent interface { @@ -210,29 +194,17 @@ func (e *Editor) processEvents(gtx layout.Context) { e.events = e.events[:n] e.prevEvents = n - if e.shaper == nil { - // Can't process events without a shaper. - return - } - oldStart, oldLen := min(e.caret.start, e.caret.end), e.SelectionLen() + oldStart, oldLen := min(e.text.Selection()), e.text.SelectionLen() e.processPointer(gtx) e.processKey(gtx) // Queue a SelectEvent if the selection changed, including if it went away. - if newStart, newLen := min(e.caret.start, e.caret.end), e.SelectionLen(); oldStart != newStart || oldLen != newLen { + if newStart, newLen := min(e.text.Selection()), e.text.SelectionLen(); oldStart != newStart || oldLen != newLen { e.events = append(e.events, SelectEvent{}) } } -func (e *Editor) makeValid() { - if e.valid { - return - } - e.layoutText(e.shaper) - e.valid = true -} - func (e *Editor) processPointer(gtx layout.Context) { - sbounds := e.scrollBounds() + sbounds := e.text.ScrollBounds() var smin, smax int var axis gesture.Axis if e.SingleLine { @@ -245,11 +217,11 @@ func (e *Editor) processPointer(gtx layout.Context) { sdist := e.scroller.Scroll(gtx.Metric, gtx, gtx.Now, axis) var soff int if e.SingleLine { - e.scrollRel(sdist, 0) - soff = e.scrollOff.X + e.text.ScrollRel(sdist, 0) + soff = e.text.ScrollOff().X } else { - e.scrollRel(0, sdist) - soff = e.scrollOff.Y + e.text.ScrollRel(0, sdist) + soff = e.text.ScrollOff().Y } for _, evt := range e.clickDragEvents(gtx) { switch evt := evt.(type) { @@ -257,37 +229,38 @@ func (e *Editor) processPointer(gtx layout.Context) { switch { case evt.Type == gesture.TypePress && evt.Source == pointer.Mouse, evt.Type == gesture.TypeClick && evt.Source != pointer.Mouse: - prevCaretPos := e.caret.start + prevCaretPos, _ := e.text.Selection() e.blinkStart = gtx.Now - e.moveCoord(image.Point{ + e.text.MoveCoord(image.Point{ X: int(math.Round(float64(evt.Position.X))), Y: int(math.Round(float64(evt.Position.Y))), }) e.requestFocus = true if e.scroller.State() != gesture.StateFlinging { - e.caret.scroll = true + e.scrollCaret = true } if evt.Modifiers == key.ModShift { + start, end := e.text.Selection() // 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-e.caret.start) < abs(e.caret.start-prevCaretPos) { - e.caret.end = prevCaretPos + if abs(end-start) < abs(start-prevCaretPos) { + e.text.SetCaret(start, prevCaretPos) } } else { - e.ClearSelection() + e.text.ClearSelection() } e.dragging = true // Process multi-clicks. switch { case evt.NumClicks == 2: - e.moveWord(-1, selectionClear) - e.moveWord(1, selectionExtend) + e.text.MoveWord(-1, selectionClear) + e.text.MoveWord(1, selectionExtend) e.dragging = false case evt.NumClicks >= 3: - e.moveStart(selectionClear) - e.moveEnd(selectionExtend) + e.text.MoveStart(selectionClear) + e.text.MoveEnd(selectionExtend) e.dragging = false } } @@ -300,11 +273,11 @@ func (e *Editor) processPointer(gtx layout.Context) { case evt.Type == pointer.Drag && evt.Source == pointer.Mouse: if e.dragging { e.blinkStart = gtx.Now - e.moveCoord(image.Point{ + e.text.MoveCoord(image.Point{ X: int(math.Round(float64(evt.Position.X))), Y: int(math.Round(float64(evt.Position.Y))), }) - e.caret.scroll = true + e.scrollCaret = true if release { e.dragging = false @@ -331,7 +304,7 @@ func (e *Editor) clickDragEvents(gtx layout.Context) []event.Event { } func (e *Editor) processKey(gtx layout.Context) { - if e.rr.Changed() { + if e.text.Changed() { e.events = append(e.events, ChangeEvent{}) } // adjust keeps track of runes dropped because of MaxLen. @@ -350,13 +323,13 @@ func (e *Editor) processKey(gtx layout.Context) { if !e.ReadOnly && e.Submit && (ke.Name == key.NameReturn || ke.Name == key.NameEnter) { if !ke.Modifiers.Contain(key.ModShift) { e.events = append(e.events, SubmitEvent{ - Text: e.Text(), + Text: e.text.Text(), }) continue } } e.command(gtx, ke) - e.caret.scroll = true + e.scrollCaret = true e.scroller.Stop() case key.SnippetEvent: e.updateSnippet(gtx, ke.Start, ke.End) @@ -364,7 +337,7 @@ func (e *Editor) processKey(gtx layout.Context) { if e.ReadOnly { break } - e.caret.scroll = true + e.scrollCaret = true e.scroller.Stop() s := ke.Text moves := 0 @@ -381,65 +354,38 @@ func (e *Editor) processKey(gtx layout.Context) { } moves += e.replace(ke.Range.Start, ke.Range.End, s, true) adjust += utf8.RuneCountInString(ke.Text) - moves - e.caret.xoff = 0 + // Reset caret xoff. + e.text.MoveCaret(0, 0) if submit { - if e.rr.Changed() { + if e.text.Changed() { e.events = append(e.events, ChangeEvent{}) } e.events = append(e.events, SubmitEvent{ - Text: e.Text(), + Text: e.text.Text(), }) } // Complete a paste event, initiated by Shortcut-V in Editor.command(). case clipboard.Event: - e.caret.scroll = true + e.scrollCaret = true e.scroller.Stop() - e.append(ke.Text) + e.Insert(ke.Text) case key.SelectionEvent: - e.caret.scroll = true + e.scrollCaret = true e.scroller.Stop() ke.Start -= adjust ke.End -= adjust adjust = 0 - e.caret.start = e.closestToRune(ke.Start).runes - e.caret.end = e.closestToRune(ke.End).runes + e.text.SetCaret(ke.Start, ke.End) } } - if e.rr.Changed() { + if e.text.Changed() { e.events = append(e.events, ChangeEvent{}) } } -func (e *Editor) closestToRune(runeIdx int) combinedPos { - e.makeValid() - pos, _ := e.index.closestToRune(runeIdx) - return pos -} - -func (e *Editor) closestToLineCol(line, col int) combinedPos { - e.makeValid() - return e.index.closestToLineCol(screenPos{line: line, col: col}) -} - -func (e *Editor) closestToXY(x fixed.Int26_6, y int) combinedPos { - e.makeValid() - return e.index.closestToXY(x, y) -} - -func (e *Editor) moveLines(distance int, selAct selectionAction) { - caretStart := e.closestToRune(e.caret.start) - x := caretStart.x + e.caret.xoff - // Seek to line. - pos := e.closestToLineCol(caretStart.lineCol.line+distance, 0) - pos = e.closestToXY(x, pos.y) - e.caret.start = pos.runes - e.caret.xoff = x - pos.x - e.updateSelection(selAct) -} - func (e *Editor) command(gtx layout.Context, k key.Event) { direction := 1 - if e.locale.Direction.Progression() == system.TowardOrigin { + if gtx.Locale.Direction.Progression() == system.TowardOrigin { direction = -1 } moveByWord := k.Modifiers.Contain(key.ModShortcutAlt) @@ -450,7 +396,7 @@ func (e *Editor) command(gtx layout.Context, k key.Event) { switch k.Name { case key.NameReturn, key.NameEnter: if !e.ReadOnly { - e.append("\n") + e.Insert("\n") } case key.NameDeleteBackward: if !e.ReadOnly { @@ -469,35 +415,35 @@ func (e *Editor) command(gtx layout.Context, k key.Event) { } } case key.NameUpArrow: - e.moveLines(-1, selAct) + e.text.MoveLines(-1, selAct) case key.NameDownArrow: - e.moveLines(+1, selAct) + e.text.MoveLines(+1, selAct) case key.NameLeftArrow: if moveByWord { - e.moveWord(-1*direction, selAct) + e.text.MoveWord(-1*direction, selAct) } else { if selAct == selectionClear { - e.ClearSelection() + e.text.ClearSelection() } - e.MoveCaret(-1*direction, -1*direction*int(selAct)) + e.text.MoveCaret(-1*direction, -1*direction*int(selAct)) } case key.NameRightArrow: if moveByWord { - e.moveWord(1*direction, selAct) + e.text.MoveWord(1*direction, selAct) } else { if selAct == selectionClear { - e.ClearSelection() + e.text.ClearSelection() } - e.MoveCaret(1*direction, int(selAct)*direction) + e.text.MoveCaret(1*direction, int(selAct)*direction) } case key.NamePageUp: - e.movePages(-1, selAct) + e.text.MovePages(-1, selAct) case key.NamePageDown: - e.movePages(+1, selAct) + e.text.MovePages(+1, selAct) case key.NameHome: - e.moveStart(selAct) + e.text.MoveStart(selAct) case key.NameEnd: - e.moveEnd(selAct) + e.text.MoveEnd(selAct) // Initiate a paste operation, by requesting the clipboard contents; other // half is in Editor.processKey() under clipboard.Event. case "V": @@ -506,7 +452,7 @@ func (e *Editor) command(gtx layout.Context, k key.Event) { } // Copy or Cut selection -- ignored if nothing selected. case "C", "X": - if text := e.SelectedText(); text != "" { + if text := e.text.SelectedText(); text != "" { clipboard.WriteOp{Text: text}.Add(gtx.Ops) if k.Name == "X" && !e.ReadOnly { e.Delete(1) @@ -514,8 +460,7 @@ func (e *Editor) command(gtx layout.Context, k key.Event) { } // Select all case "A": - e.caret.end = 0 - e.caret.start = e.Len() + e.text.SetCaret(0, e.text.Len()) case "Z": if !e.ReadOnly { if k.Modifiers.Contain(key.ModShift) { @@ -537,71 +482,35 @@ func (e *Editor) Focused() bool { return e.focused } -// calculateViewSize determines the size of the current visible content, -// ensuring that even if there is no text content, some space is reserved -// for the caret. -func (e *Editor) calculateViewSize(gtx layout.Context) image.Point { - base := e.dims.Size - if caretWidth := e.caretWidth(gtx); base.X < caretWidth { - base.X = caretWidth +// initBuffer should be invoked first in every exported function that accesses +// text state. It ensures that the underlying text widget is both ready to use +// and has its fields synced with the editor. +func (e *Editor) initBuffer() { + if e.buffer == nil { + e.buffer = new(editBuffer) + e.text.SetSource(e.buffer) } - return gtx.Constraints.Constrain(base) + e.text.Alignment = e.Alignment + e.text.SingleLine = e.SingleLine + e.text.Mask = e.Mask } // Layout lays out the editor. If content is not nil, it is laid out on top. func (e *Editor) Layout(gtx layout.Context, lt *text.Shaper, font text.Font, size unit.Sp, content layout.Widget) layout.Dimensions { - if e.locale != gtx.Locale { - e.locale = gtx.Locale - e.invalidate() - } - textSize := fixed.I(gtx.Sp(size)) - if e.font != font || e.textSize != textSize { - e.invalidate() - e.font = font - e.textSize = textSize - } - maxWidth := gtx.Constraints.Max.X - if e.SingleLine { - maxWidth = math.MaxInt - } - minWidth := gtx.Constraints.Min.X - if maxWidth != e.maxWidth { - e.maxWidth = maxWidth - e.invalidate() - } - if minWidth != e.minWidth { - e.minWidth = minWidth - e.invalidate() - } - if lt != e.shaper { - e.shaper = lt - e.invalidate() - } - if e.Mask != e.lastMask { - e.lastMask = e.Mask - e.invalidate() - } - - e.makeValid() - e.processEvents(gtx) - e.makeValid() - - if viewSize := e.calculateViewSize(gtx); viewSize != e.viewSize { - e.viewSize = viewSize - e.invalidate() - } - e.makeValid() + e.initBuffer() + e.text.Update(gtx, lt, font, size, e.processEvents) dims := e.layout(gtx, content) if e.focused { // Notify IME of selection if it changed. newSel := e.ime.selection + start, end := e.text.Selection() newSel.rng = key.Range{ - Start: e.caret.start, - End: e.caret.end, + Start: start, + End: end, } - caretPos, carAsc, carDesc := e.caretInfo() + caretPos, carAsc, carDesc := e.text.CaretInfo() newSel.caret = key.Caret{ Pos: layout.FPt(caretPos), Ascent: float32(carAsc), @@ -628,19 +537,23 @@ func (e *Editor) updateSnippet(gtx layout.Context, start, end int) { if start > end { start, end = end, start } - imeStart := e.closestToRune(start) - imeEnd := e.closestToRune(end) - e.ime.start = imeStart.runes - e.ime.end = imeEnd.runes - startOff := e.runeOffset(imeStart.runes) - endOff := e.runeOffset(imeEnd.runes) - e.rr.Seek(int64(startOff), io.SeekStart) + length := e.text.Len() + if start > length { + start = length + } + if end > length { + end = length + } + e.ime.start = start + e.ime.end = end + startOff := e.text.ByteOffset(start) + endOff := e.text.ByteOffset(end) n := endOff - startOff - if n > len(e.ime.scratch) { + if n > int64(len(e.ime.scratch)) { e.ime.scratch = make([]byte, n) } scratch := e.ime.scratch[:n] - read, _ := e.rr.Read(scratch) + read, _ := e.text.ReadAt(scratch, startOff) if read != len(scratch) { panic("e.rr.Read truncated data") } @@ -666,14 +579,16 @@ func (e *Editor) updateSnippet(gtx layout.Context, start, end int) { func (e *Editor) layout(gtx layout.Context, content layout.Widget) layout.Dimensions { // Adjust scrolling for new viewport and layout. - e.scrollRel(0, 0) + e.text.ScrollRel(0, 0) - if e.caret.scroll { - e.caret.scroll = false - e.scrollToCaret() + if e.scrollCaret { + e.scrollCaret = false + e.text.ScrollToCaret() } + textDims := e.text.FullDimensions() + visibleDims := e.text.Dimensions() - defer clip.Rect(image.Rectangle{Max: e.viewSize}).Push(gtx.Ops).Pop() + defer clip.Rect(image.Rectangle{Max: visibleDims.Size}).Push(gtx.Ops).Pop() pointer.CursorText.Add(gtx.Ops) var keys key.Set if e.focused { @@ -681,17 +596,17 @@ func (e *Editor) layout(gtx layout.Context, content layout.Widget) layout.Dimens const keyFilterNoRightDown = "(ShortAlt)-(Shift)-[←,↑]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z" const keyFilterNoArrows = "(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z" const keyFilterAllArrows = "(ShortAlt)-(Shift)-[←,→,↑,↓]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z" - caret := e.closestToRune(e.caret.start) + caret, _ := e.text.Selection() switch { - case caret.runes == 0 && caret.runes == e.Len(): + case caret == 0 && caret == e.text.Len(): keys = keyFilterNoArrows - case caret.runes == 0: + case caret == 0: if gtx.Locale.Direction.Progression() == system.FromOrigin { keys = keyFilterNoLeftUp } else { keys = keyFilterNoRightDown } - case caret.runes == e.Len(): + case caret == e.text.Len(): if gtx.Locale.Direction.Progression() == system.FromOrigin { keys = keyFilterNoRightDown } else { @@ -710,17 +625,19 @@ func (e *Editor) layout(gtx layout.Context, content layout.Widget) layout.Dimens var scrollRange image.Rectangle if e.SingleLine { - scrollRange.Min.X = min(-e.scrollOff.X, 0) - scrollRange.Max.X = max(0, e.dims.Size.X-(e.scrollOff.X+e.viewSize.X)) + scrollOffX := e.text.ScrollOff().X + scrollRange.Min.X = min(-scrollOffX, 0) + scrollRange.Max.X = max(0, textDims.Size.X-(scrollOffX+visibleDims.Size.X)) } else { - scrollRange.Min.Y = -e.scrollOff.Y - scrollRange.Max.Y = max(0, e.dims.Size.Y-(e.scrollOff.Y+e.viewSize.Y)) + scrollOffY := e.text.ScrollOff().Y + scrollRange.Min.Y = -scrollOffY + scrollRange.Max.Y = max(0, textDims.Size.Y-(scrollOffY+visibleDims.Size.Y)) } e.scroller.Add(gtx.Ops, scrollRange) e.clicker.Add(gtx.Ops) e.dragger.Add(gtx.Ops) - e.caret.on = false + e.showCaret = false if e.focused { now := gtx.Now dt := now.Sub(e.blinkStart) @@ -731,255 +648,70 @@ func (e *Editor) layout(gtx layout.Context, content layout.Widget) layout.Dimens redraw := op.InvalidateOp{At: nextBlink} redraw.Add(gtx.Ops) } - e.caret.on = e.focused && (!blinking || dt%timePerBlink < timePerBlink/2) + e.showCaret = e.focused && (!blinking || dt%timePerBlink < timePerBlink/2) } if content != nil { content(gtx) } - return layout.Dimensions{Size: e.viewSize, Baseline: e.dims.Baseline} + return visibleDims } // PaintSelection paints the contrasting background for selected text. func (e *Editor) PaintSelection(gtx layout.Context) { + e.initBuffer() if !e.focused { return } - localViewport := image.Rectangle{Max: e.viewSize} - docViewport := image.Rectangle{Max: e.viewSize}.Add(e.scrollOff) - defer clip.Rect(localViewport).Push(gtx.Ops).Pop() - e.regions = e.index.locate(docViewport, e.caret.start, e.caret.end, e.regions) - for _, region := range e.regions { - area := clip.Rect(region.Bounds).Push(gtx.Ops) - paint.PaintOp{}.Add(gtx.Ops) - area.Pop() - } + e.text.PaintSelection(gtx) } func (e *Editor) PaintText(gtx layout.Context) { - m := op.Record(gtx.Ops) - viewport := image.Rectangle{ - Min: e.scrollOff, - Max: e.viewSize.Add(e.scrollOff), - } - it := textIterator{viewport: viewport} - - startGlyph := 0 - for _, line := range e.index.lines { - if line.descent.Ceil()+line.yOff >= viewport.Min.Y { - break - } - startGlyph += line.glyphs - } - var glyphs [32]text.Glyph - line := glyphs[:0] - for _, g := range e.index.glyphs[startGlyph:] { - var ok bool - if line, ok = it.paintGlyph(gtx, e.shaper, g, line); !ok { - break - } - } - - call := m.Stop() - viewport.Min = viewport.Min.Add(it.padding.Min) - viewport.Max = viewport.Max.Add(it.padding.Max) - defer clip.Rect(viewport.Sub(e.scrollOff)).Push(gtx.Ops).Pop() - call.Add(gtx.Ops) -} - -// caretWidth returns the width occupied by the caret for the current -// gtx. -func (e *Editor) caretWidth(gtx layout.Context) int { - carWidth2 := gtx.Dp(1) / 2 - if carWidth2 < 1 { - carWidth2 = 1 - } - return carWidth2 + e.initBuffer() + e.text.PaintText(gtx) } func (e *Editor) PaintCaret(gtx layout.Context) { - if !e.caret.on || e.ReadOnly { + e.initBuffer() + if !e.showCaret || e.ReadOnly { return } - carWidth2 := e.caretWidth(gtx) - caretPos, carAsc, carDesc := e.caretInfo() - - carRect := image.Rectangle{ - Min: caretPos.Sub(image.Pt(carWidth2, carAsc)), - Max: caretPos.Add(image.Pt(carWidth2, carDesc)), - } - cl := image.Rectangle{Max: e.viewSize} - carRect = cl.Intersect(carRect) - if !carRect.Empty() { - defer clip.Rect(carRect).Push(gtx.Ops).Pop() - paint.PaintOp{}.Add(gtx.Ops) - } -} - -func (e *Editor) caretInfo() (pos image.Point, ascent, descent int) { - caretStart := e.closestToRune(e.caret.start) - - ascent = caretStart.ascent.Ceil() - descent = caretStart.descent.Ceil() - - pos = image.Point{ - X: caretStart.x.Round(), - Y: caretStart.y, - } - pos = pos.Sub(e.scrollOff) - return + e.text.PaintCaret(gtx) } // Len is the length of the editor contents, in runes. func (e *Editor) Len() int { - e.makeValid() - return e.closestToRune(math.MaxInt).runes + e.initBuffer() + return e.text.Len() } // Text returns the contents of the editor. func (e *Editor) Text() string { - return e.rr.String() + e.initBuffer() + return e.text.Text() } -// SetText replaces the contents of the editor, clearing any selection first. func (e *Editor) SetText(s string) { - e.rr = editBuffer{} - e.caret.start = 0 - e.caret.end = 0 + e.initBuffer() if e.SingleLine { s = strings.ReplaceAll(s, "\n", " ") } - e.replace(e.caret.start, e.caret.end, s, true) - e.caret.xoff = 0 -} - -func (e *Editor) scrollBounds() image.Rectangle { - var b image.Rectangle - if e.SingleLine { - if len(e.index.lines) > 0 { - line := e.index.lines[0] - b.Min.X = line.xOff.Floor() - if b.Min.X > 0 { - b.Min.X = 0 - } - } - b.Max.X = e.dims.Size.X + b.Min.X - e.viewSize.X - } else { - b.Max.Y = e.dims.Size.Y - e.viewSize.Y - } - return b -} - -func (e *Editor) scrollRel(dx, dy int) { - e.scrollAbs(e.scrollOff.X+dx, e.scrollOff.Y+dy) -} - -func (e *Editor) scrollAbs(x, y int) { - e.scrollOff.X = x - e.scrollOff.Y = y - b := e.scrollBounds() - if e.scrollOff.X > b.Max.X { - e.scrollOff.X = b.Max.X - } - if e.scrollOff.X < b.Min.X { - e.scrollOff.X = b.Min.X - } - if e.scrollOff.Y > b.Max.Y { - e.scrollOff.Y = b.Max.Y - } - if e.scrollOff.Y < b.Min.Y { - e.scrollOff.Y = b.Min.Y - } -} - -func (e *Editor) moveCoord(pos image.Point) { - x := fixed.I(pos.X + e.scrollOff.X) - y := pos.Y + e.scrollOff.Y - e.caret.start = e.closestToXY(x, y).runes - e.caret.xoff = 0 -} - -func (e *Editor) layoutText(lt *text.Shaper) { - e.rr.Reset() - var r io.RuneReader = &e.rr - if e.Mask != 0 { - e.maskReader.Reset(&e.rr, e.Mask) - r = &e.maskReader - } - e.index = glyphIndex{} - it := textIterator{viewport: image.Rectangle{Max: image.Point{X: math.MaxInt, Y: math.MaxInt}}} - if lt != nil { - lt.Layout(text.Parameters{ - Font: e.font, - PxPerEm: e.textSize, - Alignment: e.Alignment, - }, e.minWidth, e.maxWidth, e.locale, r) - for glyph, ok := it.processGlyph(lt.NextGlyph()); ok; glyph, ok = it.processGlyph(lt.NextGlyph()) { - e.index.Glyph(glyph) - } - } else { - // Make a fake glyph for every rune in the reader. - for _, _, err := r.ReadRune(); err != io.EOF; _, _, err = r.ReadRune() { - g, _ := it.processGlyph(text.Glyph{Runes: 1, Flags: text.FlagClusterBreak}, true) - e.index.Glyph(g) - - } - } - dims := layout.Dimensions{Size: it.bounds.Size()} - dims.Baseline = dims.Size.Y - it.baseline - e.dims = dims + e.replace(0, e.text.Len(), s, true) + // Reset xoff and move the caret to the beginning. + e.SetCaret(0, 0) } // CaretPos returns the line & column numbers of the caret. func (e *Editor) CaretPos() (line, col int) { - pos := e.closestToRune(e.caret.start) - return pos.lineCol.line, pos.lineCol.col + e.initBuffer() + return e.text.CaretPos() } // CaretCoords returns the coordinates of the caret, relative to the // editor itself. func (e *Editor) CaretCoords() f32.Point { - pos := e.closestToRune(e.caret.start) - return f32.Pt(float32(pos.x)/64-float32(e.scrollOff.X), float32(pos.y-e.scrollOff.Y)) -} - -// indexRune returns the latest rune index and byte offset no later than r. -func (e *Editor) indexRune(r int) offEntry { - // Initialize index. - if len(e.offIndex) == 0 { - e.offIndex = append(e.offIndex, offEntry{}) - } - i := sort.Search(len(e.offIndex), func(i int) bool { - entry := e.offIndex[i] - return entry.runes >= r - }) - // Return the entry guaranteed to be less than or equal to r. - if i > 0 { - i-- - } - return e.offIndex[i] -} - -// runeOffset returns the byte offset into e.rr of the r'th rune. -// r must be a valid rune index, usually returned by closestPosition. -func (e *Editor) runeOffset(r int) int { - const runesPerIndexEntry = 50 - entry := e.indexRune(r) - lastEntry := e.offIndex[len(e.offIndex)-1].runes - for entry.runes < r { - if entry.runes > lastEntry && entry.runes%runesPerIndexEntry == runesPerIndexEntry-1 { - e.offIndex = append(e.offIndex, entry) - } - _, s := e.rr.runeAt(entry.bytes) - entry.bytes += s - entry.runes++ - } - return entry.bytes -} - -func (e *Editor) invalidate() { - e.offIndex = e.offIndex[:0] - e.valid = false + e.initBuffer() + return e.text.CaretCoords() } // Delete runes from the caret position. The sign of runes specifies the @@ -987,44 +719,37 @@ func (e *Editor) invalidate() { // // If there is a selection, it is deleted and counts as a single rune. func (e *Editor) Delete(runes int) { + e.initBuffer() if runes == 0 { return } - start := e.caret.start - end := e.caret.end + start, end := e.text.Selection() if start != end { runes -= sign(runes) } end += runes e.replace(start, end, "", true) - e.caret.xoff = 0 + // Reset xoff. + e.text.MoveCaret(0, 0) e.ClearSelection() } -// Insert inserts text at the caret, moving the caret forward. If there is a -// selection, Insert overwrites it. func (e *Editor) Insert(s string) { - e.append(s) - e.caret.scroll = true -} - -// 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 -func (e *Editor) append(s string) { + e.initBuffer() if e.SingleLine { s = strings.ReplaceAll(s, "\n", " ") } - moves := e.replace(e.caret.start, e.caret.end, s, true) - e.caret.xoff = 0 - start := e.caret.start - if end := e.caret.end; end < start { + start, end := e.text.Selection() + moves := e.replace(start, end, s, true) + if end < start { start = end } - e.caret.start = start + moves - e.caret.end = e.caret.start + // Reset xoff. + e.text.MoveCaret(0, 0) + e.SetCaret(start+moves, start+moves) + e.scrollCaret = true } // modification represents a change to the contents of the editor buffer. @@ -1045,6 +770,7 @@ type modification struct { // undo applies the modification at e.history[e.historyIdx] and decrements // e.historyIdx. func (e *Editor) undo() { + e.initBuffer() if len(e.history) < 1 || e.nextHistoryIdx == 0 { return } @@ -1059,6 +785,7 @@ func (e *Editor) undo() { // redo applies the modification at e.history[e.historyIdx] and increments // e.historyIdx. func (e *Editor) redo() { + e.initBuffer() if len(e.history) < 1 || e.nextHistoryIdx == len(e.history) { return } @@ -1073,15 +800,16 @@ func (e *Editor) redo() { // replace the text between start and end with s. Indices are in runes. // It returns the number of runes inserted. // addHistory controls whether this modification is recorded in the undo -// history. +// history. replace can modify text in positions unrelated to the cursor +// position. func (e *Editor) replace(start, end int, s string, addHistory bool) int { + length := e.text.Len() if start > end { start, end = end, start } - startPos := e.closestToRune(start) - endPos := e.closestToRune(end) - startOff := e.runeOffset(startPos.runes) - replaceSize := endPos.runes - startPos.runes + start = min(start, length) + end = min(end, length) + replaceSize := end - start el := e.Len() var sc int idx := 0 @@ -1098,119 +826,49 @@ func (e *Editor) replace(start, end int, s string, addHistory bool) int { idx += n sc++ } - newEnd := startPos.runes + sc if addHistory { - e.rr.Seek(int64(startOff), 0) deleted := make([]rune, 0, replaceSize) + readPos := e.text.ByteOffset(start) for i := 0; i < replaceSize; i++ { - ru, _, _ := e.rr.ReadRune() + ru, s, _ := e.text.ReadRuneAt(int64(readPos)) + readPos += int64(s) deleted = append(deleted, ru) } if e.nextHistoryIdx < len(e.history) { e.history = e.history[:e.nextHistoryIdx] } e.history = append(e.history, modification{ - StartRune: startPos.runes, + StartRune: start, ApplyContent: s, ReverseContent: string(deleted), }) e.nextHistoryIdx++ } - e.rr.deleteRunes(startOff, replaceSize) - e.rr.prepend(startOff, s) + sc = e.text.Replace(start, end, s) + newEnd := start + sc adjust := func(pos int) int { switch { - case newEnd < pos && pos <= endPos.runes: + case newEnd < pos && pos <= end: pos = newEnd - case endPos.runes < pos: - diff := newEnd - endPos.runes + case end < pos: + diff := newEnd - end pos = pos + diff } return pos } - e.caret.start = adjust(e.caret.start) - e.caret.end = adjust(e.caret.end) e.ime.start = adjust(e.ime.start) e.ime.end = adjust(e.ime.end) - e.invalidate() return sc } -func (e *Editor) movePages(pages int, selAct selectionAction) { - caret := e.closestToRune(e.caret.start) - x := caret.x + e.caret.xoff - y := caret.y + pages*e.viewSize.Y - pos := e.closestToXY(x, y) - e.caret.start = pos.runes - e.caret.xoff = x - pos.x - e.updateSelection(selAct) -} - // MoveCaret moves the caret (aka selection start) and the selection end // relative to their current positions. Positive distances moves forward, // negative distances moves backward. Distances are in runes. func (e *Editor) MoveCaret(startDelta, endDelta int) { - e.caret.xoff = 0 - e.caret.start = e.closestToRune(e.caret.start + startDelta).runes - e.caret.end = e.closestToRune(e.caret.end + endDelta).runes -} - -func (e *Editor) moveStart(selAct selectionAction) { - caret := e.closestToRune(e.caret.start) - caret = e.closestToLineCol(caret.lineCol.line, 0) - e.caret.start = caret.runes - e.caret.xoff = -caret.x - e.updateSelection(selAct) -} - -func (e *Editor) moveEnd(selAct selectionAction) { - caret := e.closestToRune(e.caret.start) - caret = e.closestToLineCol(caret.lineCol.line, math.MaxInt) - e.caret.start = caret.runes - e.caret.xoff = fixed.I(e.maxWidth) - caret.x - e.updateSelection(selAct) -} - -// moveWord moves the caret to the next word in the specified direction. -// Positive is forward, negative is backward. -// Absolute values greater than one will skip that many words. -func (e *Editor) moveWord(distance int, selAct selectionAction) { - // split the distance information into constituent parts to be - // used independently. - words, direction := distance, 1 - if distance < 0 { - words, direction = distance*-1, -1 - } - // atEnd if caret is at either side of the buffer. - caret := e.closestToRune(e.caret.start) - atEnd := func() bool { - return caret.runes == 0 || caret.runes == e.Len() - } - // next returns the appropriate rune given the direction. - next := func() (r rune) { - off := e.runeOffset(caret.runes) - if direction < 0 { - r, _ = e.rr.runeBefore(off) - } else { - r, _ = e.rr.runeAt(off) - } - return r - } - for ii := 0; ii < words; ii++ { - for r := next(); unicode.IsSpace(r) && !atEnd(); r = next() { - e.MoveCaret(direction, 0) - caret = e.closestToRune(e.caret.start) - } - e.MoveCaret(direction, 0) - caret = e.closestToRune(e.caret.start) - for r := next(); !unicode.IsSpace(r) && !atEnd(); r = next() { - e.MoveCaret(direction, 0) - caret = e.closestToRune(e.caret.start) - } - } - e.updateSelection(selAct) + e.initBuffer() + e.text.MoveCaret(startDelta, endDelta) } // deleteWord deletes the next word(s) in the specified direction. @@ -1223,7 +881,8 @@ func (e *Editor) deleteWord(distance int) { return } - if e.caret.start != e.caret.end { + start, end := e.text.Selection() + if start != end { e.Delete(1) distance -= sign(distance) } @@ -1237,26 +896,26 @@ func (e *Editor) deleteWord(distance int) { if distance < 0 { words, direction = distance*-1, -1 } + caret, _ := e.text.Selection() // atEnd if offset is at or beyond either side of the buffer. - caret := e.closestToRune(e.caret.start) atEnd := func(runes int) bool { - idx := caret.runes + runes*direction + idx := caret + runes*direction return idx <= 0 || idx >= e.Len() } // next returns the appropriate rune given the direction and offset in runes). next := func(runes int) rune { - idx := caret.runes + runes*direction + idx := caret + runes*direction if idx < 0 { idx = 0 } else if idx > e.Len() { idx = e.Len() } - off := e.runeOffset(idx) + off := e.text.ByteOffset(idx) var r rune if direction < 0 { - r, _ = e.rr.runeBefore(off) + r, _, _ = e.text.ReadRuneBefore(int64(off)) } else { - r, _ = e.rr.runeAt(off) + r, _, _ = e.text.ReadRuneAt(int64(off)) } return r } @@ -1271,92 +930,58 @@ func (e *Editor) deleteWord(distance int) { e.Delete(runes * direction) } -func (e *Editor) scrollToCaret() { - caret := e.closestToRune(e.caret.start) - if e.SingleLine { - var dist int - if d := caret.x.Floor() - e.scrollOff.X; d < 0 { - dist = d - } else if d := caret.x.Ceil() - (e.scrollOff.X + e.viewSize.X); d > 0 { - dist = d - } - e.scrollRel(dist, 0) - } else { - miny := caret.y - caret.ascent.Ceil() - maxy := caret.y + caret.descent.Ceil() - var dist int - if d := miny - e.scrollOff.Y; d < 0 { - dist = d - } else if d := maxy - (e.scrollOff.Y + e.viewSize.Y); d > 0 { - dist = d - } - e.scrollRel(0, dist) - } -} - // SelectionLen returns the length of the selection, in runes; it is // equivalent to utf8.RuneCountInString(e.SelectedText()). func (e *Editor) SelectionLen() int { - return abs(e.caret.start - e.caret.end) + e.initBuffer() + return e.text.SelectionLen() } // Selection returns the start and end of the selection, as rune offsets. // start can be > end. func (e *Editor) Selection() (start, end int) { - return e.caret.start, e.caret.end + e.initBuffer() + return e.text.Selection() } // SetCaret moves the caret to start, and sets the selection end to end. start // and end are in runes, and represent offsets into the editor text. func (e *Editor) SetCaret(start, end int) { - e.caret.start = e.closestToRune(start).runes - e.caret.end = e.closestToRune(end).runes - e.caret.scroll = true + e.initBuffer() + e.text.SetCaret(start, end) + e.scrollCaret = true e.scroller.Stop() } // SelectedText returns the currently selected text (if any) from the editor. func (e *Editor) SelectedText() string { - startOff := e.runeOffset(e.caret.start) - endOff := e.runeOffset(e.caret.end) - start := min(startOff, endOff) - end := max(startOff, endOff) - buf := make([]byte, end-start) - e.rr.Seek(int64(start), 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() - } + e.initBuffer() + return e.text.SelectedText() } // ClearSelection clears the selection, by setting the selection end equal to // the selection start. func (e *Editor) ClearSelection() { - e.caret.end = e.caret.start + e.initBuffer() + e.text.ClearSelection() } // WriteTo implements io.WriterTo. func (e *Editor) WriteTo(w io.Writer) (int64, error) { - return e.rr.WriteTo(w) + e.initBuffer() + return e.text.WriteTo(w) } // Seek implements io.Seeker. func (e *Editor) Seek(offset int64, whence int) (int64, error) { - return e.rr.Seek(offset, io.SeekStart) + e.initBuffer() + return e.text.Seek(offset, whence) } // Read implements io.Reader. func (e *Editor) Read(p []byte) (int, error) { - return e.rr.Read(p) + e.initBuffer() + return e.text.Read(p) } func max(a, b int) int { diff --git a/widget/editor_test.go b/widget/editor_test.go index e8ab6312..528edc91 100644 --- a/widget/editor_test.go +++ b/widget/editor_test.go @@ -121,7 +121,7 @@ func TestEditorReadOnly(t *testing.T) { // Select everything. gtx.Ops.Reset() - gtx.Queue.(*testQueue).events = append(gtx.Queue.(*testQueue).events, key.Event{Name: "A", Modifiers: key.ModShortcut}) + gtx.Queue = &testQueue{events: []event.Event{key.Event{Name: "A", Modifiers: key.ModShortcut}}} dims = e.Layout(gtx, cache, font, fontSize, nil) textContent := e.Text() cStart2, cEnd2 := e.Selection() @@ -137,7 +137,7 @@ func TestEditorReadOnly(t *testing.T) { // Type some new characters. gtx.Ops.Reset() - gtx.Queue.(*testQueue).events = append(gtx.Queue.(*testQueue).events, key.EditEvent{Range: key.Range{Start: cStart2, End: cEnd2}, Text: "something else"}) + gtx.Queue = &testQueue{events: []event.Event{key.EditEvent{Range: key.Range{Start: cStart2, End: cEnd2}, Text: "something else"}}} dims = e.Layout(gtx, cache, font, fontSize, nil) textContent2 := e.Text() if textContent2 != textContent { @@ -146,7 +146,7 @@ func TestEditorReadOnly(t *testing.T) { // Try to delete selection. gtx.Ops.Reset() - gtx.Queue.(*testQueue).events = append(gtx.Queue.(*testQueue).events, key.Event{Name: key.NameDeleteBackward}) + gtx.Queue = &testQueue{events: []event.Event{key.Event{Name: key.NameDeleteBackward}}} dims = e.Layout(gtx, cache, font, fontSize, nil) textContent2 = e.Text() if textContent2 != textContent { @@ -156,7 +156,7 @@ func TestEditorReadOnly(t *testing.T) { // Click and drag from the middle of the first line // to the center. gtx.Ops.Reset() - gtx.Queue.(*testQueue).events = append(gtx.Queue.(*testQueue).events, + gtx.Queue = &testQueue{events: []event.Event{ pointer.Event{ Type: pointer.Press, Buttons: pointer.ButtonPrimary, @@ -172,7 +172,8 @@ func TestEditorReadOnly(t *testing.T) { Buttons: pointer.ButtonPrimary, Position: layout.FPt(dims.Size).Mul(.5), }, - ) + }} + e.Layout(gtx, cache, font, fontSize, nil) cStart3, cEnd3 := e.Selection() if cStart3 == cStart2 || cEnd3 == cEnd2 { t.Errorf("expected mouse interaction to change selection.") @@ -246,7 +247,7 @@ func TestEditor(t *testing.T) { // Regression test for bad in-cluster rune offset math. e.SetText("æbc") e.Layout(gtx, cache, font, fontSize, nil) - e.moveEnd(selectionClear) + e.text.MoveEnd(selectionClear) assertCaret(t, e, 0, 3, len("æbc")) textSample := "æbc\naøå••" @@ -258,19 +259,19 @@ func TestEditor(t *testing.T) { } e.Layout(gtx, cache, font, fontSize, nil) assertCaret(t, e, 0, 0, 0) - e.moveEnd(selectionClear) + e.text.MoveEnd(selectionClear) assertCaret(t, e, 0, 3, len("æbc")) e.MoveCaret(+1, +1) assertCaret(t, e, 1, 0, len("æbc\n")) e.MoveCaret(-1, -1) assertCaret(t, e, 0, 3, len("æbc")) - e.moveLines(+1, selectionClear) + e.text.MoveLines(+1, selectionClear) assertCaret(t, e, 1, 4, len("æbc\naøå•")) - e.moveEnd(selectionClear) + e.text.MoveEnd(selectionClear) assertCaret(t, e, 1, 5, len("æbc\naøå••")) e.MoveCaret(+1, +1) assertCaret(t, e, 1, 5, len("æbc\naøå••")) - e.moveLines(3, selectionClear) + e.text.MoveLines(3, selectionClear) e.SetCaret(0, 0) assertCaret(t, e, 0, 0, 0) @@ -282,7 +283,7 @@ func TestEditor(t *testing.T) { // Ensure that password masking does not affect caret behavior e.MoveCaret(-3, -3) assertCaret(t, e, 1, 1, len("æbc\na")) - e.Mask = '*' + e.text.Mask = '*' e.Layout(gtx, cache, font, fontSize, nil) assertCaret(t, e, 1, 1, len("æbc\na")) e.MoveCaret(-3, -3) @@ -324,9 +325,9 @@ func TestEditor(t *testing.T) { // Test that moveLine applies x offsets from previous moves. e.SetText("long line\nshort") e.SetCaret(0, 0) - e.moveEnd(selectionClear) - e.moveLines(+1, selectionClear) - e.moveLines(-1, selectionClear) + e.text.MoveEnd(selectionClear) + e.text.MoveLines(+1, selectionClear) + e.text.MoveLines(-1, selectionClear) assertCaret(t, e, 0, utf8.RuneCountInString("long line"), len("long line")) } @@ -366,14 +367,14 @@ func TestEditorRTL(t *testing.T) { e.MoveCaret(+1, +1) assertCaret(t, e, 0, 3, len("الح")) // Move to the "end" of the line. This moves to the left edge of the line. - e.moveEnd(selectionClear) + e.text.MoveEnd(selectionClear) assertCaret(t, e, 0, 4, len("الحب")) sentence := "الحب سماء لا\nتمط غير الأحلام" e.SetText(sentence) e.Layout(gtx, cache, font, fontSize, nil) assertCaret(t, e, 0, 0, 0) - e.moveEnd(selectionClear) + e.text.MoveEnd(selectionClear) assertCaret(t, e, 0, 12, len("الحب سماء لا")) e.MoveCaret(+1, +1) assertCaret(t, e, 1, 0, len("الحب سماء لا\n")) @@ -383,13 +384,13 @@ func TestEditorRTL(t *testing.T) { assertCaret(t, e, 1, 0, len("الحب سماء لا\n")) e.MoveCaret(-1, -1) assertCaret(t, e, 0, 12, len("الحب سماء لا")) - e.moveLines(+1, selectionClear) + e.text.MoveLines(+1, selectionClear) assertCaret(t, e, 1, 14, len("الحب سماء لا\nتمط غير الأحلا")) - e.moveEnd(selectionClear) + e.text.MoveEnd(selectionClear) assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام")) e.MoveCaret(+1, +1) assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام")) - e.moveLines(3, selectionClear) + e.text.MoveLines(3, selectionClear) assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام")) e.SetCaret(utf8.RuneCountInString(sentence), 0) assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام")) @@ -440,7 +441,7 @@ func TestEditorLigature(t *testing.T) { assertCaret(t, e, 0, 0, 0) e.SetText("fl") // just a ligature e.Layout(gtx, cache, font, fontSize, nil) - e.moveEnd(selectionClear) + e.text.MoveEnd(selectionClear) assertCaret(t, e, 0, 2, len("fl")) e.MoveCaret(-1, -1) assertCaret(t, e, 0, 1, len("f")) @@ -451,7 +452,7 @@ func TestEditorLigature(t *testing.T) { e.SetText("flaffl•ffi\n•fflfi") // 3 ligatures on line 0, 2 on line 1 e.Layout(gtx, cache, font, fontSize, nil) assertCaret(t, e, 0, 0, 0) - e.moveEnd(selectionClear) + e.text.MoveEnd(selectionClear) assertCaret(t, e, 0, 10, len("ffaffl•ffi")) e.MoveCaret(+1, +1) assertCaret(t, e, 1, 0, len("ffaffl•ffi\n")) @@ -504,13 +505,13 @@ func TestEditorLigature(t *testing.T) { e.Layout(gtx, cache, font, fontSize, nil) // Ensure that all runes in the final cluster of a line are properly // decoded when moving to the end of the line. This is a regression test. - e.moveEnd(selectionClear) + e.text.MoveEnd(selectionClear) // The first line was broken by line wrapping, not a newline character. As such, // the cursor can reach the position after the final glyph (a space). assertCaret(t, e, 0, 14, len("fflffl fflffl ")) - e.moveLines(1, selectionClear) + e.text.MoveLines(1, selectionClear) assertCaret(t, e, 1, 13, len("fflffl fflffl fflffl fflffl")) - e.moveLines(-1, selectionClear) + e.text.MoveLines(-1, selectionClear) assertCaret(t, e, 0, 14, len("fflffl fflffl ")) // Absurdly narrow constraints to force each ligature onto its own line. @@ -554,7 +555,7 @@ 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) } - caretBytes := e.runeOffset(e.caret.start) + caretBytes := e.text.runeOffset(e.text.caret.start) if bytes != caretBytes { t.Errorf("caret at buffer position %d, expected %d", caretBytes, bytes) } @@ -588,9 +589,8 @@ func TestEditorCaretConsistency(t *testing.T) { fontSize := unit.Sp(10) font := text.Font{} for _, a := range []text.Alignment{text.Start, text.Middle, text.End} { - e := &Editor{ - Alignment: a, - } + e := &Editor{} + e.Alignment = a e.Layout(gtx, cache, font, fontSize, nil) consistent := func() error { @@ -598,8 +598,8 @@ func TestEditorCaretConsistency(t *testing.T) { gotLine, gotCol := e.CaretPos() gotCoords := e.CaretCoords() // Blow away index to re-compute position from scratch. - e.invalidate() - want := e.closestToRune(e.caret.start) + e.text.invalidate() + want := e.text.closestToRune(e.text.caret.start) wantCoords := f32.Pt(float32(want.x)/64, float32(want.y)) if want.lineCol.line != gotLine || int(want.lineCol.col) != gotCol || gotCoords != wantCoords { return fmt.Errorf("caret (%d,%d) pos %s, want (%d,%d) pos %s", @@ -619,17 +619,17 @@ func TestEditorCaretConsistency(t *testing.T) { case moveRune: e.MoveCaret(int(distance), int(distance)) case moveLine: - e.moveLines(int(distance), selectionClear) + e.text.MoveLines(int(distance), selectionClear) case movePage: - e.movePages(int(distance), selectionClear) + e.text.MovePages(int(distance), selectionClear) case moveStart: - e.moveStart(selectionClear) + e.text.MoveStart(selectionClear) case moveEnd: - e.moveEnd(selectionClear) + e.text.MoveEnd(selectionClear) case moveCoord: - e.moveCoord(image.Pt(int(x), int(y))) + e.text.MoveCoord(image.Pt(int(x), int(y))) case moveWord: - e.moveWord(int(distance), selectionClear) + e.text.MoveWord(int(distance), selectionClear) case deleteWord: e.deleteWord(int(distance)) default: @@ -687,8 +687,8 @@ func TestEditorMoveWord(t *testing.T) { for ii, tt := range tests { e := setup(tt.Text) e.MoveCaret(tt.Start, tt.Start) - e.moveWord(tt.Skip, selectionClear) - caretBytes := e.runeOffset(e.caret.start) + e.text.MoveWord(tt.Skip, selectionClear) + caretBytes := e.text.runeOffset(e.text.caret.start) if caretBytes != tt.Want { t.Fatalf("[%d] moveWord: bad caret position: got %d, want %d", ii, caretBytes, tt.Want) } @@ -884,7 +884,7 @@ func TestEditorDeleteWord(t *testing.T) { e.MoveCaret(tt.Start, tt.Start) e.MoveCaret(0, tt.Selection) e.deleteWord(tt.Delete) - caretBytes := e.runeOffset(e.caret.start) + caretBytes := e.text.runeOffset(e.text.caret.start) if caretBytes != tt.Want { t.Fatalf("[%d] deleteWord: bad caret position: got %d, want %d", ii, caretBytes, tt.Want) } @@ -938,8 +938,8 @@ g 2 4 6 8 g _ = e.Events() // throw away any events from this layout // Build the selection events - startPos := e.closestToRune(start) - endPos := e.closestToRune(end) + startPos := e.text.closestToRune(start) + endPos := e.text.closestToRune(end) tq := &testQueue{ events: []event.Event{ pointer.Event{ @@ -1008,8 +1008,8 @@ g 2 4 6 8 g gtx.Queue = nil e.Layout(gtx, cache, font, fontSize, nil) - caretStart := e.closestToRune(e.caret.start) - caretEnd := e.closestToRune(e.caret.end) + caretStart := e.text.closestToRune(e.text.caret.start) + caretEnd := e.text.closestToRune(e.text.caret.end) logicalPosMatch(t, n, "start", tst.startPos, caretEnd) logicalPosMatch(t, n, "end", tst.endPos, caretStart) } @@ -1189,8 +1189,8 @@ func TestEditor_Submit(t *testing.T) { // It assumes single-run lines, which isn't safe with non-test text // data. func textWidth(e *Editor, lineNum, colStart, colEnd int) float32 { - start := e.closestToLineCol(lineNum, colStart) - end := e.closestToLineCol(lineNum, colEnd) + start := e.text.closestToLineCol(lineNum, colStart) + end := e.text.closestToLineCol(lineNum, colEnd) delta := start.x - end.x if delta < 0 { delta = -delta @@ -1201,7 +1201,7 @@ func textWidth(e *Editor, lineNum, colStart, colEnd int) float32 { // testBaseline returns the y coordinate of the baseline for the // given line number. func textBaseline(e *Editor, lineNum int) float32 { - start := e.closestToLineCol(lineNum, 0) + start := e.text.closestToLineCol(lineNum, 0) return float32(start.y) } diff --git a/widget/label.go b/widget/label.go index 2f02282d..2b1b836a 100644 --- a/widget/label.go +++ b/widget/label.go @@ -18,12 +18,30 @@ import ( // Label is a widget for laying out and drawing text. type Label struct { - // Alignment specify the text alignment. + // Alignment specifies the text alignment. Alignment text.Alignment // MaxLines limits the number of lines. Zero means no limit. MaxLines int + // Selectable optionally provides text selection state. If nil, + // text will not be selectable. + Selectable *Selectable } +// Layout the label with the given shaper, font, size, and text. Content is a function that will be invoked +// with the label's clip area applied, and should be used to set colors and paint the text/selection. +// content will only be invoked for labels with a non-nil Selectable. For stateless labels, the paint color +// should be set prior to calling Layout. +func (l Label) LayoutSelectable(gtx layout.Context, lt *text.Shaper, font text.Font, size unit.Sp, txt string, content layout.Widget) layout.Dimensions { + if l.Selectable == nil { + return l.Layout(gtx, lt, font, size, txt) + } + l.Selectable.text.Alignment = l.Alignment + l.Selectable.text.MaxLines = l.MaxLines + l.Selectable.SetText(txt) + return l.Selectable.Layout(gtx, lt, font, size, content) +} + +// Layout the text as non-interactive. func (l Label) Layout(gtx layout.Context, lt *text.Shaper, font text.Font, size unit.Sp, txt string) layout.Dimensions { cs := gtx.Constraints textSize := fixed.I(gtx.Sp(size)) diff --git a/widget/material/label.go b/widget/material/label.go index ad41db3a..2a9ae9b5 100644 --- a/widget/material/label.go +++ b/widget/material/label.go @@ -5,6 +5,7 @@ package material import ( "image/color" + "gioui.org/internal/f32color" "gioui.org/layout" "gioui.org/op/paint" "gioui.org/text" @@ -17,6 +18,8 @@ type LabelStyle struct { Font text.Font // Color is the text color. Color color.NRGBA + // SelectionColor is the color of the background for selected text. + SelectionColor color.NRGBA // Alignment specify the text alignment. Alignment text.Alignment // MaxLines limits the number of lines. Zero means no limit. @@ -25,6 +28,7 @@ type LabelStyle struct { TextSize unit.Sp shaper *text.Shaper + State *widget.Selectable } func H1(th *Theme, txt string) LabelStyle { @@ -85,15 +89,25 @@ func Overline(th *Theme, txt string) LabelStyle { func Label(th *Theme, size unit.Sp, txt string) LabelStyle { return LabelStyle{ - Text: txt, - Color: th.Palette.Fg, - TextSize: size, - shaper: th.Shaper, + Text: txt, + Color: th.Palette.Fg, + SelectionColor: f32color.MulAlpha(th.Palette.ContrastBg, 0x60), + TextSize: size, + shaper: th.Shaper, } } func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions { paint.ColorOp{Color: l.Color}.Add(gtx.Ops) - tl := widget.Label{Alignment: l.Alignment, MaxLines: l.MaxLines} - return tl.Layout(gtx, l.shaper, l.Font, l.TextSize, l.Text) + tl := widget.Label{Alignment: l.Alignment, MaxLines: l.MaxLines, Selectable: l.State} + if l.State == nil { + return tl.Layout(gtx, l.shaper, l.Font, l.TextSize, l.Text) + } + return tl.LayoutSelectable(gtx, l.shaper, l.Font, l.TextSize, l.Text, func(gtx layout.Context) layout.Dimensions { + paint.ColorOp{Color: l.SelectionColor}.Add(gtx.Ops) + l.State.PaintSelection(gtx) + paint.ColorOp{Color: l.Color}.Add(gtx.Ops) + l.State.PaintText(gtx) + return layout.Dimensions{} + }) } diff --git a/widget/selectable.go b/widget/selectable.go new file mode 100644 index 00000000..d8411fff --- /dev/null +++ b/widget/selectable.go @@ -0,0 +1,343 @@ +package widget + +import ( + "image" + "math" + "strings" + + "gioui.org/gesture" + "gioui.org/io/clipboard" + "gioui.org/io/event" + "gioui.org/io/key" + "gioui.org/io/pointer" + "gioui.org/io/system" + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/text" + "gioui.org/unit" +) + +// stringSource is an immutable textSource with a fixed string +// value. +type stringSource struct { + reader *strings.Reader +} + +var _ textSource = stringSource{} + +func newStringSource(str string) stringSource { + return stringSource{ + reader: strings.NewReader(str), + } +} + +func (s stringSource) Changed() bool { + return false +} + +func (s stringSource) Size() int64 { + return s.reader.Size() +} + +func (s stringSource) ReadAt(b []byte, offset int64) (int, error) { + return s.reader.ReadAt(b, offset) +} + +// ReplaceRunes is unimplemented, as a stringSource is immutable. +func (s stringSource) ReplaceRunes(byteOffset, runeCount int64, str string) { + return +} + +// Selectable holds text selection state. +type Selectable struct { + initialized bool + source stringSource + lastValue string + text textView + focused bool + requestFocus bool + dragging bool + dragger gesture.Drag + scroller gesture.Scroll + scrollOff image.Point + + clicker gesture.Click + // events is the list of events not yet processed. + events []EditorEvent + // prevEvents is the number of events from the previous frame. + prevEvents int +} + +// initialize must be called at the beginning of any exported method that +// manipulates text state. It ensures that the underlying text is safe to +// access. +func (l *Selectable) initialize() { + if !l.initialized { + l.source = newStringSource("") + l.text.SetSource(l.source) + l.initialized = true + } +} + +// Focus requests the input focus for the label. +func (l *Selectable) Focus() { + l.requestFocus = true +} + +// Focused returns whether the label is focused or not. +func (l *Selectable) Focused() bool { + return l.focused +} + +// PaintSelection paints the contrasting background for selected text. +func (l *Selectable) PaintSelection(gtx layout.Context) { + l.initialize() + if !l.focused { + return + } + l.text.PaintSelection(gtx) +} + +func (l *Selectable) PaintText(gtx layout.Context) { + l.initialize() + l.text.PaintText(gtx) +} + +// SelectionLen returns the length of the selection, in runes; it is +// equivalent to utf8.RuneCountInString(e.SelectedText()). +func (l *Selectable) SelectionLen() int { + l.initialize() + return l.text.SelectionLen() +} + +// Selection returns the start and end of the selection, as rune offsets. +// start can be > end. +func (l *Selectable) Selection() (start, end int) { + l.initialize() + return l.text.Selection() +} + +// SetCaret moves the caret to start, and sets the selection end to end. start +// and end are in runes, and represent offsets into the editor text. +func (l *Selectable) SetCaret(start, end int) { + l.initialize() + l.text.SetCaret(start, end) +} + +// SelectedText returns the currently selected text (if any) from the editor. +func (l *Selectable) SelectedText() string { + l.initialize() + return l.text.SelectedText() +} + +// ClearSelection clears the selection, by setting the selection end equal to +// the selection start. +func (l *Selectable) ClearSelection() { + l.initialize() + l.text.ClearSelection() +} + +// Text returns the contents of the label. +func (l *Selectable) Text() string { + l.initialize() + return l.text.Text() +} + +// SetText updates the text to s if it does not already contain s. Updating the +// text will clear the selection unless the selectable already contains s. +func (l *Selectable) SetText(s string) { + l.initialize() + if l.lastValue != s { + l.source = newStringSource(s) + l.lastValue = s + l.text.SetSource(l.source) + } +} + +// Layout clips to the dimensions of the selectable, updates the shaped text, configures input handling, and invokes +// content. content is expected to set colors and invoke the Paint methods. content may be nil, in which case nothing +// will be displayed. +func (l *Selectable) Layout(gtx layout.Context, lt *text.Shaper, font text.Font, size unit.Sp, content layout.Widget) layout.Dimensions { + l.initialize() + l.text.Update(gtx, lt, font, size, l.handleEvents) + dims := l.text.Dimensions() + defer clip.Rect(image.Rectangle{Max: dims.Size}).Push(gtx.Ops).Pop() + pointer.CursorText.Add(gtx.Ops) + var keys key.Set + if l.focused { + const keyFilterAllArrows = "(ShortAlt)-(Shift)-[←,→,↑,↓]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z" + keys = keyFilterAllArrows + } + key.InputOp{Tag: l, Keys: keys}.Add(gtx.Ops) + if l.requestFocus { + key.FocusOp{Tag: l}.Add(gtx.Ops) + key.SoftKeyboardOp{Show: true}.Add(gtx.Ops) + } + l.requestFocus = false + + l.clicker.Add(gtx.Ops) + l.dragger.Add(gtx.Ops) + + if content != nil { + content(gtx) + } + return dims +} + +func (l *Selectable) handleEvents(gtx layout.Context) { + // Flush events from before the previous Layout. + n := copy(l.events, l.events[l.prevEvents:]) + l.events = l.events[:n] + l.prevEvents = n + oldStart, oldLen := min(l.text.Selection()), l.text.SelectionLen() + l.processPointer(gtx) + l.processKey(gtx) + // Queue a SelectEvent if the selection changed, including if it went away. + if newStart, newLen := min(l.text.Selection()), l.text.SelectionLen(); oldStart != newStart || oldLen != newLen { + l.events = append(l.events, SelectEvent{}) + } +} + +func (e *Selectable) processPointer(gtx layout.Context) { + for _, evt := range e.clickDragEvents(gtx) { + switch evt := evt.(type) { + case gesture.ClickEvent: + switch { + case evt.Type == gesture.TypePress && evt.Source == pointer.Mouse, + evt.Type == gesture.TypeClick && evt.Source != pointer.Mouse: + prevCaretPos, _ := e.text.Selection() + e.text.MoveCoord(image.Point{ + X: int(math.Round(float64(evt.Position.X))), + Y: int(math.Round(float64(evt.Position.Y))), + }) + e.requestFocus = true + if evt.Modifiers == key.ModShift { + start, end := e.text.Selection() + // If they clicked closer to the end, then change the end to + // where the caret used to be (effectively swapping start & end). + if abs(end-start) < abs(start-prevCaretPos) { + e.text.SetCaret(start, prevCaretPos) + } + } else { + e.text.ClearSelection() + } + e.dragging = true + + // Process multi-clicks. + switch { + case evt.NumClicks == 2: + e.text.MoveWord(-1, selectionClear) + e.text.MoveWord(1, selectionExtend) + e.dragging = false + case evt.NumClicks >= 3: + e.text.MoveStart(selectionClear) + e.text.MoveEnd(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.text.MoveCoord(image.Point{ + X: int(math.Round(float64(evt.Position.X))), + Y: int(math.Round(float64(evt.Position.Y))), + }) + + if release { + e.dragging = false + } + } + } + } + } +} + +func (e *Selectable) 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 *Selectable) processKey(gtx layout.Context) { + for _, ke := range gtx.Events(e) { + switch ke := ke.(type) { + case key.FocusEvent: + e.focused = ke.Focus + case key.Event: + if !e.focused || ke.State != key.Press { + break + } + e.command(gtx, ke) + } + } +} + +func (e *Selectable) command(gtx layout.Context, k key.Event) { + direction := 1 + if gtx.Locale.Direction.Progression() == system.TowardOrigin { + direction = -1 + } + moveByWord := k.Modifiers.Contain(key.ModShortcutAlt) + selAct := selectionClear + if k.Modifiers.Contain(key.ModShift) { + selAct = selectionExtend + } + switch k.Name { + case key.NameUpArrow: + e.text.MoveLines(-1, selAct) + case key.NameDownArrow: + e.text.MoveLines(+1, selAct) + case key.NameLeftArrow: + if moveByWord { + e.text.MoveWord(-1*direction, selAct) + } else { + if selAct == selectionClear { + e.text.ClearSelection() + } + e.text.MoveCaret(-1*direction, -1*direction*int(selAct)) + } + case key.NameRightArrow: + if moveByWord { + e.text.MoveWord(1*direction, selAct) + } else { + if selAct == selectionClear { + e.text.ClearSelection() + } + e.text.MoveCaret(1*direction, int(selAct)*direction) + } + case key.NamePageUp: + e.text.MovePages(-1, selAct) + case key.NamePageDown: + e.text.MovePages(+1, selAct) + case key.NameHome: + e.text.MoveStart(selAct) + case key.NameEnd: + e.text.MoveEnd(selAct) + // Copy or Cut selection -- ignored if nothing selected. + case "C", "X": + if text := e.text.SelectedText(); text != "" { + clipboard.WriteOp{Text: text}.Add(gtx.Ops) + } + // Select all + case "A": + e.text.SetCaret(0, e.text.Len()) + } +} + +// Events returns available text events. +func (l *Selectable) Events() []EditorEvent { + events := l.events + l.events = nil + l.prevEvents = 0 + return events +} diff --git a/widget/selectable_test.go b/widget/selectable_test.go new file mode 100644 index 00000000..732cf8b8 --- /dev/null +++ b/widget/selectable_test.go @@ -0,0 +1,120 @@ +package widget + +import ( + "fmt" + "image" + "testing" + + "gioui.org/font/gofont" + "gioui.org/io/key" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/text" + "gioui.org/unit" +) + +func TestSelectableZeroValue(t *testing.T) { + var s Selectable + if s.Text() != "" { + t.Errorf("expected zero value to have no text, got %q", s.Text()) + } + if start, end := s.Selection(); start != 0 || end != 0 { + t.Errorf("expected start=0, end=0, got start=%d, end=%d", start, end) + } + if selected := s.SelectedText(); selected != "" { + t.Errorf("expected selected text to be \"\", got %q", selected) + } + s.SetCaret(5, 5) + if start, end := s.Selection(); start != 0 || end != 0 { + t.Errorf("expected start=0, end=0, got start=%d, end=%d", start, end) + } +} + +// Verify that an existing selection is dismissed when you press arrow keys. +func TestSelectableMove(t *testing.T) { + gtx := layout.Context{ + Ops: new(op.Ops), + Locale: english, + } + cache := text.NewShaper(gofont.Collection()) + font := text.Font{} + fontSize := unit.Sp(10) + + str := `0123456789` + + // Layout once to populate e.lines and get focus. + gtx.Queue = newQueue(key.FocusEvent{Focus: true}) + s := new(Selectable) + + w := func(layout.Context) layout.Dimensions { return layout.Dimensions{} } + Label{ + Selectable: s, + }.LayoutSelectable(gtx, cache, text.Font{}, fontSize, str, w) + + testKey := func(keyName string) { + // Select 345 + s.SetCaret(3, 6) + if start, end := s.Selection(); start != 3 || end != 6 { + t.Errorf("expected start=%d, end=%d, got start=%d, end=%d", 3, 6, start, end) + } + if expected, got := "345", s.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}) + Label{ + Selectable: s, + }.LayoutSelectable(gtx, cache, font, fontSize, str, w) + + if expected, got := "", s.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 TestSelectableConfigurations(t *testing.T) { + gtx := layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Exact(image.Pt(300, 300)), + Locale: english, + } + cache := text.NewShaper(gofont.Collection()) + fontSize := unit.Sp(10) + font := text.Font{} + sentence := "\n\n\n\n\n\n\n\n\n\n\n\nthe quick brown fox jumps over the lazy dog" + w := func(layout.Context) layout.Dimensions { return layout.Dimensions{} } + + for _, alignment := range []text.Alignment{text.Start, text.Middle, text.End} { + for _, zeroMin := range []bool{true, false} { + t.Run(fmt.Sprintf("Alignment: %v ZeroMinConstraint: %v", alignment, zeroMin), func(t *testing.T) { + defer func() { + if err := recover(); err != nil { + t.Error(err) + } + }() + if zeroMin { + gtx.Constraints.Min = image.Point{} + } else { + gtx.Constraints.Min = gtx.Constraints.Max + } + s := new(Selectable) + label := Label{ + Alignment: alignment, + Selectable: s, + } + interactiveDims := label.LayoutSelectable(gtx, cache, font, fontSize, sentence, w) + staticDims := label.Layout(gtx, cache, font, fontSize, sentence) + + if interactiveDims != staticDims { + t.Errorf("expected consistent dimensions, static returned %#+v, interactive returned %#+v", staticDims, interactiveDims) + } + }) + } + } +} diff --git a/widget/text.go b/widget/text.go index 94466ed9..615c679f 100644 --- a/widget/text.go +++ b/widget/text.go @@ -37,49 +37,6 @@ type textSource interface { ReplaceRunes(byteOffset int64, runeCount int64, replacement string) } -type maskReader2 struct { - // rr is the underlying reader. - rr io.RuneReader - maskBuf [utf8.UTFMax]byte - // mask is the utf-8 encoded mask rune. - mask []byte - // overflow contains excess mask bytes left over after the last Read call. - overflow []byte -} - -func (m *maskReader2) Reset(r io.Reader, mr rune) { - m.rr = bufio.NewReader(r) - n := utf8.EncodeRune(m.maskBuf[:], mr) - m.mask = m.maskBuf[:n] -} - -// Read reads from the underlying reader and replaces every -// rune with the mask rune. -func (m *maskReader2) Read(b []byte) (n int, err error) { - for len(b) > 0 { - var replacement []byte - if len(m.overflow) > 0 { - replacement = m.overflow - } else { - var r rune - r, _, err = m.rr.ReadRune() - if err != nil { - break - } - if r == '\n' { - replacement = []byte{'\n'} - } else { - replacement = m.mask - } - } - nn := copy(b, replacement) - m.overflow = replacement[nn:] - n += nn - b = b[nn:] - } - return n, err -} - // textView provides efficient shaping and indexing of interactive text. When provided // with a TextSource, textView will shape and cache the runes within that source. // It provides methods for configuring a viewport onto the shaped text which can @@ -102,7 +59,7 @@ type textView struct { textSize fixed.Int26_6 seekCursor int64 rr textSource - maskReader maskReader2 + maskReader maskReader lastMask rune maxWidth, minWidth int viewSize image.Point