diff --git a/docs/center.png b/docs/center.png new file mode 100644 index 0000000..2f88039 Binary files /dev/null and b/docs/center.png differ diff --git a/docs/layout.md b/docs/layout.md new file mode 100644 index 0000000..bb768a4 --- /dev/null +++ b/docs/layout.md @@ -0,0 +1,80 @@ +# Layout in Silky + +Layout in Silky is a little different because it uses an immediate mode UI. Some layouts just are not possible without custom math since in immediate mode you have to know the position of every widget as you draw it. That is how immediate mode works. This limits what layouts you can do, but it also makes reasoning about them simpler. The layout system is more straightforward and easier to think about because the problem space is smaller and more constrained. + +## The pen and stretch pen. + +![Pen and stretch pen](pen.png) + +Think about how the layout works. Imagine a "pen" that decides where the next widget goes. You create a parent element, and it has padding. Because of that padding, the pen moves inward. Then you add a child element. The child moves the pen depending on whether the parent layout is vertical or horizontal. In a vertical layout, the pen advances by the child's height. In a horizontal layout, it advances by the child's width. + +After moving the pen, you also need to account for parent item spacing, which is how much space sits between children. This spacing depends on whether the layout is vertical or horizontal and must be applied accordingly. + +There is also a second conceptual pen, the "stretch pen." When you place a child layout inside a parent, it stretches the parent's size. Both the width and height may grow depending on the child's dimensions. At the end, when the parent box is closed and drawn, the parent padding is added again to this accumulated stretch size. + +## Stretch and sizing layouts. + +Stretch to fit layouts that require knowing the sizes of all children in advance are tricky in immediate mode UIs. Withoug scrafacing perf of frame independence, you cannot look ahead to compute total child sizes before drawing them. Layouts that depend on precomputed child measurements simply do not work here. + +![Stretch pen](size.png) + +What *is* possible: + +* **Fixed size layouts**, because all sizes are known ahead of time. +* **Fill parent layouts**, because the parent's size is already known and children can expand to match it. + +When children stretch a parent, they expand its **inner dimensions**. If the inner dimensions exceed the parent's outer dimensions, a scrollbar appears. Scrollbars can be enabled or disabled depending on your needs. + +Stretching can happen independently in the X and Y directions. For example: + +* Fixed size in X and fill parent in Y +* Fill parent in X and fixed size in Y +* Or any combination of the two + +## Stacking direction and anchoring. + +Next is the **stacking direction**, which is very flexible. Stacking direction is handled by reversing signs in the layout math. The underlying logic stays the same. + +![Stacking direction](stack.png) + +There is also the concept of **anchoring**, which determines where stacking begins. You can anchor at a side and stack: + +* Anchor to Top + * Stack Left to right + * Stack Right to left +* Anchor to Bottom + * Stack Left to right + * Stack Right to left +* Anchor to Left + * Stack Top to bottom + * Stack Bottom to top +* Anchor to Right + * Stack Top to bottom + * Stack Bottom to top + +Most UIs anchor on the left and stack from top to bottom. That is the default and most common layout style. But you could build something like a chat application that anchors at the bottom and stacks upward, since new chat bubbles appear there. You can also create panels that stack controls inward from different edges to organize screen layout. + + +## Performance considerations. + +The layouts are constrained not because they are hard to build, but because of performance. In theory, we could precompute child layouts, throw away the actual widgets, and keep only their sizes. But that would add extra computation. This goes against the core philosophy of Silky, which prioritizes maximum performance. + +Yes, we could add precomputation and make the layout system more flexible, but we chose not to on purpose. The goal is to keep the UI as fast as possible. The "holy grail" of immediate mode UI is speed: no extra bookkeeping, no unnecessary memory storage, no redundant calculations. + +In immediate mode, drawing the UI is almost like using print statements. You emit the widgets, they get rendered for that frame, and then they are gone. There is no retained tree structure or persistent layout state. This simplicity is what makes immediate mode UIs so fast. + +That is the main reason the layouts are constrained: not because of technical limitations, but because of a deliberate design choice in favor of performance. + +You might say, "I want this layout or that layout, I want to do this and that." In practice, you usually can. + +In Silky, everything supports explicit X, Y, and position settings. That means you can use your own math formulas, often much simpler than trying to solve a generalized layout puzzle, to compute exactly where things should go based on what you know about your UI and your data. + +For example, you often know how many elements are in a list, and you can figure out their height from the data. With a little upfront math and some simple heuristics, you can compute the sizes and positions of even fairly complex layouts without building an elaborate layout system or doing extra passes. + +Then, when it is time to draw, you just plug in the computed numbers for X, Y, width, and height. + +In my experience, this works better than a complicated layout system. The code makes it obvious where everything goes, and it is usually much faster. + +I feel that explicit math formulas work better because they clearly express intent. In more complicated layout systems, the final layout can feel accidental, as if many separate pieces of code happen to combine and produce the result. It can seem like the layout emerges from a web of interactions rather than from a clear, deliberate decision. + +With the formula based approach, you write exactly what you want. You understand your data, you compute the layout directly, and then you apply it. The result is much more intentional. The positioning logic is explicit, readable, and grounded in the actual structure of your UI rather than being the byproduct of a complex layout engine. diff --git a/docs/pen.png b/docs/pen.png new file mode 100644 index 0000000..e173ac4 Binary files /dev/null and b/docs/pen.png differ diff --git a/docs/sementic_testing.md b/docs/sementic_testing.md new file mode 100644 index 0000000..1357d8f --- /dev/null +++ b/docs/sementic_testing.md @@ -0,0 +1,152 @@ +## Semantic testing for Silky UI + +This document explains the idea behind semantic UI testing in Silky, why it helps writing tests and AI assisted development. + +## The testing problem + +Normal tests and AI tools are not great at verifying GUI output when they must rely on pixels. + +- Pixel based checks are slow and expensive. +- GUI automation usually needs a full window, GPU, and frame rendering loop. +- Most AI verification tools are stronger with text than with images. +- For immediate mode GUI, dumping every frame is noisy because frames update constantly. + +In short: we need a fast, text first way to inspect and test UI behavior. + +## Core idea ui semantic mode + +Compile and run Silky apps in a semantic testing mode that captures what the UI logically renders instead of what it rasterizes. + +- Capture widgets as a semantic tree (kind, name, text, state, rect, children). +- Tests can walk the tree, assert on logical state, and interact with the UI. +- Export that tree as stable text snapshots. +- Compare snapshots with golden files in tests. +- Provide query and interaction helpers for tests and AI tools. +- Emit diffs only when the semantic output changes. + +This gives us browser style inspectability for native GUI, without browser overhead. + +## Why this helps live coding and AI verification + +- The output is nodes and text, so AI can read, reason, and compare quickly. +- No image scraping or OCR is required. +- Tests can assert logical intent: "button exists", "display text changed", "checkbox is checked". +- Iteration is faster because semantic capture avoids real rendering cost. +- Diffs are smaller than full frame dumps and are easier to review. + +## Current implementation in Silky + +The current implementation already provides the core semantic capture pipeline. + +### Compile flag + +- Tests are compiled with `-d:silkyTesting`. This gives you mock window, rendering, and frame pumping. +- Test files assert this flag to avoid accidental non testing runs. +- Tests can also use diffs for golden file testing. + +### Semantic model + +`src/silky/semantic.nim` defines: + +- `SemanticNode` for widget kind, name, text, rect, state, parent and children. +- `SemanticCapture` for per frame tree capture, stack management, frame number, and previous snapshot support. +- Snapshot export with `toText()` and `toSnapshot()`. +- Search helpers: + - `findByPath()` + - `findByText()` + - `findByName()` + - `findAllByText()` +- Simple text diff with `diff(old, new)`. + +### Frame integration + +In semantic mode: + +- `beginUi()` resets semantic capture each frame. +- `beginWidget()` pushes a semantic node. +- `setWidgetState()` records interactive state. +- `setWidgetRect()` updates geometry. +- `endWidget()` pops the node. +- `semanticSnapshot()` returns snapshot text for tests. + +### Example app and tests + +The calculator example shows end to end usage: + +- `examples/calculator/calculator.nim` annotates widgets with semantic metadata, including display name and button text. +- `examples/calculator/tests/test.nim` drives the UI using semantic helpers like `clickButton`, queries nodes by text/name, and asserts display output. + +## Snapshot format + +The snapshot is a readable tree, for example: + +```text +frame: 3 +Calculator: + type: SubWindow + children: + display: + type: Display + text: 7+3 + 1: + type: Button + text: = +``` + +This is a simple tree text format, but controlled by Silky so it stays stable for testing. + +## Test workflow + +Recommended flow for semantic UI tests: + +### Unit test + +1. Build and run app/test with `-d:silkyTesting`. +2. Pump a frame and capture semantic snapshot. +3. Query nodes and assert logical state. +4. Trigger interaction, click or type text. +5. Assert on new state. + +### Golden file test + +1. Build and run app/test with `-d:silkyTesting`. +2. Pump a frame and capture semantic snapshot. +3. Keep doing some complex actions and diffs snapshots. +4. Compare against golden output. + +This supports both direct assertions and golden master testing. + +## Diff strategy + +Silky currently has line by line snapshot diffing. + +- If no semantic change occurs, diff is empty. +- If semantic output changes, tests can print only changed lines. +- This avoids frame by frame spam in immediate mode. + +Future improvement: keep and publish diffs automatically only when changed from previous frame. + +## Interaction strategy + +Current tests already click controls by button text. + +Next steps can add: + +- Click by semantic path. +- Click by index under a container. +- Text lookup with disambiguation when duplicate labels exist. +- Script style actions (`click`, `type`, `assertText`, `expectVisible`). + +## Performance expectations + +Semantic mode should be much faster than full GUI automation because: + +- No real drawing pipeline is needed. +- No GPU rasterization is required. +- Data is captured in memory as plain structures and text. + +This makes it suitable for CI, local rapid iteration, and AI driven verification loops. + +## Summary + +Semantic testing gives Silky a practical path for reliable UI verification without pixel testing. It fits immediate mode UI, works well with AI tools, and is already partially implemented with semantic capture, querying, diffing, and real example coverage in calculator tests. diff --git a/docs/size.png b/docs/size.png new file mode 100644 index 0000000..7926bdb Binary files /dev/null and b/docs/size.png differ diff --git a/docs/stack.png b/docs/stack.png new file mode 100644 index 0000000..beac4da Binary files /dev/null and b/docs/stack.png differ diff --git a/examples/basicwindow/basicwindow.nim b/examples/basicwindow/basicwindow.nim index 60f8d6a..bb7b172 100644 --- a/examples/basicwindow/basicwindow.nim +++ b/examples/basicwindow/basicwindow.nim @@ -50,7 +50,7 @@ window.onFrame = proc() = # Draw tiled test texture as the background. for x in 0 ..< 16: for y in 0 ..< 10: - sk.at = vec2(x.float32 * 256, y.float32 * 256) + sk.layout.at = vec2(x.float32 * 256, y.float32 * 256) image("testTexture", rgbx(30, 30, 30, 255)) subWindow("A SubWindow", showWindow, vec2(100, 100), vec2(400, 700)): @@ -95,11 +95,11 @@ window.onFrame = proc() = if not showWindow: if window.buttonPressed[MouseLeft]: showWindow = true - sk.at = vec2(100, 100) + sk.layout.at = vec2(100, 100) text("Click anywhere to show the window") let ms = sk.avgFrameTime * 1000 - sk.at = sk.pos + vec2(sk.size.x - 250, 20) + sk.layout.at = sk.pos + vec2(sk.size.x - 250, 20) text(&"frame time: {ms:>7.3f}ms") sk.endUi() diff --git a/examples/calculator/calculator.nim b/examples/calculator/calculator.nim index 9dcf75c..78ee535 100644 --- a/examples/calculator/calculator.nim +++ b/examples/calculator/calculator.nim @@ -118,16 +118,16 @@ template calcLabel(displayText: string) = ## Displays a right-aligned label in a dark background box. let labelSize = vec2(sk.size.x - 24, 60) - labelRect = rect(sk.at, labelSize) + labelRect = rect(sk.layout.at, labelSize) sk.beginWidget("Display", name = "display", text = displayText, rect = labelRect) - sk.drawRect(sk.at, labelSize, rgbx(50, 50, 50, 255)) + sk.drawRect(sk.layout.at, labelSize, rgbx(50, 50, 50, 255)) let oldStyle = sk.textStyle sk.textStyle = "H1" let labelTextSize = sk.getTextSize(sk.textStyle, displayText) - let textX = sk.at.x + labelSize.x - labelTextSize.x - 10 - discard sk.drawText(sk.textStyle, displayText, vec2(textX, sk.at.y + 14), rgbx(255, 255, 255, 255)) + let textX = sk.layout.at.x + labelSize.x - labelTextSize.x - 10 + discard sk.drawText(sk.textStyle, displayText, vec2(textX, sk.layout.at.y + 14), rgbx(255, 255, 255, 255)) sk.textStyle = oldStyle sk.endWidget() @@ -136,7 +136,7 @@ template calcLabel(displayText: string) = template calcButton(label: string, body: untyped) = let btnSize = vec2(60, 50) - startPos = sk.at + startPos = sk.layout.at btnRect = rect(startPos, btnSize) sk.beginWidget("Button", text = label, rect = btnRect) @@ -160,9 +160,9 @@ template calcButton(label: string, body: untyped) = sk.endWidget() - sk.at.x += btnSize.x + 10 - sk.stretchAt.x = max(sk.stretchAt.x, sk.at.x + 10) - sk.stretchAt.y = max(sk.stretchAt.y, sk.at.y + 50 + 10) + sk.layout.at.x += btnSize.x + 10 + sk.layout.stretchMax.x = max(sk.layout.stretchMax.x, sk.layout.at.x + 10) + sk.layout.stretchMax.y = max(sk.layout.stretchMax.y, sk.layout.at.y + 50 + 10) window.onFrame = proc() = @@ -171,7 +171,7 @@ window.onFrame = proc() = # Draw tiled test texture as the background. for x in 0 ..< 16: for y in 0 ..< 10: - sk.at = vec2(x.float32 * 256, y.float32 * 256) + sk.layout.at = vec2(x.float32 * 256, y.float32 * 256) image("testTexture", rgbx(30, 30, 30, 255)) subWindow("Calculator", showWindow, vec2(10, 10), vec2(340, 480)): @@ -187,7 +187,7 @@ window.onFrame = proc() = # Draw the calculator display. calcLabel(displayText) - let rowX = sk.at.x + let rowX = sk.layout.at.x # Row 1: C, +/- (±), %, ÷. calcButton("C"): @@ -208,8 +208,8 @@ window.onFrame = proc() = calcButton("÷"): if inOperator(): symbols[^1].operator = "÷" - sk.at.x = rowX - sk.at.y += 60 + sk.layout.at.x = rowX + sk.layout.at.y += 60 # Row 2: 7, 8, 9, ×. calcButton("7"): @@ -224,8 +224,8 @@ window.onFrame = proc() = calcButton("×"): if inOperator(): symbols[^1].operator = "×" - sk.at.x = rowX - sk.at.y += 60 + sk.layout.at.x = rowX + sk.layout.at.y += 60 # Row 3: 4, 5, 6, -. calcButton("4"): @@ -246,8 +246,8 @@ window.onFrame = proc() = if symbols.len > 0 and symbols[^1].number == "": symbols[^1].number = "-" - sk.at.x = rowX - sk.at.y += 60 + sk.layout.at.x = rowX + sk.layout.at.y += 60 # Row 4: 1, 2, 3, +. calcButton("1"): @@ -262,8 +262,8 @@ window.onFrame = proc() = calcButton("+"): if inOperator(): symbols[^1].operator = "+" - sk.at.x = rowX - sk.at.y += 60 + sk.layout.at.x = rowX + sk.layout.at.y += 60 calcButton("0"): inNumber() @@ -277,16 +277,16 @@ window.onFrame = proc() = calcButton("="): compute() - sk.at.x = rowX - sk.at.y += 60 + sk.layout.at.x = rowX + sk.layout.at.y += 60 if not showWindow: if window.buttonPressed[MouseLeft]: showWindow = true - sk.at = vec2(100, 100) + sk.layout.at = vec2(100, 100) let ms = sk.avgFrameTime * 1000 - sk.at = sk.pos + vec2(sk.size.x - 250, 20) + sk.layout.at = sk.pos + vec2(sk.size.x - 250, 20) text(&"frame time: {ms:>7.3f}ms") sk.endUi() diff --git a/examples/gameplayer/gameplayer.nim b/examples/gameplayer/gameplayer.nim index e757c79..4d8f237 100644 --- a/examples/gameplayer/gameplayer.nim +++ b/examples/gameplayer/gameplayer.nim @@ -1,294 +1,294 @@ -import - std/[strformat, strutils], - opengl, windy, bumpy, vmath, chroma, - silky - -let builder = newAtlasBuilder(1024, 4) -builder.addDir("data/", "data/") -builder.addDir("data/ui/", "data/") -builder.addDir("data/vibe/", "data/") -builder.addFont("data/IBMPlexSans-Regular.ttf", "H1", 32.0) -builder.addFont("data/IBMPlexSans-Regular.ttf", "Default", 18.0) -builder.write("dist/atlas.png", "dist/atlas.json") - -let window = newWindow( - "Silky Example 1", - ivec2(1200, 900), - vsync = false -) -makeContextCurrent(window) -loadExtensions() - -const - BackgroundColor = parseHtmlColor("#000000").rgbx - RibbonColor = parseHtmlColor("#273646").rgbx - ScrubberColor = parseHtmlColor("#1D1D1D").rgbx - Margin = 12f - -let - sk = newSilky("dist/atlas.png", "dist/atlas.json") - vibes = @[ - "vibe/alembic", - "vibe/angry", - "vibe/anxious", - "vibe/assembler", - "vibe/asterisk", - "vibe/backpack", - "vibe/beaming", - "vibe/black-circle", - "vibe/black-heart", - "vibe/blue-circle", - "vibe/blue-diamond", - "vibe/blue-heart", - "vibe/bow", - "vibe/broken-heart", - "vibe/brown-circle", - "vibe/brown-heart", - "vibe/brown-square", - "vibe/carbon", - "vibe/carbon_a", - "vibe/carbon_b", - "vibe/carrot", - "vibe/charger", - "vibe/chart-down", - "vibe/chart-up", - "vibe/chest", - "vibe/clown", - "vibe/coin", - "vibe/compass", - "vibe/confused", - "vibe/corn", - "vibe/crying-cat", - "vibe/crying", - "vibe/dagger", - "vibe/default", - "vibe/diamond", - "vibe/divide", - "vibe/down-left", - "vibe/down-right", - "vibe/down", - "vibe/drooling", - "vibe/eight", - "vibe/factory", - "vibe/fearful", - "vibe/fire", - "vibe/five", - "vibe/four", - "vibe/fuel", - "vibe/gear", - "vibe/germanium", - "vibe/germanium_a", - "vibe/germanium_b", - "vibe/ghost", - "vibe/green-circle", - "vibe/green-heart", - "vibe/grinning-big-eyes", - "vibe/grinning-smiling-eyes", - "vibe/grinning", - "vibe/growing-heart", - "vibe/halo", - "vibe/hammer", - "vibe/hash", - "vibe/heart-arrow", - "vibe/heart-decoration", - "vibe/heart-exclamation", - "vibe/heart-eyes", - "vibe/heart-ribbon", - "vibe/heart", - "vibe/heart_a", - "vibe/heart_b", - "vibe/hundred", - "vibe/kiss", - "vibe/left", - "vibe/light-shade", - "vibe/lightning", - "vibe/love-letter", - "vibe/medium-shade", - "vibe/minus", - "vibe/moai", - "vibe/money", - "vibe/monocle", - "vibe/mountain", - "vibe/multiply", - "vibe/nine", - "vibe/numbers", - "vibe/oil", - "vibe/one", - "vibe/orange-circle", - "vibe/orange-heart", - "vibe/orange-square", - "vibe/oxygen", - "vibe/oxygen_a", - "vibe/oxygen_b", - "vibe/package", - "vibe/paperclip", - "vibe/pin", - "vibe/plug", - "vibe/plus", - "vibe/pouting", - "vibe/purple-circle", - "vibe/purple-heart", - "vibe/purple-square", - "vibe/pushpin", - "vibe/red-circle", - "vibe/red-heart", - "vibe/red-triangle", - "vibe/revolving-hearts", - "vibe/right", - "vibe/rock", - "vibe/rocket", - "vibe/rofl", - "vibe/rolling-eyes", - "vibe/rotate-clockwise", - "vibe/rotate", - "vibe/savoring", - "vibe/seahorse", - "vibe/seven", - "vibe/shield", - "vibe/silicon", - "vibe/silicon_a", - "vibe/silicon_b", - "vibe/six", - "vibe/skull-crossbones", - "vibe/sleepy", - "vibe/small-blue-diamond", - "vibe/smiling", - "vibe/smirking", - "vibe/sobbing", - "vibe/sparkle", - "vibe/sparkling-heart", - "vibe/squinting", - "vibe/star-struck", - "vibe/swearing", - "vibe/swords", - "vibe/target", - "vibe/tears-of-joy", - "vibe/ten", - "vibe/test-tube", - "vibe/three", - "vibe/tree", - "vibe/two-hearts", - "vibe/two", - "vibe/up-left", - "vibe/up-right", - "vibe/up", - "vibe/wall", - "vibe/water", - "vibe/wave", - "vibe/wheat", - "vibe/white-circle", - "vibe/white-heart", - "vibe/white-square", - "vibe/wood", - "vibe/wrench", - "vibe/yawning", - "vibe/yellow-circle", - "vibe/yellow-heart", - "vibe/yellow-square", - "vibe/zero", - ] - -var scrubValue: float32 = 0 - -window.onFrame = proc() = - - sk.beginUI(window, window.size) - - # Draw map background. - for x in 0 ..< 16: - for y in 0 ..< 10: - sk.at = vec2(x.float32 * 256, y.float32 * 256) - image("testTexture", rgbx(30, 30, 30, 255)) - - ribbon(sk.pos, vec2(sk.size.x, 64), RibbonColor): - image("ui/logo") - h1text("Hello, World!") - - sk.at = sk.pos + vec2(sk.size.x - 100, 16) - iconButton("ui/heart"): - echo "heart" - if sk.shouldShowTooltip: - tooltip("Heart") - iconButton("ui/cloud"): - echo "cloud" - if sk.shouldShowTooltip: - tooltip("Cloud") - - ribbon(vec2(0, sk.size.y - 64*2), vec2(sk.size.x, 66), ScrubberColor): - # empty ribbon to fill with icons in the future - discard - - ribbon(vec2(0, sk.size.y - 97), vec2(sk.size.x, 66), ScrubberColor): - scrubber("timeline", scrubValue, 0, 1000, $int(scrubValue + 0.5)) - - ribbon(vec2(0, sk.size.y - 64), vec2(sk.size.x, 64), RibbonColor): - - group(vec2(16, 16), TopToBottom): - clickableIcon("ui/rewindToStart", true): - echo "rewindToStart" - if sk.shouldShowTooltip: - tooltip("Rewind to Start") - clickableIcon("ui/stepBack", true): - echo "stepBack" - if sk.shouldShowTooltip: - tooltip("Step Back") - clickableIcon("ui/play", true): - echo "play" - if sk.shouldShowTooltip: - tooltip("Play") - clickableIcon("ui/stepForward", true): - echo "stepForward" - if sk.shouldShowTooltip: - tooltip("Step Forward") - clickableIcon("ui/rewindToEnd", true): - echo "rewindToEnd" - if sk.shouldShowTooltip: - tooltip("Rewind to End") - - # Position the second group relative to the right side of the window. - sk.at = sk.pos + vec2(sk.size.x - 240, 16) - group(vec2(0, 0), TopToBottom): - clickableIcon("ui/heart", true): - echo "clickable heart" - if sk.shouldShowTooltip: - tooltip("Clickable Heart") - clickableIcon("ui/cloud", true): - echo "clickable cloud" - if sk.shouldShowTooltip: - tooltip("Clickable Cloud") - clickableIcon("ui/grid", true): - echo "grid" - if sk.shouldShowTooltip: - tooltip("Grid") - clickableIcon("ui/eye", true): - echo "eye" - if sk.shouldShowTooltip: - tooltip("Eye") - clickableIcon("ui/tack", true): - echo "tack" - if sk.shouldShowTooltip: - tooltip("Tack") - - frame("vibe-frame", vec2(sk.size.x - (16 * (32 + Margin)), 100) - vec2(14, 14), vec2(700, 600) + vec2(14, 14)): - sk.at = sk.pos + vec2(Margin, Margin) * 2 - for i, vibe in vibes: - if i > 0 and i mod 13 == 0: - sk.at.x = sk.pos.x + Margin * 2 - sk.at.y += 32 + Margin - iconButton(vibe): - echo vibe - if sk.shouldShowTooltip: - tooltip(vibe) - - group(vec2(10, 200), TopToBottom): - text("Step: 1 of 10\nscore: 100\nlevel: 1\nwidth: 100\nheight: 100\nnum agents: 10") - - let ms = sk.avgFrameTime * 1000 - sk.at = sk.pos + vec2(sk.size.x - 250, 20) - text(&"frame time: {ms:>7.3f}ms") - - sk.endUi() - window.swapBuffers() - -while not window.closeRequested: - pollEvents() +import + std/[strformat, strutils], + opengl, windy, bumpy, vmath, chroma, + silky + +let builder = newAtlasBuilder(1024, 4) +builder.addDir("data/", "data/") +builder.addDir("data/ui/", "data/") +builder.addDir("data/vibe/", "data/") +builder.addFont("data/IBMPlexSans-Regular.ttf", "H1", 32.0) +builder.addFont("data/IBMPlexSans-Regular.ttf", "Default", 18.0) +builder.write("dist/atlas.png", "dist/atlas.json") + +let window = newWindow( + "Silky Example 1", + ivec2(1200, 900), + vsync = false +) +makeContextCurrent(window) +loadExtensions() + +const + BackgroundColor = parseHtmlColor("#000000").rgbx + RibbonColor = parseHtmlColor("#273646").rgbx + ScrubberColor = parseHtmlColor("#1D1D1D").rgbx + Margin = 12f + +let + sk = newSilky("dist/atlas.png", "dist/atlas.json") + vibes = @[ + "vibe/alembic", + "vibe/angry", + "vibe/anxious", + "vibe/assembler", + "vibe/asterisk", + "vibe/backpack", + "vibe/beaming", + "vibe/black-circle", + "vibe/black-heart", + "vibe/blue-circle", + "vibe/blue-diamond", + "vibe/blue-heart", + "vibe/bow", + "vibe/broken-heart", + "vibe/brown-circle", + "vibe/brown-heart", + "vibe/brown-square", + "vibe/carbon", + "vibe/carbon_a", + "vibe/carbon_b", + "vibe/carrot", + "vibe/charger", + "vibe/chart-down", + "vibe/chart-up", + "vibe/chest", + "vibe/clown", + "vibe/coin", + "vibe/compass", + "vibe/confused", + "vibe/corn", + "vibe/crying-cat", + "vibe/crying", + "vibe/dagger", + "vibe/default", + "vibe/diamond", + "vibe/divide", + "vibe/down-left", + "vibe/down-right", + "vibe/down", + "vibe/drooling", + "vibe/eight", + "vibe/factory", + "vibe/fearful", + "vibe/fire", + "vibe/five", + "vibe/four", + "vibe/fuel", + "vibe/gear", + "vibe/germanium", + "vibe/germanium_a", + "vibe/germanium_b", + "vibe/ghost", + "vibe/green-circle", + "vibe/green-heart", + "vibe/grinning-big-eyes", + "vibe/grinning-smiling-eyes", + "vibe/grinning", + "vibe/growing-heart", + "vibe/halo", + "vibe/hammer", + "vibe/hash", + "vibe/heart-arrow", + "vibe/heart-decoration", + "vibe/heart-exclamation", + "vibe/heart-eyes", + "vibe/heart-ribbon", + "vibe/heart", + "vibe/heart_a", + "vibe/heart_b", + "vibe/hundred", + "vibe/kiss", + "vibe/left", + "vibe/light-shade", + "vibe/lightning", + "vibe/love-letter", + "vibe/medium-shade", + "vibe/minus", + "vibe/moai", + "vibe/money", + "vibe/monocle", + "vibe/mountain", + "vibe/multiply", + "vibe/nine", + "vibe/numbers", + "vibe/oil", + "vibe/one", + "vibe/orange-circle", + "vibe/orange-heart", + "vibe/orange-square", + "vibe/oxygen", + "vibe/oxygen_a", + "vibe/oxygen_b", + "vibe/package", + "vibe/paperclip", + "vibe/pin", + "vibe/plug", + "vibe/plus", + "vibe/pouting", + "vibe/purple-circle", + "vibe/purple-heart", + "vibe/purple-square", + "vibe/pushpin", + "vibe/red-circle", + "vibe/red-heart", + "vibe/red-triangle", + "vibe/revolving-hearts", + "vibe/right", + "vibe/rock", + "vibe/rocket", + "vibe/rofl", + "vibe/rolling-eyes", + "vibe/rotate-clockwise", + "vibe/rotate", + "vibe/savoring", + "vibe/seahorse", + "vibe/seven", + "vibe/shield", + "vibe/silicon", + "vibe/silicon_a", + "vibe/silicon_b", + "vibe/six", + "vibe/skull-crossbones", + "vibe/sleepy", + "vibe/small-blue-diamond", + "vibe/smiling", + "vibe/smirking", + "vibe/sobbing", + "vibe/sparkle", + "vibe/sparkling-heart", + "vibe/squinting", + "vibe/star-struck", + "vibe/swearing", + "vibe/swords", + "vibe/target", + "vibe/tears-of-joy", + "vibe/ten", + "vibe/test-tube", + "vibe/three", + "vibe/tree", + "vibe/two-hearts", + "vibe/two", + "vibe/up-left", + "vibe/up-right", + "vibe/up", + "vibe/wall", + "vibe/water", + "vibe/wave", + "vibe/wheat", + "vibe/white-circle", + "vibe/white-heart", + "vibe/white-square", + "vibe/wood", + "vibe/wrench", + "vibe/yawning", + "vibe/yellow-circle", + "vibe/yellow-heart", + "vibe/yellow-square", + "vibe/zero", + ] + +var scrubValue: float32 = 0 + +window.onFrame = proc() = + + sk.beginUI(window, window.size) + + # Draw map background. + for x in 0 ..< 16: + for y in 0 ..< 10: + sk.layout.at = vec2(x.float32 * 256, y.float32 * 256) + image("testTexture", rgbx(30, 30, 30, 255)) + + ribbon(sk.pos, vec2(sk.size.x, 64), RibbonColor): + image("ui/logo") + h1text("Hello, World!") + + sk.layout.at = sk.pos + vec2(sk.size.x - 100, 16) + iconButton("ui/heart"): + echo "heart" + if sk.shouldShowTooltip: + tooltip("Heart") + iconButton("ui/cloud"): + echo "cloud" + if sk.shouldShowTooltip: + tooltip("Cloud") + + ribbon(vec2(0, sk.size.y - 64*2), vec2(sk.size.x, 66), ScrubberColor): + # empty ribbon to fill with icons in the future + discard + + ribbon(vec2(0, sk.size.y - 97), vec2(sk.size.x, 66), ScrubberColor): + scrubber("timeline", scrubValue, 0, 1000, $int(scrubValue + 0.5)) + + ribbon(vec2(0, sk.size.y - 64), vec2(sk.size.x, 64), RibbonColor): + + group(vec2(16, 16), TopToBottom): + clickableIcon("ui/rewindToStart", true): + echo "rewindToStart" + if sk.shouldShowTooltip: + tooltip("Rewind to Start") + clickableIcon("ui/stepBack", true): + echo "stepBack" + if sk.shouldShowTooltip: + tooltip("Step Back") + clickableIcon("ui/play", true): + echo "play" + if sk.shouldShowTooltip: + tooltip("Play") + clickableIcon("ui/stepForward", true): + echo "stepForward" + if sk.shouldShowTooltip: + tooltip("Step Forward") + clickableIcon("ui/rewindToEnd", true): + echo "rewindToEnd" + if sk.shouldShowTooltip: + tooltip("Rewind to End") + + # Position the second group relative to the right side of the window. + sk.layout.at = sk.pos + vec2(sk.size.x - 240, 16) + group(vec2(0, 0), TopToBottom): + clickableIcon("ui/heart", true): + echo "clickable heart" + if sk.shouldShowTooltip: + tooltip("Clickable Heart") + clickableIcon("ui/cloud", true): + echo "clickable cloud" + if sk.shouldShowTooltip: + tooltip("Clickable Cloud") + clickableIcon("ui/grid", true): + echo "grid" + if sk.shouldShowTooltip: + tooltip("Grid") + clickableIcon("ui/eye", true): + echo "eye" + if sk.shouldShowTooltip: + tooltip("Eye") + clickableIcon("ui/tack", true): + echo "tack" + if sk.shouldShowTooltip: + tooltip("Tack") + + frame("vibe-frame", vec2(sk.size.x - (16 * (32 + Margin)), 100) - vec2(14, 14), vec2(700, 600) + vec2(14, 14)): + sk.layout.at = sk.pos + vec2(Margin, Margin) * 2 + for i, vibe in vibes: + if i > 0 and i mod 13 == 0: + sk.layout.at.x = sk.pos.x + Margin * 2 + sk.layout.at.y += 32 + Margin + iconButton(vibe): + echo vibe + if sk.shouldShowTooltip: + tooltip(vibe) + + group(vec2(10, 200), TopToBottom): + text("Step: 1 of 10\nscore: 100\nlevel: 1\nwidth: 100\nheight: 100\nnum agents: 10") + + let ms = sk.avgFrameTime * 1000 + sk.layout.at = sk.pos + vec2(sk.size.x - 250, 20) + text(&"frame time: {ms:>7.3f}ms") + + sk.endUi() + window.swapBuffers() + +while not window.closeRequested: + pollEvents() diff --git a/examples/panels/panels.nim b/examples/panels/panels.nim index bc4f3a0..4b6767a 100644 --- a/examples/panels/panels.nim +++ b/examples/panels/panels.nim @@ -1,520 +1,520 @@ - -import - std/[random, strformat], - opengl, windy, bumpy, vmath, chroma, - silky - -let builder = newAtlasBuilder(1024, 4) -builder.addDir("data/", "data/") -builder.addFont("data/IBMPlexSans-Regular.ttf", "H1", 32.0) -builder.addFont("data/IBMPlexSans-Regular.ttf", "Default", 18.0) -builder.write("dist/atlas.png", "dist/atlas.json") - -let window = newWindow( - "Panels Example", - ivec2(1200, 800), - vsync = false -) -makeContextCurrent(window) -loadExtensions() - -proc snapToPixels(rect: Rect): Rect = - ## Snap rectangle coordinates to integer pixels. - rect(rect.x.int.float32, rect.y.int.float32, rect.w.int.float32, rect.h.int.float32) - -let sk = newSilky("dist/atlas.png", "dist/atlas.json") - -type - AreaLayout = enum - Horizontal - Vertical - - Area = ref object - layout: AreaLayout - areas: seq[Area] - panels: seq[Panel] - split: float32 - selectedPanelNum: int - rect: Rect # Calculated during draw - - Panel = ref object - name: string - parentArea: Area - - AreaScan = enum - Header - Body - North - South - East - West - -const - AreaHeaderHeight = 32.0 - AreaMargin = 6.0 - BackgroundColor = parseHtmlColor("#222222").rgbx - -var - rootArea: Area - dragArea: Area # For resizing splits - dragPanel: Panel # For moving panels - dropHighlight: Rect - showDropHighlight: bool - - maybeDragStartPos: Vec2 - maybeDragPanel: Panel - - prevMem: int - prevNumAlloc: int - -proc movePanels*(area: Area, panels: seq[Panel]) - -proc clear*(area: Area) = - ## Clear the area. - for panel in area.panels: - panel.parentArea = nil - for subarea in area.areas: - subarea.clear() - area.panels.setLen(0) - area.areas.setLen(0) - -proc removeBlankAreas*(area: Area) = - ## Remove blank areas recursively. - if area.areas.len > 0: - assert area.areas.len == 2 - if area.areas[0].panels.len == 0 and area.areas[0].areas.len == 0: - if area.areas[1].panels.len > 0: - area.movePanels(area.areas[1].panels) - area.areas.setLen(0) - elif area.areas[1].areas.len > 0: - let oldAreas = area.areas - area.areas = area.areas[1].areas - area.split = oldAreas[1].split - area.layout = oldAreas[1].layout - else: - discard - elif area.areas[1].panels.len == 0 and area.areas[1].areas.len == 0: - if area.areas[0].panels.len > 0: - area.movePanels(area.areas[0].panels) - area.areas.setLen(0) - elif area.areas[0].areas.len > 0: - let oldAreas = area.areas - area.areas = area.areas[0].areas - area.split = oldAreas[0].split - area.layout = oldAreas[0].layout - else: - discard - - for subarea in area.areas: - removeBlankAreas(subarea) - -proc addPanel*(area: Area, name: string) = - ## Add a panel to the area. - let panel = Panel(name: name, parentArea: area) - area.panels.add(panel) - -proc movePanel*(area: Area, panel: Panel) = - ## Move a panel to this area. - let idx = panel.parentArea.panels.find(panel) - if idx != -1: - panel.parentArea.panels.delete(idx) - area.panels.add(panel) - panel.parentArea = area - -proc insertPanel*(area: Area, panel: Panel, index: int) = - ## Insert a panel into this area at a specific index. - let idx = panel.parentArea.panels.find(panel) - var finalIndex = index - - # If moving within the same area, adjust index if we're moving forward - if panel.parentArea == area and idx != -1: - if idx < index: - finalIndex = index - 1 - - if idx != -1: - panel.parentArea.panels.delete(idx) - - # Clamp index to be safe - finalIndex = clamp(finalIndex, 0, area.panels.len) - - area.panels.insert(panel, finalIndex) - panel.parentArea = area - # Update selection to the new panel position - area.selectedPanelNum = finalIndex - -proc getTabInsertInfo(area: Area, mousePos: Vec2): (int, Rect) = - ## Get the insert information for a tab. - var x = area.rect.x + 4 - let headerH = AreaHeaderHeight - - # If no panels, insert at 0 - if area.panels.len == 0: - return (0, rect(x, area.rect.y + 4, 4, headerH - 4)) - - var bestIndex = 0 - var minDist = float32.high - var bestX = x - - # Check before first tab (index 0) - let dist0 = abs(mousePos.x - x) - minDist = dist0 - bestX = x - bestIndex = 0 - - for i, panel in area.panels: - let textSize = sk.getTextSize("Default", panel.name) - let tabW = textSize.x + 16 - - # The gap after this tab (index i + 1) - let gapX = x + tabW + 2 - let dist = abs(mousePos.x - gapX) - if dist < minDist: - minDist = dist - bestIndex = i + 1 - bestX = gapX - - x += tabW + 2 - - return (bestIndex, rect(bestX - 2, area.rect.y + 4, 4, headerH - 4)) - -proc movePanels*(area: Area, panels: seq[Panel]) = - ## Move multiple panels to this area. - var panelList = panels # Copy - for panel in panelList: - area.movePanel(panel) - -proc split*(area: Area, layout: AreaLayout) = - ## Split the area. - let - area1 = Area(rect: area.rect) # inherit rect initially - area2 = Area(rect: area.rect) - area.layout = layout - area.split = 0.5 - area.areas.add(area1) - area.areas.add(area2) - -proc scan*(area: Area): (Area, AreaScan, Rect) = - ## Scan the area to find the target under mouse. - let mousePos = window.mousePos.vec2 - var - targetArea: Area - areaScan: AreaScan - resRect: Rect - - proc visit(area: Area) = - if not mousePos.overlaps(area.rect): - return - - if area.areas.len > 0: - for subarea in area.areas: - visit(subarea) - else: - let - headerRect = rect( - area.rect.xy, - vec2(area.rect.w, AreaHeaderHeight) - ) - bodyRect = rect( - area.rect.xy + vec2(0, AreaHeaderHeight), - vec2(area.rect.w, area.rect.h - AreaHeaderHeight) - ) - northRect = rect( - area.rect.xy + vec2(0, AreaHeaderHeight), - vec2(area.rect.w, area.rect.h * 0.2) - ) - southRect = rect( - area.rect.xy + vec2(0, area.rect.h * 0.8), - vec2(area.rect.w, area.rect.h * 0.2) - ) - eastRect = rect( - area.rect.xy + vec2(area.rect.w * 0.8, 0) + vec2(0, AreaHeaderHeight), - vec2(area.rect.w * 0.2, area.rect.h - AreaHeaderHeight) - ) - westRect = rect( - area.rect.xy + vec2(0, 0) + vec2(0, AreaHeaderHeight), - vec2(area.rect.w * 0.2, area.rect.h - AreaHeaderHeight) - ) - - if mousePos.overlaps(headerRect): - areaScan = Header - resRect = headerRect - elif mousePos.overlaps(northRect): - areaScan = North - resRect = northRect - elif mousePos.overlaps(southRect): - areaScan = South - resRect = southRect - elif mousePos.overlaps(eastRect): - areaScan = East - resRect = eastRect - elif mousePos.overlaps(westRect): - areaScan = West - resRect = westRect - elif mousePos.overlaps(bodyRect): - areaScan = Body - resRect = bodyRect - - targetArea = area - - visit(rootArea) - return (targetArea, areaScan, resRect) - -proc initRootArea() = - ## Initialize the root area with default panels. - randomize() - rootArea = Area() - rootArea.split(Vertical) - rootArea.split = 0.20 - - rootArea.areas[0].addPanel("Super Panel 1") - rootArea.areas[0].addPanel("Cool Panel 2") - - rootArea.areas[1].split(Horizontal) - rootArea.areas[1].split = 0.5 - - rootArea.areas[1].areas[0].addPanel("Nice Panel 3") - rootArea.areas[1].areas[0].addPanel("The Other Panel 4") - rootArea.areas[1].areas[0].addPanel("Panel 5") - - rootArea.areas[1].areas[1].addPanel("World Class Panel 6") - rootArea.areas[1].areas[1].addPanel("FUN Panel 7") - rootArea.areas[1].areas[1].addPanel("Amazing Panel 8") - -proc regenerate() = - ## Regenerate the panel layout randomly. - rootArea = Area() - - var panelNum = 1 - proc iterate(area: Area, depth: int) = - if rand(0 .. depth) < 2: - # Split the area. - if rand(0 .. 1) == 0: - area.split(Horizontal) - else: - area.split(Vertical) - area.split = rand(0.2 .. 0.8) - iterate(area.areas[0], depth + 1) - iterate(area.areas[1], depth + 1) - else: - # Don't split the area. - for i in 0 ..< rand(1 .. 3): - area.addPanel("Panel " & $panelNum) - panelNum += 1 - iterate(rootArea, 0) - -initRootArea() - -proc drawAreaRecursive(area: Area, r: Rect) = - ## Recursively draw an area and its subareas. - area.rect = r.snapToPixels() - - if area.areas.len > 0: - let m = AreaMargin / 2 - if area.layout == Horizontal: - # Top/Bottom - let splitPos = r.h * area.split - - # Handle split resizing - let splitRect = rect(r.x, r.y + splitPos - 2, r.w, 4) - - if dragArea == nil and window.mousePos.vec2.overlaps(splitRect): - sk.cursor = Cursor(kind: ResizeUpDownCursor) - if window.buttonPressed[MouseLeft]: - dragArea = area - - let r1 = rect(r.x, r.y, r.w, splitPos - m) - let r2 = rect(r.x, r.y + splitPos + m, r.w, r.h - splitPos - m) - drawAreaRecursive(area.areas[0], r1) - drawAreaRecursive(area.areas[1], r2) - - else: - # Left/Right - let splitPos = r.w * area.split - - let splitRect = rect(r.x + splitPos - 2, r.y, 4, r.h) - - if dragArea == nil and window.mousePos.vec2.overlaps(splitRect): - sk.cursor = Cursor(kind: ResizeLeftRightCursor) - if window.buttonPressed[MouseLeft]: - dragArea = area - - let r1 = rect(r.x, r.y, splitPos - m, r.h) - let r2 = rect(r.x + splitPos + m, r.y, r.w - splitPos - m, r.h) - drawAreaRecursive(area.areas[0], r1) - drawAreaRecursive(area.areas[1], r2) - - elif area.panels.len > 0: - # Draw Panel - if area.selectedPanelNum > area.panels.len - 1: - area.selectedPanelNum = area.panels.len - 1 - - # Draw Header - let headerRect = rect(r.x, r.y, r.w, AreaHeaderHeight) - sk.draw9Patch("panel.header.9patch", 3, headerRect.xy, headerRect.wh) - - # Draw Tabs - var x = r.x + 4 - sk.pushClipRect(rect(r.x, r.y, r.w - 2, AreaHeaderHeight)) - for i, panel in area.panels: - let textSize = sk.getTextSize("Default", panel.name) - let tabW = textSize.x + 16 - let tabRect = rect(x, r.y + 4, tabW, AreaHeaderHeight - 4) - - let isSelected = i == area.selectedPanelNum - let isHovered = window.mousePos.vec2.overlaps(tabRect) - - # Handle tab clicks and dragging. - if isHovered: - if window.buttonPressed[MouseLeft]: - area.selectedPanelNum = i - # Only start dragging if the mouse moves 10 pixels or more. - maybeDragStartPos = window.mousePos.vec2 - maybeDragPanel = panel - elif window.buttonDown[MouseLeft] and dragPanel == panel: - # Dragging has started. - discard - - if window.buttonDown[MouseLeft]: - if maybeDragPanel != nil and (maybeDragStartPos - window.mousePos.vec2).length() > 10: - dragPanel = maybeDragPanel - maybeDragStartPos = vec2(0, 0) - maybeDragPanel = nil - else: - maybeDragStartPos = vec2(0, 0) - maybeDragPanel = nil - - if isSelected: - sk.draw9Patch("panel.tab.selected.9patch", 3, tabRect.xy, tabRect.wh, rgbx(255, 255, 255, 255)) - elif isHovered: - sk.draw9Patch("panel.tab.hover.9patch", 3, tabRect.xy, tabRect.wh, rgbx(255, 255, 255, 255)) - else: - sk.draw9Patch("panel.tab.9patch", 3, tabRect.xy, tabRect.wh) - - discard sk.drawText("Default", panel.name, vec2(x + 8, r.y + 4 + 2), rgbx(255, 255, 255, 255)) - - x += tabW + 2 - sk.popClipRect() - - # Draw the content area for the selected panel. - let contentRect = rect(r.x, r.y + AreaHeaderHeight, r.w, r.h - AreaHeaderHeight) - let activePanel = area.panels[area.selectedPanelNum] - let frameId = "panel:" & $cast[uint](activePanel) - let contentPos = vec2(contentRect.x, contentRect.y) - let contentSize = vec2(contentRect.w, contentRect.h) - frame(frameId, contentPos, contentSize): - # Start content with some inset padding. - sk.at += vec2(8, 8) - h1text(activePanel.name) - text("This is the content of " & activePanel.name) - for i in 0 ..< 20: - text(&"Scrollable line {i} for " & activePanel.name) - - -window.onFrame = proc() = - ## Main frame loop. - sk.beginUI(window, window.size) - - sk.drawRect(vec2(0, 0), window.size.vec2, BackgroundColor) - sk.cursor = Cursor(kind: ArrowCursor) - - # Update dragging split if active. - if dragArea != nil: - if not window.buttonDown[MouseLeft]: - dragArea = nil - else: - if dragArea.layout == Horizontal: - sk.cursor = Cursor(kind: ResizeUpDownCursor) - dragArea.split = (window.mousePos.vec2.y - dragArea.rect.y) / dragArea.rect.h - else: - sk.cursor = Cursor(kind: ResizeLeftRightCursor) - dragArea.split = (window.mousePos.vec2.x - dragArea.rect.x) / dragArea.rect.w - dragArea.split = clamp(dragArea.split, 0.1, 0.9) - - # Update dragging panel if active. - showDropHighlight = false - if dragPanel != nil: - if not window.buttonDown[MouseLeft]: - # Drop - let (targetArea, areaScan, _) = rootArea.scan() - if targetArea != nil: - case areaScan: - of Header: - let (idx, _) = targetArea.getTabInsertInfo(window.mousePos.vec2) - targetArea.insertPanel(dragPanel, idx) - of Body: - targetArea.movePanel(dragPanel) - of North: - targetArea.split(Horizontal) - targetArea.areas[0].movePanel(dragPanel) - targetArea.areas[1].movePanels(targetArea.panels) - of South: - targetArea.split(Horizontal) - targetArea.areas[1].movePanel(dragPanel) - targetArea.areas[0].movePanels(targetArea.panels) - of East: - targetArea.split(Vertical) - targetArea.areas[1].movePanel(dragPanel) - targetArea.areas[0].movePanels(targetArea.panels) - of West: - targetArea.split(Vertical) - targetArea.areas[0].movePanel(dragPanel) - targetArea.areas[1].movePanels(targetArea.panels) - - rootArea.removeBlankAreas() - dragPanel = nil - else: - # Continue dragging and show highlight. - let (targetArea, areaScan, rect) = rootArea.scan() - dropHighlight = rect - showDropHighlight = true - - if targetArea != nil and areaScan == Header: - let (_, highlightRect) = targetArea.getTabInsertInfo(window.mousePos.vec2) - dropHighlight = highlightRect - - drawAreaRecursive(rootArea, rect(0, 1, window.size.x.float32, window.size.y.float32)) - - # Draw drop highlight and ghost when dragging a panel. - if showDropHighlight and dragPanel != nil: - sk.drawRect(dropHighlight.xy, dropHighlight.wh, rgbx(255, 255, 0, 100)) - - # Draw dragging ghost - let label = dragPanel.name - let textSize = sk.getTextSize("Default", label) - let size = textSize + vec2(16, 8) - sk.draw9Patch("tooltip.9patch", 4, window.mousePos.vec2 + vec2(10, 10), size, rgbx(255, 255, 255, 200)) - discard sk.drawText("Default", label, window.mousePos.vec2 + vec2(18, 14), rgbx(255, 255, 255, 255)) - - # Regenerate the layout when R is pressed. - if window.buttonPressed[KeyR]: - regenerate() - - let ms = sk.avgFrameTime * 1000 - sk.at = sk.pos + vec2(sk.size.x - 600, 2) - let mem = getOccupiedMem() - let memoryChange = mem - prevMem - prevMem = mem - when defined(nimTypeNames): - let memCounters0 = getMemCounters() - type MemCounters = object - allocCounter: int - deallocCounter: int - let memCounters = cast[MemCounters](memCounters0) - let numAlloc = memCounters.allocCounter - let numAllocChange = numAlloc - prevNumAlloc - prevNumAlloc = numAlloc - else: - let numAllocChange = 0 - let numAlloc = 0 - let prevNumAlloc = 0 - - text(&"frame time: {ms:>7.3}ms {sk.instanceCount} {memoryChange}bytes/frame {numAllocChange}allocs/frame") - - sk.endUi() - window.swapBuffers() - - if window.cursor.kind != sk.cursor.kind: - window.cursor = sk.cursor - -while not window.closeRequested: - pollEvents() + +import + std/[random, strformat], + opengl, windy, bumpy, vmath, chroma, + silky + +let builder = newAtlasBuilder(1024, 4) +builder.addDir("data/", "data/") +builder.addFont("data/IBMPlexSans-Regular.ttf", "H1", 32.0) +builder.addFont("data/IBMPlexSans-Regular.ttf", "Default", 18.0) +builder.write("dist/atlas.png", "dist/atlas.json") + +let window = newWindow( + "Panels Example", + ivec2(1200, 800), + vsync = false +) +makeContextCurrent(window) +loadExtensions() + +proc snapToPixels(rect: Rect): Rect = + ## Snap rectangle coordinates to integer pixels. + rect(rect.x.int.float32, rect.y.int.float32, rect.w.int.float32, rect.h.int.float32) + +let sk = newSilky("dist/atlas.png", "dist/atlas.json") + +type + AreaLayout = enum + Horizontal + Vertical + + Area = ref object + layout: AreaLayout + areas: seq[Area] + panels: seq[Panel] + split: float32 + selectedPanelNum: int + rect: Rect # Calculated during draw + + Panel = ref object + name: string + parentArea: Area + + AreaScan = enum + Header + Body + North + South + East + West + +const + AreaHeaderHeight = 32.0 + AreaMargin = 6.0 + BackgroundColor = parseHtmlColor("#222222").rgbx + +var + rootArea: Area + dragArea: Area # For resizing splits + dragPanel: Panel # For moving panels + dropHighlight: Rect + showDropHighlight: bool + + maybeDragStartPos: Vec2 + maybeDragPanel: Panel + + prevMem: int + prevNumAlloc: int + +proc movePanels*(area: Area, panels: seq[Panel]) + +proc clear*(area: Area) = + ## Clear the area. + for panel in area.panels: + panel.parentArea = nil + for subarea in area.areas: + subarea.clear() + area.panels.setLen(0) + area.areas.setLen(0) + +proc removeBlankAreas*(area: Area) = + ## Remove blank areas recursively. + if area.areas.len > 0: + assert area.areas.len == 2 + if area.areas[0].panels.len == 0 and area.areas[0].areas.len == 0: + if area.areas[1].panels.len > 0: + area.movePanels(area.areas[1].panels) + area.areas.setLen(0) + elif area.areas[1].areas.len > 0: + let oldAreas = area.areas + area.areas = area.areas[1].areas + area.split = oldAreas[1].split + area.layout = oldAreas[1].layout + else: + discard + elif area.areas[1].panels.len == 0 and area.areas[1].areas.len == 0: + if area.areas[0].panels.len > 0: + area.movePanels(area.areas[0].panels) + area.areas.setLen(0) + elif area.areas[0].areas.len > 0: + let oldAreas = area.areas + area.areas = area.areas[0].areas + area.split = oldAreas[0].split + area.layout = oldAreas[0].layout + else: + discard + + for subarea in area.areas: + removeBlankAreas(subarea) + +proc addPanel*(area: Area, name: string) = + ## Add a panel to the area. + let panel = Panel(name: name, parentArea: area) + area.panels.add(panel) + +proc movePanel*(area: Area, panel: Panel) = + ## Move a panel to this area. + let idx = panel.parentArea.panels.find(panel) + if idx != -1: + panel.parentArea.panels.delete(idx) + area.panels.add(panel) + panel.parentArea = area + +proc insertPanel*(area: Area, panel: Panel, index: int) = + ## Insert a panel into this area at a specific index. + let idx = panel.parentArea.panels.find(panel) + var finalIndex = index + + # If moving within the same area, adjust index if we're moving forward + if panel.parentArea == area and idx != -1: + if idx < index: + finalIndex = index - 1 + + if idx != -1: + panel.parentArea.panels.delete(idx) + + # Clamp index to be safe + finalIndex = clamp(finalIndex, 0, area.panels.len) + + area.panels.insert(panel, finalIndex) + panel.parentArea = area + # Update selection to the new panel position + area.selectedPanelNum = finalIndex + +proc getTabInsertInfo(area: Area, mousePos: Vec2): (int, Rect) = + ## Get the insert information for a tab. + var x = area.rect.x + 4 + let headerH = AreaHeaderHeight + + # If no panels, insert at 0 + if area.panels.len == 0: + return (0, rect(x, area.rect.y + 4, 4, headerH - 4)) + + var bestIndex = 0 + var minDist = float32.high + var bestX = x + + # Check before first tab (index 0) + let dist0 = abs(mousePos.x - x) + minDist = dist0 + bestX = x + bestIndex = 0 + + for i, panel in area.panels: + let textSize = sk.getTextSize("Default", panel.name) + let tabW = textSize.x + 16 + + # The gap after this tab (index i + 1) + let gapX = x + tabW + 2 + let dist = abs(mousePos.x - gapX) + if dist < minDist: + minDist = dist + bestIndex = i + 1 + bestX = gapX + + x += tabW + 2 + + return (bestIndex, rect(bestX - 2, area.rect.y + 4, 4, headerH - 4)) + +proc movePanels*(area: Area, panels: seq[Panel]) = + ## Move multiple panels to this area. + var panelList = panels # Copy + for panel in panelList: + area.movePanel(panel) + +proc split*(area: Area, layout: AreaLayout) = + ## Split the area. + let + area1 = Area(rect: area.rect) # inherit rect initially + area2 = Area(rect: area.rect) + area.layout = layout + area.split = 0.5 + area.areas.add(area1) + area.areas.add(area2) + +proc scan*(area: Area): (Area, AreaScan, Rect) = + ## Scan the area to find the target under mouse. + let mousePos = window.mousePos.vec2 + var + targetArea: Area + areaScan: AreaScan + resRect: Rect + + proc visit(area: Area) = + if not mousePos.overlaps(area.rect): + return + + if area.areas.len > 0: + for subarea in area.areas: + visit(subarea) + else: + let + headerRect = rect( + area.rect.xy, + vec2(area.rect.w, AreaHeaderHeight) + ) + bodyRect = rect( + area.rect.xy + vec2(0, AreaHeaderHeight), + vec2(area.rect.w, area.rect.h - AreaHeaderHeight) + ) + northRect = rect( + area.rect.xy + vec2(0, AreaHeaderHeight), + vec2(area.rect.w, area.rect.h * 0.2) + ) + southRect = rect( + area.rect.xy + vec2(0, area.rect.h * 0.8), + vec2(area.rect.w, area.rect.h * 0.2) + ) + eastRect = rect( + area.rect.xy + vec2(area.rect.w * 0.8, 0) + vec2(0, AreaHeaderHeight), + vec2(area.rect.w * 0.2, area.rect.h - AreaHeaderHeight) + ) + westRect = rect( + area.rect.xy + vec2(0, 0) + vec2(0, AreaHeaderHeight), + vec2(area.rect.w * 0.2, area.rect.h - AreaHeaderHeight) + ) + + if mousePos.overlaps(headerRect): + areaScan = Header + resRect = headerRect + elif mousePos.overlaps(northRect): + areaScan = North + resRect = northRect + elif mousePos.overlaps(southRect): + areaScan = South + resRect = southRect + elif mousePos.overlaps(eastRect): + areaScan = East + resRect = eastRect + elif mousePos.overlaps(westRect): + areaScan = West + resRect = westRect + elif mousePos.overlaps(bodyRect): + areaScan = Body + resRect = bodyRect + + targetArea = area + + visit(rootArea) + return (targetArea, areaScan, resRect) + +proc initRootArea() = + ## Initialize the root area with default panels. + randomize() + rootArea = Area() + rootArea.split(Vertical) + rootArea.split = 0.20 + + rootArea.areas[0].addPanel("Super Panel 1") + rootArea.areas[0].addPanel("Cool Panel 2") + + rootArea.areas[1].split(Horizontal) + rootArea.areas[1].split = 0.5 + + rootArea.areas[1].areas[0].addPanel("Nice Panel 3") + rootArea.areas[1].areas[0].addPanel("The Other Panel 4") + rootArea.areas[1].areas[0].addPanel("Panel 5") + + rootArea.areas[1].areas[1].addPanel("World Class Panel 6") + rootArea.areas[1].areas[1].addPanel("FUN Panel 7") + rootArea.areas[1].areas[1].addPanel("Amazing Panel 8") + +proc regenerate() = + ## Regenerate the panel layout randomly. + rootArea = Area() + + var panelNum = 1 + proc iterate(area: Area, depth: int) = + if rand(0 .. depth) < 2: + # Split the area. + if rand(0 .. 1) == 0: + area.split(Horizontal) + else: + area.split(Vertical) + area.split = rand(0.2 .. 0.8) + iterate(area.areas[0], depth + 1) + iterate(area.areas[1], depth + 1) + else: + # Don't split the area. + for i in 0 ..< rand(1 .. 3): + area.addPanel("Panel " & $panelNum) + panelNum += 1 + iterate(rootArea, 0) + +initRootArea() + +proc drawAreaRecursive(area: Area, r: Rect) = + ## Recursively draw an area and its subareas. + area.rect = r.snapToPixels() + + if area.areas.len > 0: + let m = AreaMargin / 2 + if area.layout == Horizontal: + # Top/Bottom + let splitPos = r.h * area.split + + # Handle split resizing + let splitRect = rect(r.x, r.y + splitPos - 2, r.w, 4) + + if dragArea == nil and window.mousePos.vec2.overlaps(splitRect): + sk.cursor = Cursor(kind: ResizeUpDownCursor) + if window.buttonPressed[MouseLeft]: + dragArea = area + + let r1 = rect(r.x, r.y, r.w, splitPos - m) + let r2 = rect(r.x, r.y + splitPos + m, r.w, r.h - splitPos - m) + drawAreaRecursive(area.areas[0], r1) + drawAreaRecursive(area.areas[1], r2) + + else: + # Left/Right + let splitPos = r.w * area.split + + let splitRect = rect(r.x + splitPos - 2, r.y, 4, r.h) + + if dragArea == nil and window.mousePos.vec2.overlaps(splitRect): + sk.cursor = Cursor(kind: ResizeLeftRightCursor) + if window.buttonPressed[MouseLeft]: + dragArea = area + + let r1 = rect(r.x, r.y, splitPos - m, r.h) + let r2 = rect(r.x + splitPos + m, r.y, r.w - splitPos - m, r.h) + drawAreaRecursive(area.areas[0], r1) + drawAreaRecursive(area.areas[1], r2) + + elif area.panels.len > 0: + # Draw Panel + if area.selectedPanelNum > area.panels.len - 1: + area.selectedPanelNum = area.panels.len - 1 + + # Draw Header + let headerRect = rect(r.x, r.y, r.w, AreaHeaderHeight) + sk.draw9Patch("panel.header.9patch", 3, headerRect.xy, headerRect.wh) + + # Draw Tabs + var x = r.x + 4 + sk.pushClipRect(rect(r.x, r.y, r.w - 2, AreaHeaderHeight)) + for i, panel in area.panels: + let textSize = sk.getTextSize("Default", panel.name) + let tabW = textSize.x + 16 + let tabRect = rect(x, r.y + 4, tabW, AreaHeaderHeight - 4) + + let isSelected = i == area.selectedPanelNum + let isHovered = window.mousePos.vec2.overlaps(tabRect) + + # Handle tab clicks and dragging. + if isHovered: + if window.buttonPressed[MouseLeft]: + area.selectedPanelNum = i + # Only start dragging if the mouse moves 10 pixels or more. + maybeDragStartPos = window.mousePos.vec2 + maybeDragPanel = panel + elif window.buttonDown[MouseLeft] and dragPanel == panel: + # Dragging has started. + discard + + if window.buttonDown[MouseLeft]: + if maybeDragPanel != nil and (maybeDragStartPos - window.mousePos.vec2).length() > 10: + dragPanel = maybeDragPanel + maybeDragStartPos = vec2(0, 0) + maybeDragPanel = nil + else: + maybeDragStartPos = vec2(0, 0) + maybeDragPanel = nil + + if isSelected: + sk.draw9Patch("panel.tab.selected.9patch", 3, tabRect.xy, tabRect.wh, rgbx(255, 255, 255, 255)) + elif isHovered: + sk.draw9Patch("panel.tab.hover.9patch", 3, tabRect.xy, tabRect.wh, rgbx(255, 255, 255, 255)) + else: + sk.draw9Patch("panel.tab.9patch", 3, tabRect.xy, tabRect.wh) + + discard sk.drawText("Default", panel.name, vec2(x + 8, r.y + 4 + 2), rgbx(255, 255, 255, 255)) + + x += tabW + 2 + sk.popClipRect() + + # Draw the content area for the selected panel. + let contentRect = rect(r.x, r.y + AreaHeaderHeight, r.w, r.h - AreaHeaderHeight) + let activePanel = area.panels[area.selectedPanelNum] + let frameId = "panel:" & $cast[uint](activePanel) + let contentPos = vec2(contentRect.x, contentRect.y) + let contentSize = vec2(contentRect.w, contentRect.h) + frame(frameId, contentPos, contentSize): + # Start content with some inset padding. + sk.layout.at += vec2(8, 8) + h1text(activePanel.name) + text("This is the content of " & activePanel.name) + for i in 0 ..< 20: + text(&"Scrollable line {i} for " & activePanel.name) + + +window.onFrame = proc() = + ## Main frame loop. + sk.beginUI(window, window.size) + + sk.drawRect(vec2(0, 0), window.size.vec2, BackgroundColor) + sk.cursor = Cursor(kind: ArrowCursor) + + # Update dragging split if active. + if dragArea != nil: + if not window.buttonDown[MouseLeft]: + dragArea = nil + else: + if dragArea.layout == Horizontal: + sk.cursor = Cursor(kind: ResizeUpDownCursor) + dragArea.split = (window.mousePos.vec2.y - dragArea.rect.y) / dragArea.rect.h + else: + sk.cursor = Cursor(kind: ResizeLeftRightCursor) + dragArea.split = (window.mousePos.vec2.x - dragArea.rect.x) / dragArea.rect.w + dragArea.split = clamp(dragArea.split, 0.1, 0.9) + + # Update dragging panel if active. + showDropHighlight = false + if dragPanel != nil: + if not window.buttonDown[MouseLeft]: + # Drop + let (targetArea, areaScan, _) = rootArea.scan() + if targetArea != nil: + case areaScan: + of Header: + let (idx, _) = targetArea.getTabInsertInfo(window.mousePos.vec2) + targetArea.insertPanel(dragPanel, idx) + of Body: + targetArea.movePanel(dragPanel) + of North: + targetArea.split(Horizontal) + targetArea.areas[0].movePanel(dragPanel) + targetArea.areas[1].movePanels(targetArea.panels) + of South: + targetArea.split(Horizontal) + targetArea.areas[1].movePanel(dragPanel) + targetArea.areas[0].movePanels(targetArea.panels) + of East: + targetArea.split(Vertical) + targetArea.areas[1].movePanel(dragPanel) + targetArea.areas[0].movePanels(targetArea.panels) + of West: + targetArea.split(Vertical) + targetArea.areas[0].movePanel(dragPanel) + targetArea.areas[1].movePanels(targetArea.panels) + + rootArea.removeBlankAreas() + dragPanel = nil + else: + # Continue dragging and show highlight. + let (targetArea, areaScan, rect) = rootArea.scan() + dropHighlight = rect + showDropHighlight = true + + if targetArea != nil and areaScan == Header: + let (_, highlightRect) = targetArea.getTabInsertInfo(window.mousePos.vec2) + dropHighlight = highlightRect + + drawAreaRecursive(rootArea, rect(0, 1, window.size.x.float32, window.size.y.float32)) + + # Draw drop highlight and ghost when dragging a panel. + if showDropHighlight and dragPanel != nil: + sk.drawRect(dropHighlight.xy, dropHighlight.wh, rgbx(255, 255, 0, 100)) + + # Draw dragging ghost + let label = dragPanel.name + let textSize = sk.getTextSize("Default", label) + let size = textSize + vec2(16, 8) + sk.draw9Patch("tooltip.9patch", 4, window.mousePos.vec2 + vec2(10, 10), size, rgbx(255, 255, 255, 200)) + discard sk.drawText("Default", label, window.mousePos.vec2 + vec2(18, 14), rgbx(255, 255, 255, 255)) + + # Regenerate the layout when R is pressed. + if window.buttonPressed[KeyR]: + regenerate() + + let ms = sk.avgFrameTime * 1000 + sk.layout.at = sk.pos + vec2(sk.size.x - 600, 2) + let mem = getOccupiedMem() + let memoryChange = mem - prevMem + prevMem = mem + when defined(nimTypeNames): + let memCounters0 = getMemCounters() + type MemCounters = object + allocCounter: int + deallocCounter: int + let memCounters = cast[MemCounters](memCounters0) + let numAlloc = memCounters.allocCounter + let numAllocChange = numAlloc - prevNumAlloc + prevNumAlloc = numAlloc + else: + let numAllocChange = 0 + let numAlloc = 0 + let prevNumAlloc = 0 + + text(&"frame time: {ms:>7.3}ms {sk.instanceCount} {memoryChange}bytes/frame {numAllocChange}allocs/frame") + + sk.endUi() + window.swapBuffers() + + if window.cursor.kind != sk.cursor.kind: + window.cursor = sk.cursor + +while not window.closeRequested: + pollEvents() diff --git a/examples/the7gui/the7gui.nim b/examples/the7gui/the7gui.nim index d310feb..c4ea2d1 100644 --- a/examples/the7gui/the7gui.nim +++ b/examples/the7gui/the7gui.nim @@ -105,7 +105,7 @@ window.onFrame = proc() = for x in 0 ..< 16: for y in 0 ..< 10: - sk.at = vec2(x.float32 * 256, y.float32 * 256) + sk.layout.at = vec2(x.float32 * 256, y.float32 * 256) image("testTexture", rgbx(30, 30, 30, 255)) subWindow("Challenges", showChallenges, vec2(10, 10), vec2(300, 450)): @@ -263,11 +263,11 @@ window.onFrame = proc() = if not showChallenges and not showCounter and not showTemperature and not showFlightBooker and not showTimer and not showCRUD and not showCircleDrawer and not showCells: if window.buttonPressed[MouseLeft]: showChallenges = true - sk.at = vec2(100, 100) + sk.layout.at = vec2(100, 100) text("Click anywhere to show the Challenges window") let ms = sk.avgFrameTime * 1000 - sk.at = sk.pos + vec2(sk.size.x - 250, 20) + sk.layout.at = sk.pos + vec2(sk.size.x - 250, 20) text(&"frame time: {ms:>7.3f}ms") sk.endUi() diff --git a/src/silky.nim b/src/silky.nim index 2a94b6b..aa5e56d 100644 --- a/src/silky.nim +++ b/src/silky.nim @@ -1,9 +1,9 @@ import std/[tables] when defined(silkyTesting): - import silky/[semantic, atlas, widgets, textboxes, testing] - export semantic, atlas, widgets, tables, textboxes, testing + import silky/[semantic, atlas, widgets, textboxes, testing, common, scrollbars, layout] + export semantic, atlas, widgets, tables, textboxes, testing, common, scrollbars, layout else: import opengl, windy - import silky/[drawing, atlas, widgets, textboxes] - export opengl, windy, drawing, atlas, widgets, tables, textboxes + import silky/[drawing, atlas, widgets, textboxes, common, scrollbars, layout] + export opengl, windy, drawing, atlas, widgets, tables, textboxes, common, scrollbars, layout diff --git a/src/silky/common.nim b/src/silky/common.nim new file mode 100644 index 0000000..5edb9cc --- /dev/null +++ b/src/silky/common.nim @@ -0,0 +1,54 @@ +import pixie, vmath, chroma + +export pixie.HorizontalAlignment, pixie.VerticalAlignment + +const + NormalLayer* = 0 + PopupsLayer* = 1 + +type + StackDirection* = enum + ## Direction of the stack. + TopToBottom + BottomToTop + LeftToRight + RightToLeft + + Anchor* = enum + ## Anchor side for layout stacking. + AnchorLeft + AnchorRight + AnchorTop + AnchorBottom + + Theme* = object + ## Theme for the Silky UI. + padding*: int = 8 + menuPadding*: int = 2 + spacing*: int = 8 + border*: int = 10 + textPadding*: int = 4 + headerHeight*: int = 32 + defaultTextColor*: ColorRGBX = rgbx(255, 255, 255, 255) + disabledTextColor*: ColorRGBX = rgbx(150, 150, 150, 255) + errorTextColor*: ColorRGBX = rgbx(255, 100, 100, 255) + buttonHoverColor*: ColorRGBX = rgbx(255, 255, 255, 255) + buttonDownColor*: ColorRGBX = rgbx(255, 255, 255, 255) + iconButtonHoverColor*: ColorRGBX = rgbx(255, 255, 255, 255) + iconButtonDownColor*: ColorRGBX = rgbx(255, 255, 255, 255) + iconClickableUpColor*: ColorRGBX = rgbx(200, 200, 200, 200) + iconClickableOnColor*: ColorRGBX = rgbx(255, 255, 255, 255) + iconClickableHoverColor*: ColorRGBX = rgbx(255, 255, 255, 255) + iconClickableOffColor*: ColorRGBX = rgbx(110, 110, 110, 110) + dropdownHoverBgColor*: ColorRGBX = rgbx(220, 220, 240, 255) + dropdownBgColor*: ColorRGBX = rgbx(255, 255, 255, 255) + dropdownPopupBgColor*: ColorRGBX = rgbx(245, 245, 255, 255) + textColor*: ColorRGBX = rgbx(255, 255, 255, 255) + textH1Color*: ColorRGBX = rgbx(255, 255, 255, 255) + frameFocusColor*: ColorRGBX = rgbx(220, 220, 255, 255) + headerBgColor*: ColorRGBX = rgbx(30, 30, 40, 255) + menuRootHoverColor*: ColorRGBX = rgbx(70, 70, 90, 200) + menuItemHoverColor*: ColorRGBX = rgbx(70, 70, 90, 180) + menuItemBgColor*: ColorRGBX = rgbx(40, 40, 50, 140) + menuPopupHoverColor*: ColorRGBX = rgbx(80, 80, 100, 180) + menuPopupSelectedColor*: ColorRGBX = rgbx(60, 60, 80, 120) diff --git a/src/silky/drawing.nim b/src/silky/drawing.nim index 9423ab0..7989d92 100644 --- a/src/silky/drawing.nim +++ b/src/silky/drawing.nim @@ -1,7 +1,7 @@ import std/[os, strutils, tables, unicode, times], pixie, opengl, jsony, shady, vmath, windy, bumpy, - silky/[atlas, shaders] + atlas, shaders, common, layout when defined(profile): import fluffy/measure @@ -14,50 +14,7 @@ else: template measurePop*() = discard -const - NormalLayer* = 0 - PopupsLayer* = 1 - type - StackDirection* = enum - ## Direction of the stack. - TopToBottom - BottomToTop - LeftToRight - RightToLeft - - Theme* = object - ## Theme for the Silky UI. - padding*: int = 8 - menuPadding*: int = 2 - spacing*: int = 8 - border*: int = 10 - textPadding*: int = 4 - headerHeight*: int = 32 - defaultTextColor*: ColorRGBX = rgbx(255, 255, 255, 255) - disabledTextColor*: ColorRGBX = rgbx(150, 150, 150, 255) - errorTextColor*: ColorRGBX = rgbx(255, 100, 100, 255) - buttonHoverColor*: ColorRGBX = rgbx(255, 255, 255, 255) - buttonDownColor*: ColorRGBX = rgbx(255, 255, 255, 255) - iconButtonHoverColor*: ColorRGBX = rgbx(255, 255, 255, 255) - iconButtonDownColor*: ColorRGBX = rgbx(255, 255, 255, 255) - iconClickableUpColor*: ColorRGBX = rgbx(200, 200, 200, 200) - iconClickableOnColor*: ColorRGBX = rgbx(255, 255, 255, 255) - iconClickableHoverColor*: ColorRGBX = rgbx(255, 255, 255, 255) - iconClickableOffColor*: ColorRGBX = rgbx(110, 110, 110, 110) - dropdownHoverBgColor*: ColorRGBX = rgbx(220, 220, 240, 255) - dropdownBgColor*: ColorRGBX = rgbx(255, 255, 255, 255) - dropdownPopupBgColor*: ColorRGBX = rgbx(245, 245, 255, 255) - textColor*: ColorRGBX = rgbx(255, 255, 255, 255) - textH1Color*: ColorRGBX = rgbx(255, 255, 255, 255) - frameFocusColor*: ColorRGBX = rgbx(220, 220, 255, 255) - headerBgColor*: ColorRGBX = rgbx(30, 30, 40, 255) - menuRootHoverColor*: ColorRGBX = rgbx(70, 70, 90, 200) - menuItemHoverColor*: ColorRGBX = rgbx(70, 70, 90, 180) - menuItemBgColor*: ColorRGBX = rgbx(40, 40, 50, 140) - menuPopupHoverColor*: ColorRGBX = rgbx(80, 80, 100, 180) - menuPopupSelectedColor*: ColorRGBX = rgbx(60, 60, 80, 120) - SilkyVertex* {.packed.} = object pos*: Vec2 size*: Vec2 @@ -70,15 +27,12 @@ type Silky* = ref object ## The Silky that draws the AA pixel art sprites. inFrame: bool = false - at*: Vec2 - atStack: seq[Vec2] - posStack: seq[Vec2] - sizeStack: seq[Vec2] - stretchAt*: Vec2 - directionStack: seq[StackDirection] + layout*: Layout + layoutStack: seq[Layout] textStyle*: string = "Default" padding*: float32 = 12 theme*: Theme = Theme() + themeStack: seq[Theme] cursor*: Cursor = Cursor(kind: ArrowCursor) inputRunes*: seq[Rune] @@ -126,51 +80,55 @@ proc popLayer*(sk: Silky) = ## Pop the current layer from the stack. sk.currentLayer = sk.layerStack.pop() +proc pushTheme*(sk: Silky) = + ## Save the current theme onto the stack. + sk.themeStack.add(sk.theme) + +proc popTheme*(sk: Silky) = + ## Restore the previous theme from the stack. + sk.theme = sk.themeStack.pop() + proc pushLayout*( sk: Silky, pos: Vec2, size: Vec2, - direction: StackDirection = TopToBottom + direction: StackDirection = TopToBottom, + anchor: Anchor = AnchorLeft ) = ## Push a new layout container onto the stack. - sk.atStack.add(sk.at) - sk.posStack.add(pos) - sk.at = pos - sk.sizeStack.add(size) - sk.directionStack.add(direction) - sk.stretchAt = sk.at - case direction: - of TopToBottom: - sk.at = pos - of BottomToTop: - sk.at = pos + vec2(0, size.y) - of LeftToRight: - sk.at = pos - of RightToLeft: - sk.at = pos + vec2(size.x, 0) + sk.layoutStack.add(sk.layout) + sk.layout.init(pos, size, direction, anchor) proc popLayout*(sk: Silky) = ## Pop the current layout container from the stack. - sk.at = sk.atStack.pop() - discard sk.posStack.pop() - discard sk.sizeStack.pop() - discard sk.directionStack.pop() + sk.layout = sk.layoutStack.pop() proc pos*(sk: Silky): Vec2 = ## Get the current layout position. - sk.posStack[^1] + sk.layout.pos proc size*(sk: Silky): Vec2 = ## Get the current layout size. - sk.sizeStack[^1] + sk.layout.size proc rootSize*(sk: Silky): Vec2 = ## Get the root layout size. - sk.sizeStack[0] + if sk.layoutStack.len <= 1: + sk.layout.size + else: + sk.layoutStack[1].size proc stackDirection*(sk: Silky): StackDirection = ## Get the current stack direction. - sk.directionStack[^1] + sk.layout.direction + +proc stackAnchor*(sk: Silky): Anchor = + ## Get the current stack anchor. + sk.layout.anchor + +proc widgetPos*(sk: Silky, size: Vec2): Vec2 = + ## Compute top-left draw position for a widget of the given size. + sk.layout.widgetPos(size) proc pushClipRect*(sk: Silky, rect: Rect) = ## Push a new clip rectangle onto the stack. @@ -193,16 +151,8 @@ proc instanceCount*(sk: Silky): int = proc advance*(sk: Silky, amount: Vec2) = ## Advance the position. - sk.stretchAt = max(sk.stretchAt, sk.at + amount + vec2(sk.theme.spacing.float32)) - case sk.stackDirection: - of TopToBottom: - sk.at.y += amount.y + sk.theme.spacing.float32 - of BottomToTop: - sk.at.y -= amount.y + sk.theme.spacing.float32 - of LeftToRight: - sk.at.x += amount.x + sk.theme.spacing.float32 - of RightToLeft: - sk.at.x -= amount.x + sk.theme.spacing.float32 + let spacing = sk.theme.spacing.float32 + sk.layout.advance(amount, spacing) proc getImageSize*(sk: Silky, image: string): Vec2 = ## Get the size of an image in the atlas. @@ -324,7 +274,9 @@ proc drawText*( maxWidth = float32.high, maxHeight = float32.high, clip = true, - wordWrap = false + wordWrap = false, + hAlign: HorizontalAlignment = LeftAlign, + vAlign: VerticalAlignment = TopAlign ): Vec2 = ## Draw text using the specified font from the atlas. assert sk.inFrame @@ -339,6 +291,9 @@ proc drawText*( let maxPos = pos + vec2(maxWidth, maxHeight) let runedText = text.toRunes let hasSubpixel = fontData.subpixelSteps > 0 + let layer = sk.currentLayer + let needsHAlign = hAlign != LeftAlign + let needsVAlign = vAlign != TopAlign # Per-char clip rect: when clip is on, intersect parent clip rect with text bounds. let parentClip = sk.clipRect @@ -353,11 +308,29 @@ proc drawText*( else: (parentClip.xy, parentClip.wh) + # Track buffer indices for alignment fixup. + let textStartIdx = sk.layers[layer].len + var lineStartIdx = textStartIdx + + proc alignLine(sk: Silky, lineWidth: float32) = + ## Shift glyph positions for the current line based on horizontal alignment. + if not needsHAlign: return + let dx = + case hAlign: + of LeftAlign: 0.0f + of CenterAlign: floor((maxWidth - lineWidth) * 0.5) + of RightAlign: floor(maxWidth - lineWidth) + if dx != 0: + for j in lineStartIdx ..< sk.layers[layer].len: + sk.layers[layer][j].pos.x += dx + lineStartIdx = sk.layers[layer].len + var i = 0 while i < runedText.len: let rune = runedText[i] if rune == Rune(10): # Newline. + sk.alignLine(currentPos.x - pos.x) currentPos.x = pos.x currentPos.y += fontData.lineHeight inc i @@ -377,6 +350,7 @@ proc drawText*( wordW += fontData.entries["?"][0].advance inc j if currentPos.x + wordW > pos.x + maxWidth: + sk.alignLine(currentPos.x - pos.x) currentPos.x = pos.x currentPos.y += fontData.lineHeight @@ -399,9 +373,10 @@ proc drawText*( inc i continue - if currentPos.x >= maxPos.x: + if currentPos.x + entry.advance > maxPos.x: if wordWrap: # Character-level fallback for words wider than maxWidth. + sk.alignLine(currentPos.x - pos.x) currentPos.x = pos.x currentPos.y += fontData.lineHeight elif clip: @@ -421,7 +396,7 @@ proc drawText*( round(currentPos.y + entry.boundsY) ) - sk.layers[sk.currentLayer].add(SilkyVertex( + sk.layers[layer].add(SilkyVertex( pos: glyphPos, size: vec2(entry.boundsWidth, entry.boundsHeight), uvPos: [entry.x.uint16, entry.y.uint16], @@ -442,6 +417,21 @@ proc drawText*( inc i + # Align the last line. + sk.alignLine(currentPos.x - pos.x) + + # Vertical alignment: shift all glyphs in the buffer. + if needsVAlign: + let textHeight = currentPos.y - pos.y - fontData.ascent + fontData.lineHeight + let dy = + case vAlign: + of TopAlign: 0.0f + of MiddleAlign: floor((maxHeight - textHeight) * 0.5) + of BottomAlign: floor(maxHeight - textHeight) + if dy != 0: + for j in textStartIdx ..< sk.layers[layer].len: + sk.layers[layer][j].pos.y += dy + return currentPos - pos proc getTextSize*(sk: Silky, font: string, text: string): Vec2 = @@ -601,6 +591,26 @@ proc drawImage*( color ) +proc drawImage*( + sk: Silky, + name: string, + pos: Vec2, + size: Vec2, + color = rgbx(255, 255, 255, 255) +) = + ## Draw a sprite at the given position and size. + if name notin sk.atlas.entries: + echo "[Warning] Sprite not found in atlas: " & name + return + let uv = sk.atlas.entries[name] + sk.drawQuad( + pos, + size, + vec2(uv.x.float32, uv.y.float32), + vec2(uv.width.float32, uv.height.float32), + color + ) + proc drawRect*( sk: Silky, pos: Vec2, diff --git a/src/silky/layout.nim b/src/silky/layout.nim new file mode 100644 index 0000000..bd4f6aa --- /dev/null +++ b/src/silky/layout.nim @@ -0,0 +1,109 @@ +import + vmath, bumpy, + common + +type + Layout* = object + ## Stores the current layout context. + at*: Vec2 # Current layout cursor position. + num*: int # Number of widgets placed. + pos*: Vec2 # Start of the layout outer layout area. + size*: Vec2 # Size of the layout outer layout area. + direction*: StackDirection # Direction of the layout. + anchor*: Anchor # Anchor of the layout. + stretchMax*: Vec2 # Maximum stretch position inside the layout area. + stretchMin*: Vec2 # Minimum stretch position inside the layout area. + + # Layout basis vectors. + mainDir*: Vec2 + paddingDir*: Vec2 + sizeSign*: Vec2 + +const + MainDirs = [ + vec2(0, 1), + vec2(0, -1), + vec2(1, 0), + vec2(-1, 0) + ] + PaddingDirs = [ + [vec2(1, 1), vec2(-1, 1), vec2(0, 0), vec2(0, 0)], + [vec2(1, -1), vec2(-1, -1), vec2(0, 0), vec2(0, 0)], + [vec2(0, 0), vec2(0, 0), vec2(1, 1), vec2(1, -1)], + [vec2(0, 0), vec2(0, 0), vec2(-1, 1), vec2(-1, -1)] + ] + +proc applyBasis(layout: var Layout) = + ## Computes and stores basis vectors inside one layout context. + layout.mainDir = MainDirs[layout.direction.ord] + layout.paddingDir = PaddingDirs[layout.direction.ord][layout.anchor.ord] + layout.sizeSign = vec2( + if layout.paddingDir.x < 0: 1f else: 0f, + if layout.paddingDir.y < 0: 1f else: 0f + ) + +proc init*( + layout: var Layout, + pos: Vec2, + size: Vec2, + direction: StackDirection = TopToBottom, + anchor: Anchor = AnchorLeft +)= + ## Creates a new layout context with computed basis and stretch at start. + layout = Layout( + pos: pos, + size: size, + direction: direction, + anchor: anchor + ) + layout.applyBasis() + let startPos = layout.pos + layout.size * layout.sizeSign + layout.at = startPos + layout.num = 0 + layout.stretchMin = startPos + layout.stretchMax = startPos + +proc newLayout*( + pos: Vec2, + size: Vec2, + direction: StackDirection = TopToBottom, + anchor: Anchor = AnchorLeft +): Layout = + ## Creates and returns a fully initialized layout context. + result.init(pos, size, direction, anchor) + +proc applyAnchor*(layout: var Layout) = + ## Returns the initial layout cursor after anchor growth is applied. + layout.at = layout.pos + layout.size * layout.sizeSign + +proc applyPadding*(layout: var Layout, padding: Vec2) = + ## Returns the signed padding offset for this layout. + layout.at += padding * layout.paddingDir + +proc widgetPos*(layout: Layout, widgetSize: Vec2): Vec2 = + ## Returns the top-left draw position for a widget. + layout.at + widgetSize * layout.sizeSign * layout.paddingDir + +proc applySpacing*(layout: var Layout, spacing: Vec2) = + ## Returns the signed spacing offset for this layout. + layout.at += spacing * layout.sizeSign * layout.paddingDir + +proc advanceDelta*(layout: Layout, amount: Vec2, spacing: float32): Vec2 = + ## Returns the cursor delta for one placed child. + (amount + vec2(spacing)) * layout.mainDir + +proc advance*(layout: var Layout, amount: Vec2, spacing: float32) = + ## Advances layout cursor and updates stretch bounds. + layout.stretchMin = min(layout.stretchMin, layout.at) + layout.stretchMax = max(layout.stretchMax, layout.at + amount + vec2(spacing)) + layout.at += layout.advanceDelta(amount, spacing) + inc layout.num + +proc includeRect*(minPos: var Vec2, maxPos: var Vec2, pos: Vec2, size: Vec2) = + ## Expands min and max points to include one rectangle. + minPos = min(minPos, pos) + maxPos = max(maxPos, pos + size) + +proc rectFromMinMax*(minPos, maxPos: Vec2): Rect = + ## Builds a rectangle from min and max corner points. + rect(minPos, maxPos - minPos) diff --git a/src/silky/scrollbars.nim b/src/silky/scrollbars.nim new file mode 100644 index 0000000..2be3ef5 --- /dev/null +++ b/src/silky/scrollbars.nim @@ -0,0 +1,133 @@ +import vmath, bumpy + +type + ScrollArea* = object + ## Pure data for scroll state and geometry computation. + scrollPos*: Vec2 + scrollingX*: bool + scrollingY*: bool + scrollDragOffset*: Vec2 + contentMin*: Vec2 + contentMax*: Vec2 + viewPos*: Vec2 + viewSize*: Vec2 + initialized*: bool + +proc contentSize*(sa: ScrollArea): Vec2 = + ## Return the total content extent. + max(sa.contentMax - sa.contentMin, vec2(0)) + +proc scrollMax*(sa: ScrollArea): Vec2 = + ## Return the maximum scroll offset before content runs out. + max(sa.contentSize - sa.viewSize, vec2(0)) + +proc needsScrollX*(sa: ScrollArea): bool = + ## True when content is wider than the viewport. + sa.contentSize.x > sa.viewSize.x + +proc needsScrollY*(sa: ScrollArea): bool = + ## True when content is taller than the viewport. + sa.contentSize.y > sa.viewSize.y + +proc clampScroll*(sa: var ScrollArea) = + ## Clamp scroll position to the valid range. + let sm = sa.scrollMax + if sm.y > 0: + sa.scrollPos.y = clamp(sa.scrollPos.y, 0.0f, sm.y) + else: + sa.scrollPos.y = 0 + if sm.x > 0: + sa.scrollPos.x = clamp(sa.scrollPos.x, 0.0f, sm.x) + else: + sa.scrollPos.x = 0 + +proc initScroll*(sa: var ScrollArea) = + ## On the first frame with overflow, default to the far end for reversed anchors. + if sa.initialized: + return + let sm = sa.scrollMax + if sm.x <= 0 and sm.y <= 0: + return + sa.initialized = true + if sa.contentMin.y < sa.viewPos.y: + sa.scrollPos.y = sm.y + if sa.contentMin.x < sa.viewPos.x: + sa.scrollPos.x = sm.x + +proc scrollOffset*(sa: ScrollArea): Vec2 = + ## Return the translation to apply to content before drawing. + result = -sa.scrollPos + +proc applyWheel*(sa: var ScrollArea, delta: Vec2) = + ## Apply scroll wheel input. + let sm = sa.scrollMax + if not sa.scrollingY and delta.y != 0: + sa.scrollPos.y += delta.y + sa.scrollPos.y = clamp(sa.scrollPos.y, 0.0f, sm.y) + if not sa.scrollingX and delta.x != 0: + sa.scrollPos.x += delta.x + sa.scrollPos.x = clamp(sa.scrollPos.x, 0.0f, sm.x) + +proc scrollBarY*(sa: ScrollArea): tuple[track: Rect, handle: Rect] = + ## Compute vertical scrollbar track and handle rectangles. + let track = rect( + sa.viewPos.x + sa.viewSize.x - 10, + sa.viewPos.y + 2, + 8, + sa.viewSize.y - 4 - 10 + ) + let sm = sa.scrollMax + let cs = sa.contentSize + let posPercent = if sm.y > 0: sa.scrollPos.y / sm.y else: 0.0f + let sizePercent = sa.viewSize.y / cs.y + let handle = rect( + track.x, + track.y + (track.h - track.h * sizePercent) * posPercent, + 8, + track.h * sizePercent + ) + return (track, handle) + +proc scrollBarX*(sa: ScrollArea): tuple[track: Rect, handle: Rect] = + ## Compute horizontal scrollbar track and handle rectangles. + let track = rect( + sa.viewPos.x + 2, + sa.viewPos.y + sa.viewSize.y - 10, + sa.viewSize.x - 4 - 10, + 8 + ) + let sm = sa.scrollMax + let cs = sa.contentSize + let posPercent = if sm.x > 0: sa.scrollPos.x / sm.x else: 0.0f + let sizePercent = sa.viewSize.x / cs.x + let handle = rect( + track.x + (track.w - track.w * sizePercent) * posPercent, + track.y, + track.w * sizePercent, + 8 + ) + return (track, handle) + +proc dragScrollY*(sa: var ScrollArea, mouseY: float32) = + ## Update scroll position from vertical scrollbar drag. + let (track, handle) = sa.scrollBarY + let relativeY = mouseY - sa.scrollDragOffset.y - track.y + let available = track.h - handle.h + if available > 0: + let pct = clamp(relativeY / available, 0.0f, 1.0f) + sa.scrollPos.y = pct * sa.scrollMax.y + +proc dragScrollX*(sa: var ScrollArea, mouseX: float32) = + ## Update scroll position from horizontal scrollbar drag. + let (track, handle) = sa.scrollBarX + let relativeX = mouseX - sa.scrollDragOffset.x - track.x + let available = track.w - handle.w + if available > 0: + let pct = clamp(relativeX / available, 0.0f, 1.0f) + sa.scrollPos.x = pct * sa.scrollMax.x + +proc releaseIfUp*(sa: var ScrollArea, mouseDown: bool) = + ## Release scrollbar drag when mouse button is up. + if not mouseDown: + sa.scrollingY = false + sa.scrollingX = false diff --git a/src/silky/semantic.nim b/src/silky/semantic.nim index 9f04f01..cb99186 100644 --- a/src/silky/semantic.nim +++ b/src/silky/semantic.nim @@ -3,7 +3,7 @@ import std/[strutils, tables, unicode, times], vmath, bumpy, chroma, jsony, - silky/atlas + atlas, common, layout type WidgetState* = object @@ -201,71 +201,16 @@ proc diff*(old, new: string): string = return output.join("\n") -const - NormalLayer* = 0 - PopupsLayer* = 1 - type - StackDirection* = enum - TopToBottom - BottomToTop - LeftToRight - RightToLeft - - Theme* = object - ## Visual theme settings for widgets. - padding*: int = 8 - menuPadding*: int = 2 - spacing*: int = 8 - border*: int = 10 - textPadding*: int = 4 - headerHeight*: int = 32 - defaultTextColor*: ColorRGBX = rgbx(255, 255, 255, 255) - disabledTextColor*: ColorRGBX = rgbx(150, 150, 150, 255) - errorTextColor*: ColorRGBX = rgbx(255, 100, 100, 255) - buttonHoverColor*: ColorRGBX = rgbx(255, 255, 255, 255) - buttonDownColor*: ColorRGBX = rgbx(255, 255, 255, 255) - iconButtonHoverColor*: ColorRGBX = rgbx(255, 255, 255, 255) - iconButtonDownColor*: ColorRGBX = rgbx(255, 255, 255, 255) - iconClickableUpColor*: ColorRGBX = rgbx(200, 200, 200, 200) - iconClickableOnColor*: ColorRGBX = rgbx(255, 255, 255, 255) - iconClickableHoverColor*: ColorRGBX = rgbx(255, 255, 255, 255) - iconClickableOffColor*: ColorRGBX = rgbx(110, 110, 110, 110) - dropdownHoverBgColor*: ColorRGBX = rgbx(220, 220, 240, 255) - dropdownBgColor*: ColorRGBX = rgbx(255, 255, 255, 255) - dropdownPopupBgColor*: ColorRGBX = rgbx(245, 245, 255, 255) - textColor*: ColorRGBX = rgbx(255, 255, 255, 255) - textH1Color*: ColorRGBX = rgbx(255, 255, 255, 255) - frameFocusColor*: ColorRGBX = rgbx(220, 220, 255, 255) - headerBgColor*: ColorRGBX = rgbx(30, 30, 40, 255) - menuRootHoverColor*: ColorRGBX = rgbx(70, 70, 90, 200) - menuItemHoverColor*: ColorRGBX = rgbx(70, 70, 90, 180) - menuItemBgColor*: ColorRGBX = rgbx(40, 40, 50, 140) - menuPopupHoverColor*: ColorRGBX = rgbx(80, 80, 100, 180) - menuPopupSelectedColor*: ColorRGBX = rgbx(60, 60, 80, 120) - - SilkyVertex* {.packed.} = object - ## Vertex data for GPU rendering. - pos*: Vec2 - size*: Vec2 - uvPos*: array[2, uint16] - uvSize*: array[2, uint16] - color*: ColorRGBX - clipPos*: Vec2 - clipSize*: Vec2 - Silky* = ref object ## Main Silky context for testing mode without GPU. inFrame: bool = false - at*: Vec2 - atStack: seq[Vec2] - posStack: seq[Vec2] - sizeStack: seq[Vec2] - stretchAt*: Vec2 - directionStack: seq[StackDirection] + layout*: Layout + layoutStack: seq[Layout] textStyle*: string = "Default" padding*: float32 = 12 theme*: Theme = Theme() + themeStack: seq[Theme] inputRunes*: seq[Rune] showTooltip*: bool = false lastMousePos*: Vec2 @@ -273,7 +218,6 @@ type hover*: bool = false tooltipThreshold*: float64 = 0.5 atlas*: SilkyAtlas - layers*: array[2, seq[SilkyVertex]] currentLayer*: int layerStack*: seq[int] clipStack: seq[Rect] @@ -291,42 +235,49 @@ proc popLayer*(sk: Silky) = ## Pops the current rendering layer from the stack. sk.currentLayer = sk.layerStack.pop() -proc pushLayout*(sk: Silky, pos: Vec2, size: Vec2, direction: StackDirection = TopToBottom) = +proc pushTheme*(sk: Silky) = + ## Save the current theme onto the stack. + sk.themeStack.add(sk.theme) + +proc popTheme*(sk: Silky) = + ## Restore the previous theme from the stack. + sk.theme = sk.themeStack.pop() + +proc pushLayout*(sk: Silky, pos: Vec2, size: Vec2, direction: StackDirection = TopToBottom, anchor: Anchor = AnchorLeft) = ## Pushes a new layout region onto the stack. - sk.atStack.add(sk.at) - sk.posStack.add(pos) - sk.at = pos - sk.sizeStack.add(size) - sk.directionStack.add(direction) - sk.stretchAt = sk.at - case direction: - of TopToBottom: sk.at = pos - of BottomToTop: sk.at = pos + vec2(0, size.y) - of LeftToRight: sk.at = pos - of RightToLeft: sk.at = pos + vec2(size.x, 0) + sk.layoutStack.add(sk.layout) + sk.layout.init(pos, size, direction, anchor) proc popLayout*(sk: Silky) = ## Pops the current layout region from the stack. - sk.at = sk.atStack.pop() - discard sk.posStack.pop() - discard sk.sizeStack.pop() - discard sk.directionStack.pop() + sk.layout = sk.layoutStack.pop() proc pos*(sk: Silky): Vec2 = ## Returns the current layout position. - sk.posStack[^1] + sk.layout.pos proc size*(sk: Silky): Vec2 = ## Returns the current layout size. - sk.sizeStack[^1] + sk.layout.size proc rootSize*(sk: Silky): Vec2 = ## Returns the root layout size. - sk.sizeStack[0] + if sk.layoutStack.len <= 1: + sk.layout.size + else: + sk.layoutStack[1].size proc stackDirection*(sk: Silky): StackDirection = ## Returns the current stack direction. - sk.directionStack[^1] + sk.layout.direction + +proc stackAnchor*(sk: Silky): Anchor = + ## Returns the current stack anchor. + sk.layout.anchor + +proc widgetPos*(sk: Silky, size: Vec2): Vec2 = + ## Compute top-left draw position for a widget of the given size. + sk.layout.widgetPos(size) proc pushClipRect*(sk: Silky, rect: Rect) = ## Pushes a clipping rectangle onto the stack. @@ -342,12 +293,8 @@ proc clipRect*(sk: Silky): Rect = proc advance*(sk: Silky, amount: Vec2) = ## Advances the cursor position by the given amount. - sk.stretchAt = max(sk.stretchAt, sk.at + amount + vec2(sk.theme.spacing.float32)) - case sk.stackDirection: - of TopToBottom: sk.at.y += amount.y + sk.theme.spacing.float32 - of BottomToTop: sk.at.y -= amount.y + sk.theme.spacing.float32 - of LeftToRight: sk.at.x += amount.x + sk.theme.spacing.float32 - of RightToLeft: sk.at.x -= amount.x + sk.theme.spacing.float32 + let spacing = sk.theme.spacing.float32 + sk.layout.advance(amount, spacing) proc getImageSize*(sk: Silky, image: string): Vec2 = ## Returns the size of an image from the atlas. @@ -404,6 +351,10 @@ proc drawImage*(sk: Silky, name: string, pos: Vec2, color = rgbx(255, 255, 255, ## Stub for drawing an image from the atlas. discard +proc drawImage*(sk: Silky, name: string, pos: Vec2, size: Vec2, color = rgbx(255, 255, 255, 255)) {.inline.} = + ## Stub for drawing a scaled image from the atlas. + discard + proc drawRect*(sk: Silky, pos: Vec2, size: Vec2, color: ColorRGBX) {.inline.} = ## Stub for drawing a solid rectangle. discard @@ -421,7 +372,9 @@ proc drawText*( maxWidth = float32.high, maxHeight = float32.high, clip = true, - wordWrap = false + wordWrap = false, + hAlign: HorizontalAlignment = LeftAlign, + vAlign: VerticalAlignment = TopAlign ): Vec2 = ## Stub for drawing text that returns the text size. sk.getTextSize(font, text) @@ -432,9 +385,6 @@ proc clearScreen*(sk: Silky, color: ColorRGBX) {.inline.} = proc clear*(sk: Silky) = ## Clears all rendering layers. - sk.layers[NormalLayer].setLen(0) - sk.layers[PopupsLayer].setLen(0) - sk.currentLayer = NormalLayer sk.layerStack.setLen(0) proc instanceCount*(sk: Silky): int = @@ -445,9 +395,6 @@ proc newSilky*(imagePath, jsonPath: string): Silky = ## Creates a new Silky context for testing. result = Silky() result.atlas = readFile(jsonPath).fromJson(SilkyAtlas) - result.layers[NormalLayer] = @[] - result.layers[PopupsLayer] = @[] - result.currentLayer = NormalLayer result.layerStack = @[] proc beginUi*(sk: Silky, window: auto, size: IVec2) = diff --git a/src/silky/testing.nim b/src/silky/testing.nim index 6b39d95..fe9db2f 100644 --- a/src/silky/testing.nim +++ b/src/silky/testing.nim @@ -8,15 +8,24 @@ from windy/common import Button export Button, unicode type + Screen* = object + ## Test screen descriptor compatible with windy. + size*: IVec2 + Window* = ref object ## Test window that simulates a windy Window. size*: IVec2 + pos*: IVec2 mousePos*: IVec2 + mouseDelta*: IVec2 buttonDown*: array[Button, bool] buttonPressed*: array[Button, bool] buttonReleased*: array[Button, bool] scrollDelta*: Vec2 closeRequested*: bool + fullscreen*: bool + visible*: bool + minimized*: bool runeInputEnabled*: bool onRune*: proc(rune: Rune) onFrame*: proc() @@ -32,13 +41,17 @@ proc newWindow*(width = 800, height = 600): Window = ## Creates a new test window with the given dimensions. Window( size: ivec2(width.int32, height.int32), + pos: ivec2(0, 0), + mouseDelta: ivec2(0, 0), mousePos: ivec2(0, 0) ) -proc newWindow*(title: string, size: IVec2, vsync = true): Window = +proc newWindow*(title: string, size: IVec2, vsync = true, visible = true): Window = ## Creates a new test window with windy-compatible signature. Window( size: size, + pos: ivec2(0, 0), + mouseDelta: ivec2(0, 0), mousePos: ivec2(0, 0) ) @@ -62,12 +75,17 @@ proc loadExtensions*() {.inline.} = ## Stub for loading OpenGL extensions. discard +proc getScreens*(): seq[Screen] = + ## Returns a stub screen list. + @[Screen(size: ivec2(1920, 1080))] + proc resetInputState*(w: Window) = ## Resets button pressed and released states for a new frame. for i in Button: w.buttonPressed[i] = false w.buttonReleased[i] = false w.scrollDelta = vec2(0, 0) + w.mouseDelta = ivec2(0, 0) proc pressButton*(w: Window, button: Button) = ## Simulates pressing a mouse button. @@ -79,9 +97,29 @@ proc releaseButton*(w: Window, button: Button) = w.buttonDown[button] = false w.buttonReleased[button] = true +converter toButtonSet*(buttons: array[Button, bool]): set[Button] = + ## Converts windy-style button arrays to button sets. + for b in Button: + if buttons[b]: + result.incl(b) + proc moveMouse*(w: Window, x, y: int) = ## Moves the simulated mouse cursor to the given position. - w.mousePos = ivec2(x.int32, y.int32) + let nextPos = ivec2(x.int32, y.int32) + w.mouseDelta = nextPos - w.mousePos + w.mousePos = nextPos + +proc updateMouse*(w: Window) {.inline.} = + ## Stub for updating mouse state. + w.mouseDelta = ivec2(0, 0) + +proc initMouse*(w: Window) {.inline.} = + ## Stub for initializing mouse state. + discard + +proc close*(w: Window) {.inline.} = + ## Stub for closing a window. + discard proc newTestHarness*(atlasImg, atlasJson: string, width = 800, height = 600): TestHarness = ## Creates a new test harness with the given atlas files. @@ -89,9 +127,6 @@ proc newTestHarness*(atlasImg, atlasJson: string, width = 800, height = 600): Te result.frameCount = 0 result.sk = Silky() result.sk.atlas = readFile(atlasJson).fromJson(SilkyAtlas) - result.sk.layers[NormalLayer] = @[] - result.sk.layers[PopupsLayer] = @[] - result.sk.currentLayer = NormalLayer result.sk.layerStack = @[] proc beginFrame*(h: var TestHarness) = diff --git a/src/silky/textboxes.nim b/src/silky/textboxes.nim index 814404e..c30a8b8 100644 --- a/src/silky/textboxes.nim +++ b/src/silky/textboxes.nim @@ -864,9 +864,9 @@ proc textBox*( # Dimensions. let fontData = sk.atlas.fonts[sk.textStyle] let padding = sk.theme.padding.float32 - let outerRect = rect(sk.at, vec2(boxWidth, boxHeight)) + let outerRect = rect(sk.layout.at, vec2(boxWidth, boxHeight)) let innerRect = rect( - sk.at.x + padding, sk.at.y + padding, + sk.layout.at.x + padding, sk.layout.at.y + padding, boxWidth - padding * 2, boxHeight - padding * 2 ) state.boxSize = vec2(innerRect.w, innerRect.h) diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index 68b9a73..658b04c 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -3,14 +3,14 @@ import vmath, bumpy, chroma when defined(silkyTesting): - import silky/semantic, silky/testing + import semantic, testing, common, scrollbars, layout else: - import silky/drawing, windy + import drawing, common, scrollbars, windy, layout when defined(macos): - const ScrollSpeed* = 10.0 + const ScrollSpeed* = vec2(10.0, 10.0) else: - const ScrollSpeed* = -10.0 + const ScrollSpeed* = vec2(30.0, -30.0) type @@ -27,13 +27,18 @@ type visible*: bool FrameState* = ref object - scrollPos*: Vec2 - scrollingX*: bool - scrollingY*: bool - scrollDragOffset*: Vec2 + scroll*: ScrollArea + + ButtonState* = ref object + clicked*: bool + size*: Vec2 + rect*: Rect + hover*: bool + pressed*: bool ScrubberState* = ref object dragging*: bool + DropDownState* = ref object open*: bool @@ -164,13 +169,13 @@ proc subWindowStart*( sk.draw9Patch("header.hover.9patch", 6, sk.pos, sk.size) else: sk.draw9Patch("header.9patch", 6, sk.pos, sk.size) - sk.at += vec2(sk.theme.textPadding) + sk.layout.at += vec2(sk.theme.textPadding) # Handle minimizing/maximizing button for the window. let minimizeSize = sk.getImageSize("maximized") let minimizeRect = rect( - sk.at.x, - sk.at.y, + sk.layout.at.x, + sk.layout.at.y, minimizeSize.x.float32, minimizeSize.y.float32 ) @@ -181,16 +186,16 @@ proc subWindowStart*( sk.drawImage("minimized", minimizeRect.xy) else: sk.drawImage("maximized", minimizeRect.xy) - sk.at.x += sk.getImageSize("maximized").x.float32 + sk.theme.padding.float32 + sk.layout.at.x += sk.getImageSize("maximized").x.float32 + sk.theme.padding.float32 # Draw the title. - discard sk.drawText(sk.textStyle, title, sk.at, sk.theme.defaultTextColor) + discard sk.drawText(sk.textStyle, title, sk.layout.at, sk.theme.defaultTextColor) # Handle closing button for the window. let closeSize = sk.getImageSize("close") let closeRect = rect( - sk.at.x + sk.size.x - closeSize.x.float32 - sk.theme.padding.float32 * 5, - sk.at.y, + sk.layout.at.x + sk.size.x - closeSize.x.float32 - sk.theme.padding.float32 * 5, + sk.layout.at.y, closeSize.x.float32, closeSize.y.float32 ) @@ -213,8 +218,8 @@ proc subWindowEnd*(sk: Silky, window: Window, subWindowState: SubWindowState) = if not subWindowState.minimized: let resizeHandleSize = sk.getImageSize("resize") let resizeHandleRect = rect( - sk.at.x + sk.size.x - resizeHandleSize.x.float32 - sk.theme.border.float32, - sk.at.y + sk.size.y - resizeHandleSize.y.float32 - sk.theme.border.float32, + sk.layout.at.x + sk.size.x - resizeHandleSize.x.float32 - sk.theme.border.float32, + sk.layout.at.y + sk.size.y - resizeHandleSize.y.float32 - sk.theme.border.float32, resizeHandleSize.x.float32, resizeHandleSize.y.float32 ) @@ -233,33 +238,7 @@ proc subWindowEnd*(sk: Silky, window: Window, subWindowState: SubWindowState) = sk.popLayout() -template subWindow*(title: string, show: var bool, body: untyped) = - ## Create a window frame using default placement and sizing. - let state = sk.subWindowStart(window, title, show, none(Vec2), none(Vec2)) - sk.beginWidget("SubWindow", name = title, rect = rect(state.pos, state.size)) - if state.visible: - try: - if not state.minimized: - frame(title, state.bodyPos, state.bodySize): - body - finally: - sk.subWindowEnd(window, state) - sk.endWidget() - -template subWindow*(title: string, show: var bool, initialOrigin: Vec2, initialSize: Vec2, body: untyped) = - ## Create a window frame with explicit initial position and size. - let state = sk.subWindowStart(window, title, show, some(initialOrigin), some(initialSize)) - sk.beginWidget("SubWindow", name = title, rect = rect(state.pos, state.size)) - if state.visible: - try: - if not state.minimized: - frame(title, state.bodyPos, state.bodySize): - body - finally: - sk.subWindowEnd(window, state) - sk.endWidget() - -proc frameStart*(sk: Silky, id: string, framePos, frameSize: Vec2): tuple[state: FrameState, originPos: Vec2] = +proc frameStart*(sk: Silky, id: string, framePos, frameSize: Vec2): FrameState = ## Begin a scrollable frame; returns state and origin for cleanup. if id notin frameStates: frameStates[id] = FrameState() @@ -272,143 +251,83 @@ proc frameStart*(sk: Silky, id: string, framePos, frameSize: Vec2): tuple[state: sk.size.x - 2, sk.size.y - 2 )) - sk.at = sk.pos + vec2(sk.theme.padding) - let originPos = sk.at - sk.at -= frameState.scrollPos - return (frameState, originPos) + sk.layout.applyAnchor() + sk.layout.applyPadding(vec2(sk.theme.padding.float32)) + return frameState -proc frameEnd*(sk: Silky, window: Window, frameState: FrameState, originPos: Vec2) = +proc frameEnd*(sk: Silky, window: Window, frameState: FrameState) = ## Finish a scrollable frame and handle scrollbars. - if frameState.scrollingY and (window.buttonReleased[MouseLeft] or not window.buttonDown[MouseLeft]): - frameState.scrollingY = false - if frameState.scrollingX and (window.buttonReleased[MouseLeft] or not window.buttonDown[MouseLeft]): - frameState.scrollingX = false - - # Calculate content size from stretchAt (add padding for last element). - # Add scrollPos back because stretchAt is in scrolled coordinates but we need unscrolled. - sk.stretchAt += vec2(16) - let contentSize = (sk.stretchAt + frameState.scrollPos) - originPos - let scrollMax = max(contentSize - sk.size, vec2(0, 0)) - - # Clamp scroll position to valid range (handles resize making content smaller). - if scrollMax.y > 0: - frameState.scrollPos.y = clamp(frameState.scrollPos.y, 0.0, scrollMax.y) - else: - frameState.scrollPos.y = 0 - if scrollMax.x > 0: - frameState.scrollPos.x = clamp(frameState.scrollPos.x, 0.0, scrollMax.x) - else: - frameState.scrollPos.x = 0 + frameState.scroll.releaseIfUp(window.buttonDown[MouseLeft]) + + # Feed content bounds from the layout stretch tracking. + # Adjust for scroll offset so bounds are in unscrolled coordinates. + let offset = frameState.scroll.scrollOffset() + frameState.scroll.contentMin = sk.layout.stretchMin - offset + frameState.scroll.contentMax = sk.layout.stretchMax - offset - # Scroll wheel handling (only when mouse over frame). + # Initialize scroll for reversed anchors, then clamp. + frameState.scroll.initScroll() + frameState.scroll.clampScroll() + + # Scroll wheel. if sk.mouseInsideClip(window, rect(sk.pos, sk.size)): - if not frameState.scrollingY and window.scrollDelta.y != 0: - frameState.scrollPos.y += window.scrollDelta.y * ScrollSpeed - frameState.scrollPos.y = clamp(frameState.scrollPos.y, 0.0, scrollMax.y) - if not frameState.scrollingX and window.scrollDelta.x != 0: - frameState.scrollPos.x += window.scrollDelta.x * ScrollSpeed - frameState.scrollPos.x = clamp(frameState.scrollPos.x, 0.0, scrollMax.x) + frameState.scroll.applyWheel(window.scrollDelta.vec2 * ScrollSpeed) - # Draw Y scrollbar. - if contentSize.y > sk.size.y: - let scrollSize = contentSize.y - let scrollbarTrackRect = rect( - sk.pos.x + sk.size.x - 10, - sk.pos.y + 2, - 8, - sk.size.y - 4 - 10 - ) - sk.draw9Patch("scrollbar.track.9patch", 4, scrollbarTrackRect.xy, scrollbarTrackRect.wh) - - let scrollPosPercent = if scrollMax.y > 0: frameState.scrollPos.y / scrollMax.y else: 0.0 - let scrollSizePercent = sk.size.y / scrollSize - let scrollbarHandleRect = rect( - scrollbarTrackRect.x, - scrollbarTrackRect.y + (scrollbarTrackRect.h - (scrollbarTrackRect.h * scrollSizePercent)) * scrollPosPercent, - 8, - scrollbarTrackRect.h * scrollSizePercent - ) + # Draw debug bounds for the full scroll content area. + block: + let + debugPos = frameState.scroll.contentMin + frameState.scroll.scrollOffset() + debugSize = max(frameState.scroll.contentMax - frameState.scroll.contentMin, vec2(0)) + sk.drawRect(debugPos, debugSize, color(1, 0, 0, 0.14).rgbx) - # Handle scrollbar Y dragging. - if frameState.scrollingY: - let mouseY = window.mousePos.vec2.y - let relativeY = mouseY - frameState.scrollDragOffset.y - scrollbarTrackRect.y - let availableTrackHeight = scrollbarTrackRect.h - scrollbarHandleRect.h - if availableTrackHeight > 0: - let newScrollPosPercent = clamp(relativeY / availableTrackHeight, 0.0, 1.0) - frameState.scrollPos.y = newScrollPosPercent * scrollMax.y - elif sk.mouseInsideClip(window, scrollbarHandleRect): - if window.buttonPressed[MouseLeft]: - frameState.scrollingY = true - frameState.scrollDragOffset.y = window.mousePos.vec2.y - scrollbarHandleRect.y + # Draw debug around original frame bounds. + block: + let + debugPos = frameState.scroll.viewPos + frameState.scroll.scrollOffset() + debugSize = frameState.scroll.viewSize + sk.drawRect(debugPos, debugSize, color(0, 1, 0, 0.14).rgbx) - sk.draw9Patch("scrollbar.9patch", 4, scrollbarHandleRect.xy, scrollbarHandleRect.wh) + let text = "Content: " & $frameState.scroll.contentMin & " " & $frameState.scroll.contentMax & "\n" & + "Scroll: " & $frameState.scroll.scrollOffset() & "\n" & + "View: " & $frameState.scroll.viewPos & " " & $frameState.scroll.viewSize + discard sk.drawText(sk.textStyle, text, sk.layout.at, color(1, 1, 1, 1).rgbx) - # Draw X scrollbar. - if contentSize.x > sk.size.x: - let scrollSize = contentSize.x - let scrollbarTrackRect = rect( - sk.pos.x + 2, - sk.pos.y + sk.size.y - 10, - sk.size.x - 4 - 10, - 8 - ) - sk.draw9Patch("scrollbar.track.9patch", 4, scrollbarTrackRect.xy, scrollbarTrackRect.wh) - - let scrollPosPercent = if scrollMax.x > 0: frameState.scrollPos.x / scrollMax.x else: 0.0 - let scrollSizePercent = sk.size.x / scrollSize - let scrollbarHandleRect = rect( - scrollbarTrackRect.x + (scrollbarTrackRect.w - (scrollbarTrackRect.w * scrollSizePercent)) * scrollPosPercent, - scrollbarTrackRect.y, - scrollbarTrackRect.w * scrollSizePercent, - 8 - ) - # Handle scrollbar X dragging. - if frameState.scrollingX: - let mouseX = window.mousePos.vec2.x - let relativeX = mouseX - frameState.scrollDragOffset.x - scrollbarTrackRect.x - let availableTrackWidth = scrollbarTrackRect.w - scrollbarHandleRect.w - if availableTrackWidth > 0: - let newScrollPosPercent = clamp(relativeX / availableTrackWidth, 0.0, 1.0) - frameState.scrollPos.x = newScrollPosPercent * scrollMax.x - elif sk.mouseInsideClip(window, scrollbarHandleRect): + # Draw Y scrollbar. + if frameState.scroll.needsScrollY: + let (track, handle) = frameState.scroll.scrollBarY + sk.draw9Patch("scrollbar.track.9patch", 4, track.xy, track.wh) + if frameState.scroll.scrollingY: + frameState.scroll.dragScrollY(window.mousePos.vec2.y) + elif sk.mouseInsideClip(window, handle): if window.buttonPressed[MouseLeft]: - frameState.scrollingX = true - frameState.scrollDragOffset.x = window.mousePos.vec2.x - scrollbarHandleRect.x + frameState.scroll.scrollingY = true + frameState.scroll.scrollDragOffset.y = window.mousePos.vec2.y - handle.y + sk.draw9Patch("scrollbar.9patch", 4, handle.xy, handle.wh) - sk.draw9Patch("scrollbar.9patch", 4, scrollbarHandleRect.xy, scrollbarHandleRect.wh) + # Draw X scrollbar. + if frameState.scroll.needsScrollX: + let (track, handle) = frameState.scroll.scrollBarX + sk.draw9Patch("scrollbar.track.9patch", 4, track.xy, track.wh) + if frameState.scroll.scrollingX: + frameState.scroll.dragScrollX(window.mousePos.vec2.x) + elif sk.mouseInsideClip(window, handle): + if window.buttonPressed[MouseLeft]: + frameState.scroll.scrollingX = true + frameState.scroll.scrollDragOffset.x = window.mousePos.vec2.x - handle.x + sk.draw9Patch("scrollbar.9patch", 4, handle.xy, handle.wh) sk.popLayout() sk.popClipRect() -template frame*(id: string, framePos, frameSize: Vec2, body: untyped) = - ## Frame with scrollbars similar to a window body. - sk.beginWidget("Frame", name = id, rect = rect(framePos, frameSize)) - let frameCtx = sk.frameStart(id, framePos, frameSize) - try: - body - finally: - sk.frameEnd(window, frameCtx.state, frameCtx.originPos) - sk.endWidget() - -template button*(label: string, isEnabled: bool, isError: bool, body: untyped) = - let - textSize = sk.getTextSize(sk.textStyle, label) - buttonSize = textSize + vec2(sk.theme.padding) * 2 - buttonRect = rect(sk.at, buttonSize) - let hover = sk.mouseInsideClip(window, buttonRect) - let pressed = hover and window.buttonDown[MouseLeft] - - sk.beginWidget("Button", text = label, rect = buttonRect) - - let patch = - if not isEnabled: - "button.disabled.9patch" - elif isError: - "button.error.9patch" - else: - "button.9patch" +proc button*(sk: Silky, window: Window, label: string, isEnabled: bool, isError: bool): bool = + ## Draw a button and return true if clicked. + let buttonState = ButtonState() + buttonState.size = sk.getTextSize(sk.textStyle, label) + vec2(sk.theme.padding) * 2 + buttonState.rect = rect(sk.layout.at, buttonState.size) + buttonState.hover = sk.mouseInsideClip(window, buttonState.rect) + buttonState.pressed = buttonState.hover and window.buttonDown[MouseLeft] + sk.beginWidget("Button", text = label, rect = buttonState.rect) let textColor = if not isEnabled: @@ -418,65 +337,60 @@ template button*(label: string, isEnabled: bool, isError: bool, body: untyped) = else: sk.theme.defaultTextColor - if isEnabled: - if hover: - let hoverPatch = if isError: "button.error.9patch" else: "button.hover.9patch" - if window.buttonReleased[MouseLeft]: - body - elif window.buttonDown[MouseLeft]: - let downPatch = if isError: "button.error.9patch" else: "button.down.9patch" - sk.draw9Patch(downPatch, 8, sk.at, buttonSize) + if sk.mouseInsideClip(window, buttonState.rect): + if window.buttonReleased[MouseLeft]: + result = true + + let patch = + if isEnabled: + if buttonState.hover: + if isError: + "button.error.9patch" + else: + if result: + "button.down.9patch" + else: + "button.9patch" else: - sk.draw9Patch(hoverPatch, 8, sk.at, buttonSize) + "button.9patch" else: - sk.draw9Patch(patch, 8, sk.at, buttonSize) - else: - sk.draw9Patch(patch, 8, sk.at, buttonSize) - - discard sk.drawText(sk.textStyle, label, sk.at + vec2(sk.theme.padding), textColor) + "button.disabled.9patch" - sk.setWidgetState(enabled = isEnabled, pressed = pressed, hovered = hover) + sk.draw9Patch(patch, 8, sk.layout.at, buttonState.size) + discard sk.drawText(sk.textStyle, label, sk.layout.at + vec2(sk.theme.padding), textColor) + sk.setWidgetState(enabled = isEnabled, hovered = buttonState.hover, pressed = buttonState.pressed) sk.endWidget() + sk.advance(buttonState.size + vec2(sk.theme.padding)) - sk.advance(buttonSize + vec2(sk.theme.padding)) - -template button*(label: string, body: untyped) = - ## Create a button. - button(label, true, false, body) - -template button*(label: string, isEnabled: bool, body: untyped) = - ## Create a button. - button(label, isEnabled, false, body) - -template icon*(image: string) = +proc icon*(sk: Silky, image: string) = ## Draw an icon. let imageSize = sk.getImageSize(image) - sk.drawImage(image, sk.at) + sk.drawImage(image, sk.layout.at) sk.advance(vec2(imageSize.x, imageSize.y)) -template iconButton*(image: string, body) = +proc iconButton*(sk: Silky, window: Window, image: string): bool = ## Create an icon button. let m2 = vec2(8, 8) s2 = sk.getImageSize(image) + vec2(8, 8) * 2 - buttonRect = rect(sk.at - m2, s2) + buttonRect = rect(sk.layout.at - m2, s2) if sk.mouseInsideClip(window, buttonRect): sk.hover = true if window.buttonReleased[MouseLeft]: - body + result = true elif window.buttonDown[MouseLeft]: - sk.draw9Patch("button.down.9patch", 8, sk.at - m2, s2, sk.theme.iconButtonDownColor) + sk.draw9Patch("button.down.9patch", 8, sk.layout.at - m2, s2, sk.theme.iconButtonDownColor) else: - sk.draw9Patch("button.hover.9patch", 8, sk.at - m2, s2, sk.theme.iconButtonHoverColor) + sk.draw9Patch("button.hover.9patch", 8, sk.layout.at - m2, s2, sk.theme.iconButtonHoverColor) else: sk.hover = false - sk.draw9Patch("button.9patch", 8, sk.at - m2, s2) - sk.drawImage(image, sk.at) - sk.stretchAt = max(sk.stretchAt, sk.at + s2) - sk.at += vec2(32 + sk.padding, 0) + sk.draw9Patch("button.9patch", 8, sk.layout.at - m2, s2) + sk.drawImage(image, sk.layout.at) + sk.layout.stretchMax = max(sk.layout.stretchMax, sk.layout.at + s2) + sk.layout.at += vec2(32 + sk.padding, 0) -template clickableIcon*(image: string, on: bool, body) = - ## Create an clickable icon with no background and no padding. +proc clickableIcon*(sk: Silky, window: Window, image: string, on: bool): bool = + ## Draw a clickable icon with no background and no padding. Returns true if clicked. let imageSize = sk.getImageSize(image) s2 = imageSize @@ -484,10 +398,10 @@ template clickableIcon*(image: string, on: bool, body) = onColor = sk.theme.iconClickableOnColor offColor = sk.theme.iconClickableOffColor var color = upColor - if sk.mouseInsideClip(window, rect(sk.at, s2)): + if sk.mouseInsideClip(window, rect(sk.layout.at, s2)): sk.hover = true if window.buttonReleased[MouseLeft]: - body + result = true elif window.buttonDown[MouseLeft]: color = upColor else: @@ -501,47 +415,47 @@ template clickableIcon*(image: string, on: bool, body) = color = onColor else: color = offColor + sk.drawImage(image, sk.layout.at, color) + sk.layout.at += vec2(imageSize.x, 0) - sk.drawImage(image, sk.at, color) - sk.at += vec2(imageSize.x, 0) - -template radioButton*[T](label: string, variable: var T, value: T) = +proc radioButton*[T](sk: Silky, window: Window, label: string, variable: var T, value: T, isEnabled = true) = ## Radio button. let iconSize = sk.getImageSize("radio.on") textSize = sk.getTextSize(sk.textStyle, label) height = max(iconSize.y.float32, textSize.y) width = iconSize.x.float32 + sk.theme.spacing.float32 + textSize.x - hitRect = rect(sk.at, vec2(width, height)) + hitRect = rect(sk.layout.at, vec2(width, height)) sk.beginWidget("RadioButton", text = label, rect = hitRect) - if sk.mouseInsideClip(window, hitRect) and window.buttonReleased[MouseLeft]: + if isEnabled and sk.mouseInsideClip(window, hitRect) and window.buttonReleased[MouseLeft]: variable = value let on = variable == value - iconPos = vec2(sk.at.x, sk.at.y + (height - iconSize.y.float32) * 0.5) + textColor = if isEnabled: sk.theme.defaultTextColor else: sk.theme.disabledTextColor + iconPos = vec2(sk.layout.at.x, sk.layout.at.y + (height - iconSize.y.float32) * 0.5) textPos = vec2( iconPos.x + iconSize.x.float32 + sk.theme.spacing.float32, - sk.at.y + (height - textSize.y) * 0.5 + sk.layout.at.y + (height - textSize.y) * 0.5 ) sk.drawImage(if on: "radio.on" else: "radio.off", iconPos) - discard sk.drawText(sk.textStyle, label, textPos, sk.theme.defaultTextColor) + discard sk.drawText(sk.textStyle, label, textPos, textColor) sk.setWidgetState(checked = on) sk.endWidget() sk.advance(vec2(width, height)) -template checkBox*(label: string, value: var bool) = +proc checkBox*(sk: Silky, window: Window, label: string, value: var bool) = ## Checkbox. let iconSize = sk.getImageSize("check.on") textSize = sk.getTextSize(sk.textStyle, label) height = max(iconSize.y.float32, textSize.y) width = iconSize.x.float32 + sk.theme.spacing.float32 + textSize.x - hitRect = rect(sk.at, vec2(width, height)) + hitRect = rect(sk.layout.at, vec2(width, height)) sk.beginWidget("CheckBox", text = label, rect = hitRect) @@ -549,10 +463,10 @@ template checkBox*(label: string, value: var bool) = value = not value let - iconPos = vec2(sk.at.x, sk.at.y + (height - iconSize.y.float32) * 0.5) + iconPos = vec2(sk.layout.at.x, sk.layout.at.y + (height - iconSize.y.float32) * 0.5) textPos = vec2( iconPos.x + iconSize.x.float32 + sk.theme.spacing.float32, - sk.at.y + (height - textSize.y) * 0.5 + sk.layout.at.y + (height - textSize.y) * 0.5 ) sk.drawImage(if value: "check.on" else: "check.off", iconPos) discard sk.drawText(sk.textStyle, label, textPos, sk.theme.defaultTextColor) @@ -562,7 +476,7 @@ template checkBox*(label: string, value: var bool) = sk.advance(vec2(width, height)) -template dropDown*[T](selected: var T, options: openArray[T]) = +proc dropDown*[T](sk: Silky, window: Window, selected: var T, options: openArray[T]) = ## Dropdown styled like input text; options render in a new layer. let id = "dropdown_" & $cast[uint](addr selected) if id notin dropDownStates: @@ -574,7 +488,7 @@ template dropDown*[T](selected: var T, options: openArray[T]) = height = font.lineHeight + sk.theme.padding.float32 * 2 width = sk.size.x - sk.theme.padding.float32 * 3 arrowSize = sk.getImageSize("droparrow") - dropRect = rect(sk.at, vec2(width, height)) + dropRect = rect(sk.layout.at, vec2(width, height)) let displayText = $selected @@ -586,10 +500,10 @@ template dropDown*[T](selected: var T, options: openArray[T]) = state.open = not state.open # Draw control body. - sk.pushLayout(sk.at, vec2(width, height)) + sk.pushLayout(sk.layout.at, vec2(width, height)) let bgColor = if state.open or hover: sk.theme.dropdownHoverBgColor else: sk.theme.dropdownBgColor sk.draw9Patch("dropdown.9patch", 6, sk.pos, sk.size, bgColor) - discard sk.drawText(sk.textStyle, displayText, sk.at + vec2(sk.theme.padding), sk.theme.defaultTextColor) + discard sk.drawText(sk.textStyle, displayText, sk.layout.at + vec2(sk.theme.padding), sk.theme.defaultTextColor) let arrowPos = vec2( sk.pos.x + sk.size.x - arrowSize.x.float32 - sk.theme.padding.float32, sk.pos.y + (height - arrowSize.y.float32) * 0.5 @@ -640,7 +554,7 @@ template dropDown*[T](selected: var T, options: openArray[T]) = sk.popClipRect() sk.popLayer() -template listBox*[T](id: string, items: seq[T], selectedIndex: var int) = +proc listBox*[T](sk: Silky, window: Window, id: string, items: seq[T], selectedIndex: var int) = ## Listbox with scrolling and selection. let font = sk.atlas.fonts[sk.textStyle] let rowHeight = font.lineHeight + sk.theme.padding.float32 @@ -648,27 +562,29 @@ template listBox*[T](id: string, items: seq[T], selectedIndex: var int) = # Use a fixed height or calculate based on items, but capped at 4 items. let listHeight = min(rowHeight * 4.float32, rowHeight * max(1, items.len).float32) + sk.theme.padding.float32 * 2 - frame(id, sk.at, vec2(outerWidth, listHeight)): + sk.beginWidget("Frame", name = id, rect = rect(sk.layout.at, vec2(outerWidth, listHeight))) + let frameCtx = sk.frameStart(id, sk.layout.at, vec2(outerWidth, listHeight)) + try: let itemWidth = sk.size.x - sk.theme.padding.float32 * 3 for i, item in items: let - rowRect = rect(sk.at, vec2(itemWidth, rowHeight)) - textPos = sk.at + vec2(sk.theme.padding.float32, sk.theme.padding.float32 * 0.5) - + rowRect = rect(sk.layout.at, vec2(itemWidth, rowHeight)) + textPos = sk.layout.at + vec2(sk.theme.padding.float32, sk.theme.padding.float32 * 0.5) let isSelected = selectedIndex == i let rowHover = sk.mouseInsideClip(window, rowRect) - if rowHover or isSelected: let tint = if rowHover: sk.theme.menuPopupHoverColor else: sk.theme.menuPopupSelectedColor sk.drawRect(rowRect.xy, rowRect.wh, tint) if rowHover and window.buttonReleased[MouseLeft]: selectedIndex = i - discard sk.drawText(sk.textStyle, $item, textPos, sk.theme.defaultTextColor) sk.advance(vec2(itemWidth, rowHeight - sk.theme.spacing.float32)) + finally: + sk.frameEnd(window, frameCtx.state, frameCtx.originPos) + sk.endWidget() sk.advance(vec2(outerWidth, listHeight)) -template progressBar*(value: SomeNumber, minVal: SomeNumber, maxVal: SomeNumber) = +proc progressBar*(sk: Silky, value: SomeNumber, minVal: SomeNumber, maxVal: SomeNumber) = ## Non-interactive progress bar. let minF = minVal.float32 @@ -679,7 +595,7 @@ template progressBar*(value: SomeNumber, minVal: SomeNumber, maxVal: SomeNumber) bodySize = sk.getImageSize("progressBar.body.9patch") height = bodySize.y.float32 width = max(bodySize.x.float32, sk.size.x - sk.theme.padding.float32 * 3) - barRect = rect(sk.at, vec2(width, height)) + barRect = rect(sk.layout.at, vec2(width, height)) sk.draw9Patch("progressBar.body.9patch", 6, barRect.xy, barRect.wh) @@ -690,83 +606,60 @@ template progressBar*(value: SomeNumber, minVal: SomeNumber, maxVal: SomeNumber) sk.advance(vec2(width, height)) -proc groupStart*(sk: Silky, p: Vec2, direction = TopToBottom) = +proc groupStart*(sk: Silky, p: Vec2, direction = TopToBottom, anchor = AnchorLeft) = ## Start a group. - sk.pushLayout(sk.at + p, sk.size - p, direction) + sk.pushLayout(sk.layout.at + p, sk.size - p, direction, anchor) proc groupEnd*(sk: Silky) = ## End a group. - let endAt = sk.stretchAt - sk.popLayout() - sk.advance(endAt - sk.at) - -template group*(p: Vec2, direction = TopToBottom, body) = - ## Create a group. - sk.groupStart(p, direction) - try: - body - finally: - sk.groupEnd() - -proc frameStart*(sk: Silky, p, s: Vec2) = - ## Begin a simple frame. - sk.pushLayout(p, s) - sk.draw9Patch("window.9patch", 14, sk.pos, sk.size) - -proc frameEnd*(sk: Silky) = - ## Finish a simple frame. + let endMax = sk.layout.stretchMax + let endMin = sk.layout.stretchMin sk.popLayout() - -template frame*(p, s: Vec2, body: untyped) = - ## Create a frame. - sk.frameStart(p, s) - try: - body - finally: - sk.frameEnd() + sk.advance(endMax - endMin) + sk.layout.stretchMin = min(sk.layout.stretchMin, endMin) proc ribbonStart*(sk: Silky, p, s: Vec2, tint: ColorRGBX) = ## Begin a ribbon. sk.pushLayout(p, s) sk.drawRect(sk.pos, sk.size, tint) - sk.at = sk.pos + sk.layout.at = sk.pos proc ribbonEnd*(sk: Silky) = ## Finish a ribbon. sk.popLayout() -template ribbon*(p, s: Vec2, tint: ColorRGBX, body: untyped) = - ## Create a ribbon. - sk.ribbonStart(p, s, tint) - try: - body - finally: - sk.ribbonEnd() - -template image*(imageName: string, tint: ColorRGBX) = +proc image*(sk: Silky, imageName: string, tint: ColorRGBX) = ## Draw an image with explicit tint. - sk.drawImage(imageName, sk.at, tint) - sk.at.x += sk.getImageSize(imageName).x - sk.at.x += sk.padding + sk.drawImage(imageName, sk.layout.at, tint) + sk.layout.at.x += sk.getImageSize(imageName).x + sk.layout.at.x += sk.padding -template image*(imageName: string) = - ## Draw an image with default text color tint. - image(imageName, sk.theme.textColor) - -template text*(t: string) = +proc text*(sk: Silky, t: string) = ## Draw text. - let textRect = rect(sk.at, sk.getTextSize(sk.textStyle, t)) + let textRect = rect(sk.layout.at, sk.getTextSize(sk.textStyle, t)) sk.beginWidget("Text", text = t, rect = textRect) - let textSize = sk.drawText(sk.textStyle, t, sk.at, sk.theme.textColor) + let textSize = sk.drawText(sk.textStyle, t, sk.layout.at, sk.theme.textColor) sk.endWidget() sk.advance(textSize) -template h1text*(t: string) = +proc h1text*(sk: Silky, t: string) = ## Draw H1 text. - let textSize = sk.drawText("H1", t, sk.at, sk.theme.textH1Color) + let textSize = sk.drawText("H1", t, sk.layout.at, sk.theme.textH1Color) sk.advance(textSize) -template scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: string = "") = +proc rectangle*(sk: Silky, size: Vec2, color: ColorRGBX, label = "") = + ## Draw a colored rectangle that respects current stacking direction and anchor. + let drawPos = sk.widgetPos(size) + sk.drawRect(drawPos, size, color) + if label.len > 0: + discard sk.drawText( + sk.textStyle, label, drawPos, sk.theme.textColor, + size.x, size.y, + hAlign = CenterAlign, vAlign = MiddleAlign + ) + sk.advance(size) + +proc scrubber*[T, U](sk: Silky, window: Window, id: string, value: var T, minVal: T, maxVal: U, label: string = "") = ## Draggable scrubber that spans available width and advances layout. let minF = minVal.float32 @@ -793,7 +686,7 @@ template scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: handleSize = vec2(handleWidth, handleHeight) height = handleSize.y width = sk.size.x - sk.theme.padding.float32 * 3 - controlRect = rect(sk.at, vec2(width, height)) + controlRect = rect(sk.layout.at, vec2(width, height)) trackStart = controlRect.x + handleSize.x / 2 trackEnd = controlRect.x + width - handleSize.x / 2 travel = max(0f, trackEnd - trackStart) @@ -879,7 +772,7 @@ proc menuBarStart*(sk: Silky, window: Window) = let barHeight = sk.theme.headerHeight.float32 sk.pushLayout(vec2(0, 0), vec2(sk.size.x, barHeight)) sk.draw9Patch("header.9patch", 6, sk.pos, sk.size, sk.theme.headerBgColor) - sk.at = sk.pos + vec2(sk.theme.menuPadding) + sk.layout.at = sk.pos + vec2(sk.theme.menuPadding) proc menuBarEnd*(sk: Silky, window: Window) = ## Finish the menu bar and handle outside-click closing. @@ -888,14 +781,6 @@ proc menuBarEnd*(sk: Silky, window: Window) = if not menuPointInside(menuState.activeRects, window.mousePos.vec2): menuState.openPath.setLen(0) -template menuBar*(body: untyped) = - ## Horizontal application menu bar (File, Edit, ...). - sk.menuBarStart(window) - try: - body - finally: - sk.menuBarEnd(window) - proc subMenuStart*(sk: Silky, window: Window, label: string, menuWidth = 200): MenuEntryContext = ## Begin a submenu entry; returns context describing whether it is open. menuEnsureState() @@ -912,7 +797,7 @@ proc subMenuStart*(sk: Silky, window: Window, label: string, menuWidth = 200): M if isRoot: let textSize = sk.getTextSize(sk.textStyle, label) let size = textSize + vec2(sk.theme.menuPadding.float32 * 2, sk.theme.menuPadding.float32 * 2) - let menuRect = rect(sk.at, size) + let menuRect = rect(sk.layout.at, size) menuAddActive(menuRect) let hover = window.mousePos.vec2.overlaps(menuRect) @@ -932,7 +817,7 @@ proc subMenuStart*(sk: Silky, window: Window, label: string, menuWidth = 200): M if hover or open: sk.drawRect(menuRect.xy, menuRect.wh, sk.theme.menuRootHoverColor) discard sk.drawText(sk.textStyle, label, menuRect.xy + vec2(sk.theme.menuPadding), sk.theme.defaultTextColor) - sk.at.x += size.x + sk.theme.spacing.float32 + sk.layout.at.x += size.x + sk.theme.spacing.float32 if ctx.open: menuPathStack.add(label) @@ -980,17 +865,6 @@ proc subMenuEnd*(sk: Silky, ctx: MenuEntryContext) = ## Finish a submenu entry and pop path if open. if ctx.open: menuPathStack.setLen(menuPathStack.len - 1) - -template subMenu*(label: string, menuWidth = 200, body: untyped) = - ## Menu entry that can contain other menu items. - let ctx = sk.subMenuStart(window, label, menuWidth) - try: - if ctx.open: - menuPopup(ctx.path, ctx.popupPos, menuWidth): - body - finally: - sk.subMenuEnd(ctx) - proc menuItemStart*(sk: Silky, window: Window, label: string): MenuItemContext = ## Begin a menu item; returns context indicating click state. menuEnsureState() @@ -1029,18 +903,8 @@ proc menuItemEnd*(sk: Silky, ctx: MenuItemContext) = ## Finish a menu item and advance layout cursor. ctx.layout.cursorY += ctx.rowH -template menuItem*(label: string, body: untyped) = - ## Leaf menu entry that runs `body` on click. - let ctx = sk.menuItemStart(window, label) - try: - if ctx.clicked: - body - finally: - sk.menuItemEnd(ctx) - -template tooltip*(text: string) = +proc tooltip*(sk: Silky, window: Window, text: string) = ## Display a tooltip at the mouse cursor. - ## This should be called after a widget when sk.showTooltip is true. let tooltipText = text sk.pushLayer(PopupsLayer) sk.pushClipRect(rect(vec2(0, 0), sk.rootSize)) @@ -1070,3 +934,168 @@ template tooltip*(text: string) = sk.popClipRect() sk.popLayer() + +template subWindow*(title: string, show: var bool, body: untyped) = + ## Create a window frame using default placement and sizing. + let state = sk.subWindowStart(window, title, show, none(Vec2), none(Vec2)) + sk.beginWidget("SubWindow", name = title, rect = rect(state.pos, state.size)) + if state.visible: + try: + if not state.minimized: + frame(title, state.bodyPos, state.bodySize): + body + finally: + sk.subWindowEnd(window, state) + sk.endWidget() + +template subWindow*(title: string, show: var bool, initialOrigin: Vec2, initialSize: Vec2, body: untyped) = + ## Create a window frame with explicit initial position and size. + let state = sk.subWindowStart(window, title, show, some(initialOrigin), some(initialSize)) + sk.beginWidget("SubWindow", name = title, rect = rect(state.pos, state.size)) + if state.visible: + try: + if not state.minimized: + frame(title, state.bodyPos, state.bodySize): + body + finally: + sk.subWindowEnd(window, state) + sk.endWidget() + +template progressBar*(value: SomeNumber, minVal: SomeNumber, maxVal: SomeNumber) = + sk.progressBar(value, minVal, maxVal) + +template group*(p: Vec2, direction: StackDirection, anchor: Anchor, body: untyped) = + ## Create a group with explicit direction and anchor. + sk.groupStart(p, direction, anchor) + try: + body + finally: + sk.groupEnd() + +template group*(p: Vec2, direction = TopToBottom, body: untyped) = + ## Create a group. + sk.groupStart(p, direction) + try: + body + finally: + sk.groupEnd() + +template frame*(p, s: Vec2, body: untyped) = + ## Create a frame. + sk.beginWidget("Frame", name = "Frame", rect = rect(p, s)) + let frameState = sk.frameStart(p, s) + try: + body + finally: + sk.frameEnd(frameState) + sk.endWidget() + +template frame*(id: string, framePos, frameSize: Vec2, body: untyped) = + ## Frame with scrollbars similar to a window body. + sk.beginWidget("Frame", name = id, rect = rect(framePos, frameSize)) + let frameState = sk.frameStart(id, framePos, frameSize) + try: + body + finally: + sk.frameEnd(window, frameState) + sk.endWidget() + +template ribbon*(p, s: Vec2, tint: ColorRGBX, body: untyped) = + ## Create a ribbon. + sk.ribbonStart(p, s, tint) + try: + body + finally: + sk.ribbonEnd() + +template menuBar*(body: untyped) = + ## Horizontal application menu bar (File, Edit, ...). + sk.menuBarStart(window) + try: + body + finally: + sk.menuBarEnd(window) + +template menuItem*(label: string, body: untyped) = + ## Leaf menu entry that runs `body` on click. + let ctx = sk.menuItemStart(window, label) + try: + if ctx.clicked: + body + finally: + sk.menuItemEnd(ctx) + +template subMenu*(label: string, menuWidth = 200, body: untyped) = + ## Menu entry that can contain other menu items. + let ctx = sk.subMenuStart(window, label, menuWidth) + try: + if ctx.open: + menuPopup(ctx.path, ctx.popupPos, menuWidth): + body + finally: + sk.subMenuEnd(ctx) + +template button*(label: string, isEnabled: bool, isError: bool, body: untyped) = + ## Create a button with enabled and error states. + if sk.button(window, label, isEnabled, isError): + body + +template button*(label: string, body: untyped) = + ## Create a button. + if sk.button(window, label, true, false): + body + +template button*(label: string, isEnabled: bool, body: untyped) = + ## Create a button with enabled state. + if sk.button(window, label, isEnabled, false): + body + +template icon*(image: string) = + sk.icon(image) + +template clickableIcon*(image: string, on: bool, body: untyped) = + ## Create a clickable icon with no background and no padding. + if sk.clickableIcon(window, image, on): + body + +template iconButton*(image: string, body: untyped) = + ## Create an icon button. + if sk.iconButton(window, image): + body + +template radioButton*[T](label: string, variable: var T, value: T, isEnabled = true) = + sk.radioButton(window, label, variable, value, isEnabled) + +template checkBox*(label: string, value: var bool) = + sk.checkBox(window, label, value) + +template listBox*[T](id: string, items: seq[T], selectedIndex: var int) = + sk.listBox(window, id, items, selectedIndex) + +template dropDown*[T](selected: var T, options: openArray[T]) = + sk.dropDown(window, selected, options) + +template scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: string = "") = + sk.scrubber(window, id, value, minVal, maxVal, label) + +template image*(imageName: string, tint: ColorRGBX) = + ## Draw an image with explicit tint. + sk.image(imageName, tint) + +template image*(imageName: string) = + ## Draw an image with default text color tint. + sk.image(imageName, sk.theme.textColor) + +template text*(t: string) = + sk.text(t) + +template h1text*(t: string) = + sk.h1text(t) + +template rectangle*(size: Vec2, color: ColorRGBX, label = "") = + sk.rectangle(size, color, label) + +template tooltip*(text: string) = + ## Display a tooltip at the mouse cursor. + sk.tooltip(window, text) + diff --git a/tests/data/check.disabled.png b/tests/data/check.disabled.png new file mode 100644 index 0000000..f0731b1 Binary files /dev/null and b/tests/data/check.disabled.png differ diff --git a/tests/data/check.error.png b/tests/data/check.error.png new file mode 100644 index 0000000..c41f16c Binary files /dev/null and b/tests/data/check.error.png differ diff --git a/tests/data/droparrow.png b/tests/data/droparrow.png new file mode 100644 index 0000000..333555a Binary files /dev/null and b/tests/data/droparrow.png differ diff --git a/tests/data/dropdown.9patch.png b/tests/data/dropdown.9patch.png new file mode 100644 index 0000000..050da22 Binary files /dev/null and b/tests/data/dropdown.9patch.png differ diff --git a/tests/data/radio.disabled.png b/tests/data/radio.disabled.png new file mode 100644 index 0000000..497986c Binary files /dev/null and b/tests/data/radio.disabled.png differ diff --git a/tests/data/radio.error.png b/tests/data/radio.error.png new file mode 100644 index 0000000..e4d3ad4 Binary files /dev/null and b/tests/data/radio.error.png differ diff --git a/tests/data/radio.off.png b/tests/data/radio.off.png new file mode 100644 index 0000000..b51fd59 Binary files /dev/null and b/tests/data/radio.off.png differ diff --git a/tests/data/radio.on.png b/tests/data/radio.on.png new file mode 100644 index 0000000..4f61315 Binary files /dev/null and b/tests/data/radio.on.png differ diff --git a/tests/manual_flowgrid.nim b/tests/manual_flowgrid.nim index 1bd1e21..29418a2 100644 --- a/tests/manual_flowgrid.nim +++ b/tests/manual_flowgrid.nim @@ -41,22 +41,22 @@ window.onFrame = proc() = SliderWidth = 300.0f # Title. - sk.at = vec2(Margin, Margin) + sk.layout.at = vec2(Margin, Margin) text("Flow Grid Example - Resize the frame to see elements reflow") # Instructions. - sk.at = vec2(Margin, 50) + sk.layout.at = vec2(Margin, 50) text("Drag the sliders to resize the frame. Elements wrap automatically.") # Width slider with fixed width frame. - sk.at = vec2(Margin, 80) + sk.layout.at = vec2(Margin, 80) text("Width:") sk.pushLayout(vec2(Margin + SliderLabelWidth, 80), vec2(SliderWidth, 24)) scrubber("width", frameWidth, 200.0, 600.0) sk.popLayout() # Height slider with fixed width frame. - sk.at = vec2(Margin, 110) + sk.layout.at = vec2(Margin, 110) text("Height:") sk.pushLayout(vec2(Margin + SliderLabelWidth, 110), vec2(SliderWidth, 24)) scrubber("height", frameHeight, 100.0, 500.0) @@ -71,13 +71,13 @@ window.onFrame = proc() = buttonWidth = 32.0f + sk.padding margin = 12.0f scrollbarWidth = 16.0f - startX = sk.at.x + startX = sk.layout.at.x for i in 0 ..< NumItems: # Check if we need to wrap to the next line, accounting for scrollbar. - if sk.at.x + buttonWidth > sk.pos.x + sk.size.x - margin - scrollbarWidth: - sk.at.x = startX - sk.at.y += 32 + margin + if sk.layout.at.x + buttonWidth > sk.pos.x + sk.size.x - margin - scrollbarWidth: + sk.layout.at.x = startX + sk.layout.at.y += 32 + margin let icon = if i mod 2 == 0: @@ -89,9 +89,9 @@ window.onFrame = proc() = echo "Clicked item ", i # Show click status. - sk.at = vec2(framePos.x + frameWidth + 20, 150) + sk.layout.at = vec2(framePos.x + frameWidth + 20, 150) text("Click status:") - sk.at.y += 24 + sk.layout.at.y += 24 var clickCount = 0 for i in 0 ..< NumItems: if clickedItems[i]: @@ -100,7 +100,7 @@ window.onFrame = proc() = # Frame time display. let ms = sk.avgFrameTime * 1000 - sk.at = sk.pos + vec2(sk.size.x - 250, 20) + sk.layout.at = sk.pos + vec2(sk.size.x - 250, 20) text(&"frame time: {ms:>7.3f}ms") sk.endUi() diff --git a/tests/manual_layout.nim b/tests/manual_layout.nim new file mode 100644 index 0000000..b285a4a --- /dev/null +++ b/tests/manual_layout.nim @@ -0,0 +1,147 @@ +## Demonstrates layout stacking directions and anchoring with adjustable +## padding, spacing, and number of boxes. Use the controls at the top to tweak +## values, pick a stacking direction and anchor from the dropdowns. The colored +## boxes below respond to every change in real time. + +import + std/[strformat], + opengl, windy, bumpy, vmath, chroma, + silky + +let builder = newAtlasBuilder(1024, 4) +builder.addDir("tests/data/", "tests/data/") +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "H1", 32.0) +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18.0) +builder.write("tests/dist/atlas.png", "tests/dist/atlas.json") + +let window = newWindow( + "Layout Test", + ivec2(900, 700), + vsync = false +) +makeContextCurrent(window) +loadExtensions() + +const + BackgroundColor = parseHtmlColor("#1a1a2e").rgbx + BoxColors = [ + parseHtmlColor("#e74c3c").rgbx, + parseHtmlColor("#3498db").rgbx, + parseHtmlColor("#2ecc71").rgbx, + parseHtmlColor("#f39c12").rgbx, + parseHtmlColor("#9b59b6").rgbx, + parseHtmlColor("#1abc9c").rgbx, + parseHtmlColor("#e67e22").rgbx, + parseHtmlColor("#e84393").rgbx, + parseHtmlColor("#00cec9").rgbx, + parseHtmlColor("#6c5ce7").rgbx, + ] + BoxSizes = [ + vec2(48, 48), + vec2(64, 32), + vec2(32, 64), + vec2(80, 40), + vec2(40, 80), + vec2(156, 156), + vec2(272, 136), + vec2(136, 372), + vec2(160, 544), + vec2(644, 660), + ] + +let sk = newSilky("tests/dist/atlas.png", "tests/dist/atlas.json") + +var + layoutPadding = 16.0f + layoutSpacing = 8.0f + numBoxes = 5.0f + directionVal = 2 + anchorVal = 2 + +const + Directions = [TopToBottom, BottomToTop, LeftToRight, RightToLeft] + Anchors = [AnchorLeft, AnchorRight, AnchorTop, AnchorBottom] + +proc isVertical(d: int): bool = + ## Vertical directions pair with Left/Right anchors. + d <= 1 + +proc isHorizontal(d: int): bool = + ## Horizontal directions pair with Top/Bottom anchors. + d >= 2 + +window.onFrame = proc() = + sk.beginUI(window, window.size) + sk.clearScreen(BackgroundColor) + + const Margin = 20.0f + + sk.layout.at = vec2(Margin, Margin) + + # Title. + h1text("Layout Test") + + # Controls. + scrubber("padding", layoutPadding, 0.0, 60.0, &"Padding: {layoutPadding:0.1f}") + scrubber("spacing", layoutSpacing, 0.0, 40.0, &"Spacing: {layoutSpacing:0.1f}") + scrubber("numBoxes", numBoxes, 1.0, 10.0, &"Boxes: {numBoxes:0.1f}") + let prevDir = directionVal + text("Direction:") + group(vec2(0, 0), LeftToRight): + radioButton("Top to Bottom", directionVal, 0) + radioButton("Bottom to Top", directionVal, 1) + radioButton("Left to Right", directionVal, 2) + radioButton("Right to Left", directionVal, 3) + + # Auto-fix anchor when switching between vertical and horizontal. + if directionVal != prevDir: + if directionVal.isVertical and anchorVal >= 2: + anchorVal = 0 + elif directionVal.isHorizontal and anchorVal <= 1: + anchorVal = 2 + + let vertical = directionVal.isVertical + text("Anchor:") + group(vec2(0, 0), LeftToRight): + radioButton("Left", anchorVal, 0, vertical) + radioButton("Right", anchorVal, 1, vertical) + radioButton("Top", anchorVal, 2, not vertical) + radioButton("Bottom", anchorVal, 3, not vertical) + + # Layout area. + let + controlsBottom = sk.layout.at.y + 8 + areaPos = vec2(Margin, controlsBottom) + areaW = window.size.x.float32 - Margin * 2 + areaH = window.size.y.float32 - controlsBottom - Margin + areaSize = vec2(areaW, areaH) + pad = layoutPadding + stackDir = Directions[directionVal] + stackAnc = Anchors[anchorVal] + n = numBoxes.int + + sk.pushTheme() + sk.theme.spacing = layoutSpacing.int + sk.theme.padding = layoutPadding.int + + frame("layoutArea", areaPos, areaSize): + group(vec2(0, 0), stackDir, stackAnc): + for i in 0 ..< n: + let color = BoxColors[i mod BoxColors.len] + let sz = BoxSizes[i mod BoxSizes.len] + rectangle(sz, color, $(i + 1)) + sk.popTheme() + + # Frame time. + let ms = sk.avgFrameTime * 1000 + sk.layout.at = vec2(sk.size.x - 250, Margin) + text(&"frame time: {ms:>7.3f}ms") + + sk.endUi() + window.swapBuffers() + +when defined(emscripten): + window.run() +else: + while not window.closeRequested: + pollEvents() diff --git a/tests/manual_layout2.nim b/tests/manual_layout2.nim new file mode 100644 index 0000000..f905afb --- /dev/null +++ b/tests/manual_layout2.nim @@ -0,0 +1,283 @@ +## Demonstrates layout stacking directions and anchoring with adjustable +## padding, spacing, and number of boxes. Use the controls at the top to tweak +## values, pick a stacking direction and anchor from the dropdowns. The colored +## boxes below respond to every change in real time. + +import + std/[strformat], + opengl, windy, bumpy, vmath, chroma, + silky + +let builder = newAtlasBuilder(1024, 4) +builder.addDir("tests/data/", "tests/data/") +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "H1", 32.0) +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18.0) +builder.write("tests/dist/atlas.png", "tests/dist/atlas.json") + +let window = newWindow( + "Layout Test", + ivec2(900, 700), + vsync = false +) +makeContextCurrent(window) +loadExtensions() + +const + BackgroundColor = parseHtmlColor("#1a1a2e").rgbx + BoxColors = [ + parseHtmlColor("#e74c3c").rgbx, + parseHtmlColor("#3498db").rgbx, + parseHtmlColor("#2ecc71").rgbx, + parseHtmlColor("#f39c12").rgbx, + parseHtmlColor("#9b59b6").rgbx, + parseHtmlColor("#1abc9c").rgbx, + parseHtmlColor("#e67e22").rgbx, + parseHtmlColor("#e84393").rgbx, + parseHtmlColor("#00cec9").rgbx, + parseHtmlColor("#6c5ce7").rgbx, + ] + BoxSizes = [ + vec2(48, 48), + vec2(64, 32), + vec2(32, 64), + vec2(80, 40), + vec2(40, 80), + vec2(56, 56), + vec2(72, 36), + vec2(36, 72), + vec2(60, 44), + vec2(44, 60), + ] + +let sk = newSilky("tests/dist/atlas.png", "tests/dist/atlas.json") + +var + layoutPadding = 16.0f + layoutSpacing = 8.0f + numBoxes = 5.0f + directionVal = 2 + anchorVal = 2 + step = 0.0f + + debugStep = 0 + +const + Directions = [TopToBottom, BottomToTop, LeftToRight, RightToLeft] + Anchors = [AnchorLeft, AnchorRight, AnchorTop, AnchorBottom] + +proc isVertical(d: int): bool = + ## Vertical directions pair with Left/Right anchors. + d <= 1 + +proc isHorizontal(d: int): bool = + ## Horizontal directions pair with Top/Bottom anchors. + d >= 2 + +window.onFrame = proc() = + sk.beginUI(window, window.size) + sk.clearScreen(BackgroundColor) + + const Margin = 20.0f + + sk.layout.at = vec2(Margin, Margin) + + # Title. + h1text("Layout Test") + + # Controls. + scrubber("padding", layoutPadding, 0.0, 60.0, &"Padding: {layoutPadding:0.1f}") + scrubber("spacing", layoutSpacing, 0.0, 40.0, &"Spacing: {layoutSpacing:0.1f}") + scrubber("numBoxes", numBoxes, 1.0, 10.0, &"Boxes: {numBoxes:0.1f}") + scrubber("step", step, 0.0, 20.0, &"Step: {int(step)}") + + + let prevDir = directionVal + text("Direction:") + group(vec2(0, 0), LeftToRight): + radioButton("Top to Bottom", directionVal, 0) + radioButton("Bottom to Top", directionVal, 1) + radioButton("Left to Right", directionVal, 2) + radioButton("Right to Left", directionVal, 3) + + # Auto-fix anchor when switching between vertical and horizontal. + if directionVal != prevDir: + if directionVal.isVertical and anchorVal >= 2: + anchorVal = 0 + elif directionVal.isHorizontal and anchorVal <= 1: + anchorVal = 2 + + let vertical = directionVal.isVertical + text("Anchor:") + group(vec2(0, 0), LeftToRight): + radioButton("Left", anchorVal, 0, vertical) + radioButton("Right", anchorVal, 1, vertical) + radioButton("Top", anchorVal, 2, not vertical) + radioButton("Bottom", anchorVal, 3, not vertical) + + # Layout area. + let + controlsBottom = sk.layout.at.y + 8 + areaPos = vec2(Margin, controlsBottom) + areaW = window.size.x.float32 - Margin * 2 + areaH = window.size.y.float32 - controlsBottom - Margin + areaSize = vec2(areaW, areaH) + pad = layoutPadding + stackDir = Directions[directionVal] + stackAnc = Anchors[anchorVal] + n = numBoxes.int + + sk.pushTheme() + sk.theme.spacing = 0 #layoutSpacing.int + sk.theme.padding = 0 #layoutPadding.int + + let padding = vec2(layoutPadding) + let spacing = vec2(layoutSpacing) + + + #frame("layoutArea", areaPos, areaSize): + block: + sk.layout.num = 0 + + # group(vec2(0, 0), stackDir, stackAnc): + # for i in 0 ..< n: + # let color = BoxColors[i mod BoxColors.len] + # let sz = BoxSizes[i mod BoxSizes.len] + # rectangle(sz, color, $(i + 1)) + + var dr = vec2(0, 0) # Direction vector + var pd = vec2(0, 0) # Padding direction vector + var si = vec2(0, 0) # Size importance vector + + case stackDir: + of TopToBottom: + dr = vec2(0, 1) + case stackAnc: + of AnchorLeft: + si = vec2(0, 0) + pd = vec2(1, 1) + of AnchorRight: + si = vec2(1, 0) + pd = vec2(-1, 1) + else: + discard + of BottomToTop: + dr = vec2(0, -1) + case stackAnc: + of AnchorLeft: + si = vec2(0, 1) + pd = vec2(1, -1) + of AnchorRight: + si = vec2(1, 1) + pd = vec2(-1, -1) + else: + discard + of LeftToRight: + dr = vec2(1, 0) + case stackAnc: + of AnchorTop: + si = vec2(0, 0) + pd = vec2(1, 1) + of AnchorBottom: + si = vec2(0, 1) + pd = vec2(1, -1) + else: + discard + of RightToLeft: + dr = vec2(-1, 0) + case stackAnc: + of AnchorTop: + si = vec2(1, 0) + pd = vec2(-1, 1) + of AnchorBottom: + si = vec2(1, 1) + pd = vec2(-1, -1) + else: + discard + + var currentStep = 0 + + #sk.pushLayout(areaPos, areaSize, stackDir, stackAnc) + + sk.layout.stretchMax = sk.layout.at + sk.layout.stretchMin = sk.layout.at + + sk.drawRect(sk.layout.at, areaSize, rgbx(10, 10, 10, 10)) + + proc drawStep() = + if currentStep == step.int: + sk.drawRect(sk.layout.at - vec2(3, 3), vec2(6, 6), rgbx(255, 0, 0, 255)) + sk.drawRect(sk.layout.stretchMin, sk.layout.stretchMax - sk.layout.stretchMin, rgbx(60, 60, 60, 60)) + if step.int != debugStep: + debugStep = step.int + echo "step: ", currentStep + echo "at: ", sk.layout.at + echo "stretchMin: ", sk.layout.stretchMin + echo "stretchMax: ", sk.layout.stretchMax + + inc currentStep + + drawStep() + + # Apply Anchor. + sk.layout.at += areaSize * si + sk.layout.stretchMin = sk.layout.at + sk.layout.stretchMax = sk.layout.at + + drawStep() + + # Apply Padding. + sk.layout.at += padding * pd + sk.layout.stretchMin = min(sk.layout.stretchMin, sk.layout.at + padding * pd) + sk.layout.stretchMax = max(sk.layout.stretchMax, sk.layout.at + padding * pd) + + drawStep() + + for i in 0 ..< n: + + if sk.layout.num > 0: + # Apply Spacing. + sk.layout.at += spacing * dr + sk.layout.stretchMin = min(sk.layout.stretchMin, sk.layout.at + spacing * pd) + sk.layout.stretchMax = max(sk.layout.stretchMax, sk.layout.at + spacing * pd) + + drawStep() + + var color = BoxColors[i] + color.a = 128 + let size = BoxSizes[i] + # Adjust the widget position. + let pos = sk.layout.at + size * si * pd + sk.drawRect(pos, size, color) + let label = $(i + 1) + discard sk.drawText( + sk.textStyle, label, pos, sk.theme.textColor, + size.x, size.y, + hAlign = CenterAlign, vAlign = MiddleAlign + ) + + # Advance to next widget. + sk.layout.stretchMin = min(sk.layout.stretchMin, sk.layout.at + size * pd + padding * pd) + sk.layout.stretchMax = max(sk.layout.stretchMax, sk.layout.at + size * pd + padding * pd) + sk.layout.at += size * dr + inc sk.layout.num + + drawStep() + + #sk.popLayout() + + + sk.popTheme() + + # Frame time. + let ms = sk.avgFrameTime * 1000 + sk.layout.at = vec2(sk.size.x - 250, Margin) + text(&"frame time: {ms:>7.3f}ms") + + sk.endUi() + window.swapBuffers() + +when defined(emscripten): + window.run() +else: + while not window.closeRequested: + pollEvents() diff --git a/tests/manual_layout3.nim b/tests/manual_layout3.nim new file mode 100644 index 0000000..ad4747b --- /dev/null +++ b/tests/manual_layout3.nim @@ -0,0 +1,242 @@ +## Demonstrates layout stacking directions and anchoring with adjustable +## padding, spacing, and number of boxes. Use the controls at the top to tweak +## values, pick a stacking direction and anchor from the dropdowns. The colored +## boxes below respond to every change in real time. + +import + std/[strformat], + opengl, windy, bumpy, vmath, chroma, + silky + +let builder = newAtlasBuilder(1024, 4) +builder.addDir("tests/data/", "tests/data/") +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "H1", 32.0) +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18.0) +builder.write("tests/dist/atlas.png", "tests/dist/atlas.json") + +let window = newWindow( + "Layout Test", + ivec2(900, 700), + vsync = false +) +makeContextCurrent(window) +loadExtensions() + +const + BackgroundColor = parseHtmlColor("#1a1a2e").rgbx + BoxColors = [ + parseHtmlColor("#e74c3c").rgbx, + parseHtmlColor("#3498db").rgbx, + parseHtmlColor("#2ecc71").rgbx, + parseHtmlColor("#f39c12").rgbx, + parseHtmlColor("#9b59b6").rgbx, + parseHtmlColor("#1abc9c").rgbx, + parseHtmlColor("#e67e22").rgbx, + parseHtmlColor("#e84393").rgbx, + parseHtmlColor("#00cec9").rgbx, + parseHtmlColor("#6c5ce7").rgbx, + ] + BoxSizes = [ + vec2(48, 48), + vec2(64, 32), + vec2(32, 64), + vec2(80, 40), + vec2(40, 80), + vec2(56, 56), + vec2(72, 36), + vec2(36, 72), + vec2(60, 44), + vec2(44, 60), + ] + +let sk = newSilky("tests/dist/atlas.png", "tests/dist/atlas.json") + +var + layoutPadding = 16.0f + layoutSpacing = 8.0f + numBoxes = 5.0f + directionVal = 2 + anchorVal = 2 + step = 0.0f + + debugStep = 0 + +const + Directions = [TopToBottom, BottomToTop, LeftToRight, RightToLeft] + Anchors = [AnchorLeft, AnchorRight, AnchorTop, AnchorBottom] + +proc isVertical(d: int): bool = + ## Vertical directions pair with Left/Right anchors. + d <= 1 + +proc isHorizontal(d: int): bool = + ## Horizontal directions pair with Top/Bottom anchors. + d >= 2 + +window.onFrame = proc() = + sk.beginUI(window, window.size) + sk.clearScreen(BackgroundColor) + + const Margin = 20.0f + + sk.layout.at = vec2(Margin, Margin) + + # Title. + h1text("Layout Test") + + # Controls. + scrubber("padding", layoutPadding, 0.0, 60.0, &"Padding: {layoutPadding:0.1f}") + scrubber("spacing", layoutSpacing, 0.0, 40.0, &"Spacing: {layoutSpacing:0.1f}") + scrubber("numBoxes", numBoxes, 1.0, 10.0, &"Boxes: {numBoxes:0.1f}") + scrubber("step", step, 0.0, 20.0, &"Step: {int(step)}") + + let prevDir = directionVal + text("Direction:") + group(vec2(0, 0), LeftToRight): + radioButton("Top to Bottom", directionVal, 0) + radioButton("Bottom to Top", directionVal, 1) + radioButton("Left to Right", directionVal, 2) + radioButton("Right to Left", directionVal, 3) + + # Auto-fix anchor when switching between vertical and horizontal. + if directionVal != prevDir: + if directionVal.isVertical and anchorVal >= 2: + anchorVal = 0 + elif directionVal.isHorizontal and anchorVal <= 1: + anchorVal = 2 + + let vertical = directionVal.isVertical + text("Anchor:") + group(vec2(0, 0), LeftToRight): + radioButton("Left", anchorVal, 0, vertical) + radioButton("Right", anchorVal, 1, vertical) + radioButton("Top", anchorVal, 2, not vertical) + radioButton("Bottom", anchorVal, 3, not vertical) + + # Layout area. + let + controlsBottom = sk.layout.at.y + 8 + areaPos = vec2(Margin, controlsBottom) + areaW = window.size.x.float32 - Margin * 2 + areaH = window.size.y.float32 - controlsBottom - Margin + areaSize = vec2(areaW, areaH) + pad = layoutPadding + stackDir = Directions[directionVal] + stackAnc = Anchors[anchorVal] + n = numBoxes.int + + sk.pushTheme() + sk.theme.spacing = 0 #layoutSpacing.int + sk.theme.padding = 0 #layoutPadding.int + + let padding = vec2(layoutPadding) + let spacing = vec2(layoutSpacing) + + #frame("layoutArea", areaPos, areaSize): + block: + sk.drawRect(areaPos, areaSize, rgbx(10, 10, 10, 10)) + # group(vec2(0, 0), stackDir, stackAnc): + # for i in 0 ..< n: + # let color = BoxColors[i mod BoxColors.len] + # let sz = BoxSizes[i mod BoxSizes.len] + # rectangle(sz, color, $(i + 1)) + + let mainDirs = [ + vec2(0, 1), + vec2(0, -1), + vec2(1, 0), + vec2(-1, 0) + ] + let paddingDirs = [ + [vec2(1, 1), vec2(-1, 1), vec2(0, 0), vec2(0, 0)], + [vec2(1, -1), vec2(-1, -1), vec2(0, 0), vec2(0, 0)], + [vec2(0, 0), vec2(0, 0), vec2(1, 1), vec2(1, -1)], + [vec2(0, 0), vec2(0, 0), vec2(-1, 1), vec2(-1, -1)] + ] + let + mainDir = mainDirs[stackDir.ord] + paddingDir = paddingDirs[stackDir.ord][stackAnc.ord] + sizeSign = vec2( + if paddingDir.x < 0: 1f else: 0f, + if paddingDir.y < 0: 1f else: 0f + ) + + var currentStep = 0 + + sk.pushLayout(areaPos, areaSize, stackDir, stackAnc) + + sk.layout.stretchMax = sk.layout.at + # sk.layout.stretchMin = sk.layout.at + + proc drawStep() = + if currentStep == step.int: + sk.drawRect(sk.layout.at - vec2(3, 3), vec2(6, 6), rgbx(255, 0, 0, 255)) + sk.drawRect(sk.layout.stretchMin, sk.layout.stretchMax - sk.layout.stretchMin, rgbx(60, 60, 60, 60)) + if step.int != debugStep: + debugStep = step.int + echo "step: ", currentStep + echo "at: ", sk.layout.at + echo "stretchMin: ", sk.layout.stretchMin + echo "stretchMax: ", sk.layout.stretchMax + + inc currentStep + + drawStep() + + sk.layout.at += areaSize * sizeSign + sk.layout.stretchMin = sk.layout.at + sk.layout.stretchMax = sk.layout.at + + drawStep() + + sk.layout.at += padding * paddingDir + sk.layout.stretchMin = min(sk.layout.stretchMin, sk.layout.at + padding * paddingDir) + sk.layout.stretchMax = max(sk.layout.stretchMax, sk.layout.at + padding * paddingDir) + + drawStep() + + for i in 0 ..< n: + + if sk.layout.num > 0: + sk.layout.at += spacing * mainDir + sk.layout.stretchMin = min(sk.layout.stretchMin, sk.layout.at + spacing * paddingDir) + sk.layout.stretchMax = max(sk.layout.stretchMax, sk.layout.at + spacing * paddingDir) + + drawStep() + + var color = BoxColors[i] + color.a = 128 + let size = BoxSizes[i] + let pos = sk.layout.at + size * sizeSign * paddingDir + sk.drawRect(pos, size, color) + let label = $(i + 1) + discard sk.drawText( + sk.textStyle, label, pos, sk.theme.textColor, + size.x, size.y, + hAlign = CenterAlign, vAlign = MiddleAlign + ) + + sk.layout.stretchMin = min(sk.layout.stretchMin, sk.layout.at + size * paddingDir + padding * paddingDir) + sk.layout.stretchMax = max(sk.layout.stretchMax, sk.layout.at + size * paddingDir + padding * paddingDir) + sk.layout.at += size * mainDir + inc sk.layout.num + + drawStep() + + sk.popLayout() + + sk.popTheme() + + # Frame time. + let ms = sk.avgFrameTime * 1000 + sk.layout.at = vec2(sk.size.x - 250, Margin) + text(&"frame time: {ms:>7.3f}ms") + + sk.endUi() + window.swapBuffers() + +when defined(emscripten): + window.run() +else: + while not window.closeRequested: + pollEvents() diff --git a/tests/manual_subpixeltext.nim b/tests/manual_subpixeltext.nim index da524f6..34b1e68 100644 --- a/tests/manual_subpixeltext.nim +++ b/tests/manual_subpixeltext.nim @@ -41,37 +41,37 @@ window.onFrame = proc() = SliderWidth = 600.0f # Title. - sk.at = vec2(Margin, Margin) + sk.layout.at = vec2(Margin, Margin) text("Subpixel Text Positioning") # Explanation. - sk.at = vec2(Margin, 70) + sk.layout.at = vec2(Margin, 70) text("Drag the slider to move the text. Compare regular vs subpixel rendering.") # Big slider. - sk.at = vec2(Margin, 110) + sk.layout.at = vec2(Margin, 110) text(&"Offset: {textOffset:>6.2f} px") sk.pushLayout(vec2(Margin, 140), vec2(SliderWidth, 32)) scrubber("offset", textOffset, 0.0, 20.0) sk.popLayout() # Pixel-snapped font (snaps to integer pixels). - sk.at = vec2(Margin, 200) + sk.layout.at = vec2(Margin, 200) text("Pixel-snapped:") - sk.at = vec2(Margin + textOffset, 225) + sk.layout.at = vec2(Margin + textOffset, 225) sk.textStyle = "Regular" text("The quick brown fox jumps over the lazy dog.") # Bilinear filtered (GPU interpolation causes blur). - sk.at = vec2(Margin, 260) + sk.layout.at = vec2(Margin, 260) sk.textStyle = "Default" text("Bilinear filtered:") sk.drawImage("text", vec2(Margin + textOffset, 285)) # Subpixel rendered font. - sk.at = vec2(Margin, 320) + sk.layout.at = vec2(Margin, 320) text("Subpixel rendered:") - sk.at = vec2(Margin + textOffset, 345) + sk.layout.at = vec2(Margin + textOffset, 345) sk.textStyle = "Subpixel" text("The quick brown fox jumps over the lazy dog.") @@ -80,7 +80,7 @@ window.onFrame = proc() = # Frame time display. let ms = sk.avgFrameTime * 1000 - sk.at = vec2(sk.size.x - 200, Margin) + sk.layout.at = vec2(sk.size.x - 200, Margin) text(&"frame time: {ms:>7.3f}ms") sk.endUi() diff --git a/tests/manual_textalign.nim b/tests/manual_textalign.nim new file mode 100644 index 0000000..62f65d2 --- /dev/null +++ b/tests/manual_textalign.nim @@ -0,0 +1,130 @@ +## Demonstrates text alignment inside a bounded area. +## Use the radio buttons to change horizontal and vertical alignment. +## The sample text in the box below reflows to match the selection. + +import + std/[strformat], + opengl, windy, bumpy, vmath, chroma, + silky + +let builder = newAtlasBuilder(1024, 4) +builder.addDir("tests/data/", "tests/data/") +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "H1", 32.0) +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18.0) +builder.write("tests/dist/atlas.png", "tests/dist/atlas.json") + +let window = newWindow( + "Text Alignment", + ivec2(800, 600), + vsync = false +) +makeContextCurrent(window) +loadExtensions() + +const + BackgroundColor = parseHtmlColor("#1a1a2e").rgbx + AreaBgColor = parseHtmlColor("#2a2a3e").rgbx + SampleText = "The quick brown fox jumps over the lazy dog." + MultiLineText = "Left or right,\ncenter if you like.\nThree lines of text." + +let sk = newSilky("tests/dist/atlas.png", "tests/dist/atlas.json") + +var + hAlignVal = 0 + vAlignVal = 0 + +proc toHAlign(v: int): HorizontalAlignment = + ## Convert radio index to horizontal alignment. + case v: + of 0: LeftAlign + of 1: CenterAlign + of 2: RightAlign + else: LeftAlign + +proc toVAlign(v: int): VerticalAlignment = + ## Convert radio index to vertical alignment. + case v: + of 0: TopAlign + of 1: MiddleAlign + of 2: BottomAlign + else: TopAlign + +window.onFrame = proc() = + sk.beginUI(window, window.size) + sk.clearScreen(BackgroundColor) + + const Margin = 20.0f + + # Centered title. + let titleSize = sk.getTextSize("H1", "Text Alignment") + discard sk.drawText( + "H1", "Text Alignment", + vec2(Margin, Margin), + sk.theme.textH1Color, + window.size.x.float32 - Margin * 2, + hAlign = CenterAlign + ) + + # Horizontal alignment radio buttons. + sk.layout.at = vec2(Margin, Margin + titleSize.y + 16) + text("Horizontal:") + group(vec2(0, 0), LeftToRight): + radioButton("Left", hAlignVal, 0) + radioButton("Center", hAlignVal, 1) + radioButton("Right", hAlignVal, 2) + + # Vertical alignment radio buttons. + text("Vertical:") + group(vec2(0, 0), LeftToRight): + radioButton("Top", vAlignVal, 0) + radioButton("Middle", vAlignVal, 1) + radioButton("Bottom", vAlignVal, 2) + + # Text display area. + let + controlsBottom = sk.layout.at.y + 8 + areaPos = vec2(Margin, controlsBottom) + areaW = window.size.x.float32 - Margin * 2 + areaH = window.size.y.float32 - controlsBottom - Margin + areaSize = vec2(areaW, areaH) + ha = hAlignVal.toHAlign() + va = vAlignVal.toVAlign() + + # Draw area background. + sk.drawRect(areaPos, areaSize, AreaBgColor) + + # Single line sample. + discard sk.drawText( + "Default", SampleText, + areaPos + vec2(12, 12), + sk.theme.textColor, + areaW - 24, (areaH - 24) * 0.4, + hAlign = ha, vAlign = va + ) + + # Divider line. + let divY = areaPos.y + areaH * 0.45 + sk.drawRect(vec2(areaPos.x + 8, divY), vec2(areaW - 16, 1), rgbx(100, 100, 120, 255)) + + # Multi-line sample. + discard sk.drawText( + "Default", MultiLineText, + vec2(areaPos.x + 12, divY + 12), + sk.theme.textColor, + areaW - 24, areaH * 0.55 - 24, + hAlign = ha, vAlign = va + ) + + # Frame time. + let ms = sk.avgFrameTime * 1000 + sk.layout.at = vec2(sk.size.x - 250, Margin) + text(&"frame time: {ms:>7.3f}ms") + + sk.endUi() + window.swapBuffers() + +when defined(emscripten): + window.run() +else: + while not window.closeRequested: + pollEvents() diff --git a/tests/manual_textbox.nim b/tests/manual_textbox.nim index 161fa23..07f85a4 100644 --- a/tests/manual_textbox.nim +++ b/tests/manual_textbox.nim @@ -59,7 +59,7 @@ window.onFrame = proc() = const Margin = 20.0f - sk.at = vec2(Margin, Margin) + sk.layout.at = vec2(Margin, Margin) # Title. h1text("Text Box Example") @@ -104,7 +104,7 @@ window.onFrame = proc() = # Frame time display. let ms = sk.avgFrameTime * 1000 - sk.at = vec2(sk.size.x - 250, Margin) + sk.layout.at = vec2(sk.size.x - 250, Margin) text(&"frame time: {ms:>7.3f}ms") sk.endUi() diff --git a/tests/manual_wordwrap.nim b/tests/manual_wordwrap.nim index 0a4d794..77a072a 100644 --- a/tests/manual_wordwrap.nim +++ b/tests/manual_wordwrap.nim @@ -50,7 +50,7 @@ window.onFrame = proc() = Margin = 20.0f BoxHeight = 600.0f - sk.at = vec2(Margin, Margin) + sk.layout.at = vec2(Margin, Margin) # Title. h1text("Word Wrap Example") @@ -63,7 +63,7 @@ window.onFrame = proc() = checkBox("Clip", clipOn) # Word-wrapped text with background rectangle. - let wrappedPos = sk.at + let wrappedPos = sk.layout.at sk.drawRect(wrappedPos, vec2(wrapWidth, BoxHeight), rgbx(40, 40, 60, 255)) sk.drawRect(vec2(wrappedPos.x + wrapWidth, wrappedPos.y), vec2(1, BoxHeight), rgbx(100, 100, 200, 200)) discard sk.drawText( @@ -78,7 +78,7 @@ window.onFrame = proc() = # Frame time display. let ms = sk.avgFrameTime * 1000 - sk.at = vec2(sk.size.x - 250, Margin) + sk.layout.at = vec2(sk.size.x - 250, Margin) text(&"frame time: {ms:>7.3f}ms") sk.endUi() diff --git a/tests/run_all.nim b/tests/run_all.nim index 0de1ad5..05892ee 100644 --- a/tests/run_all.nim +++ b/tests/run_all.nim @@ -1,44 +1,43 @@ -## Compiles and runs all examples sequentially for visual verification. - -import std/[osproc, os, strformat] - -const Examples = [ - "basicwindow", - "calculator", - "flowgrid", - "gameplayer", - "menu", - "panels", - "the7gui", -] - -proc main() = - ## Run all examples in sequence. - let - rootDir = currentSourcePath().parentDir.parentDir - examplesDir = rootDir / "examples" - - echo "=== Silky Examples Runner ===" - echo "Compiling and running each example." - echo "Close each window to proceed to the next example.\n" - - for i, name in Examples: - let - exampleDir = examplesDir / name - nimFile = name & ".nim" - - echo fmt"[{i + 1}/{Examples.len}] Compiling and running: {name}" - - # Change to example directory so it can find its data folder - setCurrentDir(exampleDir) - - let exitCode = execCmd(fmt"nim r {nimFile}") - if exitCode != 0: - echo fmt" ERROR: {name} failed with exit code {exitCode}" - quit(exitCode) - echo "" - - echo "=== All examples completed ===" - -when isMainModule: - main() +## Compiles and runs all examples sequentially for visual verification. + +import std/[osproc, os, strformat] + +const Examples = [ + "basicwindow", + "calculator", + "gameplayer", + "menu", + "panels", + "the7gui", +] + +proc main() = + ## Run all examples in sequence. + let + rootDir = currentSourcePath().parentDir.parentDir + examplesDir = rootDir / "examples" + + echo "=== Silky Examples Runner ===" + echo "Compiling and running each example." + echo "Close each window to proceed to the next example.\n" + + for i, name in Examples: + let + exampleDir = examplesDir / name + nimFile = name & ".nim" + + echo fmt"[{i + 1}/{Examples.len}] Compiling and running: {name}" + + # Change to example directory so it can find its data folder + setCurrentDir(exampleDir) + + let exitCode = execCmd(fmt"nim r {nimFile}") + if exitCode != 0: + echo fmt" ERROR: {name} failed with exit code {exitCode}" + quit(exitCode) + echo "" + + echo "=== All examples completed ===" + +when isMainModule: + main() diff --git a/tests/test_layout.nim b/tests/test_layout.nim new file mode 100644 index 0000000..3ac1919 --- /dev/null +++ b/tests/test_layout.nim @@ -0,0 +1,108 @@ +import + std/unittest, + silky/[layout, common], + vmath + +type + LayoutCase = object + direction: StackDirection + anchor: Anchor + expectedStart: Vec2 + expectedPos: Vec2 + +const Cases = [ + LayoutCase(direction: TopToBottom, anchor: AnchorLeft, expectedStart: vec2(10, 20), expectedPos: vec2(10, 20)), + LayoutCase(direction: TopToBottom, anchor: AnchorRight, expectedStart: vec2(110, 20), expectedPos: vec2(85, 20)), + LayoutCase(direction: BottomToTop, anchor: AnchorLeft, expectedStart: vec2(10, 70), expectedPos: vec2(10, 55)), + LayoutCase(direction: BottomToTop, anchor: AnchorRight, expectedStart: vec2(110, 70), expectedPos: vec2(85, 55)), + LayoutCase(direction: LeftToRight, anchor: AnchorTop, expectedStart: vec2(10, 20), expectedPos: vec2(10, 20)), + LayoutCase(direction: LeftToRight, anchor: AnchorBottom, expectedStart: vec2(10, 70), expectedPos: vec2(10, 55)), + LayoutCase(direction: RightToLeft, anchor: AnchorTop, expectedStart: vec2(110, 20), expectedPos: vec2(85, 20)), + LayoutCase(direction: RightToLeft, anchor: AnchorBottom, expectedStart: vec2(110, 70), expectedPos: vec2(85, 55)), +] + +suite "Layout module": + test "Layout stack push and pop keeps full state": + var stack: seq[Layout] + var parent = newLayout(vec2(10, 20), vec2(100, 50), TopToBottom, AnchorRight) + parent.at = vec2(33, 44) + parent.num = 7 + parent.stretchMin = vec2(2, 3) + parent.stretchMax = vec2(80, 90) + stack.add(parent) + var child = newLayout(vec2(0, 0), vec2(20, 10), LeftToRight, AnchorBottom) + child.advance(vec2(8, 6), 2'f32) + check child.num == 1 + let restored = stack.pop() + check restored.at == vec2(33, 44) + check restored.num == 7 + check restored.pos == vec2(10, 20) + check restored.size == vec2(100, 50) + check restored.direction == TopToBottom + check restored.anchor == AnchorRight + check restored.stretchMin == vec2(2, 3) + check restored.stretchMax == vec2(80, 90) + + test "Basis, start, and widget position": + let + containerPos = vec2(10, 20) + containerSize = vec2(100, 50) + childSize = vec2(25, 15) + for c in Cases: + let + layout = newLayout(containerPos, containerSize, c.direction, c.anchor) + startPos = layout.start() + widgetPos = layout.widgetPos(childSize) + check startPos == c.expectedStart + check widgetPos == c.expectedPos + check (layout.mainDir.x == 0) xor (layout.mainDir.y == 0) + check abs(layout.mainDir.x) + abs(layout.mainDir.y) == 1 + + test "Padding offset and advance delta": + let + padding = vec2(8, 6) + amount = vec2(25, 15) + spacing = 4'f32 + let + ttbLeft = newLayout(vec2(0, 0), vec2(1, 1), TopToBottom, AnchorLeft) + ttbRight = newLayout(vec2(0, 0), vec2(1, 1), TopToBottom, AnchorRight) + bttLeft = newLayout(vec2(0, 0), vec2(1, 1), BottomToTop, AnchorLeft) + bttRight = newLayout(vec2(0, 0), vec2(1, 1), BottomToTop, AnchorRight) + ltrTop = newLayout(vec2(0, 0), vec2(1, 1), LeftToRight, AnchorTop) + ltrBottom = newLayout(vec2(0, 0), vec2(1, 1), LeftToRight, AnchorBottom) + rtlTop = newLayout(vec2(0, 0), vec2(1, 1), RightToLeft, AnchorTop) + rtlBottom = newLayout(vec2(0, 0), vec2(1, 1), RightToLeft, AnchorBottom) + check ttbLeft.paddingOffset(padding) == vec2(8, 6) + check ttbRight.paddingOffset(padding) == vec2(-8, 6) + check bttLeft.paddingOffset(padding) == vec2(8, -6) + check bttRight.paddingOffset(padding) == vec2(-8, -6) + check ltrTop.paddingOffset(padding) == vec2(8, 6) + check ltrBottom.paddingOffset(padding) == vec2(8, -6) + check rtlTop.paddingOffset(padding) == vec2(-8, 6) + check rtlBottom.paddingOffset(padding) == vec2(-8, -6) + check ttbLeft.advanceDelta(amount, spacing) == vec2(0, 19) + check bttLeft.advanceDelta(amount, spacing) == vec2(0, -19) + check ltrTop.advanceDelta(amount, spacing) == vec2(29, 0) + check rtlTop.advanceDelta(amount, spacing) == vec2(-29, 0) + + test "Rectangle helpers": + var + minPos = vec2(1000, 1000) + maxPos = vec2(-1000, -1000) + includeRect(minPos, maxPos, vec2(10, 20), vec2(30, 15)) + includeRect(minPos, maxPos, vec2(5, 18), vec2(10, 40)) + check minPos == vec2(5, 18) + check maxPos == vec2(40, 58) + let r = rectFromMinMax(minPos, maxPos) + check r.x == 5 and r.y == 18 + check r.w == 35 and r.h == 40 + + test "Advance updates stretch in layout object": + var lay = newLayout(vec2(10, 20), vec2(100, 50), RightToLeft, AnchorTop) + check lay.stretchMin == lay.at + check lay.stretchMax == lay.at + lay.advance(vec2(25, 15), 4'f32) + check lay.stretchMin == vec2(110, 20) + check lay.stretchMax == vec2(139, 39) + check lay.at == vec2(81, 20) + check lay.num == 1 diff --git a/tests/test_scrollbars.nim b/tests/test_scrollbars.nim new file mode 100644 index 0000000..47e3994 --- /dev/null +++ b/tests/test_scrollbars.nim @@ -0,0 +1,171 @@ +import vmath, bumpy, silky/scrollbars + +echo "Testing contentSize" +block: + var sa = ScrollArea(contentMin: vec2(10, 20), contentMax: vec2(110, 220)) + doAssert sa.contentSize == vec2(100, 200), "contentSize should be max - min" + +echo "Testing contentSize with reversed min/max clamps to zero" +block: + var sa = ScrollArea(contentMin: vec2(100, 100), contentMax: vec2(50, 50)) + doAssert sa.contentSize == vec2(0, 0), "contentSize should clamp negative to zero" + +echo "Testing scrollMax when content fits" +block: + var sa = ScrollArea( + contentMin: vec2(0, 0), contentMax: vec2(100, 100), + viewPos: vec2(0, 0), viewSize: vec2(200, 200) + ) + doAssert sa.scrollMax == vec2(0, 0), "scrollMax should be zero when content fits" + +echo "Testing scrollMax when content overflows" +block: + var sa = ScrollArea( + contentMin: vec2(0, 0), contentMax: vec2(500, 300), + viewPos: vec2(0, 0), viewSize: vec2(200, 200) + ) + doAssert sa.scrollMax == vec2(300, 100), "scrollMax should be content - view" + +echo "Testing needsScrollX and needsScrollY" +block: + var sa = ScrollArea( + contentMin: vec2(0, 0), contentMax: vec2(500, 100), + viewPos: vec2(0, 0), viewSize: vec2(200, 200) + ) + doAssert sa.needsScrollX == true, "needs X scroll" + doAssert sa.needsScrollY == false, "does not need Y scroll" + +echo "Testing clampScroll" +block: + var sa = ScrollArea( + contentMin: vec2(0, 0), contentMax: vec2(500, 500), + viewPos: vec2(0, 0), viewSize: vec2(200, 200), + scrollPos: vec2(999, -10) + ) + sa.clampScroll() + doAssert sa.scrollPos.x == 300, "x clamped to scrollMax" + doAssert sa.scrollPos.y == 0, "y clamped to 0" + +echo "Testing clampScroll when no overflow" +block: + var sa = ScrollArea( + contentMin: vec2(0, 0), contentMax: vec2(100, 100), + viewPos: vec2(0, 0), viewSize: vec2(200, 200), + scrollPos: vec2(50, 50) + ) + sa.clampScroll() + doAssert sa.scrollPos == vec2(0, 0), "scroll reset to zero when no overflow" + +echo "Testing scrollOffset" +block: + var sa = ScrollArea(scrollPos: vec2(30, 50)) + doAssert sa.scrollOffset == vec2(-30, -50), "offset is negative scrollPos" + +echo "Testing initScroll with top-left anchor (no auto-scroll)" +block: + var sa = ScrollArea( + contentMin: vec2(10, 10), contentMax: vec2(500, 500), + viewPos: vec2(10, 10), viewSize: vec2(200, 200) + ) + sa.initScroll() + doAssert sa.scrollPos == vec2(0, 0), "no auto-scroll for normal anchor" + doAssert sa.initialized == true, "marked initialized" + +echo "Testing initScroll with bottom anchor (content above viewport)" +block: + var sa = ScrollArea( + contentMin: vec2(10, -200), contentMax: vec2(300, 210), + viewPos: vec2(10, 10), viewSize: vec2(200, 200) + ) + sa.initScroll() + doAssert sa.scrollPos.y == sa.scrollMax.y, "y scrolled to max for bottom anchor" + doAssert sa.scrollPos.x == 0, "x stays zero" + +echo "Testing initScroll with right anchor (content left of viewport)" +block: + var sa = ScrollArea( + contentMin: vec2(-200, 10), contentMax: vec2(210, 300), + viewPos: vec2(10, 10), viewSize: vec2(200, 200) + ) + sa.initScroll() + doAssert sa.scrollPos.x == sa.scrollMax.x, "x scrolled to max for right anchor" + doAssert sa.scrollPos.y == 0, "y stays zero" + +echo "Testing initScroll with bottom-right anchor" +block: + var sa = ScrollArea( + contentMin: vec2(-200, -200), contentMax: vec2(210, 210), + viewPos: vec2(10, 10), viewSize: vec2(200, 200) + ) + sa.initScroll() + doAssert sa.scrollPos.x == sa.scrollMax.x, "x scrolled to max" + doAssert sa.scrollPos.y == sa.scrollMax.y, "y scrolled to max" + +echo "Testing initScroll only runs once" +block: + var sa = ScrollArea( + contentMin: vec2(10, -200), contentMax: vec2(300, 210), + viewPos: vec2(10, 10), viewSize: vec2(200, 200) + ) + sa.initScroll() + let firstY = sa.scrollPos.y + sa.scrollPos.y = 0 + sa.initScroll() + doAssert sa.scrollPos.y == 0, "initScroll should not run twice" + +echo "Testing initScroll skips when no overflow" +block: + var sa = ScrollArea( + contentMin: vec2(10, 10), contentMax: vec2(100, 100), + viewPos: vec2(10, 10), viewSize: vec2(200, 200) + ) + sa.initScroll() + doAssert sa.initialized == false, "not initialized when no overflow" + +echo "Testing applyWheel" +block: + var sa = ScrollArea( + contentMin: vec2(0, 0), contentMax: vec2(500, 500), + viewPos: vec2(0, 0), viewSize: vec2(200, 200), + scrollPos: vec2(100, 100) + ) + sa.applyWheel(vec2(0, -10), -10.0) + doAssert sa.scrollPos.y == 200, "wheel scrolled down" + doAssert sa.scrollPos.x == 100, "x unchanged" + +echo "Testing scrollBarY rects" +block: + var sa = ScrollArea( + contentMin: vec2(0, 0), contentMax: vec2(200, 400), + viewPos: vec2(10, 20), viewSize: vec2(200, 200), + scrollPos: vec2(0, 0) + ) + let (track, handle) = sa.scrollBarY + doAssert track.x == 200, "track x at right edge - 10" + doAssert track.y == 22, "track y at viewPos.y + 2" + doAssert track.w == 8, "track width is 8" + doAssert handle.y == track.y, "handle at top when scrollPos is 0" + +echo "Testing scrollBarX rects" +block: + var sa = ScrollArea( + contentMin: vec2(0, 0), contentMax: vec2(400, 200), + viewPos: vec2(10, 20), viewSize: vec2(200, 200), + scrollPos: vec2(0, 0) + ) + let (track, handle) = sa.scrollBarX + doAssert track.y == 210, "track y at bottom edge - 10" + doAssert track.x == 12, "track x at viewPos.x + 2" + doAssert track.h == 8, "track height is 8" + doAssert handle.x == track.x, "handle at left when scrollPos is 0" + +echo "Testing releaseIfUp" +block: + var sa = ScrollArea(scrollingX: true, scrollingY: true) + sa.releaseIfUp(true) + doAssert sa.scrollingX == true, "still dragging when mouse down" + sa.releaseIfUp(false) + doAssert sa.scrollingX == false, "released on mouse up" + doAssert sa.scrollingY == false, "released on mouse up" + +echo "All scroll tests passed."