Repository navigation
TextMate: tokenize the viewport in time-sliced runs - #629
Open
pbednarcik wants to merge 2 commits into
Open
pbednarcik wants to merge 2 commits into
pbednarcik wants to merge 2 commits into
Conversation
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
A rethrown exception in a later tokenization slice skips the pending redraw, leaving previously processed lines visually stale until another redraw is triggered.
1 open issue
What changed in this PR
This PR reduces UI-thread blocking during TextMate viewport tokenization by splitting work into short runs and deferring remaining slices below input and rendering priority.
Changes:
- Tokenizes only visible lines needing tokens, using runs of up to 8 lines with a 4 ms slice budget.
- Batches redraws and handles viewport replacement, document edits, disposal, and layout reentrancy.
- Upgrades TextMateSharp to 2.0.5 and adds headless regression tests.
| File | Description |
|---|---|
test/AvaloniaEdit.Tests/TextMate/TextEditorModelViewPortTokenizationTests.cs |
Tests slicing, scheduling, redraws, disposal, and exception handling. |
src/AvaloniaEdit.TextMate/TextMateColoringTransformer.cs |
Defers redraws for token events owned by the active viewport run. |
src/AvaloniaEdit.TextMate/TextEditorModel.cs |
Implements time-sliced tokenization and coordinated redraw handling. |
Directory.Packages.props |
Updates TextMateSharp and its grammars package to 2.0.5. |
Review effort: Balanced
pbednarcik
force-pushed
the
textmate-viewport-slices
branch
from
November 6, 2026 10:55
a536b4c to
3b4d8fb
Compare
Forcing every visible line at once made the frame after a far jump, and any input behind it, wait for a slow grammar. The pass now tokenizes only the visible lines that need tokens, in runs of up to 8 lines, continues at Background priority once 4 ms have passed, and redraws what it tokenized once instead of per run. Needs TextMateSharp 2.0.5.
pbednarcik
force-pushed
the
textmate-viewport-slices
branch
from
November 6, 2026 14:11
3b4d8fb to
ef1d03d
Compare
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
A stale token run can suppress redraw events after an overlapping viewport request, leaving visible lines uncolored because suppression does not check the request version.
1 open issue
1 resolved since last review
Review effort: Balanced
Comment on lines
+51
to
+53
| internal bool IsInViewPortRun(int firstLineIndex, int lastLineIndex) => | ||
| _runFirst >= 0 && _runChanges == _documentChanges && !Volatile.Read(ref _isDisposed) && | ||
| firstLineIndex >= _runFirst && lastLineIndex <= _runLast; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Builds on #623 (TextMateSharp 2.0.5); the new change is the second commit. It also changes
TokenizeViewPortCore, as#622 does, so whichever lands second needs a small rebase. #622's tests change with it: one that counts on the forced
first line goes, one detects the pass another way, and the harness lays out after pumping, because the pass now
redraws when it ends. Merged that way, all 588 tests pass.
Summary
On every scroll-offset change,
TokenizeViewPortCorecallsTMModel.ForceTokenizationfor all visible lines at once onthe UI thread. With a slow grammar, the frame after a far jump and any input behind it wait for the whole viewport: on a
210,000-line (9 MB) SQL file the frame took 95-113 ms at p95, and an input job queued with the jump waited 121-196 ms
at p95.
Change
The pass tokenizes only the visible lines that are invalid or have no tokens, in runs of up to 8 lines on the UI thread.
After a run, if 4 ms have passed, the rest continues in slices posted at
DispatcherPriority.Background, below inputand rendering. A newer viewport replaces the remaining range,
Disposestops the pass at the next run, and an exceptionthat escapes a run ends it. An edit up to the viewport's last line ends the pass, which starts again from the
viewport of the next layout: the view keeps its scroll offset, so the edit moves lines across the pass's line
numbers. An edit below the viewport leaves the pass running. The 4 ms is when the next run may start, not a limit on
how long one takes.
The pass redraws the lines it tokenized once: when it ends, when a newer viewport replaces it, or when the document
starts changing. A redraw per run rebuilt the viewport's visual lines between slices. The coloring transformer leaves
to that redraw only the token events a run raises on the UI thread for its own lines while the document is
unchanged and the model not disposed; events from TMModel's thread, and for any line outside the current run, redraw
as before. Disposing the model posts a redraw of the whole view, so lines the pass tokenized and had not yet redrawn
are rebuilt, also when a line transformer disposes it during layout. A line transformer that pumps the dispatcher
during layout runs posted slices and redraws inside it, where redrawing a line being built throws. A redraw the model
asks for while the view builds its lines, or before the model has seen a build end, redraws the whole view at the
start of the next build, which
VisualLineConstructionStartingannounces before any line is reused. That waitingredraw holds only the view, not the exception handler, so a disposed model and its document are not kept alive by it,
and a view that measures with no document drops it. It runs inside that build, so what it throws goes to the layout, as
a line transformer's exception does. The coloring transformer's own posted redraws are as in #623.
Skipping lines that look valid needs TextMateSharp 2.0.5: before it, TMModel could leave a line marked valid with tokens
of its old text or old start state (danipen/TextMateSharp#137 and danipen/TextMateSharp#139).
On the SQL file the far-jump frame p95 becomes 28.5-37 ms and the input wait p95 28.5-41 ms. The time until every visible
line has tokens is p95 95-109.5 ms on #623 and 92-119 ms with this PR. A scroll on a settled file now tokenizes nothing,
where the forced first line made TMModel walk to the end of the file (danipen/TextMateSharp#142 bounds that walk): a
3-line wheel stream at 60 Hz kept the UI thread busy 45-54% before and 5-11% after. Typing that scrolls the view
sideways rebuilds fewer lines: 300 keys on a generated 190,000-line C# file built 6,571-6,640 visual lines before and
3,164-3,196 after.
Cost
23-27 ms on SQL), but lines are mostly plain until release, because each scroll event starts a new pass that
overtakes the last one. Frames in full colour: 70-76% → 2-4% on SQL, 68-83% → 2-12% on C#. Once the file is
tokenized: 89-98% → 99.9%.
start state an edit above just moved; it updates when TMModel's thread reaches it, as it does on Bump TextMateSharp to 2.0.5 #623 without a
scroll. The other is a line the grammar threw on; Bump TextMateSharp to 2.0.5 #623's forced pass repaired it on the next scroll if the grammar
didn't throw again, and the fix belongs in TMModel's catch.
Backgroundpriority holds the slices back.Not covered
Measured on one Windows machine; macOS and Linux are not measured. 4 ms and 8 lines are the best of the settings tried
on one slow and one fast grammar, on TextMateSharp 2.0.4, and were not tuned again on 2.0.5.
Tests
TokenizeViewPortCorecan return before every visible line has tokens and redraws when the pass ends, so headless teststhat scroll and then read tokens or visual lines pump the dispatcher and lay out.
New
TextEditorModelViewPortTokenizationTests, 37 headless tests; TMModel's own thread is held where a test needs thepass alone:
without tokens are tokenized;
TMModel's thread was tokenizing from the old state (both fail on TextMateSharp 2.0.4);
after an earlier edit; a pass that tokenized nothing redraws nothing; tokens from another thread during a run, a line
a listener forces above or below a run or after an edit during one, and a
ForceTokenizationoutside a run, arestill redrawn without waiting for the pass; disposing the model during a run, between slices or during layout
redraws the lines the pass tokenized, and tokens a run stores after the model is disposed are redrawn;
redraws the lines the pass tokenized after the layout, also for a model created and disposed during that layout;
a disposed model and its document are not kept alive by a view that then shows no document and is not measured
again, also when the exception handler's owner holds them or the redraw after dispose throws through a handler that
rethrows, and a measure with no document drops the waiting redraw; a redraw handed to the next build reports what
its invalidation throws, once to a handler that rethrows it when a newer viewport starts that redraw;
pass, which then tokenizes the next layout's viewport, and only that layout starts a pass; an edit below the
viewport leaves the pass running;
Disposestops the slices, also during a run; an exception that escapes a run ends the pass and still redraws the lines it tokenized when the handler rethrows, and one from a replaced request leaves thenewer range pending.
All 576 tests pass in Release.
Measurements
Windows 11, .NET 10, 32 logical CPUs, 120 Hz display, a bench app on Avalonia 12.1.4. #623 against this PR, alternated,
two runs: far jumps 3 rounds a run (80 jumps to random lines after 15 warm-up jumps), the rest 2 rounds a run. Ranges
over all rounds, ms unless marked. Frame = until the second animation-frame callback after the jump; colour = until
every visible line has tokens; input wait = an
Inputjob posted with the jump.The held scrollbar streams 233 lines an event at 60 Hz for 6 s. Frame interval is the time between animation-frame
callbacks. On the wheel stream the interval p95 splits into two groups across rounds, so p99 is the row to read. One
#623 round of the C# typing case failed in the bench's undo after typing (a
NullReferenceExceptioninTextArea'sinput-method client), so that row's #623 figures are over three rounds.