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 +4 -4
- data/lib/cli.rb +1 -10
- data/lib/version.rb +1 -1
- metadata +1 -3
- data/lib/label_selector.rb +0 -28
- data/lib/selecta.rb +0 -803
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '08657a06d9b268272d9612ac57be02f24d885507770897bb03ac3b4cc60d808c'
|
4
|
+
data.tar.gz: e40e12fc73ebf44db690396983226e62218195fbbda0fa4b9879e5e714037305
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
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.
|
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
|
data/lib/label_selector.rb
DELETED
@@ -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
|