#!/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