Skip to content

Add ScrollView widget for scrolling arbitrary widget content#2

Open
sfdcdoug wants to merge 7 commits intosetdef:stablefrom
sfdcdoug:feature/scroll-view
Open

Add ScrollView widget for scrolling arbitrary widget content#2
sfdcdoug wants to merge 7 commits intosetdef:stablefrom
sfdcdoug:feature/scroll-view

Conversation

@sfdcdoug
Copy link
Copy Markdown

Summary

  • Adds a ScrollView widget that renders child widgets into a virtual buffer and copies the visible viewport based on a scroll offset
  • Enables scrolling of arbitrary widget trees (layouts, nested blocks, styled content) — not just text
  • Follows the same patterns as Overlay: Rust renderer + Ruby Data.define widget + factory method

Motivation

Paragraph supports scroll: [y, x] for text, but there's no mechanism to scroll composite widget content (e.g. a vertical layout of horizontal gutter+text pairs). This widget fills that gap using ratatui's existing Buffer::empty() and the buffer-agnostic render_node pipeline.

Implementation

Rust (ext/ratatui_ruby/src/widgets/scroll_view.rs):

  • Creates a Buffer::empty(Rect) sized to content_height
  • Renders the child widget tree into the virtual buffer via render_node
  • Copies the visible viewport (based on scroll offset and area height) cell-by-cell into the frame buffer
  • Skips the virtual buffer entirely when no scrolling is needed

Ruby (lib/ratatui_ruby/widgets/scroll_view.rb):

  • ScrollView < Data.define(:child, :scroll, :content_height) with CoerceableWidget

Integration:

  • Registered in rendering.rs dispatcher, widgets/mod.rs, widgets.rb, widget_factories.rb
  • Factory: tui.scroll_view(child:, scroll:, content_height:)

Usage

content = tui.layout(direction: :vertical, constraints: constraints, children: rows)
tui.scroll_view(child: content, scroll: [scroll_offset, 0], content_height: total_rows)

Test plan

  • Verify paragraph content scrolls correctly with various offsets
  • Verify layout content (flat and nested) scrolls correctly
  • Verify no-scroll case renders directly without virtual buffer overhead
  • Verify edge cases: scroll past content, zero content_height, empty child

Kerrick and others added 7 commits February 15, 2026 19:05
Class-Wide Snapshot Normalization: TestHelper::Snapshot now supports a
normalize_snapshots template method hook. Override it in your test
class to mask dynamic content (timestamps, PIDs, temp paths) across all
snapshot assertions without repeating normalization blocks. The hook
composes with per-call blocks: the hook runs first, then the block.

Precompiled Native Gems: Precompiled native gems are now published for
Windows (x64-mingw-ucrt), macOS, and Linux, eliminating the need to
compile the Rust extension from source.

Windows Compilation: Fixed gem compilation failures on Windows caused
by clang header errors during Rust extension compilation.

GVL Contention in poll_event: poll_event now releases Ruby's Global VM
Lock while waiting for terminal events. Previously, background threads
(e.g., those executing shell commands via Open3) were starved of the
GVL during the blocking poll, causing up to 190× slower subprocess
execution in multi-threaded applications.

Gem Size: Reduced gem size from many MB to hundreds of KB by excluding
doc/, examples/, and other development files.
The build workflow previously compiled only against Ruby 4.0,
so precompiled gems were locked to that single version despite
the gemspec declaring >= 3.2.9. The workflow now uses a 12-job
matrix (4 Ruby versions x 3 OSes) with a packaging phase that
assembles all binaries into a single platform gem per OS.

The extension loader tries a versioned subdirectory first,
falling back to the flat path for source gems.

The monolithic NativeGemRelease class is decomposed into
domain objects (CIRun, GitHubCli, NativeGemVersion,
PlatformGem, VersionedBinary) following the conventions
established in tasks/bump/.

Generated with Antigravity (https://antigravity.google)

Co-Authored-By: Claude Opus 4.6 (Thinking) <noreply@anthropic.com>
Precompiled Native Gems: Now support Ruby 3.2, 3.3, 3.4, and 4.0
(previously only 4.0)
Text.width measured per-character with UnicodeWidthChar, returning 1
for emoji with variation selectors (➡️ = U+27A1 + U+FE0F). Delegate
to Ratatui's Text::width() which measures per-grapheme and returns 2.

get_buffer_content naively concatenated all cell symbols including
continuation cells after wide characters. Each continuation cell
added an extra display column to the serialized string, breaking
23 snapshot assertions. Skip continuation cells using the same
pattern as Ratatui's Buffer Debug formatter.

Also adds the widget_table example's highlight symbol cycling (y key)
and an integration test for emoji highlight border alignment.

Generated with Antigravity (https://antigravity.google)

Co-Authored-By: Claude Opus 4.6 (Thinking) <noreply@anthropic.com>
All 42 .rs files in ext/ were incorrectly tagged AGPL-3.0-or-later
when they should be LGPL-3.0-or-later per the project license policy.
The root cause was that the license automation only processed .rb files,
so Rust files were hand-annotated with the wrong license. This adds a
headers_rs.rb script and wires it into the license rake tasks so future
.rs files are handled automatically.

AGENTS.md is also corrected to specify per-directory license rules
instead of a blanket "use AGPL for code" instruction.

Generated with Antigravity (https://antigravity.google)

Co-Authored-By: Claude Opus 4.6 (Thinking) <noreply@anthropic.com>
Rust source SPDX headers: All 42 .rs files in ext/ were incorrectly
tagged AGPL-3.0-or-later instead of LGPL-3.0-or-later. The v0.9.0
relicensing to LGPL was applied to lib/ and sig/ but missed the Rust
extension source in ext/.

Text.width emoji width: Text.width now delegates to Ratatui's
Text::width() instead of summing per-character widths. The
per-character approach returned 1 for emoji with variation selectors
(e.g. ➡️), while the grapheme-aware Text::width() correctly returns 2.

Buffer serialization of wide characters: buffer_content now skips
continuation cells after wide characters (emoji, CJK). Previously, the
continuation cell's space was included in the serialized string,
inflating its display width by 1 per wide character.
Paragraph supports scroll natively, but there's no way to scroll
composite content (layouts, nested blocks, styled widgets). ScrollView
fills this gap by rendering child widgets into a virtual buffer and
copying the visible viewport based on a scroll offset.

The approach: create an oversized Buffer::empty(), render the full
child widget tree into it via the existing render_node pipeline, then
copy the visible rows (based on scroll offset and viewport height)
into the real frame buffer. This reuses all existing rendering
infrastructure — render_node is already buffer-agnostic.

API: tui.scroll_view(child: widget, scroll: [y, x], content_height: n)

The content_height parameter tells the widget how tall the virtual
buffer needs to be. The caller computes this from their data model
(e.g. number of feed items * lines per item). When scroll is [0,0]
and content fits in the viewport, it renders directly without the
virtual buffer allocation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants