From 4b1c0526a7cda62d26fd48f0a1aa6a5dda3392e3 Mon Sep 17 00:00:00 2001 From: Hajime Hoshi Date: Sun, 4 Feb 2024 15:48:22 +0900 Subject: [PATCH] exp/textinput: add Field Closes #2827 --- examples/textinput/main.go | 217 +++++++++++--------------------- exp/textinput/field.go | 246 +++++++++++++++++++++++++++++++++++++ exp/textinput/textinput.go | 4 + 3 files changed, 322 insertions(+), 145 deletions(-) create mode 100644 exp/textinput/field.go diff --git a/examples/textinput/main.go b/examples/textinput/main.go index 81081658e..83d82d1cc 100644 --- a/examples/textinput/main.go +++ b/examples/textinput/main.go @@ -40,16 +40,9 @@ const ( ) type TextField struct { - bounds image.Rectangle - multilines bool - text string - selectionStart int - selectionEnd int - focused bool - - ch chan textinput.State - end func() - state textinput.State + bounds image.Rectangle + multilines bool + field textinput.Field } func NewTextField(bounds image.Rectangle, multilines bool) *TextField { @@ -63,17 +56,13 @@ func (t *TextField) Contains(x, y int) bool { return image.Pt(x, y).In(t.bounds) } -func (t *TextField) SetSelectionStartByCursorPosition(x, y int) (bool, error) { - if err := t.cleanUp(); err != nil { - return false, err - } +func (t *TextField) SetSelectionStartByCursorPosition(x, y int) bool { idx, ok := t.textIndexByCursorPosition(x, y) if !ok { - return false, nil + return false } - t.selectionStart = idx - t.selectionEnd = idx - return true, nil + t.field.SetSelection(idx, idx) + return true } func (t *TextField) textIndexByCursorPosition(x, y int) (int, bool) { @@ -97,20 +86,21 @@ func (t *TextField) textIndexByCursorPosition(x, y int) (int, bool) { var nlCount int var lineStart int var prevAdvance float64 - for i, r := range t.text { + txt := t.field.Text() + for i, r := range txt { var x0, x1 int - currentAdvance := text.Advance(t.text[lineStart:i], fontFace) + currentAdvance := text.Advance(txt[lineStart:i], fontFace) if lineStart < i { x0 = int((prevAdvance + currentAdvance) / 2) } if r == '\n' { x1 = int(math.MaxInt32) - } else if i < len(t.text) { + } else if i < len(txt) { nextI := i + 1 - for !utf8.ValidString(t.text[i:nextI]) { + for !utf8.ValidString(txt[i:nextI]) { nextI++ } - nextAdvance := text.Advance(t.text[lineStart:nextI], fontFace) + nextAdvance := text.Advance(txt[lineStart:nextI], fontFace) x1 = int((currentAdvance + nextAdvance) / 2) } else { x1 = int(currentAdvance) @@ -127,146 +117,86 @@ func (t *TextField) textIndexByCursorPosition(x, y int) (int, bool) { } } - return len(t.text), true + return len(txt), true } func (t *TextField) Focus() { - t.focused = true + t.field.Focus() } func (t *TextField) Blur() { - t.focused = false -} - -func (t *TextField) cleanUp() error { - if t.ch != nil { - select { - case state, ok := <-t.ch: - if state.Error != nil { - return state.Error - } - if ok && state.Committed { - t.text = t.text[:t.selectionStart] + state.Text + t.text[t.selectionEnd:] - t.selectionStart += len(state.Text) - t.selectionEnd = t.selectionStart - t.state = textinput.State{} - } - t.state = state - default: - break - } - } - if t.end != nil { - t.end() - t.ch = nil - t.end = nil - t.state = textinput.State{} - } - return nil + t.field.Blur() } func (t *TextField) Update() error { - if !t.focused { - // If the text field still has a session, read the last state and process it just in case. - if err := t.cleanUp(); err != nil { - return err - } + if !t.field.IsFocused() { return nil } - var processed bool - - // Text inputting can happen multiple times in one tick (1/60[s] by default). - // Handle all of them. - for { - if t.ch == nil { - x, y := t.bounds.Min.X, t.bounds.Min.Y - cx, cy := t.cursorPos() - px, py := textFieldPadding() - x += cx + px - y += cy + py + int(fontFace.Metrics().HAscent) - t.ch, t.end = textinput.Start(x, y) - // Start returns nil for non-supported envrionments. - if t.ch == nil { - return nil - } - } - - readchar: - for { - select { - case state, ok := <-t.ch: - if state.Error != nil { - return state.Error - } - processed = true - if !ok { - t.ch = nil - t.end = nil - t.state = textinput.State{} - break readchar - } - if state.Committed { - t.text = t.text[:t.selectionStart] + state.Text + t.text[t.selectionEnd:] - t.selectionStart += len(state.Text) - t.selectionEnd = t.selectionStart - t.state = textinput.State{} - continue - } - t.state = state - default: - break readchar - } - } - - if t.ch == nil { - continue - } - - break + x, y := t.bounds.Min.X, t.bounds.Min.Y + cx, cy := t.cursorPos() + px, py := textFieldPadding() + x += cx + px + y += cy + py + int(fontFace.Metrics().HAscent) + handled, err := t.field.HandleInput(x, y) + if err != nil { + return err } - - if processed { + if handled { return nil } switch { case inpututil.IsKeyJustPressed(ebiten.KeyEnter): if t.multilines { - t.text = t.text[:t.selectionStart] + "\n" + t.text[t.selectionEnd:] - t.selectionStart += 1 - t.selectionEnd = t.selectionStart + text := t.field.Text() + selectionStart, selectionEnd := t.field.Selection() + text = text[:selectionStart] + "\n" + text[selectionEnd:] + selectionStart += len("\n") + selectionEnd = selectionStart + t.field.SetTextAndSelection(text, selectionStart, selectionEnd) } case inpututil.IsKeyJustPressed(ebiten.KeyBackspace): - if t.selectionStart > 0 { + 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(t.text[:t.selectionStart]) - t.text = t.text[:t.selectionStart-l] + t.text[t.selectionEnd:] - t.selectionStart -= l + _, l := utf8.DecodeLastRuneInString(text[:selectionStart]) + text = text[:selectionStart-l] + text[selectionEnd:] + selectionStart -= l } - t.selectionEnd = t.selectionStart + selectionEnd = selectionStart + t.field.SetTextAndSelection(text, selectionStart, selectionEnd) case inpututil.IsKeyJustPressed(ebiten.KeyLeft): - if t.selectionStart > 0 { + text := t.field.Text() + selectionStart, _ := t.field.Selection() + if selectionStart > 0 { // TODO: Remove a grapheme instead of a code point. - _, l := utf8.DecodeLastRuneInString(t.text[:t.selectionStart]) - t.selectionStart -= l + _, l := utf8.DecodeLastRuneInString(text[:selectionStart]) + selectionStart -= l } - t.selectionEnd = t.selectionStart + t.field.SetTextAndSelection(text, selectionStart, selectionStart) case inpututil.IsKeyJustPressed(ebiten.KeyRight): - if t.selectionEnd < len(t.text) { + text := t.field.Text() + _, selectionEnd := t.field.Selection() + if selectionEnd < len(text) { // TODO: Remove a grapheme instead of a code point. - _, l := utf8.DecodeRuneInString(t.text[t.selectionEnd:]) - t.selectionEnd += l + _, l := utf8.DecodeRuneInString(text[selectionEnd:]) + selectionEnd += l } - t.selectionStart = t.selectionEnd + t.field.SetTextAndSelection(text, selectionEnd, selectionEnd) } if !t.multilines { - orig := t.text + orig := t.field.Text() new := strings.ReplaceAll(orig, "\n", "") if new != orig { - t.selectionStart -= strings.Count(orig[:t.selectionStart], "\n") - t.selectionEnd -= strings.Count(orig[:t.selectionEnd], "\n") + selectionStart, selectionEnd := t.field.Selection() + selectionStart -= strings.Count(orig[:selectionStart], "\n") + selectionEnd -= strings.Count(orig[:selectionEnd], "\n") + t.field.SetSelection(selectionStart, selectionEnd) } } @@ -276,17 +206,20 @@ func (t *TextField) Update() error { func (t *TextField) cursorPos() (int, int) { var nlCount int lastNLPos := -1 - for i, r := range t.text[:t.selectionStart] { + 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 := t.text[lastNLPos+1 : t.selectionStart] - if t.state.Text != "" { - txt += t.state.Text[:t.state.CompositionSelectionStartInBytes] - } + txt = txt[lastNLPos+1:] x := int(text.Advance(txt, fontFace)) y := nlCount * int(fontFace.Metrics().HLineGap+fontFace.Metrics().HAscent+fontFace.Metrics().HDescent) return x, y @@ -295,13 +228,14 @@ func (t *TextField) cursorPos() (int, int) { func (t *TextField) Draw(screen *ebiten.Image) { vector.DrawFilledRect(screen, float32(t.bounds.Min.X), float32(t.bounds.Min.Y), float32(t.bounds.Dx()), float32(t.bounds.Dy()), color.White, false) var clr color.Color = color.Black - if t.focused { + if t.field.IsFocused() { clr = color.RGBA{0, 0, 0xff, 0xff} } vector.StrokeRect(screen, float32(t.bounds.Min.X), float32(t.bounds.Min.Y), float32(t.bounds.Dx()), float32(t.bounds.Dy()), 1, clr, false) px, py := textFieldPadding() - if t.focused && t.selectionStart >= 0 { + selectionStart, _ := t.field.Selection() + if t.field.IsFocused() && selectionStart >= 0 { x, y := t.bounds.Min.X, t.bounds.Min.Y cx, cy := t.cursorPos() x += px + cx @@ -310,18 +244,13 @@ func (t *TextField) Draw(screen *ebiten.Image) { vector.StrokeLine(screen, float32(x), float32(y), float32(x), float32(y+h), 1, color.Black, false) } - shownText := t.text - if t.focused && t.state.Text != "" { - shownText = t.text[:t.selectionStart] + t.state.Text + t.text[t.selectionEnd:] - } - tx := t.bounds.Min.X + px ty := t.bounds.Min.Y + py op := &text.DrawOptions{} op.GeoM.Translate(float64(tx), float64(ty)) op.ColorScale.ScaleWithColor(color.Black) op.LineSpacing = fontFace.Metrics().HLineGap + fontFace.Metrics().HAscent + fontFace.Metrics().HDescent - text.Draw(screen, shownText, fontFace, op) + text.Draw(screen, t.field.TextForRendering(), fontFace, op) } const textFieldHeight = 24 @@ -353,9 +282,7 @@ func (g *Game) Update() error { for _, tf := range g.textFields { if tf.Contains(x, y) { tf.Focus() - if _, err := tf.SetSelectionStartByCursorPosition(x, y); err != nil { - return err - } + tf.SetSelectionStartByCursorPosition(x, y) } else { tf.Blur() } diff --git a/exp/textinput/field.go b/exp/textinput/field.go new file mode 100644 index 000000000..a8618b1d5 --- /dev/null +++ b/exp/textinput/field.go @@ -0,0 +1,246 @@ +// Copyright 2024 The Ebitengine Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package textinput + +import ( + "sync" +) + +var ( + theFocusedField *Field + theFocusedFieldM sync.Mutex +) + +func focusField(f *Field) { + var origField *Field + defer func() { + if origField != nil { + origField.cleanUp() + } + }() + + theFocusedFieldM.Lock() + defer theFocusedFieldM.Unlock() + if theFocusedField == f { + return + } + origField = theFocusedField + theFocusedField = f +} + +func blurField(f *Field) { + var origField *Field + defer func() { + if origField != nil { + origField.cleanUp() + } + }() + + theFocusedFieldM.Lock() + defer theFocusedFieldM.Unlock() + if theFocusedField != f { + return + } + origField = theFocusedField + theFocusedField = nil +} + +func isFieldFocused(f *Field) bool { + theFocusedFieldM.Lock() + defer theFocusedFieldM.Unlock() + return theFocusedField == f +} + +// Field is a region accepting text inputting with IME. +// +// Field is not focused by default. You have to call Focus when you start text inputting. +// +// Field is a wrapper of the low-level API like Start. +// +// For an actual usage, see the examples "textinput". +type Field struct { + text string + selectionStart int + selectionEnd int + + ch chan State + end func() + state State + err error +} + +// HandleInput updates the field state. +// HandleInput must be called every tick, i.e., every HandleInput, when Field is focused. +// HandleInput takes a position where an IME window is shown if needed. +// +// HandleInput returns whether the text inputting is handled or not. +// If HandleInput returns true, a Field user should not handle further input events. +// +// HandleInput returns an error when handling input causes an error. +func (f *Field) HandleInput(x, y int) (handled bool, err error) { + if f.err != nil { + return false, f.err + } + if !f.IsFocused() { + return false, nil + } + + // Text inputting can happen multiple times in one tick (1/60[s] by default). + // Handle all of them. + for { + if f.ch == nil { + // TODO: On iOS Safari, Start doesn't work as expected (#2898). + // Handle a click event and focus the textarea there. + f.ch, f.end = Start(x, y) + // Start returns nil for non-supported envrionments. + if f.ch == nil { + return true, nil + } + } + + readchar: + for { + select { + case state, ok := <-f.ch: + if state.Error != nil { + f.err = state.Error + return false, f.err + } + handled = true + if !ok { + f.ch = nil + f.end = nil + f.state = State{} + break readchar + } + if state.Committed { + f.text = f.text[:f.selectionStart] + state.Text + f.text[f.selectionEnd:] + f.selectionStart += len(state.Text) + f.selectionEnd = f.selectionStart + f.state = State{} + continue + } + f.state = state + default: + break readchar + } + } + + if f.ch == nil { + continue + } + + break + } + + return +} + +// Focus focuses the field. +// A Field has to be focused to start text inputting. +// +// There can be only one Field that is focused at the same time. +// When Focus is called and there is already a focused field, Focus removes the focus of that. +func (f *Field) Focus() { + focusField(f) +} + +// Blur removes the focus from the field. +func (f *Field) Blur() { + blurField(f) +} + +// IsFocused reports whether the field is focused or not. +func (f *Field) IsFocused() bool { + return isFieldFocused(f) +} + +func (f *Field) cleanUp() { + if f.err != nil { + return + } + + // If the text field still has a session, read the last state and process it just in case. + if f.ch != nil { + select { + case state, ok := <-f.ch: + if state.Error != nil { + f.err = state.Error + return + } + if ok && state.Committed { + f.text = f.text[:f.selectionStart] + state.Text + f.text[f.selectionEnd:] + f.selectionStart += len(state.Text) + f.selectionEnd = f.selectionStart + f.state = State{} + } + f.state = state + default: + break + } + } + + if f.end != nil { + f.end() + f.ch = nil + f.end = nil + f.state = State{} + } +} + +// Selection returns the current selection range in bytes. +func (f *Field) Selection() (start, end int) { + return f.selectionStart, f.selectionEnd +} + +// CompositionSelection returns the current composition selection in bytes if a text is composited. +// If a text is not composited, this returns 0s and false. +// The returned values indicate relative positions in bytes where the current composition text's start is 0. +func (f *Field) CompositionSelection() (start, end int, ok bool) { + if f.IsFocused() && f.state.Text != "" { + return f.state.CompositionSelectionStartInBytes, f.state.CompositionSelectionEndInBytes, true + } + return 0, 0, false +} + +// SetSelection sets the selection range. +func (f *Field) SetSelection(start, end int) { + f.cleanUp() + f.selectionStart = start + f.selectionEnd = end +} + +// Text returns the current text. +// The returned value doesn't include compositing texts. +func (f *Field) Text() string { + return f.text +} + +// TextForRendering returns the text for rendering. +// The returned value includes compositing texts. +func (f *Field) TextForRendering() string { + if f.IsFocused() && f.state.Text != "" { + return f.text[:f.selectionStart] + f.state.Text + f.text[f.selectionEnd:] + } + return f.text +} + +// SetTextAndSelection sets the text and the selection range. +func (f *Field) SetTextAndSelection(text string, selectionStart, selectionEnd int) { + f.cleanUp() + f.text = text + f.selectionStart = selectionStart + f.selectionEnd = selectionEnd +} diff --git a/exp/textinput/textinput.go b/exp/textinput/textinput.go index fde6a1a4c..86d566c95 100644 --- a/exp/textinput/textinput.go +++ b/exp/textinput/textinput.go @@ -25,6 +25,8 @@ import ( ) // State represents the current state of text inputting. +// +// State is the low-level API. For most use cases, Field is easier to use. type State struct { // Text represents the current inputting text. Text string @@ -45,6 +47,8 @@ type State struct { // Start starts text inputting. // Start returns a channel to send the state repeatedly, and a function to end the text inputting. // +// Start is the low-leve API. For most use cases, Field is easier to use. +// // Start returns nil and nil if the current environment doesn't support this package. func Start(x, y int) (states chan State, close func()) { cx, cy := ui.Get().LogicalPositionToClientPositionInNativePixels(float64(x), float64(y))