From 76ea01d2ce6686ab63be9f68525ed78ca4436d34 Mon Sep 17 00:00:00 2001 From: treeform Date: Sat, 14 Feb 2026 06:42:03 -0800 Subject: [PATCH 01/22] Layout ideas doc. --- docs/center.png | Bin 0 -> 13218 bytes docs/layout.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++++ docs/pen.png | Bin 0 -> 27683 bytes docs/size.png | Bin 0 -> 14001 bytes docs/stack.png | Bin 0 -> 14658 bytes 5 files changed, 85 insertions(+) create mode 100644 docs/center.png create mode 100644 docs/layout.md create mode 100644 docs/pen.png create mode 100644 docs/size.png create mode 100644 docs/stack.png diff --git a/docs/center.png b/docs/center.png new file mode 100644 index 0000000000000000000000000000000000000000..2f880398ede93e0d23cf751d726b9cd282287aa9 GIT binary patch literal 13218 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 literal 0 HcmV?d00001 diff --git a/docs/layout.md b/docs/layout.md new file mode 100644 index 0000000..5d6ff2d --- /dev/null +++ b/docs/layout.md @@ -0,0 +1,85 @@ +# 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 not possible in immediate mode UIs. 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. + +## Centering. + +A particularly tricky part of immediate mode UI is **centering**. Centering only works when both sizes are known, the size of the parent and the size of the child. Only when both dimensions are available can centering primitives be applied correctly. + +![Centering](center.png) + +## 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 0000000000000000000000000000000000000000..e173ac491213109a68bc7053e983504330a4ba8d GIT binary patch literal 27683 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 E1Ns!DUjP6A literal 0 HcmV?d00001 From eef538235fbad013a9b40040a1b216e4319e0b6d Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 15 Feb 2026 12:28:10 -0800 Subject: [PATCH 02/22] f --- docs/layout.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/layout.md b/docs/layout.md index 5d6ff2d..bb768a4 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -14,7 +14,7 @@ There is also a second conceptual pen, the "stretch pen." When you place a child ## Stretch and sizing layouts. -Stretch to fit layouts that require knowing the sizes of all children in advance are not possible in immediate mode UIs. 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 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) @@ -54,11 +54,6 @@ There is also the concept of **anchoring**, which determines where stacking begi 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. -## Centering. - -A particularly tricky part of immediate mode UI is **centering**. Centering only works when both sizes are known, the size of the parent and the size of the child. Only when both dimensions are available can centering primitives be applied correctly. - -![Centering](center.png) ## Performance considerations. From a70d952624ad8a151236f539fb5d65f6e50b090f Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 15 Feb 2026 12:35:54 -0800 Subject: [PATCH 03/22] f --- src/silky/widgets.nim | 264 ++++++++++++++++++++++++------------------ 1 file changed, 150 insertions(+), 114 deletions(-) diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index 68b9a73..d538272 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -233,32 +233,6 @@ 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] = ## Begin a scrollable frame; returns state and origin for cleanup. if id notin frameStates: @@ -382,17 +356,7 @@ proc frameEnd*(sk: Silky, window: Window, frameState: FrameState, originPos: Vec 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) = +proc button*(sk: Silky, label: string, isEnabled: bool, isError: bool, body: untyped) = let textSize = sk.getTextSize(sk.textStyle, label) buttonSize = textSize + vec2(sk.theme.padding) * 2 @@ -440,21 +404,13 @@ template button*(label: string, isEnabled: bool, isError: bool, body: untyped) = 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.advance(vec2(imageSize.x, imageSize.y)) -template iconButton*(image: string, body) = +proc iconButton*(image: string, body) = ## Create an icon button. let m2 = vec2(8, 8) @@ -475,7 +431,7 @@ template iconButton*(image: string, body) = sk.stretchAt = max(sk.stretchAt, sk.at + s2) sk.at += vec2(32 + sk.padding, 0) -template clickableIcon*(image: string, on: bool, body) = +proc clickableIcon*(image: string, on: bool, body) = ## Create an clickable icon with no background and no padding. let imageSize = sk.getImageSize(image) @@ -505,7 +461,7 @@ template clickableIcon*(image: string, on: bool, body) = 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, label: string, variable: var T, value: T) = ## Radio button. let iconSize = sk.getImageSize("radio.on") @@ -534,7 +490,7 @@ template radioButton*[T](label: string, variable: var T, value: T) = sk.advance(vec2(width, height)) -template checkBox*(label: string, value: var bool) = +proc checkBox*(sk: Silky, label: string, value: var bool) = ## Checkbox. let iconSize = sk.getImageSize("check.on") @@ -562,7 +518,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, 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: @@ -640,7 +596,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, 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 @@ -668,7 +624,7 @@ template listBox*[T](id: string, items: seq[T], selectedIndex: var int) = sk.advance(vec2(itemWidth, rowHeight - sk.theme.spacing.float32)) 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 @@ -700,14 +656,6 @@ proc groupEnd*(sk: Silky) = 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) @@ -717,14 +665,6 @@ proc frameEnd*(sk: Silky) = ## Finish a simple frame. sk.popLayout() -template frame*(p, s: Vec2, body: untyped) = - ## Create a frame. - sk.frameStart(p, s) - try: - body - finally: - sk.frameEnd() - proc ribbonStart*(sk: Silky, p, s: Vec2, tint: ColorRGBX) = ## Begin a ribbon. sk.pushLayout(p, s) @@ -735,25 +675,13 @@ 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 -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)) sk.beginWidget("Text", text = t, rect = textRect) @@ -761,12 +689,12 @@ template text*(t: string) = 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) sk.advance(textSize) -template scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: string = "") = + scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: string = "") = ## Draggable scrubber that spans available width and advances layout. let minF = minVal.float32 @@ -888,14 +816,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() @@ -980,17 +900,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,16 +938,7 @@ 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, text: string) = ## Display a tooltip at the mouse cursor. ## This should be called after a widget when sk.showTooltip is true. let tooltipText = text @@ -1070,3 +970,139 @@ 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 = TopToBottom, body) = + ## Create a group. + sk.groupStart(p, direction) + try: + body + finally: + sk.groupEnd() + +template frame*(p, s: Vec2, body: untyped) = + ## Create a frame. + sk.frameStart(p, s) + try: + body + finally: + sk.frameEnd() + +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 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, 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) = + sk.icon(image) + +template clickableIcon*(image: string, on: bool, body) = + sk.clickableIcon(image, on, body) + +template iconButton*(image: string, body) = + sk.iconButton(image, body) + +template radioButton*[T](label: string, variable: var T, value: T) = + sk.radioButton(label, variable, value) + +template checkBox*(label: string, value: var bool) = + sk.checkBox(label, value) + +template listBox*[T](id: string, items: seq[T], selectedIndex: var int) = + sk.listBox(id, items, selectedIndex) + +template dropDown*[T](selected: var T, options: openArray[T]) = + sk.dropDown(selected, options) + +template scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: string = "") = + sk.scrubber(id, value, minVal, maxVal, label) + +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 tooltip*(text: string) = + sk.tooltip(text) + From 31d401ddb9aa79541a5800baa8eae4e5d302b4df Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 15 Feb 2026 15:30:08 -0800 Subject: [PATCH 04/22] Make templates simple, have real procs. --- src/silky/widgets.nim | 144 ++++++++++++++++++++++++------------------ 1 file changed, 81 insertions(+), 63 deletions(-) diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index d538272..588d36e 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -32,8 +32,16 @@ type scrollingY*: bool scrollDragOffset*: Vec2 + ButtonState* = ref object + clicked*: bool + size*: Vec2 + rect*: Rect + hover*: bool + pressed*: bool + ScrubberState* = ref object dragging*: bool + DropDownState* = ref object open*: bool @@ -356,23 +364,14 @@ proc frameEnd*(sk: Silky, window: Window, frameState: FrameState, originPos: Vec sk.popLayout() sk.popClipRect() -proc button*(sk: Silky, 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.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: @@ -382,27 +381,30 @@ proc button*(sk: Silky, label: string, isEnabled: bool, isError: bool, body: unt 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.clicked.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) + "button.disabled.9patch" + sk.draw9Patch(patch, 8, sk.at, buttonState.size) discard sk.drawText(sk.textStyle, label, sk.at + vec2(sk.theme.padding), textColor) - - sk.setWidgetState(enabled = isEnabled, pressed = pressed, hovered = hover) + sk.setWidgetState(enabled = isEnabled, hovered = buttonState.hover, pressed = buttonState.pressed) sk.endWidget() - - sk.advance(buttonSize + vec2(sk.theme.padding)) + sk.advance(buttonState.size + vec2(sk.theme.padding)) proc icon*(sk: Silky, image: string) = ## Draw an icon. @@ -410,7 +412,7 @@ proc icon*(sk: Silky, image: string) = sk.drawImage(image, sk.at) sk.advance(vec2(imageSize.x, imageSize.y)) -proc iconButton*(image: string, body) = +proc iconButton*(sk: Silky, window: Window, image: string): bool = ## Create an icon button. let m2 = vec2(8, 8) @@ -419,7 +421,7 @@ proc iconButton*(image: string, body) = 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) else: @@ -431,8 +433,8 @@ proc iconButton*(image: string, body) = sk.stretchAt = max(sk.stretchAt, sk.at + s2) sk.at += vec2(32 + sk.padding, 0) -proc 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 @@ -443,7 +445,7 @@ proc clickableIcon*(image: string, on: bool, body) = if sk.mouseInsideClip(window, rect(sk.at, s2)): sk.hover = true if window.buttonReleased[MouseLeft]: - body + result = true elif window.buttonDown[MouseLeft]: color = upColor else: @@ -457,11 +459,10 @@ proc clickableIcon*(image: string, on: bool, body) = color = onColor else: color = offColor - sk.drawImage(image, sk.at, color) sk.at += vec2(imageSize.x, 0) -proc radioButton*[T](sk: Silky, label: string, variable: var T, value: T) = +proc radioButton*[T](sk: Silky, window: Window, label: string, variable: var T, value: T) = ## Radio button. let iconSize = sk.getImageSize("radio.on") @@ -490,7 +491,7 @@ proc radioButton*[T](sk: Silky, label: string, variable: var T, value: T) = sk.advance(vec2(width, height)) -proc checkBox*(sk: Silky, label: string, value: var bool) = +proc checkBox*(sk: Silky, window: Window, label: string, value: var bool) = ## Checkbox. let iconSize = sk.getImageSize("check.on") @@ -518,7 +519,7 @@ proc checkBox*(sk: Silky, label: string, value: var bool) = sk.advance(vec2(width, height)) -proc dropDown*[T](sk: Silky, 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: @@ -596,7 +597,7 @@ proc dropDown*[T](sk: Silky, selected: var T, options: openArray[T]) = sk.popClipRect() sk.popLayer() -proc listBox*[T](sk: Silky, 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 @@ -604,24 +605,26 @@ proc listBox*[T](sk: Silky, 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.at, vec2(outerWidth, listHeight))) + let frameCtx = sk.frameStart(id, sk.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) - 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)) proc progressBar*(sk: Silky, value: SomeNumber, minVal: SomeNumber, maxVal: SomeNumber) = @@ -694,7 +697,7 @@ proc h1text*(sk: Silky, t: string) = let textSize = sk.drawText("H1", t, sk.at, sk.theme.textH1Color) sk.advance(textSize) - scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: string = "") = +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 @@ -938,9 +941,8 @@ proc menuItemEnd*(sk: Silky, ctx: MenuItemContext) = ## Finish a menu item and advance layout cursor. ctx.layout.cursorY += ctx.rowH -proc tooltip*(sk: Silky, 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)) @@ -1061,37 +1063,52 @@ template subMenu*(label: string, menuWidth = 200, body: untyped) = 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. - button(label, true, false, body) + if sk.button(window, label, true, false): + body template button*(label: string, isEnabled: bool, body: untyped) = - ## Create a button. - button(label, isEnabled, false, body) + ## Create a button with enabled state. + if sk.buttonStart(window, label, isEnabled, false): + body template icon*(image: string) = sk.icon(image) -template clickableIcon*(image: string, on: bool, body) = - sk.clickableIcon(image, on, body) +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) = - sk.iconButton(image, 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) = - sk.radioButton(label, variable, value) + sk.radioButton(window, label, variable, value) template checkBox*(label: string, value: var bool) = - sk.checkBox(label, value) + sk.checkBox(window, label, value) template listBox*[T](id: string, items: seq[T], selectedIndex: var int) = - sk.listBox(id, items, selectedIndex) + sk.listBox(window, id, items, selectedIndex) template dropDown*[T](selected: var T, options: openArray[T]) = - sk.dropDown(selected, options) + sk.dropDown(window, selected, options) template scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: string = "") = - sk.scrubber(id, value, minVal, maxVal, label) + 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. @@ -1104,5 +1121,6 @@ template h1text*(t: string) = sk.h1text(t) template tooltip*(text: string) = - sk.tooltip(text) + ## Display a tooltip at the mouse cursor. + sk.tooltip(window, text) From 83047b614f21a54f630cbd1a6f2878fc1816dbf1 Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 15 Feb 2026 15:36:31 -0800 Subject: [PATCH 05/22] fix tests --- src/silky/widgets.nim | 4 +- tests/run_all.nim | 87 +++++++++++++++++++++---------------------- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index 588d36e..41e9338 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -392,7 +392,7 @@ proc button*(sk: Silky, window: Window, label: string, isEnabled: bool, isError: "button.error.9patch" else: if result: - "button.clicked.9patch" + "button.down.9patch" else: "button.9patch" else: @@ -1075,7 +1075,7 @@ template button*(label: string, body: untyped) = template button*(label: string, isEnabled: bool, body: untyped) = ## Create a button with enabled state. - if sk.buttonStart(window, label, isEnabled, false): + if sk.button(window, label, isEnabled, false): body template icon*(image: string) = 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() From f385067c4e90083e26ec579fc27e07b623c5e630 Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 15 Feb 2026 15:40:27 -0800 Subject: [PATCH 06/22] Fix word wrap for words larger then max width. --- src/silky/drawing.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/silky/drawing.nim b/src/silky/drawing.nim index 9423ab0..e5ea5c6 100644 --- a/src/silky/drawing.nim +++ b/src/silky/drawing.nim @@ -399,7 +399,7 @@ 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. currentPos.x = pos.x From b7b0522b37c82c612e029e7ba8f30ae85eb35450 Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 15 Feb 2026 15:58:39 -0800 Subject: [PATCH 07/22] 8 stack directions. --- src/silky/drawing.nim | 20 ++++- src/silky/semantic.nim | 20 ++++- tests/data/droparrow.png | Bin 0 -> 436 bytes tests/data/dropdown.9patch.png | Bin 0 -> 1401 bytes tests/manual_layout.nim | 153 +++++++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 tests/data/droparrow.png create mode 100644 tests/data/dropdown.9patch.png create mode 100644 tests/manual_layout.nim diff --git a/src/silky/drawing.nim b/src/silky/drawing.nim index e5ea5c6..7261e37 100644 --- a/src/silky/drawing.nim +++ b/src/silky/drawing.nim @@ -25,6 +25,10 @@ type BottomToTop LeftToRight RightToLeft + RightTopToBottom + RightBottomToTop + BottomLeftToRight + BottomRightToLeft Theme* = object ## Theme for the Silky UI. @@ -148,6 +152,14 @@ proc pushLayout*( sk.at = pos of RightToLeft: sk.at = pos + vec2(size.x, 0) + of RightTopToBottom: + sk.at = pos + vec2(size.x, 0) + of RightBottomToTop: + sk.at = pos + vec2(size.x, size.y) + of BottomLeftToRight: + sk.at = pos + vec2(0, size.y) + of BottomRightToLeft: + sk.at = pos + vec2(size.x, size.y) proc popLayout*(sk: Silky) = ## Pop the current layout container from the stack. @@ -195,13 +207,13 @@ 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: + of TopToBottom, RightTopToBottom: sk.at.y += amount.y + sk.theme.spacing.float32 - of BottomToTop: + of BottomToTop, RightBottomToTop: sk.at.y -= amount.y + sk.theme.spacing.float32 - of LeftToRight: + of LeftToRight, BottomLeftToRight: sk.at.x += amount.x + sk.theme.spacing.float32 - of RightToLeft: + of RightToLeft, BottomRightToLeft: sk.at.x -= amount.x + sk.theme.spacing.float32 proc getImageSize*(sk: Silky, image: string): Vec2 = diff --git a/src/silky/semantic.nim b/src/silky/semantic.nim index 9f04f01..427354c 100644 --- a/src/silky/semantic.nim +++ b/src/silky/semantic.nim @@ -211,6 +211,10 @@ type BottomToTop LeftToRight RightToLeft + RightTopToBottom + RightBottomToTop + BottomLeftToRight + BottomRightToLeft Theme* = object ## Visual theme settings for widgets. @@ -304,6 +308,10 @@ proc pushLayout*(sk: Silky, pos: Vec2, size: Vec2, direction: StackDirection = T of BottomToTop: sk.at = pos + vec2(0, size.y) of LeftToRight: sk.at = pos of RightToLeft: sk.at = pos + vec2(size.x, 0) + of RightTopToBottom: sk.at = pos + vec2(size.x, 0) + of RightBottomToTop: sk.at = pos + vec2(size.x, size.y) + of BottomLeftToRight: sk.at = pos + vec2(0, size.y) + of BottomRightToLeft: sk.at = pos + vec2(size.x, size.y) proc popLayout*(sk: Silky) = ## Pops the current layout region from the stack. @@ -344,10 +352,14 @@ 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 + of TopToBottom, RightTopToBottom: + sk.at.y += amount.y + sk.theme.spacing.float32 + of BottomToTop, RightBottomToTop: + sk.at.y -= amount.y + sk.theme.spacing.float32 + of LeftToRight, BottomLeftToRight: + sk.at.x += amount.x + sk.theme.spacing.float32 + of RightToLeft, BottomRightToLeft: + sk.at.x -= amount.x + sk.theme.spacing.float32 proc getImageSize*(sk: Silky, image: string): Vec2 = ## Returns the size of an image from the atlas. diff --git a/tests/data/droparrow.png b/tests/data/droparrow.png new file mode 100644 index 0000000000000000000000000000000000000000..333555aafab4d568cdbd553eb3707b4b03879df8 GIT binary patch literal 436 zcmV;l0ZaagP)MjI4B2{b`QLIDsU(E||y6+j3QBE|>|$9eb%|A)ux-LpV) z=8-Ld=0sMp>o&N=-Ndx8OMz&z?`U0DZ6pZgg>6a;(iMysJq0 znUBFxy5lsOlNI|%c>ouZk2CNn(+5=P3LL2f8(!NH9ejaz znOwz5V$2bE0dF#S$R@7ilvpUXSAPZ9GJ7TVeaF60N@@{ORvybrZXIlb0k~rKA;m+U z>d#`MqTK3vAMAj0?$o4pN}3hB1=^pblIhQ6*Hkl)|0`~HZf{|WwB`6yUODMPakRkv eGP)K~#7FwOGAx z97Pm=Gq-nk?8N!7gOE5rLIlWBuA!p_k`M`K5Y#jjSPBx95CVv_NJJPx3jP2J3XlRe z)N~Zch$xC661EHA>|mVBeeMkJ&Ac}=yLU&(g{=L%*?IGJ-sjA|S!JzN0H6^SM3qrv z(0ztb2`ukcjg8ICM`%t}m>z#Dc2sIGBn&oo zU1Qz-;rT=BCr&)|&GLm0zC!YEh(8g##G)`9vuypnmoBY5@ygPf_dC78{PgS`WVI=j zr4h;tEeRi~OFEb+(eJjQ+it;BrkWR*FPuGn`sDXG{5yGwlsG^hKpd+ddF-=+t;|pF z-49x8CqX&rXk}0)T3d*U@(SR*fz`SngROz~?ha(iG{64xi={^6*bm6PtvM72Up@2s zv)zH6-?PsvpmYUWL?y#}?Ld7}h-fnAXmVfC@mcpA^D&J;^LKjvg}2^$?DGW8{+;Q;9 zUzL?H)7;*!S8LNOxEPAq65V@6C9R8sjIq*M>q1C{Mm*JO4K_A59|gbqvJArVlvM^% z0nL+2ypubtgyKCA$CIVx{x$ZDYso84ALnJqOsSA*_OMo*bR;-Vra1W&>lT$PBx9;z zNMz6EkSfT}s<7Unl1Nni$HEHFvCXpV?9j3Zm*th=@5L$JvsT1MBb?h(v+vF&27q{_ zIEo0qw*~J*F$4b1@>^f=*%8w?UrA2L_U46K8wvl*WqF?)PI5>Th|8aoM+N4|74bqD zs-#pRU-skV1r>b|7sF&J6T@iB+NIjKwd7y7ql~tbD^Qd|3YKAIc?$-6XW{Tm zmL-mvOFSWx6BWa%hDk3QHVQwN9^(;N9U%vYBVs2GiF+>DaV`IqMd(D@*ez?)eZtoi zi*h#xSoHn}Q@r_6c`B@a32dMJh-iAe8*!l`dH<1#eo& z*jNmQLjcd|<~0Rla*|C-&`GH0WE!l9sp_GuW+g1dEDsM$?MR`r1ggv}z#j)Y;qbGz zw)Xfl&%N+*-&FAIF~=pp)QV|sSxmTcxew)IU=j*apo@l9Ro+~`cI9Op{u%i2L!Q`e zG#XbfE`RuDrt{{_oh|5fZldOWXQ8)%TC_GdjNHL_A@^3=Fz$IhZQ$miV_@t0A9(u8 zw?6&kqqE@8qbatJ#_t%*%)&-on_515r zk3Mu{?fm(3pC4abybS(8OgYgdCCD4{O=1sX4nZM02>w$6(WUq)$3yyd%vkzqfULgG zpwqZ~8tAKAd?cwcJ*m?YlcO+z;XPTDavwqdCdF+(iG(iW{0G700000NkvXX Hu0mjf`K6$; literal 0 HcmV?d00001 diff --git a/tests/manual_layout.nim b/tests/manual_layout.nim new file mode 100644 index 0000000..17434b8 --- /dev/null +++ b/tests/manual_layout.nim @@ -0,0 +1,153 @@ +## Demonstrates layout stacking directions with adjustable padding, spacing, +## and number of boxes. Use the controls at the top to tweak values and pick +## a stacking direction from the dropdown. 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 + AreaBgColor = parseHtmlColor("#2a2a3e").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, + ] + +let sk = newSilky("tests/dist/atlas.png", "tests/dist/atlas.json") + +var + layoutPadding = 16.0f + layoutSpacing = 8.0f + numBoxes = 5.0f + boxSize = 48.0f + direction = "Left, Top to Bottom" + +const Directions = [ + "Left, Top to Bottom", + "Left, Bottom to Top", + "Top, Left to Right", + "Top, Right to Left", + "Right, Top to Bottom", + "Right, Bottom to Top", + "Bottom, Left to Right", + "Bottom, Right to Left", +] + +proc toStackDirection(s: string): StackDirection = + ## Convert a direction label to a StackDirection enum. + case s: + of "Left, Top to Bottom": TopToBottom + of "Left, Bottom to Top": BottomToTop + of "Top, Left to Right": LeftToRight + of "Top, Right to Left": RightToLeft + of "Right, Top to Bottom": RightTopToBottom + of "Right, Bottom to Top": RightBottomToTop + of "Bottom, Left to Right": BottomLeftToRight + of "Bottom, Right to Left": BottomRightToLeft + else: TopToBottom + +window.onFrame = proc() = + sk.beginUI(window, window.size) + sk.clearScreen(BackgroundColor) + + const Margin = 20.0f + + sk.at = vec2(Margin, Margin) + + # Title. + h1text("Layout Test") + + # Controls. + scrubber("padding", layoutPadding, 0.0, 60.0, &"Padding: {layoutPadding:.0f}") + scrubber("spacing", layoutSpacing, 0.0, 40.0, &"Spacing: {layoutSpacing:.0f}") + scrubber("numBoxes", numBoxes, 1.0, 10.0, &"Boxes: {numBoxes:.0f}") + scrubber("boxSize", boxSize, 16.0, 120.0, &"Box size: {boxSize:.0f}") + text("Direction:") + dropDown(direction, Directions) + + # Layout area. + let + controlsBottom = sk.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 = direction.toStackDirection() + n = numBoxes.int + bs = boxSize + + # Draw area background. + sk.drawRect(areaPos, areaSize, AreaBgColor) + + # Push a layout inside the area with padding applied. + sk.pushLayout(areaPos + vec2(pad, pad), areaSize - vec2(pad * 2, pad * 2), stackDir) + let savedSpacing = sk.theme.spacing + sk.theme.spacing = layoutSpacing.int + + for i in 0 ..< n: + let color = BoxColors[i mod BoxColors.len] + let drawPos = + case stackDir: + of TopToBottom, LeftToRight: + sk.at + of BottomToTop: + sk.at - vec2(0, bs) + of RightToLeft: + sk.at - vec2(bs, 0) + of RightTopToBottom: + sk.at - vec2(bs, 0) + of BottomLeftToRight: + sk.at - vec2(0, bs) + of RightBottomToTop, BottomRightToLeft: + sk.at - vec2(bs, bs) + sk.drawRect(drawPos, vec2(bs, bs), color) + discard sk.drawText( + "Default", + $(i + 1), + drawPos + vec2(bs * 0.5 - 5, bs * 0.5 - 9), + rgbx(255, 255, 255, 255) + ) + sk.advance(vec2(bs, bs)) + + sk.theme.spacing = savedSpacing + sk.popLayout() + + # Frame time. + let ms = sk.avgFrameTime * 1000 + sk.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() From f868caf31b08e1dc81b255de3f6867e3782b34eb Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 15 Feb 2026 16:05:03 -0800 Subject: [PATCH 08/22] box sizes --- tests/manual_layout.nim | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/manual_layout.nim b/tests/manual_layout.nim index 17434b8..114215f 100644 --- a/tests/manual_layout.nim +++ b/tests/manual_layout.nim @@ -37,6 +37,18 @@ const 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") @@ -44,7 +56,6 @@ var layoutPadding = 16.0f layoutSpacing = 8.0f numBoxes = 5.0f - boxSize = 48.0f direction = "Left, Top to Bottom" const Directions = [ @@ -86,7 +97,6 @@ window.onFrame = proc() = scrubber("padding", layoutPadding, 0.0, 60.0, &"Padding: {layoutPadding:.0f}") scrubber("spacing", layoutSpacing, 0.0, 40.0, &"Spacing: {layoutSpacing:.0f}") scrubber("numBoxes", numBoxes, 1.0, 10.0, &"Boxes: {numBoxes:.0f}") - scrubber("boxSize", boxSize, 16.0, 120.0, &"Box size: {boxSize:.0f}") text("Direction:") dropDown(direction, Directions) @@ -100,7 +110,6 @@ window.onFrame = proc() = pad = layoutPadding stackDir = direction.toStackDirection() n = numBoxes.int - bs = boxSize # Draw area background. sk.drawRect(areaPos, areaSize, AreaBgColor) @@ -112,28 +121,29 @@ window.onFrame = proc() = for i in 0 ..< n: let color = BoxColors[i mod BoxColors.len] + let sz = BoxSizes[i mod BoxSizes.len] let drawPos = case stackDir: of TopToBottom, LeftToRight: sk.at of BottomToTop: - sk.at - vec2(0, bs) + sk.at - vec2(0, sz.y) of RightToLeft: - sk.at - vec2(bs, 0) + sk.at - vec2(sz.x, 0) of RightTopToBottom: - sk.at - vec2(bs, 0) + sk.at - vec2(sz.x, 0) of BottomLeftToRight: - sk.at - vec2(0, bs) + sk.at - vec2(0, sz.y) of RightBottomToTop, BottomRightToLeft: - sk.at - vec2(bs, bs) - sk.drawRect(drawPos, vec2(bs, bs), color) + sk.at - vec2(sz.x, sz.y) + sk.drawRect(drawPos, sz, color) discard sk.drawText( "Default", $(i + 1), - drawPos + vec2(bs * 0.5 - 5, bs * 0.5 - 9), + drawPos + vec2(sz.x * 0.5 - 5, sz.y * 0.5 - 9), rgbx(255, 255, 255, 255) ) - sk.advance(vec2(bs, bs)) + sk.advance(sz) sk.theme.spacing = savedSpacing sk.popLayout() From 42b50795bcb52994b326d62e80682f034a738af2 Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 15 Feb 2026 16:16:46 -0800 Subject: [PATCH 09/22] common --- src/silky.nim | 8 +-- src/silky/common.nim | 53 ++++++++++++++++++ src/silky/drawing.nim | 115 +++++++++++++++------------------------ src/silky/semantic.nim | 116 ++++++++++++++-------------------------- src/silky/widgets.nim | 26 +++++++-- tests/manual_layout.nim | 79 +++++++++++---------------- 6 files changed, 191 insertions(+), 206 deletions(-) create mode 100644 src/silky/common.nim diff --git a/src/silky.nim b/src/silky.nim index 2a94b6b..7bc2bcd 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] + export semantic, atlas, widgets, tables, textboxes, testing, common 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] + export opengl, windy, drawing, atlas, widgets, tables, textboxes, common diff --git a/src/silky/common.nim b/src/silky/common.nim new file mode 100644 index 0000000..bf07982 --- /dev/null +++ b/src/silky/common.nim @@ -0,0 +1,53 @@ +import pixie, vmath, chroma + + +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 7261e37..b947399 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 when defined(profile): import fluffy/measure @@ -14,54 +14,7 @@ else: template measurePop*() = discard -const - NormalLayer* = 0 - PopupsLayer* = 1 - type - StackDirection* = enum - ## Direction of the stack. - TopToBottom - BottomToTop - LeftToRight - RightToLeft - RightTopToBottom - RightBottomToTop - BottomLeftToRight - BottomRightToLeft - - 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 @@ -80,6 +33,7 @@ type sizeStack: seq[Vec2] stretchAt*: Vec2 directionStack: seq[StackDirection] + anchorStack: seq[Anchor] textStyle*: string = "Default" padding*: float32 = 12 theme*: Theme = Theme() @@ -134,7 +88,8 @@ 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) @@ -142,24 +97,21 @@ proc pushLayout*( sk.at = pos sk.sizeStack.add(size) sk.directionStack.add(direction) + sk.anchorStack.add(anchor) 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) - of RightTopToBottom: - sk.at = pos + vec2(size.x, 0) - of RightBottomToTop: - sk.at = pos + vec2(size.x, size.y) - of BottomLeftToRight: - sk.at = pos + vec2(0, size.y) - of BottomRightToLeft: - sk.at = pos + vec2(size.x, size.y) + of TopToBottom: + sk.at = pos + if anchor == AnchorRight: sk.at.x += size.x + of BottomToTop: + sk.at = pos + vec2(0, size.y) + if anchor == AnchorRight: sk.at.x += size.x + of LeftToRight: + sk.at = pos + if anchor == AnchorBottom: sk.at.y += size.y + of RightToLeft: + sk.at = pos + vec2(size.x, 0) + if anchor == AnchorBottom: sk.at.y += size.y proc popLayout*(sk: Silky) = ## Pop the current layout container from the stack. @@ -167,6 +119,7 @@ proc popLayout*(sk: Silky) = discard sk.posStack.pop() discard sk.sizeStack.pop() discard sk.directionStack.pop() + discard sk.anchorStack.pop() proc pos*(sk: Silky): Vec2 = ## Get the current layout position. @@ -184,6 +137,22 @@ proc stackDirection*(sk: Silky): StackDirection = ## Get the current stack direction. sk.directionStack[^1] +proc stackAnchor*(sk: Silky): Anchor = + ## Get the current stack anchor. + sk.anchorStack[^1] + +proc widgetPos*(sk: Silky, size: Vec2): Vec2 = + ## Compute top-left draw position for a widget of the given size. + result = sk.at + if sk.stackDirection in {RightToLeft}: + result.x -= size.x + if sk.stackDirection in {BottomToTop}: + result.y -= size.y + if sk.stackAnchor == AnchorRight and sk.stackDirection in {TopToBottom, BottomToTop}: + result.x -= size.x + if sk.stackAnchor == AnchorBottom and sk.stackDirection in {LeftToRight, RightToLeft}: + result.y -= size.y + proc pushClipRect*(sk: Silky, rect: Rect) = ## Push a new clip rectangle onto the stack. sk.clipStack.add(rect) @@ -207,14 +176,14 @@ 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, RightTopToBottom: - sk.at.y += amount.y + sk.theme.spacing.float32 - of BottomToTop, RightBottomToTop: - sk.at.y -= amount.y + sk.theme.spacing.float32 - of LeftToRight, BottomLeftToRight: - sk.at.x += amount.x + sk.theme.spacing.float32 - of RightToLeft, BottomRightToLeft: - sk.at.x -= amount.x + sk.theme.spacing.float32 + 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 proc getImageSize*(sk: Silky, image: string): Vec2 = ## Get the size of an image in the atlas. diff --git a/src/silky/semantic.nim b/src/silky/semantic.nim index 427354c..baa249b 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 type WidgetState* = object @@ -201,63 +201,7 @@ proc diff*(old, new: string): string = return output.join("\n") -const - NormalLayer* = 0 - PopupsLayer* = 1 - type - StackDirection* = enum - TopToBottom - BottomToTop - LeftToRight - RightToLeft - RightTopToBottom - RightBottomToTop - BottomLeftToRight - BottomRightToLeft - - 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 @@ -267,6 +211,7 @@ type sizeStack: seq[Vec2] stretchAt*: Vec2 directionStack: seq[StackDirection] + anchorStack: seq[Anchor] textStyle*: string = "Default" padding*: float32 = 12 theme*: Theme = Theme() @@ -277,7 +222,6 @@ type hover*: bool = false tooltipThreshold*: float64 = 0.5 atlas*: SilkyAtlas - layers*: array[2, seq[SilkyVertex]] currentLayer*: int layerStack*: seq[int] clipStack: seq[Rect] @@ -295,23 +239,28 @@ 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 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.anchorStack.add(anchor) 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) - of RightTopToBottom: sk.at = pos + vec2(size.x, 0) - of RightBottomToTop: sk.at = pos + vec2(size.x, size.y) - of BottomLeftToRight: sk.at = pos + vec2(0, size.y) - of BottomRightToLeft: sk.at = pos + vec2(size.x, size.y) + of TopToBottom: + sk.at = pos + if anchor == AnchorRight: sk.at.x += size.x + of BottomToTop: + sk.at = pos + vec2(0, size.y) + if anchor == AnchorRight: sk.at.x += size.x + of LeftToRight: + sk.at = pos + if anchor == AnchorBottom: sk.at.y += size.y + of RightToLeft: + sk.at = pos + vec2(size.x, 0) + if anchor == AnchorBottom: sk.at.y += size.y proc popLayout*(sk: Silky) = ## Pops the current layout region from the stack. @@ -319,6 +268,7 @@ proc popLayout*(sk: Silky) = discard sk.posStack.pop() discard sk.sizeStack.pop() discard sk.directionStack.pop() + discard sk.anchorStack.pop() proc pos*(sk: Silky): Vec2 = ## Returns the current layout position. @@ -336,6 +286,22 @@ proc stackDirection*(sk: Silky): StackDirection = ## Returns the current stack direction. sk.directionStack[^1] +proc stackAnchor*(sk: Silky): Anchor = + ## Returns the current stack anchor. + sk.anchorStack[^1] + +proc widgetPos*(sk: Silky, size: Vec2): Vec2 = + ## Compute top-left draw position for a widget of the given size. + result = sk.at + if sk.stackDirection in {RightToLeft}: + result.x -= size.x + if sk.stackDirection in {BottomToTop}: + result.y -= size.y + if sk.stackAnchor == AnchorRight and sk.stackDirection in {TopToBottom, BottomToTop}: + result.x -= size.x + if sk.stackAnchor == AnchorBottom and sk.stackDirection in {LeftToRight, RightToLeft}: + result.y -= size.y + proc pushClipRect*(sk: Silky, rect: Rect) = ## Pushes a clipping rectangle onto the stack. sk.clipStack.add(rect) @@ -352,14 +318,14 @@ 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, RightTopToBottom: - sk.at.y += amount.y + sk.theme.spacing.float32 - of BottomToTop, RightBottomToTop: - sk.at.y -= amount.y + sk.theme.spacing.float32 - of LeftToRight, BottomLeftToRight: - sk.at.x += amount.x + sk.theme.spacing.float32 - of RightToLeft, BottomRightToLeft: - sk.at.x -= amount.x + sk.theme.spacing.float32 + 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 proc getImageSize*(sk: Silky, image: string): Vec2 = ## Returns the size of an image from the atlas. diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index 41e9338..8d57d1b 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -3,9 +3,9 @@ import vmath, bumpy, chroma when defined(silkyTesting): - import silky/semantic, silky/testing + import semantic, testing, common else: - import silky/drawing, windy + import drawing, common, windy when defined(macos): const ScrollSpeed* = 10.0 @@ -649,9 +649,9 @@ proc progressBar*(sk: Silky, value: SomeNumber, minVal: SomeNumber, maxVal: Some 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.at + p, sk.size - p, direction, anchor) proc groupEnd*(sk: Silky) = ## End a group. @@ -697,6 +697,11 @@ proc h1text*(sk: Silky, t: string) = let textSize = sk.drawText("H1", t, sk.at, sk.theme.textH1Color) sk.advance(textSize) +proc rectangle*(sk: Silky, size: Vec2, color: ColorRGBX) = + ## Draw a colored rectangle that respects current stacking direction and anchor. + sk.drawRect(sk.widgetPos(size), size, color) + 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 @@ -1002,7 +1007,15 @@ template subWindow*(title: string, show: var bool, initialOrigin: Vec2, initialS template progressBar*(value: SomeNumber, minVal: SomeNumber, maxVal: SomeNumber) = sk.progressBar(value, minVal, maxVal) -template group*(p: Vec2, direction = TopToBottom, body) = +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: @@ -1120,6 +1133,9 @@ template text*(t: string) = template h1text*(t: string) = sk.h1text(t) +template rectangle*(size: Vec2, color: ColorRGBX) = + sk.rectangle(size, color) + template tooltip*(text: string) = ## Display a tooltip at the mouse cursor. sk.tooltip(window, text) diff --git a/tests/manual_layout.nim b/tests/manual_layout.nim index 114215f..e05ab26 100644 --- a/tests/manual_layout.nim +++ b/tests/manual_layout.nim @@ -1,7 +1,7 @@ -## Demonstrates layout stacking directions with adjustable padding, spacing, -## and number of boxes. Use the controls at the top to tweak values and pick -## a stacking direction from the dropdown. The colored boxes below respond -## to every change in real time. +## 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], @@ -56,32 +56,31 @@ var layoutPadding = 16.0f layoutSpacing = 8.0f numBoxes = 5.0f - direction = "Left, Top to Bottom" - -const Directions = [ - "Left, Top to Bottom", - "Left, Bottom to Top", - "Top, Left to Right", - "Top, Right to Left", - "Right, Top to Bottom", - "Right, Bottom to Top", - "Bottom, Left to Right", - "Bottom, Right to Left", -] + directionLabel = "Top to Bottom" + anchorLabel = "Left" + +const + DirectionLabels = ["Top to Bottom", "Bottom to Top", "Left to Right", "Right to Left"] + AnchorLabels = ["Left", "Right", "Top", "Bottom"] proc toStackDirection(s: string): StackDirection = ## Convert a direction label to a StackDirection enum. case s: - of "Left, Top to Bottom": TopToBottom - of "Left, Bottom to Top": BottomToTop - of "Top, Left to Right": LeftToRight - of "Top, Right to Left": RightToLeft - of "Right, Top to Bottom": RightTopToBottom - of "Right, Bottom to Top": RightBottomToTop - of "Bottom, Left to Right": BottomLeftToRight - of "Bottom, Right to Left": BottomRightToLeft + of "Top to Bottom": TopToBottom + of "Bottom to Top": BottomToTop + of "Left to Right": LeftToRight + of "Right to Left": RightToLeft else: TopToBottom +proc toAnchor(s: string): Anchor = + ## Convert an anchor label to an Anchor enum. + case s: + of "Left": AnchorLeft + of "Right": AnchorRight + of "Top": AnchorTop + of "Bottom": AnchorBottom + else: AnchorLeft + window.onFrame = proc() = sk.beginUI(window, window.size) sk.clearScreen(BackgroundColor) @@ -98,7 +97,9 @@ window.onFrame = proc() = scrubber("spacing", layoutSpacing, 0.0, 40.0, &"Spacing: {layoutSpacing:.0f}") scrubber("numBoxes", numBoxes, 1.0, 10.0, &"Boxes: {numBoxes:.0f}") text("Direction:") - dropDown(direction, Directions) + dropDown(directionLabel, DirectionLabels) + text("Anchor:") + dropDown(anchorLabel, AnchorLabels) # Layout area. let @@ -108,42 +109,22 @@ window.onFrame = proc() = areaH = window.size.y.float32 - controlsBottom - Margin areaSize = vec2(areaW, areaH) pad = layoutPadding - stackDir = direction.toStackDirection() + stackDir = directionLabel.toStackDirection() + stackAnc = anchorLabel.toAnchor() n = numBoxes.int # Draw area background. sk.drawRect(areaPos, areaSize, AreaBgColor) # Push a layout inside the area with padding applied. - sk.pushLayout(areaPos + vec2(pad, pad), areaSize - vec2(pad * 2, pad * 2), stackDir) + sk.pushLayout(areaPos + vec2(pad, pad), areaSize - vec2(pad * 2, pad * 2), stackDir, stackAnc) let savedSpacing = sk.theme.spacing sk.theme.spacing = layoutSpacing.int for i in 0 ..< n: let color = BoxColors[i mod BoxColors.len] let sz = BoxSizes[i mod BoxSizes.len] - let drawPos = - case stackDir: - of TopToBottom, LeftToRight: - sk.at - of BottomToTop: - sk.at - vec2(0, sz.y) - of RightToLeft: - sk.at - vec2(sz.x, 0) - of RightTopToBottom: - sk.at - vec2(sz.x, 0) - of BottomLeftToRight: - sk.at - vec2(0, sz.y) - of RightBottomToTop, BottomRightToLeft: - sk.at - vec2(sz.x, sz.y) - sk.drawRect(drawPos, sz, color) - discard sk.drawText( - "Default", - $(i + 1), - drawPos + vec2(sz.x * 0.5 - 5, sz.y * 0.5 - 9), - rgbx(255, 255, 255, 255) - ) - sk.advance(sz) + rectangle(sz, color) sk.theme.spacing = savedSpacing sk.popLayout() From d6ab5e2b274276cfec6d0fa221e6e07d0621fb0f Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 15 Feb 2026 16:27:26 -0800 Subject: [PATCH 10/22] Text aligment. --- src/silky/common.nim | 1 + src/silky/drawing.nim | 44 ++++++++++++- src/silky/semantic.nim | 4 +- src/silky/widgets.nim | 15 +++-- tests/data/radio.off.png | Bin 0 -> 1026 bytes tests/data/radio.on.png | Bin 0 -> 1204 bytes tests/manual_layout.nim | 2 +- tests/manual_textalign.nim | 130 +++++++++++++++++++++++++++++++++++++ 8 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 tests/data/radio.off.png create mode 100644 tests/data/radio.on.png create mode 100644 tests/manual_textalign.nim diff --git a/src/silky/common.nim b/src/silky/common.nim index bf07982..5edb9cc 100644 --- a/src/silky/common.nim +++ b/src/silky/common.nim @@ -1,5 +1,6 @@ import pixie, vmath, chroma +export pixie.HorizontalAlignment, pixie.VerticalAlignment const NormalLayer* = 0 diff --git a/src/silky/drawing.nim b/src/silky/drawing.nim index b947399..6142ced 100644 --- a/src/silky/drawing.nim +++ b/src/silky/drawing.nim @@ -305,7 +305,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 @@ -320,6 +322,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 @@ -334,11 +339,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 @@ -358,6 +381,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 @@ -383,6 +407,7 @@ proc drawText*( 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: @@ -402,7 +427,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], @@ -423,6 +448,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 = diff --git a/src/silky/semantic.nim b/src/silky/semantic.nim index baa249b..fb3c934 100644 --- a/src/silky/semantic.nim +++ b/src/silky/semantic.nim @@ -399,7 +399,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 8d57d1b..c30ff03 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -697,9 +697,16 @@ proc h1text*(sk: Silky, t: string) = let textSize = sk.drawText("H1", t, sk.at, sk.theme.textH1Color) sk.advance(textSize) -proc rectangle*(sk: Silky, size: Vec2, color: ColorRGBX) = +proc rectangle*(sk: Silky, size: Vec2, color: ColorRGBX, label = "") = ## Draw a colored rectangle that respects current stacking direction and anchor. - sk.drawRect(sk.widgetPos(size), size, color) + 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 = "") = @@ -1133,8 +1140,8 @@ template text*(t: string) = template h1text*(t: string) = sk.h1text(t) -template rectangle*(size: Vec2, color: ColorRGBX) = - sk.rectangle(size, color) +template rectangle*(size: Vec2, color: ColorRGBX, label = "") = + sk.rectangle(size, color, label) template tooltip*(text: string) = ## Display a tooltip at the mouse cursor. diff --git a/tests/data/radio.off.png b/tests/data/radio.off.png new file mode 100644 index 0000000000000000000000000000000000000000..b51fd596656e829883575c867ec7869f09958c7a GIT binary patch literal 1026 zcmV+d1pWJoP)F^W;6$DsAQ#3X;vGzr2!65^QZ+@XiNk&fiR%jvo@BE>O$pF9jJB&6R?h| zplT==JhNqTzO2B0d>#J64yXO7I4&Ptc9!61tUa&0r(~_x)e0OflZfpC9T~nwT zo(wdcHbw6vK|4{sVHj>HJI;k^XJ%;p@iTZ)dJR*5%IdAb^#ibRupiEzIsyIbilyG3 z!d=U!)Zk!feD&n=nCKWxBV_pUw!(bRJ?h66zZNwn+Ehp|2Uq0ptHkqkUPFv$^Z!^4)eNLr%?&0{8;Grl5+o-1X3hYK*3`Y`y4;+0!9uoc=eVJda^c#)S9o~CYpI8bow-NshLlDg!-NOU4|%D z;n>dLW~vGu*fAJayu|+hB_y;`v|&lafJz+T7$&CV zF_iCaI-UM-V0cdgP%lMjQ4w3?UOyu{xB`_oz5ga{{GMW#9V# z;w#&>scy{jwuUJN2fU1_LN0gj;Qpb_b9J{ka{m#yu8V~>ZUscLxoTCHFP(-1`-Ud- zxvmlH`~^vsQ(K{n5KW-#MX!%V$iXz7`0;a?66Pz$HpMqF`|#uL-@6Bn9XSMr?p&#m z@41`FSf3%Oa&=yPpzE;-r?Qztt(o(@Ewg|B9uLB3APmB_G*()|NvAXKZM*$l4!4dy ztt^5Hp*+@$Gz*e0p_HOj?3Z&NK&#MG4c3Kz-Uzk0d4jOlI#E)F(uPv}Jm7uwp>;F` wCKO=FnP(h^`lvDwR6X82OhdkpOekId09(|uwY!s7BNPJ&2_7~+1>T+wzs{#FFw!dIqU|y{oCH&bARVK&-p*kk-+nE zU01>nAq6FSJ|#ZPp`j?7e;0BkNpd;k-e3Whxm1*oE5NU157yNdG=KL zkrX_d-VS3UM__wjG85m{c}7u`U-4F+!)Zk}feGs8&@R(7pI*zapSpB4ll+vLZeeU3aC zo_ppgc>Cy^km!kn>$q@h{x)2j`3Yuk&BN(WCm|eePrflcbUe`=|BypzGcDEQf{0-l zPiB`_1}DFmMyLWO$46m&>=;z^8k8#)D3^73;_)<_Boqq457%elG_I=u#odF!VB3vY zBqGvbiu{tTqJ?7l$k}sKZ27}O18`#W7zj_cQX!F**h==k^gQ$>d!br0VEV!(FpPRX z1;YyR%@W^Rj8XLG%X8klI?)wIACL*cR#y}jlF`K+eGIO zgRfpuxkU&bL`(H9EM`H%pVO&6C{=V8EkSEWolRiUB+VvHVnZw%We=~e<-u{89osmV zy@?WL#&-F|T3&-_B+RZX1UC?9a5$R);j*a32Vas5nR!QeieRcj~^6a^< z*|OPIhz9Gn-e^@P$KQbiuMVsv;$0v3{r)+;q49K>q6w*jb%DJww>|&mEU-@tm?=Nq91$Vh4MtMaA_&Cw%sRxTj-|$$? zy?27}s70Zu9{D^H#bj|8nXq{@Szai>eP^EMFce3X_dpfny@#pIzdK$iz5WMoL@DEh S#?*BH00007.3f}ms") + + sk.endUi() + window.swapBuffers() + +when defined(emscripten): + window.run() +else: + while not window.closeRequested: + pollEvents() From e9c31c2a1fa143676e0088e7b1b916cb00164f17 Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 15 Feb 2026 16:38:13 -0800 Subject: [PATCH 11/22] Layout stuff. --- src/silky/widgets.nim | 11 +- tests/data/check.disabled.png | Bin 0 -> 482 bytes tests/data/check.error.png | Bin 0 -> 547 bytes tests/data/radio.disabled.png | Bin 0 -> 658 bytes tests/data/radio.error.png | Bin 0 -> 739 bytes tests/manual_layout.nim | 296 +++++++++++++++++----------------- 6 files changed, 158 insertions(+), 149 deletions(-) create mode 100644 tests/data/check.disabled.png create mode 100644 tests/data/check.error.png create mode 100644 tests/data/radio.disabled.png create mode 100644 tests/data/radio.error.png diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index c30ff03..d08cfcf 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -462,7 +462,7 @@ proc clickableIcon*(sk: Silky, window: Window, image: string, on: bool): bool = sk.drawImage(image, sk.at, color) sk.at += vec2(imageSize.x, 0) -proc radioButton*[T](sk: Silky, window: Window, 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") @@ -473,18 +473,19 @@ proc radioButton*[T](sk: Silky, window: Window, label: string, variable: var T, 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 + textColor = if isEnabled: sk.theme.defaultTextColor else: sk.theme.disabledTextColor iconPos = vec2(sk.at.x, sk.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.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() @@ -1111,8 +1112,8 @@ template iconButton*(image: string, body: untyped) = if sk.iconButton(window, image): body -template radioButton*[T](label: string, variable: var T, value: T) = - sk.radioButton(window, label, variable, value) +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) diff --git a/tests/data/check.disabled.png b/tests/data/check.disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..f0731b18750fbb541b0d7198512da70fbae552ca GIT binary patch literal 482 zcmV<80UiE{P)gn;-+D1tp3#9iPN?G0#eKu=JPz$Fme1!b3Y{y!T@y?*T!J|2&I+qSot z%Y{78qdd4T$`&5=CRaN!P ztq_LeD2jN@d_I3+OKM@30@HCE3W9*bFyz9jG+fEXCmkl3DYLOAe;}tgJ_hD0w=%G? zh{s%pE`LC%>$19lSnzHN|0Np2xR8Glz9$83v`zgg6{`HAG`@MQ-{XJ?cnqI{2s^gGio2DDjdt{5IM#1;S`4C&h|h9 YA4uoN4cL-Ds{jB107*qoM6N<$g7?D8nE(I) literal 0 HcmV?d00001 diff --git a/tests/data/check.error.png b/tests/data/check.error.png new file mode 100644 index 0000000000000000000000000000000000000000..c41f16ce0b569e0e2066d3e39a3aaf93556fead8 GIT binary patch literal 547 zcmV+;0^I$HP)K_dy_Bg5@L(q0~&y;j-C;PPYrKc6)ej+mF&}q``sb z-Oi>{cw4Q&-R%G)N0xxxr6Slc4ES_i2+()cYTe`Kd@wEleZ2;EzmLID7#@Qe`-z^w z0{hNpLqS<-TLw%yML3xtL@0s(Qh@&{T|TyWKT^`t9g0rK%_>NNs@PBap!l^MC!DIh z5yxQ0;6IcFC(+9DT1KoCIEnU@WQbDWR1{?s!jxw&SXPv5WuWBKu(Z{^$(&;{P?^%f z*K|F*Ov*nEPM(Az4|xsK1RdWC!3cF~jfVAsWlDnsdGHg^?;Iiyf`Av*YJ6YdyHg0R za6Y=}_lFb(m9_-S8%-!Fpw-ptb?EeZz&RhQY07B1SUepLhdYE?RMsLXRVLH$-2shO lfy*~BQk-CvzS+*o=nGNL$WT?OMVkNs002ovPDHLkV1ir}?O^}_ literal 0 HcmV?d00001 diff --git a/tests/data/radio.disabled.png b/tests/data/radio.disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..497986cbf0ea070bf6f263c3911769f6523cb1fd GIT binary patch literal 658 zcmV;D0&V??P)npnktkv)N3ABERAQtLE#I)oMj5C>o7YE|;TV zFnFKO=bsdoQ6sp-DWLIqtSaTSsL=C`#2y!w7K?>Q73zQ@UxMC%*(pt@Q;|~~RIY!` z;KPTJj^S{K8a(KNS&0OtDAY$#MLA*+3n6GnUGTNTiXpdOiMtBs3csM0%x1GF&?QWJ zSScc6T`m`X%er6=dW=LOqE9Ur%R9Ihiq5dx?P6d!ULVYX|6{S3Xnv@`K?Q!f6ewa4 zn@A+ESJnli4?P}FC6mceC=?>E*Q=_>VBp${O7t*d(&;qfD%yAB-Q=-?GON1dic9Tw z`^`w7)1|%_*=!bSWvyC~F1lj%di{p`xy0)a1_r&2F@^ye4u{1f(fMx2$G scn~Dq0DI^h6q(cWSSS>p>Bl~^SFPS9t74tFbN~PV07*qoM6N<$f^iHi4*&oF literal 0 HcmV?d00001 diff --git a/tests/data/radio.error.png b/tests/data/radio.error.png new file mode 100644 index 0000000000000000000000000000000000000000..e4d3ad4da58e781907908257080516fd4f780be7 GIT binary patch literal 739 zcmV<90v!E`P)J%0guvd-O#4SqzMS7G}2AWPK7{l zKp@QAnVxgO6p+z&?@1S9=5<}~J_;tN zY+1#hwVL*=ScG<~1;fDrm}T*OR10384;;B^CR+Q`_TwVl=j2-8UdNdk=(d}{{ifUjOhg1(&FthDktyF^c_4Pz40Z}Oy?ynQB=>pp8qw;pM33aRk z{@-&2W5`I#Oal9buD4Q_^=0v4u-)KhWMbKe#0*tSODks{{-=tQ`~FSgLvz?9)S z<_ew-*O?x^GeKD1^1{OJQm`nBjX*F6lA`eH&%);NJiQ40xnFo~jSr41SeB%ZFXM6E zr;5h|0v?1VO7EcLvx>eiHaDs5+`+i{Nlm4A^ZWdM@T{zG09QW!W})OmzBGjlN8A;x z>iTED-(Pr_$v^~0m(oJ&WlRl512Rq>UAF4=LLe04&@@h8ARI2}3-89g$$KWt`PYd= z23tuhot!`i`;-p}wic7.3f}ms") - - sk.endUi() - window.swapBuffers() - -when defined(emscripten): - window.run() -else: - while not window.closeRequested: - pollEvents() +## 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 + AreaBgColor = parseHtmlColor("#2a2a3e").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 + +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.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.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 + + # Draw area background. + sk.drawRect(areaPos, areaSize, AreaBgColor) + + # Push a layout inside the area with padding applied. + sk.pushLayout(areaPos + vec2(pad, pad), areaSize - vec2(pad * 2, pad * 2), stackDir, stackAnc) + let savedSpacing = sk.theme.spacing + sk.theme.spacing = layoutSpacing.int + + 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.theme.spacing = savedSpacing + sk.popLayout() + + # Frame time. + let ms = sk.avgFrameTime * 1000 + sk.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() From b8c5c0df0add9d1e3f53a15f61e15f0c4f48546f Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 15 Feb 2026 16:55:59 -0800 Subject: [PATCH 12/22] theme stack --- src/silky/drawing.nim | 9 +++++++++ src/silky/semantic.nim | 9 +++++++++ tests/manual_layout.nim | 25 +++++++++++-------------- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/silky/drawing.nim b/src/silky/drawing.nim index 6142ced..680d747 100644 --- a/src/silky/drawing.nim +++ b/src/silky/drawing.nim @@ -37,6 +37,7 @@ type textStyle*: string = "Default" padding*: float32 = 12 theme*: Theme = Theme() + themeStack: seq[Theme] cursor*: Cursor = Cursor(kind: ArrowCursor) inputRunes*: seq[Rune] @@ -84,6 +85,14 @@ 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, diff --git a/src/silky/semantic.nim b/src/silky/semantic.nim index fb3c934..57a0763 100644 --- a/src/silky/semantic.nim +++ b/src/silky/semantic.nim @@ -215,6 +215,7 @@ type textStyle*: string = "Default" padding*: float32 = 12 theme*: Theme = Theme() + themeStack: seq[Theme] inputRunes*: seq[Rune] showTooltip*: bool = false lastMousePos*: Vec2 @@ -239,6 +240,14 @@ proc popLayer*(sk: Silky) = ## Pops the current rendering 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, anchor: Anchor = AnchorLeft) = ## Pushes a new layout region onto the stack. sk.atStack.add(sk.at) diff --git a/tests/manual_layout.nim b/tests/manual_layout.nim index 544ce76..7d9ef3e 100644 --- a/tests/manual_layout.nim +++ b/tests/manual_layout.nim @@ -24,7 +24,6 @@ loadExtensions() const BackgroundColor = parseHtmlColor("#1a1a2e").rgbx - AreaBgColor = parseHtmlColor("#2a2a3e").rgbx BoxColors = [ parseHtmlColor("#e74c3c").rgbx, parseHtmlColor("#3498db").rgbx, @@ -121,21 +120,19 @@ window.onFrame = proc() = stackAnc = Anchors[anchorVal] n = numBoxes.int - # Draw area background. - sk.drawRect(areaPos, areaSize, AreaBgColor) - # Push a layout inside the area with padding applied. - sk.pushLayout(areaPos + vec2(pad, pad), areaSize - vec2(pad * 2, pad * 2), stackDir, stackAnc) - let savedSpacing = sk.theme.spacing - sk.theme.spacing = layoutSpacing.int - - 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.theme.spacing = savedSpacing - sk.popLayout() + 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 From 33eeb9f7e493d7b2e87bfac28f5589e2d6a2d24d Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 15 Feb 2026 19:31:48 -0800 Subject: [PATCH 13/22] more work with scrollf --- src/silky.nim | 8 +- src/silky/drawing.nim | 3 + src/silky/scrollbars.nim | 133 +++++++++++++++++++++++++++++ src/silky/semantic.nim | 3 + src/silky/widgets.nim | 141 +++++++++---------------------- tests/manual_layout.nim | 2 - tests/test_scrollbars.nim | 171 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 355 insertions(+), 106 deletions(-) create mode 100644 src/silky/scrollbars.nim create mode 100644 tests/test_scrollbars.nim diff --git a/src/silky.nim b/src/silky.nim index 7bc2bcd..6396dc8 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, common] - export semantic, atlas, widgets, tables, textboxes, testing, common + import silky/[semantic, atlas, widgets, textboxes, testing, common, scrollbars] + export semantic, atlas, widgets, tables, textboxes, testing, common, scrollbars else: import opengl, windy - import silky/[drawing, atlas, widgets, textboxes, common] - export opengl, windy, drawing, atlas, widgets, tables, textboxes, common + import silky/[drawing, atlas, widgets, textboxes, common, scrollbars] + export opengl, windy, drawing, atlas, widgets, tables, textboxes, common, scrollbars diff --git a/src/silky/drawing.nim b/src/silky/drawing.nim index 680d747..15ec40c 100644 --- a/src/silky/drawing.nim +++ b/src/silky/drawing.nim @@ -32,6 +32,7 @@ type posStack: seq[Vec2] sizeStack: seq[Vec2] stretchAt*: Vec2 + stretchMin*: Vec2 directionStack: seq[StackDirection] anchorStack: seq[Anchor] textStyle*: string = "Default" @@ -108,6 +109,7 @@ proc pushLayout*( sk.directionStack.add(direction) sk.anchorStack.add(anchor) sk.stretchAt = sk.at + sk.stretchMin = sk.at case direction: of TopToBottom: sk.at = pos @@ -183,6 +185,7 @@ proc instanceCount*(sk: Silky): int = proc advance*(sk: Silky, amount: Vec2) = ## Advance the position. + sk.stretchMin = min(sk.stretchMin, sk.at) sk.stretchAt = max(sk.stretchAt, sk.at + amount + vec2(sk.theme.spacing.float32)) case sk.stackDirection: of TopToBottom: diff --git a/src/silky/scrollbars.nim b/src/silky/scrollbars.nim new file mode 100644 index 0000000..5bfa84a --- /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, speed: float32) = + ## Apply scroll wheel input. + let sm = sa.scrollMax + if not sa.scrollingY and delta.y != 0: + sa.scrollPos.y += delta.y * speed + sa.scrollPos.y = clamp(sa.scrollPos.y, 0.0f, sm.y) + if not sa.scrollingX and delta.x != 0: + sa.scrollPos.x += delta.x * speed + 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 57a0763..1fd045f 100644 --- a/src/silky/semantic.nim +++ b/src/silky/semantic.nim @@ -210,6 +210,7 @@ type posStack: seq[Vec2] sizeStack: seq[Vec2] stretchAt*: Vec2 + stretchMin*: Vec2 directionStack: seq[StackDirection] anchorStack: seq[Anchor] textStyle*: string = "Default" @@ -257,6 +258,7 @@ proc pushLayout*(sk: Silky, pos: Vec2, size: Vec2, direction: StackDirection = T sk.directionStack.add(direction) sk.anchorStack.add(anchor) sk.stretchAt = sk.at + sk.stretchMin = sk.at case direction: of TopToBottom: sk.at = pos @@ -325,6 +327,7 @@ proc clipRect*(sk: Silky): Rect = proc advance*(sk: Silky, amount: Vec2) = ## Advances the cursor position by the given amount. + sk.stretchMin = min(sk.stretchMin, sk.at) sk.stretchAt = max(sk.stretchAt, sk.at + amount + vec2(sk.theme.spacing.float32)) case sk.stackDirection: of TopToBottom: diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index d08cfcf..ba007bb 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -3,9 +3,9 @@ import vmath, bumpy, chroma when defined(silkyTesting): - import semantic, testing, common + import semantic, testing, common, scrollbars else: - import drawing, common, windy + import drawing, common, scrollbars, windy when defined(macos): const ScrollSpeed* = 10.0 @@ -27,10 +27,7 @@ type visible*: bool FrameState* = ref object - scrollPos*: Vec2 - scrollingX*: bool - scrollingY*: bool - scrollDragOffset*: Vec2 + scroll*: ScrollArea ButtonState* = ref object clicked*: bool @@ -254,112 +251,54 @@ proc frameStart*(sk: Silky, id: string, framePos, frameSize: Vec2): tuple[state: sk.size.x - 2, sk.size.y - 2 )) + frameState.scroll.viewPos = sk.pos + frameState.scroll.viewSize = sk.size sk.at = sk.pos + vec2(sk.theme.padding) let originPos = sk.at - sk.at -= frameState.scrollPos + sk.at += frameState.scroll.scrollOffset() return (frameState, originPos) proc frameEnd*(sk: Silky, window: Window, frameState: FrameState, originPos: Vec2) = ## 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.stretchMin - offset + frameState.scroll.contentMax = sk.stretchAt - offset + vec2(16) - # 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 - ) - - # 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 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.scrollingY = true - frameState.scrollDragOffset.y = window.mousePos.vec2.y - scrollbarHandleRect.y - - sk.draw9Patch("scrollbar.9patch", 4, scrollbarHandleRect.xy, scrollbarHandleRect.wh) + frameState.scroll.scrollingY = true + frameState.scroll.scrollDragOffset.y = window.mousePos.vec2.y - handle.y + sk.draw9Patch("scrollbar.9patch", 4, handle.xy, handle.wh) # 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): + 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.scrollingX = true - frameState.scrollDragOffset.x = window.mousePos.vec2.x - scrollbarHandleRect.x - - sk.draw9Patch("scrollbar.9patch", 4, scrollbarHandleRect.xy, scrollbarHandleRect.wh) + 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() @@ -656,9 +595,11 @@ proc groupStart*(sk: Silky, p: Vec2, direction = TopToBottom, anchor = AnchorLef proc groupEnd*(sk: Silky) = ## End a group. - let endAt = sk.stretchAt + let endMax = sk.stretchAt + let endMin = sk.stretchMin sk.popLayout() - sk.advance(endAt - sk.at) + sk.advance(endMax - endMin) + sk.stretchMin = min(sk.stretchMin, endMin) proc frameStart*(sk: Silky, p, s: Vec2) = ## Begin a simple frame. diff --git a/tests/manual_layout.nim b/tests/manual_layout.nim index 7d9ef3e..10e7b29 100644 --- a/tests/manual_layout.nim +++ b/tests/manual_layout.nim @@ -120,8 +120,6 @@ window.onFrame = proc() = stackAnc = Anchors[anchorVal] n = numBoxes.int - - sk.pushTheme() sk.theme.spacing = layoutSpacing.int sk.theme.padding = layoutPadding.int diff --git a/tests/test_scrollbars.nim b/tests/test_scrollbars.nim new file mode 100644 index 0000000..162412e --- /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." From 3dbfa391023a4c1135615e9c7271c30440f65d8f Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 18 Feb 2026 04:54:36 -0800 Subject: [PATCH 14/22] semantic stuff --- docs/sementic_testing.md | 152 +++++++++++++++++++++++++++++++++++++++ src/silky/semantic.nim | 10 ++- 2 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 docs/sementic_testing.md 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/src/silky/semantic.nim b/src/silky/semantic.nim index 1fd045f..d7def60 100644 --- a/src/silky/semantic.nim +++ b/src/silky/semantic.nim @@ -394,6 +394,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 @@ -424,9 +428,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 = @@ -437,9 +438,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) = From d334cc254ee55d56ce7be4f73fdf585adfeda535 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 18 Feb 2026 04:54:48 -0800 Subject: [PATCH 15/22] more scrollbars --- src/silky/drawing.nim | 20 +++ src/silky/scrollbars.nim | 266 ++++++++++++++--------------- src/silky/testing.nim | 45 ++++- src/silky/widgets.nim | 11 +- tests/test_scrollbars.nim | 342 +++++++++++++++++++------------------- 5 files changed, 366 insertions(+), 318 deletions(-) diff --git a/src/silky/drawing.nim b/src/silky/drawing.nim index 15ec40c..6a6845d 100644 --- a/src/silky/drawing.nim +++ b/src/silky/drawing.nim @@ -634,6 +634,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/scrollbars.nim b/src/silky/scrollbars.nim index 5bfa84a..acf0da2 100644 --- a/src/silky/scrollbars.nim +++ b/src/silky/scrollbars.nim @@ -1,133 +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, speed: float32) = - ## Apply scroll wheel input. - let sm = sa.scrollMax - if not sa.scrollingY and delta.y != 0: - sa.scrollPos.y += delta.y * speed - sa.scrollPos.y = clamp(sa.scrollPos.y, 0.0f, sm.y) - if not sa.scrollingX and delta.x != 0: - sa.scrollPos.x += delta.x * speed - 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 +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, speed: float32) = + ## Apply scroll wheel input. + let sm = sa.scrollMax + if not sa.scrollingY and delta.y != 0: + sa.scrollPos.y += delta.y * speed + sa.scrollPos.y = clamp(sa.scrollPos.y, 0.0f, sm.y) + if not sa.scrollingX and delta.x != 0: + sa.scrollPos.x += delta.x * speed + 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/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/widgets.nim b/src/silky/widgets.nim index ba007bb..e4aeb96 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -601,15 +601,6 @@ proc groupEnd*(sk: Silky) = sk.advance(endMax - endMin) sk.stretchMin = min(sk.stretchMin, endMin) -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. - sk.popLayout() - proc ribbonStart*(sk: Silky, p, s: Vec2, tint: ColorRGBX) = ## Begin a ribbon. sk.pushLayout(p, s) @@ -974,11 +965,13 @@ template group*(p: Vec2, direction = TopToBottom, body: untyped) = template frame*(p, s: Vec2, body: untyped) = ## Create a frame. + sk.beginWidget("Frame", name = "Frame", rect = rect(p, s)) sk.frameStart(p, s) try: body finally: sk.frameEnd() + sk.endWidget() template frame*(id: string, framePos, frameSize: Vec2, body: untyped) = ## Frame with scrollbars similar to a window body. diff --git a/tests/test_scrollbars.nim b/tests/test_scrollbars.nim index 162412e..47e3994 100644 --- a/tests/test_scrollbars.nim +++ b/tests/test_scrollbars.nim @@ -1,171 +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." +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." From 06228c15a18fc868c564325d3d312c48b5c2858e Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 18 Feb 2026 06:20:59 -0800 Subject: [PATCH 16/22] Maybe I figured it out? --- examples/calculator/calculator.nim | 4 +- src/silky/drawing.nim | 38 ++-- src/silky/semantic.nim | 6 +- src/silky/widgets.nim | 6 +- tests/manual_layout2.nim | 274 +++++++++++++++++++++++++++++ 5 files changed, 304 insertions(+), 24 deletions(-) create mode 100644 tests/manual_layout2.nim diff --git a/examples/calculator/calculator.nim b/examples/calculator/calculator.nim index 9dcf75c..21a9156 100644 --- a/examples/calculator/calculator.nim +++ b/examples/calculator/calculator.nim @@ -161,8 +161,8 @@ 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.stretchMax.x = max(sk.stretchMax.x, sk.at.x + 10) + sk.stretchMax.y = max(sk.stretchMax.y, sk.at.y + 50 + 10) window.onFrame = proc() = diff --git a/src/silky/drawing.nim b/src/silky/drawing.nim index 6a6845d..ef8ed48 100644 --- a/src/silky/drawing.nim +++ b/src/silky/drawing.nim @@ -28,10 +28,12 @@ type ## The Silky that draws the AA pixel art sprites. inFrame: bool = false at*: Vec2 + num*: int atStack: seq[Vec2] + numStack: seq[int] posStack: seq[Vec2] sizeStack: seq[Vec2] - stretchAt*: Vec2 + stretchMax*: Vec2 stretchMin*: Vec2 directionStack: seq[StackDirection] anchorStack: seq[Anchor] @@ -103,30 +105,33 @@ proc pushLayout*( ) = ## Push a new layout container onto the stack. sk.atStack.add(sk.at) + sk.numStack.add(sk.num) + sk.num = 0 sk.posStack.add(pos) sk.at = pos sk.sizeStack.add(size) sk.directionStack.add(direction) sk.anchorStack.add(anchor) - sk.stretchAt = sk.at + sk.stretchMax = sk.at sk.stretchMin = sk.at - case direction: - of TopToBottom: - sk.at = pos - if anchor == AnchorRight: sk.at.x += size.x - of BottomToTop: - sk.at = pos + vec2(0, size.y) - if anchor == AnchorRight: sk.at.x += size.x - of LeftToRight: - sk.at = pos - if anchor == AnchorBottom: sk.at.y += size.y - of RightToLeft: - sk.at = pos + vec2(size.x, 0) - if anchor == AnchorBottom: sk.at.y += size.y + # case direction: + # of TopToBottom: + # sk.at = pos + # if anchor == AnchorRight: sk.at.x += size.x + # of BottomToTop: + # sk.at = pos + vec2(0, size.y) + # if anchor == AnchorRight: sk.at.x += size.x + # of LeftToRight: + # sk.at = pos + # if anchor == AnchorBottom: sk.at.y += size.y + # of RightToLeft: + # sk.at = pos + vec2(size.x, 0) + # if anchor == AnchorBottom: sk.at.y += size.y proc popLayout*(sk: Silky) = ## Pop the current layout container from the stack. sk.at = sk.atStack.pop() + sk.num = sk.numStack.pop() discard sk.posStack.pop() discard sk.sizeStack.pop() discard sk.directionStack.pop() @@ -186,7 +191,7 @@ proc instanceCount*(sk: Silky): int = proc advance*(sk: Silky, amount: Vec2) = ## Advance the position. sk.stretchMin = min(sk.stretchMin, sk.at) - sk.stretchAt = max(sk.stretchAt, sk.at + amount + vec2(sk.theme.spacing.float32)) + sk.stretchMax = max(sk.stretchMax, sk.at + amount + vec2(sk.theme.spacing.float32)) case sk.stackDirection: of TopToBottom: sk.at.y += amount.y + sk.theme.spacing.float32 @@ -196,6 +201,7 @@ proc advance*(sk: Silky, amount: Vec2) = sk.at.x += amount.x + sk.theme.spacing.float32 of RightToLeft: sk.at.x -= amount.x + sk.theme.spacing.float32 + inc sk.num proc getImageSize*(sk: Silky, image: string): Vec2 = ## Get the size of an image in the atlas. diff --git a/src/silky/semantic.nim b/src/silky/semantic.nim index d7def60..2f62ed9 100644 --- a/src/silky/semantic.nim +++ b/src/silky/semantic.nim @@ -209,7 +209,7 @@ type atStack: seq[Vec2] posStack: seq[Vec2] sizeStack: seq[Vec2] - stretchAt*: Vec2 + stretchMax*: Vec2 stretchMin*: Vec2 directionStack: seq[StackDirection] anchorStack: seq[Anchor] @@ -257,7 +257,7 @@ proc pushLayout*(sk: Silky, pos: Vec2, size: Vec2, direction: StackDirection = T sk.sizeStack.add(size) sk.directionStack.add(direction) sk.anchorStack.add(anchor) - sk.stretchAt = sk.at + sk.stretchMax = sk.at sk.stretchMin = sk.at case direction: of TopToBottom: @@ -328,7 +328,7 @@ proc clipRect*(sk: Silky): Rect = proc advance*(sk: Silky, amount: Vec2) = ## Advances the cursor position by the given amount. sk.stretchMin = min(sk.stretchMin, sk.at) - sk.stretchAt = max(sk.stretchAt, sk.at + amount + vec2(sk.theme.spacing.float32)) + sk.stretchMax = max(sk.stretchMax, sk.at + amount + vec2(sk.theme.spacing.float32)) case sk.stackDirection: of TopToBottom: sk.at.y += amount.y + sk.theme.spacing.float32 diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index e4aeb96..17f924a 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -266,7 +266,7 @@ proc frameEnd*(sk: Silky, window: Window, frameState: FrameState, originPos: Vec # Adjust for scroll offset so bounds are in unscrolled coordinates. let offset = frameState.scroll.scrollOffset() frameState.scroll.contentMin = sk.stretchMin - offset - frameState.scroll.contentMax = sk.stretchAt - offset + vec2(16) + frameState.scroll.contentMax = sk.stretchMax - offset + vec2(16) # Initialize scroll for reversed anchors, then clamp. frameState.scroll.initScroll() @@ -369,7 +369,7 @@ proc iconButton*(sk: Silky, window: Window, image: string): bool = 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.stretchMax = max(sk.stretchMax, sk.at + s2) sk.at += vec2(32 + sk.padding, 0) proc clickableIcon*(sk: Silky, window: Window, image: string, on: bool): bool = @@ -595,7 +595,7 @@ proc groupStart*(sk: Silky, p: Vec2, direction = TopToBottom, anchor = AnchorLef proc groupEnd*(sk: Silky) = ## End a group. - let endMax = sk.stretchAt + let endMax = sk.stretchMax let endMin = sk.stretchMin sk.popLayout() sk.advance(endMax - endMin) diff --git a/tests/manual_layout2.nim b/tests/manual_layout2.nim new file mode 100644 index 0000000..16e3d51 --- /dev/null +++ b/tests/manual_layout2.nim @@ -0,0 +1,274 @@ +## 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.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.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)) + + 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.stretchMax = sk.at + + proc drawStep() = + if currentStep == step.int: + sk.drawRect(sk.at - vec2(3, 3), vec2(6, 6), rgbx(255, 0, 0, 255)) + sk.drawRect(sk.stretchMin, sk.stretchMax - sk.stretchMin, rgbx(60, 60, 60, 60)) + if step.int != debugStep: + debugStep = step.int + echo "step: ", currentStep + echo "at: ", sk.at + echo "stretchMin: ", sk.stretchMin + echo "stretchMax: ", sk.stretchMax + + inc currentStep + + drawStep() + + sk.at += areaSize * si + sk.stretchMin = sk.at + sk.stretchMax = sk.at + + drawStep() + + sk.at += padding * pd + sk.stretchMin = min(sk.stretchMin, sk.at + padding * pd) + sk.stretchMax = max(sk.stretchMax, sk.at + padding * pd) + + drawStep() + + for i in 0 ..< n: + + if sk.num > 0: + sk.at += spacing * dr + sk.stretchMin = min(sk.stretchMin, sk.at + spacing * pd) + sk.stretchMax = max(sk.stretchMax, sk.at + spacing * pd) + + drawStep() + + var color = BoxColors[i] + color.a = 128 + let size = BoxSizes[i] + let pos = sk.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 + ) + + sk.stretchMin = min(sk.stretchMin, sk.at + size * pd + padding * pd) + sk.stretchMax = max(sk.stretchMax, sk.at + size * pd + padding * pd) + sk.at += size * dr + inc sk.num + + drawStep() + + sk.popLayout() + + + sk.popTheme() + + # Frame time. + let ms = sk.avgFrameTime * 1000 + sk.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() From 742090c7871b76d6f4f95ebcee518e64b8d166c5 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 18 Feb 2026 07:35:03 -0800 Subject: [PATCH 17/22] f --- tests/manual_layout3.nim | 241 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 tests/manual_layout3.nim diff --git a/tests/manual_layout3.nim b/tests/manual_layout3.nim new file mode 100644 index 0000000..33b8636 --- /dev/null +++ b/tests/manual_layout3.nim @@ -0,0 +1,241 @@ +## 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.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.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.stretchMax = sk.at + + proc drawStep() = + if currentStep == step.int: + sk.drawRect(sk.at - vec2(3, 3), vec2(6, 6), rgbx(255, 0, 0, 255)) + sk.drawRect(sk.stretchMin, sk.stretchMax - sk.stretchMin, rgbx(60, 60, 60, 60)) + if step.int != debugStep: + debugStep = step.int + echo "step: ", currentStep + echo "at: ", sk.at + echo "stretchMin: ", sk.stretchMin + echo "stretchMax: ", sk.stretchMax + + inc currentStep + + drawStep() + + sk.at += areaSize * sizeSign + sk.stretchMin = sk.at + sk.stretchMax = sk.at + + drawStep() + + sk.at += padding * paddingDir + sk.stretchMin = min(sk.stretchMin, sk.at + padding * paddingDir) + sk.stretchMax = max(sk.stretchMax, sk.at + padding * paddingDir) + + drawStep() + + for i in 0 ..< n: + + if sk.num > 0: + sk.at += spacing * mainDir + sk.stretchMin = min(sk.stretchMin, sk.at + spacing * paddingDir) + sk.stretchMax = max(sk.stretchMax, sk.at + spacing * paddingDir) + + drawStep() + + var color = BoxColors[i] + color.a = 128 + let size = BoxSizes[i] + let pos = sk.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.stretchMin = min(sk.stretchMin, sk.at + size * paddingDir + padding * paddingDir) + sk.stretchMax = max(sk.stretchMax, sk.at + size * paddingDir + padding * paddingDir) + sk.at += size * mainDir + inc sk.num + + drawStep() + + sk.popLayout() + + sk.popTheme() + + # Frame time. + let ms = sk.avgFrameTime * 1000 + sk.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() From 94164aec426f41c849b028d94f0587cff59cc64e Mon Sep 17 00:00:00 2001 From: treeform Date: Thu, 19 Feb 2026 06:02:19 -0800 Subject: [PATCH 18/22] layout.nim --- src/silky.nim | 8 ++--- src/silky/drawing.nim | 54 +++++---------------------------- src/silky/layout.nim | 65 ++++++++++++++++++++++++++++++++++++++++ src/silky/semantic.nim | 40 ++++--------------------- tests/test_layout.nim | 68 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 85 deletions(-) create mode 100644 src/silky/layout.nim create mode 100644 tests/test_layout.nim diff --git a/src/silky.nim b/src/silky.nim index 6396dc8..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, common, scrollbars] - export semantic, atlas, widgets, tables, textboxes, testing, common, scrollbars + 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, common, scrollbars] - export opengl, windy, drawing, atlas, widgets, tables, textboxes, common, scrollbars + 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/drawing.nim b/src/silky/drawing.nim index ef8ed48..1278c23 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, - atlas, shaders, common + atlas, shaders, common, layout when defined(profile): import fluffy/measure @@ -108,38 +108,13 @@ proc pushLayout*( sk.numStack.add(sk.num) sk.num = 0 sk.posStack.add(pos) - sk.at = pos sk.sizeStack.add(size) sk.directionStack.add(direction) sk.anchorStack.add(anchor) + sk.at = layoutStart(pos, size, direction, anchor) sk.stretchMax = sk.at sk.stretchMin = sk.at - # case direction: - # of TopToBottom: - # sk.at = pos - # if anchor == AnchorRight: sk.at.x += size.x - # of BottomToTop: - # sk.at = pos + vec2(0, size.y) - # if anchor == AnchorRight: sk.at.x += size.x - # of LeftToRight: - # sk.at = pos - # if anchor == AnchorBottom: sk.at.y += size.y - # of RightToLeft: - # sk.at = pos + vec2(size.x, 0) - # if anchor == AnchorBottom: sk.at.y += size.y - -proc popLayout*(sk: Silky) = - ## Pop the current layout container from the stack. - sk.at = sk.atStack.pop() - sk.num = sk.numStack.pop() - discard sk.posStack.pop() - discard sk.sizeStack.pop() - discard sk.directionStack.pop() - discard sk.anchorStack.pop() - -proc pos*(sk: Silky): Vec2 = - ## Get the current layout position. - sk.posStack[^1] +la proc size*(sk: Silky): Vec2 = ## Get the current layout size. @@ -159,15 +134,7 @@ proc stackAnchor*(sk: Silky): Anchor = proc widgetPos*(sk: Silky, size: Vec2): Vec2 = ## Compute top-left draw position for a widget of the given size. - result = sk.at - if sk.stackDirection in {RightToLeft}: - result.x -= size.x - if sk.stackDirection in {BottomToTop}: - result.y -= size.y - if sk.stackAnchor == AnchorRight and sk.stackDirection in {TopToBottom, BottomToTop}: - result.x -= size.x - if sk.stackAnchor == AnchorBottom and sk.stackDirection in {LeftToRight, RightToLeft}: - result.y -= size.y + layoutWidgetPos(sk.at, size, sk.stackDirection, sk.stackAnchor) proc pushClipRect*(sk: Silky, rect: Rect) = ## Push a new clip rectangle onto the stack. @@ -190,17 +157,10 @@ proc instanceCount*(sk: Silky): int = proc advance*(sk: Silky, amount: Vec2) = ## Advance the position. + let spacing = sk.theme.spacing.float32 sk.stretchMin = min(sk.stretchMin, sk.at) - sk.stretchMax = max(sk.stretchMax, 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 + sk.stretchMax = max(sk.stretchMax, sk.at + amount + vec2(spacing)) + sk.at += layoutAdvanceDelta(amount, sk.stackDirection, spacing) inc sk.num proc getImageSize*(sk: Silky, image: string): Vec2 = diff --git a/src/silky/layout.nim b/src/silky/layout.nim new file mode 100644 index 0000000..d66aec5 --- /dev/null +++ b/src/silky/layout.nim @@ -0,0 +1,65 @@ +import + vmath, bumpy, + common + +type + LayoutBasis* = object + ## Stores vectors used by the layout solver for one direction-anchor pair. + 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 layoutBasis*(direction: StackDirection, anchor: Anchor): LayoutBasis = + ## Returns the table-driven basis vectors for the given direction and anchor. + let paddingDir = PaddingDirs[direction.ord][anchor.ord] + result = LayoutBasis( + mainDir: MainDirs[direction.ord], + paddingDir: paddingDir, + sizeSign: vec2( + if paddingDir.x < 0: 1f else: 0f, + if paddingDir.y < 0: 1f else: 0f + ) + ) + +proc layoutStart*(pos, size: Vec2, direction: StackDirection, anchor: Anchor): Vec2 = + ## Returns the initial layout cursor after anchor growth is applied. + let basis = layoutBasis(direction, anchor) + pos + size * basis.sizeSign + +proc layoutPaddingOffset*(padding: Vec2, direction: StackDirection, anchor: Anchor): Vec2 = + ## Returns the signed padding offset for the selected direction and anchor. + let basis = layoutBasis(direction, anchor) + padding * basis.paddingDir + +proc layoutWidgetPos*(at, widgetSize: Vec2, direction: StackDirection, anchor: Anchor): Vec2 = + ## Returns the top-left draw position for a widget. + let basis = layoutBasis(direction, anchor) + at + widgetSize * basis.sizeSign * basis.paddingDir + +proc layoutAdvanceDelta*(amount: Vec2, direction: StackDirection, spacing: float32): Vec2 = + ## Returns the cursor delta for one placed child. + let basis = layoutBasis(direction, AnchorLeft) + (amount + vec2(spacing)) * basis.mainDir + +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/semantic.nim b/src/silky/semantic.nim index 2f62ed9..963ef2c 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, - atlas, common + atlas, common, layout type WidgetState* = object @@ -253,25 +253,12 @@ proc pushLayout*(sk: Silky, pos: Vec2, size: Vec2, direction: StackDirection = T ## 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.anchorStack.add(anchor) + sk.at = layoutStart(pos, size, direction, anchor) sk.stretchMax = sk.at sk.stretchMin = sk.at - case direction: - of TopToBottom: - sk.at = pos - if anchor == AnchorRight: sk.at.x += size.x - of BottomToTop: - sk.at = pos + vec2(0, size.y) - if anchor == AnchorRight: sk.at.x += size.x - of LeftToRight: - sk.at = pos - if anchor == AnchorBottom: sk.at.y += size.y - of RightToLeft: - sk.at = pos + vec2(size.x, 0) - if anchor == AnchorBottom: sk.at.y += size.y proc popLayout*(sk: Silky) = ## Pops the current layout region from the stack. @@ -303,15 +290,7 @@ proc stackAnchor*(sk: Silky): Anchor = proc widgetPos*(sk: Silky, size: Vec2): Vec2 = ## Compute top-left draw position for a widget of the given size. - result = sk.at - if sk.stackDirection in {RightToLeft}: - result.x -= size.x - if sk.stackDirection in {BottomToTop}: - result.y -= size.y - if sk.stackAnchor == AnchorRight and sk.stackDirection in {TopToBottom, BottomToTop}: - result.x -= size.x - if sk.stackAnchor == AnchorBottom and sk.stackDirection in {LeftToRight, RightToLeft}: - result.y -= size.y + layoutWidgetPos(sk.at, size, sk.stackDirection, sk.stackAnchor) proc pushClipRect*(sk: Silky, rect: Rect) = ## Pushes a clipping rectangle onto the stack. @@ -327,17 +306,10 @@ proc clipRect*(sk: Silky): Rect = proc advance*(sk: Silky, amount: Vec2) = ## Advances the cursor position by the given amount. + let spacing = sk.theme.spacing.float32 sk.stretchMin = min(sk.stretchMin, sk.at) - sk.stretchMax = max(sk.stretchMax, 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 + sk.stretchMax = max(sk.stretchMax, sk.at + amount + vec2(spacing)) + sk.at += layoutAdvanceDelta(amount, sk.stackDirection, spacing) proc getImageSize*(sk: Silky, image: string): Vec2 = ## Returns the size of an image from the atlas. diff --git a/tests/test_layout.nim b/tests/test_layout.nim new file mode 100644 index 0000000..fb39428 --- /dev/null +++ b/tests/test_layout.nim @@ -0,0 +1,68 @@ +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 "Basis, start, and widget position": + let + containerPos = vec2(10, 20) + containerSize = vec2(100, 50) + childSize = vec2(25, 15) + for c in Cases: + let + basis = layoutBasis(c.direction, c.anchor) + startPos = layoutStart(containerPos, containerSize, c.direction, c.anchor) + widgetPos = layoutWidgetPos(startPos, childSize, c.direction, c.anchor) + check startPos == c.expectedStart + check widgetPos == c.expectedPos + check (basis.mainDir.x == 0) xor (basis.mainDir.y == 0) + check abs(basis.mainDir.x) + abs(basis.mainDir.y) == 1 + + test "Padding offset and advance delta": + let + padding = vec2(8, 6) + amount = vec2(25, 15) + spacing = 4'f32 + check layoutPaddingOffset(padding, TopToBottom, AnchorLeft) == vec2(8, 6) + check layoutPaddingOffset(padding, TopToBottom, AnchorRight) == vec2(-8, 6) + check layoutPaddingOffset(padding, BottomToTop, AnchorLeft) == vec2(8, -6) + check layoutPaddingOffset(padding, BottomToTop, AnchorRight) == vec2(-8, -6) + check layoutPaddingOffset(padding, LeftToRight, AnchorTop) == vec2(8, 6) + check layoutPaddingOffset(padding, LeftToRight, AnchorBottom) == vec2(8, -6) + check layoutPaddingOffset(padding, RightToLeft, AnchorTop) == vec2(-8, 6) + check layoutPaddingOffset(padding, RightToLeft, AnchorBottom) == vec2(-8, -6) + check layoutAdvanceDelta(amount, TopToBottom, spacing) == vec2(0, 19) + check layoutAdvanceDelta(amount, BottomToTop, spacing) == vec2(0, -19) + check layoutAdvanceDelta(amount, LeftToRight, spacing) == vec2(29, 0) + check layoutAdvanceDelta(amount, RightToLeft, 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 From 831f131fbdc0e29c2a418bfa58ead469e225ef0d Mon Sep 17 00:00:00 2001 From: treeform Date: Thu, 19 Feb 2026 06:14:14 -0800 Subject: [PATCH 19/22] f --- src/silky/drawing.nim | 68 ++++++++++++++++++------------ src/silky/layout.nim | 96 ++++++++++++++++++++++++++++++++++--------- tests/test_layout.nim | 37 +++++++++++++++-- 3 files changed, 153 insertions(+), 48 deletions(-) diff --git a/src/silky/drawing.nim b/src/silky/drawing.nim index 1278c23..47184f4 100644 --- a/src/silky/drawing.nim +++ b/src/silky/drawing.nim @@ -29,14 +29,10 @@ type inFrame: bool = false at*: Vec2 num*: int - atStack: seq[Vec2] - numStack: seq[int] - posStack: seq[Vec2] - sizeStack: seq[Vec2] stretchMax*: Vec2 stretchMin*: Vec2 - directionStack: seq[StackDirection] - anchorStack: seq[Anchor] + layoutStack: seq[Layout] + layoutState: Layout textStyle*: string = "Default" padding*: float32 = 12 theme*: Theme = Theme() @@ -96,6 +92,20 @@ proc popTheme*(sk: Silky) = ## Restore the previous theme from the stack. sk.theme = sk.themeStack.pop() +proc captureLayoutState(sk: Silky) = + ## Copies public layout fields into the active layout state. + sk.layoutState.at = sk.at + sk.layoutState.num = sk.num + sk.layoutState.stretchMin = sk.stretchMin + sk.layoutState.stretchMax = sk.stretchMax + +proc applyLayoutState(sk: Silky) = + ## Copies active layout state into public layout fields. + sk.at = sk.layoutState.at + sk.num = sk.layoutState.num + sk.stretchMin = sk.layoutState.stretchMin + sk.stretchMax = sk.layoutState.stretchMax + proc pushLayout*( sk: Silky, pos: Vec2, @@ -104,37 +114,44 @@ proc pushLayout*( anchor: Anchor = AnchorLeft ) = ## Push a new layout container onto the stack. - sk.atStack.add(sk.at) - sk.numStack.add(sk.num) - sk.num = 0 - sk.posStack.add(pos) - sk.sizeStack.add(size) - sk.directionStack.add(direction) - sk.anchorStack.add(anchor) - sk.at = layoutStart(pos, size, direction, anchor) - sk.stretchMax = sk.at - sk.stretchMin = sk.at -la + sk.captureLayoutState() + layout.pushLayout(sk.layoutStack, sk.layoutState) + sk.layoutState = initLayout(pos, size, direction, anchor) + sk.applyLayoutState() + +proc popLayout*(sk: Silky) = + ## Pop the current layout container from the stack. + sk.captureLayoutState() + sk.layoutState = layout.popLayout(sk.layoutStack) + sk.applyLayoutState() + +proc pos*(sk: Silky): Vec2 = + ## Get the current layout position. + sk.layoutState.pos proc size*(sk: Silky): Vec2 = ## Get the current layout size. - sk.sizeStack[^1] + sk.layoutState.size proc rootSize*(sk: Silky): Vec2 = ## Get the root layout size. - sk.sizeStack[0] + if sk.layoutStack.len <= 1: + sk.layoutState.size + else: + sk.layoutStack[1].size proc stackDirection*(sk: Silky): StackDirection = ## Get the current stack direction. - sk.directionStack[^1] + sk.layoutState.direction proc stackAnchor*(sk: Silky): Anchor = ## Get the current stack anchor. - sk.anchorStack[^1] + sk.layoutState.anchor proc widgetPos*(sk: Silky, size: Vec2): Vec2 = ## Compute top-left draw position for a widget of the given size. - layoutWidgetPos(sk.at, size, sk.stackDirection, sk.stackAnchor) + sk.captureLayoutState() + layoutWidgetPos(sk.layoutState, size) proc pushClipRect*(sk: Silky, rect: Rect) = ## Push a new clip rectangle onto the stack. @@ -158,10 +175,9 @@ proc instanceCount*(sk: Silky): int = proc advance*(sk: Silky, amount: Vec2) = ## Advance the position. let spacing = sk.theme.spacing.float32 - sk.stretchMin = min(sk.stretchMin, sk.at) - sk.stretchMax = max(sk.stretchMax, sk.at + amount + vec2(spacing)) - sk.at += layoutAdvanceDelta(amount, sk.stackDirection, spacing) - inc sk.num + sk.captureLayoutState() + advanceLayout(sk.layoutState, amount, spacing) + sk.applyLayoutState() proc getImageSize*(sk: Silky, image: string): Vec2 = ## Get the size of an image in the atlas. diff --git a/src/silky/layout.nim b/src/silky/layout.nim index d66aec5..1a1e95a 100644 --- a/src/silky/layout.nim +++ b/src/silky/layout.nim @@ -3,8 +3,18 @@ import common type - LayoutBasis* = object - ## Stores vectors used by the layout solver for one direction-anchor pair. + Layout* = object + ## Stores the current layout context. + at*: Vec2 + num*: int + pos*: Vec2 + size*: Vec2 + direction*: StackDirection + anchor*: Anchor + stretchMax*: Vec2 + stretchMin*: Vec2 + + # Layout basis vectors. mainDir*: Vec2 paddingDir*: Vec2 sizeSign*: Vec2 @@ -23,37 +33,85 @@ const [vec2(0, 0), vec2(0, 0), vec2(-1, 1), vec2(-1, -1)] ] -proc layoutBasis*(direction: StackDirection, anchor: Anchor): LayoutBasis = - ## Returns the table-driven basis vectors for the given direction and anchor. - let paddingDir = PaddingDirs[direction.ord][anchor.ord] - result = LayoutBasis( - mainDir: MainDirs[direction.ord], +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 initLayout*( + pos: Vec2, + size: Vec2, + direction: StackDirection = TopToBottom, + anchor: Anchor = AnchorLeft +): Layout = + ## Creates a new layout context with computed basis and stretch at start. + var basisLayout = Layout(direction: direction, anchor: anchor) + basisLayout.applyBasis() + let + startPos = pos + size * basisLayout.sizeSign + mainDir = basisLayout.mainDir + paddingDir = basisLayout.paddingDir + sizeSign = basisLayout.sizeSign + result = Layout( + at: startPos, + num: 0, + pos: pos, + size: size, + direction: direction, + anchor: anchor, + stretchMin: startPos, + stretchMax: startPos, + mainDir: mainDir, paddingDir: paddingDir, - sizeSign: vec2( - if paddingDir.x < 0: 1f else: 0f, - if paddingDir.y < 0: 1f else: 0f - ) + sizeSign: sizeSign ) +proc pushLayout*(stack: var seq[Layout], layout: Layout) = + ## Pushes a full layout snapshot onto a stack. + stack.add(layout) + +proc popLayout*(stack: var seq[Layout]): Layout = + ## Pops and returns one full layout snapshot from a stack. + stack.pop() + proc layoutStart*(pos, size: Vec2, direction: StackDirection, anchor: Anchor): Vec2 = ## Returns the initial layout cursor after anchor growth is applied. - let basis = layoutBasis(direction, anchor) - pos + size * basis.sizeSign + var layout = Layout(direction: direction, anchor: anchor) + layout.applyBasis() + pos + size * layout.sizeSign proc layoutPaddingOffset*(padding: Vec2, direction: StackDirection, anchor: Anchor): Vec2 = ## Returns the signed padding offset for the selected direction and anchor. - let basis = layoutBasis(direction, anchor) - padding * basis.paddingDir + var layout = Layout(direction: direction, anchor: anchor) + layout.applyBasis() + padding * layout.paddingDir proc layoutWidgetPos*(at, widgetSize: Vec2, direction: StackDirection, anchor: Anchor): Vec2 = ## Returns the top-left draw position for a widget. - let basis = layoutBasis(direction, anchor) - at + widgetSize * basis.sizeSign * basis.paddingDir + var layout = Layout(at: at, direction: direction, anchor: anchor) + layout.applyBasis() + layout.at + widgetSize * layout.sizeSign * layout.paddingDir + +proc layoutWidgetPos*(layout: Layout, widgetSize: Vec2): Vec2 = + ## Returns the top-left draw position for a widget. + layout.at + widgetSize * layout.sizeSign * layout.paddingDir proc layoutAdvanceDelta*(amount: Vec2, direction: StackDirection, spacing: float32): Vec2 = ## Returns the cursor delta for one placed child. - let basis = layoutBasis(direction, AnchorLeft) - (amount + vec2(spacing)) * basis.mainDir + var layout = Layout(direction: direction, anchor: AnchorLeft) + layout.applyBasis() + (amount + vec2(spacing)) * layout.mainDir + +proc advanceLayout*(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 += (amount + vec2(spacing)) * layout.mainDir + inc layout.num proc includeRect*(minPos: var Vec2, maxPos: var Vec2, pos: Vec2, size: Vec2) = ## Expands min and max points to include one rectangle. diff --git a/tests/test_layout.nim b/tests/test_layout.nim index fb39428..bc7e7a7 100644 --- a/tests/test_layout.nim +++ b/tests/test_layout.nim @@ -22,6 +22,27 @@ const Cases = [ ] suite "Layout module": + test "Layout stack push and pop keeps full state": + var stack: seq[Layout] + var parent = initLayout(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) + pushLayout(stack, parent) + var child = initLayout(vec2(0, 0), vec2(20, 10), LeftToRight, AnchorBottom) + advanceLayout(child, vec2(8, 6), 2'f32) + check child.num == 1 + let restored = popLayout(stack) + 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) @@ -29,13 +50,13 @@ suite "Layout module": childSize = vec2(25, 15) for c in Cases: let - basis = layoutBasis(c.direction, c.anchor) + layout = initLayout(containerPos, containerSize, c.direction, c.anchor) startPos = layoutStart(containerPos, containerSize, c.direction, c.anchor) widgetPos = layoutWidgetPos(startPos, childSize, c.direction, c.anchor) check startPos == c.expectedStart check widgetPos == c.expectedPos - check (basis.mainDir.x == 0) xor (basis.mainDir.y == 0) - check abs(basis.mainDir.x) + abs(basis.mainDir.y) == 1 + 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 @@ -66,3 +87,13 @@ suite "Layout module": 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 = initLayout(vec2(10, 20), vec2(100, 50), RightToLeft, AnchorTop) + check lay.stretchMin == lay.at + check lay.stretchMax == lay.at + advanceLayout(lay, 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 From 8ffb9490106d747dda2b22f512040ef99ec2164b Mon Sep 17 00:00:00 2001 From: treeform Date: Thu, 19 Feb 2026 06:31:08 -0800 Subject: [PATCH 20/22] major layout refactor --- examples/basicwindow/basicwindow.nim | 6 +- examples/calculator/calculator.nim | 44 +- examples/gameplayer/gameplayer.nim | 588 +++++++-------- examples/panels/panels.nim | 1040 +++++++++++++------------- examples/the7gui/the7gui.nim | 6 +- src/silky/drawing.nim | 47 +- src/silky/layout.nim | 72 +- src/silky/semantic.nim | 45 +- src/silky/textboxes.nim | 4 +- src/silky/widgets.nim | 114 +-- tests/manual_flowgrid.nim | 22 +- tests/manual_layout.nim | 6 +- tests/manual_layout2.nim | 48 +- tests/manual_layout3.nim | 48 +- tests/manual_subpixeltext.nim | 18 +- tests/manual_textalign.nim | 6 +- tests/manual_textbox.nim | 4 +- tests/manual_wordwrap.nim | 6 +- tests/test_layout.nim | 53 +- 19 files changed, 1064 insertions(+), 1113 deletions(-) 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 21a9156..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.stretchMax.x = max(sk.stretchMax.x, sk.at.x + 10) - sk.stretchMax.y = max(sk.stretchMax.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/drawing.nim b/src/silky/drawing.nim index 47184f4..7989d92 100644 --- a/src/silky/drawing.nim +++ b/src/silky/drawing.nim @@ -27,12 +27,8 @@ type Silky* = ref object ## The Silky that draws the AA pixel art sprites. inFrame: bool = false - at*: Vec2 - num*: int - stretchMax*: Vec2 - stretchMin*: Vec2 + layout*: Layout layoutStack: seq[Layout] - layoutState: Layout textStyle*: string = "Default" padding*: float32 = 12 theme*: Theme = Theme() @@ -92,20 +88,6 @@ proc popTheme*(sk: Silky) = ## Restore the previous theme from the stack. sk.theme = sk.themeStack.pop() -proc captureLayoutState(sk: Silky) = - ## Copies public layout fields into the active layout state. - sk.layoutState.at = sk.at - sk.layoutState.num = sk.num - sk.layoutState.stretchMin = sk.stretchMin - sk.layoutState.stretchMax = sk.stretchMax - -proc applyLayoutState(sk: Silky) = - ## Copies active layout state into public layout fields. - sk.at = sk.layoutState.at - sk.num = sk.layoutState.num - sk.stretchMin = sk.layoutState.stretchMin - sk.stretchMax = sk.layoutState.stretchMax - proc pushLayout*( sk: Silky, pos: Vec2, @@ -114,44 +96,39 @@ proc pushLayout*( anchor: Anchor = AnchorLeft ) = ## Push a new layout container onto the stack. - sk.captureLayoutState() - layout.pushLayout(sk.layoutStack, sk.layoutState) - sk.layoutState = initLayout(pos, size, direction, anchor) - sk.applyLayoutState() + 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.captureLayoutState() - sk.layoutState = layout.popLayout(sk.layoutStack) - sk.applyLayoutState() + sk.layout = sk.layoutStack.pop() proc pos*(sk: Silky): Vec2 = ## Get the current layout position. - sk.layoutState.pos + sk.layout.pos proc size*(sk: Silky): Vec2 = ## Get the current layout size. - sk.layoutState.size + sk.layout.size proc rootSize*(sk: Silky): Vec2 = ## Get the root layout size. if sk.layoutStack.len <= 1: - sk.layoutState.size + sk.layout.size else: sk.layoutStack[1].size proc stackDirection*(sk: Silky): StackDirection = ## Get the current stack direction. - sk.layoutState.direction + sk.layout.direction proc stackAnchor*(sk: Silky): Anchor = ## Get the current stack anchor. - sk.layoutState.anchor + sk.layout.anchor proc widgetPos*(sk: Silky, size: Vec2): Vec2 = ## Compute top-left draw position for a widget of the given size. - sk.captureLayoutState() - layoutWidgetPos(sk.layoutState, size) + sk.layout.widgetPos(size) proc pushClipRect*(sk: Silky, rect: Rect) = ## Push a new clip rectangle onto the stack. @@ -175,9 +152,7 @@ proc instanceCount*(sk: Silky): int = proc advance*(sk: Silky, amount: Vec2) = ## Advance the position. let spacing = sk.theme.spacing.float32 - sk.captureLayoutState() - advanceLayout(sk.layoutState, amount, spacing) - sk.applyLayoutState() + sk.layout.advance(amount, spacing) proc getImageSize*(sk: Silky, image: string): Vec2 = ## Get the size of an image in the atlas. diff --git a/src/silky/layout.nim b/src/silky/layout.nim index 1a1e95a..2b7ff40 100644 --- a/src/silky/layout.nim +++ b/src/silky/layout.nim @@ -42,75 +42,57 @@ proc applyBasis(layout: var Layout) = if layout.paddingDir.y < 0: 1f else: 0f ) -proc initLayout*( +proc init*( + layout: var Layout, pos: Vec2, size: Vec2, direction: StackDirection = TopToBottom, anchor: Anchor = AnchorLeft -): Layout = +)= ## Creates a new layout context with computed basis and stretch at start. - var basisLayout = Layout(direction: direction, anchor: anchor) - basisLayout.applyBasis() - let - startPos = pos + size * basisLayout.sizeSign - mainDir = basisLayout.mainDir - paddingDir = basisLayout.paddingDir - sizeSign = basisLayout.sizeSign - result = Layout( - at: startPos, - num: 0, + layout = Layout( pos: pos, size: size, direction: direction, - anchor: anchor, - stretchMin: startPos, - stretchMax: startPos, - mainDir: mainDir, - paddingDir: paddingDir, - sizeSign: sizeSign + 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 pushLayout*(stack: var seq[Layout], layout: Layout) = - ## Pushes a full layout snapshot onto a stack. - stack.add(layout) - -proc popLayout*(stack: var seq[Layout]): Layout = - ## Pops and returns one full layout snapshot from a stack. - stack.pop() +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 layoutStart*(pos, size: Vec2, direction: StackDirection, anchor: Anchor): Vec2 = +proc start*(layout: Layout): Vec2 = ## Returns the initial layout cursor after anchor growth is applied. - var layout = Layout(direction: direction, anchor: anchor) - layout.applyBasis() - pos + size * layout.sizeSign + layout.pos + layout.size * layout.sizeSign -proc layoutPaddingOffset*(padding: Vec2, direction: StackDirection, anchor: Anchor): Vec2 = - ## Returns the signed padding offset for the selected direction and anchor. - var layout = Layout(direction: direction, anchor: anchor) - layout.applyBasis() +proc paddingOffset*(layout: Layout, padding: Vec2): Vec2 = + ## Returns the signed padding offset for this layout. padding * layout.paddingDir -proc layoutWidgetPos*(at, widgetSize: Vec2, direction: StackDirection, anchor: Anchor): Vec2 = +proc widgetPos*(layout: Layout, widgetSize: Vec2): Vec2 = ## Returns the top-left draw position for a widget. - var layout = Layout(at: at, direction: direction, anchor: anchor) - layout.applyBasis() layout.at + widgetSize * layout.sizeSign * layout.paddingDir -proc layoutWidgetPos*(layout: Layout, widgetSize: Vec2): Vec2 = - ## Returns the top-left draw position for a widget. - layout.at + widgetSize * layout.sizeSign * layout.paddingDir - -proc layoutAdvanceDelta*(amount: Vec2, direction: StackDirection, spacing: float32): Vec2 = +proc advanceDelta*(layout: Layout, amount: Vec2, spacing: float32): Vec2 = ## Returns the cursor delta for one placed child. - var layout = Layout(direction: direction, anchor: AnchorLeft) - layout.applyBasis() (amount + vec2(spacing)) * layout.mainDir -proc advanceLayout*(layout: var Layout, amount: Vec2, spacing: float32) = +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 += (amount + vec2(spacing)) * layout.mainDir + layout.at += layout.advanceDelta(amount, spacing) inc layout.num proc includeRect*(minPos: var Vec2, maxPos: var Vec2, pos: Vec2, size: Vec2) = diff --git a/src/silky/semantic.nim b/src/silky/semantic.nim index 963ef2c..cb99186 100644 --- a/src/silky/semantic.nim +++ b/src/silky/semantic.nim @@ -205,14 +205,8 @@ type 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] - stretchMax*: Vec2 - stretchMin*: Vec2 - directionStack: seq[StackDirection] - anchorStack: seq[Anchor] + layout*: Layout + layoutStack: seq[Layout] textStyle*: string = "Default" padding*: float32 = 12 theme*: Theme = Theme() @@ -251,46 +245,39 @@ proc popTheme*(sk: Silky) = 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.sizeStack.add(size) - sk.directionStack.add(direction) - sk.anchorStack.add(anchor) - sk.at = layoutStart(pos, size, direction, anchor) - sk.stretchMax = sk.at - sk.stretchMin = sk.at + 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() - discard sk.anchorStack.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.anchorStack[^1] + sk.layout.anchor proc widgetPos*(sk: Silky, size: Vec2): Vec2 = ## Compute top-left draw position for a widget of the given size. - layoutWidgetPos(sk.at, size, sk.stackDirection, sk.stackAnchor) + sk.layout.widgetPos(size) proc pushClipRect*(sk: Silky, rect: Rect) = ## Pushes a clipping rectangle onto the stack. @@ -307,9 +294,7 @@ proc clipRect*(sk: Silky): Rect = proc advance*(sk: Silky, amount: Vec2) = ## Advances the cursor position by the given amount. let spacing = sk.theme.spacing.float32 - sk.stretchMin = min(sk.stretchMin, sk.at) - sk.stretchMax = max(sk.stretchMax, sk.at + amount + vec2(spacing)) - sk.at += layoutAdvanceDelta(amount, sk.stackDirection, spacing) + sk.layout.advance(amount, spacing) proc getImageSize*(sk: Silky, image: string): Vec2 = ## Returns the size of an image from the atlas. 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 17f924a..df94806 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -169,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 ) @@ -186,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 ) @@ -218,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 ) @@ -253,9 +253,9 @@ proc frameStart*(sk: Silky, id: string, framePos, frameSize: Vec2): tuple[state: )) frameState.scroll.viewPos = sk.pos frameState.scroll.viewSize = sk.size - sk.at = sk.pos + vec2(sk.theme.padding) - let originPos = sk.at - sk.at += frameState.scroll.scrollOffset() + sk.layout.at = sk.pos + vec2(sk.theme.padding) + let originPos = sk.layout.at + sk.layout.at += frameState.scroll.scrollOffset() return (frameState, originPos) proc frameEnd*(sk: Silky, window: Window, frameState: FrameState, originPos: Vec2) = @@ -265,8 +265,8 @@ proc frameEnd*(sk: Silky, window: Window, frameState: FrameState, originPos: Vec # 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.stretchMin - offset - frameState.scroll.contentMax = sk.stretchMax - offset + vec2(16) + frameState.scroll.contentMin = sk.layout.stretchMin - offset + frameState.scroll.contentMax = sk.layout.stretchMax - offset + vec2(16) # Initialize scroll for reversed anchors, then clamp. frameState.scroll.initScroll() @@ -307,7 +307,7 @@ proc button*(sk: Silky, window: Window, label: string, isEnabled: bool, isError: ## 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.at, buttonState.size) + 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) @@ -339,8 +339,8 @@ proc button*(sk: Silky, window: Window, label: string, isEnabled: bool, isError: else: "button.disabled.9patch" - sk.draw9Patch(patch, 8, sk.at, buttonState.size) - discard sk.drawText(sk.textStyle, label, sk.at + vec2(sk.theme.padding), textColor) + 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)) @@ -348,7 +348,7 @@ proc button*(sk: Silky, window: Window, label: string, isEnabled: bool, isError: 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)) proc iconButton*(sk: Silky, window: Window, image: string): bool = @@ -356,21 +356,21 @@ proc iconButton*(sk: Silky, window: Window, image: string): bool = 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]: 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.stretchMax = max(sk.stretchMax, 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) 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. @@ -381,7 +381,7 @@ proc clickableIcon*(sk: Silky, window: Window, image: string, on: bool): bool = 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]: result = true @@ -398,8 +398,8 @@ proc clickableIcon*(sk: Silky, window: Window, image: string, on: bool): bool = color = onColor else: color = offColor - sk.drawImage(image, sk.at, color) - sk.at += vec2(imageSize.x, 0) + sk.drawImage(image, sk.layout.at, color) + sk.layout.at += vec2(imageSize.x, 0) proc radioButton*[T](sk: Silky, window: Window, label: string, variable: var T, value: T, isEnabled = true) = ## Radio button. @@ -408,7 +408,7 @@ proc radioButton*[T](sk: Silky, window: Window, label: string, variable: var T, 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) @@ -418,10 +418,10 @@ proc radioButton*[T](sk: Silky, window: Window, label: string, variable: var T, let on = variable == value textColor = if isEnabled: sk.theme.defaultTextColor else: sk.theme.disabledTextColor - 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 on: "radio.on" else: "radio.off", iconPos) discard sk.drawText(sk.textStyle, label, textPos, textColor) @@ -438,7 +438,7 @@ proc checkBox*(sk: Silky, window: Window, label: string, value: var bool) = 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) @@ -446,10 +446,10 @@ proc checkBox*(sk: Silky, window: Window, 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) @@ -471,7 +471,7 @@ proc dropDown*[T](sk: Silky, window: Window, selected: var T, options: openArray 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 @@ -483,10 +483,10 @@ proc dropDown*[T](sk: Silky, window: Window, selected: var T, options: openArray 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 @@ -545,14 +545,14 @@ proc listBox*[T](sk: Silky, window: Window, id: string, items: seq[T], selectedI # 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 - sk.beginWidget("Frame", name = id, rect = rect(sk.at, vec2(outerWidth, listHeight))) - let frameCtx = sk.frameStart(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: @@ -578,7 +578,7 @@ proc progressBar*(sk: Silky, value: SomeNumber, minVal: SomeNumber, maxVal: Some 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) @@ -591,21 +591,21 @@ proc progressBar*(sk: Silky, value: SomeNumber, minVal: SomeNumber, maxVal: Some proc groupStart*(sk: Silky, p: Vec2, direction = TopToBottom, anchor = AnchorLeft) = ## Start a group. - sk.pushLayout(sk.at + p, sk.size - p, direction, anchor) + sk.pushLayout(sk.layout.at + p, sk.size - p, direction, anchor) proc groupEnd*(sk: Silky) = ## End a group. - let endMax = sk.stretchMax - let endMin = sk.stretchMin + let endMax = sk.layout.stretchMax + let endMin = sk.layout.stretchMin sk.popLayout() sk.advance(endMax - endMin) - sk.stretchMin = min(sk.stretchMin, 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. @@ -613,21 +613,21 @@ proc ribbonEnd*(sk: Silky) = 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 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) 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) proc rectangle*(sk: Silky, size: Vec2, color: ColorRGBX, label = "") = @@ -669,7 +669,7 @@ proc scrubber*[T, U](sk: Silky, window: Window, id: string, value: var T, minVal 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) @@ -755,7 +755,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. @@ -780,7 +780,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) @@ -800,7 +800,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) 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 index 10e7b29..07abbd6 100644 --- a/tests/manual_layout.nim +++ b/tests/manual_layout.nim @@ -76,7 +76,7 @@ window.onFrame = proc() = const Margin = 20.0f - sk.at = vec2(Margin, Margin) + sk.layout.at = vec2(Margin, Margin) # Title. h1text("Layout Test") @@ -110,7 +110,7 @@ window.onFrame = proc() = # Layout area. let - controlsBottom = sk.at.y + 8 + controlsBottom = sk.layout.at.y + 8 areaPos = vec2(Margin, controlsBottom) areaW = window.size.x.float32 - Margin * 2 areaH = window.size.y.float32 - controlsBottom - Margin @@ -134,7 +134,7 @@ window.onFrame = proc() = # Frame time. 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_layout2.nim b/tests/manual_layout2.nim index 16e3d51..5488685 100644 --- a/tests/manual_layout2.nim +++ b/tests/manual_layout2.nim @@ -79,7 +79,7 @@ window.onFrame = proc() = const Margin = 20.0f - sk.at = vec2(Margin, Margin) + sk.layout.at = vec2(Margin, Margin) # Title. h1text("Layout Test") @@ -116,7 +116,7 @@ window.onFrame = proc() = # Layout area. let - controlsBottom = sk.at.y + 8 + controlsBottom = sk.layout.at.y + 8 areaPos = vec2(Margin, controlsBottom) areaW = window.size.x.float32 - Margin * 2 areaH = window.size.y.float32 - controlsBottom - Margin @@ -197,48 +197,48 @@ window.onFrame = proc() = sk.pushLayout(areaPos, areaSize, stackDir, stackAnc) - sk.stretchMax = sk.at + sk.layout.stretchMax = sk.layout.at proc drawStep() = if currentStep == step.int: - sk.drawRect(sk.at - vec2(3, 3), vec2(6, 6), rgbx(255, 0, 0, 255)) - sk.drawRect(sk.stretchMin, sk.stretchMax - sk.stretchMin, rgbx(60, 60, 60, 60)) + 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.at - echo "stretchMin: ", sk.stretchMin - echo "stretchMax: ", sk.stretchMax + echo "at: ", sk.layout.at + echo "stretchMin: ", sk.layout.stretchMin + echo "stretchMax: ", sk.layout.stretchMax inc currentStep drawStep() - sk.at += areaSize * si - sk.stretchMin = sk.at - sk.stretchMax = sk.at + sk.layout.at += areaSize * si + sk.layout.stretchMin = sk.layout.at + sk.layout.stretchMax = sk.layout.at drawStep() - sk.at += padding * pd - sk.stretchMin = min(sk.stretchMin, sk.at + padding * pd) - sk.stretchMax = max(sk.stretchMax, sk.at + padding * pd) + 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.num > 0: - sk.at += spacing * dr - sk.stretchMin = min(sk.stretchMin, sk.at + spacing * pd) - sk.stretchMax = max(sk.stretchMax, sk.at + spacing * pd) + if sk.layout.num > 0: + 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] - let pos = sk.at + size * si * pd + let pos = sk.layout.at + size * si * pd sk.drawRect(pos, size, color) let label = $(i + 1) discard sk.drawText( @@ -247,10 +247,10 @@ window.onFrame = proc() = hAlign = CenterAlign, vAlign = MiddleAlign ) - sk.stretchMin = min(sk.stretchMin, sk.at + size * pd + padding * pd) - sk.stretchMax = max(sk.stretchMax, sk.at + size * pd + padding * pd) - sk.at += size * dr - inc sk.num + 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() @@ -261,7 +261,7 @@ window.onFrame = proc() = # Frame time. 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_layout3.nim b/tests/manual_layout3.nim index 33b8636..166a30a 100644 --- a/tests/manual_layout3.nim +++ b/tests/manual_layout3.nim @@ -79,7 +79,7 @@ window.onFrame = proc() = const Margin = 20.0f - sk.at = vec2(Margin, Margin) + sk.layout.at = vec2(Margin, Margin) # Title. h1text("Layout Test") @@ -115,7 +115,7 @@ window.onFrame = proc() = # Layout area. let - controlsBottom = sk.at.y + 8 + controlsBottom = sk.layout.at.y + 8 areaPos = vec2(Margin, controlsBottom) areaW = window.size.x.float32 - Margin * 2 areaH = window.size.y.float32 - controlsBottom - Margin @@ -165,48 +165,48 @@ window.onFrame = proc() = sk.pushLayout(areaPos, areaSize, stackDir, stackAnc) - sk.stretchMax = sk.at + sk.layout.stretchMax = sk.layout.at proc drawStep() = if currentStep == step.int: - sk.drawRect(sk.at - vec2(3, 3), vec2(6, 6), rgbx(255, 0, 0, 255)) - sk.drawRect(sk.stretchMin, sk.stretchMax - sk.stretchMin, rgbx(60, 60, 60, 60)) + 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.at - echo "stretchMin: ", sk.stretchMin - echo "stretchMax: ", sk.stretchMax + echo "at: ", sk.layout.at + echo "stretchMin: ", sk.layout.stretchMin + echo "stretchMax: ", sk.layout.stretchMax inc currentStep drawStep() - sk.at += areaSize * sizeSign - sk.stretchMin = sk.at - sk.stretchMax = sk.at + sk.layout.at += areaSize * sizeSign + sk.layout.stretchMin = sk.layout.at + sk.layout.stretchMax = sk.layout.at drawStep() - sk.at += padding * paddingDir - sk.stretchMin = min(sk.stretchMin, sk.at + padding * paddingDir) - sk.stretchMax = max(sk.stretchMax, sk.at + padding * paddingDir) + 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.num > 0: - sk.at += spacing * mainDir - sk.stretchMin = min(sk.stretchMin, sk.at + spacing * paddingDir) - sk.stretchMax = max(sk.stretchMax, sk.at + spacing * paddingDir) + 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.at + size * sizeSign * paddingDir + let pos = sk.layout.at + size * sizeSign * paddingDir sk.drawRect(pos, size, color) let label = $(i + 1) discard sk.drawText( @@ -215,10 +215,10 @@ window.onFrame = proc() = hAlign = CenterAlign, vAlign = MiddleAlign ) - sk.stretchMin = min(sk.stretchMin, sk.at + size * paddingDir + padding * paddingDir) - sk.stretchMax = max(sk.stretchMax, sk.at + size * paddingDir + padding * paddingDir) - sk.at += size * mainDir - inc sk.num + 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() @@ -228,7 +228,7 @@ window.onFrame = proc() = # Frame time. 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_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 index 5fdcfea..62f65d2 100644 --- a/tests/manual_textalign.nim +++ b/tests/manual_textalign.nim @@ -66,7 +66,7 @@ window.onFrame = proc() = ) # Horizontal alignment radio buttons. - sk.at = vec2(Margin, Margin + titleSize.y + 16) + sk.layout.at = vec2(Margin, Margin + titleSize.y + 16) text("Horizontal:") group(vec2(0, 0), LeftToRight): radioButton("Left", hAlignVal, 0) @@ -82,7 +82,7 @@ window.onFrame = proc() = # Text display area. let - controlsBottom = sk.at.y + 8 + controlsBottom = sk.layout.at.y + 8 areaPos = vec2(Margin, controlsBottom) areaW = window.size.x.float32 - Margin * 2 areaH = window.size.y.float32 - controlsBottom - Margin @@ -117,7 +117,7 @@ window.onFrame = proc() = # Frame time. 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_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/test_layout.nim b/tests/test_layout.nim index bc7e7a7..3ac1919 100644 --- a/tests/test_layout.nim +++ b/tests/test_layout.nim @@ -24,16 +24,16 @@ const Cases = [ suite "Layout module": test "Layout stack push and pop keeps full state": var stack: seq[Layout] - var parent = initLayout(vec2(10, 20), vec2(100, 50), TopToBottom, AnchorRight) + 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) - pushLayout(stack, parent) - var child = initLayout(vec2(0, 0), vec2(20, 10), LeftToRight, AnchorBottom) - advanceLayout(child, vec2(8, 6), 2'f32) + 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 = popLayout(stack) + let restored = stack.pop() check restored.at == vec2(33, 44) check restored.num == 7 check restored.pos == vec2(10, 20) @@ -50,9 +50,9 @@ suite "Layout module": childSize = vec2(25, 15) for c in Cases: let - layout = initLayout(containerPos, containerSize, c.direction, c.anchor) - startPos = layoutStart(containerPos, containerSize, c.direction, c.anchor) - widgetPos = layoutWidgetPos(startPos, childSize, c.direction, c.anchor) + 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) @@ -63,18 +63,27 @@ suite "Layout module": padding = vec2(8, 6) amount = vec2(25, 15) spacing = 4'f32 - check layoutPaddingOffset(padding, TopToBottom, AnchorLeft) == vec2(8, 6) - check layoutPaddingOffset(padding, TopToBottom, AnchorRight) == vec2(-8, 6) - check layoutPaddingOffset(padding, BottomToTop, AnchorLeft) == vec2(8, -6) - check layoutPaddingOffset(padding, BottomToTop, AnchorRight) == vec2(-8, -6) - check layoutPaddingOffset(padding, LeftToRight, AnchorTop) == vec2(8, 6) - check layoutPaddingOffset(padding, LeftToRight, AnchorBottom) == vec2(8, -6) - check layoutPaddingOffset(padding, RightToLeft, AnchorTop) == vec2(-8, 6) - check layoutPaddingOffset(padding, RightToLeft, AnchorBottom) == vec2(-8, -6) - check layoutAdvanceDelta(amount, TopToBottom, spacing) == vec2(0, 19) - check layoutAdvanceDelta(amount, BottomToTop, spacing) == vec2(0, -19) - check layoutAdvanceDelta(amount, LeftToRight, spacing) == vec2(29, 0) - check layoutAdvanceDelta(amount, RightToLeft, spacing) == vec2(-29, 0) + 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 @@ -89,10 +98,10 @@ suite "Layout module": check r.w == 35 and r.h == 40 test "Advance updates stretch in layout object": - var lay = initLayout(vec2(10, 20), vec2(100, 50), RightToLeft, AnchorTop) + var lay = newLayout(vec2(10, 20), vec2(100, 50), RightToLeft, AnchorTop) check lay.stretchMin == lay.at check lay.stretchMax == lay.at - advanceLayout(lay, vec2(25, 15), 4'f32) + 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) From 5f55ed17bf77cdd3f00b9268c095932cda6b4e61 Mon Sep 17 00:00:00 2001 From: treeform Date: Thu, 19 Feb 2026 07:06:49 -0800 Subject: [PATCH 21/22] more boxes --- tests/manual_layout.nim | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/manual_layout.nim b/tests/manual_layout.nim index 07abbd6..b285a4a 100644 --- a/tests/manual_layout.nim +++ b/tests/manual_layout.nim @@ -42,11 +42,11 @@ const vec2(32, 64), vec2(80, 40), vec2(40, 80), - vec2(56, 56), - vec2(72, 36), - vec2(36, 72), - vec2(60, 44), - vec2(44, 60), + 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") From f03e62e793d032cf516de32b01267c34c148184c Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 22 Feb 2026 11:02:23 -0800 Subject: [PATCH 22/22] f --- src/silky/layout.nim | 28 +++++++++++--------- src/silky/scrollbars.nim | 6 ++--- src/silky/widgets.nim | 55 ++++++++++++++++++++++++++-------------- tests/manual_layout2.nim | 15 ++++++++--- tests/manual_layout3.nim | 1 + 5 files changed, 68 insertions(+), 37 deletions(-) diff --git a/src/silky/layout.nim b/src/silky/layout.nim index 2b7ff40..bd4f6aa 100644 --- a/src/silky/layout.nim +++ b/src/silky/layout.nim @@ -5,14 +5,14 @@ import type Layout* = object ## Stores the current layout context. - at*: Vec2 - num*: int - pos*: Vec2 - size*: Vec2 - direction*: StackDirection - anchor*: Anchor - stretchMax*: Vec2 - stretchMin*: Vec2 + 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 @@ -72,18 +72,22 @@ proc newLayout*( ## Creates and returns a fully initialized layout context. result.init(pos, size, direction, anchor) -proc start*(layout: Layout): Vec2 = +proc applyAnchor*(layout: var Layout) = ## Returns the initial layout cursor after anchor growth is applied. - layout.pos + layout.size * layout.sizeSign + layout.at = layout.pos + layout.size * layout.sizeSign -proc paddingOffset*(layout: Layout, padding: Vec2): Vec2 = +proc applyPadding*(layout: var Layout, padding: Vec2) = ## Returns the signed padding offset for this layout. - padding * layout.paddingDir + 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 diff --git a/src/silky/scrollbars.nim b/src/silky/scrollbars.nim index acf0da2..2be3ef5 100644 --- a/src/silky/scrollbars.nim +++ b/src/silky/scrollbars.nim @@ -58,14 +58,14 @@ proc scrollOffset*(sa: ScrollArea): Vec2 = ## Return the translation to apply to content before drawing. result = -sa.scrollPos -proc applyWheel*(sa: var ScrollArea, delta: Vec2, speed: float32) = +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 * speed + 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 * speed + 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] = diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index df94806..658b04c 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -3,14 +3,14 @@ import vmath, bumpy, chroma when defined(silkyTesting): - import semantic, testing, common, scrollbars + import semantic, testing, common, scrollbars, layout else: - import drawing, common, scrollbars, 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 @@ -238,7 +238,7 @@ proc subWindowEnd*(sk: Silky, window: Window, subWindowState: SubWindowState) = sk.popLayout() -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() @@ -251,14 +251,11 @@ proc frameStart*(sk: Silky, id: string, framePos, frameSize: Vec2): tuple[state: sk.size.x - 2, sk.size.y - 2 )) - frameState.scroll.viewPos = sk.pos - frameState.scroll.viewSize = sk.size - sk.layout.at = sk.pos + vec2(sk.theme.padding) - let originPos = sk.layout.at - sk.layout.at += frameState.scroll.scrollOffset() - return (frameState, originPos) - -proc frameEnd*(sk: Silky, window: Window, frameState: FrameState, originPos: Vec2) = + sk.layout.applyAnchor() + sk.layout.applyPadding(vec2(sk.theme.padding.float32)) + return frameState + +proc frameEnd*(sk: Silky, window: Window, frameState: FrameState) = ## Finish a scrollable frame and handle scrollbars. frameState.scroll.releaseIfUp(window.buttonDown[MouseLeft]) @@ -266,7 +263,7 @@ proc frameEnd*(sk: Silky, window: Window, frameState: FrameState, originPos: Vec # 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 + vec2(16) + frameState.scroll.contentMax = sk.layout.stretchMax - offset # Initialize scroll for reversed anchors, then clamp. frameState.scroll.initScroll() @@ -274,7 +271,27 @@ proc frameEnd*(sk: Silky, window: Window, frameState: FrameState, originPos: Vec # Scroll wheel. if sk.mouseInsideClip(window, rect(sk.pos, sk.size)): - frameState.scroll.applyWheel(window.scrollDelta.vec2, ScrollSpeed) + frameState.scroll.applyWheel(window.scrollDelta.vec2 * ScrollSpeed) + + # 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) + + # 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) + + 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 Y scrollbar. if frameState.scroll.needsScrollY: @@ -966,21 +983,21 @@ template group*(p: Vec2, direction = TopToBottom, body: untyped) = template frame*(p, s: Vec2, body: untyped) = ## Create a frame. sk.beginWidget("Frame", name = "Frame", rect = rect(p, s)) - sk.frameStart(p, s) + let frameState = sk.frameStart(p, s) try: body finally: - sk.frameEnd() + 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 frameCtx = sk.frameStart(id, framePos, frameSize) + let frameState = sk.frameStart(id, framePos, frameSize) try: body finally: - sk.frameEnd(window, frameCtx.state, frameCtx.originPos) + sk.frameEnd(window, frameState) sk.endWidget() template ribbon*(p, s: Vec2, tint: ColorRGBX, body: untyped) = diff --git a/tests/manual_layout2.nim b/tests/manual_layout2.nim index 5488685..f905afb 100644 --- a/tests/manual_layout2.nim +++ b/tests/manual_layout2.nim @@ -136,7 +136,8 @@ window.onFrame = proc() = #frame("layoutArea", areaPos, areaSize): block: - sk.drawRect(areaPos, areaSize, rgbx(10, 10, 10, 10)) + sk.layout.num = 0 + # group(vec2(0, 0), stackDir, stackAnc): # for i in 0 ..< n: # let color = BoxColors[i mod BoxColors.len] @@ -195,9 +196,12 @@ window.onFrame = proc() = var currentStep = 0 - sk.pushLayout(areaPos, areaSize, stackDir, stackAnc) + #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: @@ -214,12 +218,14 @@ window.onFrame = proc() = 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) @@ -229,6 +235,7 @@ window.onFrame = proc() = 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) @@ -238,6 +245,7 @@ window.onFrame = proc() = 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) @@ -247,6 +255,7 @@ window.onFrame = proc() = 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 @@ -254,7 +263,7 @@ window.onFrame = proc() = drawStep() - sk.popLayout() + #sk.popLayout() sk.popTheme() diff --git a/tests/manual_layout3.nim b/tests/manual_layout3.nim index 166a30a..ad4747b 100644 --- a/tests/manual_layout3.nim +++ b/tests/manual_layout3.nim @@ -166,6 +166,7 @@ window.onFrame = proc() = sk.pushLayout(areaPos, areaSize, stackDir, stackAnc) sk.layout.stretchMax = sk.layout.at + # sk.layout.stretchMin = sk.layout.at proc drawStep() = if currentStep == step.int: