From a35db9456c1e6b1ba891c737e210df27567bcc7a Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 9 Sep 2026 16:04:40 -0700 Subject: [PATCH 1/3] Add automatic text input for Silky 0.2.0 --- .github/workflows/build.yml | 5 + .gitignore | 1 + docs/text_inputs.md | 37 ++ examples/basicwindow/basicwindow.nim | 12 +- examples/menu/menu.nim | 4 - examples/panels/panels.nim | 2 +- examples/the7gui/the7gui.nim | 4 - examples/todomvc/todomvc.nim | 4 - silky.nimble | 2 +- src/silky.nim | 8 +- src/silky/common.nim | 2 + src/silky/contexts.nim | 26 +- src/silky/internal/browsers.js | 56 +++ src/silky/internal/browsers.nim | 43 +++ src/silky/internal/inputs.nim | 230 ++++++++++++ src/silky/semantics.nim | 30 +- src/silky/testing.nim | 19 +- src/silky/testwindow.nim | 41 ++- src/silky/textboxes.nim | 181 +++++---- tests/manual_inputs.nim | 214 +++++++++++ tests/manual_textbox.nim | 4 - tests/test_browser_inputs.cjs | 138 +++++++ tests/test_inputs.nim | 526 +++++++++++++++++++++++++++ 23 files changed, 1456 insertions(+), 133 deletions(-) create mode 100644 docs/text_inputs.md create mode 100644 src/silky/common.nim create mode 100644 src/silky/internal/browsers.js create mode 100644 src/silky/internal/browsers.nim create mode 100644 src/silky/internal/inputs.nim create mode 100644 tests/manual_inputs.nim create mode 100644 tests/test_browser_inputs.cjs create mode 100644 tests/test_inputs.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5c735a..910ecee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,10 +54,14 @@ jobs: if: matrix.emscripten uses: mymindstorm/setup-emsdk@v14 + - name: Test browser input adapter + run: node tests/test_browser_inputs.cjs + - name: Run unit tests if: ${{ !matrix.emscripten }} run: | nim r tests/test_textboxes.nim + nim r -d:silkyTesting tests/test_inputs.nim nim r tests/test_atlas_png.nim nim r -d:silkyTesting tests/test_layout.nim nim r -d:silkyTesting tests/test_layout_regressions.nim @@ -74,6 +78,7 @@ jobs: nim c ${{ matrix.build_flags }} examples/panels/panels.nim nim c ${{ matrix.build_flags }} examples/the7gui/the7gui.nim nim c ${{ matrix.build_flags }} examples/todomvc/todomvc.nim + nim c ${{ matrix.build_flags }} tests/manual_inputs.nim - name: Compile examples (Emscripten) if: matrix.emscripten diff --git a/.gitignore b/.gitignore index bc6dd93..46ca81f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ *.data *.wasm *.js +!src/silky/internal/browsers.js *.a *.o *.so diff --git a/docs/text_inputs.md b/docs/text_inputs.md new file mode 100644 index 0000000..e4e88d8 --- /dev/null +++ b/docs/text_inputs.md @@ -0,0 +1,37 @@ +# Text input + +Construct Silky normally with `newSilky`. Text boxes activate text input when +clicked and keep it active between frames while focused. Clicking away, omitting +the focused field from a frame, or changing window focus ends text input. A +disabled field can select and copy text but cannot edit it. + +Use `sk.buttonDown`, `sk.buttonPressed`, and `sk.buttonReleased` for application +controls. Keyboard keys in these views are false during text input, so typing +WASD cannot also move a character. Mouse buttons continue to work. Windy's raw +`window.buttonDown`, `window.buttonPressed`, and `window.buttonReleased` remain +unfiltered. + +Creating Silky from a window automatically installs its input handlers. No +callback registration or input setup is needed. If window callbacks change, +Silky refreshes its handlers at the start of the next UI frame. + +Application keyboard and rune callbacks are consumed during text input. Mouse +and focus callbacks still run. On entering text input, previously forwarded key +holds receive release callbacks so application controls do not remain stuck. +The corresponding physical releases are then consumed once. + +Remove any manual `inputRunes` forwarding and `runeInputEnabled` assignments. +Silky manages rune input automatically. Each focused text box processes all +pending text edits in arrival order before drawing, using modifiers captured +with each event. There is no setup call or public queue to manage. Input beyond +the internal limit of 65,536 pending events raises `SilkyError`. + +Emscripten builds automatically capture modifiers from DOM keyboard events. +When Windy cancels a printable keydown and prevents its keypress, Silky delivers +the browser's character through the rune callback. Shortcut keys do not insert +characters, and a corresponding keypress cannot insert the character twice. +This requires no application setup and does not add IME composition support. + +Run `nim r tests/manual_inputs.nim` from the repository root to try single-line, +password, and multiline fields with a keyboard display. Hold keys outside the +fields to highlight them, then click a field and type to check suppression. diff --git a/examples/basicwindow/basicwindow.nim b/examples/basicwindow/basicwindow.nim index db00896..5825aed 100644 --- a/examples/basicwindow/basicwindow.nim +++ b/examples/basicwindow/basicwindow.nim @@ -21,10 +21,6 @@ loadExtensions() let sk = newSilky(window, "dist/atlas.png") -window.runeInputEnabled = true -window.onRune = proc(rune: Rune) = - sk.inputRunes.add(rune) - const ElementOptions = ["Fire", "Water", "Earth", "Air"] PowerOptions = ["Low", "Medium", "High"] @@ -67,11 +63,11 @@ proc returnTest() = characters "You will not see this." window.onFrame = proc() = - if window.buttonPressed[KeyEqual] or - window.buttonPressed[NumpadAdd]: + if sk.buttonPressed[KeyEqual] or + sk.buttonPressed[NumpadAdd]: sk.uiScale = min(sk.uiScale + 0.25'f, 4.0'f) - if window.buttonPressed[KeyMinus] or - window.buttonPressed[NumpadSubtract]: + if sk.buttonPressed[KeyMinus] or + sk.buttonPressed[NumpadSubtract]: sk.uiScale = max(sk.uiScale - 0.25'f, 0.25'f) sk.beginUI(window, window.size) diff --git a/examples/menu/menu.nim b/examples/menu/menu.nim index 4028547..20e3418 100644 --- a/examples/menu/menu.nim +++ b/examples/menu/menu.nim @@ -20,10 +20,6 @@ const BackgroundColor = parseHtmlColor("#808080").rgbx let sk = newSilky(window, "dist/atlas.png") -window.runeInputEnabled = true -window.onRune = proc(rune: Rune) = - sk.inputRunes.add(rune) - window.onFrame = proc() = sk.beginUI(window, window.size) sk.clearScreen(BackgroundColor) diff --git a/examples/panels/panels.nim b/examples/panels/panels.nim index ccd8334..36aaf6a 100644 --- a/examples/panels/panels.nim +++ b/examples/panels/panels.nim @@ -502,7 +502,7 @@ window.onFrame = proc() = tint "#ffffff" # Regenerate the layout when R is pressed. - if window.buttonPressed[KeyR]: + if sk.buttonPressed[KeyR]: regenerate() let ms = sk.avgFrameTime * 1000 diff --git a/examples/the7gui/the7gui.nim b/examples/the7gui/the7gui.nim index ed35484..027cf27 100644 --- a/examples/the7gui/the7gui.nim +++ b/examples/the7gui/the7gui.nim @@ -33,10 +33,6 @@ sk.theme.buttonDownColor = rgbx(180, 180, 180, 255) sk.theme.menuPopupHoverColor = parseHtmlColor("#3498DB").rgbx sk.theme.menuPopupSelectedColor = parseHtmlColor("#2980B9").rgbx -window.runeInputEnabled = true -window.onRune = proc(rune: Rune) = - sk.inputRunes.add(rune) - var showChallenges = true showCounter = false diff --git a/examples/todomvc/todomvc.nim b/examples/todomvc/todomvc.nim index dd95cb4..5093d6f 100644 --- a/examples/todomvc/todomvc.nim +++ b/examples/todomvc/todomvc.nim @@ -47,10 +47,6 @@ sk.theme.frameFocusColor = parseHtmlColor("#D5DBDB").rgbx sk.theme.buttonHoverColor = rgbx(200, 200, 200, 255) sk.theme.buttonDownColor = rgbx(180, 180, 180, 255) -window.runeInputEnabled = true -window.onRune = proc(rune: Rune) = - sk.inputRunes.add(rune) - var showWindow = true todos: seq[Todo] diff --git a/silky.nimble b/silky.nimble index dfb1fb0..81c10ff 100644 --- a/silky.nimble +++ b/silky.nimble @@ -1,4 +1,4 @@ -version = "0.1.0" +version = "0.2.0" author = "Andre von Houck" description = "Silky immediate mode UI graphics." license = "MIT" diff --git a/src/silky.nim b/src/silky.nim index 64b0ec8..9cf0160 100644 --- a/src/silky.nim +++ b/src/silky.nim @@ -1,8 +1,11 @@ import std/[tables] +from silky/common import SilkyError +export SilkyError when defined(silkyTesting): import silky/[semantics, atlas, widgets, textboxes, testing, dsl, menus, profiles] - export semantics, atlas, tables, textboxes, testing, dsl, menus, profiles + export semantics except textInputs + export atlas, tables, textboxes, testing, dsl, menus, profiles export widgets except button, checkBox, clickableIcon, dropDown, frame, group, h1text, icon, iconButton, image, listBox, progressBar, radioButton, ribbon, scrubber, text @@ -19,7 +22,8 @@ else: not defined(useMetal4) and not defined(useCpu): export opengl - export windy, contexts, atlas, tables, textboxes, dsl, menus + export contexts except textInputs + export windy, atlas, tables, textboxes, dsl, menus export widgets except button, checkBox, clickableIcon, dropDown, frame, group, h1text, icon, iconButton, image, listBox, progressBar, radioButton, ribbon, scrubber, text diff --git a/src/silky/common.nim b/src/silky/common.nim new file mode 100644 index 0000000..c360f8f --- /dev/null +++ b/src/silky/common.nim @@ -0,0 +1,2 @@ +type SilkyError* = object of CatchableError + ## Reports invalid Silky state or input failures. diff --git a/src/silky/contexts.nim b/src/silky/contexts.nim index 80fa401..aa6bf25 100644 --- a/src/silky/contexts.nim +++ b/src/silky/contexts.nim @@ -1,7 +1,8 @@ import std/[algorithm, tables, unicode, times], pixie, vmath, windy, bumpy, - silky/[atlas, clips, layout, profiles] + silky/[atlas, clips, layout, profiles], + silky/internal/inputs export layout, profiles @@ -86,7 +87,7 @@ type padding*: float32 = 12 theme*: Theme = Theme() cursor*: Cursor = Cursor(kind: ArrowCursor) - inputRunes*: seq[Rune] + inputs: TextInputs mousePos*: Vec2 mouseDelta*: Vec2 mouseIdleTime*: float64 @@ -119,6 +120,10 @@ type proc currentDrawLayer*(sk: Silky): int = sk.drawer.currentLayer +proc textInputs*(sk: Silky): TextInputs = + ## Provides internal text input state to the text widgets. + sk.inputs + proc pushLayer*(sk: Silky, layer: int) = ## Pushes a new rendering layer onto the stack. sk.drawer.layerStack.add(sk.drawer.currentLayer) @@ -299,6 +304,7 @@ proc resetInteractions*(sk: Silky) = proc beginUiShared*(sk: Silky, window: Window, size: IVec2) = ## Starts a frame and updates the shared UI state. + sk.inputs.beginInputFrame(window) beginProfileFrame() sk.tooltipActive = sk.showTooltip @@ -349,7 +355,7 @@ proc endUiShared*(sk: Silky) = sk.popClipRect() sk.frameTime = epochTime() - sk.frameStartTime sk.avgFrameTime = (sk.avgFrameTime * 0.99) + (sk.frameTime * 0.01) - sk.inputRunes.setLen(0) + sk.inputs.endInputFrame(sk.window) sk.inFrame = false measurePop() endProfileFrame() @@ -696,12 +702,14 @@ proc newSilky*( atlas: SilkyAtlas ): Silky {.measure.} = ## Creates a new Silky context and eagerly initializes its drawer. + ## Automatically installs input handling on the window. result = Silky() result.image = image result.atlas = atlas result.builder = newAtlasBuilderFromAtlas(atlas, image) result.window = window result.drawer = newDrawer(window, image) + result.inputs = newTextInputs(window) proc newSilky*(window: Window, atlasPngPath: string): Silky {.measure.} = ## Creates a new Silky from one atlas PNG file. @@ -983,16 +991,16 @@ proc endUi*(sk: Silky) = sk.endUiShared() proc buttonDown*(sk: Silky): ButtonView = - ## Returns a view that returns true if the selected button is down - sk.window.buttonDown + ## Returns held buttons, excluding keyboard keys during text entry. + sk.inputs.filterButtons(sk.window.buttonDown) proc buttonPressed*(sk: Silky): ButtonView = - ## Returns a view that returns true the frame the selected button is pressed - sk.window.buttonPressed + ## Returns pressed buttons, excluding keyboard keys during text entry. + sk.inputs.filterButtons(sk.window.buttonPressed) proc buttonReleased*(sk: Silky): ButtonView = - ## Returns a view that returns true the frame the selected button is released - sk.window.buttonReleased + ## Returns released buttons, excluding keyboard keys during text entry. + sk.inputs.filterButtons(sk.window.buttonReleased) when not defined(useDirectX) and not defined(useVulkan) and diff --git a/src/silky/internal/browsers.js b/src/silky/internal/browsers.js new file mode 100644 index 0000000..5dd58e1 --- /dev/null +++ b/src/silky/internal/browsers.js @@ -0,0 +1,56 @@ +function installSilkyBrowserInputs(canvas, receiveRune, receiveModifiers) { + // The Emscripten window uses one canvas for its lifetime. + if (canvas.silkyInputListeners) { + canvas.silkyInputListeners.abort(); + } + const controller = new AbortController(); + canvas.silkyInputListeners = controller; + const options = {capture: true, signal: controller.signal}; + let fallbackKey = null; + + function beforeKeyboard(event) { + if (event.target !== canvas) { + return; + } + if (event.type === 'keydown' || event.type === 'keyup') { + fallbackKey = null; + } + if (event.type === 'keypress' && fallbackKey === event.key) { + // Some synthetic event sources send keypress even after cancellation. + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } + receiveModifiers(16 | (event.ctrlKey ? 1 : 0) | + (event.shiftKey ? 2 : 0) | (event.altKey ? 4 : 0) | + (event.metaKey ? 8 : 0) | + (event.getModifierState('AltGraph') ? 32 : 0)); + } + + function afterKeyboard(event) { + if (event.target === canvas) { + receiveModifiers(0); + } + } + + for (const type of ['keydown', 'keyup', 'keypress']) { + // Document capture runs before Windy's canvas callbacks. + document.addEventListener(type, beforeKeyboard, options); + document.addEventListener(type, afterKeyboard, { + signal: controller.signal + }); + } + + canvas.addEventListener('keydown', function(event) { + // Windy cancels keydown, suppressing the keypress that supplies its runes. + if (!event.defaultPrevented || event.isComposing || event.metaKey || + (event.ctrlKey && !event.getModifierState('AltGraph'))) { + return; + } + const characters = Array.from(event.key); + if (characters.length === 1 && + receiveRune(characters[0].codePointAt(0))) { + fallbackKey = event.key; + } + }, options); +} diff --git a/src/silky/internal/browsers.nim b/src/silky/internal/browsers.nim new file mode 100644 index 0000000..0366f28 --- /dev/null +++ b/src/silky/internal/browsers.nim @@ -0,0 +1,43 @@ +import + std/unicode, + windy + +type BrowserInputs* = ref object + windowPointer: pointer + modifiers*: int32 + +{.emit: """ +#include +EM_JS(void, silkyBrowserInit, (void *context), { +""" & staticRead("browsers.js") & """ + installSilkyBrowserInputs( + document.querySelector('#canvas'), + (rune) => _silkyBrowserRune(context, rune), + (modifiers) => _silkyBrowserModifiers(context, modifiers) + ); +}); +""".} + +proc silkyBrowserRune(context: pointer, value: int32): cint + {.exportc, cdecl, codegenDecl: "EMSCRIPTEN_KEEPALIVE $# $#$#".} = + ## Delivers printable browser text through the normal rune callback. + let + inputs = cast[BrowserInputs](context) + window = cast[Window](inputs.windowPointer) + if window.runeInputEnabled and window.onRune != nil and + value >= 32 and value notin 127 .. 159: + window.onRune(Rune(value)) + return 1 + +proc silkyBrowserModifiers(context: pointer, modifiers: int32) + {.exportc, cdecl, codegenDecl: "EMSCRIPTEN_KEEPALIVE $# $#$#".} = + ## Captures DOM modifiers before Windy's keyboard callback runs. + cast[BrowserInputs](context).modifiers = modifiers + +proc silkyBrowserInit(context: pointer) {.importc, nodecl.} + ## Installs the browser listeners for this input state. + +proc newBrowserInputs*(window: Window): BrowserInputs = + ## Bridges browser characters and modifiers to Windy's callbacks. + result = BrowserInputs(windowPointer: cast[pointer](window)) + silkyBrowserInit(cast[pointer](result)) diff --git a/src/silky/internal/inputs.nim b/src/silky/internal/inputs.nim new file mode 100644 index 0000000..97724da --- /dev/null +++ b/src/silky/internal/inputs.nim @@ -0,0 +1,230 @@ +import + std/unicode, + silky/common + +when defined(silkyTesting): + import silky/testwindow +else: + import windy + +when defined(emscripten) and not defined(silkyTesting): + import browsers + +const MaxPendingEvents = 65_536 + +type + InputKind* = enum + KeyDown, KeyUp, TextInput + + InputEvent* = object + kind*: InputKind + button*: Button + rune*: Rune + control*, shift*, alt*, super*: bool + altGraph*: bool + + CallbackIdentity = tuple[code, environment: pointer] + + TextInputs* = ref object + events: seq[InputEvent] + active: bool + editable: bool + id: string + epoch: uint64 + submitted: bool + consumed: bool + callbacks: array[4, CallbackIdentity] + forwardedKeys: set[Button] + consumedKeys: set[Button] + releaseCallback: proc(button: Button) + when defined(emscripten) and not defined(silkyTesting): + browser: BrowserInputs + +proc identity[T](callback: T): CallbackIdentity = + ## Identifies a callback without retaining its closure environment. + (rawProc(callback), rawEnv(callback)) + +proc focusEpoch*(inputs: TextInputs): uint64 = + ## Returns the generation of the current text focus. + inputs.epoch + +when defined(silkyTesting): + proc filterButtons*( + inputs: TextInputs, buttons: array[Button, bool] + ): array[Button, bool] = + ## Hides keyboard state from normal controls during text entry. + result = buttons + if inputs.active: + for button in Key0 .. Button.high: + result[button] = false +else: + proc filterButtons*(inputs: TextInputs, buttons: ButtonView): ButtonView = + ## Hides keyboard state from normal controls during text entry. + if inputs.active: + ButtonView(set[Button](buttons) - {Key0 .. Button.high}) + else: + buttons + +proc stopInput*(inputs: TextInputs, window: Window) = + ## Ends text capture and discards input for the previous field. + if inputs.active: + inc inputs.epoch + inputs.active = false + inputs.editable = false + inputs.id.setLen(0) + inputs.events.setLen(0) + if window.runeInputEnabled: + window.runeInputEnabled = false + +proc modifiers(inputs: TextInputs, window: Window): InputEvent = + ## Captures modifier state at the time of a callback. + when defined(emscripten) and not defined(silkyTesting): + let bits = inputs.browser.modifiers + if (bits and 16) != 0: + result.control = (bits and 1) != 0 + result.shift = (bits and 2) != 0 + result.alt = (bits and 4) != 0 + result.super = (bits and 8) != 0 + result.altGraph = (bits and 32) != 0 + return + result.control = window.buttonDown[KeyLeftControl] or + window.buttonDown[KeyRightControl] + result.shift = window.buttonDown[KeyLeftShift] or + window.buttonDown[KeyRightShift] + result.alt = window.buttonDown[KeyLeftAlt] or + window.buttonDown[KeyRightAlt] + result.super = window.buttonDown[KeyLeftSuper] or + window.buttonDown[KeyRightSuper] + +proc isKeyboard(inputs: TextInputs, button: Button): bool = + ## Includes browser keys that Windy does not map to a button yet. + when defined(emscripten) and not defined(silkyTesting): + if (inputs.browser.modifiers and 16) != 0: + return true + button >= Key0 + +proc addEvent(inputs: TextInputs, event: InputEvent) = + ## Appends an event without coalescing or dropping earlier events. + if inputs.events.len >= MaxPendingEvents: + raise newException(SilkyError, "Too many pending text input events") + inputs.events.add(event) + +proc overrideCallbacks( + inputs: TextInputs, window: Window, initialize = false +) = + ## Automatically installs handlers for any changed callback slots. + let windowPointer = cast[pointer](window) + # The window owns these callbacks, so a pointer avoids a reference cycle. + if initialize or identity(window.onButtonPress) != inputs.callbacks[0]: + let previousPress = window.onButtonPress + window.onButtonPress = proc(button: Button) = + let window = cast[Window](windowPointer) + if inputs.active and inputs.isKeyboard(button): + var event = inputs.modifiers(window) + event.kind = KeyDown + event.button = button + inputs.consumedKeys.incl(button) + inputs.addEvent(event) + return + if button >= Key0: + inputs.forwardedKeys.incl(button) + inputs.consumedKeys.excl(button) + if previousPress != nil: + previousPress(button) + inputs.callbacks[0] = identity(window.onButtonPress) + if initialize or identity(window.onButtonRelease) != inputs.callbacks[1]: + let previousRelease = window.onButtonRelease + inputs.releaseCallback = previousRelease + window.onButtonRelease = proc(button: Button) = + let window = cast[Window](windowPointer) + if inputs.active and inputs.isKeyboard(button): + var event = inputs.modifiers(window) + event.kind = KeyUp + event.button = button + inputs.consumedKeys.excl(button) + inputs.addEvent(event) + return + if button >= Key0: + inputs.forwardedKeys.excl(button) + if button in inputs.consumedKeys: + inputs.consumedKeys.excl(button) + return + if previousRelease != nil: + previousRelease(button) + inputs.callbacks[1] = identity(window.onButtonRelease) + if initialize or identity(window.onRune) != inputs.callbacks[2]: + let previousRune = window.onRune + window.onRune = proc(rune: Rune) = + let window = cast[Window](windowPointer) + if inputs.active: + if inputs.editable: + var event = inputs.modifiers(window) + event.kind = TextInput + event.rune = rune + inputs.addEvent(event) + return + if previousRune != nil: + previousRune(rune) + inputs.callbacks[2] = identity(window.onRune) + if initialize or identity(window.onFocusChange) != inputs.callbacks[3]: + let previousFocus = window.onFocusChange + window.onFocusChange = proc() = + inputs.stopInput(cast[Window](windowPointer)) + if previousFocus != nil: + previousFocus() + inputs.callbacks[3] = identity(window.onFocusChange) + +proc newTextInputs*(window: Window): TextInputs = + ## Sets up all text input handling directly from the window. + result = TextInputs() + when defined(emscripten) and not defined(silkyTesting): + result.browser = newBrowserInputs(window) + result.overrideCallbacks(window, initialize = true) + result.stopInput(window) + +proc beginInputFrame*(inputs: TextInputs, window: Window) = + ## Refreshes input handlers and starts the input frame. + if inputs == nil: + raise newException(SilkyError, "Create Silky with newSilky before use") + inputs.overrideCallbacks(window) + inputs.submitted = false + inputs.consumed = false + +proc submitInput*( + inputs: TextInputs, + window: Window, + id: string, + focused, editable: bool +) = + ## Updates the active field without changing Windy's key state. + if not focused: + if inputs.active and inputs.id == id: + inputs.stopInput(window) + return + if not inputs.active or inputs.id != id: + inputs.events.setLen(0) + inc inputs.epoch + inputs.id = id + inputs.active = true + let releasedKeys = inputs.forwardedKeys + inputs.forwardedKeys = {} + inputs.consumedKeys = inputs.consumedKeys + releasedKeys + if inputs.releaseCallback != nil: + for button in releasedKeys: + inputs.releaseCallback(button) + inputs.editable = editable + inputs.submitted = true + if window.runeInputEnabled != editable: + window.runeInputEnabled = editable + +proc takeInput*(inputs: TextInputs): seq[InputEvent] = + ## Takes the entire pending batch at most once in this UI frame. + if inputs.consumed or not inputs.active: + return + inputs.consumed = true + swap(result, inputs.events) + +proc endInputFrame*(inputs: TextInputs, window: Window) = + ## Ends capture when the active field was not submitted this frame. + if not inputs.submitted: + inputs.stopInput(window) diff --git a/src/silky/semantics.nim b/src/silky/semantics.nim index 5092b3c..f455937 100644 --- a/src/silky/semantics.nim +++ b/src/silky/semantics.nim @@ -3,7 +3,8 @@ import std/[algorithm, strutils, tables, unicode, times], vmath, bumpy, chroma, pixie, - silky/[atlas, clips, layout], testwindow + silky/[atlas, clips, layout], testwindow, + silky/internal/inputs from windy/common import Button, CursorKind, Cursor @@ -284,7 +285,7 @@ type padding*: float32 = 12 theme*: Theme = Theme() cursor*: Cursor = Cursor(kind: ArrowCursor) - inputRunes*: seq[Rune] + inputs: TextInputs mousePos*: Vec2 mouseDelta*: Vec2 mouseIdleTime*: float64 @@ -316,6 +317,10 @@ type proc currentDrawLayer*(sk: Silky): int = sk.currentLayer +proc textInputs*(sk: Silky): TextInputs = + ## Provides internal text input state to the text widgets. + sk.inputs + proc pushLayer*(sk: Silky, layer: int) = ## Pushes a new rendering layer onto the stack. sk.layerStack.add(sk.currentLayer) @@ -587,6 +592,7 @@ proc instanceCount*(sk: Silky): int = proc newSilky*(window: Window, atlas: SilkyAtlas): Silky = ## Creates a new Silky context for testing from atlas data. + ## Automatically installs input handling on the window. result = Silky() result.atlas = atlas result.layers[NormalLayer] = @[] @@ -594,6 +600,7 @@ proc newSilky*(window: Window, atlas: SilkyAtlas): Silky = result.currentLayer = NormalLayer result.layerStack = @[] result.window = window + result.inputs = newTextInputs(window) proc newSilky*(window: Window, atlasPngPath: string): Silky = ## Creates a new Silky context for testing from a single atlas PNG. @@ -602,6 +609,7 @@ proc newSilky*(window: Window, atlasPngPath: string): Silky = proc beginUi*(sk: Silky, window: auto, size: IVec2) = ## Begins a new UI frame. + sk.inputs.beginInputFrame(window) sk.tooltipActive = sk.showTooltip sk.showTooltip = false sk.framebufferSize = size @@ -633,16 +641,20 @@ proc endUi*(sk: Silky) = sk.popClipRect() sk.frameTime = epochTime() - sk.frameStartTime sk.avgFrameTime = (sk.avgFrameTime * 0.99) + (sk.frameTime * 0.01) - sk.inputRunes.setLen(0) + sk.inputs.endInputFrame(sk.window) + sk.inFrame = false -template buttonDown*(sk: Silky): array[Button, bool] = - sk.window.buttonDown +proc buttonDown*(sk: Silky): array[Button, bool] = + ## Returns held buttons, excluding keyboard keys during text entry. + sk.inputs.filterButtons(sk.window.buttonDown) -template buttonPressed*(sk: Silky): array[Button, bool] = - sk.window.buttonPressed +proc buttonPressed*(sk: Silky): array[Button, bool] = + ## Returns pressed buttons, excluding keyboard keys during text entry. + sk.inputs.filterButtons(sk.window.buttonPressed) -template buttonReleased*(sk: Silky): array[Button, bool] = - sk.window.buttonReleased +proc buttonReleased*(sk: Silky): array[Button, bool] = + ## Returns released buttons, excluding keyboard keys during text entry. + sk.inputs.filterButtons(sk.window.buttonReleased) proc beginWidget*(sk: Silky, kind: string, name = "", text = "", rect = rect(0f, 0f, 0f, 0f)) {.inline.} = ## Begins a new semantic widget node. diff --git a/src/silky/testing.nim b/src/silky/testing.nim index dd4a03c..974b7ba 100644 --- a/src/silky/testing.nim +++ b/src/silky/testing.nim @@ -19,13 +19,7 @@ proc newTestHarness*(atlas: SilkyAtlas, width = 800, height = 600): TestHarness ## Creates a new test harness from atlas data. result.window = newWindow(width, height) result.frameCount = 0 - result.sk = Silky() - result.sk.window = result.window - result.sk.atlas = atlas - result.sk.layers[NormalLayer] = @[] - result.sk.layers[PopupsLayer] = @[] - result.sk.currentLayer = NormalLayer - result.sk.layerStack = @[] + result.sk = newSilky(result.window, atlas) proc newTestHarness*(atlasPng: string, width = 800, height = 600): TestHarness = ## Creates a new test harness from a single atlas PNG. @@ -34,18 +28,11 @@ proc newTestHarness*(atlasPng: string, width = 800, height = 600): TestHarness = proc beginFrame*(h: var TestHarness) = ## Begins a new test frame. - h.sk.framebufferSize = h.window.size - h.sk.mousePos = h.window.mousePos.vec2 / h.sk.uiScale - h.sk.mouseDelta = h.window.mouseDelta.vec2 / h.sk.uiScale - h.sk.pushLayout(vec2(0, 0), h.window.size.vec2 / h.sk.uiScale) - h.sk.pushClipRect(rect(0, 0, h.sk.size.x, h.sk.size.y)) - h.sk.semantic.reset() + h.sk.beginUi(h.window, h.window.size) proc endFrame*(h: var TestHarness) = ## Ends the current test frame and captures the snapshot. - h.sk.popClipRect() - h.sk.popLayout() - h.sk.clear() + h.sk.endUi() inc h.frameCount h.lastSnapshot = h.sk.semantic.toSnapshot() diff --git a/src/silky/testwindow.nim b/src/silky/testwindow.nim index 25790b0..8ae0b79 100644 --- a/src/silky/testwindow.nim +++ b/src/silky/testwindow.nim @@ -2,8 +2,7 @@ import std/unicode, - vmath, bumpy, - silky/[semantics, atlas] + vmath from windy/common import Button, CursorKind, Cursor export Button, CursorKind, Cursor, unicode @@ -22,8 +21,13 @@ type Window* = ref object cursor*: Cursor = Cursor(kind: ArrowCursor) runeInputEnabled*: bool onRune*: proc(rune: Rune) + onButtonPress*: proc(button: Button) + onButtonRelease*: proc(button: Button) + onFocusChange*: proc() onFrame*: proc() +var clipboard: string + proc newWindow*(width = 800, height = 600): Window = ## Creates a new test window with the given dimensions. Window( @@ -51,8 +55,12 @@ proc pollEvents*() {.inline.} = discard proc getClipboardString*(): string = - ## Stub for getting clipboard content. - "" + ## Returns the simulated clipboard content. + clipboard + +proc setClipboardString*(value: string) = + ## Updates the simulated clipboard content. + clipboard = value proc makeContextCurrent*(window: Window) {.inline.} = ## Stub for OpenGL context creation. @@ -71,14 +79,35 @@ proc resetInputState*(w: Window) = w.scrollDelta = vec2(0, 0) proc pressButton*(w: Window, button: Button) = - ## Simulates pressing a mouse button. + ## Simulates pressing a button, including repeated presses. w.buttonDown[button] = true w.buttonPressed[button] = true + if w.onButtonPress != nil: + w.onButtonPress(button) proc releaseButton*(w: Window, button: Button) = - ## Simulates releasing a mouse button. + ## Simulates releasing a button. w.buttonDown[button] = false w.buttonReleased[button] = true + if w.onButtonRelease != nil: + w.onButtonRelease(button) + +proc typeRune*(w: Window, rune: Rune) = + ## Delivers a printable rune when text input is enabled. + if not w.runeInputEnabled: + return + if rune.int32 < 32 or rune.int32 in 127 .. 159: + return + if w.onRune != nil: + w.onRune(rune) + +proc changeFocus*(w: Window) = + ## Releases held buttons and delivers a window focus change. + for button in Button: + if w.buttonDown[button]: + w.releaseButton(button) + if w.onFocusChange != nil: + w.onFocusChange() proc moveMouse*(w: Window, x, y: int) = ## Moves the simulated mouse cursor to the given position. diff --git a/src/silky/textboxes.nim b/src/silky/textboxes.nim index d327410..1e716b9 100644 --- a/src/silky/textboxes.nim +++ b/src/silky/textboxes.nim @@ -1,7 +1,7 @@ import std/[tables, unicode, times], vmath, bumpy, chroma, - silky/atlas + silky/atlas, silky/internal/inputs ## Multi-line text box widget. ## @@ -31,9 +31,6 @@ import when defined(silkyTesting): import silky/[semantics, testing, profiles] - proc setClipboardString(value: string) = - ## Stub for setting clipboard in test mode. - discard else: import silky/[contexts, profiles], windy @@ -59,6 +56,7 @@ type scrollPos*: Vec2 savedX*: float32 focused*: bool + focusEpoch: uint64 layout*: seq[Rect] dirty*: bool undoStack*: seq[(seq[Rune], int)] @@ -699,85 +697,138 @@ proc scrollBy*(state: TextBoxState, amount, viewportHeight: float32) = let maxScroll = max(0.0f, state.innerHeight - viewportHeight) state.scrollPos.y = clamp(state.scrollPos.y, 0.0f, maxScroll) -proc handleKeyboard*( - state: TextBoxState, - window: Window, - inputRunes: seq[Rune], - ctrl, shift, alt: bool, - wordWrap: bool -) = - ## Processes keyboard input for the text box. - # On Mac: Option = word-level, Cmd = line/document-level. - # On Windows/Linux: Ctrl = word-level, Home/End = line, Ctrl+Home/End = document. +proc handleKey(state: TextBoxState, event: InputEvent) = + ## Applies one keyboard press using its captured modifiers. + let + ctrl = event.super or (event.control and not event.altGraph) + shift = event.shift when defined(macosx): - let wordMod = alt - let lineMod = ctrl + let + wordMod = event.alt + lineMod = ctrl else: - let wordMod = ctrl - let lineMod = false - for r in inputRunes: - state.typeCharacter(r) - if window.buttonPressed[KeyBackspace]: - if wordMod: state.backspaceWord() - else: state.backspace() - elif window.buttonPressed[KeyDelete]: - if wordMod: state.deleteWord() - else: state.delete() - elif window.buttonPressed[KeyLeft]: - if wordMod: state.leftWord(shift) - elif lineMod: state.startOfLine(shift) - else: state.left(shift) - elif window.buttonPressed[KeyRight]: - if wordMod: state.rightWord(shift) - elif lineMod: state.endOfLine(shift) - else: state.right(shift) - elif window.buttonPressed[KeyUp]: + let + wordMod = ctrl + lineMod = false + case event.button + of KeyBackspace: + if wordMod: + state.backspaceWord() + else: + state.backspace() + of KeyDelete: + if wordMod: + state.deleteWord() + else: + state.delete() + of KeyLeft: + if wordMod: + state.leftWord(shift) + elif lineMod: + state.startOfLine(shift) + else: + state.left(shift) + of KeyRight: + if wordMod: + state.rightWord(shift) + elif lineMod: + state.endOfLine(shift) + else: + state.right(shift) + of KeyUp: if lineMod: state.cursor = 0 - if not shift: state.selector = state.cursor - else: state.up(shift) - elif window.buttonPressed[KeyDown]: + if not shift: + state.selector = state.cursor + else: + state.up(shift) + of KeyDown: if lineMod: state.cursor = state.runes.len - if not shift: state.selector = state.cursor - else: state.down(shift) - elif window.buttonPressed[KeyHome]: + if not shift: + state.selector = state.cursor + else: + state.down(shift) + of KeyHome: if ctrl: state.cursor = 0 - if not shift: state.selector = state.cursor - else: state.startOfLine(shift) - elif window.buttonPressed[KeyEnd]: + if not shift: + state.selector = state.cursor + else: + state.startOfLine(shift) + of KeyEnd: if ctrl: state.cursor = state.runes.len - if not shift: state.selector = state.cursor - else: state.endOfLine(shift) - elif window.buttonPressed[KeyPageUp]: + if not shift: + state.selector = state.cursor + else: + state.endOfLine(shift) + of KeyPageUp: state.pageUp(shift) - elif window.buttonPressed[KeyPageDown]: + of KeyPageDown: state.pageDown(shift) - elif window.buttonPressed[KeyEnter]: + of KeyEnter: if not state.singleLine: state.typeCharacter(LF) - elif ctrl: - if window.buttonPressed[KeyA]: + of KeyA: + if ctrl: state.selectAll() - elif window.buttonPressed[KeyC]: + of KeyC: + if ctrl: let copied = state.copyText() if copied.len > 0: setClipboardString(copied) - elif window.buttonPressed[KeyX]: + of KeyX: + if ctrl: let cut = state.cutText() if cut.len > 0: setClipboardString(cut) - elif window.buttonPressed[KeyV]: + of KeyV: + if ctrl and state.enabled: let clip = getClipboardString() if clip.len > 0: state.pasteText(clip) - elif window.buttonPressed[KeyZ]: - if shift: state.redo() - else: state.undo() - elif window.buttonPressed[KeyY]: + of KeyZ: + if ctrl and state.enabled: + if shift: + state.redo() + else: + state.undo() + of KeyY: + if ctrl and state.enabled: state.redo() + else: + discard + +proc handleInput( + state: TextBoxState, + inputs: TextInputs, + fontData: FontAtlas, + maxWidth: float32 +) = + ## Drains ordered input and refreshes layout before cursor navigation. + let + epoch = inputs.focusEpoch + events = inputs.takeInput() + for event in events: + if inputs.focusEpoch != epoch: + break + case event.kind + of TextInput: + state.typeCharacter(event.rune) + of InputKind.KeyDown: + if state.dirty and event.button in { + KeyLeft, KeyRight, KeyUp, KeyDown, KeyHome, KeyEnd, + KeyPageUp, KeyPageDown + }: + state.computeLayout(fontData, maxWidth) + state.handleKey(event) + of InputKind.KeyUp: + discard + if events.len > 0: + if state.dirty: + state.computeLayout(fontData, maxWidth) + state.scrollToCursor() proc drawScrollbars*(sk: Silky, state: TextBoxState, window: Window, outerRect, innerRect: Rect, mouseVec: Vec2, clipRect: Rect) = @@ -879,6 +930,10 @@ proc textBox*( newState.setText(t) textBoxStates[id] = newState state = textBoxStates[id] + let inputs = sk.textInputs + if state.focused and state.focusEpoch != inputs.focusEpoch: + state.focused = false + state.dragging = false state.enabled = enabled state.allowedChars = allowedChars if state.password != password: @@ -909,14 +964,8 @@ proc textBox*( if state.dirty or state.layout.len == 0 or state.lastMaxWidth != innerRect.w: state.computeLayout(fontData, innerRect.w) - let ctrl = window.buttonDown[KeyLeftControl] or - window.buttonDown[KeyRightControl] or - window.buttonDown[KeyLeftSuper] or - window.buttonDown[KeyRightSuper] let shift = window.buttonDown[KeyLeftShift] or window.buttonDown[KeyRightShift] - let alt = window.buttonDown[KeyLeftAlt] or - window.buttonDown[KeyRightAlt] let mouseVec = sk.mousePos let mouseInside = mouseVec.overlaps(outerRect) and mouseVec.overlaps(sk.clipRect) @@ -946,8 +995,10 @@ proc textBox*( state.mouseAction(localMouse, click = false, shift = true) if window.buttonReleased[MouseLeft] or not window.buttonDown[MouseLeft]: state.dragging = false + inputs.submitInput(window, id, state.focused, enabled) if state.focused: - state.handleKeyboard(window, sk.inputRunes, ctrl, shift, alt, state.wordWrap) + state.focusEpoch = inputs.focusEpoch + state.handleInput(inputs, fontData, innerRect.w) t = state.getText() if state.dirty: state.computeLayout(fontData, innerRect.w) diff --git a/tests/manual_inputs.nim b/tests/manual_inputs.nim new file mode 100644 index 0000000..7d746e7 --- /dev/null +++ b/tests/manual_inputs.nim @@ -0,0 +1,214 @@ +import + std/math, + chroma, vmath, bumpy, + silky + +const + Margin = 20.0'f + Gap = 20.0'f + LeftWidth = 300.0'f + Background = rgbx(26, 30, 38, 255) + KeyHeight = 36.0'f + KeyGap = 4.0'f + Keyboard = [ + @[("`", KeyBacktick, 1.0'f), ("1", Key1, 1.0'f), + ("2", Key2, 1.0'f), ("3", Key3, 1.0'f), ("4", Key4, 1.0'f), + ("5", Key5, 1.0'f), ("6", Key6, 1.0'f), ("7", Key7, 1.0'f), + ("8", Key8, 1.0'f), ("9", Key9, 1.0'f), ("0", Key0, 1.0'f), + ("-", KeyMinus, 1.0'f), ("=", KeyEqual, 1.0'f), + ("Back", KeyBackspace, 2.0'f)], + @[("Tab", KeyTab, 1.5'f), ("Q", KeyQ, 1.0'f), ("W", KeyW, 1.0'f), + ("E", KeyE, 1.0'f), ("R", KeyR, 1.0'f), ("T", KeyT, 1.0'f), + ("Y", KeyY, 1.0'f), ("U", KeyU, 1.0'f), ("I", KeyI, 1.0'f), + ("O", KeyO, 1.0'f), ("P", KeyP, 1.0'f), + ("[", KeyLeftBracket, 1.0'f), ("]", KeyRightBracket, 1.0'f), + ("\\", KeyBackslash, 1.5'f)], + @[("Caps", KeyCapsLock, 1.75'f), ("A", KeyA, 1.0'f), + ("S", KeyS, 1.0'f), ("D", KeyD, 1.0'f), ("F", KeyF, 1.0'f), + ("G", KeyG, 1.0'f), ("H", KeyH, 1.0'f), ("J", KeyJ, 1.0'f), + ("K", KeyK, 1.0'f), ("L", KeyL, 1.0'f), + (";", KeySemicolon, 1.0'f), ("'", KeyApostrophe, 1.0'f), + ("Enter", KeyEnter, 2.25'f)], + @[("Shift", KeyLeftShift, 2.25'f), ("Z", KeyZ, 1.0'f), + ("X", KeyX, 1.0'f), ("C", KeyC, 1.0'f), ("V", KeyV, 1.0'f), + ("B", KeyB, 1.0'f), ("N", KeyN, 1.0'f), ("M", KeyM, 1.0'f), + (",", KeyComma, 1.0'f), (".", KeyPeriod, 1.0'f), + ("/", KeySlash, 1.0'f), ("Shift", KeyRightShift, 2.75'f)], + @[("Ctrl", KeyLeftControl, 1.25'f), + ("Super", KeyLeftSuper, 1.25'f), ("Alt", KeyLeftAlt, 1.25'f), + ("Space", KeySpace, 6.25'f), ("Alt", KeyRightAlt, 1.25'f), + ("Super", KeyRightSuper, 1.25'f), ("Menu", KeyMenu, 1.25'f), + ("Ctrl", KeyRightControl, 1.25'f)] + ] + +proc drawKeyboard(sk: Silky, y, lineHeight: float32) = + ## Draws staggered QWERTY rows using the same key state as game controls. + discard sk.drawText( + "Default", + "Click outside the fields, then hold keys. Typing keeps these keys dark.", + vec2(Margin, y), + sk.theme.textColor + ) + let + unit = min(60.0'f, max(0.0'f, sk.size.x - Margin * 2) / 15) + keys = sk.buttonDown + for rowIndex, row in Keyboard: + var x = Margin + for (label, button, units) in row: + let + width = unit * units - KeyGap + pos = vec2(x, y + lineHeight + 8 + rowIndex.float32 * KeyHeight) + color = + if keys[button]: + rgbx(65, 150, 220, 255) + else: + rgbx(48, 55, 66, 255) + textSize = sk.getTextSize("Default", label) + sk.drawRect(pos, vec2(max(0.0'f, width), KeyHeight - KeyGap), color) + if width >= textSize.x: + discard sk.drawText( + "Default", + label, + pos + vec2((width - textSize.x) / 2, 3), + sk.theme.textColor + ) + x += unit * units + +proc main() = + ## Displays four text fields for shortcut, selection, and resize checks. + let builder = newAtlasBuilder(1024, 4) + builder.addDir("tests/data/", "tests/data/") + builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18.0) + let + font = builder.atlas.fonts["Default"] + padding = Theme().padding.float32 + lineHeight = font.lineHeight + labelHeight = lineHeight + 8 + headerHeight = lineHeight + Gap + keyboardHeight = labelHeight + KeyHeight * Keyboard.len.float32 + var glyph: ptr LetterEntry + doAssert font.lookupLetter(Rune('0'), 0, glyph) + let + editorWidth = glyph.advance * 60 + padding * 2 + editorHeight = lineHeight * 20 + padding * 2 + window = newWindow( + "Silky Text Inputs", + ivec2( + ceil(Margin * 2 + LeftWidth + Gap + editorWidth).int32, + ceil(Margin * 2 + headerHeight + labelHeight + editorHeight + + Gap + keyboardHeight).int32 + ) + ) + window.makeContextCurrent() + loadExtensions() + let sk = newSilky(window, builder.atlasImage, builder.atlas) + var + name = "Hello" + password = "secret" + notes = "First line\nSecond line\nThird line" + document = "Try quick Command-A or Ctrl-A, then type a replacement.\n\n" + for i in 1 .. 30: + document.add("Line " & $i & ": Edit, select, and scroll this text.\n") + + window.onFrame = proc() = + sk.beginUi(window, window.size) + sk.clearScreen(Background) + let + availableWidth = max(0.0'f, sk.size.x - Margin * 2 - Gap) + leftWidth = min(LeftWidth, availableWidth * 0.35'f) + rightX = Margin + leftWidth + Gap + top = Margin + headerHeight + rightWidth = max(0.0'f, sk.size.x - rightX - Margin) + keyboardY = max(top, sk.size.y - Margin - keyboardHeight) + rightHeight = max(0.0'f, keyboardY - Gap - top - labelHeight) + singleHeight = lineHeight + padding * 2 + discard sk.drawText( + "Default", + "Click a field. Try Ctrl/Cmd+A, then type. Resize the window.", + vec2(Margin, Margin), + sk.theme.textColor + ) + + proc field( + id, label: string, + value: var string, + x, y, width, height: float32, + singleLine = false, + password = false + ) = + ## Draws a labeled input with explicit viewport dimensions. + if y + labelHeight >= keyboardY - Gap or width <= 0 or height <= 0: + return + discard sk.drawText( + "Default", + label, + vec2(x, y), + sk.theme.textColor + ) + sk.at = vec2(x, y + labelHeight) + sk.textBox( + window, + id, + value, + width, + height, + wrapWords = not singleLine, + singleLine = singleLine, + password = password + ) + + sk.pushClipRect(rect( + Margin, + top, + max(0.0'f, sk.size.x - Margin * 2), + max(0.0'f, keyboardY - Gap - top) + )) + field( + "name", + "Single line", + name, + Margin, + top, + leftWidth, + singleHeight, + singleLine = true + ) + field( + "password", + "Password", + password, + Margin, + top + labelHeight + singleHeight + Gap, + leftWidth, + singleHeight, + singleLine = true, + password = true + ) + field( + "notes", + "Three lines", + notes, + Margin, + top + (labelHeight + singleHeight + Gap) * 2, + leftWidth, + lineHeight * 3 + padding * 2 + ) + field( + "document", + "Editor", + document, + rightX, + top, + rightWidth, + rightHeight + ) + sk.popClipRect() + sk.drawKeyboard(keyboardY, lineHeight) + sk.endUi() + window.swapBuffers() + + while not window.closeRequested: + pollEvents() + window.close() + +main() diff --git a/tests/manual_textbox.nim b/tests/manual_textbox.nim index e77bfcd..ff7a866 100644 --- a/tests/manual_textbox.nim +++ b/tests/manual_textbox.nim @@ -43,10 +43,6 @@ Done.""" let sk = newSilky(window, "tests/dist/atlas.png") -window.runeInputEnabled = true -window.onRune = proc(rune: Rune) = - sk.inputRunes.add(rune) - var boxWidth = 500.0f boxHeight = 400.0f diff --git a/tests/test_browser_inputs.cjs b/tests/test_browser_inputs.cjs new file mode 100644 index 0000000..62722f5 --- /dev/null +++ b/tests/test_browser_inputs.cjs @@ -0,0 +1,138 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +class Target { + listeners = []; + + addEventListener(type, callback, options = {}) { + this.listeners.push({type, callback, options}); + } + + dispatch(event, capture) { + for (const listener of this.listeners) { + if (event.stopped) { + break; + } + if (listener.type === event.type && + !!listener.options.capture === capture && + !listener.options.signal?.aborted) { + listener.callback(event); + } + } + } +} + +const document = new Target(); +const canvas = new Target(); +const runtime = {document, AbortController}; +vm.createContext(runtime); +vm.runInContext(fs.readFileSync( + 'src/silky/internal/browsers.js', 'utf8' +), runtime); + +let editable = false; +let cancelKeydown = true; +let modifiers = 0; +let events = []; + +canvas.addEventListener('keydown', event => { + events.push(['down', event.key, modifiers]); + if (cancelKeydown) { + event.preventDefault(); + } +}, {capture: true}); +canvas.addEventListener('keyup', event => { + events.push(['up', event.key, modifiers]); +}, {capture: true}); +canvas.addEventListener('keypress', event => { + if (editable) { + events.push(['rune', event.key, modifiers]); + } +}, {capture: true}); + +function install() { + runtime.installSilkyBrowserInputs(canvas, rune => { + if (!editable) { + return false; + } + events.push(['rune', String.fromCodePoint(rune), modifiers]); + return true; + }, value => { modifiers = value; }); +} + +function send(type, key, properties = {}) { + const event = { + target: canvas, + type, + key, + defaultPrevented: false, + stopped: false, + preventDefault() { this.defaultPrevented = true; }, + stopImmediatePropagation() { this.stopped = true; }, + getModifierState(name) { return name === 'AltGraph' && this.altGraph; }, + ...properties + }; + document.dispatch(event, true); + event.target.dispatch(event, true); + document.dispatch(event, false); + assert.equal(modifiers, 0); +} + +install(); +send('keydown', 'w'); +send('keyup', 'w'); +assert.deepEqual(events.map(event => event[0]), ['down', 'up']); + +console.log('Testing characters from cancelled browser keydown events'); +editable = true; +events = []; +for (const key of ['w', 'รฉ', '็•Œ', '๐Ÿ˜€', ' ']) { + send('keydown', key); + send('keypress', key); + send('keyup', key); +} +assert.equal(events.filter(event => event[0] === 'rune') + .map(event => event[1]).join(''), 'wรฉ็•Œ๐Ÿ˜€ '); + +console.log('Testing captured modifiers and shortcut character suppression'); +events = []; +send('keydown', 'a', {metaKey: true}); +send('keyup', 'a', {metaKey: true}); +send('keydown', 'a', {ctrlKey: true}); +send('keydown', 'A', {shiftKey: true}); +send('keydown', '@', {ctrlKey: true, altKey: true, altGraph: true}); +send('keydown', 'Dead', {altKey: true}); +send('keydown', '็•Œ', {isComposing: true}); +assert.deepEqual(events.filter(event => event[0] === 'rune'), [ + ['rune', 'A', 18], ['rune', '@', 53] +]); +assert.deepEqual(events.slice(0, 3), [ + ['down', 'a', 24], ['up', 'a', 24], ['down', 'a', 17] +]); + +console.log('Testing repeated presses and normal keypress compatibility'); +events = []; +send('keydown', 'w'); +send('keydown', 'w', {repeat: true}); +send('keyup', 'w'); +cancelKeydown = false; +send('keydown', 'x'); +send('keypress', 'x'); +send('keyup', 'x'); +assert.equal(events.filter(event => event[0] === 'rune') + .map(event => event[1]).join(''), 'wwx'); + +console.log('Testing listener replacement and unrelated DOM targets'); +install(); +events = []; +cancelKeydown = true; +send('keydown', 'x'); +send('keyup', 'x'); +assert.equal(events.filter(event => event[0] === 'rune').length, 1); +events = []; +const unrelated = new Target(); +send('keydown', 'y', {target: unrelated}); +assert.deepEqual(events, []); + +console.log('All browser input adapter tests passed'); diff --git a/tests/test_inputs.nim b/tests/test_inputs.nim new file mode 100644 index 0000000..aad24b1 --- /dev/null +++ b/tests/test_inputs.nim @@ -0,0 +1,526 @@ +import + std/unicode, + vmath, + silky + +when not defined(silkyTesting): + {.error: "Compile with -d:silkyTesting".} + +type Fixture = object + harness: TestHarness + id: string + text: string + singleLine: bool + enabled: bool + password: bool + allowedChars: seq[Rune] + +var + atlas: SilkyAtlas + nextId: int + +block: + let builder = newAtlasBuilder(1024, 4) + builder.addDir("tests/data/", "tests/data/") + builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18.0) + atlas = builder.atlas + +proc fixture( + text = "Hello", + onPress: proc(button: Button) = nil, + onRelease: proc(button: Button) = nil, + onRune: proc(rune: Rune) = nil, + onFocus: proc() = nil +): Fixture = + ## Creates a real Silky test context with optional application callbacks. + inc nextId + result.id = "input" & $nextId + result.text = text + result.singleLine = true + result.enabled = true + result.harness.window = newWindow() + let window = result.harness.window + window.onButtonPress = onPress + window.onButtonRelease = onRelease + window.onRune = onRune + window.onFocusChange = onFocus + result.harness.sk = newSilky(window, atlas) + +proc frame(f: var Fixture, visible = true, duplicate = false) = + ## Draws an actual text box and finishes the input frame. + f.harness.beginFrame() + if visible: + let + sk = f.harness.sk + window = f.harness.window + for i in 0 .. ord(duplicate): + sk.at = vec2(20, 20) + sk.textBox( + window, + f.id, + f.text, + 240, + 120, + wrapWords = true, + singleLine = f.singleLine, + enabled = f.enabled, + password = f.password, + allowedChars = f.allowedChars + ) + f.harness.endFrame() + f.harness.window.resetInputState() + +proc state(f: Fixture): TextBoxState = + ## Returns the text state belonging to a fixture. + textBoxStates[f.id] + +proc focus(f: var Fixture) = + ## Clicks the field and positions the caret at the end of its text. + f.frame() + let window = f.harness.window + f.state.lastClickTime = 0 + window.moveMouse(35, 35) + window.pressButton(MouseLeft) + window.releaseButton(MouseLeft) + f.frame() + f.state.cursor = f.state.runes.len + f.state.selector = f.state.cursor + +proc tap(window: Window, button: Button) = + ## Delivers a complete key press and release without drawing. + window.pressButton(button) + window.releaseButton(button) + +proc shortcut(window: Window, button: Button, modifier = KeyLeftSuper) = + ## Delivers a shortcut entirely between UI frames. + window.pressButton(modifier) + window.tap(button) + window.releaseButton(modifier) + +proc typeText(window: Window, text: string) = + ## Sends platform text callbacks without synthesizing key events. + for rune in text.runes: + window.typeRune(rune) + +block: + echo "Testing normal keys, callback chaining, and focus activation" + var + presses, releases, runes, focuses: int + f = fixture( + onPress = (proc(button: Button) = inc presses), + onRelease = (proc(button: Button) = inc releases), + onRune = (proc(rune: Rune) = inc runes), + onFocus = (proc() = inc focuses) + ) + let + window = f.harness.window + sk = f.harness.sk + doAssert not window.runeInputEnabled + window.shortcut(KeyA) + window.typeText("ignored") + doAssert presses == 2 and releases == 2 and runes == 0 + doAssert window.buttonPressed[KeyA] and window.buttonReleased[KeyA] + doAssert not window.buttonDown[KeyA] + doAssert sk.buttonPressed[KeyA] and sk.buttonReleased[KeyA] + f.focus() + doAssert window.runeInputEnabled + f.frame() + doAssert window.runeInputEnabled + doAssert f.text == "Hello" and f.state.cursor == f.state.selector + let before = presses + window.shortcut(KeyA) + window.typeText("R") + doAssert presses == before and runes == 0 + doAssert not sk.buttonPressed[KeyA] and not sk.buttonReleased[KeyA] + f.harness.beginFrame() + f.harness.sk.at = vec2(20, 20) + f.harness.sk.textBox(window, f.id, f.text, 240, 120, false, true) + doAssert window.buttonPressed[KeyA] and window.buttonReleased[KeyA] + doAssert not window.buttonDown[KeyLeftSuper] + doAssert f.text == "R" + f.harness.endFrame() + window.resetInputState() + window.typeText("discarded") + window.changeFocus() + doAssert focuses == 1 and not window.runeInputEnabled + f.frame() + doAssert not f.state.focused and f.text == "R" + +block: + echo "Testing keyboard consumption and callback holds during text entry" + var + held: set[Button] + presses, releases: int + f = fixture( + onPress = (proc(button: Button) = + held.incl(button) + inc presses), + onRelease = (proc(button: Button) = + held.excl(button) + inc releases) + ) + let + window = f.harness.window + sk = f.harness.sk + window.pressButton(KeyW) + doAssert sk.buttonDown[KeyW] and KeyW in held + f.focus() + doAssert not sk.buttonDown[KeyW] and KeyW notin held + doAssert window.buttonDown[KeyW] + let + previousPresses = presses + previousReleases = releases + window.releaseButton(KeyW) + window.pressButton(KeyW) + window.typeText("w") + doAssert not sk.buttonDown[KeyW] + doAssert not sk.buttonPressed[KeyW] and not sk.buttonReleased[KeyW] + f.frame() + doAssert f.text == "Hellow" + doAssert presses == previousPresses and releases == previousReleases + window.releaseButton(KeyW) + window.pressButton(MouseRight) + doAssert sk.buttonDown[MouseRight] and sk.buttonPressed[MouseRight] + doAssert MouseRight in held + window.releaseButton(MouseRight) + doAssert sk.buttonReleased[MouseRight] and MouseRight notin held + doAssert presses == previousPresses + 1 + doAssert releases == previousReleases + 1 + window.pressButton(KeyA) + f.frame(visible = false) + window.releaseButton(KeyA) + doAssert releases == previousReleases + 1 + window.pressButton(KeyD) + doAssert sk.buttonDown[KeyD] and sk.buttonPressed[KeyD] + doAssert KeyD in held + window.releaseButton(KeyD) + doAssert sk.buttonReleased[KeyD] and KeyD notin held + doAssert presses == previousPresses + 2 + doAssert releases == previousReleases + 2 + +block: + echo "Testing inactive input never accumulates a text batch" + var + runes: int + f = fixture(onRune = (proc(rune: Rune) = inc runes)) + let window = f.harness.window + for i in 0 ..< 65_537: + window.tap(KeyA) + window.onRune(Rune('a')) + doAssert runes == 65_537 + f.focus() + doAssert f.text == "Hello" + +block: + echo "Testing quick shortcuts and same-frame replacement" + for modifier in [ + KeyLeftSuper, KeyRightSuper, KeyLeftControl, KeyRightControl + ]: + var f = fixture() + f.focus() + let window = f.harness.window + window.shortcut(KeyA, modifier) + window.typeText("R") + f.frame() + doAssert f.text == "R" + doAssert f.state.cursor == 1 and f.state.selector == 1 + f.frame() + doAssert f.text == "R" + +block: + echo "Testing shortcut selection followed by typing next frame" + var f = fixture() + f.focus() + let window = f.harness.window + window.shortcut(KeyA) + f.frame() + doAssert f.state.selection == (0 .. 5) + window.typeText("Next") + f.frame() + doAssert f.text == "Next" + +block: + echo "Testing text before shortcuts and nonoverlapping modifiers" + var f = fixture() + f.focus() + let window = f.harness.window + window.typeText("X") + window.shortcut(KeyA) + f.frame() + doAssert f.text == "HelloX" and f.state.selection == (0 .. 6) + window.typeText("R") + window.tap(KeyLeftSuper) + window.tap(KeyA) + window.typeText("a") + f.frame() + doAssert f.text == "Ra" and f.state.selection == (2 .. 2) + window.pressButton(KeyLeftSuper) + f.frame() + window.tap(KeyA) + window.releaseButton(KeyLeftSuper) + window.typeText("S") + f.frame() + doAssert f.text == "S" + +block: + echo "Testing repeated presses, selection, undo, and multiple shortcuts" + var f = fixture() + f.focus() + let window = f.harness.window + window.pressButton(KeyBackspace) + window.pressButton(KeyBackspace) + window.releaseButton(KeyBackspace) + window.shortcut(KeyA) + window.typeText("World") + window.shortcut(KeyA) + window.typeText("Q") + window.shortcut(KeyZ) + window.shortcut(KeyY) + f.frame() + doAssert f.text == "Q" + window.typeText("ab") + window.pressButton(KeyLeftShift) + window.tap(KeyLeft) + window.tap(KeyLeft) + window.releaseButton(KeyLeftShift) + window.typeText("!") + f.frame() + doAssert f.text == "Q!" + +block: + echo "Testing Unicode, password, filtering, and single-line input" + var f = fixture("") + f.password = true + f.focus() + let window = f.harness.window + window.typeText("รฉไธ–็•Œ") + window.tap(KeyEnter) + f.frame() + doAssert f.text == "รฉไธ–็•Œ" + doAssert f.state.displayText == "***" + f.allowedChars = "0123456789".toRunes + f.frame() + window.shortcut(KeyA) + window.typeText("a12b3") + f.frame() + doAssert f.text == "123" + +block: + echo "Testing disabled fields retain selection and copy without editing" + var f = fixture() + f.focus() + let window = f.harness.window + window.typeText("!") + f.frame() + f.enabled = false + f.frame() + doAssert not window.runeInputEnabled + window.shortcut(KeyA) + window.shortcut(KeyC) + window.shortcut(KeyX) + window.shortcut(KeyV) + window.shortcut(KeyZ) + window.shortcut(KeyY) + window.tap(KeyBackspace) + window.typeText("ignored") + f.frame() + doAssert f.text == "Hello!" and getClipboardString() == "Hello!" + doAssert f.state.selection == (0 .. 6) + +block: + echo "Testing focus loss, disappearing fields, and field switching" + var f = fixture() + f.focus() + let window = f.harness.window + window.typeText("discarded") + window.moveMouse(600, 400) + window.tap(MouseLeft) + f.frame() + doAssert f.text == "Hello" and not window.runeInputEnabled + f.focus() + window.typeText("discarded") + f.frame(visible = false) + doAssert not window.runeInputEnabled + window.typeText("ignored") + f.frame() + doAssert not f.state.focused and f.text == "Hello" + f.focus() + window.typeText("discarded") + f.id.add("other") + f.text = "Other" + f.frame() + doAssert f.text == "Other" and not window.runeInputEnabled + f.focus() + window.typeText("!") + f.frame() + doAssert f.text == "Other!" + +block: + echo "Testing one-batch editing matches separate frames and layout" + var fixtures = [fixture(""), fixture("")] + for f in fixtures.mitems: + f.singleLine = false + f.focus() + for i, f in fixtures.mpairs: + let window = f.harness.window + for step in 0 .. 8: + case step + of 0: + window.typeText("A long line that wraps across several rows.") + of 1: + window.tap(KeyEnter) + of 2: + window.typeText("Second line") + of 3: + window.tap(KeyHome) + of 4: + window.tap(KeyUp) + of 5: + window.typeText("!") + of 6: + window.tap(KeyDown) + of 7: + window.tap(KeyBackspace) + else: + window.typeText("รฉ") + if i == 1: + f.frame() + if i == 0: + f.frame() + doAssert fixtures[0].text == fixtures[1].text + doAssert fixtures[0].state.cursor == fixtures[1].state.cursor + doAssert fixtures[0].state.selection == fixtures[1].state.selection + +block: + echo "Testing no duplicate consumption and events arriving after a drain" + var f = fixture("") + f.focus() + let + window = f.harness.window + sk = f.harness.sk + window.typeText("one") + f.frame(duplicate = true) + doAssert f.text == "one" + f.harness.beginFrame() + sk.at = vec2(20, 20) + sk.textBox(window, f.id, f.text, 240, 120, false, true) + window.typeText("two") + f.harness.endFrame() + doAssert f.text == "one" + f.frame() + doAssert f.text == "onetwo" + +block: + echo "Testing input queues are isolated between contexts" + var + first = fixture() + second = fixture("Other") + first.focus() + second.focus() + first.harness.window.shortcut(KeyA) + first.harness.window.typeText("First") + second.harness.window.typeText("!") + second.frame() + first.frame() + doAssert first.text == "First" and second.text == "Other!" + +block: + echo "Testing overflow reports an error and retains accepted events" + var f = fixture() + f.focus() + let window = f.harness.window + window.shortcut(KeyA) + for i in 0 ..< 65_532: + window.pressButton(KeyLeftShift) + var failed = false + try: + window.typeText("overflow") + except SilkyError: + failed = true + doAssert failed + f.frame() + doAssert f.text == "Hello" and f.state.selection == (0 .. 5) + window.releaseButton(KeyLeftShift) + window.typeText("R") + f.frame() + doAssert f.text == "R" + +block: + echo "Testing application callbacks assigned after construction" + var + calls: array[4, int] + f = fixture() + let window = f.harness.window + window.onButtonPress = proc(button: Button) = inc calls[0] + window.onButtonRelease = proc(button: Button) = inc calls[1] + window.onRune = proc(rune: Rune) = inc calls[2] + window.onFocusChange = proc() = inc calls[3] + f.frame() + window.tap(KeyW) + window.onRune(Rune('w')) + doAssert calls == [1, 1, 1, 0] + f.focus() + let before = calls + window.shortcut(KeyA) + window.typeText("R") + f.frame() + doAssert f.text == "R" and calls == before + window.changeFocus() + f.frame() + doAssert calls[3] == 1 and not f.state.focused + window.tap(KeyW) + doAssert calls[0] == before[0] + 1 + doAssert calls[1] == before[1] + 1 + +block: + echo "Testing changed callbacks preserve input and run once" + for slot in 0 .. 3: + var + calls: int + f = fixture() + let window = f.harness.window + f.focus() + window.typeText("!") + case slot + of 0: + window.onButtonPress = proc(button: Button) = inc calls + of 1: + window.onButtonRelease = proc(button: Button) = inc calls + of 2: + window.onRune = proc(rune: Rune) = inc calls + else: + window.onFocusChange = proc() = inc calls + f.frame() + doAssert f.text == "Hello!" and f.state.focused + for i in 0 ..< 5: + window.shortcut(KeyA) + window.typeText("R") + f.frame() + doAssert f.text == "R" and calls == 0 + window.changeFocus() + f.frame() + window.tap(KeyW) + window.onRune(Rune('w')) + doAssert calls == 1 and not f.state.focused + +block: + echo "Testing cleared callbacks are set up again automatically" + var f = fixture() + let window = f.harness.window + f.focus() + window.typeText("!") + window.onButtonPress = nil + window.onButtonRelease = nil + window.onRune = nil + window.onFocusChange = nil + f.frame() + doAssert f.text == "Hello!" and f.state.focused + window.shortcut(KeyA) + window.typeText("R") + f.frame() + doAssert f.text == "R" + window.changeFocus() + f.frame() + doAssert not f.state.focused and not window.runeInputEnabled + +echo "All ordered text input tests passed" From cb63eac539d3be3e64eefcd348da3bebe92f4140 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 9 Sep 2026 16:19:52 -0700 Subject: [PATCH 2/3] Use Windy browser input callbacks --- .github/workflows/build.yml | 3 - .gitignore | 1 - docs/text_inputs.md | 7 +- nimby.lock | 2 +- src/silky/internal/browsers.js | 56 ------------- src/silky/internal/browsers.nim | 43 ---------- src/silky/internal/inputs.nim | 28 +------ src/silky/textboxes.nim | 3 +- tests/test_browser_inputs.cjs | 138 -------------------------------- tests/test_inputs.nim | 14 ++++ 10 files changed, 21 insertions(+), 274 deletions(-) delete mode 100644 src/silky/internal/browsers.js delete mode 100644 src/silky/internal/browsers.nim delete mode 100644 tests/test_browser_inputs.cjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 910ecee..c4287a7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,9 +54,6 @@ jobs: if: matrix.emscripten uses: mymindstorm/setup-emsdk@v14 - - name: Test browser input adapter - run: node tests/test_browser_inputs.cjs - - name: Run unit tests if: ${{ !matrix.emscripten }} run: | diff --git a/.gitignore b/.gitignore index 46ca81f..bc6dd93 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ *.data *.wasm *.js -!src/silky/internal/browsers.js *.a *.o *.so diff --git a/docs/text_inputs.md b/docs/text_inputs.md index e4e88d8..33cee17 100644 --- a/docs/text_inputs.md +++ b/docs/text_inputs.md @@ -26,11 +26,8 @@ pending text edits in arrival order before drawing, using modifiers captured with each event. There is no setup call or public queue to manage. Input beyond the internal limit of 65,536 pending events raises `SilkyError`. -Emscripten builds automatically capture modifiers from DOM keyboard events. -When Windy cancels a printable keydown and prevents its keypress, Silky delivers -the browser's character through the rune callback. Shortcut keys do not insert -characters, and a corresponding keypress cannot insert the character twice. -This requires no application setup and does not add IME composition support. +Native and Emscripten builds use the same Windy callbacks for text and modifier +state. No browser-specific input setup is needed. Run `nim r tests/manual_inputs.nim` from the repository root to try single-line, password, and multiline fields with a keyboard display. Hold keys outside the diff --git a/nimby.lock b/nimby.lock index fea1329..84503ee 100644 --- a/nimby.lock +++ b/nimby.lock @@ -8,7 +8,7 @@ nimsimd 1.3.2 https://github.com/guzba/nimsimd 3f6b2668ffb0867d0bf786a658b817763 bumpy 1.1.3 https://github.com/treeform/bumpy dc9a3d6d15680ec7f603959d65ba8ee4f1ddbc72 crunchy 0.1.11 https://github.com/guzba/crunchy 98eb6526982bb8aae8eec6e8781f4539fa19e049 bitty 0.1.4 https://github.com/treeform/bitty/ c40e81277c04befa37234cba5052f58648bef895 -windy 0.5.0 https://github.com/treeform/windy 5971abb08f416cfb8a3603a3093503eb07f2f5c4 +windy 0.5.0 https://github.com/treeform/windy 234b2dec841ea1a72621c36aa791a92e6898aaba opengl 1.2.9 https://github.com/nim-lang/opengl 8e2e098f82dc5eefd874488c37b5830233cd18f4 urlly 1.1.1 https://github.com/treeform/urlly 99784779f05649df25fd9c33003d8ef6de027345 ws 0.5.0 https://github.com/treeform/ws cbb8f763b436669392d10baec2a45778395395cc diff --git a/src/silky/internal/browsers.js b/src/silky/internal/browsers.js deleted file mode 100644 index 5dd58e1..0000000 --- a/src/silky/internal/browsers.js +++ /dev/null @@ -1,56 +0,0 @@ -function installSilkyBrowserInputs(canvas, receiveRune, receiveModifiers) { - // The Emscripten window uses one canvas for its lifetime. - if (canvas.silkyInputListeners) { - canvas.silkyInputListeners.abort(); - } - const controller = new AbortController(); - canvas.silkyInputListeners = controller; - const options = {capture: true, signal: controller.signal}; - let fallbackKey = null; - - function beforeKeyboard(event) { - if (event.target !== canvas) { - return; - } - if (event.type === 'keydown' || event.type === 'keyup') { - fallbackKey = null; - } - if (event.type === 'keypress' && fallbackKey === event.key) { - // Some synthetic event sources send keypress even after cancellation. - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - receiveModifiers(16 | (event.ctrlKey ? 1 : 0) | - (event.shiftKey ? 2 : 0) | (event.altKey ? 4 : 0) | - (event.metaKey ? 8 : 0) | - (event.getModifierState('AltGraph') ? 32 : 0)); - } - - function afterKeyboard(event) { - if (event.target === canvas) { - receiveModifiers(0); - } - } - - for (const type of ['keydown', 'keyup', 'keypress']) { - // Document capture runs before Windy's canvas callbacks. - document.addEventListener(type, beforeKeyboard, options); - document.addEventListener(type, afterKeyboard, { - signal: controller.signal - }); - } - - canvas.addEventListener('keydown', function(event) { - // Windy cancels keydown, suppressing the keypress that supplies its runes. - if (!event.defaultPrevented || event.isComposing || event.metaKey || - (event.ctrlKey && !event.getModifierState('AltGraph'))) { - return; - } - const characters = Array.from(event.key); - if (characters.length === 1 && - receiveRune(characters[0].codePointAt(0))) { - fallbackKey = event.key; - } - }, options); -} diff --git a/src/silky/internal/browsers.nim b/src/silky/internal/browsers.nim deleted file mode 100644 index 0366f28..0000000 --- a/src/silky/internal/browsers.nim +++ /dev/null @@ -1,43 +0,0 @@ -import - std/unicode, - windy - -type BrowserInputs* = ref object - windowPointer: pointer - modifiers*: int32 - -{.emit: """ -#include -EM_JS(void, silkyBrowserInit, (void *context), { -""" & staticRead("browsers.js") & """ - installSilkyBrowserInputs( - document.querySelector('#canvas'), - (rune) => _silkyBrowserRune(context, rune), - (modifiers) => _silkyBrowserModifiers(context, modifiers) - ); -}); -""".} - -proc silkyBrowserRune(context: pointer, value: int32): cint - {.exportc, cdecl, codegenDecl: "EMSCRIPTEN_KEEPALIVE $# $#$#".} = - ## Delivers printable browser text through the normal rune callback. - let - inputs = cast[BrowserInputs](context) - window = cast[Window](inputs.windowPointer) - if window.runeInputEnabled and window.onRune != nil and - value >= 32 and value notin 127 .. 159: - window.onRune(Rune(value)) - return 1 - -proc silkyBrowserModifiers(context: pointer, modifiers: int32) - {.exportc, cdecl, codegenDecl: "EMSCRIPTEN_KEEPALIVE $# $#$#".} = - ## Captures DOM modifiers before Windy's keyboard callback runs. - cast[BrowserInputs](context).modifiers = modifiers - -proc silkyBrowserInit(context: pointer) {.importc, nodecl.} - ## Installs the browser listeners for this input state. - -proc newBrowserInputs*(window: Window): BrowserInputs = - ## Bridges browser characters and modifiers to Windy's callbacks. - result = BrowserInputs(windowPointer: cast[pointer](window)) - silkyBrowserInit(cast[pointer](result)) diff --git a/src/silky/internal/inputs.nim b/src/silky/internal/inputs.nim index 97724da..e301f83 100644 --- a/src/silky/internal/inputs.nim +++ b/src/silky/internal/inputs.nim @@ -7,9 +7,6 @@ when defined(silkyTesting): else: import windy -when defined(emscripten) and not defined(silkyTesting): - import browsers - const MaxPendingEvents = 65_536 type @@ -21,7 +18,6 @@ type button*: Button rune*: Rune control*, shift*, alt*, super*: bool - altGraph*: bool CallbackIdentity = tuple[code, environment: pointer] @@ -37,8 +33,6 @@ type forwardedKeys: set[Button] consumedKeys: set[Button] releaseCallback: proc(button: Button) - when defined(emscripten) and not defined(silkyTesting): - browser: BrowserInputs proc identity[T](callback: T): CallbackIdentity = ## Identifies a callback without retaining its closure environment. @@ -78,15 +72,6 @@ proc stopInput*(inputs: TextInputs, window: Window) = proc modifiers(inputs: TextInputs, window: Window): InputEvent = ## Captures modifier state at the time of a callback. - when defined(emscripten) and not defined(silkyTesting): - let bits = inputs.browser.modifiers - if (bits and 16) != 0: - result.control = (bits and 1) != 0 - result.shift = (bits and 2) != 0 - result.alt = (bits and 4) != 0 - result.super = (bits and 8) != 0 - result.altGraph = (bits and 32) != 0 - return result.control = window.buttonDown[KeyLeftControl] or window.buttonDown[KeyRightControl] result.shift = window.buttonDown[KeyLeftShift] or @@ -96,13 +81,6 @@ proc modifiers(inputs: TextInputs, window: Window): InputEvent = result.super = window.buttonDown[KeyLeftSuper] or window.buttonDown[KeyRightSuper] -proc isKeyboard(inputs: TextInputs, button: Button): bool = - ## Includes browser keys that Windy does not map to a button yet. - when defined(emscripten) and not defined(silkyTesting): - if (inputs.browser.modifiers and 16) != 0: - return true - button >= Key0 - proc addEvent(inputs: TextInputs, event: InputEvent) = ## Appends an event without coalescing or dropping earlier events. if inputs.events.len >= MaxPendingEvents: @@ -119,7 +97,7 @@ proc overrideCallbacks( let previousPress = window.onButtonPress window.onButtonPress = proc(button: Button) = let window = cast[Window](windowPointer) - if inputs.active and inputs.isKeyboard(button): + if inputs.active and button >= Key0: var event = inputs.modifiers(window) event.kind = KeyDown event.button = button @@ -137,7 +115,7 @@ proc overrideCallbacks( inputs.releaseCallback = previousRelease window.onButtonRelease = proc(button: Button) = let window = cast[Window](windowPointer) - if inputs.active and inputs.isKeyboard(button): + if inputs.active and button >= Key0: var event = inputs.modifiers(window) event.kind = KeyUp event.button = button @@ -177,8 +155,6 @@ proc overrideCallbacks( proc newTextInputs*(window: Window): TextInputs = ## Sets up all text input handling directly from the window. result = TextInputs() - when defined(emscripten) and not defined(silkyTesting): - result.browser = newBrowserInputs(window) result.overrideCallbacks(window, initialize = true) result.stopInput(window) diff --git a/src/silky/textboxes.nim b/src/silky/textboxes.nim index 1e716b9..e2aca6b 100644 --- a/src/silky/textboxes.nim +++ b/src/silky/textboxes.nim @@ -699,8 +699,9 @@ proc scrollBy*(state: TextBoxState, amount, viewportHeight: float32) = proc handleKey(state: TextBoxState, event: InputEvent) = ## Applies one keyboard press using its captured modifiers. + # AltGr can appear as Control+Alt and must not trigger editing shortcuts. let - ctrl = event.super or (event.control and not event.altGraph) + ctrl = event.super or (event.control and not event.alt) shift = event.shift when defined(macosx): let diff --git a/tests/test_browser_inputs.cjs b/tests/test_browser_inputs.cjs deleted file mode 100644 index 62722f5..0000000 --- a/tests/test_browser_inputs.cjs +++ /dev/null @@ -1,138 +0,0 @@ -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const vm = require('node:vm'); - -class Target { - listeners = []; - - addEventListener(type, callback, options = {}) { - this.listeners.push({type, callback, options}); - } - - dispatch(event, capture) { - for (const listener of this.listeners) { - if (event.stopped) { - break; - } - if (listener.type === event.type && - !!listener.options.capture === capture && - !listener.options.signal?.aborted) { - listener.callback(event); - } - } - } -} - -const document = new Target(); -const canvas = new Target(); -const runtime = {document, AbortController}; -vm.createContext(runtime); -vm.runInContext(fs.readFileSync( - 'src/silky/internal/browsers.js', 'utf8' -), runtime); - -let editable = false; -let cancelKeydown = true; -let modifiers = 0; -let events = []; - -canvas.addEventListener('keydown', event => { - events.push(['down', event.key, modifiers]); - if (cancelKeydown) { - event.preventDefault(); - } -}, {capture: true}); -canvas.addEventListener('keyup', event => { - events.push(['up', event.key, modifiers]); -}, {capture: true}); -canvas.addEventListener('keypress', event => { - if (editable) { - events.push(['rune', event.key, modifiers]); - } -}, {capture: true}); - -function install() { - runtime.installSilkyBrowserInputs(canvas, rune => { - if (!editable) { - return false; - } - events.push(['rune', String.fromCodePoint(rune), modifiers]); - return true; - }, value => { modifiers = value; }); -} - -function send(type, key, properties = {}) { - const event = { - target: canvas, - type, - key, - defaultPrevented: false, - stopped: false, - preventDefault() { this.defaultPrevented = true; }, - stopImmediatePropagation() { this.stopped = true; }, - getModifierState(name) { return name === 'AltGraph' && this.altGraph; }, - ...properties - }; - document.dispatch(event, true); - event.target.dispatch(event, true); - document.dispatch(event, false); - assert.equal(modifiers, 0); -} - -install(); -send('keydown', 'w'); -send('keyup', 'w'); -assert.deepEqual(events.map(event => event[0]), ['down', 'up']); - -console.log('Testing characters from cancelled browser keydown events'); -editable = true; -events = []; -for (const key of ['w', 'รฉ', '็•Œ', '๐Ÿ˜€', ' ']) { - send('keydown', key); - send('keypress', key); - send('keyup', key); -} -assert.equal(events.filter(event => event[0] === 'rune') - .map(event => event[1]).join(''), 'wรฉ็•Œ๐Ÿ˜€ '); - -console.log('Testing captured modifiers and shortcut character suppression'); -events = []; -send('keydown', 'a', {metaKey: true}); -send('keyup', 'a', {metaKey: true}); -send('keydown', 'a', {ctrlKey: true}); -send('keydown', 'A', {shiftKey: true}); -send('keydown', '@', {ctrlKey: true, altKey: true, altGraph: true}); -send('keydown', 'Dead', {altKey: true}); -send('keydown', '็•Œ', {isComposing: true}); -assert.deepEqual(events.filter(event => event[0] === 'rune'), [ - ['rune', 'A', 18], ['rune', '@', 53] -]); -assert.deepEqual(events.slice(0, 3), [ - ['down', 'a', 24], ['up', 'a', 24], ['down', 'a', 17] -]); - -console.log('Testing repeated presses and normal keypress compatibility'); -events = []; -send('keydown', 'w'); -send('keydown', 'w', {repeat: true}); -send('keyup', 'w'); -cancelKeydown = false; -send('keydown', 'x'); -send('keypress', 'x'); -send('keyup', 'x'); -assert.equal(events.filter(event => event[0] === 'rune') - .map(event => event[1]).join(''), 'wwx'); - -console.log('Testing listener replacement and unrelated DOM targets'); -install(); -events = []; -cancelKeydown = true; -send('keydown', 'x'); -send('keyup', 'x'); -assert.equal(events.filter(event => event[0] === 'rune').length, 1); -events = []; -const unrelated = new Target(); -send('keydown', 'y', {target: unrelated}); -assert.deepEqual(events, []); - -console.log('All browser input adapter tests passed'); diff --git a/tests/test_inputs.nim b/tests/test_inputs.nim index aad24b1..5a95153 100644 --- a/tests/test_inputs.nim +++ b/tests/test_inputs.nim @@ -287,6 +287,20 @@ block: f.frame() doAssert f.text == "Q!" +block: + echo "Testing AltGr characters do not trigger Control shortcuts" + var f = fixture("Hello") + f.focus() + let window = f.harness.window + window.pressButton(KeyLeftControl) + window.pressButton(KeyRightAlt) + window.tap(KeyA) + window.typeText("ฤ…") + window.releaseButton(KeyRightAlt) + window.releaseButton(KeyLeftControl) + f.frame() + doAssert f.text == "Helloฤ…" + block: echo "Testing Unicode, password, filtering, and single-line input" var f = fixture("") From b9de7dbb0419a65996355f9904e4a2e005345d02 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 9 Sep 2026 16:37:43 -0700 Subject: [PATCH 3/3] Update Windy input fix reference --- nimby.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nimby.lock b/nimby.lock index 84503ee..cec4b91 100644 --- a/nimby.lock +++ b/nimby.lock @@ -8,7 +8,7 @@ nimsimd 1.3.2 https://github.com/guzba/nimsimd 3f6b2668ffb0867d0bf786a658b817763 bumpy 1.1.3 https://github.com/treeform/bumpy dc9a3d6d15680ec7f603959d65ba8ee4f1ddbc72 crunchy 0.1.11 https://github.com/guzba/crunchy 98eb6526982bb8aae8eec6e8781f4539fa19e049 bitty 0.1.4 https://github.com/treeform/bitty/ c40e81277c04befa37234cba5052f58648bef895 -windy 0.5.0 https://github.com/treeform/windy 234b2dec841ea1a72621c36aa791a92e6898aaba +windy 0.5.0 https://github.com/treeform/windy d3d92f5decf22f98c61d198d6285427c83f67b60 opengl 1.2.9 https://github.com/nim-lang/opengl 8e2e098f82dc5eefd874488c37b5830233cd18f4 urlly 1.1.1 https://github.com/treeform/urlly 99784779f05649df25fd9c33003d8ef6de027345 ws 0.5.0 https://github.com/treeform/ws cbb8f763b436669392d10baec2a45778395395cc