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

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) ⇒ Buffer

@param size — grid dimensions in columns × rows.

Parameters:



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

#heightInteger (readonly)

Returns:

  • (Integer)


131
132
133
# File 'lib/tuile/buffer.rb', line 131

def height
  @height
end

#widthInteger (readonly)

Returns:

  • (Integer)


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

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:



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

Parameters:

  • x (Integer)
  • y (Integer)


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

Parameters:

  • x (Integer)
  • y (Integer)


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?

@param x — column.

@param y — row.

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

Parameters:

  • x (Integer)
  • y (Integer)

Returns:



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

Parameters:



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.

Returns:

  • (Boolean)


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

Parameters:



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

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


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.

Parameters:

Returns:



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.

Parameters:

  • x (Integer)
  • y (Integer)

Returns:

  • (Boolean)


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

Parameters:

  • x (Integer)
  • y (Integer)

Returns:

  • (Integer)


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

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



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

Parameters:

Returns:

  • (::Array[String])


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

Parameters:

Returns:

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

Parameters:

Returns:

  • (::Array[String])


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

Parameters:



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.

Parameters:

  • y (Integer)

Returns:

  • (String)


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.

Parameters:

  • y (Integer)

Returns:

  • (String)


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

Parameters:



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

#sizeSize

@return — grid dimensions.

Returns:



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