Class: Tuile::Buffer
- Inherits:
-
Object
- Object
- Tuile::Buffer
- Defined in:
- lib/tuile/buffer.rb,
sig/tuile.rbs
Overview
An in-memory grid of styled cells mirroring the terminal screen. This is the back buffer behind flicker-free rendering: components paint into it (via #set_text / #set_char / #fill) instead of writing escape sequences straight to the terminal, and #flush emits the minimal escape string needed to bring a terminal — one that already matches the buffer's state as of the previous flush — up to date. Only cells that actually changed are emitted, so nothing flickers regardless of terminal/multiplexer synchronized-output support.
Coordinates are 0-based (x, y) = (column, row), matching
Component#rect and TTY::Cursor.move_to.
Dirty tracking
Every mutator compares the incoming grapheme+style against what's already there and records the cell dirty only when it differs — so both mutation and #flush cost scale with what actually changed, never with the buffer size. There is deliberately no per-frame whole-buffer clear or copy; un-touched cells retain the previous frame's value.
Cells are mutable and pre-allocated — the grid builds its Cells once (at construction and #resize) and rewrites them in place, so a normal paint allocates nothing per cell. That's why Cell is a plain mutable object, not a frozen value type.
Wide characters
A 2-column glyph (fullwidth CJK, most emoji) occupies its origin cell plus a continuation cell to its right (an empty-grapheme Cell the flush emits nothing for, since the glyph itself advances the cursor two columns). Overwriting either half of a wide glyph blanks the orphaned half, so the grid never holds a dangling continuation or a headless one.
Defined Under Namespace
Classes: Cell
Constant Summary collapse
- DEFAULT_STYLE =
Returns the unstyled default.
StyledString::Style::DEFAULT
- WIDTH_CACHE =
Memo for display_width: a grapheme's display width is fixed, and a TTY paints from a small, recurring alphabet (ASCII, box-drawing rules, a few emoji), so the per-grapheme width lookup — the dominant cost of a repaint (see
benchmark/display_width.rb) — collapses to a Hash read after the first sighting. Shared across all buffers and unbounded, but bounded in practice by the font's glyph set.The memo carries its weight most for emoji: resolving a sequence under StyledString::EMOJI_WIDTH costs ~20x a plain lookup, and this pays it once per distinct cluster. Racing writes from a non-UI thread are benign rather than merely absent — the value for a grapheme is deterministic, so a lost write only costs a recomputation.
Hash.new { |h, g| h[g] = Unicode::DisplayWidth.of(g, emoji: StyledString::EMOJI_WIDTH) }
- StyledString =
Instance Attribute Summary collapse
-
#color_depth ⇒ Symbol
readonly
What the terminal can show (ColorDepth::DEPTHS).
- #height ⇒ Integer readonly
- #width ⇒ Integer readonly
Class Method Summary collapse
-
.display_width(grapheme) ⇒ Integer
Memoized Unicode::DisplayWidth.of.
Instance Method Summary collapse
-
#allocate_grid(size) ⇒ void
(Re)allocates a blank grid of
sizewith clean dirty state. -
#blank_left_partner(x, y) ⇒ void
If
(x, y)holds a continuation, blanks the head of the glyph it belongs to and every continuation up to — but not including —x. -
#blank_right_partner(x, y) ⇒ void
Blanks the run of continuations immediately right of
(x, y)— the tail of a glyph whose head is at or beforex, and which the write landing onxis about to decapitate. -
#cell(x, y) ⇒ Cell?
@param
x— column. -
#clear(style = DEFAULT_STYLE) ⇒ void
Blanks the entire buffer in
style. -
#dirty? ⇒ Boolean
@return — true if any cell has changed since the last #flush.
-
#fill(rect, style = DEFAULT_STYLE) ⇒ void
Fills the intersection of
rectand the buffer with blank cells instyle— the cell-grid equivalent of clearing a background. -
#flush ⇒ String
Emits the minimal escape sequence that updates a terminal — already matching this buffer as of the previous flush — to the current contents, then clears the dirty flags.
-
#flush_row(out, y, style) ⇒ StyledString::Style
Emits the dirty cells of row
yintoout, breaking a run at each clean cell, and returns the running style at the end of the row. -
#in_bounds?(x, y) ⇒ Boolean
@param
x— column. -
#index(x, y) ⇒ Integer
@param
x— column. -
#initialize(size, color_depth: :truecolor) ⇒ Buffer
constructor
@param
size— grid dimensions in columns × rows. -
#mark_all_dirty ⇒ void
Marks every cell dirty, so the next #flush re-emits the whole grid.
-
#put_char(x, y, grapheme, w, style) ⇒ Object
Core of #set_char with the grapheme's display width already known.
-
#quantized_style(style) ⇒ StyledString::Style
styleas #color_depth can actually show it, each color through Color#quantize. -
#region_ansi(rect) ⇒ ::Array[String]
@param
rect. -
#region_cells(rect) ⇒ ::Array[::Array[Cell]]
@param
rect. -
#region_text(rect) ⇒ ::Array[String]
@param
rect. -
#resize(size) ⇒ void
Resizes the grid to
size, reallocating blank cells and marking the whole buffer dirty — after a resize the terminal contents are undefined, so the next flush redraws from scratch. -
#row_ansi(y) ⇒ String
@param
y— row. -
#row_text(y) ⇒ String
@param
y— row. -
#set_char(x, y, grapheme, style = DEFAULT_STYLE) ⇒ Object
Writes one grapheme cluster at
(x, y). -
#set_text(x, y, styled) ⇒ void
Writes a StyledString starting at
(x, y), advancing by each grapheme's display width and clipping at the right edge. -
#size ⇒ Size
@return — grid dimensions.
-
#write_cell(x, y, grapheme, style) ⇒ Object
Rewrites the cell at
(x, y)in place, marking it (and its row) dirty only when grapheme or style actually changes.
Constructor Details
#initialize(size, color_depth: :truecolor) ⇒ Buffer
@param size — grid dimensions in columns × rows.
@param color_depth — what the terminal can show — one of ColorDepth::DEPTHS; #flush degrades every emitted color to it. Validated here rather than at paint time: a bad value would otherwise surface as an exception mid-frame, far from the mistake.
124 125 126 127 128 129 130 131 132 133 134 |
# File 'lib/tuile/buffer.rb', line 124 def initialize(size, color_depth: :truecolor) raise ArgumentError, "invalid color depth: #{color_depth.inspect}" unless ColorDepth::DEPTHS.include?(color_depth) @color_depth = color_depth allocate_grid(size) # A fresh buffer never matches the terminal yet — the screen holds # whatever was there at startup — so it begins fully dirty and the first # flush paints the whole grid (gaps included). Same reasoning as {#resize}. mark_all_dirty end |
Instance Attribute Details
#color_depth ⇒ Symbol (readonly)
What the terminal can show (ColorDepth::DEPTHS). Cells hold whatever color a component painted — #region_ansi and friends report that, unchanged — and only #flush degrades it on the way to the wire.
146 147 148 |
# File 'lib/tuile/buffer.rb', line 146 def color_depth @color_depth end |
#height ⇒ Integer (readonly)
140 141 142 |
# File 'lib/tuile/buffer.rb', line 140 def height @height end |
#width ⇒ Integer (readonly)
140 141 142 |
# File 'lib/tuile/buffer.rb', line 140 def width @width end |
Class Method Details
.display_width(grapheme) ⇒ Integer
Memoized Unicode::DisplayWidth.of. Use this for every paint-path width lookup instead of calling the gem directly.
@param grapheme — one grapheme cluster.
@return — its display width in columns (0 for combining marks).
116 |
# File 'lib/tuile/buffer.rb', line 116 def self.display_width(grapheme) = WIDTH_CACHE[grapheme] |
Instance Method Details
#allocate_grid(size) ⇒ void
This method returns an undefined value.
(Re)allocates a blank grid of size with clean dirty state. Callers
follow with #mark_all_dirty when the terminal doesn't match the new
grid — construction and #resize both do.
@param size
376 377 378 379 380 381 382 383 384 |
# File 'lib/tuile/buffer.rb', line 376 def allocate_grid(size) raise TypeError, "expected Size, got #{size.inspect}" unless size.is_a?(Size) @width = size.width @height = size.height @cells = Array.new(@width * @height) { Cell.new(" ", DEFAULT_STYLE) } @dirty_rows = Array.new(@height, false) @any_dirty = false end |
#blank_left_partner(x, y) ⇒ void
This method returns an undefined value.
If (x, y) holds a continuation, blanks the head of the glyph it belongs to
and every continuation up to — but not including — x. Called before a
write lands on x, so the glyph reaching into x isn't left headless.
Walks left rather than assuming the head sits at x - 1: a glyph may be
wider than two columns, so its tail can run several cells.
@param x — column
@param y — row
500 501 502 503 504 505 506 507 508 509 510 511 512 |
# File 'lib/tuile/buffer.rb', line 500 def blank_left_partner(x, y) return unless in_bounds?(x, y) && @cells[index(x, y)].continuation? head = x - 1 head -= 1 while in_bounds?(head, y) && @cells[index(head, y)].continuation? return unless in_bounds?(head, y) cx = head while cx < x write_cell(cx, y, " ", DEFAULT_STYLE) cx += 1 end end |
#blank_right_partner(x, y) ⇒ void
This method returns an undefined value.
Blanks the run of continuations immediately right of (x, y) — the tail of
a glyph whose head is at or before x, and which the write landing on x
is about to decapitate. A continuation always belongs to the nearest glyph
on its left, so the empty-grapheme test is exact — and cheaper than
re-measuring that glyph's width.
@param x — column
@param y — row
522 523 524 525 526 527 528 |
# File 'lib/tuile/buffer.rb', line 522 def blank_right_partner(x, y) cx = x + 1 while in_bounds?(cx, y) && @cells[index(cx, y)].continuation? write_cell(cx, y, " ", DEFAULT_STYLE) cx += 1 end end |
#cell(x, y) ⇒ Cell?
153 154 155 156 157 |
# File 'lib/tuile/buffer.rb', line 153 def cell(x, y) return nil unless in_bounds?(x, y) @cells[index(x, y)] end |
#clear(style = DEFAULT_STYLE) ⇒ void
This method returns an undefined value.
Blanks the entire buffer in style. A flat pass over every cell — no
rect math or nested loops, since it covers the whole grid. Only cells
that actually change are marked dirty (and their rows), so a #flush
after clearing an already-blank buffer emits nothing.
@param style
227 228 229 230 231 232 233 234 |
# File 'lib/tuile/buffer.rb', line 227 def clear(style = DEFAULT_STYLE) @cells.each_with_index do |c, i| next unless c.set(" ", style) @dirty_rows[i / @width] = true @any_dirty = true end end |
#dirty? ⇒ Boolean
@return — true if any cell has changed since the last #flush.
160 |
# File 'lib/tuile/buffer.rb', line 160 def dirty? = @any_dirty |
#fill(rect, style = DEFAULT_STYLE) ⇒ void
This method returns an undefined value.
Fills the intersection of rect and the buffer with blank cells in
style — the cell-grid equivalent of clearing a background. Only bg
shows; the grapheme is a space.
@param rect
@param style
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 |
# File 'lib/tuile/buffer.rb', line 205 def fill(rect, style = DEFAULT_STYLE) top = [rect.top, 0].max bottom = [rect.top + rect.height, @height].min left = [rect.left, 0].max right = [rect.left + rect.width, @width].min y = top while y < bottom x = left while x < right write_cell(x, y, " ", style) x += 1 end y += 1 end end |
#flush ⇒ String
Emits the minimal escape sequence that updates a terminal — already
matching this buffer as of the previous flush — to the current contents,
then clears the dirty flags. Returns "" when nothing changed.
Scans only dirty rows; within a row, consecutive dirty cells form one run
(one TTY::Cursor.move_to followed by their graphemes), with a running
StyledString::Style#sgr_to diff so only changed attributes are sent
(continuation cells emit nothing). The sequence always ends in the default
style (Ansi::RESET when needed), the invariant the next flush relies on:
the terminal's SGR state is default at flush boundaries.
@return — the escape sequence to write to the terminal.
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 |
# File 'lib/tuile/buffer.rb', line 267 def flush return "" unless @any_dirty out = +"" style = DEFAULT_STYLE y = 0 while y < @height if @dirty_rows[y] @dirty_rows[y] = false style = flush_row(out, y, style) end y += 1 end out << Ansi::RESET unless style.default? @any_dirty = false out end |
#flush_row(out, y, style) ⇒ StyledString::Style
Emits the dirty cells of row y into out, breaking a run at each clean
cell, and returns the running style at the end of the row.
@param out — accumulator.
@param y
@param style — style the terminal currently holds.
392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 |
# File 'lib/tuile/buffer.rb', line 392 def flush_row(out, y, style) base = y * @width run_open = false x = 0 while x < @width c = @cells[base + x] if c.dirty c.dirty = false # A continuation cell (right half of a wide glyph) renders nothing of # its own and must never open a run: positioning the cursor onto its # column would land the next glyph on the wide glyph's left half, # corrupting it. When the wide glyph itself is dirty it is emitted from # its origin cell and advances the cursor across this column; when the # glyph is intact this column needs no output at all. (A continuation # can be left spuriously dirty by an in-place wide-glyph repaint, so we # can't assume its origin was emitted in this same run.) unless c.continuation? unless run_open out << TTY::Cursor.move_to(x, y) run_open = true end shown = quantized_style(c.style) out << style.sgr_to(shown) << c.grapheme style = shown end else run_open = false end x += 1 end style end |
#in_bounds?(x, y) ⇒ Boolean
@param x — column
@param y — row
@return — true when (x, y) falls within the grid.
475 |
# File 'lib/tuile/buffer.rb', line 475 def in_bounds?(x, y) = x >= 0 && x < @width && y >= 0 && y < @height |
#index(x, y) ⇒ Integer
@param x — column
@param y — row
@return — flat-array index for (x, y).
470 |
# File 'lib/tuile/buffer.rb', line 470 def index(x, y) = (y * @width) + x |
#mark_all_dirty ⇒ void
This method returns an undefined value.
Marks every cell dirty, so the next #flush re-emits the whole grid. Used after a resize and whenever the terminal contents become unknown (e.g. the screen was cleared underneath us).
240 241 242 243 244 |
# File 'lib/tuile/buffer.rb', line 240 def mark_all_dirty @cells.each { |c| c.dirty = true } @dirty_rows.fill(true) @any_dirty = true end |
#put_char(x, y, grapheme, w, style) ⇒ Object
Core of #set_char with the grapheme's display width already known. #set_text computes each width once while advancing the column and passes it straight through, so the paint hot path measures every grapheme exactly once (and that once is a display_width memo read). See #set_char for the wide-glyph / clipping / out-of-bounds contract.
@param x — column.
@param y — row.
@param grapheme — one grapheme cluster.
@param w — grapheme's display width (0, 1, or 2).
@param style
344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 |
# File 'lib/tuile/buffer.rb', line 344 def put_char(x, y, grapheme, w, style) return unless in_bounds?(x, y) return if w <= 0 if w > 1 && !in_bounds?(x + w - 1, y) blank_left_partner(x, y) return write_cell(x, y, " ", style) end # Repair only the glyphs we'd leave half-overwritten on our flanks: one # whose tail reaches `x`, or one whose head sits at the last cell we write. # The cells we fully rewrite need no pre-blanking — pre-blanking a # continuation only to re-empty it would churn it spuriously dirty, which # misplaces the next flush onto the glyph's right half (see bug/, balloon # corruption). blank_left_partner(x, y) blank_right_partner(x + w - 1, y) write_cell(x, y, grapheme, style) # A while loop, not (1...w).each: this runs once per painted cell, and a # Range allocation per cell is 8000 per full-screen repaint. i = 1 while i < w write_cell(x + i, y, "", style) i += 1 end end |
#quantized_style(style) ⇒ StyledString::Style
style as #color_depth can actually show it, each color through
Color#quantize. Applied before the StyledString::Style#sgr_to
diff, so two RGBs that quantize onto the same cell emit nothing at all
rather than a redundant SGR.
The one-slot memo is load-bearing, not a micro-optimization: this runs
per dirty cell, while a painted run shares one frozen
StyledString::Style instance, so remembering just the last answer
collapses the work onto actual style transitions. Without it a
full-screen repaint of RGB-styled content measured 51 ms against 15 ms
at :truecolor — a keyed cache is still the wrong answer
(D_color_depth), but paying the arithmetic 8000 times for one span
was too.
@param style
@return — style itself whenever nothing needed
degrading — Color#quantize's identity contract, extended.
442 443 444 445 446 447 448 449 450 451 452 453 454 455 |
# File 'lib/tuile/buffer.rb', line 442 def quantized_style(style) return style if @color_depth == :truecolor return @quantized_style if style.equal?(@quantized_source) fg = style.fg&.quantize(@color_depth) bg = style.bg&.quantize(@color_depth) @quantized_source = style @quantized_style = if fg.equal?(style.fg) && bg.equal?(style.bg) style else style.merge(fg: fg, bg: bg) end end |
#region_ansi(rect) ⇒ ::Array[String]
@param rect
@return — each row within rect rendered to ANSI, top to
bottom — byte-identical to what a component's per-row set_text over
that rect emitted. The region equivalent of #row_ansi. Intended for
tests asserting styled output.
325 326 327 328 329 |
# File 'lib/tuile/buffer.rb', line 325 def region_ansi(rect) region_cells(rect).map do |row| StyledString.new(row.map { |c| StyledString::Span.new(text: c.grapheme, style: c.style) }).to_ansi end end |
#region_cells(rect) ⇒ ::Array[::Array[Cell]]
@param rect
@return — cells within rect, row-major, clamped to
the grid (out-of-bounds positions yield a blank cell).
460 461 462 463 464 465 |
# File 'lib/tuile/buffer.rb', line 460 def region_cells(rect) blank = Cell.new(" ", DEFAULT_STYLE) (rect.top...(rect.top + rect.height)).map do |y| (rect.left...(rect.left + rect.width)).map { |x| cell(x, y) || blank } end end |
#region_text(rect) ⇒ ::Array[String]
@param rect
@return — the plain text of each row within rect's column
range, top to bottom. The region equivalent of #row_text, for asserting
what a component painted into its own rect. Intended for tests.
316 317 318 |
# File 'lib/tuile/buffer.rb', line 316 def region_text(rect) region_cells(rect).map { |row| row.map(&:grapheme).join } end |
#resize(size) ⇒ void
This method returns an undefined value.
Resizes the grid to size, reallocating blank cells and marking the
whole buffer dirty — after a resize the terminal contents are undefined,
so the next flush redraws from scratch.
@param size
251 252 253 254 |
# File 'lib/tuile/buffer.rb', line 251 def resize(size) allocate_grid(size) mark_all_dirty end |
#row_ansi(y) ⇒ String
@param y — row.
@return — row y rendered to ANSI across its full width — the
minimal-SGR encoding of its cells, equivalent to what a component's
set_text of the whole row would have printed. Intended for tests that
assert on styled output (see FakeScreen); empty for an out-of-range row.
301 302 303 304 305 306 307 308 309 310 |
# File 'lib/tuile/buffer.rb', line 301 def row_ansi(y) return "" unless y >= 0 && y < @height base = y * @width spans = (0...@width).map do |x| c = @cells[base + x] StyledString::Span.new(text: c.grapheme, style: c.style) end StyledString.new(spans).to_ansi end |
#row_text(y) ⇒ String
@param y — row.
@return — the plain text of row y (continuation cells contribute
nothing, so wide glyphs read as their single cluster). Intended for
tests; see FakeScreen.
289 290 291 292 293 294 |
# File 'lib/tuile/buffer.rb', line 289 def row_text(y) return "" unless y >= 0 && y < @height base = y * @width (0...@width).map { |x| @cells[base + x].grapheme }.join end |
#set_char(x, y, grapheme, style = DEFAULT_STYLE) ⇒ Object
Writes one grapheme cluster at (x, y). A 2-column glyph also writes a
continuation cell at (x + 1, y); a wide glyph that would overflow the
last column is replaced by a blank (terminals can't render a half-clipped
wide glyph). Zero-width input (a lone combining mark) is ignored — it has
no cell of its own. Out-of-bounds writes are dropped.
@param x — column.
@param y — row.
@param grapheme — one grapheme cluster.
@param style
172 173 174 |
# File 'lib/tuile/buffer.rb', line 172 def set_char(x, y, grapheme, style = DEFAULT_STYLE) put_char(x, y, grapheme, Buffer.display_width(grapheme), style) end |
#set_text(x, y, styled) ⇒ void
This method returns an undefined value.
Writes a StyledString starting at (x, y), advancing by each grapheme's
display width and clipping at the right edge. Newlines are not handled —
pass the text of one row.
@param x — starting column.
@param y — row.
@param styled
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/tuile/buffer.rb', line 183 def set_text(x, y, styled) col = x styled.spans.each do |span| span.text.grapheme_clusters.each do |g| w = Buffer.display_width(g) next if w <= 0 # combining mark with no base in this run: skip break if col >= @width # rest of the line is clipped # Reuse the width we just computed: put_char skips re-measuring `g`. put_char(col, y, g, w, span.style) col += w end end end |
#size ⇒ Size
@return — grid dimensions.
137 |
# File 'lib/tuile/buffer.rb', line 137 def size = Size.new(@width, @height) |
#write_cell(x, y, grapheme, style) ⇒ Object
Rewrites the cell at (x, y) in place, marking it (and its row) dirty
only when grapheme or style actually changes. Caller guarantees (x, y)
is in bounds.
@param x — column
@param y — row
@param grapheme — the new grapheme cluster
@param style — the new style
485 486 487 488 489 490 |
# File 'lib/tuile/buffer.rb', line 485 def write_cell(x, y, grapheme, style) return unless @cells[index(x, y)].set(grapheme, style) @dirty_rows[y] = true @any_dirty = true end |