Class: Tuile::Buffer
- Inherits:
-
Object
- Object
- Tuile::Buffer
- 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
- #height ⇒ Integer readonly
- #width ⇒ Integer readonly
Instance Method Summary collapse
- #cell(x, y) ⇒ Cell?
-
#clear(style = DEFAULT_STYLE) ⇒ void
Blanks the entire buffer in ‘style`.
-
#dirty? ⇒ Boolean
True if any cell has changed since the last #flush.
-
#fill(rect, style = DEFAULT_STYLE) ⇒ void
Fills the intersection of ‘rect` and the buffer with blank cells in `style` — 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.
-
#initialize(size) ⇒ Buffer
constructor
A new instance of Buffer.
-
#mark_all_dirty ⇒ void
Marks every cell dirty, so the next #flush re-emits the whole grid.
-
#region_ansi(rect) ⇒ 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.
-
#region_text(rect) ⇒ Array<String>
The plain text of each row within ‘rect`’s column range, top to bottom.
-
#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
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.
-
#row_text(y) ⇒ String
The plain text of row ‘y` (continuation cells contribute nothing, so wide glyphs read as their single cluster).
-
#set_char(x, y, grapheme, style = DEFAULT_STYLE) ⇒ void
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
Grid dimensions.
Constructor Details
#initialize(size) ⇒ Buffer
Returns a new instance of Buffer.
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
#height ⇒ Integer (readonly)
116 117 118 |
# File 'lib/tuile/buffer.rb', line 116 def height @height end |
#width ⇒ Integer (readonly)
116 117 118 |
# File 'lib/tuile/buffer.rb', line 116 def width @width end |
Instance Method Details
#cell(x, y) ⇒ Cell?
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.
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.
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.
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 |
#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.
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_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).
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.
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.
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.
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.
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.
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.
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.
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 |