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:
- 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.
- Runs the ticker. Anything scheduled with
Tickerthat is due runs now. - Draws a frame, if one is owed.
Repaint.TakeRequested()consumes the request; a change of terminal size counts as one too. - 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:
FrameThread.RunPending— work handed over from other threads.Surface.StartFrame()— reads the terminal size, reallocates the planes if it changed, clears every cell to a space styledTheme.Default, and skipsVerticalPaddingrows.- 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. - The current view's
Draw(), through the navigator. - The output line, when
ShowOutputLineis on. - The hints box, when
Hintsasks for it and no modal is open, listing the keys of whatever holds the focus. - The log overlay, while it is visible.
- Every open modal, innermost last, each offset three columns and one row from the one below it.
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.
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 asks | When |
|---|---|
| the input router | after every key, mouse event and paste |
| the navigator | on every route change |
ArlecchinoState | when Output, a modal or the file picker is assigned |
| every atom | on every write that actually changed the value — Repaint subscribes to AtomChanges.Written |
Ticker | after anything scheduled has run |
FrameThread.Post | as the work is queued |
| the loop itself | when 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:
| Call | Effect |
|---|---|
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.Valuewas called from thread 7, but frames are drawn on thread 4. Views, widgets and atoms are not thread-safe: hand the change over withFrameThread.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.
| Member | Meaning |
|---|---|
FrameThread.IsCurrent | Whether 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.HasPending | Whether 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.
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));
}
}
| Member | Meaning |
|---|---|
Every(interval, action) | Repeats, waiting the interval between runs |
After(delay, action) | Runs once |
NextDue | When 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.