Class: Clacky::UI2::OutputBuffer
- Inherits:
-
Object
- Object
- Clacky::UI2::OutputBuffer
- Defined in:
- lib/clacky/ui2/output_buffer.rb
Overview
OutputBuffer manages the logical sequence of rendered output lines.
It replaces the scattered state that used to live across LayoutManager (@output_buffer + @output_row) and UIController (@progress_message / “last line” assumptions).
Core concepts:
-
Every append returns an
id. Callers can later replace(id, …) or remove(id) that exact entry without relying on “the last line”. -
Each entry tracks whether it has been “committed” to the terminal scrollback (i.e. scrolled off the top of the visible window by a native terminal n). Committed entries are NEVER re-drawn from the buffer again — this is what prevents the classic “scroll up shows a duplicated line” bug.
-
Entries may contain multi-line content (already wrapped). Each entry stores its visual line count so the renderer can compute exact rows to clear when replacing or removing.
The buffer itself does NOT talk to the terminal. It is a pure data structure; a renderer (LayoutManager) consumes it through the snapshot APIs: visible_entries, entry_by_id, tail_lines.
Defined Under Namespace
Classes: Entry
Constant Summary collapse
- DEFAULT_MAX_ENTRIES =
2000
Instance Attribute Summary collapse
-
#entries ⇒ Object
readonly
Returns the value of attribute entries.
-
#id ⇒ Object
readonly
Monotonic id, unique within the buffer.
-
#lines ⇒ Object
readonly
Rendered (already-wrapped) visual lines.
Instance Method Summary collapse
-
#append(content, kind: :text) ⇒ Integer
Append a new entry.
-
#clear ⇒ Object
Clear everything.
-
#commit_oldest_lines(line_count) ⇒ Integer
Commit the oldest N VISUAL rows.
-
#commit_through(id) ⇒ Object
Mark an entry (and every older live entry) as committed to terminal scrollback.
-
#committed(value) ⇒ Object
True once pushed into terminal scrollback.
-
#entry_by_id(id) ⇒ Entry?
Look up an entry by id.
-
#fully_editable?(id) ⇒ Boolean
Does this id refer to an entry that can still be replaced or removed in place? A partially-committed entry (prefix already in scrollback via a scroll) is NOT editable — its visible suffix is frozen until it either fully commits or (rare) a full repaint rewrites the screen.
-
#initialize(max_entries: DEFAULT_MAX_ENTRIES) ⇒ OutputBuffer
constructor
A new instance of OutputBuffer.
-
#kind(value) ⇒ Object
:text | :progress | :system (hint for renderer).
-
#live?(id) ⇒ Boolean
Does this id still refer to a live, editable entry?.
-
#live_entries ⇒ Array<Entry>
Entries that are still live (not committed).
-
#live_line_count ⇒ Object
Total visual lines across live entries.
-
#live_size ⇒ Object
Number of live entries.
-
#remove(id) ⇒ Entry?
Remove an entry.
-
#replace(id, content) ⇒ Integer?
Replace an existing entry’s content.
-
#size ⇒ Object
Total number of entries (committed + live) currently tracked.
-
#tail_lines(n) ⇒ Array<String>
The last N *visual lines* across live entries, preserving entry boundaries.
-
#version ⇒ Object
Monotonic version (incremented on every mutation).
Constructor Details
#initialize(max_entries: DEFAULT_MAX_ENTRIES) ⇒ OutputBuffer
Returns a new instance of OutputBuffer.
64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/clacky/ui2/output_buffer.rb', line 64 def initialize(max_entries: DEFAULT_MAX_ENTRIES) @entries = [] # Array<Entry> in insertion order @index = {} # id => Entry (fast lookup) @next_id = 1 @max_entries = max_entries @mutex = Mutex.new # Monotonic counter incremented every time the buffer changes. # Renderers can compare this against a saved version to decide # whether their cached screen image is still valid. @version = 0 end |
Instance Attribute Details
#entries ⇒ Object (readonly)
Returns the value of attribute entries.
62 63 64 |
# File 'lib/clacky/ui2/output_buffer.rb', line 62 def entries @entries end |
#id ⇒ Object (readonly)
Monotonic id, unique within the buffer
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/clacky/ui2/output_buffer.rb', line 36 Entry = Struct.new(:id, :lines, :kind, :committed, :committed_line_offset, keyword_init: true) do # Visual row count this entry currently OCCUPIES on screen. Once a # prefix of the entry's lines has been pushed into scrollback by # a scroll+partial-commit, those prefix rows are no longer on # screen — so height drops accordingly. When +committed+ flips to # true the entry is considered fully off-screen and height is 0. def height return 0 if committed lines.length - (committed_line_offset || 0) end # The currently on-screen lines of this entry (lines that haven't # been pushed to scrollback yet). Returns [] once fully committed. def visible_lines return [] if committed off = committed_line_offset || 0 off.zero? ? lines : lines[off..] || [] end def to_s lines.join("\n") end end |
#lines ⇒ Object (readonly)
Rendered (already-wrapped) visual lines
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/clacky/ui2/output_buffer.rb', line 36 Entry = Struct.new(:id, :lines, :kind, :committed, :committed_line_offset, keyword_init: true) do # Visual row count this entry currently OCCUPIES on screen. Once a # prefix of the entry's lines has been pushed into scrollback by # a scroll+partial-commit, those prefix rows are no longer on # screen — so height drops accordingly. When +committed+ flips to # true the entry is considered fully off-screen and height is 0. def height return 0 if committed lines.length - (committed_line_offset || 0) end # The currently on-screen lines of this entry (lines that haven't # been pushed to scrollback yet). Returns [] once fully committed. def visible_lines return [] if committed off = committed_line_offset || 0 off.zero? ? lines : lines[off..] || [] end def to_s lines.join("\n") end end |
Instance Method Details
#append(content, kind: :text) ⇒ Integer
Append a new entry. content may be a String (may include n) or an Array<String> of already-split lines.
82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/clacky/ui2/output_buffer.rb', line 82 def append(content, kind: :text) @mutex.synchronize do lines = normalize_lines(content) entry = Entry.new(id: next_id!, lines: lines, kind: kind, committed: false, committed_line_offset: 0) @entries << entry @index[entry.id] = entry trim_if_needed bump_version entry.id end end |
#clear ⇒ Object
Clear everything. Used by /clear command.
311 312 313 314 315 316 317 |
# File 'lib/clacky/ui2/output_buffer.rb', line 311 def clear @mutex.synchronize do @entries.clear @index.clear bump_version end end |
#commit_oldest_lines(line_count) ⇒ Integer
Commit the oldest N VISUAL rows. Used when the renderer scrolls N lines off the top via native n. Commits are precise at the visual row granularity (even mid-entry): if the oldest live entry is multi-line and only its prefix has scrolled off, that prefix is recorded in committed_line_offset and only the still-visible suffix remains eligible for future repaints.
This is the critical invariant for preventing the “scroll up to see a line already in scrollback, then render_output_from_buffer repaints it again on screen” duplicate-output regression: every visual row that went into terminal scrollback MUST be removed from the buffer’s pool of repaintable live rows, regardless of whether it sat alone in a 1-line entry or at the top of a 10-line entry.
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 |
# File 'lib/clacky/ui2/output_buffer.rb', line 185 def commit_oldest_lines(line_count) return 0 if line_count <= 0 @mutex.synchronize do remaining = line_count committed = 0 changed = false @entries.each do |e| break if remaining <= 0 next if e.committed h = e.height if h <= remaining # Full scroll-off of this entry's remaining visible rows. e.committed = true e.committed_line_offset = e.lines.length # normalize remaining -= h committed += 1 changed = true else # Partial scroll: record the new offset and stop (there are # still visible rows of this entry on screen). e.committed_line_offset = (e.committed_line_offset || 0) + remaining remaining = 0 changed = true break end end bump_version if changed committed end end |
#commit_through(id) ⇒ Object
Mark an entry (and every older live entry) as committed to terminal scrollback. Called by the renderer after it has emitted a native n that scrolled the top-of-screen row off into scrollback.
Committing always flows from oldest → newest: if entry X is committed, every entry older than X must also be committed, because they have already scrolled past X on the screen.
151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/clacky/ui2/output_buffer.rb', line 151 def commit_through(id) @mutex.synchronize do committed_any = false @entries.each do |e| break if e.id > id unless e.committed e.committed = true committed_any = true end end bump_version if committed_any end end |
#committed=(value) ⇒ Object
True once pushed into terminal scrollback
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/clacky/ui2/output_buffer.rb', line 36 Entry = Struct.new(:id, :lines, :kind, :committed, :committed_line_offset, keyword_init: true) do # Visual row count this entry currently OCCUPIES on screen. Once a # prefix of the entry's lines has been pushed into scrollback by # a scroll+partial-commit, those prefix rows are no longer on # screen — so height drops accordingly. When +committed+ flips to # true the entry is considered fully off-screen and height is 0. def height return 0 if committed lines.length - (committed_line_offset || 0) end # The currently on-screen lines of this entry (lines that haven't # been pushed to scrollback yet). Returns [] once fully committed. def visible_lines return [] if committed off = committed_line_offset || 0 off.zero? ? lines : lines[off..] || [] end def to_s lines.join("\n") end end |
#entry_by_id(id) ⇒ Entry?
Look up an entry by id.
263 264 265 |
# File 'lib/clacky/ui2/output_buffer.rb', line 263 def entry_by_id(id) @mutex.synchronize { @index[id] } end |
#fully_editable?(id) ⇒ Boolean
Does this id refer to an entry that can still be replaced or removed in place? A partially-committed entry (prefix already in scrollback via a scroll) is NOT editable — its visible suffix is frozen until it either fully commits or (rare) a full repaint rewrites the screen.
283 284 285 286 287 288 |
# File 'lib/clacky/ui2/output_buffer.rb', line 283 def fully_editable?(id) @mutex.synchronize do e = @index[id] !!(e && !e.committed && (e.committed_line_offset || 0) == 0) end end |
#kind=(value) ⇒ Object
:text | :progress | :system (hint for renderer)
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/clacky/ui2/output_buffer.rb', line 36 Entry = Struct.new(:id, :lines, :kind, :committed, :committed_line_offset, keyword_init: true) do # Visual row count this entry currently OCCUPIES on screen. Once a # prefix of the entry's lines has been pushed into scrollback by # a scroll+partial-commit, those prefix rows are no longer on # screen — so height drops accordingly. When +committed+ flips to # true the entry is considered fully off-screen and height is 0. def height return 0 if committed lines.length - (committed_line_offset || 0) end # The currently on-screen lines of this entry (lines that haven't # been pushed to scrollback yet). Returns [] once fully committed. def visible_lines return [] if committed off = committed_line_offset || 0 off.zero? ? lines : lines[off..] || [] end def to_s lines.join("\n") end end |
#live?(id) ⇒ Boolean
Does this id still refer to a live, editable entry?
269 270 271 272 273 274 |
# File 'lib/clacky/ui2/output_buffer.rb', line 269 def live?(id) @mutex.synchronize do e = @index[id] !!(e && !e.committed) end end |
#live_entries ⇒ Array<Entry>
Entries that are still live (not committed). These are candidates for re-rendering into the visible output area.
222 223 224 |
# File 'lib/clacky/ui2/output_buffer.rb', line 222 def live_entries @mutex.synchronize { @entries.reject(&:committed).dup } end |
#live_line_count ⇒ Object
Total visual lines across live entries.
301 302 303 |
# File 'lib/clacky/ui2/output_buffer.rb', line 301 def live_line_count @mutex.synchronize { @entries.sum { |e| e.committed ? 0 : e.height } } end |
#live_size ⇒ Object
Number of live entries.
296 297 298 |
# File 'lib/clacky/ui2/output_buffer.rb', line 296 def live_size @mutex.synchronize { @entries.count { |e| !e.committed } } end |
#remove(id) ⇒ Entry?
Remove an entry. Committed entries cannot be removed (they are in terminal scrollback). Partially-committed entries also cannot be removed — their prefix is frozen in scrollback. Returns the removed Entry, or nil if no-op.
128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/clacky/ui2/output_buffer.rb', line 128 def remove(id) @mutex.synchronize do entry = @index[id] return nil unless entry return nil if entry.committed return nil if (entry.committed_line_offset || 0) > 0 @entries.delete(entry) @index.delete(id) bump_version entry end end |
#replace(id, content) ⇒ Integer?
Replace an existing entry’s content. If the id no longer exists (e.g. the entry was trimmed or already committed and recycled), this is a no-op and returns nil.
Replacing a committed entry is silently ignored — committed content lives in terminal scrollback and cannot be edited in place. Same for an entry whose prefix has been partial-committed: the prefix is already in scrollback and replacing the entry would either strand those lines (if shorter) or duplicate them (if longer).
107 108 109 110 111 112 113 114 115 116 117 118 119 |
# File 'lib/clacky/ui2/output_buffer.rb', line 107 def replace(id, content) @mutex.synchronize do entry = @index[id] return nil unless entry return nil if entry.committed return nil if (entry.committed_line_offset || 0) > 0 old_height = entry.height entry.lines = normalize_lines(content) bump_version old_height end end |
#size ⇒ Object
Total number of entries (committed + live) currently tracked.
291 292 293 |
# File 'lib/clacky/ui2/output_buffer.rb', line 291 def size @mutex.synchronize { @entries.size } end |
#tail_lines(n) ⇒ Array<String>
The last N *visual lines* across live entries, preserving entry boundaries. Returns an Array<String> suitable for row-by-row painting. If the last live entry is taller than n, only its last n lines are returned.
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/clacky/ui2/output_buffer.rb', line 233 def tail_lines(n) return [] if n <= 0 @mutex.synchronize do collected = [] @entries.reverse_each do |e| break if collected.length >= n next if e.committed # The entry's still-visible lines (excluding any prefix already # committed to scrollback via a partial commit). vis = e.visible_lines next if vis.empty? # Prepend the entry's visible lines in order remaining = n - collected.length if vis.length <= remaining collected = vis + collected else collected = vis.last(remaining) + collected break end end collected end end |
#version ⇒ Object
Monotonic version (incremented on every mutation).
306 307 308 |
# File 'lib/clacky/ui2/output_buffer.rb', line 306 def version @version end |