reflex-terminal 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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.doc/ext/reflex-terminal/native.cpp +19 -0
  3. data/.doc/ext/reflex-terminal/renderer.cpp +110 -0
  4. data/.doc/ext/reflex-terminal/terminal.cpp +530 -0
  5. data/.github/PULL_REQUEST_TEMPLATE.md +12 -0
  6. data/.github/workflows/release-gem.yml +60 -0
  7. data/.github/workflows/tag.yml +35 -0
  8. data/.github/workflows/test.yml +46 -0
  9. data/.github/workflows/utils.rb +127 -0
  10. data/CONTRIBUTING.md +7 -0
  11. data/ChangeLog.md +6 -0
  12. data/Gemfile +5 -0
  13. data/LICENSE +21 -0
  14. data/README.md +100 -0
  15. data/Rakefile +44 -0
  16. data/VERSION +1 -0
  17. data/examples/terminal.rb +101 -0
  18. data/ext/reflex-terminal/defs.h +17 -0
  19. data/ext/reflex-terminal/extconf.rb +29 -0
  20. data/ext/reflex-terminal/native.cpp +19 -0
  21. data/ext/reflex-terminal/renderer.cpp +118 -0
  22. data/ext/reflex-terminal/terminal.cpp +572 -0
  23. data/include/reflex/terminal.h +233 -0
  24. data/include/reflex-terminal/defs.h +36 -0
  25. data/include/reflex-terminal/renderer.h +57 -0
  26. data/include/reflex-terminal/ruby/renderer.h +41 -0
  27. data/include/reflex-terminal/ruby/terminal.h +43 -0
  28. data/include/reflex-terminal/ruby.h +11 -0
  29. data/include/reflex-terminal.h +13 -0
  30. data/lib/reflex/bell_event.rb +15 -0
  31. data/lib/reflex/terminal.rb +168 -0
  32. data/lib/reflex/terminal_view.rb +268 -0
  33. data/lib/reflex-terminal/ext.rb +2 -0
  34. data/lib/reflex-terminal/extension.rb +41 -0
  35. data/lib/reflex-terminal.rb +7 -0
  36. data/reflex-terminal.gemspec +39 -0
  37. data/src/pty.cpp +236 -0
  38. data/src/renderer.cpp +365 -0
  39. data/src/terminal.cpp +1547 -0
  40. data/src/terminal.h +66 -0
  41. data/src/win32/pty.cpp +387 -0
  42. data/test/helper.rb +11 -0
  43. data/test/test_renderer.rb +113 -0
  44. data/test/test_terminal.rb +683 -0
  45. data/test/test_terminal_view.rb +182 -0
  46. metadata +152 -0
