From 6ee5d16b92615da019f29f78d6aae246c18f28e9 Mon Sep 17 00:00:00 2001 From: Richard Higgins Date: Tue, 8 Sep 2026 19:10:19 -0700 Subject: [PATCH 1/3] Preserve clicks whose press and release arrive in one frame --- .github/workflows/build.yml | 1 + src/silky/widgets.nim | 6 +++-- tests/test_interactions.nim | 49 +++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/test_interactions.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c4287a7..8979fd1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,6 +57,7 @@ jobs: - name: Run unit tests if: ${{ !matrix.emscripten }} run: | + nim r -d:silkyTesting tests/test_interactions.nim nim r tests/test_textboxes.nim nim r -d:silkyTesting tests/test_inputs.nim nim r tests/test_atlas_png.nim diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index 247c1ff..bd3f9f5 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -98,12 +98,14 @@ proc interact*( return ReleasedOutside if not hover: return None + # A complete click can arrive between frames. Use the final button state + # to distinguish it from a release followed by another press. + if released and not down: + return Released if pressed: return Pressed if down: return Held - if released: - return Released return Hovered proc vec2(v: SomeNumber): Vec2 = diff --git a/tests/test_interactions.nim b/tests/test_interactions.nim new file mode 100644 index 0000000..9bb1a9c --- /dev/null +++ b/tests/test_interactions.nim @@ -0,0 +1,49 @@ +## Mouse event batches must not lose release activation between rendered frames. +## Run with: nim r -d:silkyTesting tests/test_interactions.nim +import bumpy, silky + +let window = newWindow(200, 200) +let sk = Silky(window: window) +let target = rect(20'f32, 20'f32, 100'f32, 40'f32) + +proc sample(enabled = true): Interaction = + sk.beginUI(window, window.size) + result = sk.interact(target, enabled) + sk.endUi() + window.resetInputState() + +window.moveMouse(50, 30) +discard sample() +doAssert sample() == Hovered +window.pressButton(MouseLeft) +doAssert sample() == Pressed +doAssert sample() == Held +window.releaseButton(MouseLeft) +doAssert sample() == Released +discard sample() + +# A full click between two frames used to return Pressed and lose the release. +window.pressButton(MouseLeft) +window.releaseButton(MouseLeft) +doAssert sample() == Released +doAssert sample() == Hovered + +window.pressButton(MouseLeft) +discard sample() +window.releaseButton(MouseLeft) +window.pressButton(MouseLeft) +doAssert sample() == Pressed +doAssert sample() == Held +window.releaseButton(MouseLeft) +doAssert sample() == Released +discard sample() + +window.pressButton(MouseLeft) +window.releaseButton(MouseLeft) +doAssert sample(false) == Disabled + +window.pressButton(MouseLeft) +window.moveMouse(190, 190) +window.releaseButton(MouseLeft) +doAssert sample() == ReleasedOutside +echo "Batched mouse interaction tests passed" From babbfa81d16cb71b9127d1dd8f7f6e846177bfe2 Mon Sep 17 00:00:00 2001 From: Richard Higgins Date: Wed, 9 Sep 2026 09:21:28 -0700 Subject: [PATCH 2/3] Preserve completed clicks in press-driven widgets --- src/silky/widgets.nim | 12 +++++--- tests/test_interactions.nim | 55 ++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index bd3f9f5..cd4f4ab 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -108,6 +108,10 @@ proc interact*( return Held return Hovered +proc pressedThisFrame(sk: Silky, interaction: Interaction): bool = + # A completed click still carries a press, without repeating normal releases. + sk.buttonPressed[MouseLeft] and interaction in [Pressed, Released] + proc vec2(v: SomeNumber): Vec2 = ## Create a Vec2 from a number. vec2(v.float32, v.float32) @@ -191,7 +195,7 @@ proc subWindowStart*( ) minimizeInteraction = sk.interact(minimizeRect, true) - if minimizeInteraction == Pressed: + if sk.pressedThisFrame(minimizeInteraction): subWindowState.minimized = not subWindowState.minimized if subWindowState.minimized: @@ -564,7 +568,7 @@ template clickableIcon*(image: string, on: bool, body) = if sk.hover: sk.tooltipAnchor = iconRect - if interaction == Pressed: + if sk.pressedThisFrame(interaction): body sk.drawImage(image, sk.at, color) @@ -921,7 +925,7 @@ template scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: handleReleased = handleInteraction in [Released, ReleasedOutside] controlReleased = controlInteraction in [Released, ReleasedOutside] released = handleReleased or controlReleased - pressed = handleInteraction == Pressed or controlInteraction == Pressed + pressed = sk.pressedThisFrame(handleInteraction) or sk.pressedThisFrame(controlInteraction) # Dragging logic. if scrubState.dragging and released: @@ -931,7 +935,7 @@ template scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: let t = clamp((sk.mousePos.x - trackStart) / travelSafe, 0f, 1f) value = (minF + t * range).T elif pressed: - scrubState.dragging = true + scrubState.dragging = sk.buttonDown[MouseLeft] let t = clamp((sk.mousePos.x - trackStart) / travelSafe, 0f, 1f) value = (minF + t * range).T diff --git a/tests/test_interactions.nim b/tests/test_interactions.nim index 9bb1a9c..4cbccf8 100644 --- a/tests/test_interactions.nim +++ b/tests/test_interactions.nim @@ -1,6 +1,7 @@ ## Mouse event batches must not lose release activation between rendered frames. ## Run with: nim r -d:silkyTesting tests/test_interactions.nim -import bumpy, silky +import std/[options, tables] +import bumpy, silky, vmath let window = newWindow(200, 200) let sk = Silky(window: window) @@ -47,3 +48,55 @@ window.moveMouse(190, 190) window.releaseButton(MouseLeft) doAssert sample() == ReleasedOutside echo "Batched mouse interaction tests passed" + +# Exercise the actual press-driven controls, using a small semantic atlas. +block: + let atlas = SilkyAtlas(size: 64) + for name in ["heart", "maximized", "minimized", "close", "scrubber.handle"]: + atlas.entries[name] = Entry(width: 16, height: 16) + let window = newWindow(240, 240) + let sk = newSilky(window, atlas) + var icons = 0 + var volume = 0'f32 + var show = true + var panel: SubWindowState + window.onFrame = proc() = + sk.beginUI(window, window.size) + sk.at = vec2(20, 20) + clickableIcon "heart", true: inc icons + sk.at = vec2(20, 60) + scrubber "batch-test", volume, 0'f32, 1'f32 + panel = sk.subWindowStart(window, "batch-window", show, + some(vec2(20, 120)), some(vec2(180, 100))) + sk.subWindowEnd(window, panel) + sk.endUi() + + proc clickAt(x, y: int, completed: bool) = + window.moveMouse(x, y) + window.pumpFrame(sk) + window.pumpFrame(sk) + window.pressButton(MouseLeft) + if not completed: + window.pumpFrame(sk) + window.releaseButton(MouseLeft) + window.pumpFrame(sk) + window.pumpFrame(sk) + + for completed in [false, true]: + let before = icons + clickAt(24, 24, completed) + doAssert icons == before + 1 + volume = 0 + clickAt(120, 64, completed) + doAssert volume > 0.3 and volume < 0.7 + let stopped = volume + window.moveMouse(40, 64) + window.pumpFrame(sk) + doAssert volume == stopped, "released scrubber must not keep dragging" + clickAt(24 + sk.theme.border + sk.theme.textPadding, + 124 + sk.theme.border + sk.theme.textPadding, completed) + doAssert panel.minimized + clickAt(24 + sk.theme.border + sk.theme.textPadding, + 124 + sk.theme.border + sk.theme.textPadding, completed) + doAssert not panel.minimized + echo "Press-driven widget callback tests passed" From 5e1fb72aaa10bbc5a393995f2dc6ab4ae3e7b880 Mon Sep 17 00:00:00 2001 From: Richard Higgins Date: Sun, 20 Sep 2026 10:30:14 -0700 Subject: [PATCH 3/3] Update click regression fixture for current Silky API --- tests/test_interactions.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_interactions.nim b/tests/test_interactions.nim index 4cbccf8..a20d42b 100644 --- a/tests/test_interactions.nim +++ b/tests/test_interactions.nim @@ -4,7 +4,7 @@ import std/[options, tables] import bumpy, silky, vmath let window = newWindow(200, 200) -let sk = Silky(window: window) +let sk = newSilky(window, SilkyAtlas(size: 64)) let target = rect(20'f32, 20'f32, 100'f32, 40'f32) proc sample(enabled = true): Interaction =