diff options
| author | Florian Fischer <florian.fischer@muhq.space> | 2025-10-18 14:44:54 +0200 |
|---|---|---|
| committer | Florian Fischer <florian.fischer@muhq.space> | 2025-10-18 14:47:21 +0200 |
| commit | 03f09542930eb96a657a88e5b57bf47a95ac73da (patch) | |
| tree | 2b3de603b4ad25471da76dc04ce51a5e7f59f8c2 | |
| parent | 848dceaef3578c874633611b81eae465c9255f52 (diff) | |
| download | muhqs-game-03f09542930eb96a657a88e5b57bf47a95ac73da.tar.gz muhqs-game-03f09542930eb96a657a88e5b57bf47a95ac73da.zip | |
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | go/activities/draft.go | 2 | ||||
| -rw-r--r-- | go/activities/sealed.go | 1 | ||||
| -rw-r--r-- | go/client/startMenu.go | 3 | ||||
| -rw-r--r-- | go/dummy-ui/main.go | 2 | ||||
| -rw-r--r-- | go/ui/collection.go | 15 | ||||
| -rw-r--r-- | go/ui/colors.go | 1 | ||||
| -rw-r--r-- | go/ui/textBox.go | 2 | ||||
| -rw-r--r-- | go/ui/textInput.go | 260 | ||||
| -rw-r--r-- | go/ui/textInput_generic.go | 15 | ||||
| -rw-r--r-- | go/ui/textInput_wasm.go | 49 |
11 files changed, 248 insertions, 104 deletions
@@ -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 } |
