package ui import ( "image" "math" "strings" "unicode/utf8" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/exp/textinput" "github.com/hajimehoshi/ebiten/v2/inpututil" "github.com/hajimehoshi/ebiten/v2/text/v2" "github.com/hajimehoshi/ebiten/v2/vector" ) // TextInput is a widget allowing the user to input text. // It is based on the ebiten examples/textinput licensed under the Apache License, Version 2.0 type TextInput struct { TextBox label string multiline bool field textinput.Field } func (ti *TextInput) InitTextInput(x, y int, width, height int, label string, multiline bool) { ti.TextBox = *(NewFixedTextBox(x, y, width, height, "").Centering(true)) ti.label = label ti.multiline = multiline ti.renderImpl = func() *ebiten.Image { if ti.field.Text() == "" { ti.text = ti.label } else { ti.text = ti.field.TextForRendering() } img := ti.render() // Draw focus hint if ti.field.IsFocused() { vector.StrokeRect(img, float32(0), float32(0), float32(ti.Width), float32(ti.Height), 1, FocusHintColor, false) } // Draw the cursor selectionStart, _ := ti.field.Selection() if ti.field.IsFocused() && selectionStart >= 0 { _cx, _cy := ti.cursorPos() cx, cy := float64(_cx), float64(_cy) w, h := text.Measure(ti.text, ti.font, ti.font.Size*ti.lineSpacing) if ti.center { cx += (float64(ti.Width) - w) / 2 } else { cx += float64(ti.XMargin) } cy += (float64(ti.Height) - h) / 2 fMetrics := ti.font.Metrics() ch := fMetrics.HLineGap + fMetrics.HAscent + fMetrics.HDescent vector.StrokeLine(img, float32(cx), float32(cy), float32(cx), float32(cy+ch), 1, ti.fg, false) } return img } } func NewTextInput(x, y int, width, height int, label string, multiline bool) *TextInput { w := &TextInput{} w.InitTextInput(x, y, width, height, label, multiline) return w } func (t *TextInput) SetSelectionStartByCursorPosition(x, y int) bool { idx, ok := t.textIndexByCursorPosition(x, y) if !ok { return false } t.field.SetSelection(idx, idx) return true } func (t *TextInput) textIndexByCursorPosition(x, y int) (int, bool) { if !t.Contains(x, y) { return 0, false } x -= t.X y -= t.Y px, py := t.XMargin, t.YMargin x -= px y -= py if x < 0 { x = 0 } if y < 0 { y = 0 } fMetrics := t.font.Metrics() lineSpacingInPixels := int(fMetrics.HLineGap + fMetrics.HAscent + fMetrics.HDescent) var nlCount int var lineStart int var prevAdvance float64 txt := t.field.Text() for i, r := range txt { var x0, x1 int currentAdvance := text.Advance(txt[lineStart:i], t.font) if lineStart < i { x0 = int((prevAdvance + currentAdvance) / 2) } if r == '\n' { x1 = int(math.MaxInt32) } else if i < len(txt) { nextI := i + 1 for !utf8.ValidString(txt[i:nextI]) { nextI++ } nextAdvance := text.Advance(txt[lineStart:nextI], t.font) x1 = int((currentAdvance + nextAdvance) / 2) } else { x1 = int(currentAdvance) } if x0 <= x && x < x1 && nlCount*lineSpacingInPixels <= y && y < (nlCount+1)*lineSpacingInPixels { return i, true } prevAdvance = currentAdvance if r == '\n' { nlCount++ lineStart = i + 1 prevAdvance = 0 } } return len(txt), true } func (t *TextInput) cursorPos() (int, int) { var nlCount int lastNLPos := -1 txt := t.field.TextForRendering() selectionStart, _ := t.field.Selection() if s, _, ok := t.field.CompositionSelection(); ok { selectionStart += s } txt = txt[:selectionStart] for i, r := range txt { if r == '\n' { nlCount++ lastNLPos = i } } txt = txt[lastNLPos+1:] x := int(text.Advance(txt, t.font)) fMetrics := t.font.Metrics() y := nlCount * int((fMetrics.HLineGap+fMetrics.HAscent+fMetrics.HDescent)*t.lineSpacing) return x, y } // SetTextAndSelection sets the text and selection of the underlying TextInput field and redraws the widget. func (t *TextInput) SetTextAndSelection(text string, selectionStartInBytes, selectionEndInBytes int) { t.field.SetTextAndSelection(text, selectionStartInBytes, selectionEndInBytes) t.ForceRedraw() } // SetInput replaces the text and sets the selection to the end. func (t *TextInput) SetInput(input string) { t.SetTextAndSelection(input, len(input), len(input)) } // AddInput adds input at the current selection and move the the selection. func (t *TextInput) AddInput(input []rune) { text := t.field.Text() selStart, selEnd := t.field.Selection() text = text[:selStart] + string(input) + text[selEnd:] t.SetTextAndSelection(text, selStart+len(input), selEnd+len(input)) } func (ti *TextInput) Text() string { return ti.field.Text() } // return the text or use label as fallback func (ti *TextInput) TextOrLabel() string { if input := ti.field.Text(); input != "" { return input } return ti.label } func (*TextInput) IsFocusable() bool { return true } func (ti *TextInput) Focus(focus bool) { if focus { ti.field.Focus() } else { ti.field.Blur() } ti.ForceRedraw() } func (*TextInput) IsUpdatable() bool { return true } func (t *TextInput) Update() error { if !t.field.IsFocused() { return nil } x, y := t.X, t.Y cx, cy := t.cursorPos() px, py := t.XMargin, t.YMargin x0 := x + cx + px x1 := x0 + 1 y0 := y + cy + py fMetrics := t.font.Metrics() y1 := y0 + int(fMetrics.HLineGap+fMetrics.HAscent+fMetrics.HDescent) handled, err := t.field.HandleInputWithBounds(image.Rect(x0, y0, x1, y1)) if err != nil { return err } if handled { t.ForceRedraw() return nil } switch { case inpututil.IsKeyJustPressed(ebiten.KeyEnter): if t.multiline { text := t.field.Text() selectionStart, selectionEnd := t.field.Selection() text = text[:selectionStart] + "\n" + text[selectionEnd:] selectionStart += len("\n") selectionEnd = selectionStart t.SetTextAndSelection(text, selectionStart, selectionEnd) } case inpututil.IsKeyJustPressed(ebiten.KeyBackspace): text := t.field.Text() selectionStart, selectionEnd := t.field.Selection() if selectionStart != selectionEnd { text = text[:selectionStart] + text[selectionEnd:] } else if selectionStart > 0 { // TODO: Remove a grapheme instead of a code point. _, l := utf8.DecodeLastRuneInString(text[:selectionStart]) text = text[:selectionStart-l] + text[selectionEnd:] selectionStart -= l } selectionEnd = selectionStart t.SetTextAndSelection(text, selectionStart, selectionEnd) case inpututil.IsKeyJustPressed(ebiten.KeyLeft): text := t.field.Text() selectionStart, _ := t.field.Selection() if selectionStart > 0 { // TODO: Remove a grapheme instead of a code point. _, l := utf8.DecodeLastRuneInString(text[:selectionStart]) selectionStart -= l } t.SetTextAndSelection(text, selectionStart, selectionStart) case inpututil.IsKeyJustPressed(ebiten.KeyRight): text := t.field.Text() _, selectionEnd := t.field.Selection() if selectionEnd < len(text) { // TODO: Remove a grapheme instead of a code point. _, l := utf8.DecodeRuneInString(text[selectionEnd:]) selectionEnd += l } t.SetTextAndSelection(text, selectionEnd, selectionEnd) } if !t.multiline { orig := t.field.Text() new := strings.ReplaceAll(orig, "\n", "") if new != orig { selectionStart, selectionEnd := t.field.Selection() selectionStart -= strings.Count(orig[:selectionStart], "\n") selectionEnd -= strings.Count(orig[:selectionEnd], "\n") t.field.SetSelection(selectionStart, selectionEnd) } } return nil }