From c631e5af5ca103d5d498256c070655082250590d Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 26 May 2026 06:53:03 -0700 Subject: [PATCH] Add immediate Fidget-style DSL --- .github/workflows/build.yml | 2 +- examples/basicwindow/basicwindow.nim | 163 +++--- examples/calculator/calculator.nim | 294 +++++------ examples/gameplayer/gameplayer.nim | 503 +++++++++--------- examples/menu/menu.nim | 104 ++-- examples/panels/panels.nim | 90 ++-- examples/the7gui/the7gui.nim | 365 ++++++------- src/silky.nim | 14 +- src/silky/fidgetdsl.nim | 752 +++++++++++++++++++++++++++ src/silky/menus.nim | 309 +++++++++++ src/silky/semantic.nim | 34 +- src/silky/widgets.nim | 251 --------- 12 files changed, 1886 insertions(+), 995 deletions(-) create mode 100644 src/silky/fidgetdsl.nim create mode 100644 src/silky/menus.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 125f7f1..52743f1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,8 +49,8 @@ jobs: - name: Run unit tests if: ${{ !matrix.emscripten }} run: | - nim r tests/test_semantic.nim nim r tests/test_textboxes.nim + nim r tests/test_atlas_png.nim - name: Compile examples if: ${{ !matrix.emscripten }} diff --git a/examples/basicwindow/basicwindow.nim b/examples/basicwindow/basicwindow.nim index 630ca6f..1a8a163 100644 --- a/examples/basicwindow/basicwindow.nim +++ b/examples/basicwindow/basicwindow.nim @@ -32,18 +32,21 @@ var power = "Medium" progress = 0.0 howMuch = 30.0 - earlyReturn = true # Demonstrates that early return from a group works. - words = @["Alpha", "Bravo", "Charlie", "Delta"] - wordsIdx = 0 - clickableEnabled = true + earlyReturn = true proc returnTest() = - text("Return Test") - group(vec2(8, 8), LeftToRight): - text("Group") + text "return title": + characters "Return Test" + group "return row": + box 220, 34 + layout LeftToRight + itemSpacing 8 + text "return group": + characters "Group" if earlyReturn: return - text("You will not see this.") + text "return hidden": + characters "You will not see this." window.onFrame = proc() = if window.buttonPressed[KeyEqual] or @@ -55,78 +58,82 @@ window.onFrame = proc() = sk.beginUI(window, window.size) - # 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) - image("testTexture", rgbx(30, 30, 30, 255)) - - subWindow("A SubWindow", showWindow, vec2(100, 100), vec2(400, 700)): - text("Hello world!") - button("Close Me"): - showWindow = false - textInput("input", inputText) - - radioButton("Avg", option, 1) - radioButton("Max", option, 2) - radioButton("Min", option, 3) - - checkBox("Cumulative", cumulative) - - text("Select an option:") - dropDown(element, ["Fire", "Water", "Earth", "Air"]) - dropDown(power, ["Low", "Medium", "High"]) - - text("Progress Bar:") - progressBar(progress, 0, 100) - progress += 0.01 - if progress > 100.0: - progress = 0.0 - - text(&"How much: {howMuch:.2f}") - scrubber("howMuch", howMuch, 0.0, 100.0) - - group(vec2(8, 8), LeftToRight): - icon("heart") - text("Heart") - icon("cloud") - text("Cloud") - - group(vec2(8, 8), LeftToRight): - clickableIcon("heart", true): - discard - text("on") - clickableIcon("heart", false): - discard - text("off") - clickableIcon("heart", clickableEnabled): - clickableEnabled = not clickableEnabled - text("switch") - - group(vec2(8, 8), LeftToRight): - iconButton("cloud"): - wordsIdx = (wordsIdx + 1) mod words.len - text(words[wordsIdx]) - - text("A bunch of text to test the scrolling, in any direction.") - text("Does it work?") - - for i in 0 ..< 10: - text("Time will tell...") - - returnTest() - - if not showWindow: - if window.buttonPressed[MouseLeft]: - showWindow = true - sk.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) - text(&"ui scale: {sk.uiScale:>4.2f}x (+/-)") - sk.at = sk.pos + vec2(sk.size.x - 250, 48) - text(&"frame time: {ms:>7.3f}ms") + sk.drawImage("testTexture", vec2(x.float32 * 256, y.float32 * 256), rgbx(30, 30, 30, 255)) + + ui: + subWindow("A SubWindow", showWindow, vec2(100, 100), vec2(400, 700)): + text "hello": + characters "Hello world!" + + button "Close Me": + showWindow = false + + textInput "input", inputText + + group "radio buttons": + box 350, 32 + layout LeftToRight + itemSpacing 12 + radioButton "Avg", option, 1 + radioButton "Max", option, 2 + radioButton "Min", option, 3 + + checkBox "Cumulative", cumulative + + text "select label": + characters "Select an option:" + dropDown element, ["Fire", "Water", "Earth", "Air"] + dropDown power, ["Low", "Medium", "High"] + + text "progress label": + characters "Progress Bar:" + progressBar progress, 0, 100 + progress += 0.01 + if progress > 100.0: + progress = 0.0 + + text "scrubber label": + characters &"How much: {howMuch:.2f}" + scrubber "howMuch", howMuch, 0.0, 100.0, &"{howMuch:.0f}" + + group "icons row": + box 260, 32 + layout LeftToRight + itemSpacing 8 + icon "heart" + text "heart label": + characters "Heart" + icon "cloud" + text "cloud label": + characters "Cloud" + + text "scroll one": + characters "A bunch of text to test the scrolling, in any direction." + text "scroll two": + characters "Does it work?" + + for i in 0 ..< 10: + text "time line " & $i: + characters "Time will tell..." + + returnTest() + + if not showWindow: + text "closed message": + box 100, 100, 360, 32 + characters "Click anywhere to show the window" + if window.buttonPressed[MouseLeft]: + showWindow = true + + let ms = sk.avgFrameTime * 1000 + text "scale readout": + box sk.size.x - 250, 20, 230, 22 + characters &"ui scale: {sk.uiScale:>4.2f}x (+/-)" + text "time readout": + box sk.size.x - 250, 48, 230, 22 + characters &"frame time: {ms:>7.3f}ms" sk.endUi() window.swapBuffers() diff --git a/examples/calculator/calculator.nim b/examples/calculator/calculator.nim index d21b959..e89ac0f 100644 --- a/examples/calculator/calculator.nim +++ b/examples/calculator/calculator.nim @@ -113,181 +113,145 @@ let sk = newSilky(window, "dist/atlas.png") var showWindow = true -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) - - sk.beginWidget("Display", name = "display", text = displayText, rect = labelRect) - sk.drawRect(sk.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)) - sk.textStyle = oldStyle - sk.endWidget() - - sk.advance(vec2(0, 70)) +template calcDisplay(displayText: string) = + let displayTextSize = sk.getTextSize("H1", displayText) + rectangle "display": + semanticKind "Display" + semanticName "display" + semanticText displayText + box 292, 60 + tint rgbx(50, 50, 50, 255) + text "display text": + box max(10.0'f, 282.0'f - displayTextSize.x), 10, displayTextSize.x, 42 + font "H1" + characters displayText + tint "#ffffff" template calcButton(label: string, body: untyped) = - let - btnSize = vec2(60, 50) - startPos = sk.at - btnRect = rect(startPos, btnSize) - - sk.beginWidget("Button", text = label, rect = btnRect) - - if sk.mouseHover(window, btnRect): - if window.buttonReleased[MouseLeft]: + let buttonTextSize = sk.getTextSize("Default", label) + rectangle "calc button:" & label: + semanticKind "Button" + semanticText label + box 60, 50 + patch "button.9patch", 4 + onHover: + patch "button.hover.9patch", 4 + tint rgbx(220, 220, 220, 255) + onDown: + patch "button.down.9patch", 4 + tint rgbx(200, 200, 200, 255) + onClick: body - elif window.buttonDown[MouseLeft]: - sk.draw9Patch("button.down.9patch", 4, startPos, btnSize, rgbx(200, 200, 200, 255)) - else: - sk.draw9Patch("button.hover.9patch", 4, startPos, btnSize, rgbx(220, 220, 220, 255)) - else: - sk.draw9Patch("button.9patch", 4, startPos, btnSize) - - let oldStyle = sk.textStyle - sk.textStyle = "Default" - let textSize = sk.getTextSize(sk.textStyle, label) - let textPos = startPos + (btnSize - textSize) / 2 - discard sk.drawText(sk.textStyle, label, textPos, rgbx(255, 255, 255, 255)) - sk.textStyle = oldStyle - - 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) + text "calc label:" & label: + box (60.0'f - buttonTextSize.x) * 0.5, 13, buttonTextSize.x, 24 + characters label + tint "#ffffff" + +template calcRow(id: string, body: untyped) = + group "row:" & id: + box 292, 54 + layout LeftToRight + itemSpacing 10 + body window.onFrame = proc() = - sk.beginUI(window, window.size) sk.clearScreen(BackgroundColor) - # 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) - image("testTexture", rgbx(30, 30, 30, 255)) - - subWindow("Calculator", showWindow, vec2(10, 10), vec2(340, 480)): - - # Build the display formula string. - var formula = "" - for t in symbols: - formula.add(t.number) - formula.add(t.operator) - formula = formula.replace("--", "+").replace("+-", "-") - let displayText = if formula == "": "0" else: formula - - # Draw the calculator display. - calcLabel(displayText) - - let rowX = sk.at.x - - # Row 1: C, +/- (±), %, ÷. - calcButton("C"): - if symbols.len > 0: - repeat.setLen(0) - symbols.setLen(symbols.len - 1) - - calcButton("±"): - if symbols.len > 0 and symbols[^1].kind == Number: - var number = toFloat(symbols[^1].number) - symbols[^1].number = fromFloat(number / -1) - - calcButton("%"): - if symbols.len > 0 and symbols[^1].kind == Number: - var number = toFloat(symbols[^1].number) - symbols[^1].number = fromFloat(number / 100) - - calcButton("÷"): - if inOperator(): symbols[^1].operator = "÷" - - sk.at.x = rowX - sk.at.y += 60 - - # Row 2: 7, 8, 9, ×. - calcButton("7"): - inNumber() - symbols[^1].number.add("7") - calcButton("8"): - inNumber() - symbols[^1].number.add("8") - calcButton("9"): - inNumber() - symbols[^1].number.add("9") - calcButton("×"): - if inOperator(): symbols[^1].operator = "×" - - sk.at.x = rowX - sk.at.y += 60 - - # Row 3: 4, 5, 6, -. - calcButton("4"): - inNumber() - symbols[^1].number.add("4") - calcButton("5"): - inNumber() - symbols[^1].number.add("5") - calcButton("6"): - inNumber() - symbols[^1].number.add("6") - calcButton("-"): - # Minus symbol can be an operator or the start of a negative number. - if inOperator(): - symbols[^1].operator = "-" - else: - inNumber() - if symbols.len > 0 and symbols[^1].number == "": - symbols[^1].number = "-" - - sk.at.x = rowX - sk.at.y += 60 - - # Row 4: 1, 2, 3, +. - calcButton("1"): - inNumber() - symbols[^1].number.add("1") - calcButton("2"): - inNumber() - symbols[^1].number.add("2") - calcButton("3"): - inNumber() - symbols[^1].number.add("3") - calcButton("+"): - if inOperator(): symbols[^1].operator = "+" - - sk.at.x = rowX - sk.at.y += 60 - - calcButton("0"): - inNumber() - symbols[^1].number.add("0") - - calcButton("."): - inNumber() - if "." notin symbols[^1].number: - symbols[^1].number.add(".") - - calcButton("="): - compute() - - sk.at.x = rowX - sk.at.y += 60 - - if not showWindow: - if window.buttonPressed[MouseLeft]: - showWindow = true - sk.at = vec2(100, 100) - - let ms = sk.avgFrameTime * 1000 - sk.at = sk.pos + vec2(sk.size.x - 250, 20) - text(&"frame time: {ms:>7.3f}ms") + sk.drawImage("testTexture", vec2(x.float32 * 256, y.float32 * 256), rgbx(30, 30, 30, 255)) + + ui: + subWindow("Calculator", showWindow, vec2(10, 10), vec2(340, 480)): + var formula = "" + for t in symbols: + formula.add(t.number) + formula.add(t.operator) + formula = formula.replace("--", "+").replace("+-", "-") + let displayText = if formula == "": "0" else: formula + + calcDisplay displayText + + calcRow "ops": + calcButton "C": + if symbols.len > 0: + repeat.setLen(0) + symbols.setLen(symbols.len - 1) + calcButton "±": + if symbols.len > 0 and symbols[^1].kind == Number: + let number = toFloat(symbols[^1].number) + symbols[^1].number = fromFloat(number / -1) + calcButton "%": + if symbols.len > 0 and symbols[^1].kind == Number: + let number = toFloat(symbols[^1].number) + symbols[^1].number = fromFloat(number / 100) + calcButton "÷": + if inOperator(): symbols[^1].operator = "÷" + + calcRow "789": + calcButton "7": + inNumber() + symbols[^1].number.add("7") + calcButton "8": + inNumber() + symbols[^1].number.add("8") + calcButton "9": + inNumber() + symbols[^1].number.add("9") + calcButton "×": + if inOperator(): symbols[^1].operator = "×" + + calcRow "456": + calcButton "4": + inNumber() + symbols[^1].number.add("4") + calcButton "5": + inNumber() + symbols[^1].number.add("5") + calcButton "6": + inNumber() + symbols[^1].number.add("6") + calcButton "-": + if inOperator(): + symbols[^1].operator = "-" + else: + inNumber() + if symbols.len > 0 and symbols[^1].number == "": + symbols[^1].number = "-" + + calcRow "123": + calcButton "1": + inNumber() + symbols[^1].number.add("1") + calcButton "2": + inNumber() + symbols[^1].number.add("2") + calcButton "3": + inNumber() + symbols[^1].number.add("3") + calcButton "+": + if inOperator(): symbols[^1].operator = "+" + + calcRow "0": + calcButton "0": + inNumber() + symbols[^1].number.add("0") + calcButton ".": + inNumber() + if "." notin symbols[^1].number: + symbols[^1].number.add(".") + calcButton "=": + compute() + + if not showWindow: + if window.buttonPressed[MouseLeft]: + showWindow = true + + let ms = sk.avgFrameTime * 1000 + text "frame time": + box sk.size.x - 250, 20, 230, 22 + characters &"frame time: {ms:>7.3f}ms" sk.endUi() window.swapBuffers() diff --git a/examples/gameplayer/gameplayer.nim b/examples/gameplayer/gameplayer.nim index 16d5169..7caa1e0 100644 --- a/examples/gameplayer/gameplayer.nim +++ b/examples/gameplayer/gameplayer.nim @@ -1,5 +1,5 @@ import - std/[strformat, strutils], + std/[strformat], bumpy, vmath, chroma, silky @@ -12,7 +12,7 @@ builder.addFont("data/IBMPlexSans-Regular.ttf", "Default", 18.0) builder.write("dist/atlas.png") let window = newWindow( - "Silky Example 1", + "Silky Game Player", ivec2(1200, 900), vsync = false ) @@ -27,264 +27,283 @@ const let sk = newSilky(window, "dist/atlas.png") 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", + "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() = +template ribbon(id: string, p, s: Vec2, ribbonTint: ColorRGBX, body: untyped) = + rectangle id: + box p.x, p.y, s.x, s.y + tint ribbonTint + layout LeftToRight + horizontalPadding 16 + verticalPadding 12 + itemSpacing 12 + body + +template vibeButton(id, imageName: string, x, y: float32) = + rectangle id: + box x, y, 40, 40 + patch "button.9patch", 8 + onHover: + patch "button.hover.9patch", 8 + onDown: + patch "button.down.9patch", 8 + onClick: + echo imageName + rectangle id & ":image": + box 4, 4, 32, 32 + image imageName +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.drawImage("testTexture", vec2(x.float32 * 256, y.float32 * 256), rgbx(30, 30, 30, 255)) - 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") + ui: + ribbon("top ribbon", sk.pos, vec2(sk.size.x, 64), RibbonColor): + rectangle "logo": + box 0, 0, 48, 40 + image "ui/logo" + text "title": + box 62, 4, 320, 40 + characters "Hello, World!" + font "H1" + group "top actions": + box sk.size.x - 160, 0, 120, 40 + layout LeftToRight + itemSpacing 8 + iconButton "ui/heart": + echo "heart" + iconButton "ui/cloud": + echo "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("middle ribbon", vec2(0, sk.size.y - 128), vec2(sk.size.x, 66), ScrubberColor): + discard - ribbon(vec2(0, sk.size.y - 97), vec2(sk.size.x, 66), ScrubberColor): - scrubber("timeline", scrubValue, 0, 1000, $int(scrubValue + 0.5)) + ribbon("timeline 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): + ribbon("transport ribbon", vec2(0, sk.size.y - 64), vec2(sk.size.x, 64), RibbonColor): + group "transport buttons": + box 16, 0, 260, 40 + layout LeftToRight + itemSpacing 8 + clickableIcon "ui/rewindToStart", true: + echo "rewindToStart" + clickableIcon "ui/stepBack", true: + echo "stepBack" + clickableIcon "ui/play", true: + echo "play" + clickableIcon "ui/stepForward", true: + echo "stepForward" + clickableIcon "ui/rewindToEnd", true: + echo "rewindToEnd" - 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") + group "right tools": + box sk.size.x - 260, 0, 240, 40 + layout LeftToRight + itemSpacing 8 + clickableIcon "ui/heart", true: + echo "clickable heart" + clickableIcon "ui/cloud", true: + echo "clickable cloud" + clickableIcon "ui/grid", true: + echo "grid" + clickableIcon "ui/eye", true: + echo "eye" + clickableIcon "ui/tack", true: + echo "tack" - # 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": + box sk.size.x - (16 * (32 + Margin)) - 14, 100 - 14, 700 + 14, 600 + 14 + patch "frame.9patch", 6 + layout TopToBottom + horizontalPadding 24 + verticalPadding 24 + itemSpacing 0 - 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) + for i, vibe in vibes: + let + col = i mod 13 + row = i div 13 + x = col.float32 * (32 + Margin) + y = row.float32 * (32 + Margin) + vibeButton "vibe:" & $i, vibe, x, y - group(vec2(10, 200), TopToBottom): - text("Step: 1 of 10\nscore: 100\nlevel: 1\nwidth: 100\nheight: 100\nnum agents: 10") + frame "stats": + box 10, 200, 220, 180 + patch "window.9patch", 14 + layout TopToBottom + horizontalPadding 16 + verticalPadding 16 + text "stats text": + characters "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") + let ms = sk.avgFrameTime * 1000 + text "frame time": + box sk.size.x - 250, 20, 230, 22 + characters &"frame time: {ms:>7.3f}ms" sk.endUi() window.swapBuffers() diff --git a/examples/menu/menu.nim b/examples/menu/menu.nim index 9d654df..7aec40e 100644 --- a/examples/menu/menu.nim +++ b/examples/menu/menu.nim @@ -26,53 +26,77 @@ window.onRune = proc(rune: Rune) = sk.inputRunes.add(rune) window.onFrame = proc() = - sk.beginUI(window, window.size) - - # Clear screen with the selected background color. sk.clearScreen(BackgroundColor) - menuBar: - subMenu("File", menuWidth = 200): - menuItem("Open"): - echo "Open" - subMenu("Open Recent", menuWidth = 120): - menuItem("File 1"): + ui: + rectangle "menubar": + box 0, 0, sk.size.x, 36 + patch "header.9patch", 6 + tint "#ffffff" + group "menu roots": + box 8, 4, 420, 28 + layout LeftToRight + itemSpacing 4 + menuRoot "File", 72 + menuRoot "Edit", 72 + menuRoot "View", 72 + menuRoot "Help", 72 + + case openMenu + of "File": + popupMenu "file", 8, 36, 200, 250: + menuItem "Open": + echo "Open" + menuItem "Open Recent / File 1": echo "File 1" - menuItem("File 2"): + menuItem "Open Recent / File 2": echo "File 2" - menuItem("File 3"): + menuItem "Open Recent / File 3": echo "File 3" - subMenu("Even More", menuWidth = 100): - menuItem("Config A"): - echo "Config A" - menuItem("Config B"): - echo "Config B" - menuItem("Save"): - echo "Save" - menuItem("Close"): - echo "Close" - subMenu("Edit", menuWidth = 150): - menuItem("Cut"): - echo "Cut" - menuItem("Copy"): - echo "Copy" - menuItem("Paste"): - echo "Paste" - subMenu("View", menuWidth = 150): - menuItem("Fullscreen"): - echo "Fullscreen" - menuItem("Windowed"): - echo "Windowed" - menuItem("Maximized"): - echo "Maximized" - subMenu("Help", menuWidth = 100): - menuItem("About"): - echo "About" + menuItem "Even More / Config A": + echo "Config A" + menuItem "Even More / Config B": + echo "Config B" + menuItem "Save": + echo "Save" + menuItem "Close": + echo "Close" + of "Edit": + popupMenu "edit", 84, 36, 170, 110: + menuItem "Cut": + echo "Cut" + menuItem "Copy": + echo "Copy" + menuItem "Paste": + echo "Paste" + of "View": + popupMenu "view", 160, 36, 170, 110: + menuItem "Fullscreen": + echo "Fullscreen" + menuItem "Windowed": + echo "Windowed" + menuItem "Maximized": + echo "Maximized" + of "Help": + popupMenu "help", 236, 36, 130, 48: + menuItem "About": + echo "About" + else: + discard + + text "hint": + box 24, 80, 460, 26 + characters "Menus are now authored as immediate DSL scopes." + font "H1" + + let ms = sk.avgFrameTime * 1000 + text "frame time": + box sk.size.x - 250, 20, 230, 22 + characters &"frame time: {ms:>7.3f}ms" - let ms = sk.avgFrameTime * 1000 - sk.at = sk.pos + vec2(sk.size.x - 250, 20) - text(&"frame time: {ms:>7.3f}ms") + if window.buttonPressed[MouseLeft] and window.mousePos.y > 360: + openMenu = "" sk.endUi() window.swapBuffers() diff --git a/examples/panels/panels.nim b/examples/panels/panels.nim index c2d40a5..f8be72e 100644 --- a/examples/panels/panels.nim +++ b/examples/panels/panels.nim @@ -346,7 +346,9 @@ proc drawAreaRecursive(area: Area, r: Rect) = # Draw Header let headerRect = rect(r.x, r.y, r.w, AreaHeaderHeight) - sk.draw9Patch("panel.header.9patch", 3, headerRect.xy, headerRect.wh) + rectangle "panel.header:" & $cast[uint](area): + box headerRect.x, headerRect.y, headerRect.w, headerRect.h + patch "panel.header.9patch", 3 # Draw Tabs var x = r.x + 4 @@ -379,14 +381,18 @@ proc drawAreaRecursive(area: Area, r: Rect) = 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)) + rectangle "panel.tab:" & $cast[uint](panel): + box tabRect.x, tabRect.y, tabRect.w, tabRect.h + if isSelected: + patch "panel.tab.selected.9patch", 3 + elif isHovered: + patch "panel.tab.hover.9patch", 3 + else: + patch "panel.tab.9patch", 3 + text "panel.tab.text:" & $cast[uint](panel): + box 8, 6, tabRect.w - 16, tabRect.h - 8 + characters panel.name + tint "#ffffff" x += tabW + 2 sk.popClipRect() @@ -395,15 +401,18 @@ proc drawAreaRecursive(area: Area, r: Rect) = 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) + frame frameId: + box contentRect.x, contentRect.y, contentRect.w, contentRect.h + layout TopToBottom + horizontalPadding 8 + verticalPadding 8 + itemSpacing 8 h1text(activePanel.name) - text("This is the content of " & activePanel.name) + text "panel.body:" & $cast[uint](activePanel): + characters "This is the content of " & activePanel.name for i in 0 ..< 20: - text(&"Scrollable line {i} for " & activePanel.name) + text "panel.line:" & $cast[uint](activePanel) & ":" & $i: + characters &"Scrollable line {i} for " & activePanel.name window.onFrame = proc() = @@ -468,26 +477,35 @@ window.onFrame = proc() = 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 - 250, 20) - text(&"frame time: {ms:>7.3f}ms") + ui: + 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: + rectangle "drop highlight": + box dropHighlight.x, dropHighlight.y, dropHighlight.w, dropHighlight.h + tint rgbx(255, 255, 0, 100) + + let label = dragPanel.name + let textSize = sk.getTextSize("Default", label) + let ghostPos = window.mousePos.vec2 + vec2(10, 10) + rectangle "drag ghost": + box ghostPos.x, ghostPos.y, textSize.x + 16, textSize.y + 8 + patch "tooltip.9patch", 4 + tint rgbx(255, 255, 255, 200) + text "drag ghost text": + box 8, 4, textSize.x, textSize.y + characters label + tint "#ffffff" + + # Regenerate the layout when R is pressed. + if window.buttonPressed[KeyR]: + regenerate() + + let ms = sk.avgFrameTime * 1000 + text "frame time": + box sk.size.x - 250, 20, 230, 22 + characters &"frame time: {ms:>7.3f}ms" sk.endUi() window.swapBuffers() diff --git a/examples/the7gui/the7gui.nim b/examples/the7gui/the7gui.nim index 4661573..e913495 100644 --- a/examples/the7gui/the7gui.nim +++ b/examples/the7gui/the7gui.nim @@ -10,7 +10,7 @@ builder.addFont("data/IBMPlexSans-Regular.ttf", "Default", 18.0) builder.write("dist/atlas.png") let window = newWindow( - "7GUIs - Counter", + "7GUIs", ivec2(800, 600), vsync = false ) @@ -21,7 +21,6 @@ const BackgroundColor = parseHtmlColor("#000000").rgbx let sk = newSilky(window, "dist/atlas.png") -# Set up a light theme for 7GUIs. sk.theme.defaultTextColor = parseHtmlColor("#2C3E50").rgbx sk.theme.disabledTextColor = parseHtmlColor("#95A5A6").rgbx sk.theme.errorTextColor = parseHtmlColor("#E74C3C").rgbx @@ -70,34 +69,29 @@ var oldCrudSelected = -1 proc isValidDate(s: string): bool = - ## Check if the string is a valid date. try: discard parse(s, "dd.MM.yyyy") - return true + true except: - return false + false proc parseDate(s: string): DateTime = - ## Parse a date string or return a safe default on failure. try: - return parse(s, "dd.MM.yyyy") + parse(s, "dd.MM.yyyy") except: - return dateTime(2000, Month(1), 1, 0, 0, 0, zone = utc()) + dateTime(2000, Month(1), 1, 0, 0, 0, zone = utc()) proc isValidFloat(s: string): bool = - ## Check if the string is a valid float. try: discard parseFloat(s) - return true + true except ValueError: - return false + false window.onFrame = proc() = - sk.beginUI(window, window.size) sk.clearScreen(BackgroundColor) - # Update the timer elapsed time. let now = epochTime() let dt = now - lastFrameTime lastFrameTime = now @@ -105,170 +99,193 @@ window.onFrame = proc() = 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)) - - subWindow("Challenges", showChallenges, vec2(10, 10), vec2(300, 450)): - button("Counter"): showCounter = not showCounter - button("Temperature Converter"): showTemperature = not showTemperature - button("Flight Booker"): showFlightBooker = not showFlightBooker - button("Timer"): showTimer = not showTimer - button("CRUD"): showCRUD = not showCRUD - button("Circle Drawer", false): showCircleDrawer = not showCircleDrawer - button("Cells", false): showCells = not showCells - - subWindow("Counter", showCounter, vec2(320, 50), vec2(320, 200)): - text(&"{counter}") - button("Count"): - inc counter - - subWindow("Temperature Converter", showTemperature, vec2(320, 60), vec2(320, 250)): - let cValid = isValidFloat(celsius) - let oldCelsius = celsius - text("Celsius") - textInput("celsius", celsius, true, not cValid) - if celsius != oldCelsius: - try: - let c = parseFloat(celsius) - let f = c * (9.0 / 5.0) + 32.0 - fahrenheit = fmt"{f:.1f}" - if "fahrenheit" in textBoxStates: - textBoxStates["fahrenheit"].setText(fahrenheit) - except ValueError: - discard - - let fValid = isValidFloat(fahrenheit) - let oldFahrenheit = fahrenheit - text("Fahrenheit") - textInput("fahrenheit", fahrenheit, true, not fValid) - if fahrenheit != oldFahrenheit: - try: - let f = parseFloat(fahrenheit) - let c = (f - 32.0) * (5.0 / 9.0) - celsius = fmt"{c:.1f}" - if "celsius" in textBoxStates: - textBoxStates["celsius"].setText(celsius) - except ValueError: - discard - - subWindow("Flight Booker", showFlightBooker, vec2(320, 70), vec2(350, 400)): - dropDown(flightType, ["one-way flight", "return flight"]) - - let startValid = isValidDate(startDateStr) - text("Start Date") - textInput("startDate", startDateStr, true, not startValid) - - let isReturn = flightType == "return flight" - let returnValid = isValidDate(returnDateStr) - text("Return Date") - textInput("returnDate", returnDateStr, isReturn, isReturn and not returnValid) - - var dateOrderError = false - if isReturn and startValid and returnValid: - let start = parseDate(startDateStr) - let ret = parseDate(returnDateStr) - if ret < start: - dateOrderError = true - - var canBook = startValid and (not isReturn or (returnValid and not dateOrderError)) - - button("Book", canBook, dateOrderError): - if flightType == "one-way flight": - bookedMessage = &"You have booked a one-way flight on {startDateStr}." - else: - bookedMessage = &"You have booked a return flight departing on {startDateStr} and returning on {returnDateStr}." - - if dateOrderError: - text("Return date cannot be before start date.") - elif bookedMessage != "": - text(bookedMessage) - - subWindow("Timer", showTimer, vec2(320, 80), vec2(300, 250)): - text(&"Elapsed Time: {timerElapsed:.1f}s") - progressBar(timerElapsed, 0, timerDuration) - text("Duration:") - scrubber("timer_scrubber", timerDuration, 0.1, 60.0) - button("Reset"): - timerElapsed = 0.0 - - subWindow("CRUD", showCRUD, vec2(150, 150), vec2(400, 450)): - text("Filter prefix:") - textInput("crudPrefix", crudPrefix) - - # Filter database based on prefix using case insensitive comparison. - var filteredItems: seq[string] - var originalIndices: seq[int] - for i, person in crudDatabase: - if crudPrefix == "" or person.toLowerAscii().startsWith(crudPrefix.toLowerAscii()): - filteredItems.add(person) - originalIndices.add(i) - - # If selection is out of bounds for the filtered list, reset it. - if crudSelected >= filteredItems.len: - crudSelected = -1 - - listBox("crud_list", filteredItems, crudSelected) - - # If selection changed, sync the name and surname fields. - if crudSelected != oldCrudSelected: - if crudSelected != -1 and crudSelected < filteredItems.len: - let person = filteredItems[crudSelected] - let parts = person.split(", ") - if parts.len == 2: - crudSurname = parts[0] - crudName = parts[1] - else: - # Clear fields when selection is lost. - crudName = "" - crudSurname = "" - - # Sync back to input text states to update display immediately. - if "crudName" in textBoxStates: textBoxStates["crudName"].setText(crudName) - if "crudSurname" in textBoxStates: textBoxStates["crudSurname"].setText(crudSurname) - oldCrudSelected = crudSelected - - text("Name:") - textInput("crudName", crudName) - text("Surname:") - textInput("crudSurname", crudSurname) - - let canUpdateDelete = crudSelected != -1 - let originalIdx = if canUpdateDelete: originalIndices[crudSelected] else: -1 - - group(vec2(0, 0), LeftToRight): - button("Create"): - if crudName != "" and crudSurname != "": - crudDatabase.add(crudSurname & ", " & crudName) + sk.drawImage("testTexture", vec2(x.float32 * 256, y.float32 * 256), rgbx(30, 30, 30, 255)) + + ui: + subWindow("Challenges", showChallenges, vec2(10, 10), vec2(300, 450)): + button "Counter": + showCounter = not showCounter + button "Temperature Converter": + showTemperature = not showTemperature + button "Flight Booker": + showFlightBooker = not showFlightBooker + button "Timer": + showTimer = not showTimer + button "CRUD": + showCRUD = not showCRUD + button "Circle Drawer", false: + showCircleDrawer = not showCircleDrawer + button "Cells", false: + showCells = not showCells + + subWindow("Counter", showCounter, vec2(320, 50), vec2(320, 200)): + text "counter value": + characters &"{counter}" + button "Count": + inc counter + + subWindow("Temperature Converter", showTemperature, vec2(320, 60), vec2(320, 250)): + let cValid = isValidFloat(celsius) + let oldCelsius = celsius + text "celsius label": + characters "Celsius" + textInput "celsius", celsius, true, not cValid + if celsius != oldCelsius: + try: + let c = parseFloat(celsius) + let f = c * (9.0 / 5.0) + 32.0 + fahrenheit = fmt"{f:.1f}" + if "fahrenheit" in textBoxStates: + textBoxStates["fahrenheit"].setText(fahrenheit) + except ValueError: + discard + + let fValid = isValidFloat(fahrenheit) + let oldFahrenheit = fahrenheit + text "fahrenheit label": + characters "Fahrenheit" + textInput "fahrenheit", fahrenheit, true, not fValid + if fahrenheit != oldFahrenheit: + try: + let f = parseFloat(fahrenheit) + let c = (f - 32.0) * (5.0 / 9.0) + celsius = fmt"{c:.1f}" + if "celsius" in textBoxStates: + textBoxStates["celsius"].setText(celsius) + except ValueError: + discard + + subWindow("Flight Booker", showFlightBooker, vec2(320, 70), vec2(350, 400)): + dropDown flightType, ["one-way flight", "return flight"] + + let startValid = isValidDate(startDateStr) + text "start label": + characters "Start Date" + textInput "startDate", startDateStr, true, not startValid + + let isReturn = flightType == "return flight" + let returnValid = isValidDate(returnDateStr) + text "return label": + characters "Return Date" + textInput "returnDate", returnDateStr, isReturn, isReturn and not returnValid + + var dateOrderError = false + if isReturn and startValid and returnValid: + let start = parseDate(startDateStr) + let ret = parseDate(returnDateStr) + if ret < start: + dateOrderError = true + + let canBook = startValid and (not isReturn or (returnValid and not dateOrderError)) + + button "Book", canBook, dateOrderError: + if flightType == "one-way flight": + bookedMessage = &"You have booked a one-way flight on {startDateStr}." + else: + bookedMessage = &"You have booked a return flight departing on {startDateStr} and returning on {returnDateStr}." + + if dateOrderError: + text "date order error": + characters "Return date cannot be before start date." + elif bookedMessage != "": + text "booked message": + characters bookedMessage + + subWindow("Timer", showTimer, vec2(320, 80), vec2(300, 250)): + text "elapsed": + characters &"Elapsed Time: {timerElapsed:.1f}s" + progressBar timerElapsed, 0, timerDuration + text "duration label": + characters "Duration:" + scrubber "timer_scrubber", timerDuration, 0.1, 60.0 + button "Reset": + timerElapsed = 0.0 + + subWindow("CRUD", showCRUD, vec2(150, 150), vec2(400, 450)): + text "filter label": + characters "Filter prefix:" + textInput "crudPrefix", crudPrefix + + var filteredItems: seq[string] + var originalIndices: seq[int] + for i, person in crudDatabase: + if crudPrefix == "" or person.toLowerAscii().startsWith(crudPrefix.toLowerAscii()): + filteredItems.add(person) + originalIndices.add(i) + + if crudSelected >= filteredItems.len: + crudSelected = -1 + + listBox "crud_list", filteredItems, crudSelected + + if crudSelected != oldCrudSelected: + if crudSelected != -1 and crudSelected < filteredItems.len: + let person = filteredItems[crudSelected] + let parts = person.split(", ") + if parts.len == 2: + crudSurname = parts[0] + crudName = parts[1] + else: crudName = "" crudSurname = "" - button("Update", canUpdateDelete): - if crudName != "" and crudSurname != "": - crudDatabase[originalIdx] = crudSurname & ", " & crudName - - button("Delete", canUpdateDelete): - crudDatabase.delete(originalIdx) - crudSelected = -1 - crudName = "" - crudSurname = "" - if "crudName" in textBoxStates: textBoxStates["crudName"].setText("") - if "crudSurname" in textBoxStates: textBoxStates["crudSurname"].setText("") - - subWindow("Circle Drawer", showCircleDrawer, vec2(160, 160), vec2(400, 400)): - text("Coming soon...") - - subWindow("Cells", showCells, vec2(170, 170), vec2(500, 400)): - text("Coming soon...") - - 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) - text("Click anywhere to show the Challenges window") - - let ms = sk.avgFrameTime * 1000 - sk.at = sk.pos + vec2(sk.size.x - 250, 20) - text(&"frame time: {ms:>7.3f}ms") + if "crudName" in textBoxStates: textBoxStates["crudName"].setText(crudName) + if "crudSurname" in textBoxStates: textBoxStates["crudSurname"].setText(crudSurname) + oldCrudSelected = crudSelected + + text "name label": + characters "Name:" + textInput "crudName", crudName + text "surname label": + characters "Surname:" + textInput "crudSurname", crudSurname + + let canUpdateDelete = crudSelected != -1 + let originalIdx = if canUpdateDelete: originalIndices[crudSelected] else: -1 + + group "crud actions": + box 340, 42 + layout LeftToRight + itemSpacing 8 + button "Create": + if crudName != "" and crudSurname != "": + crudDatabase.add(crudSurname & ", " & crudName) + crudName = "" + crudSurname = "" + + button "Update", canUpdateDelete: + if crudName != "" and crudSurname != "": + crudDatabase[originalIdx] = crudSurname & ", " & crudName + + button "Delete", canUpdateDelete: + crudDatabase.delete(originalIdx) + crudSelected = -1 + crudName = "" + crudSurname = "" + if "crudName" in textBoxStates: textBoxStates["crudName"].setText("") + if "crudSurname" in textBoxStates: textBoxStates["crudSurname"].setText("") + + subWindow("Circle Drawer", showCircleDrawer, vec2(160, 160), vec2(400, 400)): + text "circle soon": + characters "Coming soon..." + + subWindow("Cells", showCells, vec2(170, 170), vec2(500, 400)): + text "cells soon": + characters "Coming soon..." + + if not showChallenges and not showCounter and not showTemperature and + not showFlightBooker and not showTimer and not showCRUD and + not showCircleDrawer and not showCells: + text "restore prompt": + box 100, 100, 440, 28 + characters "Click anywhere to show the Challenges window" + if window.buttonPressed[MouseLeft]: + showChallenges = true + + let ms = sk.avgFrameTime * 1000 + text "frame time": + box sk.size.x - 250, 20, 230, 22 + characters &"frame time: {ms:>7.3f}ms" sk.endUi() window.swapBuffers() diff --git a/src/silky.nim b/src/silky.nim index 3e8a5fd..ec346fa 100644 --- a/src/silky.nim +++ b/src/silky.nim @@ -1,20 +1,26 @@ 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, fidgetdsl, menus] + export semantic, atlas, tables, textboxes, testing, fidgetdsl, menus + export widgets except + button, checkBox, clickableIcon, dropDown, frame, group, h1text, icon, + iconButton, image, listBox, progressBar, radioButton, ribbon, scrubber, text else: import windy when not defined(useDirectX) and not defined(useVulkan) and not defined(useMetal4): import opengl - import silky/[contexts, atlas, widgets, textboxes] + import silky/[contexts, atlas, widgets, textboxes, fidgetdsl, menus] when not defined(useDirectX) and not defined(useVulkan) and not defined(useMetal4): export opengl - export windy, contexts, atlas, widgets, tables, textboxes + export windy, contexts, atlas, tables, textboxes, fidgetdsl, menus + export widgets except + button, checkBox, clickableIcon, dropDown, frame, group, h1text, icon, + iconButton, image, listBox, progressBar, radioButton, ribbon, scrubber, text when defined(useMetal4): proc loadExtensions*() {.inline.} = diff --git a/src/silky/fidgetdsl.nim b/src/silky/fidgetdsl.nim new file mode 100644 index 0000000..f814841 --- /dev/null +++ b/src/silky/fidgetdsl.nim @@ -0,0 +1,752 @@ +import + std/tables, + bumpy, chroma, pixie, vmath, + silky/widgets as baseWidgets + +when defined(silkyTesting): + import silky/[semantic, testing] +else: + import silky/contexts, windy + +type + DslNodeKind* = enum + nkRoot + nkFrame + nkGroup + nkRectangle + nkText + nkComponent + nkInstance + + PatchSpec* = object + name*: string + top*, right*, bottom*, left*: int + + DslNode* = ref object + kind*: DslNodeKind + id*: string + boxRect*: Rect + resolvedRect*: Rect + hasBox*: bool + resolved*: bool + materialized*: bool + startedChildren*: bool + pushedLayout*: bool + pushedClip*: bool + patch*: PatchSpec + imageName*: string + characters*: string + fontName*: string + tintColor*: ColorRGBX + hasTint*: bool + clipContent*: bool + direction*: StackDirection + horizontalPadding*: float32 + verticalPadding*: float32 + itemSpacing*: float32 + hAlign*: HorizontalAlignment + vAlign*: VerticalAlignment + frameState*: FrameState + frameOrigin*: Vec2 + interactionResolved*: bool + interaction*: Interaction + semanticOpened*: bool + semanticKind*: string + semanticName*: string + semanticText*: string + semanticEnabled*: bool + semanticFocused*: bool + semanticPressed*: bool + semanticHovered*: bool + semanticChecked*: bool + semanticValue*: string + +var + root*: DslNode + parent*: DslNode + current*: DslNode + scopeStack*: seq[DslNode] + +proc dslVec2(v: SomeNumber): Vec2 {.inline.} = + vec2(v.float32, v.float32) + +proc dslVec2[A, B](x: A, y: B): Vec2 {.inline.} = + vec2(x.float32, y.float32) + +proc white(): ColorRGBX = + rgbx(255, 255, 255, 255) + +proc patchSpec(name: string, top, right, bottom, left: int): PatchSpec = + PatchSpec(name: name, top: top, right: right, bottom: bottom, left: left) + +proc patchSpec(name: string, border: int): PatchSpec = + patchSpec(name, border, border, border, border) + +proc defaultSemanticKind(node: DslNode): string = + case node.kind + of nkRoot: "Root" + of nkFrame: "Frame" + of nkGroup: "Group" + of nkRectangle: "Rectangle" + of nkText: "Text" + of nkComponent: "Component" + of nkInstance: "Instance" + +proc nodeSemanticKind(node: DslNode): string = + if node.semanticKind.len > 0: + node.semanticKind + else: + node.defaultSemanticKind() + +proc nodeSemanticName(node: DslNode): string = + if node.semanticName.len > 0: + node.semanticName + else: + node.id + +proc nodeSemanticText(node: DslNode): string = + if node.semanticText.len > 0: + node.semanticText + elif node.kind == nkText: + node.characters + else: + "" + +proc beginSemantic(sk: Silky, node: DslNode, r: Rect) = + if node.kind == nkRoot or node.semanticOpened: + return + sk.beginWidget(node.nodeSemanticKind(), node.nodeSemanticName(), node.nodeSemanticText(), r) + sk.setWidgetState( + enabled = node.semanticEnabled, + focused = node.semanticFocused, + pressed = node.semanticPressed, + hovered = node.semanticHovered, + checked = node.semanticChecked, + value = node.semanticValue + ) + node.semanticOpened = true + +proc endSemantic(sk: Silky, node: DslNode) = + if node != nil and node.semanticOpened: + sk.endWidget() + node.semanticOpened = false + +proc resetNodeRect*(node: DslNode) {.inline.} = + if node != nil: + node.resolved = false + node.interactionResolved = false + +proc newDslNode(sk: Silky, kind: DslNodeKind, id: string): DslNode = + result = DslNode( + kind: kind, + id: id, + boxRect: rect(0'f, 0'f, 0'f, 0'f), + resolvedRect: rect(0'f, 0'f, 0'f, 0'f), + fontName: sk.textStyle, + tintColor: white(), + direction: TopToBottom, + itemSpacing: sk.theme.spacing.float32, + hAlign: LeftAlign, + vAlign: TopAlign, + semanticEnabled: true + ) + case kind + of nkFrame: + result.patch = patchSpec("frame.9patch", 6) + result.clipContent = true + result.horizontalPadding = sk.theme.padding.float32 + result.verticalPadding = sk.theme.padding.float32 + else: + discard + +proc beginDsl*(sk: Silky) = + ## Starts a transient authoring stack for the current immediate frame. + root = newDslNode(sk, nkRoot, "root") + root.resolvedRect = rect(dslVec2(0, 0), sk.rootSize) + root.resolved = true + root.materialized = true + root.startedChildren = true + scopeStack.setLen(0) + scopeStack.add(root) + parent = nil + current = root + +proc endDsl*(sk: Silky) = + ## Clears the transient DSL stack. No nodes are retained. + discard sk + scopeStack.setLen(0) + root = nil + parent = nil + current = nil + +proc ensureDsl(sk: Silky) = + if scopeStack.len == 0: + sk.beginDsl() + elif root != nil: + root.resolvedRect = rect(dslVec2(0, 0), sk.rootSize) + +proc nodeTint(sk: Silky, node: DslNode): ColorRGBX = + if node.hasTint: + node.tintColor + elif node.kind == nkText: + sk.theme.textColor + else: + white() + +proc inferNodeSize(sk: Silky, node: DslNode, pos: Vec2): Vec2 = + if node.hasBox: + return node.boxRect.wh + if node.characters.len > 0: + return sk.getTextSize(node.fontName, node.characters) + if node.imageName.len > 0: + return sk.getImageSize(node.imageName) + let used = pos - sk.pos + dslVec2(max(0.0'f, sk.size.x - used.x), max(0.0'f, sk.size.y - used.y)) + +proc resolveNodeRect(sk: Silky, node: DslNode): Rect = + if node.resolved: + return node.resolvedRect + let pos = + if node.hasBox: + sk.pos + node.boxRect.xy + else: + sk.at + let size = sk.inferNodeSize(node, pos) + node.resolvedRect = rect(pos, size) + node.resolved = true + node.resolvedRect + +proc setInteractionState(node: DslNode, interaction: Interaction) = + node.semanticHovered = interaction in [Pressed, Held, Released, Hovered] + node.semanticPressed = interaction in [Pressed, Held] + +proc nodeInteraction*(sk: Silky, node: DslNode, isEnabled = true, isError = false): Interaction {.inline.} = + if node == nil or node.kind == nkRoot: + return None + if not node.interactionResolved: + node.semanticEnabled = isEnabled + let r = sk.resolveNodeRect(node) + node.interaction = sk.interact(r, isEnabled, isError) + node.setInteractionState(node.interaction) + node.interactionResolved = true + node.interaction + +proc advanceDsl(sk: Silky, owner: DslNode, amount: Vec2) = + let spacing = + if owner != nil: + owner.itemSpacing + else: + sk.theme.spacing.float32 + sk.stretchAt = max(sk.stretchAt, sk.at + amount + dslVec2(spacing)) + case sk.stackDirection + of TopToBottom: + sk.at.y += amount.y + spacing + of BottomToTop: + sk.at.y -= amount.y + spacing + of LeftToRight: + sk.at.x += amount.x + spacing + of RightToLeft: + sk.at.x -= amount.x + spacing + +proc drawNode(sk: Silky, node: DslNode, keepSemanticOpen: bool) = + let r = sk.resolveNodeRect(node) + let color = sk.nodeTint(node) + sk.beginSemantic(node, r) + if node.patch.name.len > 0: + sk.draw9Patch( + node.patch.name, + node.patch.top, + node.patch.right, + node.patch.bottom, + node.patch.left, + r.xy, + r.wh, + color + ) + if node.kind == nkRectangle and node.patch.name.len == 0 and + node.imageName.len == 0 and node.characters.len == 0 and node.hasTint: + sk.drawRect(r.xy, r.wh, color) + if node.imageName.len > 0: + sk.drawImage(node.imageName, r.xy, color) + if node.characters.len > 0: + discard sk.drawText( + node.fontName, + node.characters, + r.xy, + color, + maxWidth = r.w, + maxHeight = r.h, + hAlign = node.hAlign, + vAlign = node.vAlign + ) + node.materialized = true + if not keepSemanticOpen: + sk.endSemantic(node) + +proc finishFrameScrollbars(sk: Silky, window: auto, node: DslNode) = + let frameState = node.frameState + if frameState == nil: + return + let r = node.resolvedRect + 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 + + sk.stretchAt += dslVec2(16) + let contentSize = (sk.stretchAt + frameState.scrollPos) - node.frameOrigin + let scrollMax = max(contentSize - r.wh, dslVec2(0, 0)) + + 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 + + if sk.mousePos.overlaps(r) and sk.mousePos.overlaps(sk.clipRect): + 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) + + if contentSize.y > r.h: + let scrollbarTrackRect = rect(r.x + r.w - 10, r.y + 2, 8, r.h - 14) + sk.draw9Patch("scrollbar.track.9patch", 4, scrollbarTrackRect.xy, scrollbarTrackRect.wh) + let + scrollPosPercent = if scrollMax.y > 0: frameState.scrollPos.y / scrollMax.y else: 0.0 + scrollSizePercent = r.h / contentSize.y + scrollbarHandleRect = rect( + scrollbarTrackRect.x, + scrollbarTrackRect.y + + (scrollbarTrackRect.h - scrollbarTrackRect.h * scrollSizePercent) * + scrollPosPercent, + 8, + scrollbarTrackRect.h * scrollSizePercent + ) + if frameState.scrollingY: + let relativeY = sk.mousePos.y - 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.interact(scrollbarHandleRect, true) == Pressed: + frameState.scrollingY = true + frameState.scrollDragOffset.y = sk.mousePos.y - scrollbarHandleRect.y + sk.draw9Patch("scrollbar.9patch", 4, scrollbarHandleRect.xy, scrollbarHandleRect.wh) + + if contentSize.x > r.w: + let scrollbarTrackRect = rect(r.x + 2, r.y + r.h - 10, r.w - 14, 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 + scrollSizePercent = r.w / contentSize.x + scrollbarHandleRect = rect( + scrollbarTrackRect.x + + (scrollbarTrackRect.w - scrollbarTrackRect.w * scrollSizePercent) * + scrollPosPercent, + scrollbarTrackRect.y, + scrollbarTrackRect.w * scrollSizePercent, + 8 + ) + if frameState.scrollingX: + let relativeX = sk.mousePos.x - 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.interact(scrollbarHandleRect, true) == Pressed: + frameState.scrollingX = true + frameState.scrollDragOffset.x = sk.mousePos.x - scrollbarHandleRect.x + sk.draw9Patch("scrollbar.9patch", 4, scrollbarHandleRect.xy, scrollbarHandleRect.wh) + +proc pushChildrenLayout(sk: Silky, node: DslNode) = + if node.pushedLayout: + return + sk.drawNode(node, keepSemanticOpen = true) + let r = node.resolvedRect + if node.kind == nkFrame: + if node.id notin frameStates: + frameStates[node.id] = FrameState() + node.frameState = frameStates[node.id] + let clipRect = rect(r.x + 1, r.y + 1, max(0.0'f, r.w - 2), max(0.0'f, r.h - 2)) + sk.pushClipRect(clipRect) + node.pushedClip = true + node.frameOrigin = r.xy + dslVec2(node.horizontalPadding, node.verticalPadding) + let childSize = dslVec2( + max(0.0'f, r.w - node.horizontalPadding * 2), + max(0.0'f, r.h - node.verticalPadding * 2) + ) + sk.pushLayout(node.frameOrigin - node.frameState.scrollPos, childSize, node.direction) + else: + if node.clipContent: + sk.pushClipRect(r) + node.pushedClip = true + let childPos = r.xy + dslVec2(node.horizontalPadding, node.verticalPadding) + let childSize = dslVec2( + max(0.0'f, r.w - node.horizontalPadding * 2), + max(0.0'f, r.h - node.verticalPadding * 2) + ) + sk.pushLayout(childPos, childSize, node.direction) + node.pushedLayout = true + +proc materializeNode(sk: Silky, node: DslNode, forChildren: bool) = + if not node.materialized: + if forChildren: + sk.pushChildrenLayout(node) + else: + sk.drawNode(node, keepSemanticOpen = false) + +proc startChildren(sk: Silky, node: DslNode) = + if node == nil or node.kind == nkRoot or node.startedChildren: + return + node.startedChildren = true + sk.pushChildrenLayout(node) + +proc closeChildrenLayout(sk: Silky, window: auto, node: DslNode) = + if not node.pushedLayout: + return + if node.kind == nkFrame: + sk.finishFrameScrollbars(window, node) + sk.popLayout() + if node.pushedClip: + sk.popClipRect() + sk.endSemantic(node) + node.pushedLayout = false + node.pushedClip = false + +proc beginNode*(sk: Silky, kind: DslNodeKind, id: string) {.inline.} = + sk.ensureDsl() + let owner = scopeStack[^1] + sk.startChildren(owner) + parent = owner + current = newDslNode(sk, kind, id) + scopeStack.add(current) + +proc finishNode*(sk: Silky, window: auto) {.inline.} = + if scopeStack.len == 0: + return + let node = scopeStack[^1] + if node.kind == nkRoot: + return + if not node.startedChildren: + sk.materializeNode(node, forChildren = false) + else: + sk.closeChildrenLayout(window, node) + let amount = node.resolvedRect.wh + discard scopeStack.pop() + if scopeStack.len > 0: + current = scopeStack[^1] + parent = + if scopeStack.len > 1: + scopeStack[^2] + else: + nil + sk.advanceDsl(current, amount) + else: + current = nil + parent = nil + +proc scopeRect*(sk: Silky): Rect {.inline.} = + ## Returns the current node rect, resolving it without retaining anything. + if current == nil or current.kind == nkRoot: + return rect(0'f, 0'f, 0'f, 0'f) + sk.resolveNodeRect(current) + +proc startCurrentChildren*(sk: Silky) {.inline.} = + ## Lets direct immediate widgets participate inside the active DSL scope. + if current != nil and current.kind != nkRoot: + sk.startChildren(current) + +template ui*(body: untyped) = + ## Wraps a Silky frame in the transient Fidget-style authoring stack. + sk.beginDsl() + try: + body + finally: + sk.endDsl() + +template dslNode(kindValue: DslNodeKind, id: string, body: untyped) = + sk.beginNode(kindValue, id) + try: + body + finally: + sk.finishNode(window) + +template frame*(id: string, body: untyped) = + dslNode(nkFrame, id, body) + +template group*(id: string, body: untyped) = + dslNode(nkGroup, id, body) + +template rectangle*(id: string, body: untyped) = + dslNode(nkRectangle, id, body) + +template text*(id: string, body: untyped) = + dslNode(nkText, id, body) + +template component*(id: string, body: untyped) = + dslNode(nkComponent, id, body) + +template instance*(id: string, body: untyped) = + dslNode(nkInstance, id, body) + +proc box*[A, B, C, D: SomeNumber](x: A, y: B, w: C, h: D) {.inline.} = + if current != nil: + current.boxRect = rect(x.float32, y.float32, w.float32, h.float32) + current.hasBox = true + current.resetNodeRect() + +proc box*(r: Rect) {.inline.} = + if current != nil: + current.boxRect = r + current.hasBox = true + current.resetNodeRect() + +template box*[A, B: SomeNumber](w: A, h: B) = + box(sk.at.x - sk.pos.x, sk.at.y - sk.pos.y, w, h) + +proc font*(name: string) {.inline.} = + if current != nil: + current.fontName = name + current.resetNodeRect() + +proc characters*(value: string) {.inline.} = + if current != nil: + current.characters = value + current.resetNodeRect() + +proc setImage*(name: string) {.inline.} = + if current != nil: + current.imageName = name + current.resetNodeRect() + +proc semanticKind*(kind: string) {.inline.} = + if current != nil: + current.semanticKind = kind + +proc semanticName*(name: string) {.inline.} = + if current != nil: + current.semanticName = name + +proc semanticText*(text: string) {.inline.} = + if current != nil: + current.semanticText = text + +proc semanticState*( + enabled = true, + focused = false, + pressed = false, + hovered = false, + checked = false, + value = "" +) {.inline.} = + if current != nil: + current.semanticEnabled = enabled + current.semanticFocused = focused + current.semanticPressed = pressed + current.semanticHovered = hovered + current.semanticChecked = checked + current.semanticValue = value + +template image*(imageName: string) = + if current != nil and current.kind != nkRoot: + setImage(imageName) + else: + baseWidgets.image(imageName) + +template image*(imageName: string, imageTint: ColorRGBX) = + if current != nil and current.kind != nkRoot: + setImage(imageName) + tint(imageTint) + else: + baseWidgets.image(imageName, imageTint) + +proc clipContent*(enabled = true) {.inline.} = + if current != nil: + current.clipContent = enabled + +proc layout*(direction: StackDirection) {.inline.} = + if current != nil: + current.direction = direction + +proc horizontalPadding*(value: SomeNumber) {.inline.} = + if current != nil: + current.horizontalPadding = value.float32 + +proc verticalPadding*(value: SomeNumber) {.inline.} = + if current != nil: + current.verticalPadding = value.float32 + +proc itemSpacing*(value: SomeNumber) {.inline.} = + if current != nil: + current.itemSpacing = value.float32 + +proc patch*(name: string) {.inline.} = + if current != nil: + if name.len == 0: + current.patch = PatchSpec() + else: + current.patch = patchSpec(name, 0) + +proc patch*(name: string, border: SomeInteger) {.inline.} = + if current != nil: + if name.len == 0: + current.patch = PatchSpec() + else: + current.patch = patchSpec(name, border.int) + +proc patch*(name: string, top, right, bottom, left: SomeInteger) {.inline.} = + if current != nil: + if name.len == 0: + current.patch = PatchSpec() + else: + current.patch = patchSpec(name, top.int, right.int, bottom.int, left.int) + +proc tint*(value: ColorRGBX) {.inline.} = + if current != nil: + current.tintColor = value + current.hasTint = true + +proc tint*(value: string) {.inline.} = + tint(parseHtmlColor(value).rgbx) + +proc textAlign*(hAlign: HorizontalAlignment, vAlign: VerticalAlignment = TopAlign) {.inline.} = + if current != nil: + current.hAlign = hAlign + current.vAlign = vAlign + +template onHover*(body: untyped) = + block: + let interaction = sk.nodeInteraction(current) + if interaction in [Hovered, Pressed, Held, Released]: + sk.hover = true + body + +template onDown*(body: untyped) = + block: + let interaction = sk.nodeInteraction(current) + if interaction in [Pressed, Held]: + sk.hover = true + body + +template onClick*(body: untyped) = + block: + let interaction = sk.nodeInteraction(current) + if interaction == Released: + sk.hover = true + sk.mouseConsumed = true + body + +template onClickOutside*(body: untyped) = + block: + let eventRect = sk.scopeRect() + if window.buttonReleased[MouseLeft] and not sk.mousePos.overlaps(eventRect): + body + +template text*(value: string) = + if current != nil and current.kind != nkRoot: + text("text:" & value): + characters(value) + else: + baseWidgets.text(value) + +template h1text*(value: string) = + if current != nil and current.kind != nkRoot: + text("h1:" & value): + characters(value) + font("H1") + tint(sk.theme.textH1Color) + else: + baseWidgets.h1text(value) + +template button*(label: string, isEnabled: bool, isError: bool, body: untyped) = + block: + sk.startCurrentChildren() + baseWidgets.button(label, isEnabled, isError): + body + +template button*(label: string, body: untyped) = + block: + sk.startCurrentChildren() + baseWidgets.button(label, true, false): + body + +template button*(label: string, isEnabled: bool, body: untyped) = + block: + sk.startCurrentChildren() + baseWidgets.button(label, isEnabled, false): + body + +template icon*(imageName: string) = + block: + sk.startCurrentChildren() + baseWidgets.icon(imageName) + +template iconButton*(imageName: string, body: untyped) = + block: + sk.startCurrentChildren() + baseWidgets.iconButton(imageName): + body + +template clickableIcon*(imageName: string, on: bool, body: untyped) = + block: + sk.startCurrentChildren() + baseWidgets.clickableIcon(imageName, on): + body + +template radioButton*[T](label: string, variable: var T, value: T) = + block: + sk.startCurrentChildren() + baseWidgets.radioButton(label, variable, value) + +template checkBox*(label: string, value: var bool) = + block: + sk.startCurrentChildren() + baseWidgets.checkBox(label, value) + +template progressBar*(value: SomeNumber, minVal: SomeNumber, maxVal: SomeNumber) = + block: + sk.startCurrentChildren() + baseWidgets.progressBar(value, minVal, maxVal) + +template dropDown*[T](selected: var T, options: openArray[T]) = + block: + sk.startCurrentChildren() + baseWidgets.dropDown(selected, options) + +template scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: string = "") = + block: + sk.startCurrentChildren() + baseWidgets.scrubber(id, value, minVal, maxVal, label) + +template listBox*[T](id: string, items: seq[T], selectedIndex: var int) = + block: + sk.startCurrentChildren() + baseWidgets.listBox(id, items, selectedIndex) + +template group*(p: Vec2, direction = TopToBottom, body: untyped) = + group("group"): + box(p.x, p.y, max(0.0'f, sk.size.x - p.x), max(0.0'f, sk.size.y - p.y)) + layout(direction) + body + +template frame*(id: string, framePos, frameSize: Vec2, body: untyped) = + frame(id): + box(framePos.x, framePos.y, frameSize.x, frameSize.y) + body + +template frame*(p, s: Vec2, body: untyped) = + frame("frame"): + box(p.x, p.y, s.x, s.y) + body + +template ribbon*(p, s: Vec2, ribbonTint: ColorRGBX, body: untyped) = + rectangle("ribbon"): + box(p.x, p.y, s.x, s.y) + tint(ribbonTint) + body diff --git a/src/silky/menus.nim b/src/silky/menus.nim new file mode 100644 index 0000000..3df4c38 --- /dev/null +++ b/src/silky/menus.nim @@ -0,0 +1,309 @@ +import + vmath, bumpy, chroma, + silky/fidgetdsl + +when defined(silkyTesting): + import silky/[semantic, testing] +else: + import silky/contexts, windy + +type + MenuState* = ref object + ## Tracks which menus are open and their active hit areas. + openPath*: seq[string] + activeRects: seq[Rect] + + MenuLayout = ref object + origin: Vec2 + width: float32 + cursorY: float32 + + MenuEntryContext* = object + path*: seq[string] + popupPos*: Vec2 + popupWidth*: int + open*: bool + isRoot*: bool + + MenuItemContext* = object + layout*: MenuLayout + rowH*: float32 + clicked*: bool + +var + openMenu* = "" + menuState*: MenuState = MenuState( + openPath: @[], + activeRects: @[] + ) + menuLayouts: seq[MenuLayout] + menuPathStack: seq[string] + +proc vec2(v: SomeNumber): Vec2 = + ## Create a Vec2 from a number. + vec2(v.float32, v.float32) + +proc vec2[A, B](x: A, y: B): Vec2 = + ## Create a Vec2 from two numbers. + vec2(x.float32, y.float32) + +proc menuPathOpen(path: seq[string]): bool = + ## Check if the given menu path is currently open. + menuState.openPath.len >= path.len and menuState.openPath[0 ..< path.len] == path + +proc menuEnsureState() = + ## Initialize menu state if not already created. + if menuState.isNil: + menuState = MenuState( + openPath: @[], + activeRects: @[] + ) + +proc menuAddActive(rect: Rect) = + ## Record a rect so outside-click detection can close menus. + menuState.activeRects.add(rect) + +proc menuPointInside(rects: seq[Rect], p: Vec2): bool = + ## Check if point is inside any of the given rectangles. + for r in rects: + if p.overlaps(r): + return true + return false + +proc menuPopupStart*(sk: Silky, path: seq[string], popupAt: Vec2, popupWidth = 200) = + ## Begin a popup; caller must call menuPopupEnd. + menuEnsureState() + sk.pushLayer(PopupsLayer) + sk.pushRawClipRect(rect(vec2(0, 0), sk.rootSize)) + let layout = MenuLayout( + origin: popupAt, + width: popupWidth.float32, + cursorY: sk.theme.menuPadding.float32 + ) + menuLayouts.add(layout) + +proc menuPopupEnd*(sk: Silky) = + ## Finish a popup and record its active area. + let layout = menuLayouts[^1] + let popupHeight = layout.cursorY + sk.theme.menuPadding.float32 + menuAddActive(rect(layout.origin, vec2(layout.width, popupHeight))) + menuLayouts.setLen(menuLayouts.len - 1) + sk.popClipRect() + sk.popLayer() + +template menuPopup(path: seq[string], popupAt: Vec2, popupWidth = 200, body: untyped) = + ## Render a popup in a single pass with caller-provided width. + sk.menuPopupStart(path, popupAt, popupWidth) + try: + body + finally: + sk.menuPopupEnd() + +proc menuBarStart*(sk: Silky, window: Window) = + ## Begin the horizontal application menu bar. + menuEnsureState() + menuState.activeRects.setLen(0) + menuPathStack.setLen(0) + + let elevate = menuState.openPath.len > 0 + discard elevate + + 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) + +proc menuBarEnd*(sk: Silky, window: Window) = + ## Finish the menu bar and handle outside-click closing. + sk.popLayout() + if menuState.openPath.len > 0 and window.buttonPressed[MouseLeft]: + if not menuPointInside(menuState.activeRects, sk.mousePos): + 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() + let path = menuPathStack & @[label] + let isRoot = menuLayouts.len == 0 + var ctx = MenuEntryContext( + path: path, + popupPos: vec2(0), + popupWidth: menuWidth, + open: false, + isRoot: isRoot + ) + + 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) + menuAddActive(menuRect) + + let hover = sk.mousePos.overlaps(menuRect) + var open = menuPathOpen(path) + + if hover and window.buttonReleased[MouseLeft]: + if open: + menuState.openPath.setLen(0) + else: + menuState.openPath = path + elif hover and menuState.openPath.len > 0 and not window.buttonDown[MouseLeft]: + menuState.openPath = path + + open = menuPathOpen(path) + ctx.open = open + + 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 + + if ctx.open: + menuPathStack.add(label) + ctx.popupPos = vec2(menuRect.x, menuRect.y + menuRect.h) + else: + var layout = menuLayouts[^1] + let textSize = sk.getTextSize(sk.textStyle, label) + let rowH = textSize.y + sk.theme.menuPadding.float32 * 2 + let rowPos = vec2(layout.origin.x + sk.theme.menuPadding.float32, layout.origin.y + layout.cursorY) + let rowSize = vec2(layout.width - sk.theme.menuPadding.float32 * 2, rowH) + let itemRect = rect(rowPos, rowSize) + menuAddActive(itemRect) + + var open = menuPathOpen(path) + let hover = sk.mousePos.overlaps(itemRect) + + if hover and menuState.openPath.len >= path.len - 1: + menuState.openPath = path + + open = menuPathOpen(path) + ctx.open = open + + sk.drawRect(itemRect.xy, itemRect.wh, sk.theme.menuItemBgColor) + if hover or open: + sk.drawRect(itemRect.xy, itemRect.wh, sk.theme.menuItemHoverColor) + discard sk.drawText( + sk.textStyle, + label, + rowPos + vec2(sk.theme.textPadding), + sk.theme.defaultTextColor + ) + + let arrowPos = vec2(itemRect.x + itemRect.w - textSize.y, rowPos.y + sk.theme.textPadding.float32) + discard sk.drawText(sk.textStyle, ">", arrowPos, sk.theme.defaultTextColor) + + layout.cursorY += rowH + + if ctx.open: + menuPathStack.add(label) + ctx.popupPos = vec2(itemRect.x + itemRect.w, itemRect.y) + + return ctx + +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() + let layout = menuLayouts[^1] + + let textSize = sk.getTextSize(sk.textStyle, label) + let rowH = textSize.y + sk.theme.menuPadding.float32 * 2 + let rowPos = vec2(layout.origin.x + sk.theme.menuPadding.float32, layout.origin.y + layout.cursorY) + let rowSize = vec2(layout.width - sk.theme.menuPadding.float32 * 2, rowH) + let itemRect = rect(rowPos, rowSize) + menuAddActive(itemRect) + + let hover = sk.mousePos.overlaps(itemRect) + sk.drawRect(itemRect.xy, itemRect.wh, sk.theme.menuItemBgColor) + if hover: + sk.drawRect(itemRect.xy, itemRect.wh, sk.theme.menuPopupHoverColor) + discard sk.drawText( + sk.textStyle, + label, + rowPos + vec2(sk.theme.textPadding), + sk.theme.defaultTextColor + ) + + var clicked = false + if hover and window.buttonReleased[MouseLeft]: + menuState.openPath.setLen(0) + clicked = true + + return MenuItemContext( + layout: layout, + rowH: rowH, + clicked: clicked + ) + +proc menuItemEnd*(sk: Silky, ctx: MenuItemContext) = + ## Finish a menu item and advance layout cursor. + ctx.layout.cursorY += ctx.rowH + +template menuRoot*(label: string, width: float32) = + rectangle "menu root:" & label: + box width, 28 + tint rgbx(50, 50, 65, 180) + onHover: + tint sk.theme.menuRootHoverColor + onClick: + if openMenu == label: + openMenu = "" + else: + openMenu = label + text "menu root text:" & label: + box 10, 5, width - 20, 18 + characters label + +template menuItem*(label: string, body: untyped) = + ## Leaf menu entry that runs `body` on click. + if menuLayouts.len > 0: + let ctx = sk.menuItemStart(window, label) + try: + if ctx.clicked: + body + finally: + sk.menuItemEnd(ctx) + else: + rectangle "menu item:" & label: + box 180, 28 + tint sk.theme.menuItemBgColor + onHover: + tint sk.theme.menuItemHoverColor + onClick: + openMenu = "" + body + text "menu item text:" & label: + box 10, 5, 160, 18 + characters label + +template popupMenu*(id: string, x, y, w, h: float32, body: untyped) = + frame "popup:" & id: + box x, y, w, h + patch "dropdown.9patch", 6 + layout TopToBottom + horizontalPadding 6 + verticalPadding 6 + itemSpacing 4 + body diff --git a/src/silky/semantic.nim b/src/silky/semantic.nim index 416cf9a..3d78aab 100644 --- a/src/silky/semantic.nim +++ b/src/silky/semantic.nim @@ -2,7 +2,7 @@ import std/[strutils, tables, unicode, times], - vmath, bumpy, chroma, + vmath, bumpy, chroma, pixie, silky/atlas, testwindow from windy/common import Button, CursorKind, Cursor @@ -446,7 +446,13 @@ proc drawQuad*(sk: Silky, pos: Vec2, size: Vec2, uvPos: Vec2, uvSize: Vec2, colo ## Stub for drawing a textured quad. discard -proc drawImage*(sk: Silky, name: string, pos: Vec2, color = rgbx(255, 255, 255, 255)) {.inline.} = +proc drawImage*( + sk: Silky, + name: string, + pos: Vec2, + color = rgbx(255, 255, 255, 255), + mask = "" +) {.inline.} = ## Stub for drawing an image from the atlas. discard @@ -454,13 +460,31 @@ proc drawRect*(sk: Silky, pos: Vec2, size: Vec2, color: ColorRGBX) {.inline.} = ## Stub for drawing a solid rectangle. discard -proc drawTriangle*(sk: Silky, positions: array[3, Vec2], uvs: array[3, Vec2], colors: array[3, ColorRGBX]) {.inline.} = +proc drawTriangle*( + sk: Silky, + positions: array[3, Vec2], + uvs: array[3, Vec2], + colors: array[3, ColorRGBX], + clipPos = vec2(-1, -1), + clipSize = vec2(-1, -1) +) {.inline.} = discard proc draw9Patch*(sk: Silky, name: string, patch: int, pos: Vec2, size: Vec2, color = rgbx(255, 255, 255, 255)) {.inline.} = ## Stub for drawing a 9-patch image. discard +proc draw9Patch*( + sk: Silky, + name: string, + top, right, bottom, left: int, + pos: Vec2, + size: Vec2, + color = rgbx(255, 255, 255, 255) +) {.inline.} = + ## Stub for drawing a 9-patch image with independent border sizes. + discard + proc drawText*( sk: Silky, font: string, @@ -470,7 +494,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) diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index 754788f..24f00d7 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -38,28 +38,6 @@ type DropDownState* = ref object open*: bool - MenuState* = ref object - ## Tracks which menus are open and their active hit areas. - openPath*: seq[string] - activeRects: seq[Rect] - - MenuLayout = ref object - origin: Vec2 - width: float32 - cursorY: float32 - - MenuEntryContext* = object - path*: seq[string] - popupPos*: Vec2 - popupWidth*: int - open*: bool - isRoot*: bool - - MenuItemContext* = object - layout*: MenuLayout - rowH*: float32 - clicked*: bool - Interaction* = enum None, Pressed, @@ -75,12 +53,6 @@ var frameStates*: Table[string, FrameState] scrubberStates*: Table[string, ScrubberState] dropDownStates*: Table[string, DropDownState] - menuState*: MenuState = MenuState( - openPath: @[], - activeRects: @[] - ) - menuLayouts: seq[MenuLayout] - menuPathStack: seq[string] proc mouseHover( interactor: var Interactor, @@ -134,29 +106,6 @@ proc interact*( return Released return Hovered -proc menuPathOpen(path: seq[string]): bool = - ## Check if the given menu path is currently open. - menuState.openPath.len >= path.len and menuState.openPath[0 ..< path.len] == path - -proc menuEnsureState() = - ## Initialize menu state if not already created. - if menuState.isNil: - menuState = MenuState( - openPath: @[], - activeRects: @[] - ) - -proc menuAddActive(rect: Rect) = - ## Record a rect so outside-click detection can close menus. - menuState.activeRects.add(rect) - -proc menuPointInside(rects: seq[Rect], p: Vec2): bool = - ## Check if point is inside any of the given rectangles. - for r in rects: - if p.overlaps(r): - return true - return false - proc vec2(v: SomeNumber): Vec2 = ## Create a Vec2 from a number. vec2(v.float32, v.float32) @@ -947,206 +896,6 @@ template scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: sk.drawImage("scrubber.handle", handlePos2) sk.advance(vec2(width, height)) -proc menuPopupStart*(sk: Silky, path: seq[string], popupAt: Vec2, popupWidth = 200) = - ## Begin a popup; caller must call menuPopupEnd. - menuEnsureState() - sk.pushLayer(PopupsLayer) - sk.pushRawClipRect(rect(vec2(0, 0), sk.rootSize)) - let layout = MenuLayout( - origin: popupAt, - width: popupWidth.float32, - cursorY: sk.theme.menuPadding.float32 - ) - menuLayouts.add(layout) - -proc menuPopupEnd*(sk: Silky) = - ## Finish a popup and record its active area. - let layout = menuLayouts[^1] - let popupHeight = layout.cursorY + sk.theme.menuPadding.float32 - menuAddActive(rect(layout.origin, vec2(layout.width, popupHeight))) - menuLayouts.setLen(menuLayouts.len - 1) - sk.popClipRect() - sk.popLayer() - -template menuPopup(path: seq[string], popupAt: Vec2, popupWidth = 200, body: untyped) = - ## Render a popup in a single pass with caller-provided width. - sk.menuPopupStart(path, popupAt, popupWidth) - try: - body - finally: - sk.menuPopupEnd() - -proc menuBarStart*(sk: Silky, window: Window) = - ## Begin the horizontal application menu bar. - menuEnsureState() - menuState.activeRects.setLen(0) - menuPathStack.setLen(0) - - let elevate = menuState.openPath.len > 0 - discard elevate - - 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) - -proc menuBarEnd*(sk: Silky, window: Window) = - ## Finish the menu bar and handle outside-click closing. - sk.popLayout() - if menuState.openPath.len > 0 and window.buttonPressed[MouseLeft]: - if not menuPointInside(menuState.activeRects, sk.mousePos): - 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() - let path = menuPathStack & @[label] - let isRoot = menuLayouts.len == 0 - var ctx = MenuEntryContext( - path: path, - popupPos: vec2(0), - popupWidth: menuWidth, - open: false, - isRoot: isRoot - ) - - 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) - menuAddActive(menuRect) - - let hover = sk.mousePos.overlaps(menuRect) - var open = menuPathOpen(path) - - if hover and window.buttonReleased[MouseLeft]: - if open: - menuState.openPath.setLen(0) - else: - menuState.openPath = path - elif hover and menuState.openPath.len > 0 and not window.buttonDown[MouseLeft]: - menuState.openPath = path - - open = menuPathOpen(path) - ctx.open = open - - 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 - - if ctx.open: - menuPathStack.add(label) - ctx.popupPos = vec2(menuRect.x, menuRect.y + menuRect.h) - else: - var layout = menuLayouts[^1] - let textSize = sk.getTextSize(sk.textStyle, label) - let rowH = textSize.y + sk.theme.menuPadding.float32 * 2 - let rowPos = vec2(layout.origin.x + sk.theme.menuPadding.float32, layout.origin.y + layout.cursorY) - let rowSize = vec2(layout.width - sk.theme.menuPadding.float32 * 2, rowH) - let itemRect = rect(rowPos, rowSize) - menuAddActive(itemRect) - - var open = menuPathOpen(path) - let hover = sk.mousePos.overlaps(itemRect) - - if hover and menuState.openPath.len >= path.len - 1: - menuState.openPath = path - - open = menuPathOpen(path) - ctx.open = open - - sk.drawRect(itemRect.xy, itemRect.wh, sk.theme.menuItemBgColor) - if hover or open: - sk.drawRect(itemRect.xy, itemRect.wh, sk.theme.menuItemHoverColor) - discard sk.drawText( - sk.textStyle, - label, - rowPos + vec2(sk.theme.textPadding), - sk.theme.defaultTextColor - ) - - let arrowPos = vec2(itemRect.x + itemRect.w - textSize.y, rowPos.y + sk.theme.textPadding.float32) - discard sk.drawText(sk.textStyle, ">", arrowPos, sk.theme.defaultTextColor) - - layout.cursorY += rowH - - if ctx.open: - menuPathStack.add(label) - ctx.popupPos = vec2(itemRect.x + itemRect.w, itemRect.y) - - return ctx - -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() - let layout = menuLayouts[^1] - - let textSize = sk.getTextSize(sk.textStyle, label) - let rowH = textSize.y + sk.theme.menuPadding.float32 * 2 - let rowPos = vec2(layout.origin.x + sk.theme.menuPadding.float32, layout.origin.y + layout.cursorY) - let rowSize = vec2(layout.width - sk.theme.menuPadding.float32 * 2, rowH) - let itemRect = rect(rowPos, rowSize) - menuAddActive(itemRect) - - let hover = sk.mousePos.overlaps(itemRect) - sk.drawRect(itemRect.xy, itemRect.wh, sk.theme.menuItemBgColor) - if hover: - sk.drawRect(itemRect.xy, itemRect.wh, sk.theme.menuPopupHoverColor) - discard sk.drawText( - sk.textStyle, - label, - rowPos + vec2(sk.theme.textPadding), - sk.theme.defaultTextColor - ) - - var clicked = false - if hover and window.buttonReleased[MouseLeft]: - menuState.openPath.setLen(0) - clicked = true - - return MenuItemContext( - layout: layout, - rowH: rowH, - clicked: clicked - ) - -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) = ## Draws a tooltip with fade-in and stem image. let tooltipText = text