ora-cli 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.
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