diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5c735a..c4287a7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,6 +58,7 @@ jobs: 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 +75,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/docs/text_inputs.md b/docs/text_inputs.md new file mode 100644 index 0000000..33cee17 --- /dev/null +++ b/docs/text_inputs.md @@ -0,0 +1,34 @@ +# 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`. + +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 +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/nimby.lock b/nimby.lock index fea1329..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 5971abb08f416cfb8a3603a3093503eb07f2f5c4 +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 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/inputs.nim b/src/silky/internal/inputs.nim new file mode 100644 index 0000000..e301f83 --- /dev/null +++ b/src/silky/internal/inputs.nim @@ -0,0 +1,206 @@ +import + std/unicode, + silky/common + +when defined(silkyTesting): + import silky/testwindow +else: + import windy + +const MaxPendingEvents = 65_536 + +type + InputKind* = enum + KeyDown, KeyUp, TextInput + + InputEvent* = object + kind*: InputKind + button*: Button + rune*: Rune + control*, shift*, alt*, super*: 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) + +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. + 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 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 button >= Key0: + 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 button >= Key0: + 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() + 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..e2aca6b 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,139 @@ 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. + # AltGr can appear as Control+Alt and must not trigger editing shortcuts. + let + ctrl = event.super or (event.control and not event.alt) + 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 +931,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 +965,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 +996,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_inputs.nim b/tests/test_inputs.nim new file mode 100644 index 0000000..5a95153 --- /dev/null +++ b/tests/test_inputs.nim @@ -0,0 +1,540 @@ +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 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("") + 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"