suit

DSL

The declarative surface — the builders that produce vdom VNodes, the render-tree equivalent of riposte’s HTML DSL. Each builder emits an element whose tag selects a RenderObject kind and whose…

import io.github.edadma.suit.dsl.*

The declarative surface — the builders that produce vdom VNodes, the render-tree equivalent of riposte’s HTML DSL. Each builder emits an element whose tag selects a RenderObject kind and whose props are typed values (a Color, an EdgeInsets, an Alignment, a layout enum), never strings.

Configuration goes in the first parameter list and children in the second, so nesting reads cleanly:

col(spacing = 8)(
  box(bg = Color.rgb(0x222831))(text("hi")),
  spacer(),
  box(bg = Color.rgb(0x222831))(text("bye")),
)

Sizes are plain Doubles; Double.NaN means “unset” (left to the layout), so call sites stay free of Some(...).

box

The styled container — suit’s workhorse rectangle.

def box(
    bg:          Paint | Null        = null,   // a Color flows in as a solid; or a gradient
    border:      Paint | Null        = null,
    borderWidth: Double              = 0.0,
    radius:      Double              = 0.0,    // uniform corner radius
    corners:     BorderRadius | Null = null,   // per-corner control (overrides radius)
    shadow:      Shadow | Null       = null,
    opacity:     Double              = 1.0,
    width:       Double              = Double.NaN,
    height:      Double              = Double.NaN,
    padding:     EdgeInsets | Null   = null,
    clip:          Boolean             = false,  // overflow hidden — clip children to the box
    ignorePointer: Boolean             = false,  // click-through: the box and subtree take no pointer
    textColor:     Color | Null        = null,   // text-style cascade — see below
    textSize:      Double              = Double.NaN,
    textWeight:    Int                 = 0,       // 0 = inherit; 100–900 (see FontWeight)
    flex:          Int                 = 0,
    focusable:     Boolean             = false,
    acceptsText:   Boolean             = false,  // open text input while focused (text fields)
    cursor:        Cursor | Null       = null,   // pointer shape while hovered — see the Input guide
    ref: Ref[RenderObject | Null] | Null = null, // bind the live render object into a useRef box
    // pointer / wheel / key / focus handlers — see the Input guide
    onClick: (PointerEvent => Unit) | Null = null,
    /* onMouseDown, onMouseUp, onMouseMove, onMouseEnter, onMouseLeave,
       onWheel, onKeyDown, onKeyUp, onTextInput, onFocus, onBlur */
)(children: VNode*): VNode

bg / border give it a fill and outline — pass a Color (it converts to a solid paint) or a LinearGradient / RadialGradient; borderWidth sets the outline weight. radius rounds all four corners uniformly, or corners sets each independently; shadow casts a drop shadow; opacity fades the whole box (children included). width / height fix its size (omit to fill a tight parent or wrap a loose one); padding insets its child; clip hides anything the children draw outside the box (rounded corners included); flex makes it expand inside a row/column.

textColor / textSize / textWeight seed a text-style cascade: descendant text that doesn’t fix its own colour, size, or weight inherits these, CSS-style, from the nearest ancestor that set them. textWeight is a numeric weight (100900, see FontWeight) driven through the bundled variable font’s wght axis. ignorePointer makes the box and its whole subtree transparent to hit-testing, so a click passes straight through to whatever is behind (a floating tooltip uses this). ref binds the live render object into a useRef[RenderObject | Null](null) box once it mounts, so a parent can read its laid-out position and size — an overlay anchored to a trigger does exactly this. See the input guide for the handlers.

text

def text(
    content:  String,
    size:     Double       = Double.NaN,
    color:    Color | Null = null,
    weight:   Int          = 0,                  // 0 = inherit; 100–900 (see FontWeight)
    mono:     Boolean      = false,              // true = monospaced family
    align:    TextAlign    = TextAlign.Left,     // Left | Center | Right
    maxLines: Int          = 1,                  // 1 = single line; 0 = unlimited
    overflow: TextOverflow = TextOverflow.Clip,  // Clip | Ellipsis
    softWrap: Boolean      = true,
): VNode

A run of text. size, color, and weight are optional: omit any and it is inherited from the nearest enclosing box that sets textSize / textColor / textWeight, falling back to the default text style if nothing in the tree sets one. weight is a numeric font weight (100900, e.g. FontWeight.Bold) rendered through the bundled variable font’s wght axis — one Inter file serves every weight. mono = true switches the run to the bundled monospaced family (JetBrains Mono) instead of the proportional default — for code, tabular figures, or anything that wants a fixed advance.

