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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +574 -0
- data/lib/rubytui/app.rb +174 -0
- data/lib/rubytui/backend.rb +158 -0
- data/lib/rubytui/backends/ansi_backend.rb +391 -0
- data/lib/rubytui/backends/test_backend.rb +223 -0
- data/lib/rubytui/buffer.rb +400 -0
- data/lib/rubytui/cell.rb +55 -0
- data/lib/rubytui/color.rb +153 -0
- data/lib/rubytui/color_mode.rb +203 -0
- data/lib/rubytui/errors.rb +13 -0
- data/lib/rubytui/event.rb +161 -0
- data/lib/rubytui/frame.rb +70 -0
- data/lib/rubytui/input/key.rb +93 -0
- data/lib/rubytui/input/parser.rb +231 -0
- data/lib/rubytui/input/reader.rb +119 -0
- data/lib/rubytui/layout/constraint.rb +83 -0
- data/lib/rubytui/layout/flex.rb +16 -0
- data/lib/rubytui/layout/layout.rb +205 -0
- data/lib/rubytui/modifier.rb +67 -0
- data/lib/rubytui/rect.rb +126 -0
- data/lib/rubytui/stateful_widget.rb +20 -0
- data/lib/rubytui/style.rb +143 -0
- data/lib/rubytui/symbols.rb +88 -0
- data/lib/rubytui/terminal.rb +218 -0
- data/lib/rubytui/text/line.rb +67 -0
- data/lib/rubytui/text/span.rb +34 -0
- data/lib/rubytui/text/text.rb +82 -0
- data/lib/rubytui/unicode.rb +162 -0
- data/lib/rubytui/version.rb +6 -0
- data/lib/rubytui/widget.rb +20 -0
- data/lib/rubytui/widgets/async_image.rb +248 -0
- data/lib/rubytui/widgets/block.rb +260 -0
- data/lib/rubytui/widgets/canvas.rb +248 -0
- data/lib/rubytui/widgets/chart.rb +224 -0
- data/lib/rubytui/widgets/gauge.rb +139 -0
- data/lib/rubytui/widgets/image.rb +330 -0
- data/lib/rubytui/widgets/input_field.rb +245 -0
- data/lib/rubytui/widgets/list.rb +186 -0
- data/lib/rubytui/widgets/paragraph.rb +181 -0
- data/lib/rubytui/widgets/popup.rb +140 -0
- data/lib/rubytui/widgets/scrollbar.rb +175 -0
- data/lib/rubytui/widgets/sparkline.rb +86 -0
- data/lib/rubytui/widgets/table.rb +231 -0
- data/lib/rubytui/widgets/tabs.rb +90 -0
- data/lib/rubytui.rb +389 -0
- 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
|