class

Phosphor::Renderer

Inherits Reference < Object

Computes frame-to-frame diffs and writes only the changed cells to the terminal.

Maintains @last_buffer across calls. On the first draw, and on any draw right after #invalidate (screen transitions, resize), there's no previous buffer to diff against — every cell is written unconditionally via Buffer#full_redraw instead, so cells the new frame leaves at their own default don't leave stale content from whatever was on the real terminal before.

Constants

FPS_SAMPLE_SIZE = 30

How many recent #draw calls #fps averages over. 30 is enough to smooth out one-off frame hitches without lagging far behind the actual current rate.

Log = ::Log.for("phosphor.renderer")

Constructors

Instance methods

bg_style

The theme's background color as an explicit Style, recomputed on every #draw call from theme's :background style. Exposed so painting that happens outside the normal #drawScreen#render path — View#transition_widget, which runs between real screen renders and has no Theme of its own — can fill a region with the same background color a real draw would use.

fg is always Color::Default, never the background color itself — Buffer#set_string merges a written Style over whatever the target cell already carries, so a widget that paints text with no explicit fg of its own would inherit this fill's fg underneath it. Setting fg to match bg would make that inherited text invisible against its own background; Color::Default keeps it readable while bg still overrides the terminal's own default background. Same tradeoff ConfirmMock::Props#bg_style already makes.

Source
draw(screen : Screen(S, M), state : S, terminal : Terminal, dark_background : Bool = true, color_profile : ColorProfile = ColorProfile::TrueColor, theme : Theme = Theme.new, debug_overlay : Proc(Buffer, Frame, Void) | Nil = nil, min_interval : Time::Span = Time::Span.zero) : Void forall S, M

Renders one frame: allocates a fresh Buffer, asks screen to fill it, diffs against the previous frame, and writes only changed cells to terminal.

Cell::CONTINUATION slots are skipped in the write loop — the wide character's primary cluster already occupies two terminal columns when written.

dark_background and color_profile are forwarded to the new Buffer so set_string can resolve AdaptiveColor and downsample colors a widget sets — default to true/TrueColor, matching App's own stubbed defaults.

theme's :background style fills every cell in frame before screen renders into it, so an untouched cell (an empty area below a short list, a status bar that doesn't span the full width) carries an explicit background rather than whatever a terminal emulator defaults to. Defaults to Theme.new, whose :background is Color::Default — i.e. no explicit color, matching the pre-existing behavior for callers that don't pass a theme. Also cached on #bg_style (with both fg and bg set explicitly) for callers painting outside this method entirely — see #bg_style's own doc comment.

debug_overlay, if given, is called with the same buf and frame right after screen renders into it — so its cells paint over the screen's own content and are included in the diff written to terminal, the same as any other content. Used by App for the App.run(debug: true) overlay (Phase 20); nil in every other case.

#stats is recomputed at the end of every call, unconditionally — this happens regardless of App.run(stats: true), which only gates whether the debug overlay displays the numbers. Collection has to stay unconditional or the very act of turning the overlay on would change the timings it's trying to show.

min_interval, if given and nonzero, caps draw frequency: a call arriving less than min_interval after the previous completed draw returns immediately without rendering anything (no Buffer allocation, no diff, no write — and #fps/#stats are left exactly as the last real draw left them, since a skipped call isn't a frame). App.run(max_fps: ...) computes this from 1.second / max_fps and passes it on every call; defaults to Time::Span.zero (never skips) for direct callers — App#print, App#inline_resize, and specs — that don't go through the main loop's cap.

Generic (forall S, M) rather than declared on a Renderer(S, M) class — Renderer itself carries no app-state-shaped data, only #draw needs to know S/M, and a forall method lets one Renderer instance draw screens for any S/M rather than binding it at construction time.

Source
draw_frame(buf : Buffer, frame : Frame, terminal : Terminal) : Void

Writes buf to terminal as a full redraw — the flush primitive View#transition_widget uses to paint each intermediate transition step live, rather than only ever showing the widget's final state.

Invalidates before writing (not after) so this call's own write is itself a full redraw, not a diff against whatever @last_buffer held coming in (the last real screen render, or the previous transition step) — successive transition steps aren't meaningfully diffable against each other the way ordinary re-renders are. This mirrors App#play_transition's own renderer.invalidate placement exactly; see that method's doc comment for the same reasoning applied to whole-screen transitions.

frame isn't used yet — accepted for a future sub-region write. For now writing all of buf is correct: the caller already composited this transition step into the right region of buf before calling this.

Source
fps

Frames rendered per second, as a rolling average over the last FPS_SAMPLE_SIZE #draw calls. 0.0 until at least two draws have happened — one timestamp alone has no interval to measure from.

Source
invalidate

Discards the cached previous frame, so the next #draw call treats every cell as changed and repaints the whole frame from scratch.

Needed after something moves the frame's absolute position outside of #draw itself — e.g. App#print scrolling an Inline block down a row. #draw only diffs by cell content, so with unchanged content it can't tell the target position moved and would write nothing.

Source
stats

The most recent #draw call's cost — cells changed, bytes written, and rolling average draw time. Collection is unconditional (see the doc comment above #draw); App.run(stats: true) only controls whether the debug overlay displays this, not whether it's tracked.

Source
write_buffer(buf : Buffer, terminal : Terminal, draw_start : FrameTimestamp = now) : Void

Diffs buf against @last_buffer (or does a full redraw if there is none) and writes only the changed cells to terminal — the second half of #draw, split out so a already-painted Buffer that didn't come from a Screen#render call can go straight to the terminal the same way. Transition playback in App#push_screen is the other caller: each transition frame is a Buffer a Transition's own Proc paints directly, with no Screen#render step to call.

draw_start backdates #fps/#stats#avg_draw_time_ms tracking to when the caller actually started this frame (#draw passes its own draw_start, from before screen.render ran); defaults to #now for callers with no earlier timestamp of their own.

Source