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 }