It is single-line by default. Set maxLines to something other than 1 (use 0 for unlimited) and it word-wraps to the width the layout gives it, breaking at spaces and hard-breaking any single word too wide for a line; explicit \ns always start a new line, and softWrap = false breaks only at those. maxLines caps the number of lines; overflow = TextOverflow.Ellipsis then trims the dropped tail and marks it with (it also truncates a single over-wide line). align positions each line horizontally within the measured block. The lines are computed once during layout and replayed by paint, so the two passes always agree.

text(
  "A long paragraph that wraps across as many lines as it needs.",
  maxLines = 0,
)
text("Capped at two lines, the rest trimmed…", maxLines = 2, overflow = TextOverflow.Ellipsis)
text("centred", align = TextAlign.Center)
text("heavier", weight = FontWeight.Bold)        // 700; FontWeight has Thin…Black (100–900)
text("monospaced", mono = true)                  // JetBrains Mono

svg

def svg(image: SvgImage, width: Double = Double.NaN, height: Double = Double.NaN): VNode

A scalable vector image. It sizes to width / height when given, otherwise to the SVG’s own intrinsic size, each clamped to the constraints; being vectors, it stays crisp at any size — librsvg renders it straight into the Cairo context, with no intermediate raster. It is a leaf, so wrap it in a box to give an icon a background, padding, or a click handler.

Load an SvgImage from the platform loader (native Svg):

val icon = Svg.fromString("""<svg viewBox="0 0 64 64">…</svg>""") // or Svg.fromFile(path)

svg(icon, width = 24, height = 24)   // the one document, drawn crisp at any size
svg(icon, width = 72, height = 72)

Svg.fromString / Svg.fromFile / Svg.fromBytes throw RsvgException on a parse error. The loader is native-only (it wraps librsvg); SvgImage itself is platform-neutral, so a headless test can supply its own stand-in.

image

def image(image: RasterImage, width: Double = Double.NaN, height: Double = Double.NaN): VNode

A raster (bitmap) image. It sizes to width / height when given, otherwise to the image’s own pixel size, each clamped to the constraints, and scales to whatever box it ends up in. It is a leaf, so wrap it in a box for a background, padding, rounded corners (clip = true), or a click handler.

Load a RasterImage from the platform loader (native Raster), which decodes JPEG through turbojpeg (libjpeg-turbo). PNG is handled separately by Cairo, so for raster the loader is your JPEG path.

val photo = Raster.fromFile("photo.jpg")   // or Raster.fromBytes(bytes)

image(photo)                       // at the image's own pixel size
image(photo, width = 96, height = 96)
box(radius = 12, clip = true)(image(photo, width = 96, height = 96))  // rounded

Raster.fromFile / Raster.fromBytes throw if the image can’t be decoded. The loader is native-only (it wraps turbojpeg + Cairo); RasterImage itself is platform-neutral, so a headless test can supply its own stand-in.

canvas

def canvas(
    width:  Double = Double.NaN,
    height: Double = Double.NaN,
    ref:    Ref[RenderObject | Null] | Null = null,
    focusable: Boolean = false,
    // pointer / wheel / key handlers — see the Input guide
)(draw: (Canvas, Size) => Unit): VNode

A direct drawing surface — the toolkit’s <canvas>. draw is handed the same Canvas the built-in widgets paint through, plus the surface’s Size, and issues the drawing for the current frame. It draws in a local coordinate space whose origin is the canvas’s own top-left (0..width × 0..height), and the drawing is clipped to the canvas’s bounds, so it cannot spill past the edges. It sizes to width / height when given, otherwise fills the space its parent offers.

It is a leaf in the render tree, but it still takes pointer, wheel, and key handlers (and focusable), so an interactive surface — a sim you can click into, a sketch pad — works. Because the same Canvas seam backs it, the draw routine is testable against a RecordingCanvas exactly as suit’s own widgets are.

canvas(width = 400, height = 300) { (c, size) =>
  c.fillRect(Rect(0, 0, size.width, size.height), Color.rgb(0x101418))
  c.fillCircle(Offset(size.width / 2, size.height / 2), 40, Color.rgb(0x6c5ce7))
}

The Canvas both draws and measures text: c.drawText(origin, text, style) places a line with its top-left at origin, and c.measureText(text, style) returns the line’s Size without drawing — using the same measurer, so the two agree. That lets a painter centre its own labels:

canvas(height = 120) { (c, size) =>
  val style = TextStyle(48, Color.white, FontWeight.Bold)
  val m     = c.measureText("PAUSED", style)
  c.drawText(Offset((size.width - m.width) / 2, (size.height - m.height) / 2), "PAUSED", style)
}

For vector outlines, build a Path (move/line/arc/close, or Path.polyline for a point list) and strokePath/fillPath it — one stroked path, so corners join cleanly, unlike stroking each edge with a separate line:

canvas(width = 120, height = 120) { (c, _) =>
  val ship = Path.polyline(Seq(Offset(60, 20), Offset(80, 90), Offset(60, 75), Offset(40, 90)))
  c.strokePath(ship, Color.white, 2.0, LineJoin.Round)
}

To animate, drive it with useFrame: keep the changing state in a useRef, advance it in the callback, and read it back in draw.

val phase = useRef(0.0)
useFrame(_ => phase.current += 0.03)
canvas(height = 120) { (c, size) =>
  val x = size.width / 2 + math.cos(phase.current) * 40
  c.fillCircle(Offset(x, size.height / 2), 8, Color.rgb(0x9c6bff))
}

useFrame

def useFrame(cb: Double => Unit)(using Hooks): Unit

suit’s requestAnimationFrame: cb runs once per frame for as long as the calling component is mounted, receiving the current time in milliseconds (the same clock the motion hooks ease over). After each tick it requests a repaint, so a canvas whose draw reads state the callback advanced re-runs that frame. It advances state imperatively and repaints — it does not re-render the component, so there is no reconcile per frame; hold the animated state in a useRef (whose identity is stable, so the draw closure reads it without changing). If the callback also needs the vnode tree to change, call a useState setter from inside it as usual.

useInterval

def useInterval(
    cb:          () => Unit,
    ms:          Int,
    enabled:     Boolean    = true,
    restartKeys: Array[Any] = Array(),
)(using Hooks): Unit

suit’s setInterval: cb runs every ms milliseconds while the component is mounted, riding the same timer seam the motion hooks use. enabled gates it — while false no timer is armed, so an idle component leaves the clock idle rather than pinning it active. The interval re-arms from now whenever ms, enabled, or any value in restartKeys changes, which lets a phase restart on demand (the text-field caret bumps a counter in restartKeys so it stays solid for a full interval after each keystroke). Unlike useFrame it does not request a repaint; the usual cb flips a useState, which re-renders on its own.

surface

def surface(
    image:  RasterImage,
    handle: SurfaceHandle | Null = null,
    width:  Double = Double.NaN,
    height: Double = Double.NaN,
    ref:    Ref[RenderObject | Null] | Null = null,
    focusable: Boolean = false,
    // pointer / wheel / key handlers — see the Input guide
): VNode

The retained counterpart to canvas. Where a canvas hands you suit’s Canvas afresh every frame, a surface lets you keep your own drawing surface — draw into it whenever and however you like, with the full underlying graphics API (raw Cairo on the native backend, not suit’s Canvas subset) — and have suit blit it to the screen. This is the escape hatch for drawing suit’s Canvas doesn’t cover, e.g. Cairo’s complete text and font machinery.

You supply a RasterImage that wraps the surface (CairoBitmap.wrap(surface) on Native) and a SurfaceHandle. Draw into the surface on your own schedule, then call handle.repaint() to composite the new pixels. Unlike a canvas it re-blits only when poked, not every frame, so a static richly-drawn panel costs one copy per change rather than a re-rasterise at frame rate — and because it is a repaint boundary, that copy repaints just its region and leaves the rest of the window untouched.

It sizes to width / height when given, otherwise to the surface’s pixel size, and is a leaf that still takes pointer, wheel, and key handlers (and focusable), so an interactive panel works.

val sx   = DevicePixelRatio.scaleX                      // device pixels per logical unit
val surf = imageSurfaceCreate(Format.ARGB32, (400 * sx).toInt, (300 * sx).toInt)
val img  = CairoBitmap.wrap(surf)                       // present the surface as a RasterImage
val h    = useRef(new SurfaceHandle).current            // stable across renders

def redraw(): Unit =
  val cr = surf.create
  cr.scale(sx, sx)                                       // draw in logical units
  cr.selectFontFace("Georgia", FontSlant.Normal, FontWeight.Bold)
  cr.setFontSize(18)
  cr.moveTo(20, 40)
  cr.showText("Anything Cairo can draw")                 // the full raw Cairo text API
  cr.destroy()
  surf.flush(); surf.markDirty()                         // publish the pixels…
  h.repaint()                                            // …and ask suit to blit them

surface(img, h, width = 400, height = 300)

