diff --git a/examples/textinput/main.go b/examples/textinput/main.go new file mode 100644 index 000000000..b2f14a544 --- /dev/null +++ b/examples/textinput/main.go @@ -0,0 +1,357 @@ +// Copyright 2023 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 main + +import ( + "image" + "image/color" + "log" + "math" + "strings" + "unicode/utf8" + + "github.com/hajimehoshi/bitmapfont/v3" + "golang.org/x/image/font" + "golang.org/x/image/math/fixed" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/exp/textinput" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +var fontFace = bitmapfont.FaceEA + +const ( + screenWidth = 640 + screenHeight = 480 +) + +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 +} + +func NewTextField(bounds image.Rectangle, multilines bool) *TextField { + return &TextField{ + bounds: bounds, + multilines: multilines, + } +} + +func (t *TextField) Contains(x, y int) bool { + return image.Pt(x, y).In(t.bounds) +} + +func (t *TextField) SetSelectionStartByCursorPosition(x, y int) bool { + idx, ok := t.textIndexByCursorPosition(x, y) + if !ok { + return false + } + + t.selectionStart = idx + t.selectionEnd = idx + return true +} + +func (t *TextField) textIndexByCursorPosition(x, y int) (int, bool) { + if !t.Contains(x, y) { + return 0, false + } + + x -= t.bounds.Min.X + y -= t.bounds.Min.Y + px, py := textFieldPadding() + x -= px + y -= py + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + + lineHeight := fontFace.Metrics().Height.Ceil() + var nlCount int + var lineStart int + var prevAdvance fixed.Int26_6 + for i, r := range t.text { + var x0, x1 int + currentAdvance := font.MeasureString(fontFace, t.text[lineStart:i]) + if lineStart < i { + x0 = ((prevAdvance + currentAdvance) / 2).Ceil() + } + if r == '\n' { + x1 = int(math.MaxInt32) + } else if i < len(t.text) { + nextI := i + 1 + for !utf8.ValidString(t.text[i:nextI]) { + nextI++ + } + nextAdvance := font.MeasureString(fontFace, t.text[lineStart:nextI]) + x1 = ((currentAdvance + nextAdvance) / 2).Ceil() + } else { + x1 = currentAdvance.Ceil() + } + if x0 <= x && x < x1 && nlCount*lineHeight <= y && y < (nlCount+1)*lineHeight { + return i, true + } + prevAdvance = currentAdvance + + if r == '\n' { + nlCount++ + lineStart = i + 1 + prevAdvance = 0 + } + } + + return len(t.text), true +} + +func (t *TextField) Focus() { + t.focused = true +} + +func (t *TextField) Blur() { + t.focused = false +} + +func (t *TextField) Update() { + if !t.focused { + if t.end != nil { + t.end() + t.ch = nil + t.end = nil + t.state = textinput.State{} + } + return + } + + var processed bool + 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 + fontFace.Metrics().Ascent.Ceil() + t.ch, t.end = textinput.Start(x, y) + // Start returns nil for non-supported envrionments. + if t.ch == nil { + return + } + } + + readchar: + for { + select { + case state, ok := <-t.ch: + 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 + state = textinput.State{} + continue + } + t.state = state + default: + break readchar + } + } + + if t.ch == nil { + continue + } + + break + } + + if processed { + return + } + + 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 + } + case inpututil.IsKeyJustPressed(ebiten.KeyBackspace): + if t.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 + } + t.selectionEnd = t.selectionStart + case inpututil.IsKeyJustPressed(ebiten.KeyLeft): + if t.selectionStart > 0 { + // TODO: Remove a grapheme instead of a code point. + _, l := utf8.DecodeLastRuneInString(t.text[:t.selectionStart]) + t.selectionStart -= l + } + t.selectionEnd = t.selectionStart + case inpututil.IsKeyJustPressed(ebiten.KeyRight): + if t.selectionEnd < len(t.text) { + // TODO: Remove a grapheme instead of a code point. + _, l := utf8.DecodeRuneInString(t.text[t.selectionEnd:]) + t.selectionEnd += l + } + t.selectionStart = t.selectionEnd + } + + if !t.multilines { + orig := t.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") + } + } +} + +func (t *TextField) cursorPos() (int, int) { + var nlCount int + lastNLPos := -1 + for i, r := range t.text[:t.selectionStart] { + if r == '\n' { + nlCount++ + lastNLPos = i + } + } + + txt := t.text[lastNLPos+1 : t.selectionStart] + if t.state.Text != "" { + txt += t.state.Text[:t.state.CompositionSelectionStartInBytes] + } + x := font.MeasureString(fontFace, txt).Ceil() + y := nlCount * fontFace.Metrics().Height.Ceil() + return x, y +} + +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 { + 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 { + x, y := t.bounds.Min.X, t.bounds.Min.Y + cx, cy := t.cursorPos() + x += px + cx + y += py + cy + h := fontFace.Metrics().Height.Ceil() + 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 + fontFace.Metrics().Ascent.Ceil() + text.Draw(screen, shownText, fontFace, tx, ty, color.Black) +} + +const textFieldHeight = 24 + +func textFieldPadding() (int, int) { + m := fontFace.Metrics() + return 4, (textFieldHeight - m.Height.Ceil()) / 2 +} + +type Game struct { + textFields []*TextField +} + +func (g *Game) Update() error { + if g.textFields == nil { + g.textFields = append(g.textFields, NewTextField(image.Rect(16, 16, screenWidth-16, 16+textFieldHeight), false)) + g.textFields = append(g.textFields, NewTextField(image.Rect(16, 48, screenWidth-16, 48+textFieldHeight), false)) + g.textFields = append(g.textFields, NewTextField(image.Rect(16, 80, screenWidth-16, screenHeight-16), true)) + } + + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { + x, y := ebiten.CursorPosition() + for _, tf := range g.textFields { + if tf.Contains(x, y) { + tf.Focus() + tf.SetSelectionStartByCursorPosition(x, y) + } else { + tf.Blur() + } + } + } + + for _, tf := range g.textFields { + tf.Update() + } + + x, y := ebiten.CursorPosition() + var inTextField bool + for _, tf := range g.textFields { + if tf.Contains(x, y) { + inTextField = true + break + } + } + if inTextField { + ebiten.SetCursorShape(ebiten.CursorShapeText) + } else { + ebiten.SetCursorShape(ebiten.CursorShapeDefault) + } + + return nil +} + +func (g *Game) Draw(screen *ebiten.Image) { + screen.Fill(color.RGBA{0xcc, 0xcc, 0xcc, 0xff}) + for _, tf := range g.textFields { + tf.Draw(screen) + } +} + +func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { + return screenWidth, screenHeight +} + +func main() { + ebiten.SetWindowSize(screenWidth, screenHeight) + ebiten.SetWindowTitle("Text Input (Ebitengine Demo)") + if err := ebiten.RunGame(&Game{}); err != nil { + log.Fatal(err) + } +} diff --git a/exp/textinput/textinput.go b/exp/textinput/textinput.go new file mode 100644 index 000000000..b76d233b9 --- /dev/null +++ b/exp/textinput/textinput.go @@ -0,0 +1,87 @@ +// Copyright 2023 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 provides a text-inputting controller. +// This package is experimental and the API might be changed in the future. +// +// This package is supported by macOS and Web browsers so far. +package textinput + +import ( + "unicode/utf16" +) + +// State represents the current state of text inputting. +type State struct { + // Text represents the current inputting text. + Text string + + // CompositionSelectionStartInBytes represents the start position of the selection in bytes. + CompositionSelectionStartInBytes int + + // CompositionSelectionStartInBytes represents the end position of the selection in bytes. + CompositionSelectionEndInBytes int + + // Committed reports whether the current Text is the settled text. + Committed bool +} + +// Start starts text inputting. +// Start returns a channel to send the state repeatedly, and a function to end the text inputting. +// +// Start returns nil and nil if the current environment doesn't support this package. +func Start(x, y int) (states chan State, close func()) { + return theTextInput.Start(x, y) +} + +func convertUTF16CountToByteCount(text string, c int) int { + return len(string(utf16.Decode(utf16.Encode([]rune(text))[:c]))) +} + +type session struct { + ch chan State + done chan struct{} +} + +func newSession() *session { + return &session{ + ch: make(chan State, 1), + done: make(chan struct{}), + } +} + +func (s *session) end() { + if s.ch == nil { + return + } + close(s.ch) + s.ch = nil + close(s.done) +} + +func (s *session) trySend(state State) { + for { + select { + case s.ch <- state: + return + default: + // Only the last value matters. + select { + case <-s.ch: + case <-s.done: + return + } + } + } +} diff --git a/exp/textinput/textinput_darwin.go b/exp/textinput/textinput_darwin.go new file mode 100644 index 000000000..a1096aef6 --- /dev/null +++ b/exp/textinput/textinput_darwin.go @@ -0,0 +1,104 @@ +// Copyright 2023 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. + +//go:build !ios + +package textinput + +// TODO: Remove Cgo after ebitengine/purego#143 is resolved. + +// #cgo CFLAGS: -x objective-c +// #cgo LDFLAGS: -framework Cocoa +// +// #import +// +// @interface TextInputClient : NSView +// @end +// +// static TextInputClient* getTextInputClient() { +// static TextInputClient* textInputClient; +// if (!textInputClient) { +// textInputClient = [[TextInputClient alloc] init]; +// } +// return textInputClient; +// } +// +// static void start(int x, int y) { +// TextInputClient* textInputClient = getTextInputClient(); +// NSWindow* window = [[NSApplication sharedApplication] mainWindow]; +// [[window contentView] addSubview: textInputClient]; +// [window makeFirstResponder: textInputClient]; +// +// y = [[window contentView] frame].size.height - y - 4; +// [textInputClient setFrame:NSMakeRect(x, y, 1, 1)]; +// } +import "C" + +import ( + "github.com/hajimehoshi/ebiten/v2/internal/ui" +) + +type textInput struct { + // session must be accessed from the main thread. + session *session +} + +var theTextInput textInput + +func (t *textInput) Start(x, y int) (chan State, func()) { + var session *session + ui.RunOnMainThread(func() { + if t.session != nil { + t.session.end() + t.session = nil + } + C.start(C.int(x), C.int(y)) + session = newSession() + t.session = session + }) + return session.ch, session.end +} + +//export ebitengine_textinput_update +func ebitengine_textinput_update(text *C.char, start, end C.int, committed C.int) { + theTextInput.update(C.GoString(text), int(start), int(end), committed != 0) +} + +func (t *textInput) update(text string, start, end int, committed bool) { + if t.session != nil { + startInBytes := convertUTF16CountToByteCount(text, start) + endInBytes := convertUTF16CountToByteCount(text, end) + t.session.trySend(State{ + Text: text, + CompositionSelectionStartInBytes: startInBytes, + CompositionSelectionEndInBytes: endInBytes, + Committed: committed, + }) + } + if committed { + t.end() + } +} + +//export ebitengine_textinput_end +func ebitengine_textinput_end() { + theTextInput.end() +} + +func (t *textInput) end() { + if t.session != nil { + t.session.end() + t.session = nil + } +} diff --git a/exp/textinput/textinput_darwin.m b/exp/textinput/textinput_darwin.m new file mode 100644 index 000000000..6d0fe70c5 --- /dev/null +++ b/exp/textinput/textinput_darwin.m @@ -0,0 +1,100 @@ +// Copyright 2023 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. + +//go:build !ios + +#import + +void ebitengine_textinput_update(const char* text, int start, int end, BOOL committed); +void ebitengine_textinput_end(); + +@interface TextInputClient : NSView +{ + NSString* markedText_; + NSRange markedRange_; + NSRange selectedRange_; +} +@end + +@implementation TextInputClient + +- (BOOL)hasMarkedText { + return markedText_ != nil; +} + +- (NSRange)markedRange { + return markedRange_; +} + +- (NSRange)selectedRange { + return selectedRange_; +} + +- (void)setMarkedText:(id)string + selectedRange:(NSRange)selectedRange + replacementRange:(NSRange)replacementRange { + if ([string isKindOfClass:[NSAttributedString class]]) { + string = [string string]; + } + markedText_ = string; + selectedRange_ = selectedRange; + markedRange_ = NSMakeRange(0, [string length]); + ebitengine_textinput_update([string UTF8String], selectedRange.location, selectedRange.location + selectedRange.length, NO); +} + +- (void)unmarkText { + markedText_ = nil; +} + +- (NSArray *)validAttributesForMarkedText { + return @[]; +} + +- (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)range + actualRange:(NSRangePointer)actualRange { + return nil; +} + +- (void)insertText:(id)string + replacementRange:(NSRange)replacementRange { + if ([string isKindOfClass:[NSAttributedString class]]) { + string = [string string]; + } + if ([string length] == 1 && [string characterAtIndex:0] < 0x20) { + return; + } + ebitengine_textinput_update([string UTF8String], 0, [string length], YES); +} + +- (NSUInteger)characterIndexForPoint:(NSPoint)point { + return 0; +} + + +- (NSRect)firstRectForCharacterRange:(NSRange)range + actualRange:(NSRangePointer)actualRange { + NSWindow* window = [self window]; + return [window convertRectToScreen:[self frame]]; +} + +- (void)doCommandBySelector:(SEL)selector { + // Do nothing. +} + +- (BOOL)resignFirstResponder { + ebitengine_textinput_end(); + return [super resignFirstResponder]; +} + +@end diff --git a/exp/textinput/textinput_js.go b/exp/textinput/textinput_js.go new file mode 100644 index 000000000..0d8ed010f --- /dev/null +++ b/exp/textinput/textinput_js.go @@ -0,0 +1,151 @@ +// Copyright 2023 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 ( + "fmt" + "syscall/js" + + "github.com/hajimehoshi/ebiten/v2/internal/ui" +) + +var ( + document = js.Global().Get("document") + body = document.Get("body") +) + +func init() { + if !document.Truthy() { + return + } + theTextInput.init() +} + +type textInput struct { + textareaElement js.Value + + session *session +} + +var theTextInput textInput + +func (t *textInput) init() { + t.textareaElement = document.Call("createElement", "textarea") + t.textareaElement.Set("autocapitalize", "off") + t.textareaElement.Set("spellcheck", false) + t.textareaElement.Set("translate", "no") + t.textareaElement.Set("wrap", "off") + + style := t.textareaElement.Get("style") + style.Set("position", "absolute") + style.Set("left", "0") + style.Set("top", "0") + style.Set("opacity", "0") + style.Set("resize", "none") + style.Set("cursor", "normal") + style.Set("pointerEvents", "none") + style.Set("overflow", "hidden") + style.Set("tabindex", "-1") + style.Set("width", "1px") + style.Set("height", "1px") + + t.textareaElement.Call("addEventListener", "compositionend", js.FuncOf(func(this js.Value, args []js.Value) any { + t.trySend(true) + return nil + })) + t.textareaElement.Call("addEventListener", "focusout", js.FuncOf(func(this js.Value, args []js.Value) any { + if t.session != nil { + t.session.end() + t.session = nil + } + return nil + })) + t.textareaElement.Call("addEventListener", "keydown", js.FuncOf(func(this js.Value, args []js.Value) any { + e := args[0] + if e.Get("code").String() == "Tab" { + e.Call("preventDefault") + } + if !e.Get("isComposing").Bool() { + ui.UpdateInputFromEvent(e) + } + return nil + })) + t.textareaElement.Call("addEventListener", "keyup", js.FuncOf(func(this js.Value, args []js.Value) any { + e := args[0] + if !e.Get("isComposing").Bool() { + ui.UpdateInputFromEvent(e) + } + return nil + })) + t.textareaElement.Call("addEventListener", "input", js.FuncOf(func(this js.Value, args []js.Value) any { + e := args[0] + t.trySend(!e.Get("isComposing").Bool()) + return nil + })) + // TODO: What about other events like wheel? +} + +func (t *textInput) Start(x, y int) (chan State, func()) { + if !t.textareaElement.Truthy() { + return nil, nil + } + + if t.session != nil { + t.session.end() + t.session = nil + } + + if !body.Call("contains", t.textareaElement).Bool() { + body.Call("appendChild", t.textareaElement) + } + t.textareaElement.Set("value", "") + t.textareaElement.Call("focus") + + xf, yf := ui.LogicalPositionToClientPosition(float64(x), float64(y)) + style := t.textareaElement.Get("style") + style.Set("left", fmt.Sprintf("%0.2fpx", xf)) + style.Set("top", fmt.Sprintf("%0.2fpx", yf)) + + s := newSession() + t.session = s + return s.ch, s.end +} + +func (t *textInput) trySend(committed bool) { + if t.session == nil { + return + } + + textareaValue := t.textareaElement.Get("value").String() + start := t.textareaElement.Get("selectionStart").Int() + end := t.textareaElement.Get("selectionEnd").Int() + startInBytes := convertUTF16CountToByteCount(textareaValue, start) + endInBytes := convertUTF16CountToByteCount(textareaValue, end) + + t.session.trySend(State{ + Text: textareaValue, + CompositionSelectionStartInBytes: startInBytes, + CompositionSelectionEndInBytes: endInBytes, + Committed: committed, + }) + + if committed { + if t.session != nil { + t.session.end() + t.session = nil + } + t.textareaElement.Set("value", "") + } +} diff --git a/exp/textinput/textinput_null.go b/exp/textinput/textinput_null.go new file mode 100644 index 000000000..fb1455a26 --- /dev/null +++ b/exp/textinput/textinput_null.go @@ -0,0 +1,25 @@ +// Copyright 2023 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. + +//go:build (!darwin && !js) || ios + +package textinput + +type textInput struct{} + +var theTextInput textInput + +func (t *textInput) Start(x, y int) (chan State, func()) { + return nil, nil +} diff --git a/internal/ui/context.go b/internal/ui/context.go index 6ff4c8446..e1cd16f2b 100644 --- a/internal/ui/context.go +++ b/internal/ui/context.go @@ -263,6 +263,11 @@ func (c *context) clientPositionToLogicalPosition(x, y float64, deviceScaleFacto return (x*deviceScaleFactor - ox) / s, (y*deviceScaleFactor - oy) / s } +func (c *context) logicalPositionToClientPosition(x, y float64, deviceScaleFactor float64) (float64, float64) { + s, ox, oy := c.screenScaleAndOffsets() + return (x*s + ox) / deviceScaleFactor, (y*s + oy) / deviceScaleFactor +} + func (c *context) screenScaleAndOffsets() (scale, offsetX, offsetY float64) { scaleX := c.screenWidth / c.offscreenWidth scaleY := c.screenHeight / c.offscreenHeight @@ -273,3 +278,7 @@ func (c *context) screenScaleAndOffsets() (scale, offsetX, offsetY float64) { offsetY = (c.screenHeight - height) / 2 return } + +func LogicalPositionToClientPosition(x, y float64) (float64, float64) { + return theUI.context.logicalPositionToClientPosition(x, y, theUI.DeviceScaleFactor()) +} diff --git a/internal/ui/input_js.go b/internal/ui/input_js.go index e15971155..f2c9ab7b4 100644 --- a/internal/ui/input_js.go +++ b/internal/ui/input_js.go @@ -222,3 +222,7 @@ func (u *userInterfaceImpl) keyName(key Key) string { } return n.String() } + +func UpdateInputFromEvent(e js.Value) { + theUI.updateInputFromEvent(e) +} diff --git a/internal/ui/ui_glfw.go b/internal/ui/ui_glfw.go index 5b80e2d79..b7042cbff 100644 --- a/internal/ui/ui_glfw.go +++ b/internal/ui/ui_glfw.go @@ -1570,3 +1570,7 @@ func (u *userInterfaceImpl) setOrigWindowPos(x, y int) { func IsScreenTransparentAvailable() bool { return true } + +func RunOnMainThread(f func()) { + theUI.mainThread.Call(f) +}