819 lines
		
	
	
		
			21 KiB
		
	
	
	
		
			Ruby
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			819 lines
		
	
	
		
			21 KiB
		
	
	
	
		
			Ruby
		
	
	
		
			Executable File
		
	
	
	
	
#!/usr/bin/env bash
 | 
						|
# vim: set ft=ruby:
 | 
						|
 | 
						|
# This file executes as a bash script, which turns around and executes Ruby via
 | 
						|
# the line below. The -x argument to Ruby makes it discard everything before
 | 
						|
# the second "!ruby" shebang. This allows us to work on Linux, where the
 | 
						|
# shebang can only have one argument so we can't directly say
 | 
						|
# "#!/usr/bin/env ruby --disable-gems". Thanks for that, Linux.
 | 
						|
#
 | 
						|
# If this seems confusing, don't worry. You can treat it as a normal Ruby file
 | 
						|
# starting with the "!ruby" shebang below.
 | 
						|
 | 
						|
exec /usr/bin/env ruby --disable-gems -x "$0" $*
 | 
						|
#!ruby
 | 
						|
 | 
						|
if RUBY_VERSION < '1.9.3'
 | 
						|
  abort "error: Selecta requires Ruby 1.9.3 or higher."
 | 
						|
end
 | 
						|
 | 
						|
require "optparse"
 | 
						|
require "io/console"
 | 
						|
require "io/wait"
 | 
						|
require "set"
 | 
						|
 | 
						|
KEY_CTRL_C = ?\C-c
 | 
						|
KEY_CTRL_N = ?\C-n
 | 
						|
KEY_CTRL_P = ?\C-p
 | 
						|
KEY_CTRL_U = ?\C-u
 | 
						|
KEY_CTRL_H = ?\C-h
 | 
						|
KEY_CTRL_W = ?\C-w
 | 
						|
KEY_CTRL_J = ?\C-j
 | 
						|
KEY_CTRL_M = ?\C-m
 | 
						|
KEY_DELETE = 127.chr # Equivalent to ?\C-?
 | 
						|
 | 
						|
