Skip to main content

Layout

There is no layout engine and no component tree. A view draws where it says to draw, and the surface offers three ways of saying it: a flow cursor that walks down the screen, absolute calls that address a row directly, and regions that carve the frame into rectangles with their own coordinates. Most views use one; a view with panes uses regions and never counts a row itself.

A screen made of several panes can describe its shape as a tree instead of carving it by hand. That is still not a component tree: it decides where things go and nothing else — no lifetimes, no state, no re-render pass. Widgets stay widgets, and a view that would rather split regions itself keeps working exactly as before.

Flow layout

Flow calls advance an internal cursor line by line. They are the default way to write a view.

CallBehavior
AppendLine(text, style, align, margin)One line at the cursor, honoring Align.Left/Center/Right inside the content width and all four margins
WriteTableRow(cells, widths, style, prefix)A line of padded columns; a positive width right-aligns the cell, a negative one left-aligns it
FillLine()A rule across the content width
SkipLine()Leaves a blank line
ListWindow()How many rows a scrolling list may use: the free lines minus room for the chrome, at least four
_surface.AppendLine("Mods", Theme.Header, Align.Center, new Margin(0, 1, 0, 1));
_surface.WriteTableRow(["Name", "Version"], [-30, 10], Theme.TableHeader);
_surface.FillLine();

Every flow call stops silently once the frame is full, so a view never has to bound its own output. The content width is the frame minus HorizontalPadding on both sides, so a flow view sits inside the gutters the application configured.

These calls belong to the frame. Inside a pane they write at the top of the screen and paint over its border, so a pane filled line by line asks the region for a flow of its own.

Absolute layout

Absolute calls address rows directly and ignore the flow cursor — this is what the file picker and the modal boxes are drawn with.

CallBehavior
WriteAt(row, column, text, style)Writes at an exact cell, clipping to the frame
WriteLineAt(row, text, style)Restyles the whole row, then writes the text at HorizontalPadding
FillLineAt(row, style)A rule on that row

WriteBlock(lines, style, align, margin) sits in between: it takes a block of pre-built lines and places it as a unit, aligned horizontally (Left/Center/Right) and vertically (Top/Middle/Bottom) against the whole frame.

Align and Margin

Align is a [Flags] enum, so the two axes combine. Align.Right | Align.Bottom is how the hints box is anchored to a corner. Only the block and region calls honor the vertical flags; a flow line has already decided which row it is on.

Margin is (Left, Top, Right, Bottom). On a flow call the top and bottom margins are blank lines around the text; on Inset they are the space taken off each side.

new Margin(2, 1, 3, 2) // 2 left, 1 top, 3 right, 2 bottom
new Margin(1) // the same on every side

Regions

Absolute coordinates get unwieldy the moment a view has panes. A SurfaceRegion is a rectangle on the surface with its own coordinate system and its own clipping — writing outside it is dropped, not spilled onto a neighbor:

var frame = _surface.Frame.Inset(new Margin(2, 1, 3, 2));
var (toolbar, rest) = frame.SplitTop(2);
var (browser, status) = rest.SplitTop(rest.Height - 2);
var (sidebar, list) = browser.Border(Theme.Secondary).SplitLeft(22);

sidebar.Write(0, 0, "Favorites", Theme.Secondary);
list.WriteLine(0, "Name", Theme.TableHeader);
MemberMeaning
Surface.Frame / Surface.ContentThe whole frame, and the frame minus the configured padding
Left / Top / Right / BottomThe edges in frame coordinates; Right and Bottom are one past the edge
Width / Height / IsEmptyThe size, and whether there is any room to draw at all
Inset(margin) / Inset(all)A smaller region inside this one
SplitLeft(width) / SplitTop(height)Two regions; the split is clamped to what the region actually has
Rows(row, count)A horizontal band of the region, clamped to its bounds
Write(row, column, text, style)Writes in region coordinates, clipped to it — a negative column starts the text off the left edge and shows what fits
WriteLine(row, text, style, align)A whole line, aligned inside the region
Fill(style, character)Paints every cell of the region
Border(style, title)Draws a box and returns the region inside it
Flow()A cursor that writes line after line inside this region — see below
Contains(frameRow, frameColumn) / ToLocal(...)Hit-testing for mouse events

SurfaceRegion is a readonly record struct, so region with { Top = region.Top - offset } is a valid way to shift one, and two regions compare by value.

Both the modal boxes and the file picker are drawn this way, so the same code that positions a pane also answers "was this click inside it".

Panes as a tree

