rubyterm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/termbuffer.rb ADDED
@@ -0,0 +1,365 @@
1
+ require 'set'
2
+
3
+ BOLD = 0x002
4
+ FAINT = 0x004
5
+ ITALICS = 0x008
6
+ UNDERLINE = 0x010
7
+ BLINK = 0x020
8
+ RAPID_BLINK = 0x040
9
+ INVERSE = 0x080
10
+ INVISIBLE = 0x100
11
+ CROSSED_OUT = 0x200
12
+ DBL_UNDERLINE = 0x400
13
+ OVERLINE = 0x800
14
+
15
+ # The screen buffer: a grid of styled cells + scrollback + scroll region.
16
+ #
17
+ # Storage is COLUMNAR. A row is not an array of [ch,fg,bg,flags] cell
18
+ # objects; it is split across three parallel arrays of tagged immediates,
19
+ # held per row:
20
+ #
21
+ # @chars[y][x] - codepoint Integer (nil = unset/blank cell)
22
+ # @style[y][x] - packed Integer: fg(24) | bg(24)<<24 | flags<<48
23
+ # @gen[y][x] - per-cell generation Integer (the damage primitive)
24
+ #
25
+ # Integers in the fixnum range and nil are stored in the VALUE word with no
26
+ # heap allocation, so writing a cell allocates nothing (vs. an Array per
27
+ # glyph before). fg/bg are 24-bit and flags < 2^12, so a cell's attributes
28
+ # pack into one fixnum. See docs/architecture-review.md §8.
29
+ #
30
+ # @gen is a monotonic per-cell version, bumped only when a cell's content
31
+ # actually changes. It is the damage primitive a backend consumes to know
32
+ # which cells to repaint; the harness's markers check reads it too
33
+ # (generation_at), but it is genuine production state, not debug-only
34
+ # weight. Because @gen is stored and moved alongside @chars/@style, the
35
+ # generation follows a cell through scrolls and line/char insert+delete for
36
+ # free - no separate bookkeeping.
37
+ #
38
+ # Scrollback lines reuse the exact columnar representation: a scrolled-off
39
+ # line is the pair [chars_row, style_row], moved straight into history (no
40
+ # per-cell objects, ~40x fewer retained objects than the old form).
41
+ class TermBuffer
42
+ attr_accessor :scroll_start, :scroll_end
43
+ attr_reader :w, :scrollback_buffer, :scrollback_lineattrs, :generation
44
+
45
+ def initialize
46
+ @w = nil
47
+ @h = nil
48
+ @generation = 0
49
+ clear
50
+ @scroll_start = nil
51
+ @scroll_end = nil
52
+ end
53
+
54
+ def clear
55
+ @chars = []
56
+ @style = []
57
+ @gen = []
58
+ @lineattrs = []
59
+ @scrollback_buffer = []
60
+ @scrollback_lineattrs = []
61
+ @blinky = Set.new
62
+ @row_dirty = [] # rows changed since the last each_damaged walk
63
+ # NB: @generation is deliberately NOT reset - it stays monotonic across
64
+ # clears so a redrawn-after-clear cell never collides with a stale gen.
65
+ end
66
+
67
+ def scrollback_size = @scrollback_buffer.size
68
+ def blinky = @blinky
69
+
70
+ def on_resize(w, h)
71
+ raise if !h
72
+ @w, @h = w, h
73
+ enforce_height
74
+ end
75
+ alias resize on_resize
76
+
77
+ # # Packing helpers
78
+
79
+ def pack_style(fg, bg, flags)
80
+ (fg.to_i & 0xFFFFFF) | ((bg.to_i & 0xFFFFFF) << 24) | (flags.to_i << 48)
81
+ end
82
+
83
+ def cell(ch, style)
84
+ [ch, style & 0xFFFFFF, (style >> 24) & 0xFFFFFF, style >> 48]
85
+ end
86
+
87
+ # Reconstruct a [chars, styles] scrollback pair into an array of cells.
88
+ def unpack_line(packed)
89
+ chars, styles = packed
90
+ chars.map.with_index { |ch, x| ch && cell(ch, styles[x]) }
91
+ end
92
+
93
+ private def ensure_row(y)
94
+ @chars[y] ||= []
95
+ @style[y] ||= []
96
+ @gen[y] ||= []
97
+ @lineattrs[y] ||= 0
98
+ end
99
+
100
+ # Rebuild row y as an array of cells (nil for unset positions), or nil if
101
+ # the row has never been written. Non-mutating.
102
+ private def reconstruct_row(y)
103
+ chars = @chars[y] or return nil
104
+ styles = @style[y]
105
+ chars.map.with_index { |ch, x| ch && cell(ch, styles[x]) }
106
+ end
107
+
108
+ # # Reads
109
+
110
+ def get(x, y)
111
+ if y < 0
112
+ row = line_at(y)
113
+ return row && row[x]
114
+ end
115
+ chars = @chars[y] or return nil
116
+ ch = chars[x] or return nil
117
+ cell(ch, @style[y][x])
118
+ end
119
+
120
+ # Whole row of cells. Negative rows come from scrollback.
121
+ def getline(y)
122
+ return line_at(y) if y < 0
123
+ reconstruct_row(y) || []
124
+ end
125
+
126
+ # Like #getline but non-vivifying and nil (not []) for an absent row, and
127
+ # mapping negative rows into the (unpacked) scrollback. Safe for read-only
128
+ # traversal such as selection extraction across scrollback.
129
+ def line_at(y)
130
+ if y < 0 && !@scrollback_buffer.empty?
131
+ i = @scrollback_buffer.size + y
132
+ return i >= 0 ? unpack_line(@scrollback_buffer[i]) : nil
133
+ end
134
+ reconstruct_row(y)
135
+ end
136
+
137
+ def lineattrs(y)
138
+ y = y.to_i
139
+ if y < 0 && !@scrollback_lineattrs.empty?
140
+ i = @scrollback_lineattrs.size + y
141
+ return i >= 0 ? @scrollback_lineattrs[i] : 0
142
+ end
143
+ @lineattrs[y]
144
+ end
145
+
146
+ # The damage primitive: the generation at which (x,y) last changed, or
147
+ # nil for an unset cell. Scrollback is not damage-tracked.
148
+ def generation_at(x, y)
149
+ return nil if y < 0
150
+ g = @gen[y] and g[x]
151
+ end
152
+
153
+ # Yield [x, y, ch, fg, bg, flags] for every cell whose content changed
154
+ # after +since_gen+ - the damage since the last flush - as scalars, no
155
+ # cell Array allocated. A damage-driven renderer walks this instead of
156
+ # being told to draw eagerly on every #set. Returns the current
157
+ # generation so the caller can advance its watermark. (Walks all rows;
158
+ # row-level dirty tracking is a later optimisation.)
159
+ def each_damaged(since_gen)
160
+ @row_dirty.each_index do |y|
161
+ next unless @row_dirty[y]
162
+ @row_dirty[y] = false
163
+ gens = @gen[y] or next
164
+ chars = @chars[y]
165
+ styles = @style[y]
166
+ gens.each_index do |x|
167
+ g = gens[x]
168
+ next if !g || g <= since_gen
169
+ ch = chars[x] or next
170
+ s = styles[x]
171
+ yield x, y, ch, s & 0xFFFFFF, (s >> 24) & 0xFFFFFF, s >> 48
172
+ end
173
+ end
174
+ @generation
175
+ end
176
+
177
+ # True if (x,y) currently holds exactly this content. Lets the draw path
178
+ # skip identical repaints without reconstructing a cell Array (the prior
179
+ # `new == get(x,y)` allocated one per character).
180
+ def cell_eq?(x, y, ch, fg, bg, flags)
181
+ chars = @chars[y] or return false
182
+ chars[x] == ch && @style[y][x] == pack_style(fg, bg, flags)
183
+ end
184
+
185
+ # True if (x,y) has never been written (blank).
186
+ def unset?(x, y)
187
+ chars = @chars[y]
188
+ !chars || chars[x].nil?
189
+ end
190
+
191
+ # Yields [x, y, cell] for every *set* cell, scrollback (if offset>0) first
192
+ # at the top, then the live grid below it.
193
+ def each_character(scrollback_offset = 0)
194
+ used = 0
195
+ if scrollback_offset > 0 && !@scrollback_buffer.empty?
196
+ offset = [@scrollback_buffer.size, scrollback_offset].min
197
+ if offset > 0
198
+ lines = @scrollback_buffer[-offset..-1] || []
199
+ lines.each_with_index do |packed, idx|
200
+ chars, styles = packed
201
+ chars.each_with_index do |ch, x|
202
+ yield x, idx, cell(ch, styles[x]) if ch
203
+ end
204
+ end
205
+ used = lines.size
206
+ end
207
+ end
208
+
209
+ # +1 mirrors the historical off-by-one (draw one extra row).
210
+ remaining = @h ? (@h - used + 1) : @chars.size
211
+ @chars.each_with_index do |chars, y|
212
+ next if !chars || y >= remaining
213
+ styles = @style[y]
214
+ chars.each_with_index do |ch, x|
215
+ yield x, y + used, cell(ch, styles[x]) if ch
216
+ end
217
+ end
218
+ end
219
+
220
+ def each_character_between(spos, epos)
221
+ if spos.end > epos.end
222
+ spos, epos = epos, spos
223
+ elsif spos.end == epos.end && spos.first > epos.first
224
+ spos, epos = epos, spos
225
+ end
226
+
227
+ x = spos.first
228
+ xend, ymax = epos.first, epos.end
229
+ (spos.end..ymax).each do |y|
230
+ line = line_at(y) || ""
231
+ xmax = y == ymax ? xend + 1 : line.length - 1
232
+ xmax = [xmax, line.length - 1].min
233
+ xmax = 0 if xmax < 0
234
+ while x <= xmax
235
+ yield(x, y, line[x])
236
+ x += 1
237
+ end
238
+ x = 0
239
+ end
240
+ end
241
+
242
+ # # Writes
243
+
244
+ def set(x, y, ch, fg = 0, bg = 0, flags = 0)
245
+ ch = ch.ord
246
+ if flags.anybits?(BLINK | RAPID_BLINK)
247
+ @blinky << [x, y]
248
+ elsif !@blinky.empty?
249
+ # Only bother removing (and allocating the [x,y] key) when something
250
+ # actually blinks. The overwhelmingly common case is no blinking cells
251
+ # at all, so this skips a per-character array alloc + Set#delete.
252
+ @blinky.delete([x, y])
253
+ end
254
+ ensure_row(y)
255
+ style = pack_style(fg, bg, flags)
256
+ # Bump the generation only on an actual content change (identical
257
+ # rewrites keep their gen, so a cell that didn't change isn't seen as
258
+ # damaged).
259
+ if @chars[y][x] != ch || @style[y][x] != style
260
+ @gen[y][x] = (@generation += 1)
261
+ @row_dirty[y] = true
262
+ end
263
+ @chars[y][x] = ch
264
+ @style[y][x] = style
265
+ end
266
+
267
+ def set_lineattrs(y, v) = (@lineattrs[y] = v)
268
+
269
+ # ICH / IRM: open a gap of +num+ cells at x by inserting +cell+, shifting
270
+ # the rest of the line right; cells pushed past the right margin are
271
+ # discarded (the line never grows beyond width). Inserted cells carry no
272
+ # generation (they did not go through #set); they are blanks the caller
273
+ # repaints.
274
+ def insert(x, y, num, cell)
275
+ ensure_row(y)
276
+ ch = cell[0]
277
+ style = pack_style(cell[1], cell[2], cell[3])
278
+ num.times do
279
+ @chars[y].insert(x, ch)
280
+ @style[y].insert(x, style)
281
+ @gen[y].insert(x, nil)
282
+ end
283
+ if @w && @chars[y].length > @w
284
+ @chars[y].slice!(@w..)
285
+ @style[y].slice!(@w..)
286
+ @gen[y].slice!(@w..)
287
+ end
288
+ end
289
+
290
+ # DCH: delete +num+ cells at (x,y), shifting the remainder left (gens
291
+ # follow their cells). Vacated cells at the right become blank.
292
+ def delete_chars(x, y, num)
293
+ chars = @chars[y] or return
294
+ num.times do
295
+ break if x >= chars.length
296
+ chars.delete_at(x)
297
+ @style[y].delete_at(x)
298
+ @gen[y].delete_at(x)
299
+ end
300
+ end
301
+
302
+ def clear_line(y, start_x = 0, end_x = nil)
303
+ if !end_x
304
+ # Clear to end of line: truncate the row at start_x. Dropped cells
305
+ # become unset (gen nil), so a stale on-screen tail is detectable.
306
+ if @chars[y]
307
+ @chars[y] = @chars[y][0...start_x]
308
+ @style[y] = (@style[y] || [])[0...start_x]
309
+ @gen[y] = (@gen[y] || [])[0...start_x]
310
+ end
311
+ else
312
+ (start_x..end_x).each { |x| set(x, y, ' ') }
313
+ end
314
+ end
315
+
316
+ # # Line operations (region-aware)
317
+
318
+ private def raw_delete_line(y)
319
+ @chars.slice!(y)
320
+ @style.slice!(y)
321
+ @gen.slice!(y)
322
+ @lineattrs.slice!(y)
323
+ end
324
+
325
+ private def raw_insert_line(y)
326
+ @chars.insert(y, nil)
327
+ @style.insert(y, nil)
328
+ @gen.insert(y, nil)
329
+ @lineattrs.insert(y, 0)
330
+ enforce_height
331
+ end
332
+
333
+ def delete_line(y)
334
+ raw_delete_line(y)
335
+ # In a scroll region, deleting a line shifts the region up and inserts a
336
+ # blank line at the bottom (scroll_end), not at the top.
337
+ raw_insert_line(@scroll_end) if @scroll_start
338
+ end
339
+
340
+ def insert_line(y)
341
+ raw_insert_line(y)
342
+ # Inserting pushes the region down; discard the line that falls just
343
+ # past the bottom of the region.
344
+ raw_delete_line(@scroll_end + 1) if @scroll_end
345
+ end
346
+
347
+ def scroll_up
348
+ # Move the top line of the region into scrollback - the columnar
349
+ # [chars, style] arrays ARE the packed scrollback form, so this is a
350
+ # straight handoff (delete_line then drops the live references). The
351
+ # gen row is discarded (scrollback is not damage-tracked).
352
+ y = @scroll_start.to_i
353
+ @scrollback_buffer.push([@chars[y] || [], @style[y] || []])
354
+ @scrollback_lineattrs.push(lineattrs(y))
355
+ delete_line(y)
356
+ end
357
+
358
+ def enforce_height
359
+ return unless @h
360
+ @chars.slice!(@h..)
361
+ @style.slice!(@h..)
362
+ @gen.slice!(@h..)
363
+ @lineattrs.slice!(@h..)
364
+ end
365
+ end
@@ -0,0 +1,319 @@
1
+
2
+ # FIXME: Roll this into the actual buffer.
3
+ class TrackChanges
4
+ # The cursor is rendered as an overlay - a cell repainted with this
5
+ # background. The AnsiBackend recognises it and turns it into a real
6
+ # terminal cursor; the X11 backend paints the block.
7
+ CURSOR = 0xff00ff
8
+
9
+ def initialize buffer, adapter
10
+ @buffer = buffer
11
+ @adapter = adapter
12
+ @cursor_pos = nil # where the cursor overlay was last painted
13
+ # When true, #set only mutates the buffer; rendering is deferred to the
14
+ # next #draw_flush, which walks the buffer's damage (generation) instead
15
+ # of drawing eagerly per cell. Default off (the proven eager path) while
16
+ # the damage-driven path is validated against it; see test_damage.rb.
17
+ @defer = false
18
+ @last_flush_gen = 0
19
+ @rows = 24 # overwritten by on_resize before use
20
+ # When true, ALL rendering is suppressed (the buffer still mutates):
21
+ # used to jump-scroll a flood of output by interpreting many chunks and
22
+ # then doing ONE full redraw of the final screen, skipping the
23
+ # intermediate frames the user would never see. The model (incl.
24
+ # scrollback) stays correct; only the framebuffer is batched.
25
+ @suspend = false
26
+ clear
27
+ end
28
+
29
+ attr_reader :buffer
30
+ attr_accessor :defer, :suspend
31
+
32
+ # Rendering is off either because we're viewing scrollback history or
33
+ # because output is being jump-scrolled.
34
+ def suppressed? = @suspend || @adapter.scrollback_mode
35
+
36
+ def clear
37
+ # Flush any batched text first: otherwise pending draws are emitted to
38
+ # the screen AFTER the clear and survive it (stale content; the buffer
39
+ # is already correct, so only the incremental render diverges).
40
+ draw_flush
41
+ @buffer.clear
42
+ @adapter.clear unless suppressed?
43
+ end
44
+
45
+ # Methods that does not alter the buffer
46
+ def lineattrs(y) = @buffer.lineattrs(y)
47
+ def get(x,y) = @buffer.get(x,y)
48
+ def scroll_start = @buffer.scroll_start
49
+ def scroll_end = @buffer.scroll_end
50
+ def blinky = @buffer.blinky
51
+ # Backend-facing queries the interpreter routes through the buffer rather
52
+ # than reaching the adapter directly (so Term talks only to its buffer).
53
+ def scrollback_mode = @adapter.scrollback_mode
54
+ def set_columns(cols) = @adapter.set_columns(cols)
55
+ def each_character(scrollback_offset = 0, &block)
56
+ @buffer.each_character(scrollback_offset, &block)
57
+ end
58
+
59
+ # # Mutation
60
+ #
61
+ # Scroll the region up one line: draw pending damage, scroll the model,
62
+ # then drive the backend - a blit, or (when scrolled back) just anchor the
63
+ # viewport so the frozen history lines stay in place. The blit's inclusive
64
+ # bottom row is the scroll region's, or the last screen row when unset.
65
+ def scroll_up
66
+ draw_flush
67
+ start = @buffer.scroll_start.to_i
68
+ bottom = @buffer.scroll_end || (@rows - 1)
69
+ @buffer.scroll_up
70
+ return if @suspend
71
+ if @adapter.scrollback_mode
72
+ @adapter.scrollback_anchor
73
+ else
74
+ @adapter.scroll_up(start, bottom)
75
+ end
76
+ end
77
+
78
+ def delete_lines(y, num, maxy)
79
+ draw_flush
80
+ # Delete repeatedly at the SAME row: each delete shifts the rows below
81
+ # up into y, so deleting at y+i would skip every other line.
82
+ num.times { @buffer.delete_line(y) }
83
+ @adapter.delete_lines(y, num, @buffer.scroll_end||maxy) unless suppressed?
84
+ end
85
+
86
+ def insert_lines(y, num, maxy)
87
+ draw_flush
88
+ num.times.each {|i| @buffer.insert_line(y+i) }
89
+ @adapter.insert_lines(y, num, @buffer.scroll_end || maxy) unless suppressed?
90
+ end
91
+
92
+ def clear_line(*args)
93
+ draw_flush
94
+ @buffer.clear_line(*args)
95
+ @adapter.clear_line(*args) unless suppressed?
96
+ end
97
+
98
+ def set(x,y,c,fg,bg,mode)
99
+ # MUST be before the @buffer.set below, as draw_buffered compares
100
+ # against the buffer's *current* content to avoid redundant redraws.
101
+ # Skipped while scrolled back so live output does not paint over the
102
+ # scrolled-back view (the buffer still updates).
103
+ #
104
+ # draw_buffered reads the cell's four fields synchronously and never
105
+ # retains the array, so we reuse a per-instance scratch cell instead of
106
+ # allocating [c,fg,bg,mode] per character. Safe: a single processing
107
+ # thread, with no re-entrancy back into #set.
108
+ unless @defer || @adapter.scrollback_mode
109
+ s = (@scratch ||= [])
110
+ s[0], s[1], s[2], s[3] = c, fg, bg, mode
111
+ draw_buffered(x, y, s)
112
+ end
113
+ @buffer.set(x,y,c,fg,bg,mode)
114
+ end
115
+
116
+ def on_resize(w,h)
117
+ raise if !h
118
+ @rows = h # used as the default scroll-region bottom in scroll_up
119
+ # FIXME: Window is currently resized separately.
120
+ @buffer.on_resize(w,h)
121
+ end
122
+
123
+ # Explicit delegations to the underlying buffer, replacing a catch-all
124
+ # method_missing so the buffer's surface through TrackChanges is
125
+ # knowable. Each is a model-only mutation whose on-screen redraw the
126
+ # caller drives separately (Term#redraw_line_from_cursor after
127
+ # insert/delete_chars; Term#set_line_attrs after set_lineattrs) or a
128
+ # read-only query - none of them paint, which is why they bypass the
129
+ # adapter. (The scroll_start/scroll_end getters are defined above; only
130
+ # the setters delegate.)
131
+ def insert(*args) = @buffer.insert(*args)
132
+ def delete_chars(*args) = @buffer.delete_chars(*args)
133
+ def set_lineattrs(*args) = @buffer.set_lineattrs(*args)
134
+ def scroll_start=(v); @buffer.scroll_start = v; end
135
+ def scroll_end=(v); @buffer.scroll_end = v; end
136
+ def scrollback_size = @buffer.scrollback_size
137
+ def each_character_between(*args, &block) = @buffer.each_character_between(*args, &block)
138
+
139
+ def redraw_blink
140
+ return nil if suppressed?
141
+ b = @buffer.blinky
142
+ return nil if b.empty?
143
+ b.each { |x,y| redraw(x,y) }
144
+ draw_flush
145
+ end
146
+
147
+ def redraw(x,y) = draw_buffered(x,y, @buffer.get(x,y), true)
148
+
149
+ # Render the cursor overlay at (x,y) if +visible+, after restoring the
150
+ # cell under its previous position. A no-op while scrolled back, so the
151
+ # live cursor doesn't paint over the frozen history view.
152
+ def draw_cursor(x, y, visible)
153
+ return if suppressed?
154
+ clear_cursor
155
+ return unless visible
156
+ redraw_with(x, y, bg: CURSOR)
157
+ @cursor_pos = [x, y]
158
+ end
159
+
160
+ def clear_cursor
161
+ return if suppressed?
162
+ return unless @cursor_pos
163
+ redraw(*@cursor_pos)
164
+ @cursor_pos = nil
165
+ end
166
+
167
+ def redraw_all(scrollback_offset = 0)
168
+ @buffer.each_character(scrollback_offset) { |*args| draw_buffered(*args, true) }
169
+ # We just force-drew every cell, so nothing is damaged relative to now:
170
+ # advance the watermark before flushing so the damage walk doesn't redraw
171
+ # it all again (and so the next incremental flush only sees new changes).
172
+ @last_flush_gen = @buffer.generation
173
+ draw_flush
174
+ end
175
+
176
+ def redraw_with(x,y, fg: nil, bg: nil)
177
+ cell = Array(@buffer.get(x,y)).dup
178
+ cell[0] ||= " "
179
+ cell[1] = fg if fg
180
+ cell[2] = bg if bg
181
+ draw_buffered(x,y, cell, true)
182
+ end
183
+
184
+ # Draw an already-resolved cell at a *screen* position, optionally
185
+ # overriding fg/bg. Used when the cell's buffer row and its screen row
186
+ # differ (selection highlighting while scrolled back into scrollback).
187
+ def redraw_cell_at(screen_x, screen_y, cell, fg: nil, bg: nil)
188
+ cell = Array(cell).dup
189
+ cell[0] ||= " "
190
+ cell[1] = fg if fg
191
+ cell[2] = bg if bg
192
+ draw_buffered(screen_x, screen_y, cell, true)
193
+ end
194
+
195
+ # Repaint whatever is currently displayed at a screen position, given the
196
+ # active scrollback offset (so scrollback rows repaint their scrolled-off
197
+ # content rather than the live buffer's).
198
+ def redraw_display(screen_x, screen_y, scrollback_offset = 0)
199
+ buffer_y = screen_y - scrollback_offset
200
+ draw_buffered(screen_x, screen_y, @buffer.get(screen_x, buffer_y), true)
201
+ end
202
+
203
+ # Public flush point. In the default (eager) mode draws already happened
204
+ # on #set, so this just emits the pending run. In damage-driven (defer)
205
+ # mode #set only mutates, so a flush first walks the buffer's damage and
206
+ # draws the changed cells (run-batched) before emitting. Either way it
207
+ # then emits the run buffer, which also carries force-redraws (cursor,
208
+ # ICH/DCH, blink, selection).
209
+ def draw_flush
210
+ return if @suspend # jump-scrolling: defer all rendering to the redraw
211
+ if @defer && !@adapter.scrollback_mode
212
+ @buffer.each_damaged(@last_flush_gen) do |x, y, ch, fg, bg, flags|
213
+ s = (@scratch ||= [])
214
+ s[0], s[1], s[2], s[3] = ch, fg, bg, flags
215
+ draw_buffered(x, y, s, true)
216
+ end
217
+ @last_flush_gen = @buffer.generation
218
+ end
219
+ flush_buf
220
+ end
221
+
222
+ # Emit the batched run and reset the batch. Internal: draw_buffered calls
223
+ # this on a run break, so it must NOT re-enter the damage walk above.
224
+ def flush_buf
225
+ if @bufx && @buf && @buf[0] && !@buf[0].empty?
226
+ c = @buf[0]
227
+ fg = @buf[1] || PALETTE_BASIC[7]
228
+ bg = @buf[2] || PALETTE_BASIC[0]
229
+ if c == " " && fg == 0 && bg == 0 # Why?
230
+ else
231
+ lineattrs = @buffer.lineattrs(@bufy)
232
+ flags = @buf[3].to_i
233
+ #p [:flush, fg, c]
234
+ @adapter.draw(@bufx, @bufy, c, fg, bg, flags, lineattrs)
235
+ end
236
+ end
237
+ @buf = []
238
+ @bufx = nil
239
+ @bufy = nil
240
+ @last_x = -2
241
+ @last_y = -2
242
+ end
243
+
244
+
245
+ # This is a hack
246
+ def draw_buffered(x,y,cell, force=false)
247
+ @last_x ||= -255
248
+ @last_y ||= -255
249
+ @buf ||= ["",PALETTE_BASIC[7], PALETTE_BASIC[0],0]
250
+ cell ||= [" "]
251
+
252
+ #p [:buffered, x, y, cell, @bufx, @bufy, @buf, force]
253
+ if @buf[0] && @buf[0].length > 160
254
+ flush_buf
255
+ elsif @last_y != y || @last_x + 1 != x
256
+ flush_buf
257
+ elsif (@buf[1] != cell[1]) or (@buf[2] != cell[2]) or (@buf[3] != cell[3])
258
+ flush_buf
259
+ else
260
+ end
261
+
262
+ # FIXME: It is possible this is called from multiple threads.
263
+ # Uh oh. That *will* be trouble. Either changes must be serialized -
264
+ # they certainly must be for the backend screen buffer - or
265
+ # this must be made thread local.
266
+ #
267
+ @buf[0] ||= ""
268
+
269
+ # This is to get better performance out of applications that
270
+ # carelessly prints far more than they ought to.
271
+ # *cough* my editor *cough*
272
+ if force
273
+ match = false
274
+ else
275
+ # Skip the draw if the buffer already holds this exact cell, or if
276
+ # we're writing a default-background space over an unset cell.
277
+ # Compared against the buffer's columnar storage directly, so no cell
278
+ # Array is reconstructed per character.
279
+ # FIXME: Make this more deliberate about *background* attributes.
280
+ match = (cell[0] == 32 && cell[2] == BG && @buffer.unset?(x, y)) ||
281
+ @buffer.cell_eq?(x, y, cell[0], cell[1], cell[2], cell[3])
282
+ end
283
+
284
+ # FIXME: The #to_s here is a workaround for thread sync issues.
285
+ if @buf[0].to_s.empty?
286
+ if match
287
+ return
288
+ else
289
+ #p [:diff, x,y, cell, bcell]
290
+ end
291
+ elsif match
292
+ # This heuristic could probably be better:
293
+ # * Keep a count, and trigger on the *number of matches*
294
+ # instead of on the number of characters. This to e.g. prevent a
295
+ # single coinciding character from splitting up the rendering into
296
+ # 8-char chunks
297
+ #p [:match_non_empty, @buf[0].length]
298
+ if @buf[0]&.length.to_i > 8
299
+ # If flushing here, chop the buffer down to the point of the first
300
+ # match.
301
+ flush_buf
302
+ return
303
+ end
304
+ end
305
+
306
+ c = cell[0]
307
+
308
+ @buf[1] ||= cell[1]
309
+ @buf[2] ||= cell[2]
310
+ @buf[3] ||= cell[3]
311
+ @buf[0] ||= ""
312
+ @buf[0] << (c || "")
313
+ @bufx ||= x
314
+ @bufy ||= y
315
+ @last_x = x
316
+ @last_y = y
317
+ end
318
+
319
+ end