class Selecta
 | 
						|
  VERSION = [0, 0, 6]
 | 
						|
 | 
						|
  def main
 | 
						|
    # We have to parse options before setting up the screen or trying to read
 | 
						|
    # the input in case the user did '-h', an invalid option, etc. and we need
 | 
						|
    # to terminate.
 | 
						|
    options = Configuration.parse_options(ARGV)
 | 
						|
    input_lines = $stdin.readlines
 | 
						|
 | 
						|
    search = Screen.with_screen do |screen, tty|
 | 
						|
      config = Configuration.from_inputs(input_lines, options, screen.height)
 | 
						|
      run_in_screen(config, screen, tty)
 | 
						|
    end
 | 
						|
 | 
						|
    unless search.selection == Search::NoSelection
 | 
						|
      puts search.selection
 | 
						|
    end
 | 
						|
  rescue Screen::NotATTY
 | 
						|
    $stderr.puts(
 | 
						|
      "Can't get a working TTY. Selecta requires an ANSI-compatible terminal.")
 | 
						|
    exit(1)
 | 
						|
  rescue Abort
 | 
						|
    # We were aborted via ^C.
 | 
						|
    #
 | 
						|
    # If we didn't mess with the TTY configuration at all, then ^C would send
 | 
						|
    # SIGINT to the entire process group. That would terminate both Selecta and
 | 
						|
    # anything piped into or out of it. Because Selecta puts the terminal in
 | 
						|
    # raw mode, that doesn't happen; instead, we detect the ^C as normal input
 | 
						|
    # and raise Abort, which leads here.
 | 
						|
    #
 | 
						|
    # To make pipelines involving Selecta behave as people expect, we send
 | 
						|
    # SIGINT to our own process group, which should exactly match what termios
 | 
						|
    # would do to us if the terminal weren't in raw mode. "Should!" <- Remove
 | 
						|
    # those scare quotes if ten years pass without this breaking!
 | 
						|
    #
 | 
						|
    # The SIGINT will cause Ruby to raise Interrupt, so we also have to handle
 | 
						|
    # that here.
 | 
						|
    begin
 | 
						|
      Process.kill("INT", -Process.getpgrp)
 | 
						|
    rescue Interrupt
 | 
						|
      exit(1)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def run_in_screen(config, screen, tty)
 | 
						|
    search = Search.from_config(config)
 | 
						|
 | 
						|
    # We emit the number of lines we'll use later so we don't clobber whatever
 | 
						|
    # was already on the screen.
 | 
						|
    config.visible_choices.times { tty.puts }
 | 
						|
    begin
 | 
						|
      search = ui_event_loop(search, screen, tty)
 | 
						|
    ensure
 | 
						|
      # Always move the cursor to the bottom so the next program doesn't draw
 | 
						|
      # over whatever we left on the screen.
 | 
						|
      screen.move_cursor(screen.height - 1, 0)
 | 
						|
    end
 | 
						|
    search
 | 
						|
  end
 | 
						|
 | 
						|
  # Use the search and screen to process user actions until they quit.
 | 
						|
  def ui_event_loop(search, screen, tty)
 | 
						|
    while not search.done?
 | 
						|
      Renderer.render!(search, screen)
 | 
						|
      search = handle_keys(search, tty)
 | 
						|
    end
 | 
						|
    search
 | 
						|
  end
 | 
						|
 | 
						|
  def handle_keys(search, tty)
 | 
						|
    new_query_chars = ""
 | 
						|
 | 
						|
    # Read through all of the buffered input characters. Process control
 | 
						|
    # characters immediately. Save any query characters to be processed
 | 
						|
    # together at the end, since there's no reason to process intermediate
 | 
						|
    # results when there are more characters already buffered.
 | 
						|
    tty.get_available_input.chars.each do |char|
 | 
						|
      is_query_char = !!(char =~ /[[:print:]]/)
 | 
						|
      if is_query_char
 | 
						|
        new_query_chars << char
 | 
						|
      else
 | 
						|
        search = handle_control_character(search, char)
 | 
						|
      end
 | 
						|
    end
 | 
						|
 | 
						|
    if new_query_chars.empty?
 | 
						|
      search
 | 
						|
    else
 | 
						|
      search.append_search_string(new_query_chars)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  # On each keystroke, generate a new search object
 | 
						|
  def handle_control_character(search, key)
 | 
						|
    case key
 | 
						|
 | 
						|
    when KEY_CTRL_N then search.down
 | 
						|
    when KEY_CTRL_P then search.up
 | 
						|
 | 
						|
    when KEY_CTRL_U then search.clear_query
 | 
						|
    when KEY_CTRL_W then search.delete_word
 | 
						|
    when KEY_CTRL_H, KEY_DELETE then search.backspace
 | 
						|
 | 
						|
    when ?\r, KEY_CTRL_J, KEY_CTRL_M then search.done
 | 
						|
 | 
						|
    when KEY_CTRL_C then raise Abort
 | 
						|
 | 
						|
    else search
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  class Abort < RuntimeError; end
 | 
						|
end
 | 
						|
 | 
						|
