Skip to main content

Async atoms

A terminal application has one drawing thread and no way to block it. AsyncAtom<T> is the shape work that happens elsewhere takes: it wraps a load in progress, lands the result on the frame loop through FrameThread, and exposes the status as an atom so a view can draw a spinner without knowing anything about tasks.

private readonly AsyncAtom<IReadOnlyList<Mod>> _mods = new();
private readonly ModsService _service;

_mods.Load(async token => await _service.LoadAsync(token));
MemberMeaning
ValueLast loaded value; default until one arrives
StatusIdle, Loading, Loaded, Failed — an IReadableAtom<LoadStatus>
IsLoadingShorthand for the status being Loading
ErrorThe exception of the last failure, as a readable atom
Load(load)Starts a load, canceling the one in flight
Cancel()Cancels without starting another
Subscribe(listener)Notified when the value changes
SubscribeToStatus(listener)Notified when the status changes

A failed load is kept as Failed + Error rather than thrown at the render loop. Canceling keeps the last value but drops the status back to Idle, so a spinner bound to it stops.

Drawing one

private readonly Surface _surface;
private readonly Spinner _spinner = new();
private readonly Table<Mod> _table;

public void Draw()
{
if (_mods.IsLoading)
{
_surface.AppendLine($"{_spinner.Frame()} loading…", Theme.Secondary, Align.Center);
return;
}

if (_mods.Status.Value == LoadStatus.Failed)
{
_surface.AppendLine(_mods.Error.Value?.Message ?? "", Theme.Error, Align.Center);
return;
}

_table.Rows = _mods.Value ?? [];
_table.Draw(_surface.Content);
}

The Spinner advances on a Ticker, which is what makes it move while nothing else is happening.

Tying work to the screen

Work outlives the screen that started it unless something stops it. ViewLifetime is that something: it is scoped, so each screen gets its own, and navigating away cancels it.

public sealed class ModsView : IArlecchinoView
{
private readonly AsyncAtom<IReadOnlyList<Mod>> _mods;

public ModsView(ViewLifetime lifetime, ModService service)
{
_mods = lifetime.Loading<IReadOnlyList<Mod>>();
lifetime.Track(_mods.Subscribe(Redraw));

_mods.Load(token => service.LoadAsync(token));
}
}
MemberDoes
Loading<T>(initial)An AsyncAtom<T> that is canceled when the screen goes away
Track(resource)Disposes a subscription, timer or handle with the screen; returns it back
OnClose(action)Runs something as the screen goes
ClosingThe token to pass into work you start yourself; readable after the screen has gone

The view no longer needs IDisposable for any of this. What it does still need it for is anything it wants to do before its scope is released — the view is disposed first, then the scope.

Releasing happens once, over a snapshot of what was registered, so a resource whose own Dispose reaches back into the lifetime does not break the screen it is closing. Anything handed to Track after that point is disposed immediately rather than held by a screen that is already gone.

Reloading

Load cancels whatever is in flight, so a reload command is one line and pressing it twice does not leave two loads racing to write the same atom:

ViewCommand.For(ConsoleKey.R, () => "reload", () => _mods.Load(token => _service.LoadAsync(token)))

Doing it by hand

AsyncAtom is a convenience over one rule, not a requirement. Work that does not fit it posts its own result:

private IReadOnlyList<Mod> _rows = [];

Task.Run(async () =>
{
var loaded = await _service.LoadAsync(lifetime.Closing);
FrameThread.Post(() => _rows = loaded);
});

The rule is the same either way: nothing touches a view, a widget or an atom except the drawing thread, and Post is the door.

A worked example

samples/Arlecchino.Processes is this page as a running program — it reads the process list on a background thread through an AsyncAtom, shows a spinner while it loads, sorts and filters what came back, and reloads on r. See Showcase.