HiDPI. A surface is a fixed grid of pixels. Read DevicePixelRatio.scaleX / scaleY (installed by the runtime; 1.0 on a 1× display, 2.0 on a Retina display), make the surface in device pixels (logical × scale), and pass logical width / height to the widget; the blit then lands the surface’s pixels one-to-one on the display. (Scaling the Cairo context by the same ratio, as above, lets your drawing code stay in logical units.)

You own the surface, so you free it (surface.destroy()) when the panel goes away — suit only reads it. A repaint() before the widget mounts, or after it unmounts, is a harmless no-op.

video

def video(
  layer:       VideoLayer,
  fit:         VideoFit = VideoFit.Contain,
  pixelAspect: Double   = 1.0,
  background:  Color    = Color.black,
  width:       Double   = Double.NaN,
  height:      Double   = Double.NaN,
  // … the same pointer / key handlers and `focusable` as `canvas`
): VNode

A video frame — the one thing suit does not rasterise.

Everything else in a window is drawn by Cairo into one image surface and uploaded as one texture. Push video through that and every frame costs a colourspace conversion (a decoder emits YUV, Cairo wants BGRA), a CPU blit, a CPU scale, and a re-upload of the whole window — three full-frame passes, thirty or sixty times a second.

So a video layer skips Cairo. Its frame stays in the decoder’s own YUV layout in its own texture, and the renderer converts and scales it in the blit’s shader. video reserves a rectangle, fills it with background, and punches a transparent hole exactly where the frame belongs; the runtime blits the texture into that hole from underneath and the UI composites over it. The hole is the whole mechanism.

The payoff: a new frame costs no repaint — no relayout, not even a dirty flag. Nothing Cairo drew has changed. Hand the layer a frame and the next present shows it, riding the vsync the loop was already doing.

val tex = useMemo(() => VideoTexture(1920, 1080, VideoFormat.I420, VideoColorspace.BT709), Array())

// on a decoder thread — never touch state or the tree here; hand it over (see the threading guide)
UiThread.post(() => tex.update(f.y, f.yPitch, f.u, f.uPitch, f.v, f.vPitch))

video(tex, fit = VideoFit.Contain)

Fitting. VideoFit.Contain (the default) scales the frame to fit entirely inside, preserving aspect, and centres it — the whole frame is visible and the leftover shows background as letterbox or pillarbox bars. That is what a preview monitor wants: never crop what the editor is judging. Cover fills the rectangle and crops the overhang instead (by reading a sub-rect of the frame, so no clip is involved). Fill stretches to the rectangle exactly, ignoring aspect.

Pixel aspect. pixelAspect is the displayed width of one stored pixel over its height. Leave it at 1.0 for square-pixel formats — everything HD, and most modern files — but set it for anamorphic and SD sources, where ignoring it shows people visibly too thin or too wide.

It fills the space the parent offers unless given width / height, and takes pointer and key handlers, so a click-to-scrub monitor works.

Warning

Match the colorspace to the source. A VideoTexture fixes its colorspace at creation, because that is the only point SDL allows it. VideoColorspace.BT709 is right for HD and is the default; BT601 for SD; JPEG for full-range sources. Getting it wrong is not an error — the picture just comes out with shifted colour.

Video always composites under the UI, which is the right constraint for an editor (a preview monitor and timeline thumbnails, with chrome above them). suit is not a compositing engine: two clips dissolving into one another is your pipeline doing the mix and handing suit one output frame.

Inside a scroll. Because the frame is blitted straight onto the window rather than drawn by Cairo, it is confined to a scroll viewport or clipped box by the same clip that bounds its hole: the blit is cropped to the ancestor clips and the source narrowed to the still-visible slice, so a preview in scrolling chrome stays within it instead of the texture spilling over the edges. (A clip’s corner radius is not applied to the blit — the visible frame keeps square corners.)

scrollView

def scrollView(
    axis:               Axis                            = Axis.Vertical,
    both:               Boolean                         = false,
    scrollbar:          Boolean                         = false,
    scrollbarThumb:     Color | Null                    = null,
    scrollbarTrack:     Color | Null                    = null,
    scrollbarThickness: Double                          = Double.NaN,
    ref:                Ref[RenderObject | Null] | Null = null,
    onScroll:           (Offset => Unit) | Null         = null,
)(children: VNode*): VNode

A scrolling viewport over its content. The viewport fills the space its parent gives it; the content takes its natural extent along the scroll axis and is clipped to the viewport, so anything past the edges is hidden rather than overflowing. The wheel scrolls it with no extra wiring — the scroll position lives on the viewport and persists across re-renders. Give it a single content node (wrap several in a col / row).

