ora-cli 0.1.0

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