Class: Tuile::Buffer

Inherits:
Object
  • Object
show all
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.

Returns:

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.

Returns:

  • (Hash{String => Integer})
Hash.new { |h, g| h[g] = Unicode::DisplayWidth.of(g, emoji: StyledString::EMOJI_WIDTH) }
StyledString =

Returns:

  • (:Style style)

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

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.

Parameters:

  • size (Size)
  • color_depth: (Symbol) (defaults to: :truecolor)


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_depthSymbol (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.

Returns:

  • (Symbol)


146
147
148
# File 'lib/tuile/buffer.rb', line 146

def color_depth
  @color_depth
end

#heightInteger (readonly)

Returns:

  • (Integer)


140
141
142
# File 'lib/tuile/buffer.rb', line 140

def height
  @height
end

#widthInteger (readonly)

Returns:

  • (Integer)


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).

Parameters:

  • grapheme (String)

Returns:

  • (Integer)


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

Parameters:



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

Parameters:

  • x (Integer)
  • y (Integer)


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

Parameters:

  • x (Integer)
  • y (Integer)


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?

@param x — column.

@param y — row.

@return — the live cell at (x, y) (do not mutate — paint via #set_char / #set_text so dirty tracking stays correct), or nil when out of bounds.

Parameters:

  • x (Integer)
  • y (Integer)

Returns:



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

Parameters:



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.

Returns:

  • (Boolean)


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

Parameters:



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

#flushString

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.

Returns:

  • (String)


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.

Parameters:

Returns:



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.

Parameters:

  • x (Integer)
  • y (Integer)

Returns:

  • (Boolean)


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).

Parameters:

  • x (Integer)
  • y (Integer)

Returns:

  • (Integer)


470
# File 'lib/tuile/buffer.rb', line 470

def index(x, y) = (y * @width) + x

#mark_all_dirtyvoid

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 wgrapheme'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

@returnstyle itself whenever nothing needed degrading — Color#quantize's identity contract, extended.

Parameters:

Returns:



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.

Parameters:

Returns:

  • (::Array[String])


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).

Parameters:

Returns:

  • (::Array[::Array[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.

Parameters:

Returns:

  • (::Array[String])


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

Parameters:



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.

Parameters:

  • y (Integer)

Returns:

  • (String)


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.

Parameters:

  • y (Integer)

Returns:

  • (String)


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

Parameters:



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

#sizeSize

@return — grid dimensions.

Returns:



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