Class: Clacky::UI2::OutputBuffer

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

Instance Method Summary collapse

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

#entriesObject (readonly)

Returns the value of attribute entries.



62
63
64
# File 'lib/clacky/ui2/output_buffer.rb', line 62

def entries
  @entries
end

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

#linesObject (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.

Parameters:

  • content (String, Array<String>)
  • kind (Symbol) (defaults to: :text)

    :text (default), :progress, :system

Returns:

  • (Integer)

    id of the newly created entry



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

#clearObject

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.

Parameters:

  • line_count (Integer)

    Number of visual lines pushed to scrollback

Returns:

  • (Integer)

    Number of entries NEWLY marked fully committed (partial commits on an entry do NOT count toward this total —callers use the return value only as a debug hint, not for row bookkeeping).



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.

Parameters:

  • id (Integer)


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.

Parameters:

  • id (Integer)

Returns:



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.

Parameters:

  • id (Integer)

Returns:

  • (Boolean)


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?

Parameters:

  • id (Integer)

Returns:

  • (Boolean)


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_entriesArray<Entry>

Entries that are still live (not committed). These are candidates for re-rendering into the visible output area.

Returns:



222
223
224
# File 'lib/clacky/ui2/output_buffer.rb', line 222

def live_entries
  @mutex.synchronize { @entries.reject(&:committed).dup }
end

#live_line_countObject

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_sizeObject

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.

Parameters:

  • id (Integer)

Returns:



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

Parameters:

  • id (Integer)
  • content (String, Array<String>)

Returns:

  • (Integer, nil)

    Old visible height if replaced, nil if no-op



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

#sizeObject

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.

Parameters:

  • n (Integer)

Returns:

  • (Array<String>)


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

#versionObject

Monotonic version (incremented on every mutation).



306
307
308
# File 'lib/clacky/ui2/output_buffer.rb', line 306

def version
  @version
end