github.com/Seikaijyu/gio@v0.0.1/widget/selectable.go (about)

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