Skip to content

Releases

v0.22.0 — fold before the first child, and no more "Loading…" flicker

Choose a tag to compare

@tya5 tya5 released this 14 Aug 23:44

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 folded

The 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

Choose a tag to compare

@tya5 tya5 released this 14 Aug 09:55

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 in pyproject.toml already 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

Choose a tag to compare

@tya5 tya5 released this 14 Aug 09:49

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 reflow

Read 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.present receives the Entry, not the item: async def present(self, entry: Entry[T], width: int). Migration is mechanical — read entry.item where you read item. 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 with FlowDecorator.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 pass parent=.
  • Removing an entry removes its subtree — a child cannot outlive the parent that positions it.

Worth knowing

⚠️ Reserve the scrollbar gutter if you fold large groups:

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

Choose a tag to compare

@tya5 tya5 released this 14 Aug 09:48

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.py bound c to collapse/expand-all, which FlowView itself has owned for the cursor toggle since 0.14.0 — the example's key silently did nothing. Rebound to a.
  • 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

Choose a tag to compare

@tya5 tya5 released this 12 Aug 23:40

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

Choose a tag to compare

@tya5 tya5 released this 12 Aug 23:34

Images in the feed need no image library

Following the chain from the Sixel report:

  1. Sixel is unplaceable here — it draws relative to the cursor and occupies no cells, so a virtualized painter can't position or clip it.
  2. 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.
  3. ∴ 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

Choose a tag to compare

@tya5 tya5 released this 12 Aug 22:54

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 ImageRenderable

The README now carries this matrix, the measurement and the guidance.

Fixed

  • examples/image.py used 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

Choose a tag to compare

@tya5 tya5 released this 12 Aug 15:02

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 meanwhile

The 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

Choose a tag to compare

@tya5 tya5 released this 11 Aug 01:11

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_selection are now async (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

Choose a tag to compare

@tya5 tya5 released this 10 Aug 23:54

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_of is 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.
  • FlowLayout per-entry operations no longer scan the whole cache. store runs on every completed present and every patch_rows chunk, release on 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: store 0.155 ms → 0.001 ms, release 0.302 ms → 0.0001 ms — both now flat in cache size.

Full Changelog: v0.16.3...v0.16.4