From d4ef74e720319fc59020e96ab5715c0fddfeb4fc Mon Sep 17 00:00:00 2001 From: treeform Date: Fri, 10 Jul 2026 18:24:16 -1000 Subject: [PATCH 1/4] Add two-pen immediate-mode layout: directions, hug, fill, indent, center, origin-relative scrolling Implements the layout theory in docs/layout_theory.md: a layout scope is two pens (pen P, stretch pen S) plus a sign vector sigma pointing from the origin corner into the region. All direction math lives in placedPos/advancePen in the new src/silky/layout.nim; the old per-direction case blocks and parallel stacks are gone. - All four stack directions work through one rule (flip sigma). - Per-axis sizing modes: fixed, fill (remaining known space), and hug (children extent measured by the stretch pen; chrome emitted at close and rotated behind the children in the vertex buffer). - Reverse-direction hug via vertex-span translation (position is relative until close). - indent blocks (cross-axis pen nudge), center() when both sizes known. - Scrolling is origin-relative: scrollPos is the distance t from the origin corner, clamped to overflow; content translates by -sigma*t. A bottom-origin frame rests scrolled to the bottom with its thumb resting at the bottom of the track. One shared frameScrollbars proc replaces the duplicated widget/DSL scrollbar code. - tests/manual_layout.nim: interactive playground (directions, sizing modes, indent, centering, scroll frames). - tests/test_layout.nim: 14 headless tests asserting exact rects for every feature via the semantic layer. - newTestHarness now wires sk.window (interactive widgets segfaulted). All 86 existing example tests pass unchanged. Co-Authored-By: Claude Fable 5 --- docs/layout_plan.md | 191 +++++++++++++++++++++ docs/layout_theory.md | 295 ++++++++++++++++++++++++++++++++ src/silky/contexts.nim | 131 ++++++++------ src/silky/dsl.nim | 368 +++++++++++++++++++++++++++++----------- src/silky/layout.nim | 123 ++++++++++++++ src/silky/semantics.nim | 124 +++++++++----- src/silky/testing.nim | 1 + src/silky/widgets.nim | 222 ++++++++++++++---------- tests/manual_layout.nim | 203 ++++++++++++++++++++++ tests/test_layout.nim | 213 +++++++++++++++++++++++ 10 files changed, 1579 insertions(+), 292 deletions(-) create mode 100644 docs/layout_plan.md create mode 100644 docs/layout_theory.md create mode 100644 src/silky/layout.nim create mode 100644 tests/manual_layout.nim create mode 100644 tests/test_layout.nim diff --git a/docs/layout_plan.md b/docs/layout_plan.md new file mode 100644 index 0000000..48c7a21 --- /dev/null +++ b/docs/layout_plan.md @@ -0,0 +1,191 @@ +# Immediate-Mode Layout — Implementation Plan + +This plan realizes the axioms in [layout_theory.md](layout_theory.md). +Read that first; nothing here introduces a concept the theory doesn't. +The job is to make the code satisfy the axioms: today `sk.at` and +`sk.stretchAt` are the two pens (A1), but the axioms around them are +enforced nowhere in particular — direction math is smeared across +`pushLayout`, `advance`, `inferNodeSize`, and the DSL. + +## Why the previous attempt (`layout` branch) stalled + +`manual_layout3.nim` treated *direction* and *anchor* as independent +4-way choices, forcing a 4×4 table of hand-tuned sign vectors +(`mainDirs`, `paddingDirs`, `sizeSign`) with sign-sensitive +`stretchMin`/`stretchMax` updates at every step. That is axioms A3–A5 +written out pointwise, one combination at a time. The theory collapses +it: the anchor *is* the origin corner, the origin corner *is* the sign +vector `σ` (A2), and one `place()` function applies A3–A5 for every +direction at once (T1). + +## The key implementation enabler: the vertex buffer is patchable + +The drawer keeps plain CPU-side `seq[DrawerVertex]` per layer +(`drawers/ogl.nim`), uploaded once at frame end, and each vertex is +self-contained (pos, uv, color, clipPos/clipSize, maskUv). This is what +lets axiom A6 (relativity — a finished scope is a rigid, placeable box) +be realized within a single frame: + +1. **Reserve** a vertex span before children draw, **fill it in** at + scope close → a hug parent draws behind children it hadn't seen yet + (T2). +2. **Translate** an emitted span (pos and clipPos) at scope close → + deferred placement of unknown-size boxes (T6). + +Both are O(1)-per-vertex writes into slots already allocated this frame — +no cache, no retained tree. + +## Sizing modes (A8 in code) + +Per-axis mode on each node: + +| Mode | Known at... | Meaning | +|---|---|---| +| `Fixed` | begin | explicit px, or measured content (text, image) | +| `Fill` | begin, iff parent axis known | remaining region space (T3) | +| `Hug` | close | content box `|S − O|` (T2) | + +Legality follows T3/T5: Fill or centering inside an unknown axis +degrades to content size with a debug-build warning. + +## The layout scope + +Today's four parallel stacks (`atStack`, `posStack`, `sizeStack`, +`directionStack`) plus the single global `stretchAt` become one stack of +scopes — a scope is the theory's tuple `(O, σ, P, S)` plus bookkeeping: + +```nim +LayoutScope = object + origin: Vec2 # O — the anchor corner, padded inward (A2) + signs: Vec2 # σ — (+1|-1) per axis (A2) + mainAxis: int # 0 = x, 1 = y (from the stack direction) + regionSize: Vec2 # 0 where unknown + knownW, knownH: bool # A8 + pen: Vec2 # P (A1) + stretch: Vec2 # S (A1); starts at origin, moves only along σ + itemSpacing: float32 + indent: float32 # cross-axis pen offset (T4) + spanStart: (int, int) # (layer, vertexIndex) at open — for A6 patch/shift + placedCount: int # spacing goes between elements, not before the first +``` + +Exactly two procs own the axioms — no other code moves a pen: + +```nim +proc place(scope: var LayoutScope, size: Vec2): Vec2 + ## A3 + A4 + A5 in one motion, valid for all four σ: + ## returns the element's min corner (box spans pen → pen + σ·size), + ## advances pen along main axis by σ·(size + spacing), + ## absorbs the far corner into stretch. + +proc contentBox(scope: LayoutScope): Rect + ## box(O, S) — what a hug parent becomes (T2), what scrollbars + ## measure (T7). +``` + +`pushLayout`/`popLayout`/`advance`/`pos`/`size` remain as thin +compatibility wrappers over the scope stack. + +## Feature-by-feature realization + +- **Four directions (T1).** `resolveNodeRect`/`inferNodeSize` route + through `place()`; the per-direction `case` blocks in `advance` and + `pushLayout` are deleted, replaced by `σ`. +- **Hug (T2).** At node begin: reserve chrome span (9-patch = 9 quads = + 54 verts, rect = 6). Children emit normally. At close: rect = + `contentBox()` + padding, patch the span, then the parent consumes the + size via its own `place()`. +- **Fill (T3).** Remaining space `R − |P − O|` along a known axis; + cross-axis stretch = known inner cross size. Unknown parent axis → + content fallback + debug warning. +- **Indent (T4).** `indent(n)` / block template adjusts `scope.indent`; + `place()` adds `σ.cross · indent` on the cross axis. +- **Centering (T5).** `center()` legal when both region and element are + known; pure arithmetic at placement, no advance. +- **Reverse-direction hug (T6).** Child laid out relative to provisional + origin; at close, `translateSpan` shifts its vertices (pos + clipPos) + into final position and the pen is corrected. Nodes that shifted + re-resolve their interaction rect at close. +- **Scrolling (T7).** `finishFrameScrollbars` measures `contentBox()` + instead of forward-only `stretchAt`, and `FrameState.scrollPos` + becomes `t`: distance from the origin corner, per axis, clamped to + the overflow — not a top-left-biased pixel offset. The child scope's + origin is translated by `−σ·t`. Thumb position and travel direction + derive from `σ`: the thumb rests at the origin edge of the track + (bottom-origin frame → thumb starts at the bottom, scrolls up; + right-origin → starts right, scrolls left). Mouse-wheel sign maps to + `dt` per axis via `σ` so wheel-down always reveals the far end. +- **Interaction on hug nodes.** `onClick`/`onHover` resolve the rect at + the moment they're called, i.e. against the content box *so far*. + Documented rule: event handlers go after the children in the body + (natural for buttons: content first, `onClick` last). No frame lag, + no rect cache. + +## API sketch (DSL additions) + +```nim +group "toolbar": + layout LeftToRight + hug() # both axes; or hugWidth()/hugHeight() + itemSpacing 4 + ... + +frame "sidebar": + width 240 # Fixed main axis + fillHeight() # Fill cross axis + ... + +rectangle "badge": + size 60, 20 + center() # T5: both known, else debug warning + +group "tree": + indent 16: # T4 + text "child": ... +``` + +Defaults stay compatible: `frame`/`group` keep today's +fill-remaining-space behavior unless told otherwise; `text`/`image` stay +content-measured. + +## Phases + +**Phase 1 — make the pens lawful (no visible behavior change)** +1. `LayoutScope` replaces the parallel stacks and global `stretchAt`; + `place()`/`contentBox()` own A3–A5; wrappers keep the old API. +2. Drawer span API: `reserveQuads(count) -> Span`, patching via a common + quad emitter shared with `drawRect`/`draw9Patch`, and + `translateSpan(span, offset)`. +3. Existing examples/tests must not move by a pixel (TopToBottom and + LeftToRight parity). + +**Phase 2 — sizing modes** +4. Per-axis `SizeMode` on `DslNode`; `width`/`height`/`size`/`fill*`/ + `hug*` verbs; known-flag propagation; debug-build legality warnings. +5. Hug via reserved chrome span (T2). +6. Route DSL rect resolution through `place()` — fixes reverse + directions for known-size children immediately (T1). +7. `center()` (T5). + +**Phase 3 — the long tail** +8. `indent` block template (T4). +9. Reverse-direction hug via `translateSpan` + interaction re-resolve + (T6). +10. Scrollbar content from `contentBox()` (T7). + +**Phase 4 — proof** +11. `examples/layouts` becomes the interactive playground the old branch + wanted: scrubbers for padding/spacing/box count, radios for the four + directions and origin corners, toggles for hug/fill/fixed — every + diagram reproduced live. +12. Golden tests via the `silkyTesting` harness: 4 directions × + {fixed, fill, hug} parents, nested hug, indent, centering, and a + BottomToTop scrolling frame. + +## Deliberately not built + +Per the theory's "what the axioms forbid" (violations of A8): fill or +centering inside hug, weighted multi-fill (`flex-grow`), and +justify/space-between — each requires seeing all siblings before placing +the first, i.e. a second pass. Degradation is predictable (content size) +and warned in debug builds. diff --git a/docs/layout_theory.md b/docs/layout_theory.md new file mode 100644 index 0000000..15d8fd2 --- /dev/null +++ b/docs/layout_theory.md @@ -0,0 +1,295 @@ +# A Theory of Immediate-Mode Layout + +Layout needs no tree, no measure pass, and no cache. It needs exactly two +points of state — two pens — and a handful of axioms about how they move. +Everything else (stack directions, hug, fill, indent, centering, +scrolling) is a theorem derived from them. + +## The two pens + +- **The pen** `P` — where the next element is drawn. +- **The stretch pen** `S` — the farthest corner any element has reached. + +That is the entire layout state of a scope. If a layout idea cannot be +expressed as motion of these two pens, it is not silky-immediate-mode and we do +not build it. + +``` +O................... O = origin corner: both pens start here +: +-------------+ : +: | first | : +: +-------------+ : +: +-------+ : +: | second| : +: +-------+ : +: P : P = pen: the next element draws here +:..................S S = stretch pen: farthest corner reached + box(O,S) = the content box, at all times +``` + +## Axioms + +**A1 — State.** A layout scope carries two points, the pen `P` and the +stretch pen `S`, and a sign vector `σ = (σx, σy)`, each component `+1` or +`-1`. Nothing else is remembered between elements. + +**A2 — Origin.** A scope begins at an *origin corner* `O`: any of the +four corners of its region (moved inward by padding). Both pens start +there: `P = S = O`. The choice of corner is exactly the choice of `σ` — +`σ` points from the origin corner into the region. The stack direction +picks which axis of `σ` is the *main* axis; the other is the *cross* +axis. Four directions × two cross signs = the four corners: you can +start anywhere and go any way. + +``` +TopToBottom BottomToTop LeftToRight RightToLeft +(origin: top) (origin: bottom) (origin: left) (origin: right) + +O---------+ +---------+ O---------+ +---------O +| [1] | | [3] | |[1][2][3]| |[3][2][1]| +| [2] | | [2] ^ | | -> | | <- | +| [3] | | | [1] | | | | | | ++------v--+ O---------+ +---------+ +---------+ + +[n] = draw order. The pen starts at O and advances along the arrow. +``` + +**A3 — Draw.** An element of size `s` drawn at pen `P` occupies the box +spanning from `P` to `P + σ·s` (componentwise). An element is drawn where +the pen is, and stretches the other way — always away from the origin +corner, never toward it. + +``` +σ = (+1,+1) σ = (-1,-1) +element grows right + down: element grows left + up: + +P-----------+ +-----------+ +| element | | element | +| v ^ | ++---------->+ +<----------P +``` + +**A4 — Advance.** After an element is drawn, the pen moves along the main +axis only: + +``` +P.main += σ.main · (s.main + spacing) +``` + +`P.cross` never moves on its own. + +**A5 — Stretch.** After an element is drawn, the stretch pen absorbs its +far corner, componentwise in the direction of `σ`: + +``` +S = farthest_σ(S, P_before + σ·s) +``` + +The stretch pen only ever moves away from the origin. `box(O, S)` is at +every moment the exact bounding box of everything drawn so far — the +*content box*. + +**A6 — Relativity.** Everything inside a scope is positioned relative to +its origin `O`. A finished scope is a rigid box `box(O, S)`; drawing +commutes with translation, so the parent may place that box anywhere and +the contents move with it. + +**A7 — Composition.** A child scope opens with its origin at the parent's +pen (its `σ` is its own). When it closes, its content box becomes an +ordinary element of size `|S − O|` in the parent, subject to A3, A4, A5. +Layout is this recursion and nothing more. + +**A8 — Knowledge.** A length is *known* if it was given (fixed), measured +(text, image), or inherited from a known region. A scope's own extent is +known only when it closes. Any rule that consumes a length may only +consume a known one; there is no way to ask about the future. + +## The axioms in one picture + +``` +Vertical layout parent (TopToBottom, origin top-left) + ++- parent ------------------------------------+ +| | padding moves O +|<-pad-> O <-pad-> | inward (A2) +| +- child 1 ---------------+ | +| | | | child advance: after +| +-------------------------+ | drawing, P moves down by +| : item spacing | s.main + spacing (A4) +| +- child 2 ---------------+ | +| |<----- child stretch ----|--------->| child stretch: take the +| +-------------------------+ | parent's known inner +| P . | width on the cross +| | . | axis (T3) +| v S | ++---------------------------------------------+ + +Horizontal layout parent (LeftToRight, origin top-left) + ++- parent ------------------------------------+ +| | +| O :item | +| +- child 1 -+:spacing +- child 2 -+ | +| | | | | | +| | | | | -> P advances right (A4) +| +-----------+ +-----------+ | +| S | ++---------------------------------------------+ +``` + +**T1 — The four directions are one rule.** No per-direction cases exist: +flipping `σ` components in A3–A5 produces all of them. (The failed +`layout` branch was an attempt to write A3–A5 pointwise as 4×4 sign +tables — every combination hand-derived, every new feature multiplying +the cases.) + +**T2 — Hug.** When a scope closes, `box(O, S)` is its size (A5), and by +A7 the parent consumes it like any fixed element. A parent hugs its +children by definition — no measuring pass, because the stretch pen was +measuring all along. Drawing the parent's own chrome *behind* children it +hasn't seen yet is an implementation concern (reserved vertex span), not +a theoretical one: by A6 the chrome is just part of the rigid box. + +**T3 — Fill.** If a scope's region length `R` is known (A8), the +remaining space along an axis is `R − |P − O|`, available right now, and +an element may take it as its size. Fill inside an unknown (hug) region +is *undefined by A8*, not unsupported: the space does not exist yet, and +no pass will come along later to invent it. + +``` +Fixed: you say it Hug: children say it Fill: parent says it + (T2) (parent must be known) + ++---- 240px ----+ O............... +- parent known -----+ +| | : [child] : | +----------------+ | +| | : [wider child]: | |<---- fill ---->| | ++---------------+ :..............S | +----------------+ | + parent becomes | child takes what | + box(O,S) + padding | remains, right now | + +--------------------+ +``` + +**T4 — Indent.** `P.cross += σ.cross · n` (and its inverse to de-indent). +Legal because A4 reserves cross-axis motion for exactly this kind of +explicit nudge; persistent for following siblings because A4 never +touches cross; safe because the stretch pen (A5) keeps recording the +truth about extent. + +``` +O ++--------------------------------------+ +| [########## row 1 ###########] | +| [###### row 2 ######] | indent +| [## row 3 ##] | indent again +| [#### row 4 ########] | (cross offset persists, A4) +| [##### row 5 #####] | de-indent ++--------------------------------------+ + the pen walks down the main axis; indent nudges where + each row begins on the cross axis +``` + +**T5 — Centering.** By A8 centering consumes two lengths — the region's +and the element's — so both must be known; then `P = center − s/2` is +plain arithmetic, a degenerate scope with no advance. "Centering only +when both sizes are known" is a consequence of the axioms, not a caveat. + +``` ++- parent: size known ------------+ +| . | +| +-- child: known --+ | +| . . . .| + |. . .| center X +| +------------------+ | +| . | +| . center Y | ++---------------------------------+ + +P = center - s/2 on each centered axis; both lengths +must already be known (A8) +``` + +**T6 — Reverse-direction hug.** A child whose extent is unknown (A8) +inside a reverse-direction parent seems paradoxical: A3 wants to place +its box against the pen, but the box's size arrives only at close. A6 +resolves it: contents are drawn relative to `O`, the finished box is +rigid, so placement may happen *at close* and the whole box translates +into position. (Implementation: shift the vertex span; theory: position +was never absolute to begin with.) + +**T7 — Scrolling.** Scrolling is not a widget; it is the natural +ramification of clipping children inside a parent. If a scope clips to a +known region `R` and its content box outgrows it, the overflow is + +``` +V = max(|S − O| − R, 0) (per axis) +``` + +A scroll offset `t ∈ [0, V]` translates the contents by `−σ·t` (legal by +A6; the clip stays on the region). Because content can only ever grow +*away* from the origin corner (A3, A5), everything about scrolling is +already decided: + +- **The scroll origin is the layout origin.** At rest (`t = 0`) the + origin edge is pinned and visible; the overflow hides at the far end. + A bottom-origin stack rests scrolled to the bottom and scrolls up. A + right-origin stack rests at the right and scrolls left. A top-origin + stack rests at the top — the familiar case, revealed as just one of + four. +- **The scrollbar starts where the layout starts.** The thumb sits at + the origin edge of the track at `t = 0` and moves away from it; its + length is the visible fraction `R / |S − O|`. The scrollbar is nothing + but the visible ratio of the two pens' separation to the region. +- **Origin-pinning falls out.** Content added to a bottom-origin log + while resting at `t = 0` keeps the origin edge pinned — chat-style + follow behavior with no special case. + +``` +Top-origin (TopToBottom) Bottom-origin (BottomToTop) +rest shows the top: rest shows the bottom: + +O--------------+-+ . [5] . overflow hides +| [1] |#| thumb rests . [4]..........: past the far +| [2] |#| at the origin +--------------+-+ (top) edge +| [3] | | (top), moves | [3] | | ++--------------+-+ away from it | [2] |#| thumb rests at +. [4] . | [1] |#| the origin +. [5]..........: overflow past O--------------+-+ (bottom), moves + the far edge up as you scroll + +[n] = draw order. Dotted = clipped content, # = scrollbar thumb. +``` + +The offset `t` persists across frames, but it is user interaction state +(like a checkbox's bool), not derived layout — the content size it is +clamped against is re-measured from the pens every frame. Works +identically in all four directions because A5 does. + +**T8 — Completeness (informal).** Fixed, fill, and hug per axis; four +origins; indent on the cross axis; nesting by A7 — this closes over the +layouts in the diagrams: stacks in any direction with anchoring, parents +hugging children or children filling parents, indentation trees, centered +dialogs, scrolling frames. Anything expressible as pens flowing corner to +corner is drawable in a single pass on the first frame. + +## What the axioms forbid + +These are not missing features; they contradict A8 (no knowledge of the +future) and are what make the single pass possible: + +- Fill or stretch inside a hug region (T3). +- Centering against an unknown size (T5). +- Weighted multi-fill (flexbox `flex-grow` ratios) — dividing remaining + space among elements requires seeing all of them first: a second pass. +- Justify/space-between — same reason. + +Each gets a predictable degradation (fall back to content size) and a +debug-build warning. + +## Correspondence to today's code + +The pens already exist: `sk.at` is `P`, `sk.stretchAt` is `S` +(`contexts.nim`). But today `stretchAt` is forward-biased (`max` only) — +A5 says it must move *in the direction of σ* — and `advance` plus +`pushLayout` hard-code fragments of A3–A5 per direction at call sites. +The implementation plan (`layout_plan.md`) is the mechanical work of +making the code satisfy the axioms: one `place()` owning A3–A5, an +origin+signs scope owning A2, spans owning A6. diff --git a/src/silky/contexts.nim b/src/silky/contexts.nim index 0aeaaf0..c5faad2 100644 --- a/src/silky/contexts.nim +++ b/src/silky/contexts.nim @@ -1,7 +1,9 @@ import - std/[tables, unicode, times], + std/[algorithm, tables, unicode, times], pixie, vmath, windy, bumpy, - silky/[atlas, profiles] + silky/[atlas, layout, profiles] + +export layout, profiles when defined(useDirectX): import silky/drawers/dx12 @@ -23,13 +25,6 @@ const SpaceRune = Rune(32) type - StackDirection* = enum - ## Direction of the current layout flow. - TopToBottom - BottomToTop - LeftToRight - RightToLeft - Theme* = object ## Theme for the Silky UI. padding*: int = 8 @@ -84,14 +79,9 @@ type ## Main Silky context shared across rendering backends. inFrame: bool = false uiScale*: float32 = 1.0 - ## Multiplies 9-patch slice borders when the atlas is baked denser (e.g. 2). + ## Multiplies 9-patch borders for a denser atlas. sliceScale*: int = 1 - at*: Vec2 - atStack: seq[Vec2] - posStack: seq[Vec2] - sizeStack: seq[Vec2] - stretchAt*: Vec2 - directionStack: seq[StackDirection] + layoutStack*: seq[LayoutScope] textStyle*: string = "Default" padding*: float32 = 12 theme*: Theme = Theme() @@ -137,51 +127,61 @@ proc popLayer*(sk: Silky) = ## Pops the current rendering layer from the stack. sk.drawer.currentLayer = sk.drawer.layerStack.pop() +proc currentScope*(sk: Silky): var LayoutScope = + ## Returns the active layout scope. + sk.layoutStack[^1] + +proc at*(sk: Silky): var Vec2 = + ## The pen P of the active scope. + sk.layoutStack[^1].pen + +proc `at=`*(sk: Silky, value: Vec2) = + sk.layoutStack[^1].pen = value + +proc stretchAt*(sk: Silky): var Vec2 = + ## The stretch pen S of the active scope. + sk.layoutStack[^1].stretch + +proc `stretchAt=`*(sk: Silky, value: Vec2) = + sk.layoutStack[^1].stretch = value + proc pushLayout*( sk: Silky, pos: Vec2, size: Vec2, - direction: StackDirection = TopToBottom + direction: StackDirection = TopToBottom, + knownW = true, + knownH = true ) = ## Pushes a new layout region onto the stack. - sk.atStack.add(sk.at) - sk.posStack.add(pos) - sk.at = pos - sk.sizeStack.add(size) - sk.directionStack.add(direction) - sk.stretchAt = sk.at - case direction: - of TopToBottom: - sk.at = pos - of BottomToTop: - sk.at = pos + vec2(0, size.y) - of LeftToRight: - sk.at = pos - of RightToLeft: - sk.at = pos + vec2(size.x, 0) + var scope = initLayoutScope(pos, size, direction, knownW, knownH) + scope.vertexLayer = sk.drawer.currentLayer + scope.vertexMark = sk.drawer.layers[scope.vertexLayer].len + sk.layoutStack.add(scope) 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() + ## Pops the current layout region, folding its stretch pen into the + ## parent so overflow keeps propagating like the old global stretchAt. + let child = sk.layoutStack.pop() + if sk.layoutStack.len > 0: + let parent = addr sk.layoutStack[^1] + parent.stretch = farthest(parent.signs, parent.stretch, child.stretch) proc pos*(sk: Silky): Vec2 = ## Returns the current layout position. - sk.posStack[^1] + sk.layoutStack[^1].regionPos proc size*(sk: Silky): Vec2 = ## Returns the current layout size. - sk.sizeStack[^1] + sk.layoutStack[^1].regionSize proc rootSize*(sk: Silky): Vec2 = ## Returns the root layout size. - sk.sizeStack[0] + sk.layoutStack[0].regionSize proc stackDirection*(sk: Silky): StackDirection = ## Returns the current stack direction. - sk.directionStack[^1] + sk.layoutStack[^1].direction proc pushRawClipRect*(sk: Silky, rect: Rect) = ## Pushes a clip rectangle without intersection. @@ -219,21 +219,44 @@ proc instanceCount*(sk: Silky): int = for i in 0 ..< sk.drawer.layers.len: result += sk.drawer.layers[i].len +proc advance*(sk: Silky, amount: Vec2, spacing: float32) = + ## Advances the current layout cursor with explicit spacing. + sk.layoutStack[^1].advancePen(amount, spacing) + proc advance*(sk: Silky, amount: Vec2) = ## Advances the current layout cursor. - 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 + sk.advance(amount, sk.theme.spacing.float32) + +proc placedAt*(sk: Silky, size: Vec2): Vec2 = + ## Returns where a box of this size lands under the current scope + ## (A3) without moving the pen. Pair with advance(). + sk.layoutStack[^1].placedPos(size) + +proc place*(sk: Silky, size: Vec2): Vec2 = + ## Places a box: returns its top-left corner and advances the pen. + result = sk.placedAt(size) + sk.advance(size) + +proc vertexMark*(sk: Silky): int = + ## Current vertex count on the active layer, for later patching. + sk.drawer.layers[sk.drawer.currentLayer].len + +proc moveVerticesBehind*(sk: Silky, layer, spanStart, chromeStart: int) = + ## Rotates vertices emitted after chromeStart to the front of the + ## span so late-drawn parent chrome renders behind its children (T2). + let total = sk.drawer.layers[layer].len + if chromeStart > spanStart and chromeStart < total: + sk.drawer.layers[layer].rotateLeft( + spanStart ..< total, chromeStart - spanStart + ) + +proc translateVertices*(sk: Silky, layer, spanStart: int, offset: Vec2) = + ## Shifts vertices emitted since spanStart; realizes A6 for boxes + ## whose position is only known at scope close (T6). + if offset == vec2(0, 0): + return + for i in spanStart ..< sk.drawer.layers[layer].len: + sk.drawer.layers[layer][i].pos += offset proc getImageSize*(sk: Silky, image: string): Vec2 = ## Returns the size of an atlas image in pixels. diff --git a/src/silky/dsl.nim b/src/silky/dsl.nim index 99ded3a..4b7a300 100644 --- a/src/silky/dsl.nim +++ b/src/silky/dsl.nim @@ -19,6 +19,13 @@ type nkComponent nkInstance + SizeMode* = enum + ## Per-axis sizing (A8): when the length becomes known. + smAuto ## Legacy default: box/content size, else fill remaining. + smFixed ## Known at begin: explicit pixels. + smFill ## Known at begin, iff the parent axis is known (T3). + smHug ## Known at close: children extent + padding (T2). + PatchSpec* = object name*: string top*, right*, bottom*, left*: int @@ -30,6 +37,13 @@ type resolvedRect*: Rect hasBox*: bool resolved*: bool + widthMode*: SizeMode + heightMode*: SizeMode + fixedSize*: Vec2 + centerXFlag*: bool + centerYFlag*: bool + chromeMark*: int + chromeLayer*: int materialized*: bool startedChildren*: bool pushedLayout*: bool @@ -160,6 +174,13 @@ proc resetDslNode(node: DslNode, sk: Silky, kind: DslNodeKind, id: string) {.mea node.resolvedRect = rect(0'f, 0'f, 0'f, 0'f) node.hasBox = false node.resolved = false + node.widthMode = smAuto + node.heightMode = smAuto + node.fixedSize = vec2(0, 0) + node.centerXFlag = false + node.centerYFlag = false + node.chromeMark = 0 + node.chromeLayer = 0 node.materialized = false node.startedChildren = false node.pushedLayout = false @@ -246,25 +267,68 @@ proc nodeTint(sk: Silky, node: DslNode): ColorRGBX = else: white() -proc inferNodeSize(sk: Silky, node: DslNode, pos: Vec2): Vec2 = - if node.hasBox: - return node.boxRect.wh +proc isHug(node: DslNode): bool {.inline.} = + node.widthMode == smHug or node.heightMode == smHug + +proc axisKnown(scope: LayoutScope, axis: int): bool {.inline.} = + if axis == 0: scope.knownW else: scope.knownH + +proc inferNodeSize(sk: Silky, node: DslNode): Vec2 = + ## Resolves per-axis size by mode (A8). Hug axes stay 0 until the + ## scope closes; fill inside an unknown axis degrades to 0 (T3). + var + content = vec2(0, 0) + hasContent = false if node.characters.len > 0: - return sk.getTextSize(node.fontName, node.characters) - if node.imageName.len > 0: - return sk.getImageSize(node.imageName) - let used = pos - sk.pos - dslVec2(max(0.0'f, sk.size.x - used.x), max(0.0'f, sk.size.y - used.y)) + content = sk.getTextSize(node.fontName, node.characters) + hasContent = true + elif node.imageName.len > 0: + content = sk.getImageSize(node.imageName) + hasContent = true + let remaining = sk.currentScope.remainingSpace() + for axis in 0 .. 1: + let mode = if axis == 0: node.widthMode else: node.heightMode + result[axis] = + case mode + of smFixed: + node.fixedSize[axis] + of smFill: + when defined(silkyLayoutDebug): + if not sk.currentScope.axisKnown(axis): + echo "[silky] fill inside a hug parent is undefined (A8): ", node.id + remaining[axis] + of smHug: + if node.kind == nkFrame: + # Frames clip and scroll; their region must be known (T7). + when defined(silkyLayoutDebug): + echo "[silky] frames cannot hug; using fill: ", node.id + remaining[axis] + else: + 0.0'f + of smAuto: + if node.hasBox: + node.boxRect.wh[axis] + elif hasContent: + content[axis] + else: + remaining[axis] proc resolveNodeRect(sk: Silky, node: DslNode): Rect = if node.resolved: return node.resolvedRect - let pos = + let size = sk.inferNodeSize(node) + var pos = if node.hasBox: sk.pos + node.boxRect.xy else: - sk.at - let size = sk.inferNodeSize(node, pos) + sk.currentScope.placedPos(size) + if node.centerXFlag or node.centerYFlag: + # T5: centering is plain arithmetic when both sizes are known. + let scope = sk.currentScope + if node.centerXFlag and scope.knownW and node.widthMode != smHug: + pos.x = scope.regionPos.x + (scope.regionSize.x - size.x) * 0.5 + if node.centerYFlag and scope.knownH and node.heightMode != smHug: + pos.y = scope.regionPos.y + (scope.regionSize.y - size.y) * 0.5 node.resolvedRect = rect(pos, size) node.resolved = true node.resolvedRect @@ -290,21 +354,11 @@ proc advanceDsl(sk: Silky, owner: DslNode, amount: Vec2) = owner.itemSpacing else: sk.theme.spacing.float32 - sk.stretchAt = max(sk.stretchAt, sk.at + amount + dslVec2(spacing)) - case sk.stackDirection - of TopToBottom: - sk.at.y += amount.y + spacing - of BottomToTop: - sk.at.y -= amount.y + spacing - of LeftToRight: - sk.at.x += amount.x + spacing - of RightToLeft: - sk.at.x -= amount.x + spacing + sk.advance(amount, spacing) -proc drawNode(sk: Silky, node: DslNode, keepSemanticOpen: bool) {.measure.} = - let r = sk.resolveNodeRect(node) +proc drawNodeVisual(sk: Silky, node: DslNode, r: Rect) = + ## Emits the node's own pixels (chrome) at the given rect. let color = sk.nodeTint(node) - sk.beginSemantic(node, r) if node.patch.name.len > 0: sk.draw9Patch( node.patch.name, @@ -332,94 +386,54 @@ proc drawNode(sk: Silky, node: DslNode, keepSemanticOpen: bool) {.measure.} = hAlign = node.hAlign, vAlign = node.vAlign ) + +proc drawNode(sk: Silky, node: DslNode, keepSemanticOpen: bool) {.measure.} = + let r = sk.resolveNodeRect(node) + sk.beginSemantic(node, r) + sk.drawNodeVisual(node, r) node.materialized = true if not keepSemanticOpen: sk.endSemantic(node) proc finishFrameScrollbars(sk: Silky, window: auto, node: DslNode) = + ## T7: content size is |S - O| of the child scope — shift-invariant, + ## so no scroll compensation is needed. The shared scrollbar proc + ## handles origin-relative t, thumb resting edge, and wheel signs. let frameState = node.frameState if frameState == nil: return - let r = node.resolvedRect - if frameState.scrollingY and (window.buttonReleased[MouseLeft] or not window.buttonDown[MouseLeft]): - frameState.scrollingY = false - if frameState.scrollingX and (window.buttonReleased[MouseLeft] or not window.buttonDown[MouseLeft]): - frameState.scrollingX = false - - sk.stretchAt += dslVec2(16) - let contentSize = (sk.stretchAt + frameState.scrollPos) - node.frameOrigin - let scrollMax = max(contentSize - r.wh, dslVec2(0, 0)) - - if scrollMax.y > 0: - frameState.scrollPos.y = clamp(frameState.scrollPos.y, 0.0, scrollMax.y) - else: - frameState.scrollPos.y = 0 - if scrollMax.x > 0: - frameState.scrollPos.x = clamp(frameState.scrollPos.x, 0.0, scrollMax.x) - else: - frameState.scrollPos.x = 0 - - if sk.mousePos.overlaps(r) and sk.mousePos.overlaps(sk.clipRect): - if not frameState.scrollingY and window.scrollDelta.y != 0: - frameState.scrollPos.y += window.scrollDelta.y * ScrollSpeed - frameState.scrollPos.y = clamp(frameState.scrollPos.y, 0.0, scrollMax.y) - if not frameState.scrollingX and window.scrollDelta.x != 0: - frameState.scrollPos.x += window.scrollDelta.x * ScrollSpeed - frameState.scrollPos.x = clamp(frameState.scrollPos.x, 0.0, scrollMax.x) - - if contentSize.y > r.h: - let scrollbarTrackRect = rect(r.x + r.w - 10, r.y + 2, 8, r.h - 14) - sk.draw9Patch("scrollbar.track.9patch", sk.theme.scrollbarTrackPatch, scrollbarTrackRect.xy, scrollbarTrackRect.wh) - let - scrollPosPercent = if scrollMax.y > 0: frameState.scrollPos.y / scrollMax.y else: 0.0 - scrollSizePercent = r.h / contentSize.y - scrollbarHandleRect = rect( - scrollbarTrackRect.x, - scrollbarTrackRect.y + - (scrollbarTrackRect.h - scrollbarTrackRect.h * scrollSizePercent) * - scrollPosPercent, - 8, - scrollbarTrackRect.h * scrollSizePercent - ) - if frameState.scrollingY: - let relativeY = sk.mousePos.y - frameState.scrollDragOffset.y - scrollbarTrackRect.y - let availableTrackHeight = scrollbarTrackRect.h - scrollbarHandleRect.h - if availableTrackHeight > 0: - let newScrollPosPercent = clamp(relativeY / availableTrackHeight, 0.0, 1.0) - frameState.scrollPos.y = newScrollPosPercent * scrollMax.y - elif sk.interact(scrollbarHandleRect, true) == Pressed: - frameState.scrollingY = true - frameState.scrollDragOffset.y = sk.mousePos.y - scrollbarHandleRect.y - sk.draw9Patch("scrollbar.9patch", sk.theme.scrollbarPatch, scrollbarHandleRect.xy, scrollbarHandleRect.wh) - - if contentSize.x > r.w: - let scrollbarTrackRect = rect(r.x + 2, r.y + r.h - 10, r.w - 14, 8) - sk.draw9Patch("scrollbar.track.9patch", sk.theme.scrollbarTrackPatch, scrollbarTrackRect.xy, scrollbarTrackRect.wh) - let - scrollPosPercent = if scrollMax.x > 0: frameState.scrollPos.x / scrollMax.x else: 0.0 - scrollSizePercent = r.w / contentSize.x - scrollbarHandleRect = rect( - scrollbarTrackRect.x + - (scrollbarTrackRect.w - scrollbarTrackRect.w * scrollSizePercent) * - scrollPosPercent, - scrollbarTrackRect.y, - scrollbarTrackRect.w * scrollSizePercent, - 8 - ) - if frameState.scrollingX: - let relativeX = sk.mousePos.x - frameState.scrollDragOffset.x - scrollbarTrackRect.x - let availableTrackWidth = scrollbarTrackRect.w - scrollbarHandleRect.w - if availableTrackWidth > 0: - let newScrollPosPercent = clamp(relativeX / availableTrackWidth, 0.0, 1.0) - frameState.scrollPos.x = newScrollPosPercent * scrollMax.x - elif sk.interact(scrollbarHandleRect, true) == Pressed: - frameState.scrollingX = true - frameState.scrollDragOffset.x = sk.mousePos.x - scrollbarHandleRect.x - sk.draw9Patch("scrollbar.9patch", sk.theme.scrollbarPatch, scrollbarHandleRect.xy, scrollbarHandleRect.wh) + let + contentSize = sk.currentScope.contentBox().wh + dslVec2(16) + signs = sk.currentScope.signs + baseWidgets.frameScrollbars( + sk, window, frameState, node.resolvedRect, contentSize, signs + ) proc pushChildrenLayout(sk: Silky, node: DslNode) = if node.pushedLayout: return + if node.isHug and node.kind != nkFrame and not node.hasBox: + # T2: chrome is deferred to close; children draw first while the + # stretch pen measures them. Record where this span begins. + let r = sk.resolveNodeRect(node) + sk.beginSemantic(node, r) + node.chromeLayer = sk.currentDrawLayer + node.chromeMark = sk.vertexMark() + let + childPos = r.xy + dslVec2(node.horizontalPadding, node.verticalPadding) + childSize = dslVec2( + max(0.0'f, r.w - node.horizontalPadding * 2), + max(0.0'f, r.h - node.verticalPadding * 2) + ) + sk.pushLayout( + childPos, + childSize, + node.direction, + knownW = node.widthMode != smHug, + knownH = node.heightMode != smHug + ) + node.pushedLayout = true + return sk.drawNode(node, keepSemanticOpen = true) let r = node.resolvedRect if node.kind == nkFrame: @@ -434,7 +448,12 @@ proc pushChildrenLayout(sk: Silky, node: DslNode) = max(0.0'f, r.w - node.horizontalPadding * 2), max(0.0'f, r.h - node.verticalPadding * 2) ) - sk.pushLayout(node.frameOrigin - node.frameState.scrollPos, childSize, node.direction) + # T7: scrolling translates the content by -σ·t, so the origin edge + # is pinned at rest and overflow hides past the far edge. + let + signs = dirSigns(node.direction) + scrollOffset = signs * node.frameState.scrollPos + sk.pushLayout(node.frameOrigin - scrollOffset, childSize, node.direction) else: if node.clipContent: sk.pushClipRect(r) @@ -460,9 +479,73 @@ proc startChildren(sk: Silky, node: DslNode) = node.startedChildren = true sk.pushChildrenLayout(node) +proc closeHugLayout(sk: Silky, node: DslNode) = + ## Closes a hug scope: measure the content box, translate the span + ## into final position (A6/T6), size the node, then emit its chrome + ## behind the children (T2). + let + scope = sk.currentScope + content = scope.contentBox() + childPos = scope.regionPos + signs = dirSigns(node.direction) + sk.popLayout() + var r = node.resolvedRect + # The stretch pen includes one trailing item spacing per axis + # (advancePen's legacy inflation); hug shouldn't. + let extent = vec2( + max(0.0'f, content.w - node.itemSpacing), + max(0.0'f, content.h - node.itemSpacing) + ) + var contentMin = content.xy + if signs.x < 0: + contentMin.x += min(node.itemSpacing, content.w) + if signs.y < 0: + contentMin.y += min(node.itemSpacing, content.h) + if node.widthMode == smHug: + r.w = extent.x + node.horizontalPadding * 2 + if node.heightMode == smHug: + r.h = extent.y + node.verticalPadding * 2 + # Reverse-direction hug: children grew away from the provisional + # origin; pull the whole rigid box back into place. + var shift = vec2(0, 0) + if node.widthMode == smHug: + shift.x = childPos.x - contentMin.x + if node.heightMode == smHug: + shift.y = childPos.y - contentMin.y + # The box's own position may move now that its size is known: + # reverse-direction parents place against the pen (A3), centered + # nodes re-center with the final size (T5). + if not node.hasBox: + var finalPos = sk.currentScope.placedPos(r.wh) + let parentScope = sk.currentScope + if node.centerXFlag and parentScope.knownW: + finalPos.x = parentScope.regionPos.x + + (parentScope.regionSize.x - r.w) * 0.5 + if node.centerYFlag and parentScope.knownH: + finalPos.y = parentScope.regionPos.y + + (parentScope.regionSize.y - r.h) * 0.5 + shift += finalPos - r.xy + r.x = finalPos.x + r.y = finalPos.y + sk.translateVertices(node.chromeLayer, node.chromeMark, shift) + node.resolvedRect = r + node.resolved = true + let chromeStart = sk.vertexMark() + sk.drawNodeVisual(node, r) + sk.moveVerticesBehind(node.chromeLayer, node.chromeMark, chromeStart) + node.materialized = true + when defined(silkyTesting): + if node.semanticOpened: + sk.setWidgetRect(r) + sk.endSemantic(node) + proc closeChildrenLayout(sk: Silky, window: auto, node: DslNode) = if not node.pushedLayout: return + if node.isHug and node.kind != nkFrame and not node.hasBox: + sk.closeHugLayout(node) + node.pushedLayout = false + return if node.kind == nkFrame: sk.finishFrameScrollbars(window, node) sk.popLayout() @@ -499,7 +582,14 @@ proc finishNode*(sk: Silky, window: auto) {.measure.} = scopeStack[^2] else: nil - sk.advanceDsl(current, amount) + # T5: a node centered on the stack's main axis is a degenerate + # scope; it does not advance the pen. + let mainAxis = sk.stackDirection.mainAxis + let centeredOnMain = + (mainAxis == 0 and node.centerXFlag) or + (mainAxis == 1 and node.centerYFlag) + if not centeredOnMain: + sk.advanceDsl(current, amount) else: current = nil parent = nil @@ -619,6 +709,80 @@ template image*(imageName: string, imageTint: ColorRGBX) = else: baseWidgets.image(imageName, imageTint) +proc width*(value: SomeNumber) {.inline.} = + ## Fixed width in pixels (A8: known at begin). + if current != nil: + current.widthMode = smFixed + current.fixedSize.x = value.float32 + current.resetNodeRect() + +proc height*(value: SomeNumber) {.inline.} = + ## Fixed height in pixels (A8: known at begin). + if current != nil: + current.heightMode = smFixed + current.fixedSize.y = value.float32 + current.resetNodeRect() + +proc size*[A, B: SomeNumber](w: A, h: B) {.inline.} = + ## Fixed size in pixels; unlike box() the position still flows. + width(w) + height(h) + +proc fillWidth*() {.inline.} = + ## Take the parent's remaining width (T3: parent must be known). + if current != nil: + current.widthMode = smFill + current.resetNodeRect() + +proc fillHeight*() {.inline.} = + ## Take the parent's remaining height (T3: parent must be known). + if current != nil: + current.heightMode = smFill + current.resetNodeRect() + +proc hugWidth*() {.inline.} = + ## Width becomes the children's extent + padding at close (T2). + if current != nil: + current.widthMode = smHug + current.resetNodeRect() + +proc hugHeight*() {.inline.} = + ## Height becomes the children's extent + padding at close (T2). + if current != nil: + current.heightMode = smHug + current.resetNodeRect() + +proc hug*() {.inline.} = + ## Hug children on both axes (T2). + hugWidth() + hugHeight() + +proc center*() {.inline.} = + ## Center in the parent on both axes (T5: both sizes must be known). + if current != nil: + current.centerXFlag = true + current.centerYFlag = true + current.resetNodeRect() + +proc centerX*() {.inline.} = + if current != nil: + current.centerXFlag = true + current.resetNodeRect() + +proc centerY*() {.inline.} = + if current != nil: + current.centerYFlag = true + current.resetNodeRect() + +template indent*(amount: SomeNumber, body: untyped) = + ## T4: nudge the cross-axis start of children placed in this block. + sk.startCurrentChildren() + sk.currentScope.indent += amount.float32 + try: + body + finally: + sk.currentScope.indent -= amount.float32 + proc clipContent*(enabled = true) {.inline.} = if current != nil: current.clipContent = enabled diff --git a/src/silky/layout.nim b/src/silky/layout.nim new file mode 100644 index 0000000..647dde4 --- /dev/null +++ b/src/silky/layout.nim @@ -0,0 +1,123 @@ +## The two-pen layout model. See docs/layout_theory.md. +## +## A layout scope carries the pen P (where the next element draws), the +## stretch pen S (the farthest corner reached), and a sign vector σ that +## points from the origin corner into the region (A1, A2). All direction +## math lives in placedPos/advancePen (A3-A5); nothing else moves a pen. + +import bumpy, vmath + +type + StackDirection* = enum + ## Direction of the current layout flow. + TopToBottom + BottomToTop + LeftToRight + RightToLeft + + LayoutScope* = object + ## One layout region: origin corner, signs, and the two pens. + regionPos*: Vec2 ## Region top-left, before direction is applied. + regionSize*: Vec2 ## Region size; meaningless on axes not known. + knownW*: bool = true ## A8: is the width known right now? + knownH*: bool = true ## A8: is the height known right now? + direction*: StackDirection + signs*: Vec2 ## σ: +1/-1 per axis, points into the region. + pen*: Vec2 ## P: the next element draws here. + stretch*: Vec2 ## S: farthest corner reached, moves along σ. + indent*: float32 ## T4: cross-axis offset for placed elements. + vertexMark*: int ## Vertex count at scope open, for hug chrome. + vertexLayer*: int ## Layer the scope opened on. + +proc mainAxis*(direction: StackDirection): int = + ## 0 = x, 1 = y. + case direction + of TopToBottom, BottomToTop: 1 + of LeftToRight, RightToLeft: 0 + +proc dirSigns*(direction: StackDirection): Vec2 = + ## σ for a direction; the cross axis keeps its natural (+1) sign. + case direction + of TopToBottom: vec2(1, 1) + of BottomToTop: vec2(1, -1) + of LeftToRight: vec2(1, 1) + of RightToLeft: vec2(-1, 1) + +proc originCorner*(regionPos, regionSize, signs: Vec2): Vec2 = + ## The corner σ points away from (A2). + result = regionPos + if signs.x < 0: + result.x += regionSize.x + if signs.y < 0: + result.y += regionSize.y + +proc origin*(scope: LayoutScope): Vec2 = + ## O: where both pens started. + originCorner(scope.regionPos, scope.regionSize, scope.signs) + +proc initLayoutScope*( + pos, size: Vec2, + direction: StackDirection, + knownW = true, + knownH = true +): LayoutScope = + let + signs = dirSigns(direction) + o = originCorner(pos, size, signs) + LayoutScope( + regionPos: pos, + regionSize: size, + knownW: knownW, + knownH: knownH, + direction: direction, + signs: signs, + pen: o, + stretch: o + ) + +proc farthest*(signs: Vec2, a, b: Vec2): Vec2 = + ## Componentwise farthest point in the direction of σ. + vec2( + if signs.x >= 0: max(a.x, b.x) else: min(a.x, b.x), + if signs.y >= 0: max(a.y, b.y) else: min(a.y, b.y) + ) + +proc placedPos*(scope: LayoutScope, size: Vec2): Vec2 = + ## A3: an element at the pen spans P .. P + σ·size; returns the box's + ## top-left corner. Indent (T4) nudges the cross axis. + var p = scope.pen + let cross = 1 - scope.direction.mainAxis + p[cross] += scope.signs[cross] * scope.indent + vec2( + if scope.signs.x >= 0: p.x else: p.x - size.x, + if scope.signs.y >= 0: p.y else: p.y - size.y + ) + +proc advancePen*(scope: var LayoutScope, size: Vec2, spacing: float32) = + ## A4 + A5: move the pen along the main axis, absorb the far corner + ## into the stretch pen. The stretch pen includes the trailing spacing + ## on both axes to match the historical stretchAt behavior. + let far = scope.pen + scope.signs * (size + vec2(spacing, spacing)) + scope.stretch = farthest(scope.signs, scope.stretch, far) + let m = scope.direction.mainAxis + scope.pen[m] += scope.signs[m] * (size[m] + spacing) + +proc traveled*(scope: LayoutScope): Vec2 = + ## How far the pen has moved from the origin, per axis (with indent). + result = abs(scope.pen - scope.origin) + let cross = 1 - scope.direction.mainAxis + result[cross] += scope.indent + +proc remainingSpace*(scope: LayoutScope): Vec2 = + ## T3: region minus consumed space; only meaningful on known axes. + max(scope.regionSize - scope.traveled, vec2(0, 0)) + +proc contentBox*(scope: LayoutScope): Rect = + ## box(O, S): the exact bounding box of everything placed (T2, T7). + let o = scope.origin + rect( + min(o.x, scope.stretch.x), + min(o.y, scope.stretch.y), + abs(scope.stretch.x - o.x), + abs(scope.stretch.y - o.y) + ) diff --git a/src/silky/semantics.nim b/src/silky/semantics.nim index f631a8e..4a9e50d 100644 --- a/src/silky/semantics.nim +++ b/src/silky/semantics.nim @@ -1,12 +1,14 @@ ## Semantic capture layer for Silky UI testing. import - std/[strutils, tables, unicode, times], + std/[algorithm, strutils, tables, unicode, times], vmath, bumpy, chroma, pixie, - silky/atlas, testwindow + silky/[atlas, layout], testwindow from windy/common import Button, CursorKind, Cursor +export layout + const LineFeedRune = Rune(10) @@ -211,12 +213,6 @@ const PopupsLayer* = 1 type - StackDirection* = enum - TopToBottom - BottomToTop - LeftToRight - RightToLeft - Theme* = object ## Visual theme settings for widgets. padding*: int = 8 @@ -281,14 +277,9 @@ type ## Main Silky context for testing mode without GPU. inFrame: bool = false uiScale*: float32 = 1.0 - ## Multiplies 9-patch slice borders when the atlas is baked denser (e.g. 2). + ## Multiplies 9-patch borders for a denser atlas. sliceScale*: int = 1 - at*: Vec2 - atStack: seq[Vec2] - posStack: seq[Vec2] - sizeStack: seq[Vec2] - stretchAt*: Vec2 - directionStack: seq[StackDirection] + layoutStack*: seq[LayoutScope] textStyle*: string = "Default" padding*: float32 = 12 theme*: Theme = Theme() @@ -334,42 +325,82 @@ 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 currentScope*(sk: Silky): var LayoutScope = + ## Returns the active layout scope. + sk.layoutStack[^1] + +proc at*(sk: Silky): var Vec2 = + ## The pen P of the active scope. + sk.layoutStack[^1].pen + +proc `at=`*(sk: Silky, value: Vec2) = + sk.layoutStack[^1].pen = value + +proc stretchAt*(sk: Silky): var Vec2 = + ## The stretch pen S of the active scope. + sk.layoutStack[^1].stretch + +proc `stretchAt=`*(sk: Silky, value: Vec2) = + sk.layoutStack[^1].stretch = value + +proc pushLayout*( + sk: Silky, + pos: Vec2, + size: Vec2, + direction: StackDirection = TopToBottom, + knownW = true, + knownH = true +) = ## Pushes a new layout region onto the stack. - sk.atStack.add(sk.at) - sk.posStack.add(pos) - sk.at = pos - sk.sizeStack.add(size) - sk.directionStack.add(direction) - sk.stretchAt = sk.at - case direction: - of TopToBottom: sk.at = pos - of BottomToTop: sk.at = pos + vec2(0, size.y) - of LeftToRight: sk.at = pos - of RightToLeft: sk.at = pos + vec2(size.x, 0) + var scope = initLayoutScope(pos, size, direction, knownW, knownH) + scope.vertexLayer = sk.currentLayer + scope.vertexMark = sk.layers[scope.vertexLayer].len + sk.layoutStack.add(scope) 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() + ## Pops the current layout region, folding its stretch pen into the + ## parent so overflow keeps propagating like the old global stretchAt. + let child = sk.layoutStack.pop() + if sk.layoutStack.len > 0: + let parent = addr sk.layoutStack[^1] + parent.stretch = farthest(parent.signs, parent.stretch, child.stretch) proc pos*(sk: Silky): Vec2 = ## Returns the current layout position. - sk.posStack[^1] + sk.layoutStack[^1].regionPos proc size*(sk: Silky): Vec2 = ## Returns the current layout size. - sk.sizeStack[^1] + sk.layoutStack[^1].regionSize proc rootSize*(sk: Silky): Vec2 = ## Returns the root layout size. - sk.sizeStack[0] + sk.layoutStack[0].regionSize proc stackDirection*(sk: Silky): StackDirection = ## Returns the current stack direction. - sk.directionStack[^1] + sk.layoutStack[^1].direction + +proc vertexMark*(sk: Silky): int = + ## Current vertex count on the active layer, for later patching. + sk.layers[sk.currentLayer].len + +proc moveVerticesBehind*(sk: Silky, layer, spanStart, chromeStart: int) = + ## Rotates vertices emitted after chromeStart to the front of the + ## span so late-drawn parent chrome renders behind its children (T2). + let total = sk.layers[layer].len + if chromeStart > spanStart and chromeStart < total: + sk.layers[layer].rotateLeft( + spanStart ..< total, chromeStart - spanStart + ) + +proc translateVertices*(sk: Silky, layer, spanStart: int, offset: Vec2) = + ## Shifts vertices emitted since spanStart; realizes A6 for boxes + ## whose position is only known at scope close (T6). + if offset == vec2(0, 0): + return + for i in spanStart ..< sk.layers[layer].len: + sk.layers[layer][i].pos += offset proc pushRawClipRect*(sk: Silky, rect: Rect) = ## Pushes a clipping rectangle onto the stack without intersection. @@ -402,14 +433,23 @@ proc clipRect*(sk: Silky): Rect = ## Returns the current clipping rectangle. sk.clipStack[^1] +proc advance*(sk: Silky, amount: Vec2, spacing: float32) = + ## Advances the current layout cursor with explicit spacing. + sk.layoutStack[^1].advancePen(amount, spacing) + 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 + sk.advance(amount, sk.theme.spacing.float32) + +proc placedAt*(sk: Silky, size: Vec2): Vec2 = + ## Returns where a box of this size lands under the current scope + ## (A3) without moving the pen. Pair with advance(). + sk.layoutStack[^1].placedPos(size) + +proc place*(sk: Silky, size: Vec2): Vec2 = + ## Places a box: returns its top-left corner and advances the pen. + result = sk.placedAt(size) + sk.advance(size) proc getImageSize*(sk: Silky, image: string): Vec2 = ## Returns the size of an image from the atlas. diff --git a/src/silky/testing.nim b/src/silky/testing.nim index 057675c..dd4a03c 100644 --- a/src/silky/testing.nim +++ b/src/silky/testing.nim @@ -20,6 +20,7 @@ proc newTestHarness*(atlas: SilkyAtlas, width = 800, height = 600): TestHarness result.window = newWindow(width, height) result.frameCount = 0 result.sk = Silky() + result.sk.window = result.window result.sk.atlas = atlas result.sk.layers[NormalLayer] = @[] result.sk.layers[PopupsLayer] = @[] diff --git a/src/silky/widgets.nim b/src/silky/widgets.nim index dd912e4..247c1ff 100644 --- a/src/silky/widgets.nim +++ b/src/silky/widgets.nim @@ -304,20 +304,29 @@ proc frameStart*(sk: Silky, id: string, framePos, frameSize: Vec2): tuple[state: sk.at -= frameState.scrollPos 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]): +proc frameScrollbars*( + sk: Silky, + window: Window, + frameState: FrameState, + frameRect: Rect, + contentSize: Vec2, + signs: Vec2 +) = + ## T7: scrolling is the natural ramification of clipping. scrollPos is + ## the distance t from the origin corner per axis, clamped to the + ## overflow. At rest (t = 0) the origin edge is pinned: a bottom-origin + ## frame rests scrolled to the bottom, and its thumb rests at the + ## bottom of the track, moving away from the origin as t grows. + 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]): + 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)) + let scrollMax = max(contentSize - frameRect.wh, vec2(0, 0)) - # Clamp scroll position to valid range (handles resize making content smaller). + # Clamp t to the overflow (handles resize making content smaller). if scrollMax.y > 0: frameState.scrollPos.y = clamp(frameState.scrollPos.y, 0.0, scrollMax.y) else: @@ -327,89 +336,101 @@ proc frameEnd*(sk: Silky, window: Window, frameState: FrameState, originPos: Vec else: frameState.scrollPos.x = 0 - # Scroll wheel handling (only when mouse over frame). - if sk.mouseOverlap(window, rect(sk.pos, sk.size)): + # Scroll wheel: the same gesture moves content the same way on screen + # in every direction, so the wheel sign maps through σ. + if sk.mouseOverlap(window, frameRect): 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) + frameState.scrollPos.y = clamp( + frameState.scrollPos.y + signs.y * window.scrollDelta.y * ScrollSpeed, + 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) - - # Draw Y scrollbar. - if contentSize.y > sk.size.y: - let - scrollSize = contentSize.y - scrollbarTrackRect = rect( - sk.pos.x + sk.size.x - 10, - sk.pos.y + 2, - 8, - sk.size.y - 4 - 10 + frameState.scrollPos.x = clamp( + frameState.scrollPos.x + signs.x * window.scrollDelta.x * ScrollSpeed, + 0.0, scrollMax.x ) - sk.draw9Patch("scrollbar.track.9patch", sk.theme.scrollbarTrackPatch, scrollbarTrackRect.xy, scrollbarTrackRect.wh) + # Y scrollbar. + if contentSize.y > frameRect.h: let - scrollPosPercent = if scrollMax.y > 0: frameState.scrollPos.y / scrollMax.y else: 0.0 - scrollSizePercent = sk.size.y / scrollSize - scrollbarHandleRect = rect( - scrollbarTrackRect.x, - scrollbarTrackRect.y + (scrollbarTrackRect.h - (scrollbarTrackRect.h * scrollSizePercent)) * scrollPosPercent, + track = rect( + frameRect.x + frameRect.w - 10, + frameRect.y + 2, 8, - scrollbarTrackRect.h * scrollSizePercent + frameRect.h - 4 - 10 ) - scrollbarHandleInteraction = sk.interact(scrollbarHandleRect, true) + thumbH = track.h * (frameRect.h / contentSize.y) + travel = track.h - thumbH + sk.draw9Patch("scrollbar.track.9patch", sk.theme.scrollbarTrackPatch, track.xy, track.wh) - # Handle scrollbar Y dragging. - if frameState.scrollingY: + proc thumbRectY(): Rect = let - relativeY = sk.mousePos.y - frameState.scrollDragOffset.y - scrollbarTrackRect.y - availableTrackHeight = scrollbarTrackRect.h - scrollbarHandleRect.h - if availableTrackHeight > 0: - let newScrollPosPercent = clamp(relativeY / availableTrackHeight, 0.0, 1.0) - frameState.scrollPos.y = newScrollPosPercent * scrollMax.y - elif scrollbarHandleInteraction == Pressed: - frameState.scrollingY = true - frameState.scrollDragOffset.y = sk.mousePos.y - scrollbarHandleRect.y - - sk.draw9Patch("scrollbar.9patch", sk.theme.scrollbarPatch, scrollbarHandleRect.xy, scrollbarHandleRect.wh) - - # Draw X scrollbar. - if contentSize.x > sk.size.x: - let - scrollSize = contentSize.x - scrollbarTrackRect = rect( - sk.pos.x + 2, - sk.pos.y + sk.size.y - 10, - sk.size.x - 4 - 10, - 8 - ) - sk.draw9Patch("scrollbar.track.9patch", sk.theme.scrollbarTrackPatch, scrollbarTrackRect.xy, scrollbarTrackRect.wh) + frac = + if scrollMax.y > 0: frameState.scrollPos.y / scrollMax.y + else: 0.0'f + offsetFrac = if signs.y >= 0: frac else: 1.0'f - frac + rect(track.x, track.y + travel * offsetFrac, 8, thumbH) + if frameState.scrollingY: + if travel > 0: + let rel = sk.mousePos.y - frameState.scrollDragOffset.y - track.y + var frac = clamp(rel / travel, 0.0, 1.0) + if signs.y < 0: + frac = 1.0 - frac + frameState.scrollPos.y = frac * scrollMax.y + else: + let thumb = thumbRectY() + if sk.interact(thumb, true) == Pressed: + frameState.scrollingY = true + frameState.scrollDragOffset.y = sk.mousePos.y - thumb.y + let thumb = thumbRectY() + sk.draw9Patch("scrollbar.9patch", sk.theme.scrollbarPatch, thumb.xy, thumb.wh) + + # X scrollbar. + if contentSize.x > frameRect.w: let - scrollPosPercent = if scrollMax.x > 0: frameState.scrollPos.x / scrollMax.x else: 0.0 - scrollSizePercent = sk.size.x / scrollSize - scrollbarHandleRect = rect( - scrollbarTrackRect.x + (scrollbarTrackRect.w - (scrollbarTrackRect.w * scrollSizePercent)) * scrollPosPercent, - scrollbarTrackRect.y, - scrollbarTrackRect.w * scrollSizePercent, + track = rect( + frameRect.x + 2, + frameRect.y + frameRect.h - 10, + frameRect.w - 4 - 10, 8 ) - scrollbarHandleInteraction = sk.interact(scrollbarHandleRect, true) + thumbW = track.w * (frameRect.w / contentSize.x) + travel = track.w - thumbW + sk.draw9Patch("scrollbar.track.9patch", sk.theme.scrollbarTrackPatch, track.xy, track.wh) - # Handle scrollbar X dragging. - if frameState.scrollingX: + proc thumbRectX(): Rect = let - relativeX = sk.mousePos.x - frameState.scrollDragOffset.x - scrollbarTrackRect.x - availableTrackWidth = scrollbarTrackRect.w - scrollbarHandleRect.w - if availableTrackWidth > 0: - let newScrollPosPercent = clamp(relativeX / availableTrackWidth, 0.0, 1.0) - frameState.scrollPos.x = newScrollPosPercent * scrollMax.x - elif scrollbarHandleInteraction == Pressed: - frameState.scrollingX = true - frameState.scrollDragOffset.x = sk.mousePos.x - scrollbarHandleRect.x + frac = + if scrollMax.x > 0: frameState.scrollPos.x / scrollMax.x + else: 0.0'f + offsetFrac = if signs.x >= 0: frac else: 1.0'f - frac + rect(track.x + travel * offsetFrac, track.y, thumbW, 8) - sk.draw9Patch("scrollbar.9patch", sk.theme.scrollbarPatch, scrollbarHandleRect.xy, scrollbarHandleRect.wh) + if frameState.scrollingX: + if travel > 0: + let rel = sk.mousePos.x - frameState.scrollDragOffset.x - track.x + var frac = clamp(rel / travel, 0.0, 1.0) + if signs.x < 0: + frac = 1.0 - frac + frameState.scrollPos.x = frac * scrollMax.x + else: + let thumb = thumbRectX() + if sk.interact(thumb, true) == Pressed: + frameState.scrollingX = true + frameState.scrollDragOffset.x = sk.mousePos.x - thumb.x + let thumb = thumbRectX() + sk.draw9Patch("scrollbar.9patch", sk.theme.scrollbarPatch, thumb.xy, thumb.wh) +proc frameEnd*(sk: Silky, window: Window, frameState: FrameState, originPos: Vec2) = + ## Finish a scrollable frame and handle scrollbars. + # stretchAt is in scrolled coordinates; add scrollPos back and 16 for + # the last element, matching the historical content measurement. + sk.stretchAt += vec2(16) + let + contentSize = (sk.stretchAt + frameState.scrollPos) - originPos + frameRect = rect(sk.pos, sk.size) + sk.frameScrollbars(window, frameState, frameRect, contentSize, vec2(1, 1)) sk.popLayout() sk.popClipRect() @@ -428,7 +449,8 @@ template button*(label: string, isEnabled: bool, isError: bool, body: untyped) = let textSize = sk.getTextSize(sk.textStyle, label) buttonSize = textSize + vec2(sk.theme.padding) * 2 - buttonRect = rect(sk.at, buttonSize) + buttonOrigin = sk.placedAt(buttonSize) + buttonRect = rect(buttonOrigin, buttonSize) when defined(silkyTesting): sk.beginWidget("Button", text = label, rect = buttonRect) @@ -455,7 +477,7 @@ template button*(label: string, isEnabled: bool, isError: bool, body: untyped) = of Hovered, Released: "button.hover.9patch" - sk.draw9Patch(patch, sk.theme.buttonPatch, sk.at, buttonSize) + sk.draw9Patch(patch, sk.theme.buttonPatch, buttonOrigin, buttonSize) if interaction == Released: body @@ -466,7 +488,7 @@ template button*(label: string, isEnabled: bool, isError: bool, body: untyped) = hovered = pressed or interaction == Hovered sk.setWidgetState(enabled = isEnabled, pressed = pressed, hovered = hovered) - let at = sk.at + vec2(sk.theme.padding) + let at = buttonOrigin + vec2(sk.theme.padding) discard sk.drawText(sk.textStyle, label, at, textColor) when defined(silkyTesting): sk.endWidget() @@ -483,7 +505,7 @@ template button*(label: string, isEnabled: bool, body: untyped) = template icon*(image: string) = ## Draw an icon. let imageSize = sk.getImageSize(image) - sk.drawImage(image, sk.at) + sk.drawImage(image, sk.placedAt(imageSize)) sk.advance(vec2(imageSize.x, imageSize.y)) template iconButton*(image: string, body) = @@ -553,7 +575,8 @@ template radioButton*[T](label: string, variable: var T, value: 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)) + radioOrigin = sk.placedAt(vec2(width, height)) + hitRect = rect(radioOrigin, vec2(width, height)) when defined(silkyTesting): sk.beginWidget("RadioButton", text = label, rect = hitRect) @@ -565,10 +588,13 @@ template radioButton*[T](label: string, variable: var T, value: T) = let on = variable == value - iconPos = vec2(sk.at.x, sk.at.y + (height - iconSize.y.float32) * 0.5) + iconPos = vec2( + radioOrigin.x, + radioOrigin.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 + radioOrigin.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) @@ -589,7 +615,8 @@ template checkBox*(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)) + checkOrigin = sk.placedAt(vec2(width, height)) + hitRect = rect(checkOrigin, vec2(width, height)) when defined(silkyTesting): sk.beginWidget("CheckBox", text = label, rect = hitRect) @@ -600,10 +627,13 @@ template checkBox*(label: string, value: var bool) = value = not value let - iconPos = vec2(sk.at.x, sk.at.y + (height - iconSize.y.float32) * 0.5) + iconPos = vec2( + checkOrigin.x, + checkOrigin.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 + checkOrigin.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) @@ -629,7 +659,7 @@ template dropDown*[T](selected: var T, options: openArray[T]) = height = font.lineHeight + sk.theme.padding.float32 * 2 width = sk.size.x - sk.theme.padding.float32 * 3 arrowSize = sk.getImageSize("droparrow") - dropRect = rect(sk.at, vec2(width, height)) + dropRect = rect(sk.placedAt(vec2(width, height)), vec2(width, height)) displayText = when T is string: selected @@ -646,7 +676,7 @@ template dropDown*[T](selected: var T, options: openArray[T]) = state.open = not state.open # Draw control body. - sk.pushLayout(sk.at, vec2(width, height)) + sk.pushLayout(dropRect.xy, vec2(width, height)) let hovered = interaction in [Pressed, Held, Hovered] let bgColor = if state.open or hovered: sk.theme.dropdownHoverBgColor else: sk.theme.dropdownBgColor sk.draw9Patch("dropdown.9patch", sk.theme.dropdownPatch, sk.pos, sk.size, bgColor) @@ -719,7 +749,7 @@ template listBox*[T](id: string, items: seq[T], selectedIndex: var int) = # Use a fixed height or calculate based on items, but capped at 4 items. let listHeight = min(rowHeight * 4.float32, rowHeight * max(1, items.len).float32) + sk.theme.padding.float32 * 2 - frame(id, sk.at, vec2(outerWidth, listHeight)): + frame(id, sk.placedAt(vec2(outerWidth, listHeight)), vec2(outerWidth, listHeight)): let itemWidth = sk.size.x - sk.theme.padding.float32 * 3 for i, item in items: let @@ -751,7 +781,7 @@ template progressBar*(value: SomeNumber, minVal: SomeNumber, maxVal: SomeNumber) bodySize = sk.getImageSize("progressBar.body.9patch") height = bodySize.y.float32 width = max(bodySize.x.float32, sk.size.x - sk.theme.padding.float32 * 3) - barRect = rect(sk.at, vec2(width, height)) + barRect = rect(sk.placedAt(vec2(width, height)), vec2(width, height)) sk.draw9Patch("progressBar.body.9patch", sk.theme.progressBarPatch, barRect.xy, barRect.wh) @@ -827,15 +857,19 @@ template image*(imageName: string) = template text*(t: string) = ## Draw text. - let textRect = rect(sk.at, sk.getTextSize(sk.textStyle, t)) + let + textMeasured = sk.getTextSize(sk.textStyle, t) + textOrigin = sk.placedAt(textMeasured) + textRect = rect(textOrigin, textMeasured) 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, textOrigin, sk.theme.textColor) sk.endWidget() sk.advance(textSize) template h1text*(t: string) = ## Draw H1 text. - let textSize = sk.drawText("H1", t, sk.at, sk.theme.textH1Color) + let h1Origin = sk.placedAt(sk.getTextSize("H1", t)) + let textSize = sk.drawText("H1", t, h1Origin, sk.theme.textH1Color) sk.advance(textSize) template scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: string = "") = @@ -865,7 +899,7 @@ template scrubber*[T, U](id: string, value: var T, minVal: T, maxVal: U, label: handleSize = vec2(handleWidth, handleHeight) height = handleSize.y width = sk.size.x - sk.theme.padding.float32 * 3 - controlRect = rect(sk.at, vec2(width, height)) + controlRect = rect(sk.placedAt(vec2(width, height)), vec2(width, height)) trackStart = controlRect.x + handleSize.x / 2 trackEnd = controlRect.x + width - handleSize.x / 2 travel = max(0f, trackEnd - trackStart) diff --git a/tests/manual_layout.nim b/tests/manual_layout.nim new file mode 100644 index 0000000..4d1e61e --- /dev/null +++ b/tests/manual_layout.nim @@ -0,0 +1,203 @@ +## Interactive playground for the two-pen layout model +## (docs/layout_theory.md). Pick a stack direction (A2), a parent sizing +## mode (fixed region / hug / fill), tweak padding and spacing, and toggle +## the indent (T4) and centering (T5) demos. The colored boxes below +## respond to every change in real time, on the first frame, single pass. + +import + std/[strformat], + opengl, windy, bumpy, vmath, chroma, pixie, + 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, + subpixelSteps = 10 +) +builder.write("tests/dist/atlas.png") + +let window = newWindow( + "Layout Test", + ivec2(900, 760), + vsync = false +) +makeContextCurrent(window) +loadExtensions() + +const + BackgroundColor = parseHtmlColor("#1a1a2e").rgbx + AreaBgColor = parseHtmlColor("#2a2a3e").rgbx + Margin = 20.0f + 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), + ] + Directions = [TopToBottom, BottomToTop, LeftToRight, RightToLeft] + +let sk = newSilky(window, "tests/dist/atlas.png") + +var + layoutPadding = 12.0f + layoutSpacing = 8.0f + numBoxes = 5.0f + directionVal = 0 + parentMode = 0 # 0 = fixed region, 1 = hug, 2 = fill cross axis + showIndent = false + showCenter = false + showWidgets = false + clickCount = 0 + +window.onFrame = proc() = + sk.beginUI(window, window.size) + sk.clearScreen(BackgroundColor) + + sk.at = vec2(Margin, Margin) + h1text("Layout Test") + + scrubber("padding", layoutPadding, 0.0, 60.0, &"Padding: {layoutPadding:0.0f}") + scrubber("spacing", layoutSpacing, 0.0, 40.0, &"Spacing: {layoutSpacing:0.0f}") + scrubber("numBoxes", numBoxes, 1.0, 10.0, &"Boxes: {numBoxes.int}") + + ui: + text("Stack direction (A2: pick the origin corner):") + group "dirRow": + hug() + layout 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) + + text("Parent sizing:") + group "modeRow": + hug() + layout LeftToRight + radioButton("Fixed region", parentMode, 0) + radioButton("Hug children (T2)", parentMode, 1) + radioButton("Fill cross axis (T3)", parentMode, 2) + radioButton("Scroll frame (T7)", parentMode, 3) + + group "toggleRow": + hug() + layout LeftToRight + checkBox("Indent (T4)", showIndent) + checkBox("Centered box (T5)", showCenter) + checkBox("Widgets in flow", showWidgets) + + let + dir = Directions[directionVal] + vertical = dir in [TopToBottom, BottomToTop] + pad = layoutPadding + spacing = layoutSpacing + n = numBoxes.int + areaTop = sk.at.y + 8 + areaPos = vec2(Margin, areaTop) + areaSize = vec2( + window.size.x.float32 - Margin * 2, + window.size.y.float32 - areaTop - Margin + ) + + ui: + rectangle "demoArea": + box areaPos.x, areaPos.y, areaSize.x, areaSize.y + tint AreaBgColor + layout dir + horizontalPadding pad + verticalPadding pad + itemSpacing spacing + + template demoBoxes() = + for i in 0 ..< n: + if showIndent and i >= 2 and i <= 3: + indent 24: + rectangle "box" & $i: + size BoxSizes[i].x, BoxSizes[i].y + tint BoxColors[i] + else: + rectangle "box" & $i: + size BoxSizes[i].x, BoxSizes[i].y + if parentMode == 2: + # T3: stretch the cross axis into the known region. + if vertical: + fillWidth() + else: + fillHeight() + tint BoxColors[i] + if showWidgets and i == 1: + button("Click " & $clickCount): + inc clickCount + checkBox("In flow", showWidgets) + + if parentMode == 1: + # T2: the frame chrome is drawn behind children it hasn't seen + # yet, sized by the stretch pen at close. With a reverse + # direction this also exercises T6 (span translation). + group "hugger": + hug() + patch "frame.9patch", 6 + layout dir + horizontalPadding pad + verticalPadding pad + itemSpacing spacing + demoBoxes() + elif parentMode == 3: + # T7: the scroll origin is the layout origin. A BottomToTop + # frame rests scrolled to the bottom and its thumb rests at + # the bottom of the track; RightToLeft rests at the right. + frame "scroller": + size 340, 300 + layout dir + horizontalPadding pad + verticalPadding pad + itemSpacing spacing + demoBoxes() + else: + demoBoxes() + + if showCenter: + # T5: both parent and child known; does not advance the pen. + rectangle "centered": + size 120, 60 + center() + tint rgbx(255, 255, 255, 40) + patch "frame.9patch", 6 + text "centerLabel": + characters "centered" + + # Frame time. + sk.at = vec2(sk.size.x - 250, Margin) + let ms = sk.avgFrameTime * 1000 + text(&"frame time: {ms:>7.3f}ms") + + sk.endUi() + window.swapBuffers() + +when defined(emscripten): + window.run() +else: + while not window.closeRequested: + pollEvents() diff --git a/tests/test_layout.nim b/tests/test_layout.nim new file mode 100644 index 0000000..df2d4d2 --- /dev/null +++ b/tests/test_layout.nim @@ -0,0 +1,213 @@ +## Headless tests for the two-pen layout model (docs/layout_theory.md). +## Asserts exact rects for all four stack directions (T1), hug (T2), +## fill (T3), indent (T4), centering (T5), and reverse-direction hug +## (T6) via the semantic capture layer. +## Run with: nim r -d:silkyTesting tests/test_layout.nim + +when not defined(silkyTesting): + {.error: "Must compile with -d:silkyTesting".} + +import + std/unittest, + bumpy, vmath, + silky + +let builder = newAtlasBuilder(1024, 4) +builder.addDir("tests/data/", "tests/data/") +builder.addFont("tests/data/IBMPlexSans-Regular.ttf", "Default", 18.0) +builder.write("tests/dist/test_layout_atlas.png") + +# The demo region: box at (50, 50) sized 400x300, padding 10, spacing 5. +# Inner region: pos (60, 60), size (380, 280). +# Two boxes: b0 48x48, b1 64x32. +var + dir = TopToBottom + hugMode = false + fillCross = false + indentSecond = false + centered = false + scrollFrame = false + +proc layoutUI(sk: Silky, window: Window) = + ui: + rectangle "demoArea": + box 50, 50, 400, 300 + tint "#2a2a3e" + layout dir + horizontalPadding 10 + verticalPadding 10 + itemSpacing 5 + + template boxes() = + rectangle "b0": + size 48, 48 + if fillCross: + fillWidth() + tint "#e74c3c" + if indentSecond: + indent 24: + rectangle "b1": + size 64, 32 + tint "#3498db" + else: + rectangle "b1": + size 64, 32 + tint "#3498db" + + if hugMode: + group "hugger": + hug() + layout dir + horizontalPadding 10 + verticalPadding 10 + itemSpacing 5 + boxes() + else: + boxes() + + if centered: + rectangle "c": + size 120, 60 + center() + tint "#ffffff" + + if scrollFrame: + # T7: frame at (450, 0) sized 200x150, default padding 8. + # Inner region: (458, 8) size 184x134; bottom edge y = 142. + frame "sf": + box 450, 0, 200, 150 + layout dir + for i in 0 ..< 8: + rectangle "sb" & $i: + size 40, 40 + tint "#2ecc71" + +proc rectOf(h: TestHarness, name: string): Rect = + let node = h.sk.semantic.root.findByName(name, "Rectangle") + check node != nil + if node != nil: + result = node.rect + +proc hugRectOf(h: TestHarness, name: string): Rect = + let node = h.sk.semantic.root.findByName(name, "Group") + check node != nil + if node != nil: + result = node.rect + +proc checkRect(r: Rect, x, y, w, h: float32) = + check abs(r.x - x) < 0.5 + check abs(r.y - y) < 0.5 + check abs(r.w - w) < 0.5 + check abs(r.h - h) < 0.5 + +var h = newTestHarness("tests/dist/test_layout_atlas.png", 800, 600) + +proc pump() = + discard h.pumpFrame(layoutUI) + +suite "Two-pen layout": + + setup: + dir = TopToBottom + hugMode = false + fillCross = false + indentSecond = false + centered = false + scrollFrame = false + if frameStates.contains("sf"): + frameStates["sf"].scrollPos = vec2(0, 0) + + test "T1 TopToBottom: pen walks down from the top-left": + pump() + checkRect(h.rectOf("b0"), 60, 60, 48, 48) + checkRect(h.rectOf("b1"), 60, 113, 64, 32) + + test "T1 BottomToTop: pen walks up from the bottom-left": + dir = BottomToTop + pump() + # Inner bottom edge is y = 340; b0 sits on it (A3: stretches away). + checkRect(h.rectOf("b0"), 60, 292, 48, 48) + checkRect(h.rectOf("b1"), 60, 255, 64, 32) + + test "T1 LeftToRight: pen walks right from the top-left": + dir = LeftToRight + pump() + checkRect(h.rectOf("b0"), 60, 60, 48, 48) + checkRect(h.rectOf("b1"), 113, 60, 64, 32) + + test "T1 RightToLeft: pen walks left from the top-right": + dir = RightToLeft + pump() + # Inner right edge is x = 440; b0's right edge sits on it. + checkRect(h.rectOf("b0"), 392, 60, 48, 48) + checkRect(h.rectOf("b1"), 323, 60, 64, 32) + + test "T2 hug: parent sizes to children extent + padding": + hugMode = true + pump() + # extent = (max(48, 64), 48 + 5 + 32) = (64, 85); + padding 20. + checkRect(h.hugRectOf("hugger"), 60, 60, 84, 105) + checkRect(h.rectOf("b0"), 70, 70, 48, 48) + checkRect(h.rectOf("b1"), 70, 123, 64, 32) + + test "T6 reverse hug: same size, anchored to the bottom": + hugMode = true + dir = BottomToTop + pump() + # Same hug size; its bottom edge sits on the inner bottom (340). + checkRect(h.hugRectOf("hugger"), 60, 235, 84, 105) + + test "T3 fill: child takes the known inner cross size": + fillCross = true + pump() + checkRect(h.rectOf("b0"), 60, 60, 380, 48) + + test "T4 indent: cross-axis nudge for the indented block": + indentSecond = true + pump() + checkRect(h.rectOf("b0"), 60, 60, 48, 48) + checkRect(h.rectOf("b1"), 84, 113, 64, 32) + + test "T5 center: both known, centered in the inner region": + centered = true + pump() + checkRect(h.rectOf("c"), 190, 170, 120, 60) + + test "T5 center: does not advance the pen": + centered = true + pump() + # b1 is placed exactly as without the centered box. + checkRect(h.rectOf("b1"), 60, 113, 64, 32) + + test "T7 scroll: top-origin frame rests at the top": + scrollFrame = true + pump() + checkRect(h.rectOf("sb0"), 458, 8, 40, 40) + + test "T7 scroll: bottom-origin frame rests scrolled to the bottom": + scrollFrame = true + dir = BottomToTop + pump() + # First box sits on the inner bottom edge — origin pinned at rest. + checkRect(h.rectOf("sb0"), 458, 102, 40, 40) + + test "T7 scroll: t translates content by -sigma*t": + scrollFrame = true + dir = BottomToTop + pump() + frameStates["sf"].scrollPos = vec2(0, 30) + pump() + # Content moves down 30 px, revealing the overflow hiding above. + checkRect(h.rectOf("sb0"), 458, 132, 40, 40) + + test "T7 scroll: t clamps to the overflow": + scrollFrame = true + dir = BottomToTop + pump() + frameStates["sf"].scrollPos = vec2(0, 99999) + pump() + pump() + # Content = 8 * (40 + 8) + 16 = 400; overflow = 400 - 150 = 250. + check abs(frameStates["sf"].scrollPos.y - 250) < 0.5 + +echo "All layout tests done." From 769a87f2a6e6fc59a49137b7a6198c92914870dc Mon Sep 17 00:00:00 2001 From: treeform Date: Fri, 10 Jul 2026 18:34:04 -1000 Subject: [PATCH 2/4] Playground: scroll frame fills the demo area, up to 30 boxes The T7 demo frame now composes fill (T3) with scrolling: fillWidth/ fillHeight instead of a fixed 340x300 box, so it uses the whole demo area. Box count cap raised to 30 (palette wraps) so horizontal directions can overflow and show their scrollbars too. Co-Authored-By: Claude Fable 5 --- tests/manual_layout.nim | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/manual_layout.nim b/tests/manual_layout.nim index 4d1e61e..1f54828 100644 --- a/tests/manual_layout.nim +++ b/tests/manual_layout.nim @@ -80,7 +80,7 @@ window.onFrame = proc() = scrubber("padding", layoutPadding, 0.0, 60.0, &"Padding: {layoutPadding:0.0f}") scrubber("spacing", layoutSpacing, 0.0, 40.0, &"Spacing: {layoutSpacing:0.0f}") - scrubber("numBoxes", numBoxes, 1.0, 10.0, &"Boxes: {numBoxes.int}") + scrubber("numBoxes", numBoxes, 1.0, 30.0, &"Boxes: {numBoxes.int}") ui: text("Stack direction (A2: pick the origin corner):") @@ -135,18 +135,18 @@ window.onFrame = proc() = if showIndent and i >= 2 and i <= 3: indent 24: rectangle "box" & $i: - size BoxSizes[i].x, BoxSizes[i].y - tint BoxColors[i] + size BoxSizes[i mod 10].x, BoxSizes[i mod 10].y + tint BoxColors[i mod 10] else: rectangle "box" & $i: - size BoxSizes[i].x, BoxSizes[i].y + size BoxSizes[i mod 10].x, BoxSizes[i mod 10].y if parentMode == 2: # T3: stretch the cross axis into the known region. if vertical: fillWidth() else: fillHeight() - tint BoxColors[i] + tint BoxColors[i mod 10] if showWidgets and i == 1: button("Click " & $clickCount): inc clickCount @@ -168,8 +168,10 @@ window.onFrame = proc() = # T7: the scroll origin is the layout origin. A BottomToTop # frame rests scrolled to the bottom and its thumb rests at # the bottom of the track; RightToLeft rests at the right. + # The frame fills the demo area (T3) and clips its overflow. frame "scroller": - size 340, 300 + fillWidth() + fillHeight() layout dir horizontalPadding pad verticalPadding pad From bca20b300ba8ca66c6618584a373c4ac24a09cfe Mon Sep 17 00:00:00 2001 From: treeform Date: Fri, 10 Jul 2026 20:58:30 -1000 Subject: [PATCH 3/4] Make scrolling an independent property: scrollable() verb (T7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrolling is a ramification of clipping, not a sizing mode — so it's now scrollable() on any known-size node, orthogonal to fixed/fill/hug. Internally scrollable nodes route through the same clip + origin- relative-scroll machinery as frames (finishFrameScrollbars now keys off frameState instead of the node kind). Playground: "Scroll frame" radio replaced by a "Scrollable (T7)" checkbox that makes the demo area itself scrollable, composing with all three sizing modes. New test: scrollable() rectangle rests on its bottom edge in BottomToTop and translates by -sigma*t. Co-Authored-By: Claude Fable 5 --- docs/layout_plan.md | 7 ++++++- src/silky/dsl.nim | 17 +++++++++++++++-- tests/manual_layout.nim | 20 ++++++-------------- tests/test_layout.nim | 26 ++++++++++++++++++++++++-- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/docs/layout_plan.md b/docs/layout_plan.md index 48c7a21..652e509 100644 --- a/docs/layout_plan.md +++ b/docs/layout_plan.md @@ -142,7 +142,12 @@ rectangle "badge": group "tree": indent 16: # T4 text "child": ... -``` + +rectangle "log": + size 400, 200 + scrollable() # T7: independent of sizing — scrolling is a + ... # ramification of clipping, legal on any +``` # known-size node (not hug, A8) Defaults stay compatible: `frame`/`group` keep today's fill-remaining-space behavior unless told otherwise; `text`/`image` stay diff --git a/src/silky/dsl.nim b/src/silky/dsl.nim index 4b7a300..cca341d 100644 --- a/src/silky/dsl.nim +++ b/src/silky/dsl.nim @@ -42,6 +42,7 @@ type fixedSize*: Vec2 centerXFlag*: bool centerYFlag*: bool + scrollableFlag*: bool chromeMark*: int chromeLayer*: int materialized*: bool @@ -179,6 +180,7 @@ proc resetDslNode(node: DslNode, sk: Silky, kind: DslNodeKind, id: string) {.mea node.fixedSize = vec2(0, 0) node.centerXFlag = false node.centerYFlag = false + node.scrollableFlag = false node.chromeMark = 0 node.chromeLayer = 0 node.materialized = false @@ -415,6 +417,10 @@ proc pushChildrenLayout(sk: Silky, node: DslNode) = if node.isHug and node.kind != nkFrame and not node.hasBox: # T2: chrome is deferred to close; children draw first while the # stretch pen measures them. Record where this span begins. + when defined(silkyLayoutDebug): + if node.scrollableFlag: + echo "[silky] scrollable needs a known region, not hug (A8): ", + node.id let r = sk.resolveNodeRect(node) sk.beginSemantic(node, r) node.chromeLayer = sk.currentDrawLayer @@ -436,7 +442,7 @@ proc pushChildrenLayout(sk: Silky, node: DslNode) = return sk.drawNode(node, keepSemanticOpen = true) let r = node.resolvedRect - if node.kind == nkFrame: + if node.kind == nkFrame or node.scrollableFlag: if node.id notin frameStates: frameStates[node.id] = FrameState() node.frameState = frameStates[node.id] @@ -546,7 +552,7 @@ proc closeChildrenLayout(sk: Silky, window: auto, node: DslNode) = sk.closeHugLayout(node) node.pushedLayout = false return - if node.kind == nkFrame: + if node.frameState != nil: sk.finishFrameScrollbars(window, node) sk.popLayout() if node.pushedClip: @@ -783,6 +789,13 @@ template indent*(amount: SomeNumber, body: untyped) = finally: sk.currentScope.indent -= amount.float32 +proc scrollable*(enabled = true) {.inline.} = + ## T7: clip this node and scroll its overflow. Scrolling is a + ## ramification of clipping, independent of sizing — legal on any + ## node whose region is known (fixed or fill; not hug, A8). + if current != nil: + current.scrollableFlag = enabled + proc clipContent*(enabled = true) {.inline.} = if current != nil: current.clipContent = enabled diff --git a/tests/manual_layout.nim b/tests/manual_layout.nim index 1f54828..8e903da 100644 --- a/tests/manual_layout.nim +++ b/tests/manual_layout.nim @@ -69,6 +69,7 @@ var showIndent = false showCenter = false showWidgets = false + showScroll = false clickCount = 0 window.onFrame = proc() = @@ -99,11 +100,11 @@ window.onFrame = proc() = radioButton("Fixed region", parentMode, 0) radioButton("Hug children (T2)", parentMode, 1) radioButton("Fill cross axis (T3)", parentMode, 2) - radioButton("Scroll frame (T7)", parentMode, 3) group "toggleRow": hug() layout LeftToRight + checkBox("Scrollable (T7)", showScroll) checkBox("Indent (T4)", showIndent) checkBox("Centered box (T5)", showCenter) checkBox("Widgets in flow", showWidgets) @@ -129,6 +130,10 @@ window.onFrame = proc() = horizontalPadding pad verticalPadding pad itemSpacing spacing + if showScroll: + # T7: scrolling is an independent property — a ramification of + # clipping — orthogonal to how children are sized. + scrollable() template demoBoxes() = for i in 0 ..< n: @@ -164,19 +169,6 @@ window.onFrame = proc() = verticalPadding pad itemSpacing spacing demoBoxes() - elif parentMode == 3: - # T7: the scroll origin is the layout origin. A BottomToTop - # frame rests scrolled to the bottom and its thumb rests at - # the bottom of the track; RightToLeft rests at the right. - # The frame fills the demo area (T3) and clips its overflow. - frame "scroller": - fillWidth() - fillHeight() - layout dir - horizontalPadding pad - verticalPadding pad - itemSpacing spacing - demoBoxes() else: demoBoxes() diff --git a/tests/test_layout.nim b/tests/test_layout.nim index df2d4d2..f5ee688 100644 --- a/tests/test_layout.nim +++ b/tests/test_layout.nim @@ -81,6 +81,17 @@ proc layoutUI(sk: Silky, window: Window) = rectangle "sb" & $i: size 40, 40 tint "#2ecc71" + # T7: scrollable() on a plain rectangle — no padding, so the + # inner region is the full box; bottom edge y = 350. + rectangle "sr": + box 660, 200, 200, 150 + scrollable() + layout dir + tint "#333344" + for i in 0 ..< 8: + rectangle "sc" & $i: + size 40, 40 + tint "#9b59b6" proc rectOf(h: TestHarness, name: string): Rect = let node = h.sk.semantic.root.findByName(name, "Rectangle") @@ -114,8 +125,9 @@ suite "Two-pen layout": indentSecond = false centered = false scrollFrame = false - if frameStates.contains("sf"): - frameStates["sf"].scrollPos = vec2(0, 0) + for id in ["sf", "sr"]: + if frameStates.contains(id): + frameStates[id].scrollPos = vec2(0, 0) test "T1 TopToBottom: pen walks down from the top-left": pump() @@ -210,4 +222,14 @@ suite "Two-pen layout": # Content = 8 * (40 + 8) + 16 = 400; overflow = 400 - 150 = 250. check abs(frameStates["sf"].scrollPos.y - 250) < 0.5 + test "T7 scrollable(): a plain rectangle scrolls like a frame": + scrollFrame = true + dir = BottomToTop + pump() + # No padding: first box sits on the box's own bottom edge (350). + checkRect(h.rectOf("sc0"), 660, 310, 40, 40) + frameStates["sr"].scrollPos = vec2(0, 30) + pump() + checkRect(h.rectOf("sc0"), 660, 340, 40, 40) + echo "All layout tests done." From 87f72d694df16ff1d584de2a855a772b96a92e03 Mon Sep 17 00:00:00 2001 From: treeform Date: Fri, 4 Sep 2026 14:31:03 -0700 Subject: [PATCH 4/4] fix hug layout and scrolling bugs --- .github/workflows/build.yml | 3 + src/silky/clips.nim | 89 ++++++++++++++++++ src/silky/contexts.nim | 112 +++++++++++++--------- src/silky/dsl.nim | 136 ++++++++++++++++----------- src/silky/layout.nim | 4 +- src/silky/semantics.nim | 45 ++++----- tests/manual_layout.nim | 2 +- tests/test_layout_clips.nim | 100 ++++++++++++++++++++ tests/test_layout_regressions.nim | 150 ++++++++++++++++++++++++++++++ 9 files changed, 516 insertions(+), 125 deletions(-) create mode 100644 src/silky/clips.nim create mode 100644 tests/test_layout_clips.nim create mode 100644 tests/test_layout_regressions.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d344800..67f33f6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,6 +56,9 @@ jobs: run: | nim r tests/test_textboxes.nim nim r tests/test_atlas_png.nim + nim r -d:silkyTesting tests/test_layout.nim + nim r -d:silkyTesting tests/test_layout_regressions.nim + nim r ${{ matrix.build_flags }} tests/test_layout_clips.nim - name: Compile examples if: ${{ !matrix.emscripten }} diff --git a/src/silky/clips.nim b/src/silky/clips.nim new file mode 100644 index 0000000..14ae5cf --- /dev/null +++ b/src/silky/clips.nim @@ -0,0 +1,89 @@ +import bumpy, vmath + +type + ClipRegion* = object + ## Original bounds and their intersection with an optional parent. + bounds*: Rect + visible*: Rect + parent*: int + fixed*: bool + + ClipStack* = object + ## Clip history retained until the current frame ends. + regions*: seq[ClipRegion] + stack*: seq[int] + captures*: int + + VertexSpan* = object + ## Vertex and clip boundaries for a deferred layout on both layers. + marks*: array[2, int] + clipMark*: int + +proc intersectClips(a, b: Rect): Rect = + ## Intersects clip bounds while keeping empty intersections empty. + let + x = max(a.x, b.x) + y = max(a.y, b.y) + rect( + x, + y, + max(0.0'f, min(a.x + a.w, b.x + b.w) - x), + max(0.0'f, min(a.y + a.h, b.y + b.h) - y) + ) + +proc addClip*( + clips: var ClipStack, + bounds: Rect, + parent = -1, + fixed = false +): int = + ## Records original clip bounds before intersecting them. + result = clips.regions.len + let visible = + if parent >= 0: + intersectClips(clips.regions[parent].visible, bounds) + else: + bounds + clips.regions.add(ClipRegion( + bounds: bounds, + visible: visible, + parent: parent, + fixed: fixed + )) + +proc pushClip*(clips: var ClipStack, bounds: Rect, raw = false) = + ## Pushes a local clip or a fixed clip that bypasses its ancestors. + let + parent = + if not raw and clips.stack.len > 0: + clips.stack[^1] + else: + -1 + index = clips.addClip(bounds, parent, fixed = raw) + clips.stack.add(index) + +proc popClip*(clips: var ClipStack) = + ## Removes the active clip while retaining bounds for deferred draws. + let index = clips.stack.pop() + if clips.captures == 0: + clips.regions.setLen(index) + +proc clipRect*(clips: ClipStack): Rect = + ## Returns the active intersection for drawing and interaction. + clips.regions[clips.stack[^1]].visible + +proc translateClips*(clips: var ClipStack, first: int, offset: Vec2) = + ## Moves local clips, then intersects them with their final ancestors. + for i in first ..< clips.regions.len: + if not clips.regions[i].fixed: + clips.regions[i].bounds.x += offset.x + clips.regions[i].bounds.y += offset.y + let parent = clips.regions[i].parent + clips.regions[i].visible = + if parent >= 0: + intersectClips( + clips.regions[parent].visible, + clips.regions[i].bounds + ) + else: + clips.regions[i].bounds diff --git a/src/silky/contexts.nim b/src/silky/contexts.nim index c5faad2..80fa401 100644 --- a/src/silky/contexts.nim +++ b/src/silky/contexts.nim @@ -1,7 +1,7 @@ import std/[algorithm, tables, unicode, times], pixie, vmath, windy, bumpy, - silky/[atlas, layout, profiles] + silky/[atlas, clips, layout, profiles] export layout, profiles @@ -108,7 +108,8 @@ type ## Live packer for runtime atlas extension; nil until first add. builder*: AtlasBuilder drawer*: Drawer - clipStack: seq[Rect] + clipStack: ClipStack + vertexClips: array[2, seq[int]] frameStartTime*: float64 frameTime*: float64 avgFrameTime*: float64 @@ -184,35 +185,20 @@ proc stackDirection*(sk: Silky): StackDirection = sk.layoutStack[^1].direction proc pushRawClipRect*(sk: Silky, rect: Rect) = - ## Pushes a clip rectangle without intersection. - sk.clipStack.add(rect) + ## Pushes fixed clip bounds without intersection with ancestors. + sk.clipStack.pushClip(rect, raw = true) proc pushClipRect*(sk: Silky, rect: Rect) = - ## Pushes a clip rectangle intersected with the parent clip. - if sk.clipStack.len == 0: - sk.pushRawClipRect(rect) - return - - let - parentClip = sk.clipStack[^1] - x1 = max(parentClip.x, rect.x) - y1 = max(parentClip.y, rect.y) - x2 = min(parentClip.x + parentClip.w, rect.x + rect.w) - y2 = min(parentClip.y + parentClip.h, rect.y + rect.h) - sk.pushRawClipRect(rect( - x1, - y1, - max(0.0'f, x2 - x1), - max(0.0'f, y2 - y1) - )) + ## Pushes local clip bounds intersected with the parent clip. + sk.clipStack.pushClip(rect) proc popClipRect*(sk: Silky) = ## Pops the current clip rectangle. - discard sk.clipStack.pop() + sk.clipStack.popClip() proc clipRect*(sk: Silky): Rect = - ## Returns the current clip rectangle. - sk.clipStack[^1] + ## Returns the current clip intersection. + sk.clipStack.clipRect() proc instanceCount*(sk: Silky): int = ## Returns the number of queued drawer vertices. @@ -241,6 +227,25 @@ proc vertexMark*(sk: Silky): int = ## Current vertex count on the active layer, for later patching. sk.drawer.layers[sk.drawer.currentLayer].len +proc beginVertexSpan*(sk: Silky): VertexSpan = + ## Captures vertices and original clips until a layout is placed. + result.clipMark = sk.clipStack.regions.len + for layer in 0 ..< result.marks.len: + result.marks[layer] = sk.drawer.layers[layer].len + sk.vertexClips[layer].setLen(result.marks[layer]) + inc sk.clipStack.captures + +proc endVertexSpan*(sk: Silky, span: VertexSpan, offset: Vec2) = + ## Places a deferred span and resolves clips against fixed ancestors. + sk.clipStack.translateClips(span.clipMark, offset) + for layer in 0 ..< span.marks.len: + for i in span.marks[layer] ..< sk.drawer.layers[layer].len: + let clip = sk.clipStack.regions[sk.vertexClips[layer][i]].visible + sk.drawer.layers[layer][i].pos += offset + sk.drawer.layers[layer][i].clipPos = clip.xy + sk.drawer.layers[layer][i].clipSize = clip.wh + dec sk.clipStack.captures + proc moveVerticesBehind*(sk: Silky, layer, spanStart, chromeStart: int) = ## Rotates vertices emitted after chromeStart to the front of the ## span so late-drawn parent chrome renders behind its children (T2). @@ -249,6 +254,10 @@ proc moveVerticesBehind*(sk: Silky, layer, spanStart, chromeStart: int) = sk.drawer.layers[layer].rotateLeft( spanStart ..< total, chromeStart - spanStart ) + if sk.clipStack.captures > 0: + sk.vertexClips[layer].rotateLeft( + spanStart ..< total, chromeStart - spanStart + ) proc translateVertices*(sk: Silky, layer, spanStart: int, offset: Vec2) = ## Shifts vertices emitted since spanStart; realizes A6 for boxes @@ -345,6 +354,23 @@ proc endUiShared*(sk: Silky) = measurePop() endProfileFrame() +proc recordVertexClip( + sk: Silky, + layer, count: int, + bounds: Rect, + inherited: bool +) = + ## Records clip ownership only while a layout awaits placement. + if sk.clipStack.captures == 0: + return + let index = + if inherited: + sk.clipStack.stack[^1] + else: + sk.clipStack.addClip(bounds) + for i in 0 ..< count: + sk.vertexClips[layer].add(index) + proc drawQuad*( sk: Silky, pos: Vec2, @@ -379,6 +405,12 @@ proc drawQuad*( m3 = maskUvPos + vec2(0, maskUvSize.y) layer = sk.drawer.currentLayer + sk.recordVertexClip( + layer, + 6, + rect(cPos, cSize), + clipPos.x < 0 and clipSize.x < 0 + ) sk.drawer.layers[layer].add(DrawerVertex( pos: pos0, uv: uv0, @@ -445,6 +477,12 @@ proc drawTriangle*( if clipSize.x < 0: sk.clipRect.wh else: clipSize layer = sk.drawer.currentLayer + sk.recordVertexClip( + layer, + 3, + rect(cPos, cSize), + clipPos.x < 0 and clipSize.x < 0 + ) for i in 0 ..< 3: sk.drawer.layers[layer].add(DrawerVertex( pos: positions[i], @@ -489,21 +527,11 @@ proc drawText*( needsVAlign = vAlign != TopAlign var currentPos = pos + vec2(0, fontData.ascent) - let - parentClip = sk.clipRect - textClip = - if clip: - let - cx1 = max(pos.x, parentClip.x) - cy1 = max(pos.y, parentClip.y) - cx2 = min(maxPos.x, parentClip.x + parentClip.w) - cy2 = min(maxPos.y, parentClip.y + parentClip.h) - ( - vec2(cx1, cy1), - vec2(max(0.0'f, cx2 - cx1), max(0.0'f, cy2 - cy1)) - ) - else: - (parentClip.xy, parentClip.wh) + if clip: + sk.pushClipRect(rect(pos, vec2(maxWidth, maxHeight))) + defer: + if clip: + sk.popClipRect() let textStartIdx = sk.drawer.layers[layer].len var lineStartIdx = textStartIdx @@ -600,9 +628,7 @@ proc drawText*( vec2(entry.boundsWidth, entry.boundsHeight), vec2(entry.x.float32, entry.y.float32), vec2(entry.boundsWidth, entry.boundsHeight), - color, - textClip[0], - textClip[1] + color ) currentPos.x += entry.advance @@ -910,6 +936,8 @@ proc clear*(sk: Silky) = sk.drawer.layers[PopupsLayer].setLen(0) sk.drawer.currentLayer = NormalLayer sk.drawer.layerStack.setLen(0) + for layer in 0 ..< sk.vertexClips.len: + sk.vertexClips[layer].setLen(0) proc beginUi*(sk: Silky, window: Window, size: IVec2) = ## Begins a new UI frame. diff --git a/src/silky/dsl.nim b/src/silky/dsl.nim index cca341d..0b74d27 100644 --- a/src/silky/dsl.nim +++ b/src/silky/dsl.nim @@ -1,6 +1,7 @@ import std/tables, bumpy, chroma, pixie, vmath, + silky/clips, silky/widgets as baseWidgets when defined(silkyTesting): @@ -45,6 +46,7 @@ type scrollableFlag*: bool chromeMark*: int chromeLayer*: int + vertexSpan: VertexSpan materialized*: bool startedChildren*: bool pushedLayout*: bool @@ -183,6 +185,7 @@ proc resetDslNode(node: DslNode, sk: Silky, kind: DslNodeKind, id: string) {.mea node.scrollableFlag = false node.chromeMark = 0 node.chromeLayer = 0 + node.vertexSpan = VertexSpan() node.materialized = false node.startedChildren = false node.pushedLayout = false @@ -335,6 +338,65 @@ proc resolveNodeRect(sk: Silky, node: DslNode): Rect = node.resolved = true node.resolvedRect +proc measureHug( + node: DslNode, + scope, parentScope: LayoutScope +): tuple[bounds: Rect, shift: Vec2] = + ## Resolves the current content extent without closing the layout. + let + content = scope.contentBox() + childPos = scope.regionPos + signs = dirSigns(node.direction) + var r = node.resolvedRect + # The stretch pen includes one trailing item spacing per axis + # (advancePen's legacy inflation); hug shouldn't. + let extent = vec2( + max(0.0'f, content.w - node.itemSpacing), + max(0.0'f, content.h - node.itemSpacing) + ) + var contentMin = content.xy + if signs.x < 0: + contentMin.x += min(node.itemSpacing, content.w) + if signs.y < 0: + contentMin.y += min(node.itemSpacing, content.h) + if node.widthMode == smHug: + r.w = extent.x + node.horizontalPadding * 2 + if node.heightMode == smHug: + r.h = extent.y + node.verticalPadding * 2 + # Reverse-direction hug: children grew away from the provisional + # origin; pull the whole rigid box back into place. + var shift = vec2(0, 0) + if node.widthMode == smHug: + shift.x = childPos.x - contentMin.x + if node.heightMode == smHug: + shift.y = childPos.y - contentMin.y + # The box's own position may move now that its size is known: + # reverse-direction parents place against the pen (A3), centered + # nodes re-center with the final size (T5). + if not node.hasBox: + var finalPos = parentScope.placedPos(r.wh) + if node.centerXFlag and parentScope.knownW: + finalPos.x = parentScope.regionPos.x + + (parentScope.regionSize.x - r.w) * 0.5 + if node.centerYFlag and parentScope.knownH: + finalPos.y = parentScope.regionPos.y + + (parentScope.regionSize.y - r.h) * 0.5 + shift += finalPos - r.xy + r.x = finalPos.x + r.y = finalPos.y + (r, shift) + +proc measuredNodeRect(sk: Silky, node: DslNode): Rect = + ## Measures an active hug for handlers placed after its children. + if node.isHug and node.pushedLayout and + node.kind != nkFrame and not node.hasBox: + return measureHug( + node, + sk.currentScope, + sk.layoutStack[^2] + ).bounds + sk.resolveNodeRect(node) + proc setInteractionState(node: DslNode, interaction: Interaction) = node.semanticHovered = interaction in [Pressed, Held, Released, Hovered] node.semanticPressed = interaction in [Pressed, Held] @@ -344,7 +406,7 @@ proc nodeInteraction*(sk: Silky, node: DslNode, isEnabled = true, isError = fals return None if not node.interactionResolved: node.semanticEnabled = isEnabled - let r = sk.resolveNodeRect(node) + let r = sk.measuredNodeRect(node) node.interaction = sk.interact(r, isEnabled, isError) node.setInteractionState(node.interaction) node.interactionResolved = true @@ -405,7 +467,10 @@ proc finishFrameScrollbars(sk: Silky, window: auto, node: DslNode) = if frameState == nil: return let - contentSize = sk.currentScope.contentBox().wh + dslVec2(16) + contentSize = sk.currentScope.contentBox().wh + dslVec2( + node.horizontalPadding * 2, + node.verticalPadding * 2 + ) signs = sk.currentScope.signs baseWidgets.frameScrollbars( sk, window, frameState, node.resolvedRect, contentSize, signs @@ -425,6 +490,7 @@ proc pushChildrenLayout(sk: Silky, node: DslNode) = sk.beginSemantic(node, r) node.chromeLayer = sk.currentDrawLayer node.chromeMark = sk.vertexMark() + node.vertexSpan = sk.beginVertexSpan() let childPos = r.xy + dslVec2(node.horizontalPadding, node.verticalPadding) childSize = dslVec2( @@ -486,54 +552,10 @@ proc startChildren(sk: Silky, node: DslNode) = sk.pushChildrenLayout(node) proc closeHugLayout(sk: Silky, node: DslNode) = - ## Closes a hug scope: measure the content box, translate the span - ## into final position (A6/T6), size the node, then emit its chrome - ## behind the children (T2). - let - scope = sk.currentScope - content = scope.contentBox() - childPos = scope.regionPos - signs = dirSigns(node.direction) + ## Places measured children and draws the final chrome behind them. + let (r, shift) = measureHug(node, sk.currentScope, sk.layoutStack[^2]) sk.popLayout() - var r = node.resolvedRect - # The stretch pen includes one trailing item spacing per axis - # (advancePen's legacy inflation); hug shouldn't. - let extent = vec2( - max(0.0'f, content.w - node.itemSpacing), - max(0.0'f, content.h - node.itemSpacing) - ) - var contentMin = content.xy - if signs.x < 0: - contentMin.x += min(node.itemSpacing, content.w) - if signs.y < 0: - contentMin.y += min(node.itemSpacing, content.h) - if node.widthMode == smHug: - r.w = extent.x + node.horizontalPadding * 2 - if node.heightMode == smHug: - r.h = extent.y + node.verticalPadding * 2 - # Reverse-direction hug: children grew away from the provisional - # origin; pull the whole rigid box back into place. - var shift = vec2(0, 0) - if node.widthMode == smHug: - shift.x = childPos.x - contentMin.x - if node.heightMode == smHug: - shift.y = childPos.y - contentMin.y - # The box's own position may move now that its size is known: - # reverse-direction parents place against the pen (A3), centered - # nodes re-center with the final size (T5). - if not node.hasBox: - var finalPos = sk.currentScope.placedPos(r.wh) - let parentScope = sk.currentScope - if node.centerXFlag and parentScope.knownW: - finalPos.x = parentScope.regionPos.x + - (parentScope.regionSize.x - r.w) * 0.5 - if node.centerYFlag and parentScope.knownH: - finalPos.y = parentScope.regionPos.y + - (parentScope.regionSize.y - r.h) * 0.5 - shift += finalPos - r.xy - r.x = finalPos.x - r.y = finalPos.y - sk.translateVertices(node.chromeLayer, node.chromeMark, shift) + sk.endVertexSpan(node.vertexSpan, shift) node.resolvedRect = r node.resolved = true let chromeStart = sk.vertexMark() @@ -604,7 +626,7 @@ proc scopeRect*(sk: Silky): Rect {.inline.} = ## Returns the current node rect, resolving it without retaining anything. if current == nil or current.kind == nkRoot: return rect(0'f, 0'f, 0'f, 0'f) - sk.resolveNodeRect(current) + sk.measuredNodeRect(current) proc startCurrentChildren*(sk: Silky) {.inline.} = ## Lets direct immediate widgets participate inside the active DSL scope. @@ -782,12 +804,14 @@ proc centerY*() {.inline.} = template indent*(amount: SomeNumber, body: untyped) = ## T4: nudge the cross-axis start of children placed in this block. - sk.startCurrentChildren() - sk.currentScope.indent += amount.float32 - try: - body - finally: - sk.currentScope.indent -= amount.float32 + block: + sk.startCurrentChildren() + let offset = amount.float32 + sk.currentScope.indent += offset + try: + body + finally: + sk.currentScope.indent -= offset proc scrollable*(enabled = true) {.inline.} = ## T7: clip this node and scroll its overflow. Scrolling is a diff --git a/src/silky/layout.nim b/src/silky/layout.nim index 647dde4..1ded3af 100644 --- a/src/silky/layout.nim +++ b/src/silky/layout.nim @@ -97,7 +97,9 @@ proc advancePen*(scope: var LayoutScope, size: Vec2, spacing: float32) = ## A4 + A5: move the pen along the main axis, absorb the far corner ## into the stretch pen. The stretch pen includes the trailing spacing ## on both axes to match the historical stretchAt behavior. - let far = scope.pen + scope.signs * (size + vec2(spacing, spacing)) + let cross = 1 - scope.direction.mainAxis + var far = scope.pen + scope.signs * (size + vec2(spacing, spacing)) + far[cross] += scope.signs[cross] * scope.indent scope.stretch = farthest(scope.signs, scope.stretch, far) let m = scope.direction.mainAxis scope.pen[m] += scope.signs[m] * (size[m] + spacing) diff --git a/src/silky/semantics.nim b/src/silky/semantics.nim index 4a9e50d..5092b3c 100644 --- a/src/silky/semantics.nim +++ b/src/silky/semantics.nim @@ -3,7 +3,7 @@ import std/[algorithm, strutils, tables, unicode, times], vmath, bumpy, chroma, pixie, - silky/[atlas, layout], testwindow + silky/[atlas, clips, layout], testwindow from windy/common import Button, CursorKind, Cursor @@ -305,7 +305,7 @@ type layers*: array[2, seq[SilkyVertex]] currentLayer*: int layerStack*: seq[int] - clipStack: seq[Rect] + clipStack: ClipStack frameStartTime*: float64 frameTime*: float64 avgFrameTime*: float64 @@ -385,6 +385,16 @@ proc vertexMark*(sk: Silky): int = ## Current vertex count on the active layer, for later patching. sk.layers[sk.currentLayer].len +proc beginVertexSpan*(sk: Silky): VertexSpan = + ## Captures clip bounds while a semantic layout awaits placement. + result.clipMark = sk.clipStack.regions.len + inc sk.clipStack.captures + +proc endVertexSpan*(sk: Silky, span: VertexSpan, offset: Vec2) = + ## Places captured clip bounds without emitting GPU vertices. + sk.clipStack.translateClips(span.clipMark, offset) + dec sk.clipStack.captures + proc moveVerticesBehind*(sk: Silky, layer, spanStart, chromeStart: int) = ## Rotates vertices emitted after chromeStart to the front of the ## span so late-drawn parent chrome renders behind its children (T2). @@ -403,35 +413,20 @@ proc translateVertices*(sk: Silky, layer, spanStart: int, offset: Vec2) = sk.layers[layer][i].pos += offset proc pushRawClipRect*(sk: Silky, rect: Rect) = - ## Pushes a clipping rectangle onto the stack without intersection. - sk.clipStack.add(rect) + ## Pushes fixed clip bounds without intersection with ancestors. + sk.clipStack.pushClip(rect, raw = true) proc pushClipRect*(sk: Silky, rect: Rect) = - ## Pushes a clipping rectangle intersected with the current clip rectangle. - if sk.clipStack.len == 0: - sk.pushRawClipRect(rect) - return - - let - parentClip = sk.clipStack[^1] - x1 = max(parentClip.x, rect.x) - y1 = max(parentClip.y, rect.y) - x2 = min(parentClip.x + parentClip.w, rect.x + rect.w) - y2 = min(parentClip.y + parentClip.h, rect.y + rect.h) - sk.pushRawClipRect(rect( - x1, - y1, - max(0.0'f, x2 - x1), - max(0.0'f, y2 - y1) - )) + ## Pushes local clip bounds intersected with the parent clip. + sk.clipStack.pushClip(rect) proc popClipRect*(sk: Silky) = - ## Pops the current clipping rectangle from the stack. - discard sk.clipStack.pop() + ## Pops the current clip rectangle. + sk.clipStack.popClip() proc clipRect*(sk: Silky): Rect = - ## Returns the current clipping rectangle. - sk.clipStack[^1] + ## Returns the current clip intersection. + sk.clipStack.clipRect() proc advance*(sk: Silky, amount: Vec2, spacing: float32) = ## Advances the current layout cursor with explicit spacing. diff --git a/tests/manual_layout.nim b/tests/manual_layout.nim index 8e903da..7daa9f0 100644 --- a/tests/manual_layout.nim +++ b/tests/manual_layout.nim @@ -6,7 +6,7 @@ import std/[strformat], - opengl, windy, bumpy, vmath, chroma, pixie, + windy, bumpy, vmath, chroma, pixie, silky let builder = newAtlasBuilder(1024, 4) diff --git a/tests/test_layout_clips.nim b/tests/test_layout_clips.nim new file mode 100644 index 0000000..7bd6dfd --- /dev/null +++ b/tests/test_layout_clips.nim @@ -0,0 +1,100 @@ +import + bumpy, chroma, vmath, + silky + +proc testClips( + direction: StackDirection, + nested, constrained, popup: bool +) = + ## Checks native vertex placement, clipping, and layer preservation. + let + builder = newAtlasBuilder(64, 2) + sk = Silky(atlas: builder.atlas, image: builder.atlasImage) + window = Window(nil) + viewport = rect(0, 0, 400, 300) + ancestor = + if constrained: + rect(60, 55, 20, 10) + else: + viewport + new(sk.drawer) + sk.pushLayout(vec2(0, 0), viewport.wh) + sk.pushClipRect(viewport) + sk.pushClipRect(ancestor) + sk.pushLayer(PopupsLayer) + sk.drawRect(vec2(2, 3), vec2(4, 5), rgbx(255, 255, 255, 255)) + sk.popLayer() + + template children() = + ## Draws clipped content and an optional popup escaping that clip. + rectangle "clipped": + size 40, 20 + clipContent() + itemSpacing 0 + rectangle "paint": + size 40, 20 + tint "#ff0000" + if popup: + sk.pushLayer(PopupsLayer) + sk.pushRawClipRect(viewport) + sk.drawRect(sk.pos, vec2(40, 20), rgbx(255, 0, 0, 255)) + sk.popClipRect() + sk.popLayer() + + ui: + group "outer": + box 50, 50, 200, 200 + rectangle "hugger": + hug() + layout direction + itemSpacing 0 + tint "#0000ff" + if nested: + rectangle "nested": + hug() + layout BottomToTop + itemSpacing 0 + tint "#00ff00" + children() + else: + children() + + doAssert sk.clipRect == ancestor + doAssert sk.drawer.layers[PopupsLayer][0].pos == vec2(2, 3) + for layer in 0 .. 1: + var + count = 0 + minimum = vec2(10000, 10000) + maximum = vec2(-10000, -10000) + let expectedClip = + if layer == PopupsLayer: + viewport + elif constrained: + ancestor + else: + rect(50, 50, 40, 20) + for vertex in sk.drawer.layers[layer]: + if vertex.color == rgbx(255, 0, 0, 255): + inc count + minimum = min(minimum, vertex.pos) + maximum = max(maximum, vertex.pos) + doAssert vertex.clipPos == expectedClip.xy + doAssert vertex.clipSize == expectedClip.wh + if layer == NormalLayer or popup: + doAssert count == 6 + doAssert minimum == vec2(50, 50) + doAssert maximum == vec2(90, 70) + else: + doAssert count == 0 + sk.popClipRect() + sk.popClipRect() + sk.popLayout() + sk.clear() + +echo "Testing hug clipping with native vertices in four directions" +for direction in StackDirection: + for nested in [false, true]: + for constrained in [false, true]: + for popup in [false, true]: + testClips(direction, nested, constrained, popup) +echo "All 32 native layout clipping cases passed." diff --git a/tests/test_layout_regressions.nim b/tests/test_layout_regressions.nim new file mode 100644 index 0000000..fc2bdb1 --- /dev/null +++ b/tests/test_layout_regressions.nim @@ -0,0 +1,150 @@ +import + bumpy, vmath, + silky + +when not defined(silkyTesting): + {.error: "Compile with -d:silkyTesting".} + +var + direction = TopToBottom + hugMode = 0 + clicks = 0 + indentCalls = 0 + padding = 0.0'f + overflow = vec2(0, 0) + +proc indentAmount(): float32 = + ## Counts evaluation of an indent expression. + inc indentCalls + 24 + +proc indentUi(sk: Silky, window: Window) = + ## Places a child inside nested indentation and a padded hug. + ui: + group "outer": + box 50, 50, 200, 200 + layout direction + group "hugger": + hug() + layout direction + horizontalPadding 3 + verticalPadding 3 + itemSpacing 0 + indent indentAmount(): + indent 8: + rectangle "child": + size 40, 20 + +proc clickUi(sk: Silky, window: Window) = + ## Handles clicks after children for fixed and per-axis hug sizes. + ui: + group "outer": + box 50, 50, 200, 200 + layout direction + rectangle "clickable": + size 40, 20 + layout direction + case hugMode + of 1: + hugWidth() + of 2: + hugHeight() + of 3: + hug() + else: + discard + itemSpacing 0 + rectangle "child": + size 40, 20 + onClick: + inc clicks + +proc scrollUi(sk: Silky, window: Window) = + ## Measures scroll overflow with the configured padding and direction. + ui: + rectangle "scroll": + box 50, 50, 100, 100 + layout direction + scrollable() + horizontalPadding padding + verticalPadding padding + itemSpacing 0 + rectangle "child": + size( + 100 - padding * 2 + overflow.x, + 100 - padding * 2 + overflow.y + ) + +proc clickFrame(harness: var TestHarness) = + ## Runs the full interaction lifecycle used by the example apps. + harness.sk.beginUi(harness.window, harness.window.size) + clickUi(harness.sk, harness.window) + harness.sk.endUi() + harness.window.resetInputState() + +proc testIndent(atlas: SilkyAtlas) = + ## Checks hug extents and single evaluation in every direction. + for value in StackDirection: + direction = value + indentCalls = 0 + var harness = newTestHarness(atlas) + discard harness.pumpFrame(indentUi) + let hugger = harness.sk.semantic.root.findByName("hugger") + doAssert hugger != nil + case direction + of TopToBottom, BottomToTop: + doAssert hugger.rect.wh == vec2(78, 26) + of LeftToRight, RightToLeft: + doAssert hugger.rect.wh == vec2(46, 58) + doAssert indentCalls == 1 + +proc testClicks(atlas: SilkyAtlas) = + ## Checks clicks against the final rect for both hug axes. + for value in StackDirection: + direction = value + for mode in 0 .. 3: + hugMode = mode + clicks = 0 + var harness = newTestHarness(atlas) + harness.clickFrame() + let node = harness.sk.semantic.root.findByName("clickable") + doAssert node != nil + harness.window.moveMouse( + (node.rect.x + node.rect.w / 2).int, + (node.rect.y + node.rect.h / 2).int + ) + harness.clickFrame() + harness.clickFrame() + harness.window.pressButton(MouseLeft) + harness.clickFrame() + harness.window.releaseButton(MouseLeft) + harness.clickFrame() + doAssert clicks == 1, $direction & " hug mode " & $mode + +proc testScroll(atlas: SilkyAtlas) = + ## Checks exact-fit and overflowing content with non-default padding. + for value in StackDirection: + direction = value + for inset in [0.0'f, 3, 10]: + padding = inset + for extra in [vec2(0, 0), vec2(20, 30)]: + overflow = extra + frameStates.clear() + var harness = newTestHarness(atlas) + discard harness.pumpFrame(scrollUi) + frameStates["scroll"].scrollPos = vec2(10000, 10000) + discard harness.pumpFrame(scrollUi, 2) + doAssert frameStates["scroll"].scrollPos == extra + +proc main() = + ## Runs headless composition regressions without atlas files. + let builder = newAtlasBuilder(64, 2) + echo "Testing indented hug in four directions" + testIndent(builder.atlas) + echo "Testing fixed and hug click handlers in four directions" + testClicks(builder.atlas) + echo "Testing scroll padding in four directions" + testScroll(builder.atlas) + echo "All layout regression tests passed." + +main()