Repository navigation
Releases
v0.22.0 — fold before the first child, and no more "Loading…" flicker
Checking #14 (filed by reyn) confirmed the report — and verifying it turned up two more defects, one of them worse than the reported one.
1. A group can be declared folded before its first child exists (#14)
set_collapsed silently discarded the state on an entry with no children, so "this group starts folded" could not be said at the moment it was known. A host had to watch its own append_child and fold on the first one.
One guard was doing two jobs — recording the intent, and skipping a redraw that would change no pixels — and the second erased the first. Now the state is recorded on any live entry, and only the notify/re-present half is skipped:
group = model.append(ToolCall("search"))
group.collapse() # no children yet; the intent is kept
group.append_child(LogLine("hit 1")) # born foldedThe issue's Proposal A, essentially as written. Good report — the deferred-application argument (an entry's own collapsed never affects its own visibility, only its descendants') was exactly right.
2. A parent now re-presents when it gains or loses a child
Since 0.21.0 the presenter receives the entry, so a header's body may draw entry.children — a ▸/▾ chevron, a "12 steps" count. Nothing invalidated that presentation, so a header kept rendering group (0) with no chevron forever after its first child arrived. Child insertion and removal now bump the parent's revision, on the same reasoning that already applied to folding.
3. update() no longer blinks the entry through the "Loading…" placeholder
The worst of the three, and pre-existing rather than new.
_band_ids is only refreshed by _present_visible, which a plain append never calls — so an entry presented by the paint path was classified off-band while fully on screen, and update() took the off-screen branch and released its presentation. Every streamed chunk painted a frame of the placeholder over content the reader was mid-sentence in.
Measured: 5 of 5 immediate paints after update() rendered "Loading..."; now 0.
Two changes — band membership is computed rather than read from a set that may be stale, and a re-present keeps drawing the previous body until the new one lands. The placeholder is for an entry that has never had a body, not for one that has.
Consumers streaming via patch_rows were unaffected, since that path stores synchronously. The memory optimisation the band check exists for is intact and now covered by a test: an entry genuinely out of view still releases its body on update().
222 tests, mypy strict, ruff — green on Python 3.11 / 3.12 / 3.13.
v0.21.1
Maintenance: CI had been red since the 0.18.x series and none of the releases in between were the cause.
ruff was declared >=0.5 in the dev extra, so CI installed whatever was newest on each run while local checks used an older one. ruff 0.16 added RUF036, which fires on a pre-existing clipboard type union (None not last) — so the workflow failed at the lint step before ever running mypy or the tests.
- Reordered the union (
bool | Awaitable[bool | None] | None) — semantically identical. - Pinned
ruff==0.16.3, so lint results are reproducible instead of drifting with each ruff release. That is what the rule-list comment inpyproject.tomlalready claimed to guarantee.
No library behaviour changed. The full suite (215 tests, mypy strict, ruff) now passes in CI on Python 3.11 / 3.12 / 3.13.
v0.21.0 — nested groups & folding
FlowView owns the tree. Entries nest to any depth, a parent folds its subtree away, and a parent stays an ordinary entry throughout — same presenter, same gutter, no special-casing anywhere in the view.
group = model.append(ToolCall("search"))
step = group.append_child(LogLine("hit 1"))
model.extend([LogLine("hit 2"), LogLine("hit 3")], parent=group)
group.collapse() # fold the subtree; the header stays
model.set_collapsed_many(groups, True) # many subtrees, ONE reflowRead the shape with entry.parent / children / depth / ancestors() / descendants(). Document order is preorder, and visible_entries() is one O(entries) pass regardless of depth — a folded subtree is contiguous, so it's skipped wholesale.
Folding is O(1) in what you fold
Descendants of a folded parent simply aren't in the laid-out set: no height, no paint, and — if never expanded — no present() at all, so a collapsed-by-default group is free until opened. Folding keeps every descendant's cached presentation, so it never re-presents what it hides. Only the header re-presents, since its body may draw the chevron.
collapsed is independent of hidden
hidden is about this entry (the filtering primitive); collapsed is about its subtree. A search filter and a fold now compose instead of fighting over one flag — unfolding a group does not resurrect entries a filter hid. entry.visible reports the combination.
Fold keys
Under the existing vim z prefix: za (toggle), zo, zc, zR (unfold all), zM (fold all). za/zo/zc act on the current entry if it heads a group, else the nearest ancestor that does — so they fold the group you are in, as in vim. All callable directly (toggle_fold(), close_all_folds(), …). New FlowView.Collapsed message.
Breaking
FlowPresenter.presentreceives theEntry, not the item:async def present(self, entry: Entry[T], width: int). Migration is mechanical — readentry.itemwhere you readitem. Once FlowView owns depth and fold state, a body that indents or draws a ▸/▾ chevron has no other way to see them, and mirroring them into your item would be the same fact stored twice. It also removes the long-standing asymmetry withFlowDecorator.decorate, which always took the entry.insert/insert_many's index is a position among siblings. Identical for a flat model; only meaningful once you passparent=.- Removing an entry removes its subtree — a child cannot outlive the parent that positions it.
Worth knowing
FlowView { scrollbar-gutter: stable; }A fold that shrinks content below the viewport height removes the scrollbar, which widens the body — and width is part of the presentation cache key, so everything re-presents. Measured on a 50-entry group: 0 presents with the gutter reserved, 12 without. Not fold-specific (hide() did it too), and not something FlowView can paper over, since the width genuinely changed.
FlowView never indents, never draws chevrons or tree guides, and never renders a parent differently. entry.depth is data; the drawing is yours. See examples/groups.py.
Also fixed: README's "Keys & focus" claimed FlowView "defines no BINDINGS of its own", which stopped being true in 0.14.0.
Closes #13. Full changelog: https://github.com/tya5/textual-flowview/blob/main/CHANGELOG.md
v0.20.0
Collapse a group in one operation, not N
FlowModel.set_hidden_many(entries, hidden) — show or hide a run of entries as a single change.
Hiding one entry at a time reflows per entry and re-runs the present band each time, so as the layout closes up, the entries sliding into the band get presented — a group collapse renders the very content it is collapsing.
Collapsing 200 of 2 000 entries, viewport 20 rows:
| collapsed group is… | one-by-one hide() |
set_hidden_many |
|---|---|---|
| at the viewport (you collapse what you're looking at) | 573 ms, 104 presents | 35 ms, 8 presents |
| entirely off-screen | 201 ms, 0 presents | 43 ms, 0 presents |
Entry.hide() / show() / set_hidden() are unchanged — this adds a path, it doesn't replace one. examples/groups.py and the README now collapse via the batch, and the example's collapse-all does every group in a single call.
Verified while implementing: a bulk visibility change preserves the scroll anchor — the entry under the top edge stays there and the offset follows it.
Fixed
examples/groups.pyboundcto collapse/expand-all, which FlowView itself has owned for the cursor toggle since 0.14.0 — the example's key silently did nothing. Rebound toa.- Corrected overstated figures published earlier in #13: the wasted-
present()effect is real but requires the collapsing group to overlap the band, and the speedup is 4.7×–16×, not the 125× first reported.
Full changelog: https://github.com/tya5/textual-flowview/blob/main/CHANGELOG.md
v0.19.1
Correction — the terminal's identity is queryable; only the placeholder capability isn't
0.18.3–0.19.0 said real pixels "cannot be detected at runtime" and called terminal allowlists fragile. Half of that was wrong.
Still true: there is no query for "do placeholders actually draw". The Kitty graphics query answers OK for mere transmission support — measured on the same WezTerm that garbles placeholders:
ESC _G i=31,s=1,v=1,a=q,t=d,f=24;AAAA ESC \ -> ESC _G i=31;OK ESC \
…which is exactly why tgp.query_terminal_support() false-positives there.
Wrong: that identifying the terminal is a fragile TERM guess. XTVERSION is answered by the terminal itself — measured on a WezTerm whose TERM was plain xterm-256color:
ESC [ > q -> ESC P >| WezTerm 20240203-110809-5046fc22 ESC \
So an opt-in allowlist for known-good terminals is a maintained list, not a fragile heuristic — though it is still a list someone has to keep, and unknown terminals must fall back anyway. Half-blocks remain the zero-maintenance default.
Also — honest wording about half-block quality
Earlier text ("looks fine", "smooth gradients") overstated it. One cell carries two pixels, so it is visibly chunky: blocky edges, banding, and small detail (faces, text in a screenshot, thin lines) is lost. It is a recognisable low-res preview, not a faithful picture. If faithful images are a requirement, a cell-painting widget is the wrong tool.
Full Changelog: v0.19.0...v0.19.1
v0.19.0
Images in the feed need no image library
Following the chain from the Sixel report:
- Sixel is unplaceable here — it draws relative to the cursor and occupies no cells, so a virtualized painter can't position or clip it.
- Kitty placeholders can't be relied on — they work on Kitty, but WezTerm and Konsole report Kitty-graphics support and then draw the placeholders as visible glyphs (verified first-hand on WezTerm 20240203), and there is no query for "do placeholders actually draw", so it can't be detected at runtime.
- ∴ the only workable form is coloured half-block cells — which need no protocol machinery at all.
And a renderable is a Rich concept, not a Textual one. So a complete image renderable is ~15 lines:
class HalfBlockImage: # pixels: rows of (r, g, b)
def __rich_console__(self, console, options):
for y in range(0, self.h - 1, 2): # two pixel rows per text row
for x in range(self.w):
t, b = self.px[y][x], self.px[y + 1][x]
yield Segment("▀", Style(color=f"rgb{t}", bgcolor=f"rgb{b}"))
yield Segment("\n")examples/image.py now does exactly that — no textual-image, no rich-pixels (pillow only, and only to generate the demo images). Verified in a real WezTerm with neither library installed: avatars and a gradient picture render cleanly and scroll like any row.
This removes the footgun rather than documenting around it — textual_image.renderable.Image auto-selects Sixel, so the convenient import was the broken one, and the library's one remaining useful mode is the one you can write yourself. (rich-pixels is the same idea as a one-liner if you'd rather not.)
Full Changelog: v0.18.3...v0.19.0
v0.18.1
Documentation — which image renderables work, and why Sixel doesn't
A consumer reported images landing in the wrong position with Sixel. The cause is structural, and it was undocumented:
FlowView paints rows as cells and repaints them independently as you scroll, so it can only place an image that occupies cells. Measured, for the same image:
| Renderable | In the rendered row | In FlowView |
|---|---|---|
Kitty graphics, Unicode-placeholder (textual_image.renderable.tgp) |
120 placeholder cells | ✅ real pixels, positioned and clipped |
Half-block / Unicode (…halfcell, rich-pixels) |
coloured cells | ✅ works everywhere, approximate |
Sixel (…sixel) |
0 cells, 0 characters | ❌ wrong position, no clipping |
Sixel draws pixels relative to the cursor instead of into cells, so a virtualized painter has no way to position or clip it.
Why this bites: textual_image.renderable.Image auto-selects Sixel first wherever the terminal supports it (WezTerm, Konsole, xterm …) — so the convenient auto-detecting import is exactly the one that breaks. Import the cell-based renderable explicitly:
try:
from textual_image.renderable.tgp import Image as ImageRenderable
except ImportError:
from textual_image.renderable.halfcell import Image as ImageRenderableThe README now carries this matrix, the measurement and the guidance.
Fixed
examples/image.pyused that auto-detecting import and so would have shown mispositioned images on Sixel-capable terminals. It now imports the Kitty placeholder renderable explicitly (verified: 456 placeholder cells painted, where Sixel would paint none).
Full Changelog: v0.18.0...v0.18.1
v0.18.0
Changed — clipboard= may be async
async def copy(text: str) -> bool:
proc = await asyncio.create_subprocess_exec("pbcopy", stdin=asyncio.subprocess.PIPE)
await proc.communicate(text.encode())
return proc.returncode == 0
FlowView(..., clipboard=copy) # awaited; the UI stays live meanwhileThe hook now accepts sync or async (the "await me maybe" pattern), so a sink that shells out to pbcopy / xclip no longer has to block the UI for the lifetime of that process. write_clipboard and yank are async accordingly. Default key bindings are unaffected.
This is the one callback whose result FlowView reports back to the caller, so it can't be fired and forgotten — every other slow-work hook can be scheduled by the caller instead.
Documentation — where slow work belongs
A false promise is corrected. FlowPresenter's docstring claimed presentation "runs inside a Textual worker so a slow presenter never blocks the UI". A worker is an asyncio task on the same loop, so a CPU-bound present — the usual shape, since rendering is pure CPU — holds the loop and freezes the UI. Measured: four 250 ms presents starved a 10 ms heartbeat to 10 ticks where a free loop manages ~121. That is exactly the trap a consumer hit and reported (#10), while the docs pointed the wrong way.
New docs/event-loop.md frames what was missing — FlowView has two kinds of extension point, and they differ completely:
Messages (Highlighted, Selected, Clicked, ReachedTop/Bottom, …) |
Callbacks (present, decorate, on_show, …) |
|
|---|---|---|
may be async |
yes — async def handlers work out of the box |
only present and clipboard |
| when it runs | later, off FlowView's call stack | inline, inside it |
So slow, reactive work belongs in a message handler, where you can simply await. Verified: an async def on_flow_view_highlighted that awaits 150 ms and then mutates the entry works as written — set_current() returns in 0.5 ms and the loop stays live.
The guide also has a per-hook table (async? how hot?), the measured difference between awaiting and merely "being in a worker" (I/O-bound worker: 29/30 heartbeats; CPU-bound: 3/33; thread=True: 35/35), what to do for the sync hooks, and why FlowView will not run your renderable on a thread.
track_visibility, animate_entry and FlowDecorator docstrings now state they are not awaited and show how to start slow work rather than wait for it.
Full Changelog: v0.17.0...v0.18.0
v0.17.0
Fixed — text search no longer silently misses matches
Search read rows through the render path, so an entry that had never scrolled into view returned the placeholder ("Loading...") instead of its content — searching a long transcript quietly found nothing beyond what you had already looked at. It was also eager (it evaluated every row in the document before checking any) and presented every entry as a side effect: a match on the next row cost 441 ms over a 10 000-row document.
Added — search_text= makes the whole model searchable
FlowView only ever sees the Presentation a presenter produced, so it cannot read an un-presented entry. Give it a way to read the item:
FlowView(model=..., presenter=..., search_text=lambda msg: msg.text)It finds the matching entry from that cheap text (no rendering) and presents only that one to resolve the exact row and column. Measured over 2 000 entries:
| before | after | |
|---|---|---|
| never-visited match at the end | not found, 579 ms | found, 1.0 ms (1 present) |
| match on the next row | 441 ms | 0.3 ms |
| no match | 465 ms, presents everything | 0.3 ms, renders nothing |
Return what the body renders — anything your presenter adds that isn't in the returned string won't be found. Without search_text the previous behaviour is kept (rendered rows only, now evaluated lazily) and documented as such.
Changed
search,search_next,search_previous,search_selectionare nowasync(a match may need its entry presented first). The default key bindings are unaffected.
Full Changelog: v0.16.4...v0.17.0
v0.16.4
Three issues found by profiling the caches and hot paths.
Fixed
- The gutter and separator caches are released on leaving the band, like body strips already were (0.16.2). They're the same kind of per-frame paint cache but were only cleared on removal / decorator change / resize — so with a decorator configured (the per-message state gutter, a headline feature) memory still grew with every entry visited. Measured over 400 entries: gutter cache ~visited count → 4 (the band).
Performance
Viewport.offset_ofis O(1) instead of a linear scan. It sits on the streaming path — every content change re-anchors the text cursor through it — and the id index it needed already existed. At 10 000 entries: 0.230 ms → 0.008 ms.FlowLayoutper-entry operations no longer scan the whole cache.storeruns on every completed present and everypatch_rowschunk,releaseon every off-screen update, yet each scanned all cached keys to find that entry's own. They now use a per-entry index. At a 10 000-entry cache:store0.155 ms → 0.001 ms,release0.302 ms → 0.0001 ms — both now flat in cache size.
Full Changelog: v0.16.3...v0.16.4