Class: Tuile::Buffer

Inherits:
Object
  • Object
show all
Defined in:
lib/tuile/buffer.rb

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. See ‘ideas/back-buffer.md`.

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.

The bookkeeping avoids hashing and full-grid scans: a dirty flag **on each cell** (O(1) set, no ‘Set` bucket math, no separate array), a per-row boolean so #flush scans only the rows that changed, and one global flag so #dirty? and the “nothing changed” early-out are O(1). #flush clears every flag it consumes.

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 is why Cell is a plain mutable object rather than a frozen value type. The empty state of a cell is a space in the default style.

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

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(size) ⇒ Buffer

Returns a new instance of Buffer.

Parameters:

  • size (Size)

    grid dimensions in columns × rows.



104
105
106
107
108
109
110
# File 'lib/tuile/buffer.rb', line 104

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)


116
117
118
# File 'lib/tuile/buffer.rb', line 116

def height
  @height
end

#widthInteger (readonly)

Returns:

  • (Integer)


116
117
118
# File 'lib/tuile/buffer.rb', line 116

def width
  @width
end

Instance Method Details

#cell(x, y) ⇒ Cell?

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

    column.

  • y (Integer)

    row.

Returns:

  • (Cell, nil)

    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.



123
124
125
126
127
# File 'lib/tuile/buffer.rb', line 123

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.

Parameters:



210
211
212
213
214
215
216
217
# File 'lib/tuile/buffer.rb', line 210

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

Returns true if any cell has changed since the last #flush.

Returns:

  • (Boolean)

    true if any cell has changed since the last #flush.



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

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.

Parameters:



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/tuile/buffer.rb', line 188

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.

Returns:

  • (String)

    the escape sequence to write to the terminal.



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/tuile/buffer.rb', line 250

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

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



223
224
225
226
227
# File 'lib/tuile/buffer.rb', line 223

def mark_all_dirty
  @cells.each { |c| c.dirty = true }
  @dirty_rows.fill(true)
  @any_dirty = true
end

#region_ansi(rect) ⇒ Array<String>

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

    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.



308
309
310
311
312
# File 'lib/tuile/buffer.rb', line 308

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_text(rect) ⇒ Array<String>

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

    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.



299
300
301
# File 'lib/tuile/buffer.rb', line 299

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.

Parameters:



234
235
236
237
# File 'lib/tuile/buffer.rb', line 234

def resize(size)
  allocate_grid(size)
  mark_all_dirty
end

#row_ansi(y) ⇒ String

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

    row.

Returns:

  • (String)

    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.



284
285
286
287
288
289
290
291
292
293
# File 'lib/tuile/buffer.rb', line 284

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

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

    row.

Returns:

  • (String)

    the plain text of row ‘y` (continuation cells contribute nothing, so wide glyphs read as their single cluster). Intended for tests; see FakeScreen.



272
273
274
275
276
277
# File 'lib/tuile/buffer.rb', line 272

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

This method returns an undefined value.

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.

Parameters:

  • x (Integer)

    column.

  • y (Integer)

    row.

  • grapheme (String)

    one grapheme cluster.

  • style (StyledString::Style) (defaults to: DEFAULT_STYLE)


142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/tuile/buffer.rb', line 142

def set_char(x, y, grapheme, style = DEFAULT_STYLE)
  return unless in_bounds?(x, y)

  w = Unicode::DisplayWidth.of(grapheme)
  return if w <= 0

  if w == 2 && !in_bounds?(x + 1, y)
    repair_orphans(x, y)
    return write_cell(x, y, " ", style)
  end

  repair_orphans(x, y)
  repair_orphans(x + 1, y) if w == 2
  write_cell(x, y, grapheme, style)
  write_cell(x + 1, y, "", style) if w == 2
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. The workhorse that replaces the old ‘screen.print(TTY::Cursor.move_to(x, y), styled.to_ansi)` per-row paint. Newlines in the string are not handled — pass one physical line.

Parameters:

  • x (Integer)

    starting column.

  • y (Integer)

    row.

  • styled (StyledString)


167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/tuile/buffer.rb', line 167

def set_line(x, y, styled)
  col = x
  styled.spans.each do |span|
    span.text.grapheme_clusters.each do |g|
      w = Unicode::DisplayWidth.of(g)
      next if w <= 0 # combining mark with no base in this run: skip

      break if col >= @width # rest of the line is clipped

      set_char(col, y, g, span.style)
      col += w
    end
  end
end

#sizeSize

Returns grid dimensions.

Returns:

  • (Size)

    grid dimensions.



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

def size = Size.new(@width, @height)