Regions solve placement, but a screen with four panes spreads its shape across the whole of Draw: half a dozen SplitTop and SplitLeft calls interleaved with the drawing, and changing the proportions means finding every one of them. PaneTree states the shape once, in one expression, and draws it in one call.

using static Arlecchino.Layout.PaneSplit;
using static Arlecchino.Layout.PaneTree;

public sealed class PanesView : IArlecchinoView
{
private readonly Surface _surface;
private readonly PaneTree _layout;
private readonly FocusRing _focus;

public PanesView(Surface surface, ArlecchinoOptions options)
{
_surface = surface;

var files = new ListBox<string>(options.Keymap)
{
Render = file => $" {file}",
Items = Files(),
};
var editor = new TextView(options.Keymap) { Text = Readme() };
var log = new ListBox<string>(options.Keymap) { Render = line => line, Items = Log() };
var status = new StatusBar { Left = [() => "ready"], Right = [() => "Esc back"] };

_layout = Branch(
Rows,
3,
Leaf(DrawToolbar, () => "toolbar"),
Branch(
Rows,
PaneSize.CellsFromEnd(2),
Branch(
Columns,
0.25,
Leaf(files, () => "files"),
Branch(0.7, Leaf(editor, () => "editor"), Leaf(log, () => "log"))),
Leaf(status))).Gaps(inner: 1, outer: 1);

_focus = _layout.AsFocusRing(options.Keymap);
}

public void Draw() => _layout.Draw(_surface.Content);

public ViewRoute Handle(KeyPress key) => _focus.Handle(key);
}

Two members build the whole thing — Branch and Leaf — and a using static of PaneTree and PaneSplit is what lets them read without a prefix on every line.

╭─ toolbar ──────────────────────────────────────────────────╮
│ │
╰────────────────────────────────────────────────────────────╯

Program.cs ╭─ editor ─────────────────────────────────╮
PanesView.cs │ │
WidgetsView.cs │ │
SettingsView.cs ╰──────────────────────────────────────────╯
╭─ log ────────────────────────────────────╮
│ │
╰──────────────────────────────────────────╯
ready Esc back

How to read one

Every node is either a branch or a leaf. A branch has exactly two halves; the size it carries says how much the first of them takes, and the second takes what is left. Three bands stacked is therefore a branch inside a branch, which is what the nesting above is: the toolbar, then everything else, and inside that everything-else the body and the status row.

Rows cuts top from bottom, Columns cuts left from right, and the size always applies to the first half — Branch(Rows, 3, header, body) gives the header three rows, Branch(Columns, 0.25, side, main) gives the sidebar a quarter of the width.

Only the two halves are ever required. Say the direction, or the size, or both, or neither:

CallMeans
Branch(Rows, 3, a, b)Cut into rows, three of them for a
Branch(Rows, a, b)Cut into rows, half each
Branch(0.25, a, b)A quarter for a, cut along whichever side is longer
Branch(a, b)Half each, along whichever side is longer

"The longer side" is measured in what the eye sees rather than in cells: a terminal cell is about twice as tall as it is wide, so an 80×24 region is a wide one and gets two columns, while 40×24 gets two rows. It is worked out per frame, so a branch left to decide can turn from columns into rows when the window is resized — which is what you want for panes of equal standing, and not what you want for chrome. Pin a toolbar with Rows and a sidebar with Columns; leave the rest to the tree.

Nothing about a frame is kept in the tree. Sizes are worked out on every Draw, so one tree fits every terminal and a resize needs no bookkeeping.

Sizes

PaneSize is three measures, and a layout survives a resize by picking the right one per split:

SizeMeansFor
0.25 — any doubleA share of what there isPanes that grow with the window: a sidebar, two halves of an editor
3 — any intExactly that many cellsChrome of a fixed height: a toolbar, a title, a one-line prompt
PaneSize.CellsFromEnd(2)Everything except that many cellsChrome anchored to the far edge: a status bar at the bottom, a gutter on the right

The unit is the literal, not the number. double and int both convert on their own, so what a size means is decided by whether it has a decimal point:

Branch(Rows, 3, header, body); // three rows
Branch(Rows, 0.3, header, body); // three tenths of the height
Branch(Columns, 3, side, main); // three columns — a count follows the direction of the cut

A count is in the units of the cut: rows for Rows, columns for Columns. PaneSize.Fraction(0.25) and PaneSize.Cells(3) are the same two things spelled out, for where the literal is not obvious enough.