It is wheel-only by default — no visible bar. Pass scrollbar = true with thumb/track colours to paint a draggable bar along the trailing edge (it shows only when the content overflows). Most callers reach for the themed scrollArea widget instead, which wires these from the active theme.

Pass both = true to scroll on both axes at once. ref reaches the viewport’s RenderScroll to drive the position from code, and onScroll reports the offset whenever the view moves — however it moved. See scrollArea for both.

Views nest safely: a wheel this viewport cannot use — because it is already at that end, or its content fits — passes out to the scroll view around it rather than being swallowed. See Chaining.

scrollView(Axis.Vertical)(
  col(crossAxisAlignment = CrossAxisAlignment.Stretch, spacing = 12)(
    items.map(card)*,
  ),
)

row / col

def row(
    mainAxisAlignment:  MainAxisAlignment  = MainAxisAlignment.Start,
    crossAxisAlignment: CrossAxisAlignment = CrossAxisAlignment.Start,
    mainAxisSize:       MainAxisSize       = MainAxisSize.Max,
    spacing:            Double             = 0.0,
    flex:               Int                = 0,
)(children: VNode*): VNode

def col(/* same parameters */)(children: VNode*): VNode

A horizontal (row) or vertical (col) stack. Children are laid along the main axis; flexible children share the leftover space. The enums:

  • MainAxisAlignmentStart, End, Center, SpaceBetween, SpaceAround, SpaceEvenly
  • CrossAxisAlignmentStart, End, Center, Stretch
  • MainAxisSizeMin (wrap children), Max (fill parent)

See the layout guide for how the two passes distribute space.

spacer

def spacer(flex: Int = 1): VNode

A flexible empty gap — the replacement for flex-grow. Inside a row or column it eats leftover space in proportion to flex, pushing its siblings apart.

padding

def padding(insets: EdgeInsets)(children: VNode*): VNode

Insets its child by insets on each side.

sizedBox

def sizedBox(width: Double = Double.NaN, height: Double = Double.NaN)(children: VNode*): VNode

A fixed-size box with no appearance: forces width/height onto its child (or occupies that size with no child). Omit an axis to leave it to the parent.

constrainedBox

def constrainedBox(maxWidth: Double = Double.NaN, maxHeight: Double = Double.NaN)(children: VNode*): VNode

Caps its child to a maximum without forcing it — the child sizes to its content but never exceeds maxWidth/maxHeight (Flutter’s ConstrainedBox). Omit an axis to leave it uncapped. Use it to bound a block of wrapping text or a panel so it grows with its content up to a limit rather than sprawling to the full width. (sizedBox pins an exact size; constrainedBox only sets a ceiling.)

stack / align / center

def stack(alignment: Alignment = Alignment.topLeft)(children: VNode*): VNode
def align(alignment: Alignment)(children: VNode*): VNode
def center(children: VNode*): VNode

stack is a z-ordered overlay: children stack back-to-front, each positioned by alignment. align positions a single child at an alignment (a one-child stack); center is align(Alignment.center).

positioned

def positioned(dx: Double, dy: Double)(children: VNode*): VNode

Places its child at the absolute pixel offset (dx, dy) within the space it is given, laying the child out at its natural size (it may overflow). It fills that space, so dropped into a full-window overlay it positions content at a screen point — which is how the anchored overlays (Menu, Tooltip) sit beside their trigger.

Geometry & colour values

The DSL takes these plain value types (all pure, no SDL dependency):

Offset(x, y)                         // a 2-D position or displacement
Size(width, height)
Rect(x, y, width, height)            // .contains(px, py), right/bottom-exclusive
EdgeInsets(top, right, bottom, left) // .all(v), .symmetric(horizontal, vertical)
Alignment(x, y)                      // fractional: (-1,-1) topLeft … (1,1) bottomRight
Color(r, g, b, a = 255)              // .rgb(0xRRGGBB), .parse("#rrggbb[aa]"), .toHex, .withAlpha

Color provides black, white, transparent, Color.rgb(hex) for packed literals, and Color.lerp(a, b, t) to blend two colours (what the motion hooks animate over).

The styling value types box paints with:

Solid(color)                                   // a flat fill
LinearGradient(stops, begin, end)              // stops: Seq[ColorStop]; begin/end: Alignment
RadialGradient(stops, center, radius)
ColorStop(offset, color)                       // offset 0..1 along the gradient
BorderRadius(tl, tr, br, bl)                   // .all(v), .top(v), .bottom(v), .zero
Shadow(color, offset, blur, spread = 0)        // a drop shadow

A bare Color converts to Solid automatically wherever a Paint is expected.

Search

Esc
to navigate to open Esc to close