Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

layout primitives

Stack / Row / Box — flexbox layout with gap, align, justify, stickToBottom

The built-in layout primitives and how they arrange children. `Stack` stacks children in a column, `Row` lays them in a row, and `Box` is a plain container; `gap`, `align`, and `justify` control spacing and alignment.

stable4 examples compiled by CIuilayout

Summary#

Osy# ships a small set of layout primitives you compose your UI from:

PrimitiveArranges its children
Stackin a column (top to bottom)
Rowin a row (left to right)
Boxa single container with no intrinsic direction
[Composable]
component ProductCard(string name, string price) {
  action AddToCart() { }
  render {
    Stack(gap: 2) {
      Text(name);
      Row(justify: Justify.Between) {
        Text(price);
        Button("Add to cart", onPress: AddToCart);
      }
    }
  }
}

Signature#

Stack / Row / Box — flexbox layout with gap, align, justify

Stack(stickToBottom: true)       { … }    // follow new content, but never fight the reader
Stack(stickToBottom: following)  { … }    // …and tell me when they scroll away

Description#

Spacing — gap#

gap sets the space between a layout's children, as a step on the spacing scale (a whole number). Larger numbers mean more space; gap: 0 (the default) means no gap.

Stack(gap: 4) { … }   // more space between rows
Row(gap: 1) { … }     // a little space between columns

Alignment — align and justify#

align and justify are built in — they need no using, and no UI kit. The platform maps them straight to flexbox, so a typo (align: Align.Centre) is a compile error rather than a silent no-op, and no kit can change what Align.Center means.

Write the value qualified — Align.Center, never a bare Center. In an argument slot a bare capitalised name could be a theme token, an enum member or a style keyword, and all three are spelled alike; the group name is what says which vocabulary you meant. align's group is Align and justify's is Justify, so the value always reads as group.member. A bare name there is refused, and the refusal names the spelling to write.

align positions children on the cross axis, justify distributes them along the main axis (the axis the primitive lays out on — vertical for Stack, horizontal for Row).

aligneffect
Align.Startpack to the start
Align.Centercenter
Align.Endpack to the end
Align.Stretchstretch to fill
Align.Baselinealign text baselines
justifyeffect
Justify.Start / Justify.Center / Justify.Endpack to the start / center / end
Justify.Betweenequal space between children
Justify.Aroundequal space around each child
Justify.Evenlyequal space between and at the edges
Row(align: Align.Center, justify: Justify.Between) {
  Text("Title");
  Button("Action", onPress: Act);
}

These names are fixed (they map to the browser's flexbox model), so a typo like align: Align.Centre is a compile error, not a silent no-op.

The value can come from anywhere#

Align and Justify are ordinary types, so anything of that type is a legal value — a local, a component parameter, a field, a method's result, a switch, an element of a list. A component that lets its caller decide how a row is arranged declares the parameter and passes it through, exactly as it would in C#:

[Composable]
component Toolbar(Justify arrange, string label) {
  render {
    Row(justify: arrange, gap: 2) { Text(label); }
  }
}

[Composable]
component Bar(bool compact) {
  Align lane = Align.Center;
  render {
    Stack(align: lane, gap: 2) {
      Toolbar(arrange: compact ? Justify.Center : Justify.Between, label: "Tools");
    }
  }
}

A value the compiler can see — a member, or a conditional over members — is resolved where it is written and costs the page nothing. A value it can only know the type of is looked up when the page renders, and the vocabulary travels with that page so nothing else pays for it.

The type is checked either way: justify: takes a Justify, so an Align, a number or a string is a compile error naming the whole set. Justify? is how a component makes the argument optional — a null one arranges nothing, the same as leaving it out.

The one value that is refused is a generic component's type parameter — component Passer<T>(T v) used as justify: v. A generic body is compiled once for every call site, so which vocabulary T stands for is not known in it and a caller could bind it to anything. Declare the parameter as Justify (as Toolbar above does) and pass it through; the compiler says exactly that if you hit it.

Following new content — stickToBottom#

A surface that grows while someone is reading it — a chat transcript, a log, a build console — should show the newest content. But it must not yank a reader who has deliberately scrolled up to re-read something earlier. That is the rule everybody gets wrong, and it is one word here:

component Transcript(string[] Lines) {
  render {
    Stack(overflowY: Overflow.Auto, gap: 2, stickToBottom: true) {
      foreach (var line in Lines) { Text(line); }
    }
  }
}

It applies to a scrolling container — one with overflowY: Overflow.Auto. New content scrolls into view while the reader is at the bottom; the moment they scroll up, following stops, and it resumes by itself when they scroll back down.

Knowing whether it is following, and jumping back

Give it a bool field instead of a literal and the field becomes the container's following state, in both directions. The container writes false into it when the reader scrolls away and true when they return — so your app can show a jump to latest affordance — and setting it back to true yourself scrolls to the bottom and resumes following:

component Chat(string[] Lines) {
  bool following = true;

  action Jump() { following = true; }

  render {
    Box {
      Stack(overflowY: Overflow.Auto, gap: 2, stickToBottom: following) {
        foreach (var line in Lines) { Text(line); }
      }
      if (!following) {
        Button("Jump to latest", onPress: Jump);
      }
    }
  }
}

The button is yours to draw and place — the platform ships none. Setting the field is the only way to scroll a container from Osy#, which is why the write half exists at all.

Note the difference between the two forms: stickToBottom: <a bool expression> is an on/off switch ("follow only while the Live tab is open"), while stickToBottom: <a field you can assign> is the following state. With a field, the behaviour stays on for as long as the container exists — a false means "not following right now", not "switched off" — which is what lets the reader resume simply by scrolling back down.

See also#

  • component — declaring a component and its render block.
  • [[ui-component#render-tree]] — the full render vocabulary (text, conditionals, loops, bindings).
  • Markdown — rendering markdown text — rendering a message's text inside a transcript, including while it is still arriving.

Related

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…

Markdown — rendering markdown text

`Markdown(text)` renders a markdown string as formatted content — headings, lists, tables, code, links. It is a…