#1430·sapling

streampager: expose viewport changes to embedding applications

Author: TyceHerrmanCreated Sep 11, 2026Updated Sep 11, 2026

Problem and motivation

A pager → host API for observing terminal resize and viewport changes. Applications can already update streampager's content and send it commands, but can't subscribe to these changes or query the live viewport through the Pager API.

possible use case: sharkdp/bat#1053 calls for resize handling and bidirectional communication. A host rendering width-dependent content needs something like:

resize → streampager updates layout → notify host [missing]
       → host rerenders → Controller applies Change::ReplaceAll
       → streampager refreshes [supported]

Sapling's pysptui is a good example of dynamic embedding that could be done. It uses a controlled file, Binding::custom, an ActionSender, a pager thread, and a progress stream. Its replace_contents() updates content through Change::ReplaceLines. Viewport notifications would let this architecture respond to layout changes.

A comment in bat#1053 on October 30, 2025 suggested sapling-streampager after jj switched from minus for better large-input handling. jj's migration commit cites avoiding indefinite input consumption and supporting pipes for child stderr.

Existing capabilities and what's still needed

Streampager provides:

Custom bindings provide a limited return path when a bound key is invoked. They don't expose resize, scrolling, file switches, or the resulting viewport. Initial config also doesn't reveal live wrapping/follow state.

The internal resize handler receives InputEvent::Resized, calls get_screen_size(), resizes and renders the screen, then returns DisplayAction::None without notifying the host. Event, EventSender, and EventStream are pub(crate). Pager::run(self) consumes the pager, leaving live screens inside the display loop without a public observation handle.

Relevant private state

Screen and RenderState already hold most of the useful state:

Host-visible information Internal representation / semantics
Terminal columns and rows width, height.
Active file Current FileIndex; distinguish application files from pager-owned views such as help.
Top logical line and wrapped portion top_line, top_line_portion; the portion is layout-dependent, not a stable reflow anchor.
Bottom visible line / visible range bottom_line is an exclusive boundary; define empty-view behavior.
Horizontal offset, wrapping, follow state left in display columns, wrapping_mode, following_end.
Overlay height and usable content dimensions overlay_height includes ruler, error, progress, search, and prompt rows.
Optional search context Pattern, current match, progress, completion; detailed search exposure need not be part of the initial API.

Public API direction

One option is PagerEvent::ViewportChanged { viewport: Viewport }, carrying a snapshot through an endpoint obtained before run(). A queryable snapshot handle with notifications or a callback could also work.

The contract should define initial-state availability, notification timing, coordinate units, ordering/coalescing, and shutdown. Dimensions and visible bounds must describe the same layout. Slow or disconnected observers shouldn't block paging or create unbounded queues. Hosts should be able to rerender through existing APIs without reentrancy problems or replacement/notification loops.

Scope and acceptance

An embedding example should be able to:

  1. Obtain initial state and observe resize, scrolling, file switches, wrapping/follow changes, and overlay/content changes that affect the viewport.
  2. Rerender for updated dimensions and replace a controlled file while paging remains responsive.
  3. Continue using bindings, actions, and content updates when observation is unused or disconnected.

Separate follow-ons:

  • Scroll anchoring across reflow: exposing top_line_portion does not preserve a stable text position after resizing. bat's anchoring discussion and riffle's wrapping limitation motivate separate design and validation.
  • **Search invalidation after Change::ReplaceAll. may not be an issue, but the reload path clears line caches, while search maintains separate match state.
  • Persistent custom header/footer: existing error/progress/ruler presentation does not establish a general host-defined header/footer API.

Prior embedding work

This builds on the original streampager's keymap/binding customization, controlled files, and FileIndex in custom callbacks, following earlier callback discussion. Sapling PR #1011 later allowed callers such as sl/jj to supply configuration without automatically reading streampager's own configuration files and environment settings.