Two edges are worth knowing. 1 and 1.0 are different sizes — one row against all of them — so a missing point is a real bug rather than a rounding difference. And a bare 0 does not compile: it fits a PaneSplit and a size equally well, and the compiler says so instead of guessing, so write PaneSize.Fraction(0) or PaneSize.Cells(0) when nothing is what you mean.

A share is clamped to 0..1, and a count larger than the region gives the first half everything and the second half nothing.

CellsFromEnd is what a status bar wants. Written as a share, a one-row bar is 0.96 on one terminal and wrong on the next; written as Rows(PaneSize.CellsFromEnd(1), body, status) it is the last row on all of them.

What goes in a pane

LeafUse
Leaf(widget)Any widget — a list, a table, a tree, a status bar
Leaf(widget, () => "files")The same, in a box with that title
Leaf(region => ...)Drawing the view does itself: a title, a box, a row of readouts
Leaf(region => ..., () => "log")The same, in a box
Leaf()Space deliberately left blank

A title is a Func<string> rather than a string, like every other piece of user-visible text in the framework, so a translated application translates the panes too. The box is drawn for you and the pane is handed the room left inside it — which is the whole of what a region.Border(...) call in every pane used to do.

A boxed widget also shows where the focus is: the border is Theme.Active while that widget holds it and Theme.Info while it does not, so the view says nothing about focus and the screen still shows it.

The same widget instance cannot be two panes. A widget remembers the region it was drawn into — that is how it answers clicks — so one in two places would draw twice and hit-test for one of them only. The tree rejects it as it is built rather than letting the screen misbehave.

A widget pane calls the widget's own Draw with the region and ignores the region it hands back, since the tree has already decided where everything goes. Both leaf kinds are checked for null as the tree is built, so a mistake surfaces at construction rather than on the first frame.

Because the tree holds what it draws, it is built where the widgets are — in the view's constructor — and lives as long as the view does. It is not a static readonly shared between views: two views sharing one tree would share its widgets, and therefore their state.

Tab walks the panes

A screen of panes wants Tab to move between them in the order they are drawn, and the tree already knows that order. AsFocusRing builds the focus ring out of the layout — every pane of it that takes the focus, left before right and top before bottom — so there is no second list to keep in step by hand:

_focus = _layout.AsFocusRing(options.Keymap);

public ViewRoute Handle(KeyPress key) => _focus.Handle(key);
public ViewRoute HandleMouse(MouseEvent mouse) => _layout.HandleMouse(mouse);

Widgets that cannot take the focus — a status bar, a pane the view draws with a delegate — are simply left out. Rearranging the tree rearranges the tab order with it, which is the point: the two cannot drift apart, because there is only one of them.

What comes back is an ordinary ring, so anything focusable that lives outside the tree is added to it afterward and lands at the end of the walk.

HandleMouse on the tree is the other half. The tree already worked out which pane owns which cells in order to draw them, and the same knowledge says where a click goes: it reaches the pane it landed in rather than being offered to every widget on the screen in turn, and no widget is asked to guess whether the point was its own. The pane that claims it takes the focus with it, for a tree whose ring came from AsFocusRing. A click in the gap between panes, in the space around them, or before the first frame was drawn belongs to no pane and is left alone.

Writing line after line inside a pane

Flow calls belong to the frame, not to a region. Reaching for region.Surface.AppendLine(...) inside a pane therefore writes at the top of the screen and paints straight over the pane's border and its neighbors — the region is not involved at all:

PLAYERS ╮╭ right ───────────╮ ← the flow cursor is the frame's
│ ││right │

A region has a flow of its own for exactly this, and it stays where it was given:

var flow = region.Flow();

flow.AppendLine("PLAYERS", Theme.TableHeader);
flow.FillLine();

foreach (var player in players)
{
flow.AppendLine(player.Name, Theme.Default);
}

Everything is written in the region's coordinates and clipped to it, and once the pane is full the calls stop doing anything — a loop over more rows than fit needs no bound of its own.

MemberMeaning
AppendLine(text, style, align)The next line, aligned inside the region
SkipLine() / Skip(rows)Leaves rows blank
FillLine(style)A rule across the region
Rewind()Back to the first row
Rest()What the cursor has not reached yet, as a region — for handing the space below to a widget
Row, FreeLines, IsFull, RegionWhere the cursor is and how much room is left

PaneFlow is a class, so passing it to a helper that writes a few more lines carries the cursor along. Two flows over the same region are independent: the second starts at its first row again.

Gaps, and panes that do not fit

Spacing belongs to the tree rather than to a call or to a branch, so a screen is loosened or tightened in one place. Gaps(inner, outer) is named the way a tiling window manager names it:

