aboutsummaryrefslogtreecommitdiff
path: root/go/ui/textInput.go
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2025-10-18 14:44:54 +0200
committerFlorian Fischer <florian.fischer@muhq.space>2025-10-18 14:47:21 +0200
commit03f09542930eb96a657a88e5b57bf47a95ac73da (patch)
tree2b3de603b4ad25471da76dc04ce51a5e7f59f8c2 /go/ui/textInput.go
parent848dceaef3578c874633611b81eae465c9255f52 (diff)
downloadmuhqs-game-main.tar.gz
muhqs-game-main.zip
improve text input handlingHEADmain
Diffstat (limited to 'go/ui/textInput.go')
-rw-r--r--go/ui/textInput.go260
1 files changed, 237 insertions, 23 deletions
diff --git a/go/ui/textInput.go b/go/ui/textInput.go
index 3a7939c8..60f749f3 100644
--- a/go/ui/textInput.go
+++ b/go/ui/textInput.go
@@ -1,67 +1,281 @@
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
- input string
- label string
- focused bool
+ label string
+ multiline bool
+ field textinput.Field
}
-func (ti *TextInput) InitTextInput(x, y int, width, height int, label string) {
+func (ti *TextInput) InitTextInput(x, y int, width, height int, label string, multiline bool) {
ti.TextBox = *(NewFixedTextBox(x, y, width, height, "").Centering(true))
- ti.input = ""
ti.label = label
+ ti.multiline = multiline
ti.renderImpl = func() *ebiten.Image {
- if ti.input == "" {
+ if ti.field.Text() == "" {
ti.text = ti.label
} else {
- ti.text = ti.input
+ 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)
}
- return ti.render()
+ // 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) *TextInput {
+func NewTextInput(x, y int, width, height int, label string, multiline bool) *TextInput {
w := &TextInput{}
- w.InitTextInput(x, y, width, height, label)
+ w.InitTextInput(x, y, width, height, label, multiline)
return w
}
-func (ti *TextInput) SetInput(input string) {
- ti.input = input
- ti.ForceRedraw()
+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 (ti *TextInput) AddInput(input []rune) {
- ti.SetInput(ti.input + string(input))
+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 (ti *TextInput) HandleKey() {
- if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
- if len(ti.input) > 0 {
- ti.input = ti.input[:len(ti.input)-1]
- ti.ForceRedraw()
+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.input
+ return ti.field.Text()
}
// return the text or use label as fallback
func (ti *TextInput) TextOrLabel() string {
- if ti.input != "" {
- return ti.input
+ 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
+}