class Configuration < Struct.new(:visible_choices, :initial_search, :choices)
 | 
						|
  def initialize(visible_choices, initialize, choices)
 | 
						|
    # Constructor is defined to force argument presence; otherwise Struct
 | 
						|
    # defaults missing arguments to nil
 | 
						|
    super
 | 
						|
  end
 | 
						|
 | 
						|
  def self.from_inputs(choices, options, screen_height=21)
 | 
						|
    # Shrink the number of visible choices if the screen is too small
 | 
						|
    visible_choices = [20, screen_height - 1].min
 | 
						|
 | 
						|
    choices = massage_choices(choices)
 | 
						|
    Configuration.new(visible_choices, options.fetch(:search), choices)
 | 
						|
  end
 | 
						|
 | 
						|
  def self.default_options
 | 
						|
    parse_options([])
 | 
						|
  end
 | 
						|
 | 
						|
  def self.parse_options(argv)
 | 
						|
    options = {}
 | 
						|
 | 
						|
    parser = OptionParser.new do |opts|
 | 
						|
      opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
 | 
						|
 | 
						|
      opts.on_tail("-h", "--help", "Show this message") do |v|
 | 
						|
        puts opts
 | 
						|
        exit
 | 
						|
      end
 | 
						|
 | 
						|
      opts.on_tail("--version", "Show version") do
 | 
						|
        puts Selecta::VERSION.join('.')
 | 
						|
        exit
 | 
						|
      end
 | 
						|
 | 
						|
      options[:search] = ""
 | 
						|
      opts.on("-s", "--search SEARCH", "Specify an initial search string") do |search|
 | 
						|
        options[:search] = search
 | 
						|
      end
 | 
						|
    end
 | 
						|
 | 
						|
    begin
 | 
						|
      parser.parse!(argv)
 | 
						|
    rescue OptionParser::InvalidOption => e
 | 
						|
      $stderr.puts e
 | 
						|
      $stderr.puts parser
 | 
						|
      exit 1
 | 
						|
    end
 | 
						|
 | 
						|
    options
 | 
						|
  end
 | 
						|
 | 
						|
  def self.massage_choices(choices)
 | 
						|
    choices.map do |choice|
 | 
						|
      # Encoding to UTF-8 with `:invalid => :replace` isn't good enough; it
 | 
						|
      # still leaves some invalid characters. For example, this string will fail:
 | 
						|
      #
 | 
						|
      # echo "девуш\xD0:" | selecta
 | 
						|
      #
 | 
						|
      # Round-tripping through UTF-16, with `:invalid => :replace` as well,
 | 
						|
      # fixes this. I don't understand why. I found it via:
 | 
						|
      #
 | 
						|
      # http://stackoverflow.com/questions/2982677/ruby-1-9-invalid-byte-sequence-in-utf-8
 | 
						|
      if choice.valid_encoding?
 | 
						|
        choice
 | 
						|
      else
 | 
						|
        utf16 = choice.encode('UTF-16', 'UTF-8', :invalid => :replace, :replace => '')
 | 
						|
        utf16.encode('UTF-8', 'UTF-16')
 | 
						|
      end.strip
 | 
						|
    end
 | 
						|
  end
 | 
						|
end
 | 
						|
 | 
						|
