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 }