github.com/utopiagio/gio@v0.0.8/widget/selectable.go (about)

     1  package widget
     2  
     3  import (
     4  	"image"
     5  	"io"
     6  	"math"
     7  	"strings"
     8  
     9  	"github.com/utopiagio/gio/font"
    10  	"github.com/utopiagio/gio/gesture"
    11  	"github.com/utopiagio/gio/io/clipboard"
    12  	"github.com/utopiagio/gio/io/event"
    13  	"github.com/utopiagio/gio/io/key"
    14  	"github.com/utopiagio/gio/io/pointer"
    15  	"github.com/utopiagio/gio/io/system"
    16  	"github.com/utopiagio/gio/layout"
    17  	"github.com/utopiagio/gio/op"
    18  	"github.com/utopiagio/gio/op/clip"
    19  	"github.com/utopiagio/gio/text"
    20  	"github.com/utopiagio/gio/unit"
    21  )
    22  
    23  // stringSource is an immutable textSource with a fixed string
    24  // value.
    25  type stringSource struct {
    26  	reader *strings.Reader
    27  }
    28  
    29  var _ textSource = stringSource{}
    30  
    31  func newStringSource(str string) stringSource {
    32  	return stringSource{
    33  		reader: strings.NewReader(str),
    34  	}
    35  }
    36  
    37  func (s stringSource) Changed() bool {
    38  	return false
    39  }
    40  
    41  func (s stringSource) Size() int64 {
    42  	return s.reader.Size()
    43  }
    44  
    45  func (s stringSource) ReadAt(b []byte, offset int64) (int, error) {
    46  	return s.reader.ReadAt(b, offset)
    47  }
    48  
    49  // ReplaceRunes is unimplemented, as a stringSource is immutable.
    50  func (s stringSource) ReplaceRunes(byteOffset, runeCount int64, str string) {
    51  }
    52  
    53  // Selectable displays selectable text.
    54  type Selectable struct {
    55  	// Alignment controls the alignment of the text.
    56  	Alignment text.Alignment
    57  	// MaxLines is the maximum number of lines of text to be displayed.
    58  	MaxLines int
    59  	// Truncator is the symbol to use at the end of the final line of text
    60  	// if text was cut off. Defaults to "…" if left empty.
    61  	Truncator string
    62  	// WrapPolicy configures how displayed text will be broken into lines.
    63  	WrapPolicy text.WrapPolicy
    64  	// LineHeight controls the distance between the baselines of lines of text.
    65  	// If zero, a sensible default will be used.
    66  	LineHeight unit.Sp
    67  	// LineHeightScale applies a scaling factor to the LineHeight. If zero, a
    68  	// sensible default will be used.
    69  	LineHeightScale float32
    70  	initialized     bool
    71  	source          stringSource
    72  	// scratch is a buffer reused to efficiently read text out of the
    73  	// textView.
    74  	scratch   []byte
    75  	lastValue string
    76  	text      textView
    77  	focused   bool
    78  	dragging  bool
    79  	dragger   gesture.Drag
    80  
    81  	clicker gesture.Click
    82  }
    83  
    84  // initialize must be called at the beginning of any exported method that
    85  // manipulates text state. It ensures that the underlying text is safe to
    86  // access.
    87  func (l *Selectable) initialize() {
    88  	if !l.initialized {
    89  		l.source = newStringSource("")
    90  		l.text.SetSource(l.source)
    91  		l.initialized = true
    92  	}
    93  }
    94  
    95  // Focused returns whether the label is focused or not.
    96  func (l *Selectable) Focused() bool {
    97  	return l.focused
    98  }
    99  
   100  // paintSelection paints the contrasting background for selected text.
   101  func (l *Selectable) paintSelection(gtx layout.Context, material op.CallOp) {
   102  	l.initialize()
   103  	if !l.focused {
   104  		return
   105  	}
   106  	l.text.PaintSelection(gtx, material)
   107  }
   108  
   109  // paintText paints the text glyphs with the provided material.
   110  func (l *Selectable) paintText(gtx layout.Context, material op.CallOp) {
   111  	l.initialize()
   112  	l.text.PaintText(gtx, material)
   113  }
   114  
   115  // SelectionLen returns the length of the selection, in runes; it is
   116  // equivalent to utf8.RuneCountInString(e.SelectedText()).
   117  func (l *Selectable) SelectionLen() int {
   118  	l.initialize()
   119  	return l.text.SelectionLen()
   120  }
   121  
   122  // Selection returns the start and end of the selection, as rune offsets.
   123  // start can be > end.
   124  func (l *Selectable) Selection() (start, end int) {
   125  	l.initialize()
   126  	return l.text.Selection()
   127  }
   128  
   129  // SetCaret moves the caret to start, and sets the selection end to end. start
   130  // and end are in runes, and represent offsets into the editor text.
   131  func (l *Selectable) SetCaret(start, end int) {
   132  	l.initialize()
   133  	l.text.SetCaret(start, end)
   134  }
   135  
   136  // SelectedText returns the currently selected text (if any) from the editor.
   137  func (l *Selectable) SelectedText() string {
   138  	l.initialize()
   139  	l.scratch = l.text.SelectedText(l.scratch)
   140  	return string(l.scratch)
   141  }
   142  
   143  // ClearSelection clears the selection, by setting the selection end equal to
   144  // the selection start.
   145  func (l *Selectable) ClearSelection() {
   146  	l.initialize()
   147  	l.text.ClearSelection()
   148  }
   149  
   150  // Text returns the contents of the label.
   151  func (l *Selectable) Text() string {
   152  	l.initialize()
   153  	l.scratch = l.text.Text(l.scratch)
   154  	return string(l.scratch)
   155  }
   156  
   157  // SetText updates the text to s if it does not already contain s. Updating the
   158  // text will clear the selection unless the selectable already contains s.
   159  func (l *Selectable) SetText(s string) {
   160  	l.initialize()
   161  	if l.lastValue != s {
   162  		l.source = newStringSource(s)
   163  		l.lastValue = s
   164  		l.text.SetSource(l.source)
   165  	}
   166  }
   167  
   168  // Truncated returns whether the text has been truncated by the text shaper to
   169  // fit within available constraints.
   170  func (l *Selectable) Truncated() bool {
   171  	return l.text.Truncated()
   172  }
   173  
   174  // Update the state of the selectable in response to input events. It returns whether the
   175  // text selection changed during event processing.
   176  func (l *Selectable) Update(gtx layout.Context) bool {
   177  	l.initialize()
   178  	return l.handleEvents(gtx)
   179  }
   180  
   181  // Layout clips to the dimensions of the selectable, updates the shaped text, configures input handling, and paints
   182  // the text and selection rectangles. The provided textMaterial and selectionMaterial ops are used to set the
   183  // paint material for the text and selection rectangles, respectively.
   184  func (l *Selectable) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, textMaterial, selectionMaterial op.CallOp) layout.Dimensions {
   185  	l.Update(gtx)
   186  	l.text.LineHeight = l.LineHeight
   187  	l.text.LineHeightScale = l.LineHeightScale
   188  	l.text.Alignment = l.Alignment
   189  	l.text.MaxLines = l.MaxLines
   190  	l.text.Truncator = l.Truncator
   191  	l.text.WrapPolicy = l.WrapPolicy
   192  	l.text.Layout(gtx, lt, font, size)
   193  	dims := l.text.Dimensions()
   194  	defer clip.Rect(image.Rectangle{Max: dims.Size}).Push(gtx.Ops).Pop()
   195  	pointer.CursorText.Add(gtx.Ops)
   196  	event.Op(gtx.Ops, l)
   197  
   198  	l.clicker.Add(gtx.Ops)
   199  	l.dragger.Add(gtx.Ops)
   200  
   201  	l.paintSelection(gtx, selectionMaterial)
   202  	l.paintText(gtx, textMaterial)
   203  	return dims
   204  }
   205  
   206  func (l *Selectable) handleEvents(gtx layout.Context) (selectionChanged bool) {
   207  	oldStart, oldLen := min(l.text.Selection()), l.text.SelectionLen()
   208  	defer func() {
   209  		if newStart, newLen := min(l.text.Selection()), l.text.SelectionLen(); oldStart != newStart || oldLen != newLen {
   210  			selectionChanged = true
   211  		}
   212  	}()
   213  	l.processPointer(gtx)
   214  	l.processKey(gtx)
   215  	return selectionChanged
   216  }
   217  
   218  func (e *Selectable) processPointer(gtx layout.Context) {
   219  	for _, evt := range e.clickDragEvents(gtx) {
   220  		switch evt := evt.(type) {
   221  		case gesture.ClickEvent:
   222  			switch {
   223  			case evt.Kind == gesture.KindPress && evt.Source == pointer.Mouse,
   224  				evt.Kind == gesture.KindClick && evt.Source != pointer.Mouse:
   225  				prevCaretPos, _ := e.text.Selection()
   226  				e.text.MoveCoord(image.Point{
   227  					X: int(math.Round(float64(evt.Position.X))),
   228  					Y: int(math.Round(float64(evt.Position.Y))),
   229  				})
   230  				gtx.Execute(key.FocusCmd{Tag: e})
   231  				if evt.Modifiers == key.ModShift {
   232  					start, end := e.text.Selection()
   233  					// If they clicked closer to the end, then change the end to
   234  					// where the caret used to be (effectively swapping start & end).
   235  					if abs(end-start) < abs(start-prevCaretPos) {
   236  						e.text.SetCaret(start, prevCaretPos)
   237  					}
   238  				} else {
   239  					e.text.ClearSelection()
   240  				}
   241  				e.dragging = true
   242  
   243  				// Process multi-clicks.
   244  				switch {
   245  				case evt.NumClicks == 2:
   246  					e.text.MoveWord(-1, selectionClear)
   247  					e.text.MoveWord(1, selectionExtend)
   248  					e.dragging = false
   249  				case evt.NumClicks >= 3:
   250  					e.text.MoveStart(selectionClear)
   251  					e.text.MoveEnd(selectionExtend)
   252  					e.dragging = false
   253  				}
   254  			}
   255  		case pointer.Event:
   256  			release := false
   257  			switch {
   258  			case evt.Kind == pointer.Release && evt.Source == pointer.Mouse:
   259  				release = true
   260  				fallthrough
   261  			case evt.Kind == pointer.Drag && evt.Source == pointer.Mouse:
   262  				if e.dragging {
   263  					e.text.MoveCoord(image.Point{
   264  						X: int(math.Round(float64(evt.Position.X))),
   265  						Y: int(math.Round(float64(evt.Position.Y))),
   266  					})
   267  
   268  					if release {
   269  						e.dragging = false
   270  					}
   271  				}
   272  			}
   273  		}
   274  	}
   275  }
   276  
   277  func (e *Selectable) clickDragEvents(gtx layout.Context) []event.Event {
   278  	var combinedEvents []event.Event
   279  	for {
   280  		evt, ok := e.clicker.Update(gtx.Source)
   281  		if !ok {
   282  			break
   283  		}
   284  		combinedEvents = append(combinedEvents, evt)
   285  	}
   286  	for {
   287  		evt, ok := e.dragger.Update(gtx.Metric, gtx.Source, gesture.Both)
   288  		if !ok {
   289  			break
   290  		}
   291  		combinedEvents = append(combinedEvents, evt)
   292  	}
   293  	return combinedEvents
   294  }
   295  
   296  func (e *Selectable) processKey(gtx layout.Context) {
   297  	for {
   298  		ke, ok := gtx.Event(
   299  			key.FocusFilter{Target: e},
   300  			key.Filter{Focus: e, Name: key.NameLeftArrow, Optional: key.ModShortcutAlt | key.ModShift},
   301  			key.Filter{Focus: e, Name: key.NameRightArrow, Optional: key.ModShortcutAlt | key.ModShift},
   302  			key.Filter{Focus: e, Name: key.NameUpArrow, Optional: key.ModShortcutAlt | key.ModShift},
   303  			key.Filter{Focus: e, Name: key.NameDownArrow, Optional: key.ModShortcutAlt | key.ModShift},
   304  
   305  			key.Filter{Focus: e, Name: key.NamePageUp, Optional: key.ModShift},
   306  			key.Filter{Focus: e, Name: key.NamePageDown, Optional: key.ModShift},
   307  			key.Filter{Focus: e, Name: key.NameEnd, Optional: key.ModShift},
   308  			key.Filter{Focus: e, Name: key.NameHome, Optional: key.ModShift},
   309  
   310  			key.Filter{Focus: e, Name: "C", Required: key.ModShortcut},
   311  			key.Filter{Focus: e, Name: "X", Required: key.ModShortcut},
   312  			key.Filter{Focus: e, Name: "A", Required: key.ModShortcut},
   313  		)
   314  		if !ok {
   315  			break
   316  		}
   317  		switch ke := ke.(type) {
   318  		case key.FocusEvent:
   319  			e.focused = ke.Focus
   320  		case key.Event:
   321  			if !e.focused || ke.State != key.Press {
   322  				break
   323  			}
   324  			e.command(gtx, ke)
   325  		}
   326  	}
   327  }
   328  
   329  func (e *Selectable) command(gtx layout.Context, k key.Event) {
   330  	direction := 1
   331  	if gtx.Locale.Direction.Progression() == system.TowardOrigin {
   332  		direction = -1
   333  	}
   334  	moveByWord := k.Modifiers.Contain(key.ModShortcutAlt)
   335  	selAct := selectionClear
   336  	if k.Modifiers.Contain(key.ModShift) {
   337  		selAct = selectionExtend
   338  	}
   339  	if k.Modifiers == key.ModShortcut {
   340  		switch k.Name {
   341  		// Copy or Cut selection -- ignored if nothing selected.
   342  		case "C", "X":
   343  			e.scratch = e.text.SelectedText(e.scratch)
   344  			if text := string(e.scratch); text != "" {
   345  				gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(strings.NewReader(text))})
   346  			}
   347  		// Select all
   348  		case "A":
   349  			e.text.SetCaret(0, e.text.Len())
   350  		}
   351  		return
   352  	}
   353  	switch k.Name {
   354  	case key.NameUpArrow:
   355  		e.text.MoveLines(-1, selAct)
   356  	case key.NameDownArrow:
   357  		e.text.MoveLines(+1, selAct)
   358  	case key.NameLeftArrow:
   359  		if moveByWord {
   360  			e.text.MoveWord(-1*direction, selAct)
   361  		} else {
   362  			if selAct == selectionClear {
   363  				e.text.ClearSelection()
   364  			}
   365  			e.text.MoveCaret(-1*direction, -1*direction*int(selAct))
   366  		}
   367  	case key.NameRightArrow:
   368  		if moveByWord {
   369  			e.text.MoveWord(1*direction, selAct)
   370  		} else {
   371  			if selAct == selectionClear {
   372  				e.text.ClearSelection()
   373  			}
   374  			e.text.MoveCaret(1*direction, int(selAct)*direction)
   375  		}
   376  	case key.NamePageUp:
   377  		e.text.MovePages(-1, selAct)
   378  	case key.NamePageDown:
   379  		e.text.MovePages(+1, selAct)
   380  	case key.NameHome:
   381  		e.text.MoveStart(selAct)
   382  	case key.NameEnd:
   383  		e.text.MoveEnd(selAct)
   384  	}
   385  }
   386  
   387  // Regions returns visible regions covering the rune range [start,end).
   388  func (l *Selectable) Regions(start, end int, regions []Region) []Region {
   389  	l.initialize()
   390  	return l.text.Regions(start, end, regions)
   391  }