class Search
 | 
						|
  attr_reader :index, :query, :config, :original_matches, :all_matches, :best_matches
 | 
						|
 | 
						|
  def initialize(vars)
 | 
						|
    @config = vars.fetch(:config)
 | 
						|
    @index = vars.fetch(:index)
 | 
						|
    @query = vars.fetch(:query)
 | 
						|
    @done = vars.fetch(:done)
 | 
						|
    @original_matches = vars.fetch(:original_matches)
 | 
						|
    @all_matches = vars.fetch(:all_matches)
 | 
						|
    @best_matches = vars.fetch(:best_matches)
 | 
						|
    @vars = vars
 | 
						|
  end
 | 
						|
 | 
						|
  def self.from_config(config)
 | 
						|
    trivial_matches = config.choices.reject(&:empty?).map do |choice|
 | 
						|
      Match.trivial(choice)
 | 
						|
    end
 | 
						|
 | 
						|
    search = new(:config => config,
 | 
						|
                 :index => 0,
 | 
						|
                 :query => "",
 | 
						|
                 :done => false,
 | 
						|
                 :original_matches => trivial_matches,
 | 
						|
                 :all_matches => trivial_matches,
 | 
						|
                 :best_matches => trivial_matches)
 | 
						|
 | 
						|
    if config.initial_search.empty?
 | 
						|
      search
 | 
						|
    else
 | 
						|
      search.append_search_string(config.initial_search)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  # Construct a new Search by merging in a hash of changes.
 | 
						|
  def merge(changes)
 | 
						|
    vars = @vars.merge(changes)
 | 
						|
 | 
						|
    # If the query changed, throw away the old matches so that new ones will be
 | 
						|
    # computed.
 | 
						|
    matches_are_stale = vars.fetch(:query) != @query
 | 
						|
    if matches_are_stale
 | 
						|
      vars = vars.reject { |key| key == :matches }
 | 
						|
    end
 | 
						|
 | 
						|
    Search.new(vars)
 | 
						|
  end
 | 
						|
 | 
						|
  def done?
 | 
						|
    @done
 | 
						|
  end
 | 
						|
 | 
						|
  def selection
 | 
						|
    if @aborted
 | 
						|
      NoSelection
 | 
						|
    else
 | 
						|
      match = best_matches.fetch(@index) { NoSelection }
 | 
						|
      if match == NoSelection
 | 
						|
        match
 | 
						|
      else
 | 
						|
        match.original_choice
 | 
						|
      end
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def down
 | 
						|
    move_cursor(1)
 | 
						|
  end
 | 
						|
 | 
						|
  def up
 | 
						|
    move_cursor(-1)
 | 
						|
  end
 | 
						|
 | 
						|
  def max_visible_choices
 | 
						|
    [@config.visible_choices, all_matches.count].min
 | 
						|
  end
 | 
						|
 | 
						|
  def append_search_string(string)
 | 
						|
    merge(:index => 0,
 | 
						|
          :query => @query + string)
 | 
						|
    .recompute_matches(all_matches)
 | 
						|
  end
 | 
						|
 | 
						|
  def backspace
 | 
						|
    merge(:index => 0,
 | 
						|
          :query => @query[0...-1])
 | 
						|
    .recompute_matches
 | 
						|
  end
 | 
						|
 | 
						|
  def clear_query
 | 
						|
    merge(:index => 0,
 | 
						|
          :query => "")
 | 
						|
    .recompute_matches
 | 
						|
  end
 | 
						|
 | 
						|
  def delete_word
 | 
						|
    merge(:index => 0,
 | 
						|
          :query => @query.sub(/[^ ]* *$/, ""))
 | 
						|
    .recompute_matches
 | 
						|
  end
 | 
						|
 | 
						|
  def done
 | 
						|
    merge(:done => true)
 | 
						|
  end
 | 
						|
 | 
						|
  def abort
 | 
						|
    merge(:aborted => true)
 | 
						|
  end
 | 
						|
 | 
						|
  def recompute_matches(previous_matches=self.original_matches)
 | 
						|
    if self.query.empty?
 | 
						|
      merge(:all_matches => original_matches,
 | 
						|
            :best_matches => original_matches)
 | 
						|
    else
 | 
						|
      all_matches = recompute_all_matches(previous_matches)
 | 
						|
      best_matches = recompute_best_matches(all_matches)
 | 
						|
      merge(:all_matches => all_matches, :best_matches => best_matches)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  private
 | 
						|
 | 
						|
  def recompute_all_matches(previous_matches)
 | 
						|
    query = self.query.downcase
 | 
						|
    query_chars = query.chars.to_a
 | 
						|
 | 
						|
    matches = previous_matches.map do |match|
 | 
						|
      choice = match.choice
 | 
						|
      score, range = Score.score(choice, query_chars)
 | 
						|
      range ? match.refine(score, range) : nil
 | 
						|
    end.compact
 | 
						|
  end
 | 
						|
 | 
						|
  def recompute_best_matches(all_matches)
 | 
						|
    return [] if all_matches.empty?
 | 
						|
 | 
						|
    count = [@config.visible_choices, all_matches.count].min
 | 
						|
    matches = []
 | 
						|
 | 
						|
    best_score = all_matches.min_by(&:score).score
 | 
						|
 | 
						|
    # Consider matches, beginning with the best-scoring. A match always ranks
 | 
						|
    # higher than other matches with worse scores. However, the ranking between
 | 
						|
    # matches of the same score depends on other factors, so we always have to
 | 
						|
    # consider all matches of a given score.
 | 
						|
    (best_score..Float::INFINITY).each do |score|
 | 
						|
      matches += all_matches.select { |match| match.score == score }
 | 
						|
      # Stop if we have enough matches.
 | 
						|
      return sub_sort_matches(matches)[0, count] if matches.length >= count
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def sub_sort_matches(matches)
 | 
						|
    matches.sort_by do |match|
 | 
						|
      [match.score, match.matching_range.count, match.choice.length]
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def move_cursor(direction)
 | 
						|
    if max_visible_choices > 0
 | 
						|
      index = (@index + direction) % max_visible_choices
 | 
						|
      merge(:index => index)
 | 
						|
    else
 | 
						|
      self
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  class NoSelection; end
 | 
						|
