module FancyGets

Constants

VERSION

Public Class Methods

gets_internal_core(is_list, is_password, word_objects = nil, chosen = nil, prefix = "> ", postfix = " <", info = nil, height = nil, on_change = nil, on_select = nil) click to toggle source

The internal routine that makes all the magic happen

# 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
  # After tweaking the down arrow code, might work OK with 3 things
  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
  # (somehow up arrow can work OK with this)
  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}"
        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}"
          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 chosen.class.name
    when "Fixnum"
      chosen = [chosen]
    when "String"
      if words.include?(chosen)
        chosen = [words.index(chosen)]
      else
        chosen = []
      end
    when "Array"
      chosen.each_with_index do |item, i|
        case item.class.name
        when "String"
          chosen[i] = words.index(item)
        when "Fixnum"
          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
    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
          if is_list
            arrow_down.call
          end
        when 65 # - up
          if is_list
            arrow_up.call
          end
        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

Public Instance Methods

gets_auto_suggest(words = nil, default = "") click to toggle source
# 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) click to toggle source

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

# 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?(Fixnum)
      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 = "") click to toggle source
# File lib/fancy_gets.rb, line 9
def gets_password(default = "")
  FancyGets.gets_internal_core(false, true, nil, default)
end