Module: FancyGets

Defined in:
lib/fancy_gets.rb,
lib/fancy_gets/version.rb

Constant Summary collapse

VERSION =
"0.1.9"

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.gets_internal_core(is_list, is_password, word_objects = nil, chosen = nil, prefix = "> ", postfix = " <", info = nil, height = nil, on_change = nil, on_select = nil) ⇒ Object

The internal routine that makes all the magic happen



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
# File 'lib/fancy_gets.rb', line 40

def self.gets_internal_core(is_list, is_password, word_objects = nil, chosen = nil, prefix = "> ", postfix = " <", info = nil, height = nil, on_change = nil, on_select = nil)
  # OK -- second parameter, is_password, means is_multiple when is_list is true
  is_multiple = is_list & is_password
  unless word_objects.nil? || is_list
    word_objects.sort! {|wo1, wo2| wo1.to_s <=> wo2.to_s}
  end
  words = word_objects.nil? ? [] : word_objects.map(&:to_s)
  if is_multiple
    chosen ||= [0] unless is_multiple
  else
    if chosen.is_a?(Enumerable)
      # Maybe find first string or object that matches the stuff they have sequenced in the chosen array
      string = chosen.first.to_s
    else
      string = chosen.to_s
    end
  end
  position = 0
  # Up and down arrows break with height of 3 or less
  height = words.length unless !height.nil? && height.is_a?(Numeric) && height >= 4
  winheight = IO.console.winsize.first - 3
  height = words.length if height > words.length
  height = winheight if height > winheight
  offset = 0
  sugg = ""
  prev_sugg = ""

  # gsub causes any color changes to not offset spacing
  uncolor = lambda { |word| word.gsub(/\033\[[0-9;]+m/, "") }

  max_word_length = words.map{|word| uncolor.call(word).length}.max

  write_sugg = lambda do
    # Find first word that case-insensitive matches what they've typed
    if string.empty?
      sugg = ""
    else
      sugg = words.select { |word| uncolor.call(word).downcase.start_with? string.downcase }.first || ""
    end
    extra_spaces = uncolor.call(prev_sugg).length - uncolor.call(sugg).length
    extra_spaces = 0 if extra_spaces < 0
    " - #{sugg}#{" " * extra_spaces} #{"\b" * ((uncolor.call(sugg).length + 4 + extra_spaces) + string.length - position)}"
  end

  pre_length = uncolor.call(prefix).length
  post_length = uncolor.call(postfix).length
  pre_post_length = pre_length + post_length

  # Used for dropdown select / deselect
  clear_dropdown_info = lambda do
    print "\b" * (uncolor.call(words[position]).length + pre_post_length)
    print (27.chr + 91.chr + 66.chr) * ((height + offset) - position)
    info_length = uncolor.call(info).length
    print " " * info_length + "\b" * info_length
  end
  make_select = lambda do |is_select, is_go_to_front = false, is_end_at_front = false|
    word = words[position]
    print "\b" * (uncolor.call(word).length + pre_post_length) if is_go_to_front
    if is_select
      print "#{prefix}#{word}#{postfix}"
    else
      print "#{" " * pre_length}#{word}#{" " * post_length}"
    end
    print " " * (max_word_length - uncolor.call(words[position]).length)
    print "\b" * (max_word_length - uncolor.call(words[position]).length)
    print "\b" * (uncolor.call(word).length + pre_post_length) if is_end_at_front
  end

  write_info = lambda do |new_info|
    # Put the response into the info line, as long as it's short enough!
    new_info.gsub!("\n", " ")
    new_info_length = uncolor.call(new_info).length
    console_width = IO.console.winsize.last
    # Might have to trim if it's a little too wide
    new_info = new_info[0...console_width] if console_width < new_info_length
    # Arrow down to the info line
    distance_down = (height + offset) - position
    print (27.chr + 91.chr + 66.chr) * distance_down
    # To start of info line
    word_length = uncolor.call(words[position]).length + pre_post_length
    print "\b" * word_length
    # Write out the new response
    prev_info_length = uncolor.call(info).length
    difference = prev_info_length - new_info_length
    difference = 0 if difference < 0
    print new_info + " " * difference
    info = new_info
    # Go up to where we originated
    print (27.chr + 91.chr + 65.chr) * distance_down
    # Arrow left or right to get to the right spot again
    new_info_length += difference
    print (new_info_length > word_length ? "\b" : (27.chr + 91.chr + 67.chr)) * (new_info_length - word_length).abs
  end

  handle_on_select = lambda do |focused|
    if on_select.is_a? Proc
      response = on_select.call({chosen: chosen, focused: focused})
      new_info = nil
      if response.is_a? Hash
        chosen = response[:chosen] || chosen
        new_info = response[:info]
      elsif response.is_a? String
        new_info = response
      end
      unless new_info.nil?
        write_info.call(new_info)
      end
    end
  end

  # **********************************************
  # ******************** DOWN ********************
  # Doesn't work with a height of 3 when there's more than 3 in the list
  arrow_down = lambda do
    if position < words.length - 1
      is_shift = false
      handle_on_select.call(word_objects[position + 1])
      # Now moving down past the bottom of the shown window?
      is_before_end = height + offset < words.length
      if is_before_end && position == (height - 2) + offset
        print "\b" * (uncolor.call(words[position]).length + pre_post_length)
        print (27.chr + 91.chr + 65.chr) * (height - 3)
        if offset == 0
          print (27.chr + 91.chr + 65.chr)
          puts "#{" " * pre_length}#{"↑" * max_word_length}#{" " * post_length}"
        end
        offset += 1
        ((offset + 1)..(offset + (height - 4))).each do |i|
          end_fill = max_word_length - uncolor.call(words[i]).length
          puts (is_multiple && chosen.include?(i)) ? "#{prefix}#{words[i]}#{postfix}#{" " * end_fill}" : "#{" " * pre_length}#{words[i]}#{" " * (end_fill + post_length)}"
        end
        is_shift = true
      end
      make_select.call(chosen.include?(position) && is_shift && is_multiple, true, true) if is_shift || !is_multiple
      w1 = uncolor.call(words[position]).length
      position += 1
      print 27.chr + 91.chr + 66.chr
      if is_shift || !is_multiple
        if is_shift && height + offset == words.length  # Go down and write the last one
          print 27.chr + 91.chr + 66.chr
          position += 1
          make_select.call(is_shift && chosen.include?(position), false, true)
          print 27.chr + 91.chr + 65.chr  # And back up
          position -= 1
        end
        make_select.call((chosen.include?(position) && is_shift) || !is_multiple)
      else
        w2 = uncolor.call(words[position]).length
        print (w1 > w2 ? "\b" : (27.chr + 91.chr + 67.chr)) * (w1 - w2).abs
      end
    end
  end

  # **********************************************
  # ********************** UP ********************
  arrow_up = lambda do
    if position > 0
      is_shift = false
      handle_on_select.call(word_objects[position - 1])
      # Now moving up past the top of the shown window?
      if position > 1 && position <= offset + 1 # - (offset > 1 ? 0 : -1)
        print "\b" * (uncolor.call(words[position]).length + pre_post_length)
        offset -= 1
        # Up next to the top, and write the first word over the up arrows
        if offset == 0
          print (27.chr + 91.chr + 65.chr)
          end_fill = max_word_length - uncolor.call(words[0]).length
          puts (is_multiple && chosen.include?(0)) ? "#{prefix}#{words[0]}#{postfix}#{" " * end_fill}" : "#{" " * pre_length}#{words[0]}#{" " * (end_fill + post_length)}"
        end
        ((offset + 1)..(offset + height - 2)).each do |i|
          end_fill = max_word_length - uncolor.call(words[i]).length
          puts ((!is_multiple && i == (offset + 1)) || (is_multiple && chosen.include?(i))) ? "#{prefix}#{words[i]}#{postfix}#{" " * end_fill}" : "#{" " * pre_length}#{words[i]}#{" " * (end_fill + post_length)}"
        end
        if offset == words.length - height - 1
          puts "#{" " * pre_length}#{"↓" * max_word_length}#{" " * post_length}"
          print (27.chr + 91.chr + 65.chr)
        end
        print (27.chr + 91.chr + 65.chr) * (height - 2)
        is_shift = true
        position -= 1
        w1 = -pre_post_length
      else
        make_select.call(chosen.include?(position) && is_shift && is_multiple, true, true) if is_shift || !is_multiple
        w1 = uncolor.call(words[position]).length
        position -= 1
        print 27.chr + 91.chr + 65.chr
      end
      if !is_shift && !is_multiple
        make_select.call(chosen.include?(position) || !is_multiple)
      else
        w2 = uncolor.call(words[position]).length
        print (w1 > w2 ? "\b" : (27.chr + 91.chr + 67.chr)) * (w1 - w2).abs
      end
    end
  end

  # Initialize everything
  if is_list
    # Maybe confirm the height is adequate by checking out IO.console.winsize
    case
    when chosen.is_a?(Numeric)
      chosen = [chosen]
    when chosen.is_a?(String)
      if words.include?(chosen)
        chosen = [words.index(chosen)]
      else
        chosen = []
      end
    when chosen.is_a?(Array)
      chosen.each_with_index do |item, i|
        case
        when item.is_a?(String)
          chosen[i] = words.index(item)
        when item.is_a?(Numeric)
          chosen[i] = nil if item < 0 || item >= words.length
        else
          chosen[i] = word_objects.index(item)
        end
      end
      chosen.select{|item| !item.nil?}.uniq
    else
      if word_objects.include?(chosen)
        chosen = [word_objects.index(chosen)]
      else
        chosen = []
      end
    end
    chosen ||= []
    chosen = [0] if chosen == [] && !is_multiple
    position = chosen.first if chosen.length > 0
    # If there's more options than we can fit at once
    if height < words.length
      # ... put the chosen one a third of the way down the screen
      offset = position - (height / 3)
      offset = words.length - height if offset > words.length - height
      offset = 0 if offset < 0
    end

    # **********************************************
    # **********************************************
    # **********************************************
    # **********************************************
    # **********************************************

    # Scrolled any amount downwards?
    #   was: if height < words.length
    puts "#{" " * pre_length}#{"↑" * max_word_length}" if offset > 0
    last_word = (height - 2) + offset + (height + offset < words.length ? 0 : 1)
    # Write all the visible words
    ((offset + (offset > 0 ? 1 : 0))..last_word).each { |i| puts chosen.include?(i) ? "#{prefix}#{words[i]}#{postfix}" : "#{" " * pre_length}#{words[i]}" }
    # Can't fit it all?
    puts "#{" " * pre_length}#{"↓" * max_word_length}" if height + offset < words.length

    info ||= "Use arrow keys#{is_multiple ? ", spacebar to toggle, and ENTER to save" : " and ENTER to make a choice"}"
    print info + (27.chr + 91.chr + 65.chr) * ((last_word - position) + (height + offset < words.length ? 2 : 1))
    # To end of text on starting line
    info_length = uncolor.call(info).length
    word_length = uncolor.call(words[position]).length + pre_post_length
    print (info_length > word_length ? "\b" : (27.chr + 91.chr + 67.chr)) * (info_length - word_length).abs
  else
    position = string.length
    if is_password
      print "*" * string.length
    else
      print string + write_sugg.call
    end
  end
  loop do
    ch = STDIN.getch
    code = ch.ord
    case code
    when 3 # CTRL-C
      clear_dropdown_info.call if is_list
      # puts "o: #{offset} p: #{position} h: #{height} wl: #{words.length}"
      exit
    when 13 # ENTER
      if is_list
        clear_dropdown_info.call
      else
        print "\n"
      end
      break
    when 27  # ESC -- which means lots of special stuff
      case ch = STDIN.getch.ord
      when 79  # Function keys
        # puts "ESC 79"
        case ch = STDIN.getch.ord
        when 80 #F1
          # puts "F1"
        when 81 #F2
        when 82 #F3
        when 83 #F4
        when 84 #F5
        when 85 #F6
        when 86 #F7
        when 87 #F8
        when 88 #F9
        when 89 #F10
        when 90 #F11
        when 91 #F12
          # puts "F12"
        when 92 #F13
        end
      when 91 # Arrow keys
        case ch = STDIN.getch.ord
        when 68 # Arrow left
          if !is_list && position > 0
            print "\b" # 27.chr + 91.chr + 68.chr
            position -= 1
          end
        when 67 # Arrow right
          if !is_list && position < string.length
            print 27.chr + 91.chr + 67.chr
            position += 1
          end
        when 66 # - down
          arrow_down.call if is_list
        when 65 # - up
          arrow_up.call if is_list
        when 51 # - Delete forwards?
        else
          # puts "ESC 91 #{ch}"
        end
      else
        # Something wacky?
        # puts "code #{ch} #{STDIN.getch.ord} #{STDIN.getch.ord} #{STDIN.getch.ord} #{STDIN.getch.ord}"
      end
    when 127 # Backspace
      if !is_list && position > 0
        string = string[0...position - 1] + string[position..-1]
        if words.empty?
          position -= 1
          print "\b#{is_password ? "*" * (string.length - position) : string[position..-1]} #{"\b" * (string.length - position + 1)}"
        else
          prev_sugg = sugg
          position -= 1
          print "\b#{string[position..-1]}#{write_sugg.call}"
        end
      end
    when 126 # Delete (forwards)
      if !is_list && position < string.length
        string = string[0...position] + string[position + 1..-1]
        if words.empty?
          print "#{is_password ? "*" * (string.length - position) : string[position..-1]} #{"\b" * (string.length - position + 1)}"
        else
          prev_sugg = sugg
          print "#{string[position..-1]}#{write_sugg.call}"
        end
      end
    else # Insert character
      if is_list
        case ch
        when " "
          if is_multiple
            # Toggle this entry
            does_include = chosen.include?(position)
            is_rejected = false
            if on_change.is_a? Proc
              # Generate what would happen if this change goes through
              if does_include
                new_chosen = chosen - [position]
              else
                new_chosen = chosen + [position]
              end
              chosen_objects = new_chosen.sort.map{|choice| word_objects[choice]}
              response = on_change.call({chosen: chosen_objects, changed: word_objects[position], is_chosen: !does_include})
              new_info = nil
              if response.is_a? Hash
                is_rejected = response[:is_rejected]
                # If they told us exactly what the choices should now be, make that happen
                if !response.nil? && response[:chosen].is_a?(Enumerable)
                  chosen = response[:chosen].map {|choice| word_objects.index(choice)}
                  is_rejected = true
                end
                new_info = response[:info]
              elsif response.is_a? String
                new_info = response
              end
              unless new_info.nil?
                write_info.call(new_info)
              end
            end
            unless is_rejected
              if does_include
                chosen -= [position]
              else
                chosen += [position]
              end
              make_select.call(!does_include, true)
            end
          else
            # Allows Windows to have a way to at least use single-select lists
            clear_dropdown_info.call
            break
          end
        when "j"  # Down
          arrow_down.call
        when "k"  # Up
          arrow_up.call
        end
      else
        string = string[0...position] + ch + string[position..-1]
        if words.empty?
          ch = "*" if is_password
          position += 1
          print "#{ch}#{is_password ? "*" * (string.length - position) : string[position..-1]}#{"\b" * (string.length - position)}"
        else
          prev_sugg = sugg
          position += 1
          print "#{ch}#{string[position..-1]}#{write_sugg.call}"
        end
      end
    end
  end

  if is_list
    # Put chosen stuff in same order as it's listed in the words array
    is_multiple ? chosen.sort.map {|c| word_objects[c] } : word_objects[position]
  else
    sugg.empty? ? string : word_objects[words.index(sugg)]
  end
end

Instance Method Details

#gets_auto_suggest(words = nil, default = "") ⇒ Object



5
6
7
# File 'lib/fancy_gets.rb', line 5

def gets_auto_suggest(words = nil, default = "")
  FancyGets.gets_internal_core(false, false, words, default)
end

#gets_list(words, is_multiple = false, chosen = nil, prefix = "> ", postfix = " <", info = nil, height = nil) ⇒ Object

Show a list of stuff, potentially some highlighted, and allow people to up-down arrow around and pick stuff



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/fancy_gets.rb', line 14

def gets_list(words, is_multiple = false, chosen = nil, prefix = "> ", postfix = " <", info = nil, height = nil)
  on_change = nil
  on_select = nil
  if words.is_a? Hash
    is_multiple = words[:is_multiple] || false
    chosen = words[:chosen]
    prefix = words[:prefix] || "> "
    postfix = words[:postfix] || " <"
    info = words[:info]
    height = words[:height] || nil
    on_change = words[:on_change]
    on_select = words[:on_select]
    words = words[:list]
  else
    # Trying to supply parameters but left out a "true" for is_multiple?
    if is_multiple.is_a?(Enumerable) || is_multiple.is_a?(String) || is_multiple.is_a?(Numeric)
      chosen = is_multiple
      is_multiple = false
    end
  end
  # Slightly inclined to ditch this in case the things they're choosing really are Enumerable
  is_multiple = true if chosen.is_a?(Enumerable)
  FancyGets.gets_internal_core(true, is_multiple, words, chosen, prefix, postfix, info, height, on_change, on_select)
end

#gets_password(default = "") ⇒ Object



9
10
11
# File 'lib/fancy_gets.rb', line 9

def gets_password(default = "")
  FancyGets.gets_internal_core(false, true, nil, default)
end