Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

LogView — a scalable streaming log

LogView is the code editor's core turned inside out: instead of a bounded document a person edits, it is an unbounded one the program appends to and the person only reads, scrolls, selects, and copies. It reuses CodeEditorState — so selection, copy, theming, and accessibility come for free and cannot drift from the editors' — but owns its own frame step (log_stream.rs) and paint body, because content arrives faster than a person types, forever, and neither the editor's full relayout nor its event handling can carry that load.

#![allow(unused)]
fn main() {
use teksilo::widgets::{LogView, LogViewHandle};
use teksilo::tokens::Color;

let log = LogView::new()
    .scrollback_limit(50_000)                 // bound the retained lines
    .severity_highlighter(|line| {            // colour a line by what it is
        if line.contains(" ERROR ") { Some(Color::new(0.92, 0.36, 0.36, 1.0)) }
        else if line.contains(" WARN ") { Some(Color::new(0.92, 0.72, 0.28, 1.0)) }
        else { None }
    })
    .font_family("monospace");
let handle: LogViewHandle = log.handle();     // append from anywhere on the UI thread
}

Feed it through the handle: append("line"), append_line, append_lines(iter) (splitting on \n; a single trailing newline is a terminator, not a blank line), clear(), and scroll_to_bottom(). handle.line_count() is a reactive Signal<usize> of the retained count for a status bar.

Two costs, bounded

A naive multi-line view over a growing document has two costs that grow without bound. LogView answers each; the numbers below are from text-typeset/docs/streaming-baseline.md (a 65-char log line, no-wrap, 16 px).

Appending one line

Because a block-count change invalidates a full layout, a consumer with no tail-append entry point is forced to re-lay-out the whole document on every appended line — O(N):

LinesFull relayout (per line)Windowed append (per line)speedup
1 00010.9 ms~10 µs1 046×
10 000113 ms~10 µs11 655×
100 0001.167 s~10 µs117 007×

The LogView never re-lays-out the whole buffer: drain_events, told the state is streaming, sets a re-window flag instead of forcing a relayout, and a frame's arrivals are batched into one append_lines.

Holding a large buffer

A resident shaped line costs ≈ 6.5 KB, so laying out the whole document is the real memory sink:

LinesFully residentWindowed (viewport only)
1 00010.8 MB3.7 MB
10 00068.1 MB3.7 MB
100 000622.9 MB3.7 MB (168× less)

LogView shapes only the rows the viewport can show, via RichTextEngine::layout_window_from_snapshots, placing each row arithmetically at y = index × row_height; the scrollbar spans the whole document even though almost none of it is shaped. Render already culls to the viewport, so shaping the rest only ever cost memory. The document's raw text (a rope, ≈ 65 B a line) is cheap by comparison — ~6.5 MB at 100 k.

The text-stack additions

The windowed path is additive to the sibling crates, so RichTextEditor's paths are byte-for-byte unchanged:

  • text-typesetDocumentFlow::{layout_window, set_uniform_extent, add_block, remove_leading, block_params_for}.
  • teksilo-textRichTextEngine::{layout_window, layout_window_from_snapshots, set_uniform_extent, append_block, remove_leading, block_visual_info}.
  • text-documentTextDocument::{append_line, append_lines, truncate_front} (undoable-false, all-or-nothing), whose events now also reach on_change subscribers, not only the poll path.

Windowing internals

The visible window (first, count) is computed from the scroll offset and a learned uniform row height (all three of the render window, the a11y window, and the a11y-change check share one window_bounds helper so they cannot disagree). Rows are located by chaining character positions through the rope (snapshot_block_at_position) — each row is one O(log n) snapshot, not the O(n) block walk TextBlock::next would be — forward from a cached (row, position) anchor that the tail-following hot path advances a few rows at a time. Windowing is therefore O(window · log n) in steady state, with a cold O(n) locate only on a far scrollbar jump or after an eviction drops the anchor.

The document's block_count stat does not count its initial empty block, so the view keeps an authoritative line count instead and fills that initial block with the first line — a fresh log opens on real content, not a blank line.

Following the tail

Following is derived from scroll position, not a mode flag that fights the user: the view sticks to the bottom only while it is already at the bottom (scroll_y ≥ max_scroll_y − ε). Scroll up to read history and it pauses; scroll back (or scroll_to_bottom()) and it resumes — the behaviour of every terminal, and the one that composes correctly with a stray key or click nudging the viewport. Set follow_tail(false) to hold position as the buffer grows.

Scrollback

scrollback_limit(n) evicts the oldest lines past n from the front. Eviction is amortised over a slack band that scales down with the cap (so a small cap is still honoured tightly), and truncate_front shifts the cursors automatically, so a live selection stays glued to surviving text. Unset (the default) keeps every line: memory stays flat in the line count (only the window is shaped), but the raw text accumulates in the rope and each append stays linear in the document size — so a genuinely unbounded, sustained high-rate producer should set a cap.

Accessibility

Role::Document (not Role::Log — that role is excluded from accesskit's text-range support, so a reader could not track a caret through it), read-only, with the same paragraph/run walk as the editor (a11y.rs) — but windowed: only the visible lines are emitted as paragraphs (numbered by global line, "line 41 002 of 128 449"), so an append re-walks O(window), not O(document). The tree re-walks on the log's own a11y_version, bumped only when the visible window changes — a scroll crossing a row, a following-tail append, an eviction — not on a sub-row pixel scroll or a tail append arriving while the reader is scrolled away. Opt into a Live::Polite region for new lines with announce_appends(true) — off by default, because a live region is right for a handful of meaningful events and hostile for a build log at fifty lines a second.

Threading

The handle is UI-thread (Rc). Feeding a log from a background thread (a PTY reader, a tracing layer) means marshalling the lines to the UI thread first — through the app's async executor, or a channel drained in a handler. Each append wakes the view, which otherwise stops asking for frames when idle.

Demo

cargo run -p log_view      # a synthetic ~40-lines/frame producer, severity
                           # colour, follow-tail, a 50k scrollback cap, and a
                           # "Burst 10k" button to watch the windowing hold