end
 | 
						|
 | 
						|
class Match < Struct.new(:original_choice, :choice, :score, :matching_range)
 | 
						|
  def self.trivial(choice)
 | 
						|
    empty_range = (0...0)
 | 
						|
    new(choice, choice.downcase, 0, empty_range)
 | 
						|
  end
 | 
						|
 | 
						|
  def to_text
 | 
						|
    if matching_range.none?
 | 
						|
      Text[original_choice]
 | 
						|
    else
 | 
						|
      before = original_choice[0...matching_range.begin]
 | 
						|
      matching = original_choice[matching_range.begin..matching_range.end]
 | 
						|
      after = original_choice[(matching_range.end + 1)..-1]
 | 
						|
      Text[before, :red, matching, :default, after]
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def refine(score, range)
 | 
						|
    Match.new(original_choice, choice, score, range)
 | 
						|
  end
 | 
						|
end
 | 
						|
 | 
						|
class Score
 | 
						|
  class << self
 | 
						|
    # A word boundary character is any ASCII character that's not alphanumeric.
 | 
						|
    # This isn't strictly correct: characters like ZERO WIDTH NON-JOINER,
 | 
						|
    # non-Latin punctuation, etc. will be incorrectly treated as non-boundary
 | 
						|
    # characters. This is necessary for performance: even building a Set of
 | 
						|
    # boundary characters based only on the input text is prohibitively slow (2-3
 | 
						|
    # seconds for 80,000 input paths on a 2014 MacBook Pro).
 | 
						|
    BOUNDARY_CHARS = (0..127).map(&:chr).select do |char|
 | 
						|
      char !~ /[A-Za-z0-9_]/
 | 
						|
    end.to_set
 | 
						|
 | 
						|
    def score(string, query_chars)
 | 
						|
      first_char, *rest = query_chars
 | 
						|
 | 
						|
      # Keep track of the best match that we've seen. This is uglier than
 | 
						|
      # building a list of matches and then sorting them, but it's faster.
 | 
						|
      best_score = Float::INFINITY
 | 
						|
      best_range = nil
 | 
						|
 | 
						|
      # Iterate over each instance of the first query character. E.g., if we're
 | 
						|
      # querying the string "axbx" for "x", we'll start at index 1 and index 3.
 | 
						|
      each_index_of_char_in_string(string, first_char) do |first_index|
 | 
						|
        score = 1
 | 
						|
 | 
						|
        # Find the best score starting at this index.
 | 
						|
        score, last_index = find_end_of_match(string, rest, score, first_index)
 | 
						|
 | 
						|
        # Did we do better than we have for the best starting point so far?
 | 
						|
        if last_index && score < best_score
 | 
						|
          best_score = score
 | 
						|
          best_range = (first_index..last_index)
 | 
						|
        end
 | 
						|
      end
 | 
						|
 | 
						|
      [best_score, best_range]
 | 
						|
    end
 | 
						|
 | 
						|
    # Find all occurrences of the character in the string, returning their indexes.
 | 
						|
    def each_index_of_char_in_string(string, char)
 | 
						|
      index = 0
 | 
						|
      while index
 | 
						|
        index = string.index(char, index)
 | 
						|
        if index
 | 
						|
          yield index
 | 
						|
          index += 1
 | 
						|
        end
 | 
						|
      end
 | 
						|
    end
 | 
						|
 | 
						|
    # Find each of the characters in the string, moving strictly left to right.
 | 
						|
    def find_end_of_match(string, chars, score, first_index)
 | 
						|
      last_index = first_index
 | 
						|
 | 
						|
      # Remember the type of the last character match for special scoring.
 | 
						|
      last_type = nil
 | 
						|
 | 
						|
      chars.each do |this_char|
 | 
						|
        # Where's the next occurrence of this character? The optimal algorithm
 | 
						|
        # would consider all instances of query character, but that's slower
 | 
						|
        # than this eager method.
 | 
						|
        index = string.index(this_char, last_index + 1)
 | 
						|
 | 
						|
        # This character doesn't occur in the string, so this can't be a match.
 | 
						|
        return [nil, nil] unless index
 | 
						|
 | 
						|
        if index == last_index + 1
 | 
						|
          # This matching character immediately follows the last matching
 | 
						|
          # character. The first two sequential characters score; subsequent
 | 
						|
          # ones don't.
 | 
						|
          if last_type != :sequential
 | 
						|
            last_type = :sequential
 | 
						|
            score += 1
 | 
						|
          end
 | 
						|
        # This character follows a boundary character.
 | 
						|
        elsif BOUNDARY_CHARS.include?(string[index - 1])
 | 
						|
          if last_type != :boundary
 | 
						|
            last_type = :boundary
 | 
						|
            score += 1
 | 
						|
          end
 | 
						|
        # This character isn't special.
 | 
						|
        else
 | 
						|
          last_type = :normal
 | 
						|
          score += index - last_index
 | 
						|
        end
 | 
						|
 | 
						|
        last_index = index
 | 
						|
      end
 | 
						|
 | 
						|
      [score, last_index]
 | 
						|
    end
 | 
						|
  end
 | 
						|
