MarkdownViewer can render markdown as it arrives, for text that grows at its end such as the
answer of a language model. A streaming session takes the text in pieces and re-renders only the
part of the document the new text can have changed, so the rest keeps its controls, its selection
and its scroll position.
Clear the viewer, then call BeginStreaming(). It returns a MarkdownStreamingSession, which you
append text to and complete when the text ends:
using MarkView.Avalonia;
async Task ShowAnswerAsync(MarkdownViewer viewer, IAsyncEnumerable<string> chunks)
{
viewer.Markdown = "";
using var session = viewer.BeginStreaming();
await foreach (var chunk in chunks)
session.Append(chunk);
} // Dispose completes the session
BeginStreaming appends to the current Markdown. Set it to an empty string (or null) first to
stream a new document; leave it as it is to continue one. Call it on the UI thread. It throws
InvalidOperationException when a session is already active on the viewer, or when Source is
set: streaming appends to Markdown, not to a file or a URL. To stream the text of a document
that has a location, set BaseUri to its folder before BeginStreaming(), so its relative links
and images resolve as they would with Source (the demo's replay does this).
Use one viewer per answer when you can, for example one per chat message. A viewer has one session at a time, and a new one starts from the viewer's current text.
IsStreaming is true from BeginStreaming() until the session completes. It is a read-only,
bindable direct property, so a UI can show a "typing" indicator while it is true.
session.Append("# Title\n\nSome ");
session.Append("text".AsSpan());
Append accepts a string or a ReadOnlySpan<char>. Appending an empty text does nothing, and a
null string throws ArgumentNullException. It throws InvalidOperationException once the
session is completed.
Append can be called from any thread. It only adds the text to a buffer, so it is cheap to call
for every token. The viewer renders the buffer at most once per update interval, on the UI
thread: all the text appended since the previous update is rendered together. A session with no
new text renders nothing and stops its timer until the next Append.
The interval is MarkdownViewer.DefaultStreamingUpdateInterval, 32 ms, unless you pass another one:
// At most ten updates per second
var session = viewer.BeginStreaming(TimeSpan.FromMilliseconds(100));
// Append from a background thread, and complete the session when the text ends
_ = Task.Run(async () =>
{
await foreach (var token in tokens)
session.Append(token);
await session.CompleteAsync();
});
Do not put the session in a using block when another thread appends to it: leaving the block
completes the session, and the next Append throws. Complete it where the text ends instead, or
await the task inside the block.
A longer interval renders less often and costs less CPU; a shorter one makes the text appear more
smoothly. The interval must be positive, or BeginStreaming throws ArgumentOutOfRangeException.
To react to each render, handle session.Updated. It is raised on the UI thread after each update,
the final one included.
Call Complete() when the text has ended. It applies a final update with everything appended so
far and ends the session. Called on the UI thread, the final update is applied before Complete
returns; called from another thread, it is posted to the UI thread. Calling it again does nothing.
session.Complete();
// Or wait until the final update is applied
await session.CompleteAsync();
CompleteAsync() returns a task that completes once the final update is applied. It never throws:
when an update throws, the task is faulted with that exception instead.
Dispose() does the same as Complete(), so a using block completes the session even when the
code that produces the text throws. If the final update itself throws, that exception comes out of
Dispose(); call Complete() or CompleteAsync() where you want to observe it. IsCompleted is
true as soon as the session accepts no more text.
When you complete a session from a background thread, await CompleteAsync() before calling
BeginStreaming() again: Complete() only posts the final update, and the session stays active
until it lands, so a second BeginStreaming() before then throws.
A session also ends, silently and without a final update, when the viewer renders something else:
setting Markdown, Source, Pipeline or a render option such as BaseUri or
ShowCodeBlockLineNumbers, or calling UseFenceRenderer. IsCompleted becomes true,
IsStreaming becomes false, and a later Append throws. Setting Markdown yourself is how you
drop an answer that is no longer wanted.
Adding to Extensions does not end a session, and does not reach it either: the session's
renderer is created by its first update. Add extensions before BeginStreaming().
If a renderer or an Updated handler throws during an update, the session ends too. The exception
is thrown from the call that applied the update, and CompleteAsync()'s task is faulted with it.
An update applied by the timer, or posted from another thread, reaches the UI thread's unhandled
exception handling instead.
Markdown and bindings. After each update, Markdown holds the text received so far. It is
set without replacing a binding, so a binding on Markdown stays in place. The viewer does not
render the text a second time.Markdown.AutoScrollToEnd. Set it to true to keep the end of the document in view as text arrives.
Scrolling up pauses following, so the user can read earlier text while the answer goes on;
scrolling back to the bottom resumes it. A scroll inside a scroll viewer nested in the content,
such as a height-limited code block, does not pause following the end. Each new session starts
following again. It has no effect outside a session, and it defaults to false.SelectionChanged. See
text selection.AreTaskListsInteractive is on.MarkdownViewerAutomationPeer gets none.viewer.AutoScrollToEnd = true;
Text that stops in the middle of a construct parses badly: `code shows a backtick and the
words after it as plain text until the second backtick arrives, and **bold shows two asterisks.
Between updates the viewer closes what is left open at the end of the text, so the text renders as
it will once complete.
Healing is a step of each update, before the parse is rendered:
Healing only ever adds characters, and only to what is parsed. Markdown always holds the text as
it was received, and the final update, the one Complete() applies, renders the text as it is,
without healing. Only a paragraph or an ATX heading (# Title) at the end of the text is healed:
the text of other blocks, a code block or an HTML block for instance, is left as it is, and so is
everything before the last block.
What is healed:
`code becomes `code`. A run is left as it is when the text ends with a
backtick (a run with nothing after it, for instance), as the closer would merge with it into a
longer run that closes nothing.[docs](https://example.com/a(b or  is
left as it is, since closers would turn it into a thematic break or a fence. Nested emphasis is closed innermost first. Only a run of exactly two ~
is strikethrough; other runs of ~, and ^, + and =, are left as they are.UseMath() does, unless
you pass bracketDelimiters: false), \( and \[ that are not closed get \) and \], the
rest of the text being the formula. Without the bracket delimiters they are plain escapes and
stay as they are. A \( with nothing after it is not closed.What is never healed:
[text or [text]: it may become a link, a reference
link, a footnote or a task list item, and the viewer does not guess.<span class="a or <https://exa. What
comes after it is not healed either, since it may be part of the tag.[text][ref], [^note]).$, with or without the math extension: an unmatched $ stays as it is, since $5 is a price.
With the math extension on, $…$ and $$…$$ pairs are not scanned for the syntax inside them.Healing is on by default. Pass healIncompleteSyntax: false to render the text as it is:
using var session = viewer.BeginStreaming(healIncompleteSyntax: false);
The healer recognises Markdig's math extension and the Math package's bracket delimiters from the
viewer's pipeline when the session starts. Set up the pipeline and the extensions before
BeginStreaming().
A link or an image can still change when the text ends inside it: an address that has not fully arrived points somewhere else, or nowhere. During an update, the viewer renders the links and images of the block that contains the end of the text as provisional:
mermaid code block, and
nothing is rendered until the block is no longer provisional, which is once a blank line or
another block follows it. The diagram appears then.$$ or with \[.
Inline formulas ($…$, \(…\)) are only parsed once complete, so they render for real;
healing closes a \( that is still open (see Healing).Provisional applies to every [text](address) link and image of that block, bare URLs turned into
links by the pipeline included, even the ones that were already complete in it. An angle-bracket
autolink (<https://…>) or a footnote reference keeps its address, since it is only parsed once
complete. A block that was rendered as provisional is never kept as it is, so the next
update renders it again. Once the text has moved past it, with a blank line or another block, that
update renders it for real, with the addresses, the images, the diagrams and the formulas. The
final update, from Complete(), renders nothing as provisional.
The demo's View options menu has a Stream the document command that replays the current page, with a toggle for healing and sliders for the chunk size and the interval.
An update does not patch the previous rendering with a diff of the text. It parses the whole text again and renders from the first block that can have changed.
A block is kept when it has the same type and the same source span as when it was rendered. Some blocks stay open until they end:
The cost per update is small. These figures come from measurements of a prototype, and depend on the document and the machine, so read them as orders of magnitude:
Without a session, nothing changes: setting Markdown renders the document as before.
A custom renderer runs once for each block it renders, including the open
tail on every update. Two members of AvaloniaRenderer tell it that the text may end in the middle
of that block:
IsStreaming is true for every update of a session but the final one. It is false when the
document is not streamed.IsProvisional(MarkdownObject) tells whether a block or an inline contains the end of the text so
far: the block that contains it, and every block around it (a heading, or a paragraph and the
list item and list that hold it). An inline is judged by its leaf block. A block is no longer
provisional once a blank line or another block follows it. It always returns false when
IsStreaming is false, so a renderer can call it without checking.A fenced code block is provisional while its fence is open, and also just after the fence closes, until a blank line or another block follows. A heading is provisional until the same happens. A block that was rendered as provisional is rendered again on the next update.
The Mermaid and Math packages take exactly the path of the sketch below for their blocks: while
IsProvisional(obj) is true, a mermaid fence is written as the standard code block, and a
$$ or \[ block is written by the renderer that would otherwise accept the fenced block
(Mermaid's whenever the Mermaid package is registered, the core code block renderer otherwise), so
the source is shown. The diagram or the formula is made once the block is no longer provisional.
In this sketch MyChart stands for your own control. See
custom extensions for how a renderer is registered.
using Avalonia.Controls;
using Markdig.Syntax;
using MarkView.Avalonia.Rendering;
public class ChartBlockRenderer : AvaloniaObjectRenderer<FencedCodeBlock>
{
protected override void Write(AvaloniaRenderer renderer, FencedCodeBlock obj)
{
var source = obj.Lines.ToString();
// The fence may be open, or the answer may stop here: show the text, not a chart.
Control content = renderer.IsProvisional(obj)
? new TextBlock { Text = source }
: new MyChart(source);
renderer.WriteBlock(content);
}
}