dcm 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
data/lib/selecta.rb ADDED
@@ -0,0 +1,803 @@
1
+ #!/usr/bin/env bash
2
+ # vim: set ft=ruby:
3
+
4
+ # This file executes as a bash script, which turns around and executes Ruby via
5
+ # the line below. The -x argument to Ruby makes it discard everything before
6
+ # the second "!ruby" shebang. This allows us to work on Linux, where the
7
+ # shebang can only have one argument so we can't directly say
8
+ # "#!/usr/bin/env ruby --disable-gems". Thanks for that, Linux.
9
+ #
10
+ # If this seems confusing, don't worry. You can treat it as a normal Ruby file
11
+ # starting with the "!ruby" shebang below.
12
+
13
+ if RUBY_VERSION < '1.9.3'
14
+ abort "error: Selecta requires Ruby 1.9.3 or higher."
15
+ end
16
+
17
+ require "optparse"
18
+ require "io/console"
19
+ require "io/wait"
20
+ require "set"
21
+
22
+ KEY_CTRL_C = ?\C-c
23
+ KEY_CTRL_N = ?\C-n
24
+ KEY_CTRL_P = ?\C-p
25
+ KEY_CTRL_U = ?\C-u
26
+ KEY_CTRL_H = ?\C-h
27
+ KEY_CTRL_W = ?\C-w
28
+ KEY_CTRL_J = ?\C-j
29
+ KEY_CTRL_M = ?\C-m
30
+ KEY_DELETE = 127.chr # Equivalent to ?\C-?
31
+
32
+ class Selecta
33
+ VERSION = [0, 0, 6]
34
+
35
+ def main_api(keys:, values: keys, options: {})
36
+ unless keys.size == values.size
37
+ $stderr.puts("Keys and values must be arrays of equals size!")
38
+ exit(1)
39
+ end
40
+ # We have to parse options before setting up the screen or trying to read
41
+ # the input in case the user did '-h', an invalid option, etc. and we need
42
+ # to terminate.
43
+ options = Configuration.parse_options(options)
44
+ input_lines = keys
45
+
46
+ search = Screen.with_screen do |screen, tty|
47
+ begin
48
+ config = Configuration.from_inputs(input_lines, options, screen.height)
49
+ run_in_screen(config, screen, tty)
50
+ ensure
51
+ config.visible_choices.times { screen.newline }
52
+ screen.cursor_up(config.height - 1)
53
+ screen.write(Text[""])
54
+ screen.cursor_up(1)
55
+ end
56
+ end
57
+
58
+ unless search.selection == Search::NoSelection
59
+ values[keys.index(search.selection)]
60
+ end
61
+ rescue Screen::NotATTY
62
+ $stderr.puts(
63
+ "Can't get a working TTY. Selecta requires an ANSI-compatible terminal.")
64
+ exit(1)
65
+ rescue Abort
66
+ # We were aborted via ^C.
67
+ #
68
+ # If we didn't mess with the TTY configuration at all, then ^C would send
69
+ # SIGINT to the entire process group. That would terminate both Selecta and
70
+ # anything piped into or out of it. Because Selecta puts the terminal in
71
+ # raw mode, that doesn't happen; instead, we detect the ^C as normal input
72
+ # and raise Abort, which leads here.
73
+ #
74
+ # To make pipelines involving Selecta behave as people expect, we send
75
+ # SIGINT to our own process group, which should exactly match what termios
76
+ # would do to us if the terminal weren't in raw mode. "Should!" <- Remove
77
+ # those scare quotes if ten years pass without this breaking!
78
+ #
79
+ # The SIGINT will cause Ruby to raise Interrupt, so we also have to handle
80
+ # that here.
81
+ begin
82
+ Process.kill("INT", -Process.getpgrp)
83
+ rescue Interrupt
84
+ exit(1)
85
+ end
86
+ end
87
+
88
+ def run_in_screen(config, screen, tty)
89
+ search = Search.from_config(config)
90
+ search = ui_event_loop(search, screen, tty)
91
+ search
92
+ end
93
+
94
+ # Use the search and screen to process user actions until they quit.
95
+ def ui_event_loop(search, screen, tty)
96
+ while not search.done?
97
+ Renderer.render!(search, screen)
98
+ search = handle_keys(search, tty)
99
+ end
100
+ search
101
+ end
102
+
103
+ def handle_keys(search, tty)
104
+ new_query_chars = ""
105
+
106
+ # Read through all of the buffered input characters. Process control
107
+ # characters immediately. Save any query characters to be processed
108
+ # together at the end, since there's no reason to process intermediate
109
+ # results when there are more characters already buffered.
110
+ tty.get_available_input.chars.each do |char|
111
+ is_query_char = !!(char =~ /[[:print:]]/)
112
+ if is_query_char
113
+ new_query_chars << char
114
+ else
115
+ search = handle_control_character(search, char)
116
+ end
117
+ end
118
+
119
+ if new_query_chars.empty?
120
+ search
121
+ else
122
+ search.append_search_string(new_query_chars)
123
+ end
124
+ end
125
+
126
+ # On each keystroke, generate a new search object
127
+ def handle_control_character(search, key)
128
+ case key
129
+
130
+ when KEY_CTRL_N then search.down
131
+ when KEY_CTRL_P then search.up
132
+
133
+ when KEY_CTRL_U then search.clear_query
134
+ when KEY_CTRL_W then search.delete_word
135
+ when KEY_CTRL_H, KEY_DELETE then search.backspace
136
+
137
+ when ?\r, KEY_CTRL_J, KEY_CTRL_M then search.done
138
+
139
+ when KEY_CTRL_C then raise Abort
140
+
141
+ else search
142
+ end
143
+ end
144
+
145
+ class Abort < RuntimeError; end
146
+ end
147
+
148
+ class Configuration < Struct.new(:height, :initial_search, :choices)
149
+ def initialize(height, initialize, choices)
150
+ # Constructor is defined to force argument presence; otherwise Struct
151
+ # defaults missing arguments to nil
152
+ super
153
+ end
154
+
155
+ def visible_choices
156
+ height - 1
157
+ end
158
+
159
+ def self.from_inputs(choices, options, screen_height=21)
160
+ # Shrink the number of visible choices if the screen is too small
161
+ if options.fetch(:height) == "full"
162
+ height = screen_height
163
+ else
164
+ height = [options.fetch(:height), screen_height].min
165
+ end
166
+
167
+ choices = massage_choices(choices)
168
+ Configuration.new(height, options.fetch(:search), choices)
169
+ end
170
+
171
+ def self.default_options
172
+ parse_options({})
173
+ end
174
+
175
+ def self.parse_options(options)
176
+ defaults = { search: "", height: 21 }
177
+ defaults.merge(options)
178
+ end
179
+
180
+ def self.massage_choices(choices)
181
+ choices.map do |choice|
182
+ # Encoding to UTF-8 with `:invalid => :replace` isn't good enough; it
183
+ # still leaves some invalid characters. For example, this string will fail:
184
+ #
185
+ # echo "девуш\xD0:" | selecta
186
+ #
187
+ # Round-tripping through UTF-16, with `:invalid => :replace` as well,
188
+ # fixes this. I don't understand why. I found it via:
189
+ #
190
+ # http://stackoverflow.com/questions/2982677/ruby-1-9-invalid-byte-sequence-in-utf-8
191
+ if choice.valid_encoding?
192
+ choice
193
+ else
194
+ utf16 = choice.encode('UTF-16', 'UTF-8', :invalid => :replace, :replace => '')
195
+ utf16.encode('UTF-8', 'UTF-16')
196
+ end.strip
197
+ end
198
+ end
199
+ end
200
+
201
+ class Search
202
+ attr_reader :index, :query, :config, :original_matches, :all_matches, :best_matches
203
+
204
+ def initialize(vars)
205
+ @config = vars.fetch(:config)
206
+ @index = vars.fetch(:index)
207
+ @query = vars.fetch(:query)
208
+ @done = vars.fetch(:done)
209
+ @original_matches = vars.fetch(:original_matches)
210
+ @all_matches = vars.fetch(:all_matches)
211
+ @best_matches = vars.fetch(:best_matches)
212
+ @vars = vars
213
+ end
214
+
215
+ def self.from_config(config)
216
+ trivial_matches = config.choices.reject(&:empty?).map do |choice|
217
+ Match.trivial(choice)
218
+ end
219
+
220
+ search = new(:config => config,
221
+ :index => 0,
222
+ :query => "",
223
+ :done => false,
224
+ :original_matches => trivial_matches,
225
+ :all_matches => trivial_matches,
226
+ :best_matches => trivial_matches)
227
+
228
+ if config.initial_search.empty?
229
+ search
230
+ else
231
+ search.append_search_string(config.initial_search)
232
+ end
233
+ end
234
+
235
+ # Construct a new Search by merging in a hash of changes.
236
+ def merge(changes)
237
+ vars = @vars.merge(changes)
238
+
239
+ # If the query changed, throw away the old matches so that new ones will be
240
+ # computed.
241
+ matches_are_stale = vars.fetch(:query) != @query
242
+ if matches_are_stale
243
+ vars = vars.reject { |key| key == :matches }
244
+ end
245
+
246
+ Search.new(vars)
247
+ end
248
+
249
+ def done?
250
+ @done
251
+ end
252
+
253
+ def selection
254
+ if @aborted
255
+ NoSelection
256
+ else
257
+ match = best_matches.fetch(@index) { NoSelection }
258
+ if match == NoSelection
259
+ match
260
+ else
261
+ match.original_choice
262
+ end
263
+ end
264
+ end
265
+
266
+ def down
267
+ move_cursor(1)
268
+ end
269
+
270
+ def up
271
+ move_cursor(-1)
272
+ end
273
+
274
+ def max_visible_choices
275
+ [@config.visible_choices, all_matches.count].min
276
+ end
277
+
278
+ def append_search_string(string)
279
+ merge(:index => 0,
280
+ :query => @query + string)
281
+ .recompute_matches(all_matches)
282
+ end
283
+
284
+ def backspace
285
+ merge(:index => 0,
286
+ :query => @query[0...-1])
287
+ .recompute_matches
288
+ end
289
+
290
+ def clear_query
291
+ merge(:index => 0,
292
+ :query => "")
293
+ .recompute_matches
294
+ end
295
+
296
+ def delete_word
297
+ merge(:index => 0,
298
+ :query => @query.sub(/[^ ]* *$/, ""))
299
+ .recompute_matches
300
+ end
301
+
302
+ def done
303
+ merge(:done => true)
304
+ end
305
+
306
+ def abort
307
+ merge(:aborted => true)
308
+ end
309
+
310
+ def recompute_matches(previous_matches=self.original_matches)
311
+ if self.query.empty?
312
+ merge(:all_matches => original_matches,
313
+ :best_matches => original_matches)
314
+ else
315
+ all_matches = recompute_all_matches(previous_matches)
316
+ best_matches = recompute_best_matches(all_matches)
317
+ merge(:all_matches => all_matches, :best_matches => best_matches)
318
+ end
319
+ end
320
+
321
+ private
322
+
323
+ def recompute_all_matches(previous_matches)
324
+ query = self.query.downcase
325
+ query_chars = query.chars.to_a
326
+
327
+ matches = previous_matches.map do |match|
328
+ choice = match.choice
329
+ score, range = Score.score(choice, query_chars)
330
+ range ? match.refine(score, range) : nil
331
+ end.compact
332
+ end
333
+
334
+ def recompute_best_matches(all_matches)
335
+ return [] if all_matches.empty?
336
+
337
+ count = [@config.visible_choices, all_matches.count].min
338
+ matches = []
339
+
340
+ best_score = all_matches.min_by(&:score).score
341
+
342
+ # Consider matches, beginning with the best-scoring. A match always ranks
343
+ # higher than other matches with worse scores. However, the ranking between
344
+ # matches of the same score depends on other factors, so we always have to
345
+ # consider all matches of a given score.
346
+ (best_score..Float::INFINITY).each do |score|
347
+ matches += all_matches.select { |match| match.score == score }
348
+ # Stop if we have enough matches.
349
+ return sub_sort_matches(matches)[0, count] if matches.length >= count
350
+ end
351
+ end
352
+
353
+ def sub_sort_matches(matches)
354
+ matches.sort_by do |match|
355
+ [match.score, match.matching_range.count, match.choice.length]
356
+ end
357
+ end
358
+
359
+ def move_cursor(direction)
360
+ if max_visible_choices > 0
361
+ index = (@index + direction) % max_visible_choices
362
+ merge(:index => index)
363
+ else
364
+ self
365
+ end
366
+ end
367
+
368
+ class NoSelection; end
369
+ end
370
+
371
+ class Match < Struct.new(:original_choice, :choice, :score, :matching_range)
372
+ def self.trivial(choice)
373
+ empty_range = (0...0)
374
+ new(choice, choice.downcase, 0, empty_range)
375
+ end
376
+
377
+ def to_text
378
+ if matching_range.none?
379
+ Text[original_choice]
380
+ else
381
+ before = original_choice[0...matching_range.begin]
382
+ matching = original_choice[matching_range.begin..matching_range.end]
383
+ after = original_choice[(matching_range.end + 1)..-1]
384
+ Text[before, :red, matching, :default, after]
385
+ end
386
+ end
387
+
388
+ def refine(score, range)
389
+ Match.new(original_choice, choice, score, range)
390
+ end
391
+ end
392
+
393
+ class Score
394
+ class << self
395
+ # A word boundary character is any ASCII character that's not alphanumeric.
396
+ # This isn't strictly correct: characters like ZERO WIDTH NON-JOINER,
397
+ # non-Latin punctuation, etc. will be incorrectly treated as non-boundary
398
+ # characters. This is necessary for performance: even building a Set of
399
+ # boundary characters based only on the input text is prohibitively slow (2-3
400
+ # seconds for 80,000 input paths on a 2014 MacBook Pro).
401
+ BOUNDARY_CHARS = (0..127).map(&:chr).select do |char|
402
+ char !~ /[A-Za-z0-9_]/
403
+ end.to_set
404
+
405
+ def score(string, query_chars)
406
+ query_chars -= [" "] # Change by Jonas Müller
407
+ first_char, *rest = query_chars
408
+
409
+ # Keep track of the best match that we've seen. This is uglier than
410
+ # building a list of matches and then sorting them, but it's faster.
411
+ best_score = Float::INFINITY
412
+ best_range = nil
413
+
414
+ # Iterate over each instance of the first query character. E.g., if we're
415
+ # querying the string "axbx" for "x", we'll start at index 1 and index 3.
416
+ each_index_of_char_in_string(string, first_char) do |first_index|
417
+ score = 1
418
+
419
+ # Find the best score starting at this index.
420
+ score, last_index = find_end_of_match(string, rest, score, first_index)
421
+
422
+ # Did we do better than we have for the best starting point so far?
423
+ if last_index && score < best_score
424
+ best_score = score
425
+ best_range = (first_index..last_index)
426
+ end
427
+ end
428
+
429
+ [best_score, best_range]
430
+ end
431
+
432
+ # Find all occurrences of the character in the string, returning their indexes.
433
+ def each_index_of_char_in_string(string, char)
434
+ index = 0
435
+ while index
436
+ index = string.index(char, index)
437
+ if index
438
+ yield index
439
+ index += 1
440
+ end
441
+ end
442
+ end
443
+
444
+ # Find each of the characters in the string, moving strictly left to right.
445
+ def find_end_of_match(string, chars, score, first_index)
446
+ last_index = first_index
447
+
448
+ # Remember the type of the last character match for special scoring.
449
+ last_type = nil
450
+
451
+ chars.each do |this_char|
452
+ # Where's the next occurrence of this character? The optimal algorithm
453
+ # would consider all instances of query character, but that's slower
454
+ # than this eager method.
455
+ index = string.index(this_char, last_index + 1)
456
+
457
+ # This character doesn't occur in the string, so this can't be a match.
458
+ return [nil, nil] unless index
459
+
460
+ if index == last_index + 1
461
+ # This matching character immediately follows the last matching
462
+ # character. The first two sequential characters score; subsequent
463
+ # ones don't.
464
+ if last_type != :sequential
465
+ last_type = :sequential
466
+ score += 1
467
+ end
468
+ # This character follows a boundary character.
469
+ elsif BOUNDARY_CHARS.include?(string[index - 1])
470
+ if last_type != :boundary
471
+ last_type = :boundary
472
+ score += 1
473
+ end
474
+ # This character isn't special.
475
+ else
476
+ last_type = :normal
477
+ score += index - last_index
478
+ end
479
+
480
+ last_index = index
481
+ end
482
+
483
+ [score, last_index]
484
+ end
485
+ end
486
+ end
487
+
488
+ class Renderer < Struct.new(:search)
489
+ def self.render!(search, screen)
490
+ rendered = Renderer.new(search).render
491
+ width = screen.width
492
+
493
+ screen.with_cursor_hidden do
494
+ rendered.choices.each_with_index do |choice, index|
495
+ choice = choice.truncate_to_width(width)
496
+ is_last_line = (index == rendered.choices.length - 1)
497
+ choice += Text["\n"] unless is_last_line
498
+ screen.write(choice)
499
+ end
500
+
501
+ # Move back up to the search line and redraw it, which will naturally
502
+ # leave the cursor at the end of the query.
503
+ screen.cursor_up(rendered.choices.length - 1)
504
+ screen.write(rendered.search_line.truncate_to_width(width))
505
+ end
506
+ end
507
+
508
+ def render
509
+ search_line = Text["#{match_count_label} > " + search.query]
510
+
511
+ matches = search.best_matches[0, search.config.visible_choices]
512
+ matches = matches.each_with_index.map do |match, index|
513
+ if index == search.index
514
+ Text[:inverse] + match.to_text + Text[:reset]
515
+ else
516
+ match.to_text
517
+ end
518
+ end
519
+ matches = correct_match_count(matches)
520
+ lines = [search_line] + matches
521
+ Rendered.new(lines, search_line)
522
+ end
523
+
524
+ def match_count_label
525
+ choice_count = search.original_matches.length
526
+ max_label_width = choice_count.to_s.length
527
+ match_count = search.all_matches.count
528
+ match_count.to_s.rjust(max_label_width)
529
+ end
530
+
531
+ def correct_match_count(matches)
532
+ limited = matches[0, search.config.visible_choices]
533
+ padded = limited + [Text[""]] * (search.config.visible_choices - limited.length)
534
+ padded
535
+ end
536
+
537
+ class Rendered < Struct.new(:choices, :search_line)
538
+ end
539
+
540
+ private
541
+
542
+ def replace_array_element(array, index, new_value)
543
+ array = array.dup
544
+ array[index] = new_value
545
+ array
546
+ end
547
+ end
548
+
549
+ class Screen
550
+ def self.with_screen
551
+ SelectaTTY.with_tty do |tty|
552
+ screen = self.new(tty)
553
+ screen.configure_tty
554
+ begin
555
+ raise NotATTY if screen.height == 0
556
+ yield screen, tty
557
+ ensure
558
+ screen.restore_tty
559
+ tty.puts
560
+ end
561
+ end
562
+ end
563
+
564
+ class NotATTY < RuntimeError; end
565
+
566
+ attr_reader :tty
567
+
568
+ def initialize(tty)
569
+ @tty = tty
570
+ @original_stty_state = tty.stty("-g")
571
+ end
572
+
573
+ def configure_tty
574
+ # -echo: terminal doesn't echo typed characters back to the terminal
575
+ # -icanon: terminal doesn't interpret special characters (like backspace)
576
+ tty.stty("raw -echo -icanon")
577
+ end
578
+
579
+ def restore_tty
580
+ tty.stty("#{@original_stty_state}")
581
+ end
582
+
583
+ def suspend
584
+ restore_tty
585
+ begin
586
+ yield
587
+ configure_tty
588
+ rescue
589
+ restore_tty
590
+ end
591
+ end
592
+
593
+ def with_cursor_hidden(&block)
594
+ write_bytes(ANSI.hide_cursor)
595
+ begin
596
+ block.call
597
+ ensure
598
+ write_bytes(ANSI.show_cursor)
599
+ end
600
+ end
601
+
602
+ def height
603
+ tty.winsize[0]
604
+ end
605
+
606
+ def width
607
+ tty.winsize[1]
608
+ end
609
+
610
+ def cursor_up(lines)
611
+ write_bytes(ANSI.cursor_up(lines))
612
+ end
613
+
614
+ def newline
615
+ write_bytes("\n")
616
+ end
617
+
618
+ def write(text)
619
+ write_bytes(ANSI.clear_line)
620
+ write_bytes("\r")
621
+
622
+ text.components.each do |component|
623
+ if component.is_a? String
624
+ write_bytes(expand_tabs(component))
625
+ elsif component == :inverse
626
+ write_bytes(ANSI.inverse)
627
+ elsif component == :reset
628
+ write_bytes(ANSI.reset)
629
+ else
630
+ if component =~ /_/
631
+ fg, bg = component.to_s.split(/_/).map(&:to_sym)
632
+ else
633
+ fg, bg = component, :default
634
+ end
635
+ write_bytes(ANSI.color(fg, bg))
636
+ end
637
+ end
638
+ end
639
+
640
+ def expand_tabs(string)
641
+ # Modified from http://markmail.org/message/avdjw34ahxi447qk
642
+ tab_width = 8
643
+ string.gsub(/([^\t\n]*)\t/) do
644
+ $1 + " " * (tab_width - ($1.size % tab_width))
645
+ end
646
+ end
647
+
648
+ def write_bytes(bytes)
649
+ tty.console_file.write(bytes)
650
+ end
651
+ end
652
+
653
+ class Text
654
+ attr_reader :components
655
+
656
+ def self.[](*args)
657
+ if args.length == 1 && args[0].is_a?(Text)
658
+ # When called as `Text[some_text]`, just return the existing text.
659
+ args[0]
660
+ else
661
+ new(args)
662
+ end
663
+ end
664
+
665
+ def initialize(components)
666
+ @components = components
667
+ end
668
+
669
+ def ==(other)
670
+ components == other.components
671
+ end
672
+
673
+ def +(other)
674
+ Text[*(components + other.components)]
675
+ end
676
+
677
+ def truncate_to_width(width)
678
+ chars_remaining = width
679
+
680
+ # Truncate each string to the number of characters left within our
681
+ # allowed width. Leave anything else alone. This may result in empty
682
+ # strings and unused ANSI control codes in the output, but that's fine.
683
+ components = self.components.map do |component|
684
+ if component.is_a?(String)
685
+ component = component[0, chars_remaining]
686
+ chars_remaining -= component.length
687
+ end
688
+ component
689
+ end
690
+
691
+ Text.new(components)
692
+ end
693
+ end
694
+
695
+ class ANSI
696
+ ESC = 27.chr
697
+
698
+ class << self
699
+ def escape(sequence)
700
+ ESC + "[" + sequence
701
+ end
702
+
703
+ def clear
704
+ escape "2J"
705
+ end
706
+
707
+ def hide_cursor
708
+ escape "?25l"
709
+ end
710
+
711
+ def show_cursor
712
+ escape "?25h"
713
+ end
714
+
715
+ def cursor_up(lines)
716
+ escape "#{lines}A"
717
+ end
718
+
719
+ def clear_line
720
+ escape "2K"
721
+ end
722
+
723
+ def color(fg, bg=:default)
724
+ fg_codes = {
725
+ :black => 30,
726
+ :red => 31,
727
+ :green => 32,
728
+ :yellow => 33,
729
+ :blue => 34,
730
+ :magenta => 35,
731
+ :cyan => 36,
732
+ :white => 37,
733
+ :default => 39,
734
+ }
735
+ bg_codes = {
736
+ :black => 40,
737
+ :red => 41,
738
+ :green => 42,
739
+ :yellow => 43,
740
+ :blue => 44,
741
+ :magenta => 45,
742
+ :cyan => 46,
743
+ :white => 47,
744
+ :default => 49,
745
+ }
746
+ fg_code = fg_codes.fetch(fg)
747
+ bg_code = bg_codes.fetch(bg)
748
+ escape "#{fg_code};#{bg_code}m"
749
+ end
750
+
751
+ def inverse
752
+ escape("7m")
753
+ end
754
+
755
+ def reset
756
+ escape("0m")
757
+ end
758
+ end
759
+ end
760
+
761
+ class SelectaTTY < Struct.new(:console_file)
762
+ def self.with_tty(&block)
763
+ # Selecta reads data from stdin and writes it to stdout, so we can't draw
764
+ # UI and receive keystrokes through them. Fortunately, all modern
765
+ # Unix-likes provide /dev/tty, which IO.console gives us.
766
+ console_file = IO.console
767
+ tty = SelectaTTY.new(console_file)
768
+ block.call(tty)
769
+ end
770
+
771
+ def get_available_input
772
+ input = console_file.getc
773
+ while console_file.ready?
774
+ input += console_file.getc
775
+ end
776
+ input
777
+ end
778
+
779
+ def puts
780
+ console_file.puts
781
+ end
782
+
783
+ def winsize
784
+ console_file.winsize
785
+ end
786
+
787
+ def stty(args)
788
+ command("stty #{args}").strip
789
+ end
790
+
791
+ private
792
+
793
+ # Run a command with the TTY as stdin, capturing the output via a pipe
794
+ def command(command)
795
+ IO.pipe do |read_io, write_io|
796
+ pid = Process.spawn(command, :in => "/dev/tty", :out => write_io)
797
+ Process.wait(pid)
798
+ raise "Command failed: #{command.inspect}" unless $?.success?
799
+ write_io.close
800
+ read_io.read
801
+ end
802
+ end
803
+ end