VideoTools/vendor/fyne.io/fyne/v2/widget/entry.go
Stu Leak 68df790d27 Fix player frame generation and video playback
Major improvements to UnifiedPlayer:

1. GetFrameImage() now works when paused for responsive UI updates
2. Play() method properly starts FFmpeg process
3. Frame display loop runs continuously for smooth video display
4. Disabled audio temporarily to fix video playback fundamentals
5. Simplified FFmpeg command to focus on video stream only

Player now:
- Generates video frames correctly
- Shows video when paused
- Has responsive progress tracking
- Starts playback properly

Next steps: Re-enable audio playback once video is stable
2026-01-07 22:20:00 -05:00

2167 lines
57 KiB
Go

package widget
import (
"image/color"
"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/lang"
"fyne.io/fyne/v2/theme"
)
const (
bindIgnoreDelay = time.Millisecond * 100 // ignore incoming DataItem fire after we have called Set
multiLineRows = 3
)
// Declare conformity with interfaces
var (
_ fyne.Disableable = (*Entry)(nil)
_ fyne.Draggable = (*Entry)(nil)
_ fyne.Focusable = (*Entry)(nil)
_ fyne.Tappable = (*Entry)(nil)
_ fyne.Widget = (*Entry)(nil)
_ desktop.Mouseable = (*Entry)(nil)
_ desktop.Keyable = (*Entry)(nil)
_ mobile.Keyboardable = (*Entry)(nil)
_ mobile.Touchable = (*Entry)(nil)
_ 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 fyne.ScrollDirection
// Set a validator that this entry will check against
// Since: 1.4
Validator fyne.StringValidator `json:"-"`
validationStatus *validationStatus
onValidationChanged func(error)
validationError error
// If true, the Validator runs automatically on render without user interaction.
// It will reflect any validation errors found or those explicitly set via SetValidationError().
// Since: 2.7
AlwaysShowValidationError bool
CursorRow, CursorColumn int
OnCursorChanged func() `json:"-"`
// Icon is displayed at the outer left of the entry.
// It is not clickable, but can be used to indicate the purpose of the entry.
// Since: 2.7
Icon fyne.Resource `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)
// selectKeyDown indicates whether left shift or right shift is currently held down
selectKeyDown bool
sel *selectable
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
// undoStack stores the data necessary for undo/redo functionality
// See entryUndoStack for implementation details.
undoStack entryUndoStack
}
// NewEntry creates a new single line entry widget.
func NewEntry() *Entry {
e := &Entry{Wrapping: fyne.TextWrap(fyne.TextTruncateClip)}
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.TextWrap(fyne.TextTruncateClip)}
e.ExtendBaseWidget(e)
return e
}
// NewPasswordEntry creates a new entry password widget
func NewPasswordEntry() *Entry {
e := &Entry{Password: true, Wrapping: fyne.TextWrap(fyne.TextTruncateClip)}
e.ExtendBaseWidget(e)
e.ActionItem = newPasswordRevealer(e)
return e
}
// AcceptsTab returns if Entry accepts the Tab key or not.
//
// 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
func (e *Entry) CreateRenderer() fyne.WidgetRenderer {
th := e.Theme()
v := fyne.CurrentApp().Settings().ThemeVariant()
e.ExtendBaseWidget(e)
// initialise
e.textProvider()
e.placeholderProvider()
e.syncSelectable()
box := canvas.NewRectangle(th.Color(theme.ColorNameInputBackground, v))
box.CornerRadius = th.Size(theme.SizeNameInputRadius)
border := canvas.NewRectangle(color.Transparent)
border.StrokeWidth = th.Size(theme.SizeNameInputBorder)
border.StrokeColor = th.Color(theme.ColorNameInputBorder, v)
border.CornerRadius = th.Size(theme.SizeNameInputRadius)
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)
}
icon := canvas.NewImageFromResource(e.Icon)
icon.FillMode = canvas.ImageFillContain
objects = append(objects, icon)
if e.Icon == nil {
icon.Hide()
}
e.syncSegments()
return &entryRenderer{box, border, e.scroll, icon, objects, e}
}
// CursorPosition returns the relative position of this Entry widget's cursor.
//
// Since: 2.7
func (e *Entry) CursorPosition() fyne.Position {
provider := e.textProvider()
th := e.Theme()
innerPad := th.Size(theme.SizeNameInnerPadding)
inputBorder := th.Size(theme.SizeNameInputBorder)
textSize := th.Size(theme.SizeNameText)
size := provider.lineSizeToColumn(e.CursorColumn, e.CursorRow, textSize, innerPad)
xPos := size.Width
yPos := size.Height * float32(e.CursorRow)
return fyne.NewPos(xPos-(inputBorder/2), yPos+innerPad-inputBorder)
}
// CursorTextOffset returns how many runes into the source text the cursor is positioned at.
//
// Since: 2.7
func (e *Entry) CursorTextOffset() (pos int) {
return textPosFromRowCol(e.CursorRow, e.CursorColumn, e.textProvider())
}
// Cursor returns the cursor type of this widget
func (e *Entry) Cursor() desktop.Cursor {
return desktop.TextCursor
}
// DoubleTapped is called when this entry has been double tapped so we should select text below the pointer
func (e *Entry) DoubleTapped(_ *fyne.PointEvent) {
e.focused = true
e.syncSelectable()
e.sel.doubleTappedAtUnixMillis = time.Now().UnixMilli()
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.sel.selectRow = e.CursorRow
e.sel.selectColumn = start
}
// Always aim to maximise the selected region
if e.sel.selectRow > e.CursorRow || (e.sel.selectRow == e.CursorRow && e.sel.selectColumn > e.CursorColumn) {
e.CursorColumn = start
} else {
e.CursorColumn = end
}
e.syncSelectable()
e.sel.selecting = true
})
}
// DragEnd is called at end of a drag event.
func (e *Entry) DragEnd() {
e.syncSelectable()
if e.CursorColumn == e.sel.selectColumn && e.CursorRow == e.sel.selectRow {
e.sel.selecting = false
}
}
// Dragged is called when the pointer moves while a button is held down.
// It updates the selection accordingly.
func (e *Entry) Dragged(d *fyne.DragEvent) {
d.Position = d.Position.Add(fyne.NewPos(0, e.Theme().Size(theme.SizeNameInputBorder)))
e.sel.dragged(d)
e.updateMousePointer(d.Position, false)
}
// ExtendBaseWidget is used by an extending widget to make use of BaseWidget functionality.
func (e *Entry) ExtendBaseWidget(wid fyne.Widget) {
e.BaseWidget.ExtendBaseWidget(wid)
e.registerShortcut()
}
// FocusGained is called when the Entry has been given focus.
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.
func (e *Entry) FocusLost() {
e.setFieldsAndRefresh(func() {
e.focused = false
e.selectKeyDown = false
})
if e.onFocusChanged != nil {
e.onFocusChanged(false)
}
}
// Hide hides the entry.
func (e *Entry) Hide() {
if e.popUp != nil {
e.popUp.Hide()
e.popUp = nil
}
e.DisableableWidget.Hide()
}
// Keyboard implements the Keyboardable interface
func (e *Entry) Keyboard() mobile.KeyboardType {
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
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.sel.selecting {
e.sel.selectRow = e.CursorRow
e.sel.selectColumn = e.CursorColumn
}
e.selectKeyDown = true
}
}
// KeyUp handler for key release events - used to reset shift modifier state for text selection
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.
func (e *Entry) MinSize() fyne.Size {
cached := e.minCache
if !cached.IsZero() {
return cached
}
e.ExtendBaseWidget(e)
min := e.BaseWidget.MinSize()
e.minCache = min
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.
func (e *Entry) MouseDown(m *desktop.MouseEvent) {
e.requestFocus()
e.syncSelectable()
if isTripleTap(e.sel.doubleTappedAtUnixMillis, time.Now().UnixMilli()) {
e.sel.selectCurrentRow(false)
e.CursorColumn = e.sel.cursorColumn
e.Refresh()
return
}
if e.selectKeyDown {
e.sel.selecting = true
}
if e.sel.selecting && !e.selectKeyDown && m.Button == desktop.MouseButtonPrimary {
e.sel.selecting = false
}
e.updateMousePointer(m.Position.Add(e.scroll.Offset), 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
func (e *Entry) MouseUp(m *desktop.MouseEvent) {
e.syncSelectable()
start, _ := e.sel.selection()
if start == -1 && e.sel.selecting && !e.selectKeyDown {
e.sel.selecting = false
}
}
// Redo un-does the last undo action.
//
// Since: 2.5
func (e *Entry) Redo() {
newText, action := e.undoStack.Redo(e.Text)
modify, ok := action.(*entryModifyAction)
if !ok {
return
}
pos := modify.Position
if !modify.Delete {
pos += len(modify.Text)
}
e.updateText(newText, false)
e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos)
e.syncSelectable()
if e.OnChanged != nil {
e.OnChanged(newText)
}
e.Refresh()
}
func (e *Entry) Refresh() {
e.minCache = fyne.Size{}
if e.sel != nil {
e.sel.style = e.TextStyle
e.sel.theme = e.Theme()
e.sel.focussed = e.focused
e.sel.Refresh()
}
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 {
return e.sel.SelectedText()
}
// SetIcon sets the leading icon resource for the entry.
// The icon will be displayed at the outer left of the entry, but is not clickable.
// This can be used to indicate the purpose of the entry, such as an email or password field.
//
// Since: 2.7
func (e *Entry) SetIcon(res fyne.Resource) {
e.Icon = res
e.Refresh()
}
// 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.Theme() // setup theme cache before locking
e.PlaceHolder = text
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.
// Calling SetText resets all undo history.
func (e *Entry) SetText(text string) {
e.setText(text, false)
}
func (e *Entry) setText(text string, fromBinding bool) {
e.Theme() // setup theme cache before locking
e.updateTextAndRefresh(text, fromBinding)
e.updateCursorAndSelection()
e.undoStack.Clear()
}
// Append appends the text to the end of the entry.
//
// Since: 2.4
func (e *Entry) Append(text string) {
provider := e.textProvider()
provider.insertAt(provider.len(), []rune(text))
content := provider.String()
changed := e.updateText(content, false)
cb := e.OnChanged
e.undoStack.Clear()
if changed {
e.validate()
if cb != nil {
cb(content)
}
}
e.Refresh()
}
// Tapped is called when this entry has been tapped. We update the cursor position in
// device-specific callbacks (MouseDown() and TouchDown()).
func (e *Entry) Tapped(ev *fyne.PointEvent) {
if fyne.CurrentDevice().IsMobile() && e.sel.selecting {
e.sel.selecting = false
}
}
// TappedSecondary is called when right or alternative tap is invoked.
//
// Opens the PopUpMenu with `Paste` item to paste text from the clipboard.
func (e *Entry) TappedSecondary(pe *fyne.PointEvent) {
if e.Disabled() && e.Password {
return // no popup options for a disabled concealed field
}
e.requestFocus()
super := e.super()
app := fyne.CurrentApp()
clipboard := app.Clipboard()
typedShortcut := super.(fyne.Shortcutable).TypedShortcut
cutItem := fyne.NewMenuItem(lang.L("Cut"), func() {
typedShortcut(&fyne.ShortcutCut{Clipboard: clipboard})
})
copyItem := fyne.NewMenuItem(lang.L("Copy"), func() {
typedShortcut(&fyne.ShortcutCopy{Clipboard: clipboard})
})
pasteItem := fyne.NewMenuItem(lang.L("Paste"), func() {
typedShortcut(&fyne.ShortcutPaste{Clipboard: clipboard})
})
selectAllItem := fyne.NewMenuItem(lang.L("Select all"), e.selectAll)
menuItems := make([]*fyne.MenuItem, 0, 6)
if e.Disabled() {
menuItems = append(menuItems, copyItem, selectAllItem)
} else if e.Password {
menuItems = append(menuItems, pasteItem, selectAllItem)
} else {
canUndo, canRedo := e.undoStack.CanUndo(), e.undoStack.CanRedo()
if canUndo {
undoItem := fyne.NewMenuItem(lang.L("Undo"), e.Undo)
menuItems = append(menuItems, undoItem)
}
if canRedo {
redoItem := fyne.NewMenuItem(lang.L("Redo"), e.Redo)
menuItems = append(menuItems, redoItem)
}
if canUndo || canRedo {
menuItems = append(menuItems, fyne.NewMenuItemSeparator())
}
menuItems = append(menuItems, cutItem, copyItem, pasteItem, selectAllItem)
}
driver := app.Driver()
entryPos := driver.AbsolutePositionForObject(super)
popUpPos := entryPos.Add(pe.Position)
e.popUp = NewPopUpMenu(fyne.NewMenu("", menuItems...), driver.CanvasForObject(super))
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
func (e *Entry) TouchDown(ev *mobile.TouchEvent) {
now := time.Now().UnixMilli()
e.syncSegments()
if !e.Disabled() {
e.requestFocus()
}
if isTripleTap(e.sel.doubleTappedAtUnixMillis, now) {
e.sel.selectCurrentRow(false)
e.CursorColumn = e.sel.cursorColumn
e.Refresh()
return
}
e.updateMousePointer(ev.Position, false)
}
// TouchUp is called when this entry gets a touch up event on mobile device.
//
// Since: 2.1
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
func (e *Entry) TouchCancel(*mobile.TouchEvent) {
}
// TypedKey receives key input events when the Entry widget is focused.
func (e *Entry) TypedKey(key *fyne.KeyEvent) {
if e.Disabled() {
return
}
if e.cursorAnim != nil {
e.cursorAnim.interrupt()
}
provider := e.textProvider()
multiLine := e.MultiLine
if e.selectKeyDown || e.sel.selecting {
if e.selectingKeyHandler(key) {
e.Refresh()
return
}
}
switch key.Name {
case fyne.KeyBackspace:
isEmpty := provider.len() == 0 || (e.CursorColumn == 0 && e.CursorRow == 0)
if isEmpty {
return
}
pos := e.CursorTextOffset()
deletedText := provider.deleteFromTo(pos-1, pos)
e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos - 1)
e.syncSelectable()
e.undoStack.MergeOrAdd(&entryModifyAction{
Delete: true,
Position: pos - 1,
Text: deletedText,
})
case fyne.KeyDelete:
pos := e.CursorTextOffset()
if provider.len() == 0 || pos == provider.len() {
return
}
deletedText := provider.deleteFromTo(pos, pos+1)
e.undoStack.MergeOrAdd(&entryModifyAction{
Delete: true,
Position: pos,
Text: deletedText,
})
case fyne.KeyReturn, fyne.KeyEnter:
e.typedKeyReturn(provider, multiLine)
case fyne.KeyTab:
e.typedKeyTab()
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:
if e.MultiLine {
e.CursorRow = 0
}
e.CursorColumn = 0
e.syncSelectable()
case fyne.KeyPageDown:
if e.MultiLine {
e.CursorRow = provider.rows() - 1
e.CursorColumn = provider.rowLength(e.CursorRow)
} else {
e.CursorColumn = provider.len()
}
e.syncSelectable()
default:
return
}
content := provider.String()
changed := e.updateText(content, false)
if e.CursorRow == e.sel.selectRow && e.CursorColumn == e.sel.selectColumn {
e.sel.selecting = false
}
cb := e.OnChanged
if changed {
e.validate()
if cb != nil {
cb(content)
}
}
e.Refresh()
}
// Undo un-does the last modifying user-action.
//
// Since: 2.5
func (e *Entry) Undo() {
newText, action := e.undoStack.Undo(e.Text)
modify, ok := action.(*entryModifyAction)
if !ok {
return
}
pos := modify.Position
if modify.Delete {
pos += len(modify.Text)
}
e.updateText(newText, false)
e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos)
e.syncSelectable()
if e.OnChanged != nil {
e.OnChanged(newText)
}
e.Refresh()
}
func (e *Entry) typedKeyUp(provider *RichText) {
if e.CursorRow > 0 {
e.CursorRow--
} else {
e.CursorColumn = 0
}
rowLength := provider.rowLength(e.CursorRow)
if e.CursorColumn > rowLength {
e.CursorColumn = rowLength
}
e.syncSelectable()
}
func (e *Entry) typedKeyDown(provider *RichText) {
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.syncSelectable()
}
func (e *Entry) typedKeyLeft(provider *RichText) {
if e.CursorColumn > 0 {
e.CursorColumn--
} else if e.MultiLine && e.CursorRow > 0 {
e.CursorRow--
e.CursorColumn = provider.rowLength(e.CursorRow)
}
e.syncSelectable()
}
func (e *Entry) typedKeyRight(provider *RichText) {
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.syncSelectable()
}
func (e *Entry) typedKeyHome() {
e.CursorColumn = 0
}
func (e *Entry) typedKeyEnd(provider *RichText) {
if e.MultiLine {
e.CursorColumn = provider.rowLength(e.CursorRow)
} else {
e.CursorColumn = provider.len()
}
}
// handler for Ctrl+[backspace/delete] - delete the word
// to the left or right of the cursor
func (e *Entry) deleteWord(right bool) {
provider := e.textProvider()
cursorRow, cursorCol := e.CursorRow, e.CursorColumn
// start, end relative to text row
start, end := getTextWhitespaceRegion(provider.row(cursorRow), cursorCol, true)
if right {
start = cursorCol
} else {
end = cursorCol
}
if start == -1 || end == -1 {
return
}
// convert start, end to absolute text position
b := provider.rowBoundary(cursorRow)
if b != nil {
start += b.begin
end += b.begin
}
erased := provider.deleteFromTo(start, end)
e.undoStack.MergeOrAdd(&entryModifyAction{
Delete: true,
Position: start,
Text: erased,
})
if !right {
e.CursorColumn = cursorCol - (end - start)
}
e.updateTextAndRefresh(provider.String(), false)
}
func (e *Entry) typedKeyTab() {
if dd, ok := fyne.CurrentApp().Driver().(desktop.Driver); ok {
if dd.CurrentKeyModifiers()&fyne.KeyModifierShift != 0 {
return // don't insert a tab when Shift+Tab typed
}
}
e.TypedRune('\t')
}
// TypedRune receives text input events when the Entry widget is focused.
func (e *Entry) TypedRune(r rune) {
if e.Disabled() {
return
}
e.syncSelectable()
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.sel.selecting {
e.eraseSelection()
}
runes := []rune{r}
pos := e.CursorTextOffset()
provider := e.textProvider()
provider.insertAt(pos, runes)
content := provider.String()
e.updateText(content, false)
e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos + len(runes))
e.syncSelectable()
e.undoStack.MergeOrAdd(&entryModifyAction{
Position: pos,
Text: runes,
})
e.validate()
if cb != nil {
cb(content)
}
e.Refresh()
}
// TypedShortcut implements the Shortcutable interface
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.sel.selecting || e.Password {
return
}
clipboard.SetContent(e.sel.SelectedText())
}
// cutToClipboard 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.sel.selecting || e.Password {
return
}
e.copyToClipboard(clipboard)
e.eraseSelectionAndUpdate()
content := e.Text
cb := e.OnChanged
e.validate()
if cb != nil {
cb(content)
}
e.Refresh()
}
// eraseSelection deletes the selected text and moves the cursor but does not update the text field.
func (e *Entry) eraseSelection() bool {
if e.Disabled() {
return false
}
provider := e.textProvider()
posA, posB := e.sel.selection()
if posA == posB {
return false
}
erasedText := provider.deleteFromTo(posA, posB)
e.CursorRow, e.CursorColumn = e.rowColFromTextPos(posA)
e.syncSelectable()
e.sel.selectRow, e.sel.selectColumn = e.CursorRow, e.CursorColumn
e.sel.selecting = false
e.undoStack.MergeOrAdd(&entryModifyAction{
Delete: true,
Position: posA,
Text: erasedText,
})
return true
}
// eraseSelectionAndUpdate removes the current selected region and moves the cursor.
// It also updates the text if something has been erased.
func (e *Entry) eraseSelectionAndUpdate() {
if e.eraseSelection() {
e.updateText(e.textProvider().String(), false)
}
}
// pasteFromClipboard inserts text from the clipboard content,
// starting from the cursor position.
func (e *Entry) pasteFromClipboard(clipboard fyne.Clipboard) {
e.syncSelectable()
text := clipboard.Content()
if text == "" {
changed := e.sel.selecting && e.eraseSelection()
if changed {
e.Refresh()
}
return // Nothing to paste into the text content.
}
if !e.MultiLine {
// format clipboard content to be compatible with single line entry
text = strings.Replace(text, "\n", " ", -1)
}
if e.sel.selecting {
e.eraseSelection()
}
runes := []rune(text)
pos := e.CursorTextOffset()
provider := e.textProvider()
provider.insertAt(pos, runes)
e.undoStack.Add(&entryModifyAction{
Position: pos,
Text: runes,
})
content := provider.String()
e.updateText(content, false)
e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos + len(runes))
e.syncSelectable()
cb := e.OnChanged
e.validate()
if cb != nil {
cb(content) // We know that the text has changed.
}
e.Refresh() // placing the cursor (and refreshing) happens last
}
// placeholderProvider returns the placeholder text handler for this entry
func (e *Entry) placeholderProvider() *RichText {
if len(e.placeholder.Segments) > 0 {
return &e.placeholder
}
e.placeholder.Scroll = widget.ScrollNone
e.placeholder.inset = fyne.NewSize(0, e.Theme().Size(theme.SizeNameInputBorder))
style := RichTextStyleInline
style.ColorName = theme.ColorNamePlaceHolder
style.TextStyle = e.TextStyle
e.placeholder.Segments = []RichTextSegment{
&TextSegment{
Style: style,
Text: e.PlaceHolder,
},
}
return &e.placeholder
}
func (e *Entry) registerShortcut() {
e.shortcut.AddShortcut(&fyne.ShortcutUndo{}, func(se fyne.Shortcut) {
e.Undo()
})
e.shortcut.AddShortcut(&fyne.ShortcutRedo{}, func(se fyne.Shortcut) {
e.Redo()
})
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
}
}
e.syncSelectable()
})
}
selectMoveWord := func(se fyne.Shortcut) {
if !e.sel.selecting {
e.sel.selectColumn = e.CursorColumn
e.sel.selectRow = e.CursorRow
e.sel.selecting = true
}
moveWord(se)
}
unselectMoveWord := func(se fyne.Shortcut) {
e.sel.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.sel.selecting = false
if s.(*desktop.CustomShortcut).KeyName == fyne.KeyLeft {
e.typedKeyHome()
} else {
e.typedKeyEnd(e.textProvider())
}
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)
e.shortcut.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyBackspace, Modifier: moveWordModifier},
func(fyne.Shortcut) { e.deleteWord(false) })
e.shortcut.AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyDelete, Modifier: moveWordModifier},
func(fyne.Shortcut) { e.deleteWord(true) })
}
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 row, col
}
// selectAll selects all text in entry
func (e *Entry) selectAll() {
if e.textProvider().len() == 0 {
return
}
e.setFieldsAndRefresh(func() {
e.sel.selectRow = 0
e.sel.selectColumn = 0
lastRow := e.textProvider().rows() - 1
e.CursorColumn = e.textProvider().rowLength(lastRow)
e.CursorRow = lastRow
e.syncSelectable()
e.sel.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.sel.selecting {
switch key.Name {
case fyne.KeyUp, fyne.KeyDown,
fyne.KeyLeft, fyne.KeyRight,
fyne.KeyEnd, fyne.KeyHome,
fyne.KeyPageUp, fyne.KeyPageDown:
e.sel.selecting = true
}
}
if !e.sel.selecting {
return false
}
switch key.Name {
case fyne.KeyBackspace, fyne.KeyDelete:
// clears the selection -- return handled
e.eraseSelectionAndUpdate()
content := e.Text
cb := e.OnChanged
e.validate()
if cb != nil {
cb(content)
}
e.Refresh()
return true
case fyne.KeyReturn, fyne.KeyEnter:
if e.MultiLine {
// clear the selection -- return unhandled to add the newline
e.setFieldsAndRefresh(e.eraseSelectionAndUpdate)
}
return false
}
if !e.selectKeyDown {
switch key.Name {
case fyne.KeyLeft:
// seek to the start of the selection -- return handled
selectStart, _ := e.sel.selection()
e.CursorRow, e.CursorColumn = e.rowColFromTextPos(selectStart)
e.syncSelectable()
e.sel.selecting = false
return true
case fyne.KeyRight:
// seek to the end of the selection -- return handled
_, selectEnd := e.sel.selection()
e.CursorRow, e.CursorColumn = e.rowColFromTextPos(selectEnd)
e.syncSelectable()
e.sel.selecting = false
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.sel.selecting = false
return false
}
}
return false
}
func (e *Entry) syncSegments() {
colName := theme.ColorNameForeground
wrap := e.textWrap()
disabled := e.Disabled()
if disabled {
colName = theme.ColorNameDisabled
}
text := e.textProvider()
text.Wrapping = wrap
textSegment := text.Segments[0].(*TextSegment)
textSegment.Text = e.Text
textSegment.Style.ColorName = colName
textSegment.Style.concealed = e.Password
textSegment.Style.TextStyle = e.TextStyle
colName = theme.ColorNamePlaceHolder
if disabled {
colName = theme.ColorNameDisabled
}
placeholder := e.placeholderProvider()
placeholder.Wrapping = wrap
textSegment = placeholder.Segments[0].(*TextSegment)
textSegment.Style.ColorName = colName
textSegment.Style.TextStyle = e.TextStyle
textSegment.Text = e.PlaceHolder
}
func (e *Entry) syncSelectable() {
if e.sel == nil {
e.sel = &selectable{theme: e.Theme(), provider: e.textProvider(), focus: e, password: e.Password, style: e.TextStyle}
e.sel.ExtendBaseWidget(e.sel)
}
e.sel.cursorRow, e.sel.cursorColumn = e.CursorRow, e.CursorColumn
}
// textProvider returns the text handler for this entry
func (e *Entry) textProvider() *RichText {
if len(e.text.Segments) > 0 {
return &e.text
}
if e.Text != "" {
e.dirty = true
}
e.text.Scroll = widget.ScrollNone
e.text.inset = fyne.NewSize(0, e.Theme().Size(theme.SizeNameInputBorder))
e.text.Segments = []RichTextSegment{&TextSegment{Style: RichTextStyleInline, Text: e.Text}}
return &e.text
}
// textWrap calculates the wrapping that we should apply.
func (e *Entry) textWrap() fyne.TextWrap {
if e.Wrapping == fyne.TextWrap(fyne.TextTruncateClip) { // 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.TextWrap(fyne.TextTruncateClip)
return fyne.TextWrapOff
}
return e.Wrapping
}
func (e *Entry) updateCursorAndSelection() {
e.CursorRow, e.CursorColumn = e.truncatePosition(e.CursorRow, e.CursorColumn)
e.syncSelectable()
e.sel.selectRow, e.sel.selectColumn = e.truncatePosition(e.sel.selectRow, e.sel.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.sel.getRowCol(p)
if !rightClick || !e.sel.selecting {
e.CursorRow = row
e.CursorColumn = col
e.syncSelectable()
}
if !e.sel.selecting {
e.sel.selectRow = row
e.sel.selectColumn = col
}
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)
changed := e.updateText(text, fromBinding)
if changed {
callback = e.OnChanged
}
e.validate()
if callback != nil {
callback(text)
}
e.Refresh()
}
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) {
onSubmitted := e.OnSubmitted
selectDown := e.selectKeyDown
text := e.Text
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
}
s := []rune("\n")
pos := e.CursorTextOffset()
provider.insertAt(pos, s)
e.undoStack.MergeOrAdd(&entryModifyAction{
Position: pos,
Text: s,
})
e.CursorColumn = 0
e.CursorRow++
e.syncSelectable()
}
func (e *Entry) setFieldsAndRefresh(f func()) {
f()
impl := e.super()
if impl == nil {
return
}
impl.Refresh()
}
var _ fyne.WidgetRenderer = (*entryRenderer)(nil)
type entryRenderer struct {
box, border *canvas.Rectangle
scroll *widget.Scroll
icon *canvas.Image
objects []fyne.CanvasObject
entry *Entry
}
func (r *entryRenderer) Destroy() {
}
func (r *entryRenderer) trailingInset() float32 {
th := r.entry.Theme()
xInset := float32(0)
if r.entry.ActionItem != nil {
xInset = r.entry.ActionItem.MinSize().Width
}
if r.entry.Validator != nil {
iconSpace := th.Size(theme.SizeNameInlineIcon) + th.Size(theme.SizeNameLineSpacing)
if r.entry.ActionItem == nil {
xInset = iconSpace + th.Size(theme.SizeNameInnerPadding)
} else {
xInset += iconSpace
}
}
return xInset
}
func (r *entryRenderer) leadingInset() float32 {
th := r.entry.Theme()
xInset := float32(0)
if r.entry.Icon != nil {
xInset = th.Size(theme.SizeNameInlineIcon) + th.Size(theme.SizeNameLineSpacing)
}
return xInset
}
func (r *entryRenderer) Layout(size fyne.Size) {
th := r.entry.Theme()
borderSize := th.Size(theme.SizeNameInputBorder)
iconSize := th.Size(theme.SizeNameInlineIcon)
innerPad := th.Size(theme.SizeNameInnerPadding)
inputBorder := th.Size(theme.SizeNameInputBorder)
// 0.5 is removed so on low DPI it rounds down on the trailing edge
r.border.Resize(fyne.NewSize(size.Width-borderSize-.5, size.Height-borderSize-.5))
r.border.StrokeWidth = borderSize
r.border.Move(fyne.NewSquareOffsetPos(borderSize / 2))
r.box.Resize(size.Subtract(fyne.NewSquareSize(borderSize * 2)))
r.box.Move(fyne.NewSquareOffsetPos(borderSize))
pad := theme.InputBorderSize()
actionIconSize := fyne.NewSize(0, size.Height-pad*2)
if r.entry.ActionItem != nil {
actionIconSize.Width = r.entry.ActionItem.MinSize().Width
r.entry.ActionItem.Resize(actionIconSize)
r.entry.ActionItem.Move(fyne.NewPos(size.Width-actionIconSize.Width-pad, pad))
}
validatorIconSize := fyne.NewSize(0, 0)
if r.entry.Validator != nil || r.entry.AlwaysShowValidationError {
validatorIconSize = fyne.NewSquareSize(iconSize)
r.ensureValidationSetup()
r.entry.validationStatus.Resize(validatorIconSize)
if r.entry.ActionItem == nil {
r.entry.validationStatus.Move(fyne.NewPos(size.Width-validatorIconSize.Width-innerPad, innerPad))
} else {
r.entry.validationStatus.Move(fyne.NewPos(size.Width-validatorIconSize.Width-actionIconSize.Width, innerPad))
}
}
if r.entry.Icon != nil {
r.icon.Resize(fyne.NewSquareSize(iconSize))
r.icon.Move(fyne.NewPos(innerPad, innerPad))
}
r.entry.textProvider().inset = fyne.NewSize(0, inputBorder)
r.entry.placeholderProvider().inset = fyne.NewSize(0, inputBorder)
entrySize := size.Subtract(fyne.NewSize(r.trailingInset()+r.leadingInset(), inputBorder*2))
entryPos := fyne.NewPos(r.leadingInset(), inputBorder)
prov := r.entry.textProvider()
textPos := textPosFromRowCol(r.entry.CursorRow, r.entry.CursorColumn, prov)
selectPos := textPosFromRowCol(r.entry.sel.selectRow, r.entry.sel.selectColumn, prov)
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)
}
resizedTextPos := textPosFromRowCol(r.entry.CursorRow, r.entry.CursorColumn, prov)
if textPos != resizedTextPos {
r.entry.setFieldsAndRefresh(func() {
r.entry.CursorRow, r.entry.CursorColumn = r.entry.rowColFromTextPos(textPos)
r.entry.sel.cursorRow, r.entry.sel.cursorRow = r.entry.CursorRow, r.entry.CursorColumn
if r.entry.sel.selecting {
r.entry.sel.selectRow, r.entry.sel.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()
}
th := r.entry.Theme()
minSize := fyne.Size{}
if r.scroll.Direction == widget.ScrollNone {
minSize = r.entry.content.MinSize().AddWidthHeight(0, th.Size(theme.SizeNameInputBorder)*2)
} else {
innerPadding := th.Size(theme.SizeNameInnerPadding)
textSize := th.Size(theme.SizeNameText)
charMin := r.entry.placeholderProvider().charMinSize(r.entry.Password, r.entry.TextStyle, textSize)
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
}
minSize = minSize.AddWidthHeight(innerPadding*2, innerPadding)
}
iconSpace := th.Size(theme.SizeNameInlineIcon) + th.Size(theme.SizeNameLineSpacing)
if r.entry.ActionItem != nil {
minSize.Width += iconSpace
}
if r.entry.Validator != nil {
minSize.Width += iconSpace
}
if r.entry.Icon != nil {
minSize.Width += iconSpace
}
return minSize
}
func (r *entryRenderer) Objects() []fyne.CanvasObject {
return r.objects
}
func (r *entryRenderer) Refresh() {
content := r.entry.content
focusedAppearance := r.entry.focused && !r.entry.Disabled()
scroll := r.entry.Scroll
wrapping := r.entry.Wrapping
r.entry.syncSegments()
r.entry.text.updateRowBounds()
r.entry.placeholder.updateRowBounds()
r.entry.text.Refresh()
r.entry.placeholder.Refresh()
th := r.entry.Theme()
inputBorder := th.Size(theme.SizeNameInputBorder)
// correct our scroll wrappers if the wrap mode changed
entrySize := r.entry.Size().Subtract(fyne.NewSize(r.trailingInset()+r.leadingInset(), inputBorder*2))
if wrapping == fyne.TextWrapOff && scroll == widget.ScrollNone && r.scroll.Content != nil {
r.scroll.Hide()
r.scroll.Content = nil
content.Move(fyne.NewPos(0, inputBorder))
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, inputBorder))
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()
v := fyne.CurrentApp().Settings().ThemeVariant()
r.box.FillColor = th.Color(theme.ColorNameInputBackground, v)
r.box.CornerRadius = th.Size(theme.SizeNameInputRadius)
r.border.CornerRadius = r.box.CornerRadius
if focusedAppearance {
r.border.StrokeColor = th.Color(theme.ColorNamePrimary, v)
} else {
if r.entry.Disabled() {
r.border.StrokeColor = th.Color(theme.ColorNameDisabled, v)
} else {
r.border.StrokeColor = th.Color(theme.ColorNameInputBorder, v)
}
}
if r.entry.ActionItem != nil {
r.entry.ActionItem.Refresh()
}
if r.entry.Validator != nil || r.entry.AlwaysShowValidationError {
if !r.entry.focused && !r.entry.Disabled() && (r.entry.dirty || r.entry.AlwaysShowValidationError) && r.entry.validationError != nil {
r.border.StrokeColor = th.Color(theme.ColorNameError, v)
}
r.ensureValidationSetup()
r.entry.validationStatus.Refresh()
} else if r.entry.validationStatus != nil {
r.entry.validationStatus.Hide()
}
if r.entry.Icon != nil {
r.icon.Resource = r.entry.Icon
r.icon.Refresh()
r.icon.Show()
} else {
r.icon.Hide()
}
r.entry.sel.Hidden = !r.entry.focused
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)
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, objects,
provider, placeholder, e,
}
r.updateScrollDirections()
r.Layout(e.Size())
return r
}
// DragEnd is called at end of a drag event.
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.
func (e *entryContent) Dragged(d *fyne.DragEvent) {
e.entry.Dragged(d)
}
var _ fyne.WidgetRenderer = (*entryContentRenderer)(nil)
type entryContentRenderer struct {
cursor *canvas.Rectangle
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 {
r.content.Theme() // setup theme cache before locking
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 {
// Objects are generated dynamically force selection rectangles to appear underneath the text
if r.content.entry.sel.selecting {
return append([]fyne.CanvasObject{r.content.entry.sel}, r.objects...)
}
return r.objects
}
func (r *entryContentRenderer) Refresh() {
provider := r.content.entry.textProvider()
placeholder := r.content.entry.placeholderProvider()
focused := r.content.entry.focused
focusedAppearance := focused && !r.content.entry.Disabled()
r.updateScrollDirections()
if provider.len() == 0 {
placeholder.Show()
} else if placeholder.Visible() {
placeholder.Hide()
}
th := r.content.entry.Theme()
if focusedAppearance {
settings := fyne.CurrentApp().Settings()
if settings.ShowAnimations() {
r.content.entry.cursorAnim.start()
} else {
r.cursor.FillColor = th.Color(theme.ColorNamePrimary, settings.ThemeVariant())
}
r.cursor.Show()
} else {
r.content.entry.cursorAnim.stop()
r.cursor.Hide()
}
r.moveCursor()
canvas.Refresh(r.content)
}
func (r *entryContentRenderer) ensureCursorVisible() {
th := r.content.entry.Theme()
lineSpace := th.Size(theme.SizeNameLineSpacing)
letter := fyne.MeasureText("e", th.Size(theme.SizeNameText), r.content.entry.TextStyle)
padX := letter.Width*2 + lineSpace
padY := letter.Height - lineSpace
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.ScrollToOffset(r.content.scroll.Offset.Add(move))
}
}
func (r *entryContentRenderer) moveCursor() {
// build r.selection[] if the user has made a selection
r.content.entry.sel.Refresh()
th := r.content.entry.Theme()
textSize := th.Size(theme.SizeNameText)
inputBorder := th.Size(theme.SizeNameInputBorder)
lineHeight := r.content.entry.text.charMinSize(r.content.entry.Password, r.content.entry.TextStyle, textSize).Height
r.cursor.Resize(fyne.NewSize(inputBorder, lineHeight))
r.cursor.Move(r.content.entry.CursorPosition())
callback := r.content.entry.OnCursorChanged
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.TextWrap(fyne.TextTruncateClip): // 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 this rune is a typical word separator then classify it as whitespace
if isWordSeparator(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 := -1
if endCheck != -1 {
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
}
func isWordSeparator(r rune) bool {
return unicode.IsSpace(r) ||
strings.ContainsRune("`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?", r)
}
// entryUndoAction represents a single user action that can be undone
type entryUndoAction interface {
Undo(string) string
Redo(string) string
}
// entryMergeableUndoAction is like entryUndoAction, but the undoStack
// can try to merge it with the next action (see TryMerge).
// This is useful because it allows grouping together actions like
// entering every single characters in a word. We don't want to have to
// undo every single character addition.
type entryMergeableUndoAction interface {
entryUndoAction
// TryMerge attempts to merge the current action
// with the next action. It returns true if successful.
// If it fails, the undoStack will simply add the next
// item without merging.
TryMerge(next entryMergeableUndoAction) bool
}
// Declare conformity with entryMergeableUndoAction interface
var _ entryMergeableUndoAction = (*entryModifyAction)(nil)
// entryModifyAction implements entryMergeableUndoAction.
// It represents the insertion/deletion of a single string at a
// position (e.g. "Hello" => "Hello, world", or "Hello" => "He").
type entryModifyAction struct {
// Delete is true if this action deletes Text, and false if it inserts Text
Delete bool
// Position represents the start position of Text
Position int
// Text is the text that is inserted or deleted at Position
Text []rune
}
func (i *entryModifyAction) Undo(s string) string {
if i.Delete {
return i.add(s)
} else {
return i.sub(s)
}
}
func (i *entryModifyAction) Redo(s string) string {
if i.Delete {
return i.sub(s)
} else {
return i.add(s)
}
}
// Inserts Text
func (i *entryModifyAction) add(s string) string {
runes := []rune(s)
return string(runes[:i.Position]) + string(i.Text) + string(runes[i.Position:])
}
// Deletes Text
func (i *entryModifyAction) sub(s string) string {
runes := []rune(s)
return string(runes[:i.Position]) + string(runes[i.Position+len(i.Text):])
}
func (i *entryModifyAction) TryMerge(other entryMergeableUndoAction) bool {
if other, ok := other.(*entryModifyAction); ok {
// Don't merge two different types of modifyAction
if i.Delete != other.Delete {
return false
}
// Don't merge two separate words
wordSeparators := func(s []rune) (num int, onlyWordSeparators bool) {
onlyWordSeparators = true
for _, r := range s {
if isWordSeparator(r) {
num++
onlyWordSeparators = false
}
}
return num, onlyWordSeparators
}
selfNumWS, _ := wordSeparators(i.Text)
otherNumWS, otherOnlyWS := wordSeparators(other.Text)
if !((selfNumWS == 0 && otherNumWS == 0) ||
(selfNumWS > 0 && otherOnlyWS)) {
return false
}
if i.Delete {
if i.Position == other.Position+len(other.Text) {
i.Position = other.Position
i.Text = append(other.Text, i.Text...)
return true
}
} else {
if i.Position+len(i.Text) == other.Position {
i.Text = append(i.Text, other.Text...)
return true
}
}
return false
}
return false
}
// entryUndoStack stores the information necessary for textual undo/redo functionality.
type entryUndoStack struct {
// items is the stack for storing the history of user actions.
items []entryUndoAction
// index is the size of the current effective undo stack.
// items[index-1] and below are the possible undo actions.
// items[index] and above are the possible redo actions.
index int
}
// Applies the undo action to s and returns the result along with the action performed
func (u *entryUndoStack) Undo(s string) (newS string, action entryUndoAction) {
if !u.CanUndo() {
return s, nil
}
u.index--
action = u.items[u.index]
return action.Undo(s), action
}
// Applies the redo action to s and returns the result along with the action performed
func (u *entryUndoStack) Redo(s string) (newS string, action entryUndoAction) {
if !u.CanRedo() {
return s, nil
}
action = u.items[u.index]
res := action.Redo(s)
u.index++
return res, action
}
// Returns true if an undo action is available
func (u *entryUndoStack) CanUndo() bool {
return u.index != 0
}
// Returns true if an redo action is available
func (u *entryUndoStack) CanRedo() bool {
return u.index != len(u.items)
}
// Adds the action to the stack, which can later be undone by calling Undo()
func (u *entryUndoStack) Add(a entryUndoAction) {
u.items = u.items[:u.index]
u.items = append(u.items, a)
u.index++
}
// Tries to merge the action with the last item on the undo stack.
// If it can't be merged, it calls Add().
func (u *entryUndoStack) MergeOrAdd(a entryUndoAction) {
u.items = u.items[:u.index]
if u.index == 0 {
u.Add(a)
return
}
ma, ok := a.(entryMergeableUndoAction)
if !ok {
u.Add(a)
return
}
mprev, ok := u.items[u.index-1].(entryMergeableUndoAction)
if !ok {
u.Add(a)
return
}
if !mprev.TryMerge(ma) {
u.Add(a)
return
}
}
// Removes all items from the undo stack
func (u *entryUndoStack) Clear() {
u.items = nil
u.index = 0
}