end
 | 
						|
 | 
						|
class Renderer < Struct.new(:search)
 | 
						|
  def self.render!(search, screen)
 | 
						|
    rendered = Renderer.new(search).render
 | 
						|
    start_line = screen.height - search.config.visible_choices - 1
 | 
						|
    screen.with_cursor_hidden do
 | 
						|
      screen.write_lines(start_line, rendered.choices)
 | 
						|
      screen.move_cursor(start_line, 0)
 | 
						|
      screen.write_line(start_line, rendered.search_line)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def render
 | 
						|
    search_line = "#{match_count_label} > " + search.query
 | 
						|
 | 
						|
    matches = search.best_matches
 | 
						|
    matches = matches.each_with_index.map do |match, index|
 | 
						|
      if index == search.index
 | 
						|
        Text[:inverse] + match.to_text + Text[:reset]
 | 
						|
      else
 | 
						|
        match.to_text
 | 
						|
      end
 | 
						|
    end
 | 
						|
    matches = correct_match_count(matches)
 | 
						|
    lines = [search_line] + matches
 | 
						|
    Rendered.new(lines, search_line)
 | 
						|
  end
 | 
						|
 | 
						|
  def match_count_label
 | 
						|
    choice_count = search.original_matches.length
 | 
						|
    max_label_width = choice_count.to_s.length
 | 
						|
    match_count = search.all_matches.count
 | 
						|
    match_count.to_s.rjust(max_label_width)
 | 
						|
  end
 | 
						|
 | 
						|
  def correct_match_count(matches)
 | 
						|
    limited = matches[0, search.config.visible_choices]
 | 
						|
    padded = limited + [""] * (search.config.visible_choices - limited.length)
 | 
						|
    padded
 | 
						|
  end
 | 
						|
 | 
						|
  class Rendered < Struct.new(:choices, :search_line)
 | 
						|
  end
 | 
						|
 | 
						|
  private
 | 
						|
 | 
						|
  def replace_array_element(array, index, new_value)
 | 
						|
    array = array.dup
 | 
						|
    array[index] = new_value
 | 
						|
    array
 | 
						|
  end
 | 
						|
end
 | 
						|
 | 
						|
