Scrollable views and activity logs¶
ScrollView displays part of an animation’s larger canvas. LogView retains
recent text and follows new messages until you scroll upward. Both are animations
with typed options, viewport resize support, and reusable KeyBindings. They
use the existing Player loop and require no new dependencies.
A focused activity log¶
from bruhanimate import (
Column, Fixed, FocusGroup, FocusTarget, KeyBinding, KeyBindings,
LogView, Panel, Slot, StatusBar, TextStyle, play,
)
log = LogView(size=(40, 6), max_lines=200, wrap="character",
style=TextStyle(foreground=252, background=235))
log.append("Ready. Press a to append a message.\nUse arrows to browse history.")
state = {"count": 0}
def add_message() -> None:
state["count"] += 1
log.append(f"Message {state['count']}")
target = FocusTarget("activity", KeyBindings(
*log.bindings.bindings,
KeyBinding.character("a", add_message, description="append"),
))
focus = FocusGroup(target)
layout = Column(
Slot(Panel(log, size=(40, 6), fit=True, title="Activity", focus=target)),
Slot(StatusBar(lambda: "LIVE" if log.follow_tail else "HISTORY",
center=focus.help_text, right="q quit"), size=Fixed(1)),
)
play(effect_factory=layout.build, event_handler=focus.handle_event,
on_resize="resize", frames=180)
The panel owns the log. Call append(text) before startup or from an input
action/simulation update. It changes history immediately; draw only paints the
retained text. Do not append from draw or from Label/StatusBar callbacks. Background
workers should queue messages for application code to drain during update; the
widget is not a thread-safe logging handler and does not poll external sources.
Log options and retention¶
size=(width, height)is the positive viewport size in cells. Layouts and fitting panels send resize notifications. When used directly, provide the initial size, or construct the view in a factory from its allocated Buffer.max_lines=1000is a positive logical-line limit. Newline-separated text adds multiple lines; an empty string adds one blank line, and a trailing newline adds a final empty line. The oldest excess lines are discarded.wrap="character"breaks long lines at cell boundaries, matching Label’s wrapping behavior.wrap="none"keeps logical lines intact and enables horizontal scrolling. The exportedLogWrapLiteral exposes both choices.follow_tail=Truestarts at the newest messages. It is a mutable Boolean property; setting it True immediately returns to the bottom.style=TextStyle(...)applies ANSI styling per cell. Text itself must contain plain single-cell characters and optional newlines. Tabs, inline escape codes, and wide/combining glyphs are rejected before history changes.fill=Truepaints styled spaces across the viewport; False leaves unused cells untouched. Spaces within a message still paint.
lines is an immutable tuple snapshot of retained logical lines. line_count,
max_lines, wrap, offset, size, content_size, and at_bottom
are typed read-only properties. clear() empties history and resets both
offsets while retaining the current follow setting.
The limit counts logical lines, not wrapped rows or characters. A very long message still occupies memory proportional to its length; choose a message-size limit in application code if needed. LogView indexes wrapped rows without building a full character canvas for the entire history.
Following and preserving your place¶
Up/Page Up or any upward scroll_to/scroll_by request suspends following,
even when already at the top. Home also suspends it. New messages then preserve
the top visible logical line and its character position. When that line is
evicted, the view shows the oldest retained line. Scrolling down to the bottom
does not resume following automatically: press End or set follow_tail=True.
Horizontal scrolling leaves the follow setting unchanged.
Resize rewraps text. While browsing history, the new top row contains the character that was at the start of the old top row, subject to bottom clamping. While following, resize keeps the newest row visible. Empty logs and content smaller than the viewport have zero offsets. Draw never modifies offsets or follow state.
A larger animation canvas¶
from bruhanimate import (
FocusGroup, FocusTarget, Label, Panel, ScrollView, play,
)
text = "\n".join(f"Row {row:02}: " + "0123456789" * 8 for row in range(60))
view = ScrollView(Label(text), size=(38, 8), content_size=(88, 60))
focus = FocusTarget("document", view.bindings)
group = FocusGroup(focus)
panel = Panel(view, size=(38, 8), title="Scrollable document", focus=focus, fit=True)
play(panel, event_handler=group.handle_event, on_resize="resize", frames=180)
ScrollView owns exactly one supplied Animation. content_size is the positive
full canvas size; size is the visible viewport. At startup the child receives
a resize notification for the content dimensions, then starts once. Child updates
receive the parent’s simulation seconds. Its optional finished status passes
through unchanged, so wrapping Playback preserves finite completion.
resize((width, height)) changes the viewport only. It keeps the child, solver
iterator, queued steps, and simulation state alive, clamping the offset when
necessary. set_content_size((width, height)) explicitly changes the full canvas
and notifies an already started child without restarting it. The child controls
how it adapts to that notification. A viewport resize never changes the canvas.
The canvas is a rectangular Buffer, so memory and child drawing cost scale with
content_size. This is suitable for modest boards and documents; LogView avoids
allocating such a canvas for long histories. Construct built-ins with a matching
content-sized buffer. background=" " fills empty cells; None reveals lower
layers through transparent content. Plain child spaces paint.
API¶
Scrollable animation canvases and bounded, append-only text history.
- class bruhanimate.views.ScrollView(animation: Animation, *, size: tuple[int, int], content_size: tuple[int, int], background: str | None = ' ')[source]¶
Own an animation rendered on a larger canvas and show a clipped viewport.
size and content_size are positive (width, height) in cells. Construction performs no lifecycle calls. Start notifies the child of content_size, then starts it; update forwards simulation seconds once. Draw only copies current content. Viewport resize clamps offset without resizing/restarting the child. set_content_size explicitly changes its canvas. Completion mirrors the child.
None cells reveal background; background=None preserves lower layers. Plain spaces paint. Close is idempotent, including before start and after failures. Never share the child with another owner. Bindings are opt-in via FocusTarget or Player. Scrolling adds no clock, input poller, or terminal dependency.
- __init__(animation: Animation, *, size: tuple[int, int], content_size: tuple[int, int], background: str | None = ' ') None[source]¶
- property finished: bool | None¶
- resize(size: tuple[int, int]) None[source]¶
Change viewport dimensions, preserving/clamping the cell offset.
- set_content_size(size: tuple[int, int]) None[source]¶
Resize the child canvas without advancing or restarting content.
- set_visible(visible: bool) None[source]¶
Propagate parent visibility; clipping alone does not infer focus.
- property bindings: KeyBindings¶
Arrow, Page Up/Down, Home/End bindings; no polling or quit defaults.
Give these to FocusTarget, or pass bindings.handle_event to Player with your own quit routing. Actions only move the view, never update content.
- property content_size: tuple[int, int]¶
Scrollable content (width, height), in cells/display rows.
- end() None¶
Move to the bottom, retaining the horizontal offset.
- home() None¶
Move to the top-left of content.
- property offset: tuple[int, int]¶
Clamped top-left (column, display row), relative to content.
- page_down() None¶
Move down one viewport height, with a one-row overlap when possible.
- page_up() None¶
Move up one viewport height, with a one-row overlap when possible.
- scroll_by(*, dx: int = 0, dy: int = 0) None¶
Move by signed columns/rows; positive means right/down.
- scroll_to(offset: tuple[int, int]) None¶
Set an absolute cell offset; out-of-range coordinates clamp.
- property size: tuple[int, int]¶
Viewport (width, height) in cells; change through resize().
- class bruhanimate.views.LogView(*, size: tuple[int, int], max_lines: int = 1000, wrap: Literal['none', 'character'] = 'character', follow_tail: bool = True, style: TextStyle | None = None, fill: bool = True)[source]¶
Bounded plain-text history with optional cell wrapping and tail following.
append accepts newline-separated logical lines, including empty/trailing lines. max_lines bounds retained logical lines, not characters or wrapped rows; oldest lines are evicted. wrap=’character’ breaks at cell boundaries; ‘none’ enables horizontal scrolling. Style is applied per cell, as with Label.
Upward scrolling/Home suspends follow_tail. End or follow_tail=True resumes. Scrolling down to the bottom alone does not resume it. Append and resize preserve the top logical line/character when not following, or show the oldest remaining line if that anchor was evicted. Draw never changes scroll state. Append before start is supported. This single-use widget owns no resources; append from the player/application thread, not a background logging thread.
- __init__(*, size: tuple[int, int], max_lines: int = 1000, wrap: Literal['none', 'character'] = 'character', follow_tail: bool = True, style: TextStyle | None = None, fill: bool = True) None[source]¶
- property max_lines: int¶
Maximum retained logical lines, before wrapping.
- property wrap: Literal['none', 'character']¶
none or character.
- Type:
Wrapping mode chosen at construction
- property lines: tuple[str, ...]¶
Snapshot of retained logical lines, oldest first.
- property line_count: int¶
Number of retained logical lines; wrapped rows do not add to this.
- property follow_tail: bool¶
Whether append/resize keeps the bottom visible; enabling jumps there.
- property at_bottom: bool¶
Whether the newest row is visible, independently of follow_tail.
- resize(size: tuple[int, int]) None[source]¶
Rewrap for a new viewport, preserving the reading anchor or tail.
- scroll_to(offset: tuple[int, int]) None[source]¶
Clamp to an absolute cell offset; upward requests suspend following.
- property bindings: KeyBindings¶
Arrow, Page Up/Down, Home/End bindings; no polling or quit defaults.
Give these to FocusTarget, or pass bindings.handle_event to Player with your own quit routing. Actions only move the view, never update content.
- property content_size: tuple[int, int]¶
Scrollable content (width, height), in cells/display rows.
- property offset: tuple[int, int]¶
Clamped top-left (column, display row), relative to content.
- page_down() None¶
Move down one viewport height, with a one-row overlap when possible.
- page_up() None¶
Move up one viewport height, with a one-row overlap when possible.
- scroll_by(*, dx: int = 0, dy: int = 0) None¶
Move by signed columns/rows; positive means right/down.
- property size: tuple[int, int]¶
Viewport (width, height) in cells; change through resize().