rubytui 1.2.3

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +574 -0
  4. data/lib/rubytui/app.rb +174 -0
  5. data/lib/rubytui/backend.rb +158 -0
  6. data/lib/rubytui/backends/ansi_backend.rb +391 -0
  7. data/lib/rubytui/backends/test_backend.rb +223 -0
  8. data/lib/rubytui/buffer.rb +400 -0
  9. data/lib/rubytui/cell.rb +55 -0
  10. data/lib/rubytui/color.rb +153 -0
  11. data/lib/rubytui/color_mode.rb +203 -0
  12. data/lib/rubytui/errors.rb +13 -0
  13. data/lib/rubytui/event.rb +161 -0
  14. data/lib/rubytui/frame.rb +70 -0
  15. data/lib/rubytui/input/key.rb +93 -0
  16. data/lib/rubytui/input/parser.rb +231 -0
  17. data/lib/rubytui/input/reader.rb +119 -0
  18. data/lib/rubytui/layout/constraint.rb +83 -0
  19. data/lib/rubytui/layout/flex.rb +16 -0
  20. data/lib/rubytui/layout/layout.rb +205 -0
  21. data/lib/rubytui/modifier.rb +67 -0
  22. data/lib/rubytui/rect.rb +126 -0
  23. data/lib/rubytui/stateful_widget.rb +20 -0
  24. data/lib/rubytui/style.rb +143 -0
  25. data/lib/rubytui/symbols.rb +88 -0
  26. data/lib/rubytui/terminal.rb +218 -0
  27. data/lib/rubytui/text/line.rb +67 -0
  28. data/lib/rubytui/text/span.rb +34 -0
  29. data/lib/rubytui/text/text.rb +82 -0
  30. data/lib/rubytui/unicode.rb +162 -0
  31. data/lib/rubytui/version.rb +6 -0
  32. data/lib/rubytui/widget.rb +20 -0
  33. data/lib/rubytui/widgets/async_image.rb +248 -0
  34. data/lib/rubytui/widgets/block.rb +260 -0
  35. data/lib/rubytui/widgets/canvas.rb +248 -0
  36. data/lib/rubytui/widgets/chart.rb +224 -0
  37. data/lib/rubytui/widgets/gauge.rb +139 -0
  38. data/lib/rubytui/widgets/image.rb +330 -0
  39. data/lib/rubytui/widgets/input_field.rb +245 -0
  40. data/lib/rubytui/widgets/list.rb +186 -0
  41. data/lib/rubytui/widgets/paragraph.rb +181 -0
  42. data/lib/rubytui/widgets/popup.rb +140 -0
  43. data/lib/rubytui/widgets/scrollbar.rb +175 -0
  44. data/lib/rubytui/widgets/sparkline.rb +86 -0
  45. data/lib/rubytui/widgets/table.rb +231 -0
  46. data/lib/rubytui/widgets/tabs.rb +90 -0
  47. data/lib/rubytui.rb +389 -0
  48. metadata +89 -0
