#!/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" 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-? KEY_ESC = ?\C-[ class Selecta VERSION = [0, 0, 2] 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) search = Screen.with_screen do |screen, tty| config = Configuration.from_inputs($stdin.readlines, options, screen.height) run_in_screen(config, screen, tty) end if search.selection == Search::NoSelection exit(1) else puts search.selection end rescue ScreenValidator::NotATTY $stderr.puts( "Can't get a working TTY. Selecta requires an ANSI-compatible terminal.") exit(1) end def run_in_screen(config, screen, tty) search = Search.blank(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 !search.done? Renderer.render!(search, screen) search = handle_key(search, tty.get_char) end search end # On each keystroke, generate a new search object def handle_key(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_ESC then search.abort when /[[:print:]]/ then search.append_search_string(key.chr) else search end 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 :choices, :index, :query, :config, :matches def initialize(vars) @config = vars.fetch(:config) @choices = vars.fetch(:choices) @index = vars.fetch(:index) @query = vars.fetch(:query) @done = vars.fetch(:done) @aborted = vars.fetch(:aborted) # Lazily compute matches if there aren't any @matches = vars.fetch(:matches) { compute_matches } @vars = vars.merge(:matches => @matches) end def self.blank(config) new(:config => config, :choices => config.choices, :index => 0, :query => config.initial_search, :done => false, :aborted => false) 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 || @aborted end def selection if @aborted NoSelection else matches.fetch(@index) { NoSelection } end end def down index = (@index + 1) % max_visible_choices merge(:index => index) end def up index = (@index - 1) % max_visible_choices merge(:index => index) end def max_visible_choices [@config.visible_choices, choices.count].min end def append_search_string(string) merge(:index => 0, :query => @query + string) end def backspace merge(:index => 0, :query => @query[0...-1]) end def clear_query merge(:index => 0, :query => "") end def delete_word merge(:index => 0, :query => @query.sub(/[^ ]* *$/, "")) end def done merge(:done => true) end def abort merge(:aborted => true) end private def compute_matches @choices.map do |choice| [choice, Score.score(choice, @query)] end.select do |choice, score| score > 0.0 end.sort_by do |choice, score| -score end.map do |choice, score| choice end end class NoSelection; end end class Score class << self def score(choice, query) return 1.0 if query.length == 0 return 0.0 if choice.length == 0 choice = choice.downcase query = query.downcase match_length = compute_match_length(choice, query.each_char.to_a) return 0.0 unless match_length # Penalize longer matches. score = query.length.to_f / match_length.to_f # Normalize vs. the length of the choice, penalizing longer strings. score / choice.length end # Find the length of the shortest substring matching the given characters. def compute_match_length(string, chars) first_char, *rest = chars first_indexes = find_char_in_string(string, first_char) first_indexes.map do |first_index| last_index = find_end_of_match(string, rest, first_index) if last_index last_index - first_index + 1 else nil end end.compact.min end # Find all occurrences of the character in the string, returning their indexes. def find_char_in_string(string, char) index = 0 indexes = [] while index index = string.index(char, index) if index indexes << index index += 1 end end indexes end # Find each of the characters in the string, moving strictly left to right. def find_end_of_match(string, chars, first_index) last_index = first_index chars.each do |this_char| index = string.index(this_char, last_index + 1) return nil unless index last_index = index end 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, rendered.search_line.length) end end def render index, matches = search.index, search.matches search_line = "> " + search.query unless matches.empty? selection_line = Text[:inverse, matches.fetch(index), :reset] matches = replace_array_element(matches, index, selection_line) end matches = correct_match_count(matches) lines = [search_line] + matches Rendered.new(lines, search_line) 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 ScreenValidator.raise_unless_screen_is_valid(screen.height) yield screen, tty ensure screen.restore_tty tty.puts end end end attr_reader :tty, :ansi def initialize(tty) @tty = tty @ansi = ANSI.new(tty.out_file) @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("-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) ansi.hide_cursor! begin block.call ensure ansi.show_cursor! end end def height size[0] end def width size[1] end def size height, width = tty.winsize [height, width] end def move_cursor(line, column) 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 ansi.setpos!(line, 0) ansi.addstr!(" " * width) text.components.each do |component| if component.is_a? String 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 = component[0..chars_to_draw] ansi.addstr!(component) column += component.length elsif component == :inverse ansi.inverse! elsif component == :reset ansi.reset! else if component =~ /_/ fg, bg = component.to_s.split(/_/).map(&:to_sym) else fg, bg = component, :default end ansi.color!(fg, bg) end end end end class ScreenValidator def self.raise_unless_screen_is_valid(screen_height) raise NotATTY if screen_height == 0 end class NotATTY < RuntimeError; 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 attr_reader :file def initialize(file) @file = file end 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 addstr(str) str 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 def clear!(*args); write clear(*args); end def setpos!(*args); write setpos(*args); end def addstr!(*args); write addstr(*args); end def color!(*args); write color(*args); end def inverse!(*args); write inverse(*args); end def reset!(*args); write reset(*args); end def hide_cursor!(*args); write hide_cursor(*args); end def show_cursor!(*args); write show_cursor(*args); end def write(bytes) file.write(bytes) end end class TTY < Struct.new(:in_file, :out_file) def self.with_tty(&block) File.open("/dev/tty", "r") do |in_file| File.open("/dev/tty", "w") do |out_file| tty = TTY.new(in_file, out_file) block.call(tty) end end end def get_char in_file.getc end def puts out_file.puts end def winsize out_file.winsize end def stty(args) command("stty #{args}") 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__ begin Selecta.new.main rescue Interrupt exit(1) end end