dcm 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98e2a0e565c14da029309665dedc6b4b62eefb485dd2307f8f3f1856004bee9b
4
- data.tar.gz: d4ac7611510ee45d60dc7c33cdeb2b04ecd7961220c5aa767c7234086bd5934a
3
+ metadata.gz: '08657a06d9b268272d9612ac57be02f24d885507770897bb03ac3b4cc60d808c'
4
+ data.tar.gz: e40e12fc73ebf44db690396983226e62218195fbbda0fa4b9879e5e714037305
5
5
  SHA512:
6
- metadata.gz: ce70b31fdfb6aad38d3da275cadaf18e8081d623375b72ecf800b2257c01412f515e3b07dc672a029416ece0e37d09977d77169cfc4b104dc9cf323e7a1ce1cd
7
- data.tar.gz: 494d1ea75f17345c1f092e357edf1699f0ef81ef1ec39da50edb60a330b10d6877b9e965b7b8d647347d6a740de7835c20c0b30db0f611626842ba0bfb7c51d3
6
+ metadata.gz: a7dc09f008c91acc39e4357ee77d4f2304efb586a4ec3d906f293104b359891e8cd9f75d316fd3882625f1e095cccd40cb5c8c42b68f2ff787ed1ff208f45443
7
+ data.tar.gz: cca6aecae8bc75568acccbef03c422f1dd64036fcbe4fb60e20d988e731dd075fa80433e865bae5b1c2f13ebbbe03effb182070c7b08efae53c799c74e537b2b
data/lib/cli.rb CHANGED
@@ -1,8 +1,6 @@
1
1
  require "rubygems"
2
2
  require "bundler"
3
3
 
4
- $LOAD_PATH.unshift "../automotive-ecu/lib"
5
-
6
4
  require "ecu"
7
5
  require "thor"
8
6
  require "filesize"
@@ -12,8 +10,8 @@ require_relative "file_reader"
12
10
  require_relative "codinginfo"
13
11
  require_relative "tempfile_handler"
14
12
  require_relative "diff_viewer"
15
- require_relative "label_selector"
16
13
  require_relative "variant_filter"
14
+ require_relative "list_colorizer"
17
15
  require_relative "core_ext"
18
16
  require_relative "version"
19
17
 
@@ -56,13 +54,6 @@ module Dcm
56
54
  exit 1
57
55
  end
58
56
 
59
- desc "liveview FILE", "Live search and view labels in FILE"
60
- def liveview(file=nil)
61
- list = parse_file(file)
62
- selector = LabelSelector.new(list)
63
- loop { selector.choose }
64
- end
65
-
66
57
  desc "creta2begu FILTER CODING FILE", "Convert a hierarchical DCM to a flat DCM"
67
58
  def creta2begu(expr, codingpath, file)
68
59
  filter = VariantFilter.new(expr)
data/lib/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Dcm
2
- VERSION = "0.1.5"
2
+ VERSION = "0.1.6"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dcm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonas Mueller
@@ -180,9 +180,7 @@ files:
180
180
  - lib/core_ext.rb
181
181
  - lib/diff_viewer.rb
182
182
  - lib/file_reader.rb
183
- - lib/label_selector.rb
184
183
  - lib/list_colorizer.rb
185
- - lib/selecta.rb
186
184
  - lib/tempfile_handler.rb
187
185
  - lib/variant_filter.rb
188
186
  - lib/version.rb
@@ -1,28 +0,0 @@
1
- require_relative "core_ext"
2
- require_relative "selecta"
3
- require_relative "list_colorizer"
4
-
5
- class LabelSelector
6
- def initialize(list)
7
- @keys = list.map(&:name)
8
- @values = list.map { |e| ListColorizer.call(e.to_s(detail: true)) }
9
- end
10
-
11
- def choose
12
- view = selecta(@keys, @values)
13
- print view
14
- STDIN.gets
15
- rescue Interrupt => e
16
- clear_screen
17
- exit 0
18
- end
19
-
20
- def selecta(keys, values)
21
- clear_screen
22
- Selecta.new.main_api(keys: keys, values: values, options: { height: "full" }).tap { clear_screen }
23
- end
24
-
25
- def clear_screen
26
- print "\e[2J\e[H"
27
- end
28
- end
data/lib/selecta.rb DELETED
@@ -1,803 +0,0 @@
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