@@ -0,0 +1,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module RubyTUI
6
+ # {Backend} that drives a real terminal with ANSI escape sequences.
7
+ #
8
+ # Output accumulates in an internal string buffer and is written to the
9
+ # output IO only by {#flush}. {#enter_alternate_screen},
10
+ # {#leave_alternate_screen}, {#enable_mouse}, {#disable_mouse} and
11
+ # {#clear_all_images!} flush immediately.
12
+ #
13
+ # @see RubyTUI.init
14
+ class AnsiBackend
15
+ include Backend
16
+
17
+ # Control Sequence Introducer (<tt>ESC [</tt>) that prefixes escape sequences.
18
+ CSI = "\e["
19
+
20
+ # @param output [IO] stream escape sequences are written to (default: $stdout)
21
+ # @param input [IO] terminal input used for raw mode and cell-size queries
22
+ # (default: $stdin)
23
+ def initialize(output: $stdout, input: $stdin)
24
+ @output = output
25
+ @input = input
26
+ @buffer = String.new(capacity: 4096)
27
+ @prev_style = nil
28
+ @original_state = nil
29
+ @next_image_id = 1
30
+ @image_id_map = {}
31
+ @cell_pixel_size = nil
32
+ end
33
+
34
+ # Append escape sequences for the changed cells to the output buffer.
35
+ #
36
+ # Emits a cursor move only where a cell does not directly follow the
37
+ # previous one, skips wide-character spacer cells (empty symbol), and
38
+ # appends an SGR reset at the end. Does nothing if +changes+ is empty.
39
+ # Does not flush.
40
+ #
41
+ # @param changes [Array<Array(Integer, Integer, Cell)>] changed cells as [x, y, cell]
42
+ # @return [void]
43
+ def draw(changes)
44
+ return if changes.empty?
45
+
46
+ last_x = -1
47
+ last_y = -1
48
+
49
+ changes.each do |x, y, cell|
50
+ # Skip spacer cells from wide characters
51
+ if cell.symbol.empty?
52
+ next
53
+ end
54
+
55
+ # Move cursor if not at expected position
56
+ unless x == last_x + 1 && y == last_y
57
+ @buffer << "#{CSI}#{y + 1};#{x + 1}H"
58
+ end
59
+
60
+ # Apply style
61
+ apply_style(cell.style)
62
+
63
+ # Write character
64
+ @buffer << cell.symbol
65
+
66
+ # Wide characters advance terminal cursor by 2
67
+ w = cell.symbol.length == 1 ? Buffer.char_width(cell.symbol) : 1
68
+ last_x = x + w - 1
69
+ last_y = y
70
+ end
71
+
72
+ # Reset style at end
73
+ @buffer << "#{CSI}0m"
74
+ @prev_style = nil
75
+ end
76
+
77
+ # Append the hide-cursor sequence (<tt>CSI ?25l</tt>) to the output buffer.
78
+ #
79
+ # @return [void]
80
+ def hide_cursor
81
+ @buffer << "#{CSI}?25l"
82
+ end
83
+
84
+ # Append the show-cursor sequence (<tt>CSI ?25h</tt>) to the output buffer.
85
+ #
86
+ # @return [void]
87
+ def show_cursor
88
+ @buffer << "#{CSI}?25h"
89
+ end
90
+
91
+ # Append a cursor-position sequence to the output buffer.
92
+ #
93
+ # @param x [Integer] zero-based column
94
+ # @param y [Integer] zero-based row
95
+ # @return [void]
96
+ def move_cursor(x, y)
97
+ @buffer << "#{CSI}#{y + 1};#{x + 1}H"
98
+ end
99
+
100
+ # Append the clear-screen sequence (<tt>CSI 2J</tt>) to the output buffer.
101
+ #
102
+ # @return [void]
103
+ def clear
104
+ @buffer << "#{CSI}2J"
105
+ end
106
+
107
+ # Switch to the alternate screen buffer (<tt>CSI ?1049h</tt>) and flush.
108
+ #
109
+ # @return [void]
110
+ def enter_alternate_screen
111
+ @buffer << "#{CSI}?1049h"
112
+ flush
113
+ end
114
+
115
+ # Switch back from the alternate screen buffer (<tt>CSI ?1049l</tt>) and flush.
116
+ #
117
+ # @return [void]
118
+ def leave_alternate_screen
119
+ @buffer << "#{CSI}?1049l"
120
+ flush
121
+ end
122
+
123
+ # Enable SGR extended mouse mode (button events + drag + scroll)
124
+ #
125
+ # Turns on modes 1000, 1002 and 1006, then flushes.
126
+ #
127
+ # @return [void]
128
+ def enable_mouse
129
+ @buffer << "#{CSI}?1000h" # Enable button event tracking
130
+ @buffer << "#{CSI}?1002h" # Enable button-event tracking with drag
131
+ @buffer << "#{CSI}?1006h" # Enable SGR extended mode (for coords > 223)
132
+ flush
133
+ end
134
+
135
+ # Disable mouse event reporting
136
+ #
137
+ # Turns off modes 1006, 1002 and 1000, then flushes.
138
+ #
139
+ # @return [void]
140
+ def disable_mouse
141
+ @buffer << "#{CSI}?1006l"
142
+ @buffer << "#{CSI}?1002l"
143
+ @buffer << "#{CSI}?1000l"
144
+ flush
145
+ end
146
+
147
+ # Put the input into raw mode, saving its terminal state for {#disable_raw_mode}.
148
+ #
149
+ # Save the termios state of the SAME fd we are about to raw! — a backtick
150
+ # <tt>stty -g</tt> reads the process's stdin, which is the wrong fd (and often
151
+ # not a tty at all) when a custom input: IO such as File.open("/dev/tty")
152
+ # is used, e.g. a TUI launched from fzf execute() with output captured.
153
+ # A failed stty yields "" which is truthy, so it must collapse to nil or
154
+ # restore would run <tt>stty ""</tt> and leave the terminal stuck in raw mode.
155
+ #
156
+ # @return [void]
157
+ def enable_raw_mode
158
+ @original_state = begin
159
+ state = IO.popen(["stty", "-g"], in: @input, err: File::NULL, &:read).chomp
160
+ state.empty? ? nil : state
161
+ rescue StandardError
162
+ nil
163
+ end
164
+ @input.raw! if @input.respond_to?(:raw!)
165
+ end
166
+
167
+ # Restore the input's terminal state saved by {#enable_raw_mode}.
168
+ #
169
+ # Runs +stty+ with the saved state on the input. Falls back to
170
+ # +cooked!+ (when the input supports it) if no state was saved or
171
+ # +stty+ raises.
172
+ #
173
+ # @return [void]
174
+ def disable_raw_mode
175
+ if @original_state
176
+ begin
177
+ system("stty", @original_state, in: @input, err: File::NULL)
178
+ rescue StandardError
179
+ @input.cooked! if @input.respond_to?(:cooked!)
180
+ end
181
+ @original_state = nil
182
+ elsif @input.respond_to?(:cooked!)
183
+ @input.cooked!
184
+ end
185
+ end
186
+
187
+ # Get the output terminal size.
188
+ #
189
+ # @return [Array(Integer, Integer)] [width, height] in cells, or [80, 24]
190
+ # if the size cannot be read from the output
191
+ def size
192
+ rows, cols = @output.winsize
193
+ [cols, rows]
194
+ rescue Errno::ENOTTY, Errno::ENODEV, NoMethodError
195
+ [80, 24]
196
+ end
197
+
198
+ # Query the terminal for cell pixel dimensions.
199
+ #
200
+ # Sends CSI 16 t and parses the response. Must be called before
201
+ # entering raw mode or while in raw mode with careful stdin handling.
202
+ # Falls back to [9, 18] when the terminal does not answer within 100 ms
203
+ # or the query raises +IOError+, +Errno::EIO+ or +Errno::ENOTTY+; the
204
+ # result, including the fallback, is cached.
205
+ #
206
+ # @return [Array(Integer, Integer)] cell dimensions [width_px, height_px]
207
+ def query_cell_pixel_size
208
+ return @cell_pixel_size if @cell_pixel_size
209
+
210
+ begin
211
+ @input.raw do
212
+ @output.write("\e[16t")
213
+ @output.flush
214
+
215
+ response = +""
216
+ deadline = Time.now + 0.1
217
+
218
+ while Time.now < deadline
219
+ if IO.select([@input], nil, nil, 0.01)
220
+ char = @input.read_nonblock(1, exception: false)
221
+ break if char == :wait_readable || char.nil?
222
+ response << char
223
+ break if char == "t"
224
+ end
225
+ end
226
+
227
+ if response =~ /\e\[6;(\d+);(\d+)t/
228
+ @cell_pixel_size = [$2.to_i, $1.to_i]
229
+ end
230
+ end
231
+ rescue IOError, Errno::EIO, Errno::ENOTTY
232
+ # Terminal doesn't support query
233
+ end
234
+
235
+ @cell_pixel_size ||= [9, 18]
236
+ end
237
+
238
+ # @return [Array(Integer, Integer), nil] cached cell dimensions [width_px, height_px],
239
+ # or nil until {#query_cell_pixel_size} has run
240
+ attr_reader :cell_pixel_size
241
+
242
+ # Emit a Kitty graphics protocol image at the given placement.
243
+ #
244
+ # Transmits the image data as chunked base64 in APC escape sequences.
245
+ # The terminal decodes and renders the image at the cursor position.
246
+ # Each image gets a unique ID for precise deletion.
247
+ # Appends to the output buffer; does not flush.
248
+ #
249
+ # @param placement [Hash{Symbol => Object}] image placement from {Buffer#image_placements}
250
+ # @return [void]
251
+ def draw_kitty_image(placement)
252
+ # Generate a unique image ID for this placement
253
+ placement_key = [placement[:x], placement[:y], placement[:content_hash]]
254
+ image_id = @image_id_map[placement_key] ||= (@next_image_id += 1)
255
+
256
+ # Position cursor at image origin
257
+ @buffer << "#{CSI}#{placement[:y] + 1};#{placement[:x] + 1}H"
258
+
259
+ encoded = [placement[:data]].pack("m0") # strict base64, no newlines
260
+ format_code = placement[:format] == :png ? 100 : 32
261
+
262
+ # Chunk into 4096-byte pieces (safe for all Kitty-compatible terminals)
263
+ chunks = encoded.scan(/.{1,4096}/m)
264
+
265
+ chunks.each_with_index do |chunk, i|
266
+ more = i < chunks.length - 1 ? 1 : 0
267
+
268
+ if i == 0
269
+ # C=1: do not move the cursor after displaying — keeps the
270
+ # terminal's cursor position in sync with the backend's model
271
+ params = +"a=T,f=#{format_code},i=#{image_id},C=1"
272
+ params << ",s=#{placement[:pixel_width]},v=#{placement[:pixel_height]}" if format_code == 32
273
+
274
+ # Source rectangle for cropping/scrolling (pixel coordinates within image)
275
+ if placement[:src_x] && placement[:src_y]
276
+ params << ",x=#{placement[:src_x]},y=#{placement[:src_y]}"
277
+ params << ",w=#{placement[:src_w]},h=#{placement[:src_h]}" if placement[:src_w] && placement[:src_h]
278
+ end
279
+
280
+ params << ",c=#{placement[:cols]},r=#{placement[:rows]},m=#{more}"
281
+ @buffer << "\e_G#{params};#{chunk}\e\\"
282
+ else
283
+ @buffer << "\e_Gm=#{more};#{chunk}\e\\"
284
+ end
285
+ end
286
+ end
287
+
288
+ # Delete a Kitty graphics protocol image by ID.
289
+ #
290
+ # Sends a delete command targeting the specific image ID, ensuring
291
+ # precise removal without affecting other images.
292
+ # Appends to the output buffer; does not flush.
293
+ #
294
+ # @param placement [Hash{Symbol => Object}] the image placement to delete
295
+ # @return [void]
296
+ def delete_kitty_image(placement)
297
+ placement_key = [placement[:x], placement[:y], placement[:content_hash]]
298
+ image_id = @image_id_map.delete(placement_key)
299
+
300
+ if image_id
301
+ # Delete by image ID — precise and reliable
302
+ @buffer << "\e_Ga=d,d=i,i=#{image_id}\e\\"
303
+ else
304
+ # Fallback: delete images whose cells were overwritten
305
+ @buffer << "\e_Ga=d,d=C\e\\"
306
+ end
307
+ end
308
+
309
+ # Delete all Kitty graphics protocol images.
310
+ #
311
+ # Nuclear option for clearing all image overlays. Useful during
312
+ # transitions or when precise tracking isn't possible.
313
+ # Forgets all tracked image IDs and flushes immediately.
314
+ #
315
+ # @return [void]
316
+ def clear_all_images!
317
+ @buffer << "\e_Ga=d,d=A\e\\"
318
+ @image_id_map.clear
319
+ flush
320
+ end
321
+
322
+ # Write the buffered output to the output IO and flush it.
323
+ #
324
+ # Does nothing if the buffer is empty.
325
+ #
326
+ # @return [void]
327
+ def flush
328
+ return if @buffer.empty?
329
+
330
+ @output.write(@buffer)
331
+ @output.flush
332
+ @buffer.clear
333
+ end
334
+
335
+ private
336
+
337
+ def apply_style(style)
338
+ return if style == @prev_style
339
+
340
+ codes = []
341
+
342
+ # Reset if transitioning from a styled state
343
+ if @prev_style && needs_reset?(style)
344
+ codes << "0"
345
+ @prev_style = nil
346
+ end
347
+
348
+ prev = @prev_style || Style::DEFAULT
349
+
350
+ # Foreground
351
+ if style.fg != prev.fg
352
+ codes << (style.fg ? style.fg.to_fg_ansi : "39")
353
+ end
354
+
355
+ # Background
356
+ if style.bg != prev.bg
357
+ codes << (style.bg ? style.bg.to_bg_ansi : "49")
358
+ end
359
+
360
+ # Modifiers added
361
+ added = style.modifiers & ~prev.modifiers
362
+ Modifier::SGR_ON.each do |flag, code|
363
+ codes << code if Modifier.contains?(added, flag)
364
+ end
365
+
366
+ # Modifiers removed
367
+ removed = prev.modifiers & ~style.modifiers
368
+ Modifier::SGR_OFF.each do |flag, code|
369
+ codes << code if Modifier.contains?(removed, flag)
370
+ end
371
+
372
+ @buffer << "#{CSI}#{codes.join(";")}m" unless codes.empty?
373
+ @prev_style = style
374
+ end
375
+
376
+ # Determine if we need a full reset (simpler than tracking individual removals)
377
+ def needs_reset?(style)
378
+ return false unless @prev_style
379
+
380
+ # If modifiers were removed and we can't undo them individually
381
+ removed = @prev_style.modifiers & ~style.modifiers
382
+ # BOLD and DIM share the same off code (22), so reset if either changes
383
+ if Modifier.contains?(removed, Modifier::BOLD) || Modifier.contains?(removed, Modifier::DIM)
384
+ return true if Modifier.contains?(@prev_style.modifiers, Modifier::BOLD) &&
385
+ Modifier.contains?(@prev_style.modifiers, Modifier::DIM)
386
+ end
387
+
388
+ false
389
+ end
390
+ end
391
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # In-memory {Backend} for tests.
5
+ #
6
+ # Records drawn cells in a grid and tracks cursor, alternate-screen,
7
+ # raw-mode and mouse state as plain values; nothing is written to a
8
+ # terminal. {#width}, {#height}, {#cursor_visible}, {#alternate_screen},
9
+ # {#raw_mode}, {#cursor_x}, {#cursor_y} and {#draw_history} expose that
10
+ # state for assertions.
11
+ #
12
+ # @example
13
+ # backend = TestBackend.new(width: 20, height: 5)
14
+ # terminal = Terminal.new(backend: backend)
15
+ # terminal.draw { |frame| frame.buffer.set_string(0, 0, "Hi") }
16
+ # backend.row_text(0) # => "Hi"
17
+ class TestBackend
18
+ include Backend
19
+
20
+ attr_reader :width, :height, :cursor_visible, :alternate_screen, :raw_mode
21
+ attr_reader :cursor_x, :cursor_y, :draw_history
22
+
23
+ # @param width [Integer] grid width in columns (default: 80)
24
+ # @param height [Integer] grid height in rows (default: 24)
25
+ def initialize(width: 80, height: 24)
26
+ @width = width
27
+ @height = height
28
+ @cells = Array.new(width * height) { Cell.new }
29
+ @cursor_visible = true
30
+ @alternate_screen = false
31
+ @raw_mode = false
32
+ @cursor_x = 0
33
+ @cursor_y = 0
34
+ @draw_history = []
35
+ end
36
+
37
+ # Record the changes and copy each cell into the in-memory grid.
38
+ #
39
+ # A copy of +changes+ is appended to {#draw_history}. Changes whose grid
40
+ # index (<tt>y * width + x</tt>) falls outside the grid are skipped.
41
+ #
42
+ # @param changes [Array<Array(Integer, Integer, Cell)>] changed cells as [x, y, cell]
43
+ # @return [void]
44
+ def draw(changes)
45
+ @draw_history << changes.dup
46
+ changes.each do |x, y, cell|
47
+ idx = y * @width + x
48
+ next if idx < 0 || idx >= @cells.length
49
+
50
+ @cells[idx] = Cell.new(cell.symbol, cell.style)
51
+ end
52
+ end
53
+
54
+ # Mark the cursor as hidden ({#cursor_visible} becomes false).
55
+ #
56
+ # @return [void]
57
+ def hide_cursor
58
+ @cursor_visible = false
59
+ end
60
+
61
+ # Mark the cursor as visible ({#cursor_visible} becomes true).
62
+ #
63
+ # @return [void]
64
+ def show_cursor
65
+ @cursor_visible = true
66
+ end
67
+
68
+ # Record the cursor position in {#cursor_x} and {#cursor_y}.
69
+ #
70
+ # @param x [Integer] zero-based column
71
+ # @param y [Integer] zero-based row
72
+ # @return [void]
73
+ def move_cursor(x, y)
74
+ @cursor_x = x
75
+ @cursor_y = y
76
+ end
77
+
78
+ # Reset every grid cell to empty.
79
+ #
80
+ # @return [void]
81
+ def clear
82
+ @cells.each(&:reset!)
83
+ end
84
+
85
+ # Set {#alternate_screen} to true.
86
+ #
87
+ # @return [void]
88
+ def enter_alternate_screen
89
+ @alternate_screen = true
90
+ end
91
+
92
+ # Set {#alternate_screen} to false.
93
+ #
94
+ # @return [void]
95
+ def leave_alternate_screen
96
+ @alternate_screen = false
97
+ end
98
+
99
+ # Set {#raw_mode} to true.
100
+ #
101
+ # @return [void]
102
+ def enable_raw_mode
103
+ @raw_mode = true
104
+ end
105
+
106
+ # Set {#raw_mode} to false.
107
+ #
108
+ # @return [void]
109
+ def disable_raw_mode
110
+ @raw_mode = false
111
+ end
112
+
113
+ # @return [Array(Integer, Integer)] the grid size as [width, height]
114
+ def size = [@width, @height]
115
+
116
+ # No-op; the test backend has no output stream.
117
+ #
118
+ # @return [void]
119
+ def flush
120
+ # no-op for test backend
121
+ end
122
+
123
+ # Record that mouse reporting is enabled (see {#mouse_enabled?}).
124
+ #
125
+ # @return [void]
126
+ def enable_mouse
127
+ @mouse_enabled = true
128
+ end
129
+
130
+ # Record that mouse reporting is disabled (see {#mouse_enabled?}).
131
+ #
132
+ # @return [void]
133
+ def disable_mouse
134
+ @mouse_enabled = false
135
+ end
136
+
137
+ # @return [Boolean] true if {#enable_mouse} was called more recently than {#disable_mouse}
138
+ def mouse_enabled? = @mouse_enabled == true
139
+
140
+ # Record an image placement (Kitty graphics protocol).
141
+ # @param placement [Hash{Symbol => Object}] placement data from {Buffer#set_image}
142
+ # @return [void]
143
+ def draw_kitty_image(placement)
144
+ @image_history ||= []
145
+ @image_history << placement.dup
146
+ end
147
+
148
+ # Record an image deletion.
149
+ # @param key [Hash{Symbol => Object}] the placement being deleted, as passed by {Terminal#draw}
150
+ # @return [void]
151
+ def delete_kitty_image(key)
152
+ @image_deletes ||= []
153
+ @image_deletes << key
154
+ end
155
+
156
+ # @return [Array<Hash{Symbol => Object}>] history of image placements drawn via {#draw_kitty_image}
157
+ def image_history = (@image_history ||= [])
158
+
159
+ # @return [Array<Hash{Symbol => Object}>] history of placements passed to {#delete_kitty_image}
160
+ def image_deletes = (@image_deletes ||= [])
161
+
162
+ # @!group Test Assertion Helpers
163
+
164
+ # Get the grid cell at (x, y).
165
+ #
166
+ # Looks up the flat index <tt>y * width + x</tt> without per-axis bounds
167
+ # checks, so an out-of-range +x+ wraps into an adjacent row and a
168
+ # negative index counts from the end of the grid.
169
+ #
170
+ # @param x [Integer] zero-based column
171
+ # @param y [Integer] zero-based row
172
+ # @return [Cell, nil] the cell, or nil if the index is outside the grid
173
+ def cell_at(x, y)
174
+ idx = y * @width + x
175
+ @cells[idx]
176
+ end
177
+
178
+ # Read text from a row starting at (x, y) for the given length
179
+ #
180
+ # @param x [Integer] starting column
181
+ # @param y [Integer] row
182
+ # @param length [Integer] number of cells to read
183
+ # @return [String] the cell symbols joined; missing cells read as a space
184
+ def text_at(x, y, length)
185
+ (0...length).map { |i| cell_at(x + i, y)&.symbol || " " }.join
186
+ end
187
+
188
+ # Read an entire row as text
189
+ #
190
+ # @param y [Integer] row
191
+ # @return [String] the row's text with trailing whitespace removed
192
+ def row_text(y)
193
+ text_at(0, y, @width).rstrip
194
+ end
195
+
196
+ # Number of draw calls made
197
+ #
198
+ # @return [Integer] number of {#draw} calls recorded in {#draw_history}
199
+ def draw_count = @draw_history.length
200
+
201
+ # Total cells changed across all draws
202
+ #
203
+ # @return [Integer] sum of the change counts in {#draw_history}
204
+ def total_changes = @draw_history.sum(&:length)
205
+
206
+ # Reset for a fresh test
207
+ #
208
+ # Clears the grid and {#draw_history}, marks the cursor visible and moves
209
+ # it to (0, 0). Alternate-screen, raw-mode and mouse state and the image
210
+ # histories are left unchanged.
211
+ #
212
+ # @return [void]
213
+ def reset!
214
+ @cells.each(&:reset!)
215
+ @draw_history.clear
216
+ @cursor_visible = true
217
+ @cursor_x = 0
218
+ @cursor_y = 0
219
+ end
220
+
221
+ # @!endgroup
222
+ end
223
+ end