class Screen
 | 
						|
  def self.with_screen
 | 
						|
    TTY.with_tty do |tty|
 | 
						|
      screen = self.new(tty)
 | 
						|
      screen.configure_tty
 | 
						|
      begin
 | 
						|
        raise NotATTY if screen.height == 0
 | 
						|
        yield screen, tty
 | 
						|
      ensure
 | 
						|
        screen.restore_tty
 | 
						|
        tty.puts
 | 
						|
      end
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  class NotATTY < RuntimeError; end
 | 
						|
 | 
						|
  attr_reader :tty
 | 
						|
 | 
						|
  def initialize(tty)
 | 
						|
    @tty = tty
 | 
						|
    @original_stty_state = tty.stty("-g")
 | 
						|
  end
 | 
						|
 | 
						|
  def configure_tty
 | 
						|
    # -echo: terminal doesn't echo typed characters back to the terminal
 | 
						|
    # -icanon: terminal doesn't  interpret special characters (like backspace)
 | 
						|
    tty.stty("raw -echo -icanon")
 | 
						|
  end
 | 
						|
 | 
						|
  def restore_tty
 | 
						|
    tty.stty("#{@original_stty_state}")
 | 
						|
  end
 | 
						|
 | 
						|
  def suspend
 | 
						|
    restore_tty
 | 
						|
    begin
 | 
						|
      yield
 | 
						|
      configure_tty
 | 
						|
    rescue
 | 
						|
      restore_tty
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def with_cursor_hidden(&block)
 | 
						|
    write_bytes(ANSI.hide_cursor)
 | 
						|
    begin
 | 
						|
      block.call
 | 
						|
    ensure
 | 
						|
      write_bytes(ANSI.show_cursor)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def height
 | 
						|
    tty.winsize[0]
 | 
						|
  end
 | 
						|
 | 
						|
  def width
 | 
						|
    tty.winsize[1]
 | 
						|
  end
 | 
						|
 | 
						|
  def move_cursor(line, column)
 | 
						|
    write_bytes(ANSI.setpos(line, column))
 | 
						|
  end
 | 
						|
 | 
						|
  def write_line(line, text)
 | 
						|
    write(line, 0, text)
 | 
						|
  end
 | 
						|
 | 
						|
  def write_lines(line, texts)
 | 
						|
    texts.each_with_index do |text, index|
 | 
						|
      write(line + index, 0, text)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def write(line, column, text)
 | 
						|
    # Discard writes outside the main screen area
 | 
						|
    write_unrestricted(line, column, text) if line < height
 | 
						|
  end
 | 
						|
 | 
						|
  def write_unrestricted(line, column, text)
 | 
						|
    text = Text[:default, text] unless text.is_a? Text
 | 
						|
    write_text_object(line, column, text)
 | 
						|
  end
 | 
						|
 | 
						|
  def write_text_object(line, column, text)
 | 
						|
    # Blank the line before drawing to it
 | 
						|
    write_bytes(ANSI.setpos(line, 0))
 | 
						|
    write_bytes(" " * width)
 | 
						|
 | 
						|
    text.components.each do |component|
 | 
						|
      if component.is_a? String
 | 
						|
        write_bytes(ANSI.setpos(line, column))
 | 
						|
        # Don't draw off the edge of the screen.
 | 
						|
        #   - width - 1 is the last column we have (zero-indexed)
 | 
						|
        #   - subtract the current column from that to get the number of
 | 
						|
        #   columns we have left.
 | 
						|
        chars_to_draw = [0, width - 1 - column].max
 | 
						|
        component = expand_tabs(component)[0..chars_to_draw]
 | 
						|
        write_bytes(component)
 | 
						|
        column += component.length
 | 
						|
      elsif component == :inverse
 | 
						|
        write_bytes(ANSI.inverse)
 | 
						|
      elsif component == :reset
 | 
						|
        write_bytes(ANSI.reset)
 | 
						|
      else
 | 
						|
        if component =~ /_/
 | 
						|
          fg, bg = component.to_s.split(/_/).map(&:to_sym)
 | 
						|
        else
 | 
						|
          fg, bg = component, :default
 | 
						|
        end
 | 
						|
        write_bytes(ANSI.color(fg, bg))
 | 
						|
      end
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def expand_tabs(string)
 | 
						|
    # Modified from http://markmail.org/message/avdjw34ahxi447qk
 | 
						|
    tab_width = 8
 | 
						|
    string.gsub(/([^\t\n]*)\t/) do
 | 
						|
      $1 + " " * (tab_width - ($1.size % tab_width))
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def write_bytes(bytes)
 | 
						|
    tty.console_file.write(bytes)
 | 
						|
  end
 | 
						|
end
 | 
						|
 | 
						|
class Text
 | 
						|
  attr_reader :components
 | 
						|
 | 
						|
  def self.[](*args)
 | 
						|
    new(args)
 | 
						|
  end
 | 
						|
 | 
						|
  def initialize(components)
 | 
						|
    @components = components
 | 
						|
  end
 | 
						|
 | 
						|
  def ==(other)
 | 
						|
    components == other.components
 | 
						|
  end
 | 
						|
 | 
						|
  def +(other)
 | 
						|
    Text[*(components + other.components)]
 | 
						|
  end
 | 
						|
