Skip to main content

The frame loop

Screen owns the loop. It runs on a thread of its own called arlecchino-frames, and that thread is the only one allowed to touch a view, a widget, an atom or the surface. Everything on this page is about when that thread does something and how work from elsewhere reaches it.

One pass of the loop

The loop wakes at TargetFramesPerSecond (60 by default) and does four things:

  1. Drains input. Keys, mouse events and pastes read by the reader thread are routed here, on the drawing thread, before anything is drawn. See Keyboard.
  2. Runs the ticker. Anything scheduled with Ticker that is due runs now.
  3. Draws a frame, if one is owed. Repaint.TakeRequested() consumes the request; a change of terminal size counts as one too.
  4. Sleeps for whatever is left of the interval, on the cancellation token's wait handle, so shutdown does not wait out the tick.

An idle application draws nothing and writes nothing to the terminal.

What one frame is made of

DrawFrame composes the whole screen in this order:

  1. FrameThread.RunPending — work handed over from other threads.
  2. Surface.StartFrame() — reads the terminal size, reallocates the planes if it changed, clears every cell to a space styled Theme.Default, and skips VerticalPadding rows.
  3. If the terminal is smaller than MinimumWidth × MinimumHeight, a size notice is drawn and the frame ends here — the view is not asked to draw at all.
  4. The current view's Draw(), through the navigator.
  5. The output line, when ShowOutputLine is on.
  6. The hints box, when Hints asks for it and no modal is open, listing the keys of whatever holds the focus.
  7. The log overlay, while it is visible.
  8. Every open modal, innermost last, each offset three columns and one row from the one below it.
  9. Surface.Build() — the composed frame goes to the terminal as one write.

Nothing reaches the terminal until Build, so a half-drawn frame is never visible.

A view that throws does not take the process down

Draw is called inside a try. A view that throws is logged and reported on the output line through ArlecchinoStrings.ViewFailed, and the rest of the frame — output line, hints, modals — is still composed. A dead process is harder to recover from than a broken screen.

Frames are drawn on request

The loop does not repaint every tick; it repaints when something asks. Repaint.Request() is called for you:

Who asksWhen
the input routerafter every key, mouse event and paste
the navigatoron every route change
ArlecchinoStatewhen Output, a modal or the file picker is assigned
every atomon every write that actually changed the value — Repaint subscribes to AtomChanges.Written
Tickerafter anything scheduled has run
FrameThread.Postas the work is queued
the loop itselfwhen the terminal changed size

Anything else that changes what a view draws has to say so:

private readonly ArlecchinoState _state;

_state.Invalidate(); // or Repaint.Request() from the service itself

A view that animates can call it from its own Draw, which effectively opts back into drawing every tick.

Repaint starts out requested, so the first frame is always drawn.

Drawing everything again

Build writes only the cells that differ from the previous frame, jumping the cursor to each changed run. That breaks down when something outside the framework has written over the terminal — a process that was suspended and resumed, a child process that printed something. Two calls fix it:

CallEffect
Screen.RedrawEverything()Safe from any thread. Marks the next frame as a full send and asks for one.
Screen.DrawOnce()Draws one full frame right now, on the calling thread. This is what headless rendering uses.

Surface.ForgetPreviousFrame() is the surface-level version of the same thing.

Which thread draws

FrameThread turns "one thread touches this" from a convention into something the framework checks. The loop claims the thread as it starts:

private readonly Repaint _repaint;

using var drawing = FrameThread.Claim(_repaint.Request);

From then on, a member that changes what a frame draws calls FrameThread.Verify(nameof(Member)) first, and throws from anywhere else:

Atom.Value was called from thread 7, but frames are drawn on thread 4. Views, widgets and atoms are not thread-safe: hand the change over with FrameThread.Post, which runs it just before the next frame.

Nothing claims the thread outside a running application — a headless host, a test, a single DrawOnce — so the checks stay quiet there and cost one comparison.

MemberMeaning
FrameThread.IsCurrentWhether this is the drawing thread, or nothing is drawing at all
FrameThread.Claim(wake)Claims the calling thread; dispose the result to give it back
FrameThread.Post(action)Hands work over from any thread
FrameThread.Post(work)The same for asynchronous work, which resumes here after every wait
FrameThread.HasPendingWhether anything posted is still waiting
FrameThread.RunPending(onError)Runs what was posted; the loop calls this each frame
FrameThread.DiscardPending()Drops what was posted and never ran
FrameThread.Verify(member)Throws unless the caller is on the drawing thread

