diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5c735a..10500e4 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 tests/test_atlas_png.nim nim r -d:silkyTesting tests/test_layout.nim diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index 247c1ff..b28e148 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -98,14 +98,20 @@ 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 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) @@ -189,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: @@ -562,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) @@ -698,8 +704,12 @@ template dropDown*[T](selected: var T, options: openArray[T]) = let rowHeight = height - popupPos = vec2(dropRect.x, dropRect.y + dropRect.h) - popupSize = vec2(width, rowHeight * options.len.float32) + popupSize = vec2(min(width, sk.rootSize.x), rowHeight * options.len.float32) + below = dropRect.y + dropRect.h + popupY = if below + popupSize.y <= sk.rootSize.y: below + else: dropRect.y - popupSize.y + popupPos = clamp(vec2(dropRect.x, popupY), vec2(0), + max(sk.rootSize - popupSize, vec2(0))) popupRect = rect(popupPos, popupSize) sk.pushLayout(popupPos, popupSize) @@ -708,7 +718,7 @@ template dropDown*[T](selected: var T, options: openArray[T]) = for i, opt in options: let rowPos = vec2(sk.pos.x, sk.pos.y + i.float32 * rowHeight) - rowRect = rect(rowPos, vec2(width, rowHeight)) + rowRect = rect(rowPos, vec2(popupSize.x, rowHeight)) textPos = rowPos + vec2(sk.theme.padding) isSelected = selected == opt interaction = sk.interact(rowRect, true) @@ -919,7 +929,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: @@ -929,7 +939,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 new file mode 100644 index 0000000..4cbccf8 --- /dev/null +++ b/tests/test_interactions.nim @@ -0,0 +1,102 @@ +## Mouse event batches must not lose release activation between rendered frames. +## Run with: nim r -d:silkyTesting tests/test_interactions.nim +import std/[options, tables] +import bumpy, silky, vmath + +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" + +# 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"