package widget import ( "image/color" "math" "runtime" "strings" "time" "unicode" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/data/binding" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/driver/mobile" "fyne.io/fyne/v2/internal/cache" "fyne.io/fyne/v2/internal/widget" "fyne.io/fyne/v2/theme" ) const ( bindIgnoreDelay = time.Millisecond * 100 // ignore incoming DataItem fire after we have called Set multiLineRows = 3 wordSeparator = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?" ) // Declare conformity with interfaces var _ fyne.Disableable = (*Entry)(nil) var _ fyne.Draggable = (*Entry)(nil) var _ fyne.Focusable = (*Entry)(nil) var _ fyne.Tappable = (*Entry)(nil) var _ fyne.Widget = (*Entry)(nil) var _ desktop.Mouseable = (*Entry)(nil) var _ desktop.Keyable = (*Entry)(nil) var _ mobile.Keyboardable = (*Entry)(nil) var _ mobile.Touchable = (*Entry)(nil) var _ fyne.Tabbable = (*Entry)(nil) // Entry widget allows simple text to be input when focused. type Entry struct { DisableableWidget shortcut fyne.ShortcutHandler Text string // Since: 2.0 TextStyle fyne.TextStyle PlaceHolder string OnChanged func(string) `json:"-"` // Since: 2.0 OnSubmitted func(string) `json:"-"` Password bool MultiLine bool Wrapping fyne.TextWrap // Scroll can be used to turn off the scrolling of our entry when Wrapping is WrapNone. // // Since: 2.4 Scroll widget.ScrollDirection // Set a validator that this entry will check against // Since: 1.4 Validator fyne.StringValidator `json:"-"` validationStatus *validationStatus onValidationChanged func(error) validationError error CursorRow, CursorColumn int OnCursorChanged func() `json:"-"` cursorAnim *entryCursorAnimation dirty bool focused bool text *RichText placeholder *RichText content *entryContent scroll *widget.Scroll // useful for Form validation (as the error text should only be shown when // the entry is unfocused) onFocusChanged func(bool) // selectRow and selectColumn represent the selection start location // The selection will span from selectRow/Column to CursorRow/Column -- note that the cursor // position may occur before or after the select start position in the text. selectRow, selectColumn int // selectKeyDown indicates whether left shift or right shift is currently held down selectKeyDown bool // selecting indicates whether the cursor has moved since it was at the selection start location selecting bool popUp *PopUpMenu // TODO: Add OnSelectChanged // ActionItem is a small item which is displayed at the outer right of the entry (like a password revealer) ActionItem fyne.CanvasObject `json:"-"` binder basicBinder conversionError error minCache *fyne.Size multiLineRows int // override global default number of visible lines } // NewEntry creates a new single line entry widget. func NewEntry() *Entry { e := &Entry{Wrapping: fyne.TextTruncate} e.ExtendBaseWidget(e) return e } // NewEntryWithData returns an Entry widget connected to the specified data source. // // Since: 2.0 func NewEntryWithData(data binding.String) *Entry { entry := NewEntry() entry.Bind(data) return entry } // NewMultiLineEntry creates a new entry that allows multiple lines func NewMultiLineEntry() *Entry { e := &Entry{MultiLine: true, Wrapping: fyne.TextTruncate} e.ExtendBaseWidget(e) return e } // NewPasswordEntry creates a new entry password widget func NewPasswordEntry() *Entry { e := &Entry{Password: true, Wrapping: fyne.TextTruncate} e.ExtendBaseWidget(e) e.ActionItem = newPasswordRevealer(e) return e } // AcceptsTab returns if Entry accepts the Tab key or not. // // Implements: fyne.Tabbable // // Since: 2.1 func (e *Entry) AcceptsTab() bool { return e.MultiLine } // Bind connects the specified data source to this Entry. // The current value will be displayed and any changes in the data will cause the widget to update. // User interactions with this Entry will set the value into the data source. // // Since: 2.0 func (e *Entry) Bind(data binding.String) { e.binder.SetCallback(e.updateFromData) e.binder.Bind(data) e.Validator = func(string) error { return e.conversionError } } // CreateRenderer is a private method to Fyne which links this widget to its renderer // // Implements: fyne.Widget func (e *Entry) CreateRenderer() fyne.WidgetRenderer { e.ExtendBaseWidget(e) // initialise e.textProvider() e.placeholderProvider() box := canvas.NewRectangle(theme.InputBackgroundColor()) box.CornerRadius = theme.InputRadiusSize() border := canvas.NewRectangle(color.Transparent) border.StrokeWidth = theme.InputBorderSize() border.StrokeColor = theme.InputBorderColor() border.CornerRadius = theme.InputRadiusSize() cursor := canvas.NewRectangle(color.Transparent) cursor.Hide() e.cursorAnim = newEntryCursorAnimation(cursor) e.content = &entryContent{entry: e} e.scroll = widget.NewScroll(nil) objects := []fyne.CanvasObject{box, border} if e.Wrapping != fyne.TextWrapOff || e.Scroll != widget.ScrollNone { e.scroll.Content = e.content objects = append(objects, e.scroll) } else { e.scroll.Hide() objects = append(objects, e.content) } e.content.scroll = e.scroll if e.Password && e.ActionItem == nil { // An entry widget has been created via struct setting manually // the Password field to true. Going to enable the password revealer. e.ActionItem = newPasswordRevealer(e) } if e.ActionItem != nil { objects = append(objects, e.ActionItem) } e.syncSegments() return &entryRenderer{box, border, e.scroll, objects, e} } // Cursor returns the cursor type of this widget // // Implements: desktop.Cursorable func (e *Entry) Cursor() desktop.Cursor { return desktop.TextCursor } // Disable this widget so that it cannot be interacted with, updating any style appropriately. // // Implements: fyne.Disableable func (e *Entry) Disable() { e.DisableableWidget.Disable() } // Disabled returns whether the entry is disabled or read-only. // // Implements: fyne.Disableable func (e *Entry) Disabled() bool { return e.DisableableWidget.disabled } // DoubleTapped is called when this entry has been double tapped so we should select text below the pointer // // Implements: fyne.DoubleTappable func (e *Entry) DoubleTapped(p *fyne.PointEvent) { row := e.textProvider().row(e.CursorRow) start, end := getTextWhitespaceRegion(row, e.CursorColumn, false) if start == -1 || end == -1 { return } e.setFieldsAndRefresh(func() { if !e.selectKeyDown { e.selectRow = e.CursorRow e.selectColumn = start } // Always aim to maximise the selected region if e.selectRow > e.CursorRow || (e.selectRow == e.CursorRow && e.selectColumn > e.CursorColumn) { e.CursorColumn = start } else { e.CursorColumn = end } e.selecting = true }) } // DragEnd is called at end of a drag event. // // Implements: fyne.Draggable func (e *Entry) DragEnd() { e.propertyLock.Lock() if e.CursorColumn == e.selectColumn && e.CursorRow == e.selectRow { e.selecting = false } shouldRefresh := !e.selecting e.propertyLock.Unlock() if shouldRefresh { e.Refresh() } } // Dragged is called when the pointer moves while a button is held down. // It updates the selection accordingly. // // Implements: fyne.Draggable func (e *Entry) Dragged(d *fyne.DragEvent) { pos := d.Position.Subtract(e.scroll.Offset).Add(fyne.NewPos(0, theme.InputBorderSize())) if !e.selecting { startPos := pos.Subtract(d.Dragged) e.selectRow, e.selectColumn = e.getRowCol(startPos) e.selecting = true } e.updateMousePointer(pos, false) } // Enable this widget, updating any style or features appropriately. // // Implements: fyne.Disableable func (e *Entry) Enable() { e.DisableableWidget.Enable() } // ExtendBaseWidget is used by an extending widget to make use of BaseWidget functionality. func (e *Entry) ExtendBaseWidget(wid fyne.Widget) { impl := e.super() if impl != nil { return } e.propertyLock.Lock() defer e.propertyLock.Unlock() e.BaseWidget.impl = wid e.registerShortcut() } // FocusGained is called when the Entry has been given focus. // // Implements: fyne.Focusable func (e *Entry) FocusGained() { e.setFieldsAndRefresh(func() { e.dirty = true e.focused = true }) if e.onFocusChanged != nil { e.onFocusChanged(true) } } // FocusLost is called when the Entry has had focus removed. // // Implements: fyne.Focusable func (e *Entry) FocusLost() { e.setFieldsAndRefresh(func() { e.focused = false e.selectKeyDown = false }) if e.onFocusChanged != nil { e.onFocusChanged(false) } } // Hide hides the entry. // // Implements: fyne.Widget func (e *Entry) Hide() { if e.popUp != nil { e.popUp.Hide() e.popUp = nil } e.DisableableWidget.Hide() } // Keyboard implements the Keyboardable interface // // Implements: mobile.Keyboardable func (e *Entry) Keyboard() mobile.KeyboardType { e.propertyLock.RLock() defer e.propertyLock.RUnlock() if e.MultiLine { return mobile.DefaultKeyboard } else if e.Password { return mobile.PasswordKeyboard } return mobile.SingleLineKeyboard } // KeyDown handler for keypress events - used to store shift modifier state for text selection // // Implements: desktop.Keyable func (e *Entry) KeyDown(key *fyne.KeyEvent) { if e.Disabled() { return } // For keyboard cursor controlled selection we now need to store shift key state and selection "start" // Note: selection start is where the highlight started (if the user moves the selection up or left then // the selectRow/Column will not match SelectionStart) if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight { if !e.selecting { e.selectRow = e.CursorRow e.selectColumn = e.CursorColumn } e.selectKeyDown = true } } // KeyUp handler for key release events - used to reset shift modifier state for text selection // // Implements: desktop.Keyable func (e *Entry) KeyUp(key *fyne.KeyEvent) { if e.Disabled() { return } // Handle shift release for keyboard selection // Note: if shift is released then the user may repress it without moving to adjust their old selection if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight { e.selectKeyDown = false } } // MinSize returns the size that this widget should not shrink below. // // Implements: fyne.Widget func (e *Entry) MinSize() fyne.Size { e.propertyLock.RLock() cached := e.minCache e.propertyLock.RUnlock() if cached != nil { return *cached } e.ExtendBaseWidget(e) min := e.BaseWidget.MinSize() if e.ActionItem != nil { min = min.Add(fyne.NewSize(theme.IconInlineSize()+theme.LineSpacing(), 0)) } if e.Validator != nil { min = min.Add(fyne.NewSize(theme.IconInlineSize()+theme.LineSpacing(), 0)) } e.propertyLock.Lock() e.minCache = &min e.propertyLock.Unlock() return min } // MouseDown called on mouse click, this triggers a mouse click which can move the cursor, // update the existing selection (if shift is held), or start a selection dragging operation. // // Implements: desktop.Mouseable func (e *Entry) MouseDown(m *desktop.MouseEvent) { e.propertyLock.Lock() if e.selectKeyDown { e.selecting = true } if e.selecting && !e.selectKeyDown && m.Button == desktop.MouseButtonPrimary { e.selecting = false } e.propertyLock.Unlock() e.updateMousePointer(m.Position, m.Button == desktop.MouseButtonSecondary) if !e.Disabled() { e.requestFocus() } } // MouseUp called on mouse release // If a mouse drag event has completed then check to see if it has resulted in an empty selection, // if so, and if a text select key isn't held, then disable selecting // // Implements: desktop.Mouseable func (e *Entry) MouseUp(m *desktop.MouseEvent) { e.propertyLock.Lock() defer e.propertyLock.Unlock() start, _ := e.selection() if start == -1 && e.selecting && !e.selectKeyDown { e.selecting = false } } func (e *Entry) Refresh() { e.propertyLock.Lock() e.minCache = nil e.propertyLock.Unlock() e.BaseWidget.Refresh() } // SelectedText returns the text currently selected in this Entry. // If there is no selection it will return the empty string. func (e *Entry) SelectedText() string { e.propertyLock.RLock() defer e.propertyLock.RUnlock() if !e.selecting { return "" } start, stop := e.selection() if start == stop { return "" } r := ([]rune)(e.textProvider().String()) return string(r[start:stop]) } // SetMinRowsVisible forces a multi-line entry to show `count` number of rows without scrolling. // This is not a validation or requirement, it just impacts the minimum visible size. // Use this carefully as Fyne apps can run on small screens so you may wish to add a scroll container if // this number is high. Default is 3. // // Since: 2.2 func (e *Entry) SetMinRowsVisible(count int) { e.multiLineRows = count e.Refresh() } // SetPlaceHolder sets the text that will be displayed if the entry is otherwise empty func (e *Entry) SetPlaceHolder(text string) { e.propertyLock.Lock() e.PlaceHolder = text e.propertyLock.Unlock() e.placeholderProvider().Segments[0].(*TextSegment).Text = text e.placeholder.updateRowBounds() e.placeholderProvider().Refresh() } // SetText manually sets the text of the Entry to the given text value. func (e *Entry) SetText(text string) { e.setText(text, false) } func (e *Entry) setText(text string, fromBinding bool) { e.updateTextAndRefresh(text, fromBinding) e.updateCursorAndSelection() } // Appends the text to the end of the entry // // Since: 2.4 func (e *Entry) Append(text string) { e.propertyLock.Lock() provider := e.textProvider() provider.insertAt(provider.len(), text) content := provider.String() changed := e.updateText(content, false) e.propertyLock.Unlock() if changed { e.Validate() if e.OnChanged != nil { e.OnChanged(content) } } e.Refresh() } // Tapped is called when this entry has been tapped. We update the cursor position in // device-specific callbacks (MouseDown() and TouchDown()). // // Implements: fyne.Tappable func (e *Entry) Tapped(ev *fyne.PointEvent) { if fyne.CurrentDevice().IsMobile() && e.selecting { e.selecting = false } } // TappedSecondary is called when right or alternative tap is invoked. // // Opens the PopUpMenu with `Paste` item to paste text from the clipboard. // // Implements: fyne.SecondaryTappable func (e *Entry) TappedSecondary(pe *fyne.PointEvent) { if e.Disabled() && e.Password { return // no popup options for a disabled concealed field } e.requestFocus() clipboard := fyne.CurrentApp().Driver().AllWindows()[0].Clipboard() super := e.super() cutItem := fyne.NewMenuItem("Cut", func() { super.(fyne.Shortcutable).TypedShortcut(&fyne.ShortcutCut{Clipboard: clipboard}) }) copyItem := fyne.NewMenuItem("Copy", func() { super.(fyne.Shortcutable).TypedShortcut(&fyne.ShortcutCopy{Clipboard: clipboard}) }) pasteItem := fyne.NewMenuItem("Paste", func() { super.(fyne.Shortcutable).TypedShortcut(&fyne.ShortcutPaste{Clipboard: clipboard}) }) selectAllItem := fyne.NewMenuItem("Select all", e.selectAll) entryPos := fyne.CurrentApp().Driver().AbsolutePositionForObject(super) popUpPos := entryPos.Add(fyne.NewPos(pe.Position.X, pe.Position.Y)) c := fyne.CurrentApp().Driver().CanvasForObject(super) var menu *fyne.Menu if e.Disabled() { menu = fyne.NewMenu("", copyItem, selectAllItem) } else if e.Password { menu = fyne.NewMenu("", pasteItem, selectAllItem) } else { menu = fyne.NewMenu("", cutItem, copyItem, pasteItem, selectAllItem) } e.popUp = NewPopUpMenu(menu, c) e.popUp.ShowAtPosition(popUpPos) } // TouchDown is called when this entry gets a touch down event on mobile device, we ensure we have focus. // // Since: 2.1 // // Implements: mobile.Touchable func (e *Entry) TouchDown(ev *mobile.TouchEvent) { if !e.Disabled() { e.requestFocus() } e.updateMousePointer(ev.Position, false) } // TouchUp is called when this entry gets a touch up event on mobile device. // // Since: 2.1 // // Implements: mobile.Touchable func (e *Entry) TouchUp(*mobile.TouchEvent) { } // TouchCancel is called when this entry gets a touch cancel event on mobile device (app was removed from focus). // // Since: 2.1 // // Implements: mobile.Touchable func (e *Entry) TouchCancel(*mobile.TouchEvent) { } // TypedKey receives key input events when the Entry widget is focused. // // Implements: fyne.Focusable func (e *Entry) TypedKey(key *fyne.KeyEvent) { if e.Disabled() { return } if e.cursorAnim != nil { e.cursorAnim.interrupt() } e.propertyLock.RLock() provider := e.textProvider() multiLine := e.MultiLine e.propertyLock.RUnlock() if e.selectKeyDown || e.selecting { if e.selectingKeyHandler(key) { e.Refresh() return } } switch key.Name { case fyne.KeyBackspace: e.propertyLock.RLock() isEmpty := provider.len() == 0 || (e.CursorColumn == 0 && e.CursorRow == 0) e.propertyLock.RUnlock() if isEmpty { return } e.propertyLock.Lock() pos := e.cursorTextPos() provider.deleteFromTo(pos-1, pos) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos - 1) e.propertyLock.Unlock() case fyne.KeyDelete: pos := e.cursorTextPos() if provider.len() == 0 || pos == provider.len() { return } e.propertyLock.Lock() provider.deleteFromTo(pos, pos+1) e.propertyLock.Unlock() case fyne.KeyReturn, fyne.KeyEnter: e.typedKeyReturn(provider, multiLine) case fyne.KeyTab: e.TypedRune('\t') case fyne.KeyUp: e.typedKeyUp(provider) case fyne.KeyDown: e.typedKeyDown(provider) case fyne.KeyLeft: e.typedKeyLeft(provider) case fyne.KeyRight: e.typedKeyRight(provider) case fyne.KeyEnd: e.typedKeyEnd(provider) case fyne.KeyHome: e.typedKeyHome() case fyne.KeyPageUp: e.propertyLock.Lock() if e.MultiLine { e.CursorRow = 0 } e.CursorColumn = 0 e.propertyLock.Unlock() case fyne.KeyPageDown: e.propertyLock.Lock() if e.MultiLine { e.CursorRow = provider.rows() - 1 e.CursorColumn = provider.rowLength(e.CursorRow) } else { e.CursorColumn = provider.len() } e.propertyLock.Unlock() default: return } e.propertyLock.Lock() content := provider.String() changed := e.updateText(content, false) if e.CursorRow == e.selectRow && e.CursorColumn == e.selectColumn { e.selecting = false } e.propertyLock.Unlock() if changed { e.Validate() if e.OnChanged != nil { e.OnChanged(content) } } e.Refresh() } func (e *Entry) typedKeyUp(provider *RichText) { e.propertyLock.Lock() if e.CursorRow > 0 { e.CursorRow-- } else { e.CursorColumn = 0 } rowLength := provider.rowLength(e.CursorRow) if e.CursorColumn > rowLength { e.CursorColumn = rowLength } e.propertyLock.Unlock() } func (e *Entry) typedKeyDown(provider *RichText) { e.propertyLock.Lock() rowLength := provider.rowLength(e.CursorRow) if e.CursorRow < provider.rows()-1 { e.CursorRow++ rowLength = provider.rowLength(e.CursorRow) } else { e.CursorColumn = rowLength } if e.CursorColumn > rowLength { e.CursorColumn = rowLength } e.propertyLock.Unlock() } func (e *Entry) typedKeyLeft(provider *RichText) { e.propertyLock.Lock() if e.CursorColumn > 0 { e.CursorColumn-- } else if e.MultiLine && e.CursorRow > 0 { e.CursorRow-- e.CursorColumn = provider.rowLength(e.CursorRow) } e.propertyLock.Unlock() } func (e *Entry) typedKeyRight(provider *RichText) { e.propertyLock.Lock() if e.MultiLine { rowLength := provider.rowLength(e.CursorRow) if e.CursorColumn < rowLength { e.CursorColumn++ } else if e.CursorRow < provider.rows()-1 { e.CursorRow++ e.CursorColumn = 0 } } else if e.CursorColumn < provider.len() { e.CursorColumn++ } e.propertyLock.Unlock() } func (e *Entry) typedKeyHome() { e.propertyLock.Lock() e.CursorColumn = 0 e.propertyLock.Unlock() } func (e *Entry) typedKeyEnd(provider *RichText) { e.propertyLock.Lock() if e.MultiLine { e.CursorColumn = provider.rowLength(e.CursorRow) } else { e.CursorColumn = provider.len() } e.propertyLock.Unlock() } // TypedRune receives text input events when the Entry widget is focused. // // Implements: fyne.Focusable func (e *Entry) TypedRune(r rune) { if e.Disabled() { return } e.propertyLock.Lock() if e.popUp != nil { e.popUp.Hide() } // if we've typed a character and we're selecting then replace the selection with the character cb := e.OnChanged if e.selecting { e.OnChanged = nil // don't propagate this change to binding etc e.eraseSelection() e.OnChanged = cb // the change later will then trigger callback } provider := e.textProvider() e.selecting = false runes := []rune{r} pos := e.cursorTextPos() provider.insertAt(pos, string(runes)) content := provider.String() e.updateText(content, false) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos + len(runes)) e.propertyLock.Unlock() e.Validate() if cb != nil { cb(content) } e.Refresh() } // TypedShortcut implements the Shortcutable interface // // Implements: fyne.Shortcutable func (e *Entry) TypedShortcut(shortcut fyne.Shortcut) { e.shortcut.TypedShortcut(shortcut) } // Unbind disconnects any configured data source from this Entry. // The current value will remain at the last value of the data source. // // Since: 2.0 func (e *Entry) Unbind() { e.Validator = nil e.binder.Unbind() } // copyToClipboard copies the current selection to a given clipboard. // This does nothing if it is a concealed entry. func (e *Entry) copyToClipboard(clipboard fyne.Clipboard) { if !e.selecting || e.Password { return } clipboard.SetContent(e.SelectedText()) } func (e *Entry) cursorColAt(text []rune, pos fyne.Position) int { for i := 0; i < len(text); i++ { str := string(text[0:i]) wid := fyne.MeasureText(str, theme.TextSize(), e.TextStyle).Width charWid := fyne.MeasureText(string(text[i]), theme.TextSize(), e.TextStyle).Width if pos.X < theme.InnerPadding()+wid+(charWid/2) { return i } } return len(text) } func (e *Entry) cursorTextPos() (pos int) { return e.textPosFromRowCol(e.CursorRow, e.CursorColumn) } // copyToClipboard copies the current selection to a given clipboard and then removes the selected text. // This does nothing if it is a concealed entry. func (e *Entry) cutToClipboard(clipboard fyne.Clipboard) { if !e.selecting || e.Password { return } e.copyToClipboard(clipboard) e.setFieldsAndRefresh(e.eraseSelection) e.propertyLock.RLock() content := e.Text e.propertyLock.RUnlock() if e.OnChanged != nil { e.OnChanged(content) } e.Validate() } // eraseSelection removes the current selected region and moves the cursor func (e *Entry) eraseSelection() { if e.Disabled() { return } provider := e.textProvider() posA, posB := e.selection() if posA == posB { return } provider.deleteFromTo(posA, posB) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(posA) e.selectRow, e.selectColumn = e.CursorRow, e.CursorColumn e.selecting = false e.updateText(provider.String(), false) } func (e *Entry) getRowCol(p fyne.Position) (int, int) { e.propertyLock.RLock() defer e.propertyLock.RUnlock() rowHeight := e.textProvider().charMinSize(e.Password, e.TextStyle).Height row := int(math.Floor(float64(p.Y+e.scroll.Offset.Y-theme.LineSpacing()) / float64(rowHeight))) col := 0 if row < 0 { row = 0 } else if row >= e.textProvider().rows() { row = e.textProvider().rows() - 1 col = e.textProvider().rowLength(row) } else { col = e.cursorColAt(e.textProvider().row(row), p.Add(e.scroll.Offset)) } return row, col } // pasteFromClipboard inserts text from the clipboard content, // starting from the cursor position. func (e *Entry) pasteFromClipboard(clipboard fyne.Clipboard) { if e.selecting { e.setFieldsAndRefresh(e.eraseSelection) } text := clipboard.Content() if !e.MultiLine { // format clipboard content to be compatible with single line entry text = strings.Replace(text, "\n", " ", -1) } provider := e.textProvider() runes := []rune(text) pos := e.cursorTextPos() provider.insertAt(pos, text) e.updateTextAndRefresh(provider.String(), false) e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos + len(runes)) e.Refresh() // placing the cursor (and refreshing) happens last } // placeholderProvider returns the placeholder text handler for this entry func (e *Entry) placeholderProvider() *RichText { if e.placeholder != nil { return e.placeholder } style := RichTextStyleInline style.ColorName = theme.ColorNamePlaceHolder style.TextStyle = e.TextStyle text := NewRichText(&TextSegment{ Style: style, Text: e.PlaceHolder, }) text.ExtendBaseWidget(text) text.inset = fyne.NewSize(0, theme.InputBorderSize()) e.placeholder = text return e.placeholder } func (e *Entry) registerShortcut() { e.shortcut.AddShortcut(&fyne.ShortcutCut{}, func(se fyne.Shortcut) { cut := se.(*fyne.ShortcutCut) e.cutToClipboard(cut.Clipboard) }) e.shortcut.AddShortcut(&fyne.ShortcutCopy{}, func(se fyne.Shortcut) { cpy := se.(*fyne.ShortcutCopy) e.copyToClipboard(cpy.Clipboard) }) e.shortcut.AddShortcut(&fyne.ShortcutPaste{}, func(se fyne.Shortcut) { paste := se.(*fyne.ShortcutPaste) e.pasteFromClipboard(paste.Clipboard) }) e.shortcut.AddShortcut(&fyne.ShortcutSelectAll{}, func(se fyne.Shortcut) { e.selectAll() }) moveWord := func(s fyne.Shortcut) { row := e.textProvider().row(e.CursorRow) start, end := getTextWhitespaceRegion(row, e.CursorColumn, true) if start == -1 || end == -1 { return } e.setFieldsAndRefresh(func() { if s.(*desktop.CustomShortcut).KeyName == fyne.KeyLeft { if e.CursorColumn == 0 { if e.CursorRow > 0 { e.CursorRow-- e.CursorColumn = len(e.textProvider().row(e.CursorRow)) } } else { e.CursorColumn = start } } else { if e.CursorColumn == len(e.textProvider().row(e.CursorRow)) { if e.CursorRow < e.textProvider().rows()-1 { e.CursorRow++ e.CursorColumn = 0 } } else { e.CursorColumn = end } } }) } selectMoveWord := func(se fyne.Shortcut) { if !e.selecting { e.selectColumn = e.CursorColumn e.selectRow = e.CursorRow e.selecting = true } moveWord(se) } unselectMoveWord := func(se fyne.Shortcut) { e.selecting = false moveWord(se) } moveWordModifier := fyne.KeyModifierShortcutDefault if runtime.GOOS == "darwin" { moveWordModifier = fyne.KeyModifierAlt // Cmd+left, Cmd+right shortcuts behave like Home and End keys on Mac OS shortcutHomeEnd := func(s fyne.Shortcut) { e.selecting = false if s.(*desktop.CustomShortcut).KeyName == fyne.KeyLeft { e.typedKeyHome() } else { e.propertyLock.RLock() provider := e.textProvider() e.propertyLock.RUnlock() e.typedKeyEnd(provider) } e.Refresh() } e.shortcut.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyLeft, Modifier: fyne.KeyModifierSuper}, shortcutHomeEnd) e.shortcut.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyRight, Modifier: fyne.KeyModifierSuper}, shortcutHomeEnd) } e.shortcut.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyLeft, Modifier: moveWordModifier}, unselectMoveWord) e.shortcut.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyLeft, Modifier: moveWordModifier | fyne.KeyModifierShift}, selectMoveWord) e.shortcut.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyRight, Modifier: moveWordModifier}, unselectMoveWord) e.shortcut.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyRight, Modifier: moveWordModifier | fyne.KeyModifierShift}, selectMoveWord) } func (e *Entry) requestFocus() { impl := e.super() if c := fyne.CurrentApp().Driver().CanvasForObject(impl); c != nil { c.Focus(impl.(fyne.Focusable)) } } // Obtains row,col from a given textual position // expects a read or write lock to be held by the caller func (e *Entry) rowColFromTextPos(pos int) (row int, col int) { provider := e.textProvider() canWrap := e.Wrapping == fyne.TextWrapBreak || e.Wrapping == fyne.TextWrapWord totalRows := provider.rows() for i := 0; i < totalRows; i++ { b := provider.rowBoundary(i) if b == nil { continue } if b.begin <= pos { if b.end < pos { row++ } col = pos - b.begin // if this gap is at `pos` and is a line wrap, increment (safe to access boundary i-1) if canWrap && b.begin == pos && pos != 0 && provider.rowBoundary(i-1).end == b.begin && row < (totalRows-1) { row++ } } else { break } } return } // selectAll selects all text in entry func (e *Entry) selectAll() { if e.textProvider().len() == 0 { return } e.setFieldsAndRefresh(func() { e.selectRow = 0 e.selectColumn = 0 lastRow := e.textProvider().rows() - 1 e.CursorColumn = e.textProvider().rowLength(lastRow) e.CursorRow = lastRow e.selecting = true }) } // selectingKeyHandler performs keypress action in the scenario that a selection // is either a) in progress or b) about to start // returns true if the keypress has been fully handled func (e *Entry) selectingKeyHandler(key *fyne.KeyEvent) bool { if e.selectKeyDown && !e.selecting { switch key.Name { case fyne.KeyUp, fyne.KeyDown, fyne.KeyLeft, fyne.KeyRight, fyne.KeyEnd, fyne.KeyHome, fyne.KeyPageUp, fyne.KeyPageDown: e.selecting = true } } if !e.selecting { return false } switch key.Name { case fyne.KeyBackspace, fyne.KeyDelete: // clears the selection -- return handled e.setFieldsAndRefresh(e.eraseSelection) e.propertyLock.RLock() content := e.Text e.propertyLock.RUnlock() if e.OnChanged != nil { e.OnChanged(content) } e.Validate() return true case fyne.KeyReturn, fyne.KeyEnter: if e.MultiLine { // clear the selection -- return unhandled to add the newline e.setFieldsAndRefresh(e.eraseSelection) } return false } if !e.selectKeyDown { switch key.Name { case fyne.KeyLeft: // seek to the start of the selection -- return handled e.propertyLock.Lock() selectStart, _ := e.selection() e.CursorRow, e.CursorColumn = e.rowColFromTextPos(selectStart) e.selecting = false e.propertyLock.Unlock() return true case fyne.KeyRight: // seek to the end of the selection -- return handled _, selectEnd := e.selection() e.propertyLock.Lock() e.CursorRow, e.CursorColumn = e.rowColFromTextPos(selectEnd) e.selecting = false e.propertyLock.Unlock() return true case fyne.KeyUp, fyne.KeyDown, fyne.KeyEnd, fyne.KeyHome, fyne.KeyPageUp, fyne.KeyPageDown: // cursor movement without left or right shift -- clear selection and return unhandled e.selecting = false return false } } return false } // selection returns the start and end text positions for the selected span of text // Note: this functionality depends on the relationship between the selection start row/col and // the current cursor row/column. // eg: (whitespace for clarity, '_' denotes cursor) // // "T e s [t i]_n g" == 3, 5 // "T e s_[t i] n g" == 3, 5 // "T e_[s t i] n g" == 2, 5 func (e *Entry) selection() (int, int) { noSelection := !e.selecting || (e.CursorRow == e.selectRow && e.CursorColumn == e.selectColumn) if noSelection { return -1, -1 } // Find the selection start rowA, colA := e.CursorRow, e.CursorColumn rowB, colB := e.selectRow, e.selectColumn // Reposition if the cursors row is more than select start row, or if the row is the same and // the cursors col is more that the select start column if rowA > e.selectRow || (rowA == e.selectRow && colA > e.selectColumn) { rowA, colA = e.selectRow, e.selectColumn rowB, colB = e.CursorRow, e.CursorColumn } return e.textPosFromRowCol(rowA, colA), e.textPosFromRowCol(rowB, colB) } // Obtains textual position from a given row and col // expects a read or write lock to be held by the caller func (e *Entry) textPosFromRowCol(row, col int) int { b := e.textProvider().rowBoundary(row) if b == nil { return col } return b.begin + col } func (e *Entry) syncSegments() { colName := theme.ColorNameForeground wrap := e.textWrap() if e.disabled { colName = theme.ColorNameDisabled } e.textProvider().Wrapping = wrap style := RichTextStyle{ Alignment: fyne.TextAlignLeading, ColorName: colName, TextStyle: e.TextStyle, } if e.Password { style = RichTextStylePassword style.ColorName = colName style.TextStyle = e.TextStyle } e.textProvider().Segments = []RichTextSegment{&TextSegment{ Style: style, Text: e.Text, }} colName = theme.ColorNamePlaceHolder if e.disabled { colName = theme.ColorNameDisabled } e.placeholderProvider().Wrapping = wrap e.placeholderProvider().Segments = []RichTextSegment{&TextSegment{ Style: RichTextStyle{ Alignment: fyne.TextAlignLeading, ColorName: colName, TextStyle: e.TextStyle, }, Text: e.PlaceHolder, }} } // textProvider returns the text handler for this entry func (e *Entry) textProvider() *RichText { if e.text != nil { return e.text } if e.Text != "" { e.dirty = true } text := NewRichTextWithText(e.Text) text.ExtendBaseWidget(text) text.inset = fyne.NewSize(0, theme.InputBorderSize()) e.text = text return e.text } // textWrap calculates the wrapping that we should apply. func (e *Entry) textWrap() fyne.TextWrap { if e.Wrapping == fyne.TextTruncate { // this is now the default - but we scroll around this large content return fyne.TextWrapOff } if !e.MultiLine && (e.Wrapping == fyne.TextWrapBreak || e.Wrapping == fyne.TextWrapWord) { fyne.LogError("Entry cannot wrap single line", nil) e.Wrapping = fyne.TextTruncate return fyne.TextWrapOff } return e.Wrapping } func (e *Entry) updateCursorAndSelection() { e.propertyLock.Lock() defer e.propertyLock.Unlock() e.CursorRow, e.CursorColumn = e.truncatePosition(e.CursorRow, e.CursorColumn) e.selectRow, e.selectColumn = e.truncatePosition(e.selectRow, e.selectColumn) } func (e *Entry) updateFromData(data binding.DataItem) { if data == nil { return } textSource, ok := data.(binding.String) if !ok { return } val, err := textSource.Get() e.conversionError = err e.Validate() if err != nil { return } e.setText(val, true) } func (e *Entry) truncatePosition(row, col int) (int, int) { if e.Text == "" { return 0, 0 } newRow := row newCol := col if row >= e.textProvider().rows() { newRow = e.textProvider().rows() - 1 } rowLength := e.textProvider().rowLength(newRow) if (newCol >= rowLength) || (newRow < row) { newCol = rowLength } return newRow, newCol } func (e *Entry) updateMousePointer(p fyne.Position, rightClick bool) { row, col := e.getRowCol(p) e.propertyLock.Lock() if !rightClick || !e.selecting { e.CursorRow = row e.CursorColumn = col } if !e.selecting { e.selectRow = row e.selectColumn = col } e.propertyLock.Unlock() r := cache.Renderer(e.content) if r != nil { r.(*entryContentRenderer).moveCursor() } } // updateText updates the internal text to the given value. // It assumes that a lock exists on the widget. func (e *Entry) updateText(text string, fromBinding bool) bool { changed := e.Text != text e.Text = text e.syncSegments() e.text.updateRowBounds() if e.Text != "" { e.dirty = true } if changed && !fromBinding { if e.binder.dataListenerPair.listener != nil { e.binder.SetCallback(nil) e.binder.CallWithData(e.writeData) e.binder.SetCallback(e.updateFromData) } } return changed } // updateTextAndRefresh updates the internal text to the given value then refreshes it. // This should not be called under a property lock func (e *Entry) updateTextAndRefresh(text string, fromBinding bool) { var callback func(string) e.setFieldsAndRefresh(func() { changed := e.updateText(text, fromBinding) if changed { callback = e.OnChanged } }) e.Validate() if callback != nil { callback(text) } } func (e *Entry) writeData(data binding.DataItem) { if data == nil { return } textTarget, ok := data.(binding.String) if !ok { return } curValue, err := textTarget.Get() if err == nil && curValue == e.Text { e.conversionError = nil return } e.conversionError = textTarget.Set(e.Text) } func (e *Entry) typedKeyReturn(provider *RichText, multiLine bool) { e.propertyLock.RLock() onSubmitted := e.OnSubmitted selectDown := e.selectKeyDown text := e.Text e.propertyLock.RUnlock() if !multiLine { // Single line doesn't support newline. // Call submitted callback, if any. if onSubmitted != nil { onSubmitted(text) } return } else if selectDown && onSubmitted != nil { // Multiline supports newline, unless shift is held and OnSubmitted is set. onSubmitted(text) return } e.propertyLock.Lock() provider.insertAt(e.cursorTextPos(), "\n") e.CursorColumn = 0 e.CursorRow++ e.propertyLock.Unlock() } var _ fyne.WidgetRenderer = (*entryRenderer)(nil) type entryRenderer struct { box, border *canvas.Rectangle scroll *widget.Scroll objects []fyne.CanvasObject entry *Entry } func (r *entryRenderer) Destroy() { } func (r *entryRenderer) trailingInset() float32 { xInset := float32(0) if r.entry.ActionItem != nil { xInset = theme.IconInlineSize() + theme.LineSpacing() } if r.entry.Validator != nil { if r.entry.ActionItem == nil { xInset = theme.IconInlineSize() + theme.LineSpacing() } else { xInset += theme.IconInlineSize() + theme.LineSpacing() } } return xInset } func (r *entryRenderer) Layout(size fyne.Size) { // 0.5 is removed so on low DPI it rounds down on the trailing edge r.border.Resize(fyne.NewSize(size.Width-theme.InputBorderSize()-.5, size.Height-theme.InputBorderSize()-.5)) r.border.StrokeWidth = theme.InputBorderSize() r.border.Move(fyne.NewSquareOffsetPos(theme.InputBorderSize() / 2)) r.box.Resize(size.Subtract(fyne.NewSquareSize(theme.InputBorderSize() * 2))) r.box.Move(fyne.NewSquareOffsetPos(theme.InputBorderSize())) actionIconSize := fyne.NewSize(0, 0) if r.entry.ActionItem != nil { actionIconSize = fyne.NewSquareSize(theme.IconInlineSize()) r.entry.ActionItem.Resize(actionIconSize) r.entry.ActionItem.Move(fyne.NewPos(size.Width-actionIconSize.Width-theme.InnerPadding(), theme.InnerPadding())) } validatorIconSize := fyne.NewSize(0, 0) if r.entry.Validator != nil { validatorIconSize = fyne.NewSquareSize(theme.IconInlineSize()) r.ensureValidationSetup() r.entry.validationStatus.Resize(validatorIconSize) if r.entry.ActionItem == nil { r.entry.validationStatus.Move(fyne.NewPos(size.Width-validatorIconSize.Width-theme.InnerPadding(), theme.InnerPadding())) } else { r.entry.validationStatus.Move(fyne.NewPos(size.Width-validatorIconSize.Width-actionIconSize.Width-theme.InnerPadding()-theme.LineSpacing(), theme.InnerPadding())) } } r.entry.textProvider().inset = fyne.NewSize(0, theme.InputBorderSize()) r.entry.placeholderProvider().inset = fyne.NewSize(0, theme.InputBorderSize()) entrySize := size.Subtract(fyne.NewSize(r.trailingInset(), theme.InputBorderSize()*2)) entryPos := fyne.NewPos(0, theme.InputBorderSize()) r.entry.propertyLock.Lock() textPos := r.entry.textPosFromRowCol(r.entry.CursorRow, r.entry.CursorColumn) selectPos := r.entry.textPosFromRowCol(r.entry.selectRow, r.entry.selectColumn) r.entry.propertyLock.Unlock() if r.entry.Wrapping == fyne.TextWrapOff && r.entry.Scroll == widget.ScrollNone { r.entry.content.Resize(entrySize) r.entry.content.Move(entryPos) } else { r.scroll.Resize(entrySize) r.scroll.Move(entryPos) } r.entry.propertyLock.Lock() resizedTextPos := r.entry.textPosFromRowCol(r.entry.CursorRow, r.entry.CursorColumn) r.entry.propertyLock.Unlock() if textPos != resizedTextPos { r.entry.setFieldsAndRefresh(func() { r.entry.CursorRow, r.entry.CursorColumn = r.entry.rowColFromTextPos(textPos) if r.entry.selecting { r.entry.selectRow, r.entry.selectColumn = r.entry.rowColFromTextPos(selectPos) } }) } } // MinSize calculates the minimum size of an entry widget. // This is based on the contained text with a standard amount of padding added. // If MultiLine is true then we will reserve space for at leasts 3 lines func (r *entryRenderer) MinSize() fyne.Size { if rend := cache.Renderer(r.entry.content); rend != nil { rend.(*entryContentRenderer).updateScrollDirections() } if r.scroll.Direction == widget.ScrollNone { return r.entry.content.MinSize().Add(fyne.NewSize(0, theme.InputBorderSize()*2)) } innerPadding := theme.InnerPadding() charMin := r.entry.placeholderProvider().charMinSize(r.entry.Password, r.entry.TextStyle) minSize := charMin.Add(fyne.NewSquareSize(innerPadding)) if r.entry.MultiLine { count := r.entry.multiLineRows if count <= 0 { count = multiLineRows } minSize.Height = charMin.Height*float32(count) + innerPadding } return minSize.Add(fyne.NewSize(innerPadding*2, innerPadding)) } func (r *entryRenderer) Objects() []fyne.CanvasObject { r.entry.propertyLock.RLock() defer r.entry.propertyLock.RUnlock() return r.objects } func (r *entryRenderer) Refresh() { r.entry.propertyLock.RLock() content := r.entry.content focusedAppearance := r.entry.focused && !r.entry.disabled scroll := r.entry.Scroll size := r.entry.size wrapping := r.entry.Wrapping r.entry.propertyLock.RUnlock() r.entry.syncSegments() r.entry.text.updateRowBounds() r.entry.placeholder.updateRowBounds() r.entry.text.Refresh() r.entry.placeholder.Refresh() // correct our scroll wrappers if the wrap mode changed entrySize := size.Subtract(fyne.NewSize(r.trailingInset(), theme.InputBorderSize()*2)) if wrapping == fyne.TextWrapOff && scroll == widget.ScrollNone && r.scroll.Content != nil { r.scroll.Hide() r.scroll.Content = nil content.Move(fyne.NewPos(0, theme.InputBorderSize())) content.Resize(entrySize) for i, o := range r.objects { if o == r.scroll { r.objects[i] = content break } } } else if (wrapping != fyne.TextWrapOff || scroll != widget.ScrollNone) && r.scroll.Content == nil { r.scroll.Content = content content.Move(fyne.NewPos(0, 0)) r.scroll.Move(fyne.NewPos(0, theme.InputBorderSize())) r.scroll.Resize(entrySize) r.scroll.Show() for i, o := range r.objects { if o == content { r.objects[i] = r.scroll break } } } r.entry.updateCursorAndSelection() r.box.FillColor = theme.InputBackgroundColor() r.box.CornerRadius = theme.InputRadiusSize() r.border.CornerRadius = theme.InputRadiusSize() if focusedAppearance { r.border.StrokeColor = theme.PrimaryColor() } else { if r.entry.Disabled() { r.border.StrokeColor = theme.DisabledColor() } else { r.border.StrokeColor = theme.InputBorderColor() } } if r.entry.ActionItem != nil { r.entry.ActionItem.Refresh() } if r.entry.Validator != nil { if !r.entry.focused && !r.entry.Disabled() && r.entry.dirty && r.entry.validationError != nil { r.border.StrokeColor = theme.ErrorColor() } r.ensureValidationSetup() r.entry.validationStatus.Refresh() } else if r.entry.validationStatus != nil { r.entry.validationStatus.Hide() } cache.Renderer(r.entry.content).Refresh() canvas.Refresh(r.entry.super()) } func (r *entryRenderer) ensureValidationSetup() { if r.entry.validationStatus == nil { r.entry.validationStatus = newValidationStatus(r.entry) r.objects = append(r.objects, r.entry.validationStatus) r.Layout(r.entry.size) r.entry.Validate() r.Refresh() } } var _ fyne.Widget = (*entryContent)(nil) type entryContent struct { BaseWidget entry *Entry scroll *widget.Scroll } func (e *entryContent) CreateRenderer() fyne.WidgetRenderer { e.ExtendBaseWidget(e) e.entry.propertyLock.Lock() defer e.entry.propertyLock.Unlock() provider := e.entry.textProvider() placeholder := e.entry.placeholderProvider() if provider.len() != 0 { placeholder.Hide() } objects := []fyne.CanvasObject{placeholder, provider, e.entry.cursorAnim.cursor} r := &entryContentRenderer{e.entry.cursorAnim.cursor, []fyne.CanvasObject{}, objects, provider, placeholder, e} r.updateScrollDirections() r.Layout(e.size) return r } // DragEnd is called at end of a drag event. // // Implements: fyne.Draggable func (e *entryContent) DragEnd() { // we need to propagate the focus, top level widget handles focus APIs e.entry.requestFocus() e.entry.DragEnd() } // Dragged is called when the pointer moves while a button is held down. // It updates the selection accordingly. // // Implements: fyne.Draggable func (e *entryContent) Dragged(d *fyne.DragEvent) { e.entry.Dragged(d) } var _ fyne.WidgetRenderer = (*entryContentRenderer)(nil) type entryContentRenderer struct { cursor *canvas.Rectangle selection []fyne.CanvasObject objects []fyne.CanvasObject provider, placeholder *RichText content *entryContent } func (r *entryContentRenderer) Destroy() { r.content.entry.cursorAnim.stop() } func (r *entryContentRenderer) Layout(size fyne.Size) { r.provider.Resize(size) r.placeholder.Resize(size) } func (r *entryContentRenderer) MinSize() fyne.Size { minSize := r.content.entry.placeholderProvider().MinSize() if r.content.entry.textProvider().len() > 0 { minSize = r.content.entry.text.MinSize() } return minSize } func (r *entryContentRenderer) Objects() []fyne.CanvasObject { r.content.entry.propertyLock.RLock() defer r.content.entry.propertyLock.RUnlock() // Objects are generated dynamically force selection rectangles to appear underneath the text if r.content.entry.selecting { objs := make([]fyne.CanvasObject, 0, len(r.selection)+len(r.objects)) objs = append(objs, r.selection...) return append(objs, r.objects...) } return r.objects } func (r *entryContentRenderer) Refresh() { r.content.entry.propertyLock.RLock() provider := r.content.entry.textProvider() placeholder := r.content.entry.placeholderProvider() focusedAppearance := r.content.entry.focused && !r.content.entry.disabled selections := r.selection r.updateScrollDirections() r.content.entry.propertyLock.RUnlock() if provider.len() == 0 { placeholder.Show() } else if placeholder.Visible() { placeholder.Hide() } if focusedAppearance { r.cursor.Show() if fyne.CurrentApp().Settings().ShowAnimations() { r.content.entry.cursorAnim.start() } } else { r.content.entry.cursorAnim.stop() r.cursor.Hide() } r.moveCursor() for _, selection := range selections { selection.(*canvas.Rectangle).Hidden = !r.content.entry.focused selection.(*canvas.Rectangle).FillColor = theme.SelectionColor() } canvas.Refresh(r.content) } // This process builds a slice of rectangles: // - one entry per row of text // - ordered by row order as they occur in multiline text // This process could be optimized in the scenario where the user is selecting upwards: // If the upwards case instead produces an order-reversed slice then only the newest rectangle would // require movement and resizing. The existing solution creates a new rectangle and then moves/resizes // all rectangles to comply with the occurrence order as stated above. func (r *entryContentRenderer) buildSelection() { r.content.entry.propertyLock.RLock() cursorRow, cursorCol := r.content.entry.CursorRow, r.content.entry.CursorColumn selectRow, selectCol := -1, -1 if r.content.entry.selecting { selectRow = r.content.entry.selectRow selectCol = r.content.entry.selectColumn } r.content.entry.propertyLock.RUnlock() if selectRow == -1 || (cursorRow == selectRow && cursorCol == selectCol) { r.selection = r.selection[:0] return } provider := r.content.entry.textProvider() // Convert column, row into x,y getCoordinates := func(column int, row int) (float32, float32) { sz := provider.lineSizeToColumn(column, row) return sz.Width, sz.Height*float32(row) - theme.InputBorderSize() + theme.InnerPadding() } lineHeight := r.content.entry.text.charMinSize(r.content.entry.Password, r.content.entry.TextStyle).Height minmax := func(a, b int) (int, int) { if a < b { return a, b } return b, a } // The remainder of the function calculates the set of boxes and add them to r.selection selectStartRow, selectEndRow := minmax(selectRow, cursorRow) selectStartCol, selectEndCol := minmax(selectCol, cursorCol) if selectRow < cursorRow { selectStartCol, selectEndCol = selectCol, cursorCol } if selectRow > cursorRow { selectStartCol, selectEndCol = cursorCol, selectCol } rowCount := selectEndRow - selectStartRow + 1 // trim r.selection to remove unwanted old rectangles if len(r.selection) > rowCount { r.selection = r.selection[:rowCount] } r.content.entry.propertyLock.Lock() defer r.content.entry.propertyLock.Unlock() // build a rectangle for each row and add it to r.selection for i := 0; i < rowCount; i++ { if len(r.selection) <= i { box := canvas.NewRectangle(theme.SelectionColor()) r.selection = append(r.selection, box) } // determine starting/ending columns for this rectangle row := selectStartRow + i startCol, endCol := selectStartCol, selectEndCol if selectStartRow < row { startCol = 0 } if selectEndRow > row { endCol = provider.rowLength(row) } // translate columns and row into draw coordinates x1, y1 := getCoordinates(startCol, row) x2, _ := getCoordinates(endCol, row) // resize and reposition each rectangle r.selection[i].Resize(fyne.NewSize(x2-x1+1, lineHeight)) r.selection[i].Move(fyne.NewPos(x1-1, y1)) } } func (r *entryContentRenderer) ensureCursorVisible() { letter := fyne.MeasureText("e", theme.TextSize(), r.content.entry.TextStyle) padX := letter.Width*2 + theme.LineSpacing() padY := letter.Height - theme.LineSpacing() cx := r.cursor.Position().X cy := r.cursor.Position().Y cx1 := cx - padX cy1 := cy - padY cx2 := cx + r.cursor.Size().Width + padX cy2 := cy + r.cursor.Size().Height + padY offset := r.content.scroll.Offset size := r.content.scroll.Size() if offset.X <= cx1 && cx2 < offset.X+size.Width && offset.Y <= cy1 && cy2 < offset.Y+size.Height { return } move := fyne.NewDelta(0, 0) if cx1 < offset.X { move.DX -= offset.X - cx1 } else if cx2 >= offset.X+size.Width { move.DX += cx2 - (offset.X + size.Width) } if cy1 < offset.Y { move.DY -= offset.Y - cy1 } else if cy2 >= offset.Y+size.Height { move.DY += cy2 - (offset.Y + size.Height) } if r.content.scroll.Content != nil { r.content.scroll.Offset = r.content.scroll.Offset.Add(move) r.content.scroll.Refresh() } } func (r *entryContentRenderer) moveCursor() { // build r.selection[] if the user has made a selection r.buildSelection() r.content.entry.propertyLock.RLock() provider := r.content.entry.textProvider() size := provider.lineSizeToColumn(r.content.entry.CursorColumn, r.content.entry.CursorRow) xPos := size.Width yPos := size.Height * float32(r.content.entry.CursorRow) r.content.entry.propertyLock.RUnlock() r.content.entry.propertyLock.Lock() lineHeight := r.content.entry.text.charMinSize(r.content.entry.Password, r.content.entry.TextStyle).Height r.cursor.Resize(fyne.NewSize(theme.InputBorderSize(), lineHeight)) r.cursor.Move(fyne.NewPos(xPos-(theme.InputBorderSize()/2), yPos+theme.InnerPadding()-theme.InputBorderSize())) callback := r.content.entry.OnCursorChanged r.content.entry.propertyLock.Unlock() r.ensureCursorVisible() if callback != nil { callback() } } func (r *entryContentRenderer) updateScrollDirections() { if r.content.scroll == nil { // not scrolling return } switch r.content.entry.Wrapping { case fyne.TextWrapOff: r.content.scroll.Direction = r.content.entry.Scroll case fyne.TextTruncate: // this is now the default - but we scroll r.content.scroll.Direction = widget.ScrollBoth default: // fyne.TextWrapBreak, fyne.TextWrapWord r.content.scroll.Direction = widget.ScrollVerticalOnly } } // getTextWhitespaceRegion returns the start/end markers for selection highlight on starting from col // and expanding to the start and end of the whitespace or text underneath the specified position. // Pass `true` for `expand` if you want whitespace selection to extend to the neighboring words. func getTextWhitespaceRegion(row []rune, col int, expand bool) (int, int) { if len(row) == 0 || col < 0 { return -1, -1 } // If the click position exceeds the length of text then snap it to the end if col >= len(row) { col = len(row) - 1 } // maps: " fi-sh 日本語本語日 \t " // into: " -- -- ------ " space := func(r rune) rune { if unicode.IsSpace(r) { return ' ' } // If this rune is a typical word separator then classify it as whitespace if strings.ContainsRune(wordSeparator, r) { return ' ' } return '-' } toks := strings.Map(space, string(row)) c := byte(' ') startCheck := col endCheck := col if expand { if col > 0 && toks[col-1] == ' ' { // ignore the prior whitespace then count startCheck = strings.LastIndexByte(toks[:startCheck], '-') if startCheck == -1 { startCheck = 0 } } if toks[col] == ' ' { // ignore the current whitespace then count endCheck = col + strings.IndexByte(toks[endCheck:], '-') } } else if toks[col] == ' ' { c = byte('-') } // LastIndexByte + 1 ensures that the position of the unwanted character ' ' is excluded // +1 also has the added side effect whereby if ' ' isn't found then -1 is snapped to 0 start := strings.LastIndexByte(toks[:startCheck], c) + 1 // IndexByte will find the position of the next unwanted character, this is to be the end // marker for the selection end := strings.IndexByte(toks[endCheck:], c) if end == -1 { end = len(toks) // snap end to len(toks) if it results in -1 } else { end += endCheck // otherwise include the text slice position } return start, end }