@@ -0,0 +1,683 @@
1
+ # -*- coding: utf-8 -*-
2
+ require_relative 'helper'
3
+
4
+
5
+ class TestTerminal < Test::Unit::TestCase
6
+
7
+ R = Reflex
8
+ T = R::Terminal
9
+
10
+ CTRL = R::MOD_CONTROL
11
+ SHIFT = R::MOD_SHIFT
12
+ OPTION = R::MOD_OPTION
13
+
14
+ # a console ends the input on ctrl+z, where a tty ends it on ctrl+d
15
+ EOF_KEY = win32? ? "\x1a\r" : "\x04"
16
+
17
+ def text(t)
18
+ t.lines.join "\n"
19
+ end
20
+
21
+ def ruby(script)
22
+ # a child that exists everywhere, unlike /bin/sh. Its argument also puts
23
+ # the quoting a windows command line needs through its paces
24
+ [RbConfig.ruby, '-e', script]
25
+ end
26
+
27
+ def terminal(*args, **kwargs)
28
+ T.new(*args, **kwargs)
29
+ end
30
+
31
+ def key_down(chars, code, modifiers = 0)
32
+ R::KeyEvent.new R::KeyEvent::DOWN, chars, code, modifiers, 0
33
+ end
34
+
35
+ def key_up(chars, code, modifiers = 0)
36
+ R::KeyEvent.new R::KeyEvent::UP, chars, code, modifiers, 0
37
+ end
38
+
39
+ def wait_for(t, timeout = 5)
40
+ deadline = Time.now + timeout
41
+ until yield
42
+ break if Time.now > deadline
43
+ t.update
44
+ sleep 0.01
45
+ end
46
+ end
47
+
48
+ def test_initialize()
49
+ t = terminal 80, 24
50
+ assert_equal 80, t.columns
51
+ assert_equal 24, t.rows
52
+ end
53
+
54
+ def test_initialize_with_invalid_size()
55
+ assert_raise(ArgumentError) {terminal 0, 24}
56
+ assert_raise(ArgumentError) {terminal 80, -1}
57
+ # to<int> coerces non-numeric values to 0, so the size check catches it
58
+ assert_raise(ArgumentError) {terminal 'x', 24}
59
+ end
60
+
61
+ def test_feed()
62
+ t = terminal 80, 4
63
+ t.feed "hi \e[31mred"
64
+ t.update
65
+
66
+ spans = t.each_span.to_a
67
+ assert_equal 2, spans.size
68
+
69
+ x, y, width, text, fg, bg, flags = spans[0]
70
+ assert_equal [0, 0, 3, %q[hi ], nil, nil, 0], [x, y, width, text, fg, bg, flags]
71
+
72
+ x, y, width, text, fg, bg, flags = spans[1]
73
+ assert_equal [3, 0, 3, %q[red]], [x, y, width, text]
74
+ assert_kind_of Integer, fg
75
+ assert_nil bg
76
+ end
77
+
78
+ def test_feed_with_invalid_bytes()
79
+ assert_raise(TypeError) {terminal.feed 42}
80
+ end
81
+
82
+ def test_each_span()
83
+ t = terminal 80, 4
84
+ t.feed "x"
85
+ t.update
86
+ assert_equal [0], t.each_span.map {|x, y,| y}
87
+ end
88
+
89
+ def test_update()
90
+ t = terminal 80, 4
91
+ t.update# flush initial state
92
+ t.feed "x"
93
+ assert_true t.update
94
+ assert_false t.update
95
+ end
96
+
97
+ def test_each_span_attributes()
98
+ t = terminal 80, 4
99
+ t.feed "\e[1mB\e[0m \e[4mU\e[0m \e[7mR"
100
+ t.update
101
+
102
+ flags = t.each_span.map {|x, y, w, str, fg, bg, flags| flags}
103
+ assert_equal T::BOLD, flags[0] & T::BOLD
104
+ assert_equal 1, (flags[2] & T::UNDERLINE_MASK) >> T::UNDERLINE_SHIFT
105
+ assert_equal T::INVERSE, flags[4] & T::INVERSE
106
+ end
107
+
108
+ def test_wide_chars()
109
+ t = terminal 20, 4
110
+ t.feed %q[あい]
111
+ t.update
112
+
113
+ x, y, width, text = t.each_span.first
114
+ assert_equal [0, 0, 4, %q[あい]], [x, y, width, text]
115
+ end
116
+
117
+ def test_grapheme_clusters()
118
+ # a cell holds a whole cluster, so a flag is one cell two columns wide
119
+ # rather than the two regional indicators it is written with, each
120
+ # taking two of its own
121
+ t = terminal 20, 4
122
+ t.feed "\u{1F1EF}\u{1F1F5}"
123
+ t.update
124
+ assert_equal [0, 0, 2, "\u{1F1EF}\u{1F1F5}"], t.each_span.first.first(4)
125
+
126
+ # a reset clears the modes, so the one that says this has to be set
127
+ # again behind it
128
+ t.reset
129
+ t.feed "\u{1F1EF}\u{1F1F5}"
130
+ t.update
131
+ assert_equal [0, 0, 2, "\u{1F1EF}\u{1F1F5}"], t.each_span.first.first(4)
132
+ end
133
+
134
+ def test_encodings()
135
+ t = terminal 20, 4
136
+ t.feed "\e]0;タイトル\a"
137
+ t.feed "あ"
138
+ t.update
139
+
140
+ assert_equal Encoding::UTF_8, t.each_span.first[3].encoding
141
+ assert_equal 'タイトル', t.title
142
+ assert_equal Encoding::UTF_8, t.lines.first.encoding
143
+
144
+ t.feed "\e[c"
145
+ assert_equal Encoding::ASCII_8BIT, t.read_pending_input.encoding
146
+ # read_pending_input is a raw byte stream for the PTY
147
+ end
148
+
149
+ def test_cursor()
150
+ t = terminal 80, 24
151
+ t.feed "abc"
152
+ t.update
153
+
154
+ x, y, style, visible = t.cursor
155
+ assert_equal [3, 0], [x, y]
156
+ assert_true visible
157
+ assert_include [T::CURSOR_BAR, T::CURSOR_BLOCK, T::CURSOR_UNDERLINE], style
158
+ end
159
+
160
+ def color(r, g, b) = Reflex::Color.new(r, g, b)
161
+
162
+ def test_default_colors()
163
+ # nil until someone says otherwise, so that a renderer can tell the child
164
+ # asking for a color apart from nobody having asked and use its own theme
165
+ t = terminal
166
+ assert_nil t.default_background_color
167
+ assert_nil t.background_color
168
+
169
+ t.default_background_color = color(1, 0, 0)
170
+ assert_equal color(1, 0, 0), t.default_background_color
171
+ assert_equal color(1, 0, 0), t.background_color
172
+
173
+ # the child changes what is in use without touching what it falls back to
174
+ t.feed "\e]11;#00ff00\a"
175
+ t.update
176
+ assert_equal color(1, 0, 0), t.default_background_color
177
+ assert_equal color(0, 1, 0), t.background_color
178
+
179
+ # and the foreground is set on its own, not as a pair with it
180
+ t.feed "\e]10;#0000ff\a"
181
+ t.update
182
+ assert_equal color(0, 0, 1), t.foreground_color
183
+
184
+ t.default_background_color = nil
185
+ assert_nil t.default_background_color
186
+
187
+ # a terminal color is 8 bits a channel, so a value between two of them
188
+ # comes back as the nearest one rather than as it was given
189
+ t.default_background_color = color(0.5, 0, 0)
190
+ assert_in_delta 0.5, t.default_background_color.red, 1.0 / 255
191
+ end
192
+
193
+ def test_palette()
194
+ t = terminal
195
+ t.feed "\e[31mR"
196
+ t.update
197
+ assert_equal 0..255, T::PALETTE_RANGE
198
+
199
+ builtin = t.palette 1
200
+ assert_equal builtin, t.default_palette(1)
201
+
202
+ t.set_default_palette 1, color(1, 1, 1)
203
+ assert_equal color(1, 1, 1), t.palette(1)
204
+
205
+ # a span carries the color the palette resolved to, not the index, so
206
+ # changing the palette changes what the cell reports
207
+ t.update
208
+ assert_equal 0xffffff, t.each_span.first[4]
209
+
210
+ t.set_default_palette 0..3, color(0, 0, 1)
211
+ assert_equal [color(0, 0, 1)] * 4, (0..3).map {|i| t.palette i}
212
+
213
+ # a nil color puts the built-in one back
214
+ t.set_default_palette 1, nil
215
+ assert_equal builtin, t.palette(1)
216
+
217
+ assert_raise(IndexError) {t.palette 256}
218
+ assert_raise(IndexError) {t.palette(-1)}
219
+ end
220
+
221
+ def test_feed_device_attributes()
222
+ t = terminal
223
+ t.feed "\e[c"
224
+ assert_match(/\e\[\?\d+/, t.read_pending_input)
225
+ assert_equal '', t.read_pending_input
226
+ end
227
+
228
+ def test_lines()
229
+ t = terminal 10, 4
230
+ t.feed "aaaaabbbbbccccc"
231
+ t.update
232
+ assert_equal ['aaaaabbbbb', 'ccccc'], t.lines[0, 2]
233
+
234
+ t.resize 20, 4
235
+ t.update
236
+ assert_equal 'aaaaabbbbbccccc', t.lines.first
237
+ end
238
+
239
+ def test_resize_with_invalid_size()
240
+ assert_raise(ArgumentError) {terminal.resize 0, 24}
241
+ assert_raise(ArgumentError) {terminal.resize 80, 24, cell_width: 0}
242
+ end
243
+
244
+ def test_title()
245
+ t = terminal
246
+ assert_equal '', t.title
247
+ t.feed "\e]0;hello\a"
248
+ assert_equal 'hello', t.title
249
+ end
250
+
251
+ def test_bells()
252
+ t = terminal
253
+ assert_equal 0, t.bells
254
+
255
+ t.feed "hi\a"
256
+ assert_equal 1, t.bells
257
+
258
+ # the count only grows, so no bell is dropped between the update that
259
+ # takes it in and the frame that answers it
260
+ t.update
261
+ t.feed "\a\a"
262
+ assert_equal 3, t.bells
263
+
264
+ # the BEL ending an OSC string is a terminator, not a bell
265
+ t.feed "\e]0;hello\a"
266
+ assert_equal 3, t.bells
267
+ end
268
+
269
+ def test_write_key()
270
+ t = terminal
271
+ t.write_key key_down("\r", R::KEY_ENTER)
272
+ assert_equal "\r", t.read_pending_input
273
+
274
+ t.write_key key_down('', R::KEY_UP)
275
+ assert_equal "\e[A", t.read_pending_input
276
+
277
+ t.write_key key_down("\x03", R::KEY_C, CTRL)
278
+ assert_equal "\x03", t.read_pending_input
279
+
280
+ t.write_key key_down('A', R::KEY_A, SHIFT)
281
+ assert_equal 'A', t.read_pending_input
282
+ end
283
+
284
+ def test_write_key_ctrl()
285
+ t = terminal
286
+ # ghostty leaves these to the kitty protocol (fixterms), so they would
287
+ # otherwise send nothing at all while an app has not asked for it
288
+ {
289
+ R::KEY_I => "\t",
290
+ R::KEY_M => "\r",
291
+ R::KEY_LBRACKET => "\e"
292
+ }.each do |code, expected|
293
+ t.write_key key_down('', code, CTRL)
294
+ assert_equal expected, t.read_pending_input
295
+ end
296
+
297
+ t.feed "\e[>1u"# the app asks for the kitty keyboard protocol
298
+ t.write_key key_down('', R::KEY_I, CTRL)
299
+ assert_equal "\e[105;5u", t.read_pending_input# ctrl+i stays distinct from tab
300
+ end
301
+
302
+ def test_write_key_ctrl_platform()
303
+ t = terminal
304
+ # macOS hands over the control character itself for ctrl+-, which the
305
+ # encoder has no legacy encoding for (C-_ is undo in emacs)
306
+ t.write_key key_down("\x1f", R::KEY_MINUS, CTRL)
307
+ assert_equal "\x1f", t.read_pending_input
308
+ end
309
+
310
+ def test_write_key_release()
311
+ t = terminal
312
+ t.write_key key_down("\r", R::KEY_ENTER)
313
+ assert_equal "\r", t.read_pending_input
314
+ t.write_key key_up("\r", R::KEY_ENTER)
315
+ assert_equal '', t.read_pending_input# a release says nothing in legacy mode
316
+
317
+ # every key as an escape code, releases included
318
+ t.feed "\e[>10u"
319
+ t.write_key key_down("\r", R::KEY_ENTER)
320
+ assert_equal "\e[13u", t.read_pending_input
321
+ t.write_key key_up("\r", R::KEY_ENTER)
322
+ assert_equal "\e[13;1:3u", t.read_pending_input
323
+ end
324
+
325
+ def test_read_pending_input()
326
+ t = terminal
327
+ t.write_key key_down("\r", R::KEY_ENTER)
328
+ assert_equal Encoding::ASCII_8BIT, t.read_pending_input.encoding
329
+ assert_equal '', t.read_pending_input
330
+ end
331
+
332
+ def test_option_as_alt()
333
+ t = terminal
334
+ assert_equal :on, t.option_as_alt
335
+
336
+ t.write_key key_down('∫', R::KEY_B, OPTION)
337
+ assert_equal "\eb", t.read_pending_input
338
+
339
+ assert_raise(ArgumentError) {t.option_as_alt = :invalid}
340
+ end
341
+
342
+ def test_option_as_alt_off()
343
+ t = terminal
344
+ t.option_as_alt = :off
345
+ t.write_key key_down('∫', R::KEY_B, OPTION)
346
+ assert_equal '∫'.b, t.read_pending_input
347
+ end if osx?# ghostty applies this setting on macos only
348
+
349
+ def test_spawn()
350
+ t = terminal 40, 6
351
+ assert_false t.alive?
352
+
353
+ t.spawn(*ruby('$stdout.sync = true; $stdin.each_line {|s| print s}'))
354
+ assert_true t.alive?
355
+ assert_raise(Rucy::NativeError) {t.spawn(*ruby('sleep'))}
356
+
357
+ t.write "hello\r"
358
+ wait_for(t) {text(t).include? 'hello'}
359
+ assert_include text(t), 'hello'
360
+
361
+ t.write EOF_KEY
362
+ wait_for(t) {not t.alive?}
363
+ assert_false t.alive?
364
+ end
365
+
366
+ def test_spawn_again()
367
+ t = terminal 40, 6
368
+ 2.times do |i|
369
+ t.reset
370
+ t.spawn(*ruby("print 'run#{i}'"))
371
+ wait_for(t) {text(t).include? "run#{i}"}
372
+ wait_for(t) {not t.alive?}
373
+ assert_include text(t), "run#{i}"
374
+ end
375
+ end
376
+
377
+ def test_close()
378
+ t = terminal 40, 6
379
+ t.spawn(*ruby('sleep'))
380
+ wait_for(t) {t.alive?}
381
+ assert_true t.alive?
382
+
383
+ t.close
384
+ assert_false t.alive?
385
+
386
+ t.spawn(*ruby("print 'after close'"))
387
+ wait_for(t) {text(t).include? 'after close'}
388
+ assert_include text(t), 'after close'
389
+ end
390
+
391
+ def test_spawn_with_args()
392
+ t = terminal 40, 6
393
+ t.spawn(*ruby('print "spawned"'))
394
+ wait_for(t) {text(t).include? 'spawned'}
395
+ assert_include text(t), 'spawned'
396
+ end
397
+
398
+ def test_spawn_sets_default_env()
399
+ t = terminal 60, 6
400
+ t.spawn(*ruby('print "[#{ENV["TERM"]}|#{ENV["TERM_PROGRAM"]}]"'))
401
+ wait_for(t) {text(t).include? ']'}
402
+ assert_include text(t), '[xterm-256color|reflex-terminal]'
403
+ end
404
+
405
+ def test_spawn_with_env_overrides_defaults()
406
+ t = terminal 60, 6
407
+ t.spawn(
408
+ {'TERM_PROGRAM' => 'my-app', MY_APP: 'yes'},
409
+ *ruby('print "[#{ENV["TERM_PROGRAM"]}|#{ENV["MY_APP"]}]"'))
410
+ wait_for(t) {text(t).include? ']'}
411
+ assert_include text(t), '[my-app|yes]'
412
+ end
413
+
414
+ def test_spawn_with_nil_env_removes_variable()
415
+ t = terminal 60, 6
416
+ # ENV#key? tells an unset variable from one set to an empty string
417
+ t.spawn(
418
+ {'TERM_PROGRAM' => nil, 'EMPTY' => ''},
419
+ *ruby('print "[#{ENV.key?("TERM_PROGRAM")}|#{ENV.key?("EMPTY")}]"'))
420
+ wait_for(t) {text(t).include? ']'}
421
+ assert_include text(t), '[false|true]'
422
+ end
423
+
424
+ def test_spawn_with_env_only()
425
+ t = terminal 60, 6
426
+ t.spawn MY_APP: 'yes'
427
+ wait_for(t) {t.alive?}
428
+ assert_true t.alive?
429
+ end
430
+
431
+ def test_spawn_with_string()
432
+ t = terminal 40, 6
433
+ # a construct only a shell expands, to show that one was involved
434
+ command, expected = win32? ?
435
+ ['echo %TERM_PROGRAM%', 'reflex-terminal'] :
436
+ ['printf "hello world" | tr a-z A-Z', 'HELLO WORLD']
437
+
438
+ t.spawn command
439
+ wait_for(t) {text(t).include? expected}
440
+ assert_include text(t), expected
441
+ end
442
+
443
+ def test_write_pointer()
444
+ t = terminal 40, 10
445
+ t.resize 40, 10, cell_width: 8, cell_height: 16
446
+
447
+ types = R::Pointer::MOUSE | R::Pointer::MOUSE_LEFT
448
+ down = R::PointerEvent.new(
449
+ R::Pointer.new(
450
+ 0, types, R::Pointer::DOWN, [12, 20], 0, 1, false, 0))
451
+
452
+ assert_false t.mouse_tracking?
453
+ t.write_pointer down
454
+ assert_equal '', t.read_pending_input# tracking off: nothing is sent
455
+
456
+ t.feed "\e[?1000h\e[?1006h"# normal tracking + SGR format
457
+ assert_true t.mouse_tracking?
458
+ t.write_pointer down
459
+ assert_equal "\e[<0;2;2M", t.read_pending_input# cell (2, 2), left press
460
+ end
461
+
462
+ def test_write_wheel()
463
+ t = terminal 40, 10
464
+ t.resize 40, 10, cell_width: 8, cell_height: 16
465
+ t.feed "\e[?1000h\e[?1006h"
466
+
467
+ # reflex counts a wheel delta downwards, so button 4 is a negative one
468
+ t.write_wheel R::WheelEvent.new(0, 0, 0, 0, -1, 0, 0)
469
+ assert_equal "\e[<64;1;1M\e[<64;1;1m", t.read_pending_input
470
+
471
+ t.write_wheel R::WheelEvent.new(0, 0, 0, 0, 1, 0, 0)
472
+ assert_equal "\e[<65;1;1M\e[<65;1;1m", t.read_pending_input
473
+ end
474
+
475
+ def test_select()
476
+ t = terminal 20, 4
477
+ t.feed "hello world\r\nfoo bar baz\r\n"
478
+ t.update
479
+
480
+ assert_false t.selection?
481
+ assert_equal '', t.selected_text
482
+
483
+ t.select 0, 0, 4, 1
484
+ assert_true t.selection?
485
+ assert_equal "hello world\nfoo b", t.selected_text
486
+
487
+ t.select 4, 1, 0, 0# dragging upward gives the later cell first
488
+ assert_equal "hello world\nfoo b", t.selected_text
489
+ end
490
+
491
+ def test_select_off_the_screen()
492
+ t = terminal 20, 4
493
+ t.feed "hello world\r\n"
494
+ t.update
495
+
496
+ # 65536 would name column 0 again if the column were narrowed to the
497
+ # cell index type without a range check of its own
498
+ [[-1, 0], [100, 0], [65536, 0], [0, -100]].each do |x, y|
499
+ t.select x, y, 4, 0
500
+ assert_false t.selection?, "#{x}, #{y} as the first cell"
501
+
502
+ t.select 0, 0, x, y
503
+ assert_false t.selection?, "#{x}, #{y} as the second cell"
504
+ end
505
+ end
506
+
507
+ def test_select_marks_spans()
508
+ t = terminal 20, 4
509
+ t.feed 'あいうえお'
510
+ t.update
511
+
512
+ # the second column holds the spacer of a wide cell, which the spans
513
+ # leave out: the character standing there still has to be marked
514
+ t.select 1, 0, 1, 0
515
+ t.update
516
+ assert_equal 'あ', t.selected_text
517
+ assert_equal(
518
+ [['あ', true], ['いうえお', false]],
519
+ t.each_span.map {|x, y, w, str, fg, bg, flags|
520
+ [str, (flags & T::SELECTED) != 0]})
521
+ end
522
+
523
+ def test_select_rect()
524
+ t = terminal 20, 4
525
+ t.feed "hello world\r\nfoo bar baz\r\n"
526
+ t.update
527
+
528
+ t.select_rect 0, 0, 4, 1
529
+ assert_equal "hello\nfoo b", t.selected_text
530
+ end
531
+
532
+ def test_select_word()
533
+ t = terminal 40, 4
534
+ t.feed "hello world\r\n"
535
+ t.update
536
+
537
+ t.select_word 0, 0
538
+ assert_equal 'hello', t.selected_text
539
+
540
+ t.select_word 6, 0# the column decides which word
541
+ assert_equal 'world', t.selected_text
542
+ end
543
+
544
+ def test_select_line()
545
+ t = terminal 10, 4
546
+ t.feed 'aaaaabbbbbccccc'# wraps onto a second row
547
+ t.update
548
+ assert_equal ['aaaaabbbbb', 'ccccc'], t.lines[0, 2]
549
+
550
+ t.select_line 0
551
+ assert_equal 'aaaaabbbbbccccc', t.selected_text
552
+ end
553
+
554
+ def test_select_in_the_history()
555
+ t = terminal 20, 3, scrollback_bytes: 64 * 1024
556
+ 30.times {|i| t.feed "line#{i}\r\n"}
557
+ t.update
558
+
559
+ # the viewport starts at line28, as test_scrollback works out, so two
560
+ # rows back is a line the history alone still holds
561
+ t.select_line(-2)
562
+ assert_equal 'line26', t.selected_text
563
+ end
564
+
565
+ def test_deselect()
566
+ t = terminal 20, 4
567
+ t.feed "hello\r\n"
568
+ t.update
569
+
570
+ t.select_word 0, 0
571
+ assert_true t.selection?
572
+
573
+ t.deselect
574
+ assert_false t.selection?
575
+ assert_equal '', t.selected_text
576
+ end
577
+
578
+ def test_scrollback()
579
+ t = terminal 20, 3, scrollback_bytes: 64 * 1024
580
+ 30.times {|i| t.feed "line#{i}\r\n"}
581
+ t.update
582
+ assert_equal 0, t.scroll
583
+
584
+ t.scroll_by(-10)
585
+ assert_equal(-10, t.scroll)
586
+
587
+ t.scroll_by 4
588
+ assert_equal(-6, t.scroll)
589
+
590
+ # the last row is the empty line the cursor sits on, so the
591
+ # viewport starts at line28 and two rows back is line26
592
+ t.scroll_to(-2)
593
+ t.update
594
+ assert_equal 'line26', t.lines.first
595
+
596
+ t.scroll_to(-10000)# further back than the history goes
597
+ t.update
598
+ assert_equal 'line0', t.lines.first
599
+ top = t.scroll
600
+ assert_operator top, :<, 0
601
+
602
+ t.scroll_by(-1)# already at the top, so it stays
603
+ assert_equal top, t.scroll
604
+
605
+ t.scroll_to 0
606
+ assert_equal 0, t.scroll
607
+ end
608
+
609
+ def test_scrollback_disabled()
610
+ t = terminal 20, 3, scrollback_bytes: 0
611
+ 30.times {|i| t.feed "line#{i}\r\n"}
612
+ t.update
613
+
614
+ t.scroll_by(-10)
615
+ assert_equal 0, t.scroll
616
+ end
617
+
618
+ def test_history()
619
+ t = terminal 20, 3, scrollback_bytes: 64 * 1024
620
+ 30.times {|i| t.feed "line#{i}\r\n"}
621
+ t.update
622
+
623
+ # the 3 rows still on screen are line28, line29 and the empty
624
+ # line the cursor sits on
625
+ assert_equal 28, t.history_rows
626
+
627
+ lines = t.each_history_line.to_a
628
+ assert_equal 28, lines.size
629
+ assert_equal %w[line0 line1 line2], lines[0, 3]
630
+ assert_equal 'line27', lines.last
631
+ end
632
+
633
+ def test_history_longer_than_a_chunk()
634
+ rows = T::HISTORY_CHUNK_SIZE * 2 + 1
635
+ t = terminal 20, 3, scrollback_bytes: 4 * 1024 * 1024
636
+ rows.times {|i| t.feed "line#{i}\r\n"}
637
+ t.update
638
+
639
+ lines = t.each_history_line.to_a
640
+ assert_equal t.history_rows, lines.size
641
+ assert_equal 'line0', lines.first
642
+ assert_equal "line#{rows - 3}", lines.last# the last 2 rows are still on screen
643
+ end
644
+
645
+ def test_history_without_scrollback()
646
+ t = terminal 20, 3, scrollback_bytes: 0
647
+ 30.times {|i| t.feed "line#{i}\r\n"}
648
+ t.update
649
+
650
+ assert_equal 0, t.history_rows
651
+ assert_equal [], t.each_history_line.to_a
652
+ end
653
+
654
+ def test_paste()
655
+ t = terminal
656
+ t.paste 'hello'
657
+ assert_equal 'hello', t.read_pending_input
658
+
659
+ t.feed "\e[?2004h"# bracketed paste mode
660
+ t.paste 'hello'
661
+ assert_equal "\e[200~hello\e[201~", t.read_pending_input
662
+ end
663
+
664
+ def test_paste_sanitize()
665
+ t = terminal
666
+ # newlines would run each line as its own command, and an escape
667
+ # sequence could drive the terminal, so both are defused
668
+ str = "a\nb\e[31m"
669
+ t.paste str
670
+ assert_equal "a\rb [31m", t.read_pending_input
671
+ assert_equal "a\nb\e[31m", str
672
+ end
673
+
674
+ def test_reset()
675
+ t = terminal 80, 4
676
+ t.feed "\e[31mred"
677
+ t.update
678
+ t.reset
679
+ t.update
680
+ assert_equal [], t.each_span.to_a
681
+ end
682
+
683
+ end# TestTerminal