_layout = Branch(...).Gaps(inner: 1, outer: 1);

inner is left empty between the two halves of every branch; outer is left empty around everything, inside the region Draw is handed. Both default to nothing, which packs panes edge to edge — what a screen of bordered boxes wants, since the borders already separate them. Gaps returns the tree it was called on, so it finishes the expression that built it.

With no inner gap, panes in a box share the line between them rather than each drawing one of their own — the tree records its boxes in a Joinery and paints them together:

├─ files ────────────┬─ authors ─────────────┬─ log ────────────┤
│ Program.cs │ fEst │ the rest of it │
╰────────────────────┴───────────────────────┴──────────────────╯

A pane without a box keeps the room it was given — it would lose a column of what it draws to a neighbor's border — and a tree with a gap is drawn as it always was. The pane holding the focus wins the color of the edges it shares, so Tab still moves a highlight around the screen.

A region too small for what it holds does not overflow. Each split is clamped to the space that exists, so the first half takes what it can and the panes that did not fit are handed empty regions; drawing into one of those writes nothing, exactly as writing outside a region does. A view needs no if (Height > 10) guards — a terminal too small for the screen is the application's business through MinimumWidth/MinimumHeight, not the layout's.

One row is worth remembering: with ShowOutputLine on, the framework draws the output line over the last row of the frame. A status bar of your own belongs one row above it — CellsFromEnd(2) rather than CellsFromEnd(1) — or it is drawn and then covered.

Members

MemberMeaning
Branch(split, size, first, second)A branch; either of split and size may be left out
Leaf(widget) / Leaf(draw) / Leaf()A pane holding a widget, one the view draws, or nothing
Leaf(widget, title) / Leaf(draw, title)The same, in a box with a title
Gaps(inner, outer)Spacing for the whole tree; returns the tree
Draw(region)Draws every pane where the branches put it
AsFocusRing(keymap)The focus ring of the screen, panes in layout order
Count, InnerGap, OuterGapHow many panes it holds, and the spacing it was given

When not to reach for it

A tree earns its keep from about three panes up. A view that draws a list under a title is shorter with flow calls, and two panes side by side are clearer as one SplitLeft. The tree is for screens whose shape is worth naming — and where changing 0.25 to 0.3 should be a one-character edit rather than a hunt through Draw.

Borders that join

region.Border(...) draws a box that knows nothing about its neighbors. That is right for a box standing on its own and wrong for panes that touch: two of them side by side put two verticals where the eye expects one.

Joinery records boxes and rules instead of drawing them, and paints at the end — so a shared cell becomes the glyph that joins them:

var joinery = new Joinery();

var files = joinery.Box(left, Theme.Info, "files");
var log = joinery.Box(right, Theme.Active, "log");

joinery.Draw(surface.Content, Theme.Info);
╭─ one ───────────────┬─ three ──────────────╮
│ │ │
├─ two ───────────────┼─ four ───────────────┤
│ │ │
╰─────────────────────┴──────────────────────╯
MemberMeaning
Box(region, style, title)Records four edges and hands back the room inside, as Border does
Across(region, row)A rule across the region, joining whatever it meets
Down(region, column)A rule down it
Draw(into, style)Paints everything, then the titles. style covers what was recorded without one
CountHow many cells carry a line so far

Coordinates are the surface's own, so regions from anywhere on the frame are recorded together, and anything falling outside into is left undrawn rather than clamped into it. A cell takes the style of the last thing recorded over it, which is how the pane holding the focus wins the edges it shares — record it last.

Clipping a whole stretch of drawing

A region clips writes to its own bounds, which is enough while the coordinates belong to it. Scrolling breaks that: the content is drawn shifted, so it reaches outside the window on purpose and must not land on a neighbor. Surface.Clip confines every write to a rectangle until the scope is disposed, whatever coordinates the writing code uses:

using (region.Surface.Clip(region))
{
Content(region with { Top = region.Top - offset, Height = contentHeight });
}

Scopes nest and the inner one is the intersection, so a clipped pane inside a clipped pane stays inside both. ScrollPane is built on this, and it is what to reach for when writing a widget that scrolls something of its own.

Choosing between them

Shape of the screenWhat to reach for
A list, a form, a page of textFlow calls
A box anchored to a cornerWriteBlock with the alignment flags
Two panes, a bordered dialogRegions
Three panes or more, chrome around a bodyA PaneTree built in the constructor
Content longer than its paneA region plus ScrollPane
Anything that has to answer a clickRegions — Contains is the hit test