aboutsummaryrefslogtreecommitdiff
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
parent848dceaef3578c874633611b81eae465c9255f52 (diff)
downloadmuhqs-game-03f09542930eb96a657a88e5b57bf47a95ac73da.tar.gz
muhqs-game-03f09542930eb96a657a88e5b57bf47a95ac73da.zip
improve text input handlingHEADmain
-rw-r--r--README.md2
-rw-r--r--go/activities/draft.go2
-rw-r--r--go/activities/sealed.go1
-rw-r--r--go/client/startMenu.go3
-rw-r--r--go/dummy-ui/main.go2
-rw-r--r--go/ui/collection.go15
-rw-r--r--go/ui/colors.go1
-rw-r--r--go/ui/textBox.go2
-rw-r--r--go/ui/textInput.go260
-rw-r--r--go/ui/textInput_generic.go15
-rw-r--r--go/ui/textInput_wasm.go49
11 files changed, 248 insertions, 104 deletions
diff --git a/README.md b/README.md
index 7a4577ee..14d60358 100644
--- a/README.md
+++ b/README.md
@@ -116,6 +116,8 @@ Things that should be done eventually:
- [ ] add more tests
- [ ] investigate data race between UI and game loop (possible big state lock)
- [ ] improve UI
+ - [ ] TextInput: follow selection
+ - [ ] TextInput: Fix mobile WASM double tap to redraw
- [ ] implement representation of next target and current selection in prompt
- [ ] implement game log
- [ ] implement log with hoverable components
diff --git a/go/activities/draft.go b/go/activities/draft.go
index 7e7eb2c6..969ce0b5 100644
--- a/go/activities/draft.go
+++ b/go/activities/draft.go
@@ -57,6 +57,7 @@ func NewLocalDraft(width, height int, playerName string) *Draft {
DRAFT_BUTTON_WIDTH*3,
DRAFT_BUTTON_HEIGHT,
"base,magic,equipments",
+ false,
)
d.AddWidget(d.setInput)
@@ -74,6 +75,7 @@ func NewLocalDraft(width, height int, playerName string) *Draft {
DRAFT_BUTTON_WIDTH,
DRAFT_BUTTON_HEIGHT,
"3x[2;8]",
+ false,
)
d.AddWidget(d.descInput)
diff --git a/go/activities/sealed.go b/go/activities/sealed.go
index 1399e144..f5dd24e8 100644
--- a/go/activities/sealed.go
+++ b/go/activities/sealed.go
@@ -42,6 +42,7 @@ func NewSealed(width, height int) *Sealed {
SEALED_BUTTON_WIDTH,
SEALED_BUTTON_HEIGHT,
"base,equipments,magic",
+ false,
)
s.AddWidget(s.setInput)
diff --git a/go/client/startMenu.go b/go/client/startMenu.go
index ff23e826..54c47196 100644
--- a/go/client/startMenu.go
+++ b/go/client/startMenu.go
@@ -44,6 +44,7 @@ func (m *startMenu) build() {
DECK_LIST_WIDTH,
DECK_LIST_HEIGHT,
"deck",
+ true,
)
deckInput.Bg(ui.Gray)
@@ -68,6 +69,7 @@ func (m *startMenu) build() {
START_BUTTON_WIDTH,
START_BUTTON_HEIGHT,
"map",
+ false,
)
if m.mapPath != "" {
@@ -90,6 +92,7 @@ func (m *startMenu) build() {
START_BUTTON_WIDTH,
START_BUTTON_HEIGHT,
"player name",
+ false,
)
if m.playerName != "" {
diff --git a/go/dummy-ui/main.go b/go/dummy-ui/main.go
index fd936466..4fe8726e 100644
--- a/go/dummy-ui/main.go
+++ b/go/dummy-ui/main.go
@@ -29,7 +29,7 @@ func (a *app) Update() error {
}
func (a *app) build() {
- tI := ui.NewTextInput(100, 100, 200, 200, "i")
+ tI := ui.NewTextInput(100, 100, 200, 200, "i", false)
tI.Bg(color.White).Fg(color.Black).Centering(true)
a.AddWidget(tI)
diff --git a/go/ui/collection.go b/go/ui/collection.go
index 3de52e11..662d1bbf 100644
--- a/go/ui/collection.go
+++ b/go/ui/collection.go
@@ -135,11 +135,6 @@ func (c *Collection) switchFocus(fcsbl Widget) {
}
}
-func passInput(ev InputEvent, textInput *TextInput) {
- textInput.AddInput(ev.Ctx.(TextCtx).in)
- textInput.HandleKey()
-}
-
func (c *Collection) IsUpdatable() bool {
return true
}
@@ -180,10 +175,6 @@ func (c *Collection) Update() error {
ConsumeInput(i)
}
case Text:
- if txt, ok := w.(*TextInput); ok {
- passInput(ev, txt)
- ConsumeInput(i)
- }
case HoverEnd:
if c.hovering != nil {
c.hovering.Hover(false, ev.X, ev.Y)
@@ -195,12 +186,6 @@ func (c *Collection) Update() error {
c.hovering = w
}
}
- // handle focused text input
- } else if ev.Kind == Text {
- if txt, ok := c.focused.(*TextInput); ok {
- passInput(ev, txt)
- ConsumeInput(i)
- }
}
}
diff --git a/go/ui/colors.go b/go/ui/colors.go
index e647b1fc..6da64320 100644
--- a/go/ui/colors.go
+++ b/go/ui/colors.go
@@ -8,6 +8,7 @@ var (
Gray = color.RGBA{0x80, 0x80, 0x80, 0xff}
ResourceBg = color.RGBA{230, 180, 128, 0xff}
+ FocusHintColor = color.RGBA{0x00, 0x00, 0xff, 0xff}
HighlightSelectionColor = color.RGBA{0xc7, 0x89, 0x33, 0xff}
HighlightOptionColor = color.RGBA{0xc7, 0x52, 0x33, 0xff}
HighlightMovementColor = color.RGBA{27, 59, 217, 0x80}
diff --git a/go/ui/textBox.go b/go/ui/textBox.go
index 85580816..dd488c40 100644
--- a/go/ui/textBox.go
+++ b/go/ui/textBox.go
@@ -211,7 +211,7 @@ func NewNumberInput(x, y int, height, width int, number int) *NumberInput {
ni := &NumberInput{
n: number,
}
- ni.InitTextInput(x, y, width, height, strconv.Itoa(number))
+ ni.InitTextInput(x, y, width, height, strconv.Itoa(number), false)
return ni
}
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
+}
diff --git a/go/ui/textInput_generic.go b/go/ui/textInput_generic.go
deleted file mode 100644
index 7d1004ff..00000000
--- a/go/ui/textInput_generic.go
+++ /dev/null
@@ -1,15 +0,0 @@
-//go:build !wasm
-
-package ui
-
-func (ti *TextInput) Focus(focus bool) {
- ti.focused = focus
-}
-
-func (*TextInput) IsUpdatable() bool { return true }
-func (ti *TextInput) Update() error {
- if ti.focused {
- }
-
- return nil
-}
diff --git a/go/ui/textInput_wasm.go b/go/ui/textInput_wasm.go
deleted file mode 100644
index 0ced3ac4..00000000
--- a/go/ui/textInput_wasm.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package ui
-
-import (
- "log"
- "syscall/js"
- "unicode/utf8"
-)
-
-// rmueller's version from:
-// ://stackoverflow.com/questions/1752414/how-to-reverse-a-string-in-go?page=1&tab=scoredesc#tab-top
-func reverseString(s string) string {
- size := len(s)
- buf := make([]byte, size)
- for start := 0; start < size; {
- r, n := utf8.DecodeRuneInString(s[start:])
- start += n
- utf8.EncodeRune(buf[size-start:], r)
- }
- return string(buf)
-}
-
-func (ti *TextInput) Focus(focus bool) {
- ti.focused = focus
- log.Printf("%v set focus: %v\n", ti, focus)
- if focus {
- d := js.Global().Get("document")
- i := d.Call("getElementById", "hiddenInput")
- // i.Call("focus", map[string]any{"preventScroll": true, "focusVisible": false})
- i.Call("focus")
- log.Println("open keyboard")
-
- log.Printf("set hidden input value to %s", ti.Text())
- // i.Set("value", ti.Text())
- i.Set("value", reverseString(ti.Text()))
- }
-}
-
-func (ti *TextInput) Update() error {
- if ti.focused {
- d := js.Global().Get("document")
- i := d.Call("getElementById", "hiddenInput")
- // t := i.Get("value").String()
- t := reverseString(i.Get("value").String())
- ti.SetInput(t)
- }
- return nil
-}
-
-func (*TextInput) IsUpdatable() bool { return true }