Files
gio-patched/widget/selectable.go
T
Chris Waldon e98c8955bb 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 <christopher.waldon.dev@gmail.com>
2022-12-23 09:31:45 -06:00

344 lines
8.9 KiB
Go

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
}