Coming back from a background task

Work that finishes on another thread hands its result back through Post:

public sealed class ModsView : IArlecchinoView
{
private readonly ModsService _mods;
private IReadOnlyList<Mod> _rows = [];

public void Reload() => Task.Run(async () =>
{
var loaded = await _mods.LoadAsync();
FrameThread.Post(() => _rows = loaded);
});
}

Post is safe from any thread, queues in order, and asks for a repaint by itself. An action that throws is logged and reported on the output line — the remaining actions still run.

Waiting without leaving the drawing thread

Post also takes work that waits. It starts on the drawing thread, and the loop holds a synchronization context, so every await inside comes back to the drawing thread rather than landing on a pool thread where the atoms would refuse it:

public void Reload() => FrameThread.Post(async () =>
{
_rows = await _mods.LoadAsync();
_status = LoadStatus.Loaded;
});

Nothing has to be handed back at the end, and nothing is lost if it fails: whatever the work throws, before a wait or after one, is reported the way a posted action's failure is. Being canceled is not a failure and passes in silence.

Work that has no business on the drawing thread still says so with ConfigureAwait(false), and then hands its result over with the plain Post above.

A frame runs what was waiting when it started, and no more. Work posted by that work belongs to the next frame, so an action that posts itself is a once-a-frame loop rather than a frame that never ends — which is the shape "carry on next frame" naturally takes.

When the work is one atom taking one value, the atom posts itself and there is no lambda to write:

private readonly Atom<ScanStep> _step;

public void Report(ScanStep value) => _step.Post(value);

Reach back for FrameThread.Post with a block when several things change together — two atoms, a list and its count — so that no frame falls between them. See Atoms.

For loading data this way without writing the plumbing, see Async atoms.

A collection that shrinks mid-frame

Widgets read their rows while drawing. If a background thread replaces a list halfway through a frame, the widget stops early and DrawFaults counts the cut-short rows; Screen logs a warning naming the route. It is a symptom of a change that skipped Post, not something to tune around.

Lending the terminal out

An editor, a pager or a shell cannot share a terminal with a full-screen application. Handover is a service in the container that stops being one for as long as the other program runs: the thread reading keys is parked, the modes are given back, the program is started with all three of its streams its own, and the next frame is drawn whole over whatever it left behind.

var code = _handover.Run(new ProcessStartInfo("vim") { ArgumentList = { path } });

Give(Action) does the same for work that is not a process. Both are called on the drawing thread and both block it, which is the point — nothing is drawn while somebody else has the screen — so neither belongs on a background thread. The terminal comes back however the work ended: a program that could not be started raises an exception into your command rather than leaving a terminal nobody can type in, and IsAway says whether somebody else has the screen at this moment.

Work on a clock

Ticker is a service in the container. Schedule an action and it runs between frames, on the drawing thread, with a repaint asked for afterward:

public sealed class ClockView : IArlecchinoView
{
private readonly Ticker _ticker;
private readonly ViewLifetime _lifetime;

private DateTime _now;

public ClockView(Ticker ticker, ViewLifetime lifetime)
{
_ticker = ticker;
_lifetime = lifetime;
_lifetime.Track(_ticker.Every(TimeSpan.FromSeconds(1), () => _now = DateTime.Now));
}
}
MemberMeaning
Every(interval, action)Repeats, waiting the interval between runs
After(delay, action)Runs once
NextDueWhen the next action is due, or null
Run(onError)Runs whatever is due; the loop calls it, a headless host calls it after moving its clock

Both schedules return the handle that cancels them. Hand it to ViewLifetime.Track and the work stops when the screen goes away.

Missed time is not made up for: an action runs at most once per pass, so a loop that was held up — a window restored from being minimized, a long operation, a debugger — resumes with a single run rather than firing everything it slept through.

Ticker takes its time from TimeProvider, which is why the test host can move the clock forward instead of sleeping.

Running the loop yourself

AddArlecchino registers a hosted service that calls Screen.Run. An application that would rather drive it — a single frame to stdout, a frame per input event — resolves Screen and calls DrawOnce, and claims the thread itself if it wants the checks to work. Hosting and options has the wiring.