end
 | 
						|
 | 
						|
class ANSI
 | 
						|
  ESC = 27.chr
 | 
						|
 | 
						|
  class << self
 | 
						|
    def escape(sequence)
 | 
						|
      ESC + "[" + sequence
 | 
						|
    end
 | 
						|
 | 
						|
    def clear
 | 
						|
      escape "2J"
 | 
						|
    end
 | 
						|
 | 
						|
    def hide_cursor
 | 
						|
      escape "?25l"
 | 
						|
    end
 | 
						|
 | 
						|
    def show_cursor
 | 
						|
      escape "?25h"
 | 
						|
    end
 | 
						|
 | 
						|
    def setpos(line, column)
 | 
						|
      escape "#{line + 1};#{column + 1}H"
 | 
						|
    end
 | 
						|
 | 
						|
    def color(fg, bg=:default)
 | 
						|
      fg_codes = {
 | 
						|
        :black => 30,
 | 
						|
        :red => 31,
 | 
						|
        :green => 32,
 | 
						|
        :yellow => 33,
 | 
						|
        :blue => 34,
 | 
						|
        :magenta => 35,
 | 
						|
        :cyan => 36,
 | 
						|
        :white => 37,
 | 
						|
        :default => 39,
 | 
						|
      }
 | 
						|
      bg_codes = {
 | 
						|
        :black => 40,
 | 
						|
        :red => 41,
 | 
						|
        :green => 42,
 | 
						|
        :yellow => 43,
 | 
						|
        :blue => 44,
 | 
						|
        :magenta => 45,
 | 
						|
        :cyan => 46,
 | 
						|
        :white => 47,
 | 
						|
        :default => 49,
 | 
						|
      }
 | 
						|
      fg_code = fg_codes.fetch(fg)
 | 
						|
      bg_code = bg_codes.fetch(bg)
 | 
						|
      escape "#{fg_code};#{bg_code}m"
 | 
						|
    end
 | 
						|
 | 
						|
    def inverse
 | 
						|
      escape("7m")
 | 
						|
    end
 | 
						|
 | 
						|
    def reset
 | 
						|
      escape("0m")
 | 
						|
    end
 | 
						|
  end
 | 
						|
end
 | 
						|
 | 
						|
class TTY < Struct.new(:console_file)
 | 
						|
  def self.with_tty(&block)
 | 
						|
    # Selecta reads data from stdin and writes it to stdout, so we can't draw
 | 
						|
    # UI and receive keystrokes through them. Fortunately, all modern
 | 
						|
    # Unix-likes provide /dev/tty, which IO.console gives us.
 | 
						|
    console_file = IO.console
 | 
						|
    tty = TTY.new(console_file)
 | 
						|
    block.call(tty)
 | 
						|
  end
 | 
						|
 | 
						|
  def get_available_input
 | 
						|
    input = console_file.getc
 | 
						|
    while console_file.ready?
 | 
						|
      input += console_file.getc
 | 
						|
    end
 | 
						|
    input
 | 
						|
  end
 | 
						|
 | 
						|
  def puts
 | 
						|
    console_file.puts
 | 
						|
  end
 | 
						|
 | 
						|
  def winsize
 | 
						|
    console_file.winsize
 | 
						|
  end
 | 
						|
 | 
						|
  def stty(args)
 | 
						|
    command("stty #{args}").strip
 | 
						|
  end
 | 
						|
 | 
						|
  private
 | 
						|
 | 
						|
  # Run a command with the TTY as stdin, capturing the output via a pipe
 | 
						|
  def command(command)
 | 
						|
    IO.pipe do |read_io, write_io|
 | 
						|
      pid = Process.spawn(command, :in => "/dev/tty", :out => write_io)
 | 
						|
      Process.wait(pid)
 | 
						|
      raise "Command failed: #{command.inspect}" unless $?.success?
 | 
						|
      write_io.close
 | 
						|
      read_io.read
 | 
						|
    end
 | 
						|
  end
 | 
						|
end
 | 
						|
 | 
						|
if $0 == __FILE__
 | 
						|
  Selecta.new.main
 | 
						|
end
 |