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