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_line / #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
- #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) ⇒ 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.
-
#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_line(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) ⇒ Buffer
@param size — grid dimensions in columns × rows.
119 120 121 122 123 124 125 |
# File 'lib/tuile/buffer.rb', line 119 def initialize(size) 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
#height ⇒ Integer (readonly)
131 132 133 |
# File 'lib/tuile/buffer.rb', line 131 def height @height end |
#width ⇒ Integer (readonly)
131 132 133 |
# File 'lib/tuile/buffer.rb', line 131 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
361 362 363 364 365 366 367 368 369 |
# File 'lib/tuile/buffer.rb', line 361 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
452 453 454 455 456 457 458 459 460 461 462 463 464 |
# File 'lib/tuile/buffer.rb', line 452 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
474 475 476 477 478 479 480 |
# File 'lib/tuile/buffer.rb', line 474 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?
138 139 140 141 142 |
# File 'lib/tuile/buffer.rb', line 138 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
212 213 214 215 216 217 218 219 |
# File 'lib/tuile/buffer.rb', line 212 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.
145 |
# File 'lib/tuile/buffer.rb', line 145 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
190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/tuile/buffer.rb', line 190 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.
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/tuile/buffer.rb', line 252 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.
377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 |
# File 'lib/tuile/buffer.rb', line 377 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 out << style.sgr_to(c.style) << c.grapheme style = c.style 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.
427 |
# File 'lib/tuile/buffer.rb', line 427 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).
422 |
# File 'lib/tuile/buffer.rb', line 422 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).
225 226 227 228 229 |
# File 'lib/tuile/buffer.rb', line 225 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_line 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
329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 |
# File 'lib/tuile/buffer.rb', line 329 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 |
#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_line over
that rect emitted. The region equivalent of #row_ansi. Intended for
tests asserting styled output.
310 311 312 313 314 |
# File 'lib/tuile/buffer.rb', line 310 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).
412 413 414 415 416 417 |
# File 'lib/tuile/buffer.rb', line 412 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.
301 302 303 |
# File 'lib/tuile/buffer.rb', line 301 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
236 237 238 239 |
# File 'lib/tuile/buffer.rb', line 236 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_line of the whole row would have printed. Intended for tests that
assert on styled output (see FakeScreen); empty for an out-of-range row.
286 287 288 289 290 291 292 293 294 295 |
# File 'lib/tuile/buffer.rb', line 286 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.
274 275 276 277 278 279 |
# File 'lib/tuile/buffer.rb', line 274 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
157 158 159 |
# File 'lib/tuile/buffer.rb', line 157 def set_char(x, y, grapheme, style = DEFAULT_STYLE) put_char(x, y, grapheme, Buffer.display_width(grapheme), style) end |
#set_line(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 one physical line.
@param x — starting column.
@param y — row.
@param styled
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/tuile/buffer.rb', line 168 def set_line(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.
128 |
# File 'lib/tuile/buffer.rb', line 128 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
437 438 439 440 441 442 |
# File 'lib/tuile/buffer.rb', line 437 def write_cell(x, y, grapheme, style) return unless @cells[index(x, y)].set(grapheme, style) @dirty_rows[y] = true @any_dirty = true end |