Add selecta

This commit is contained in:
Michael Campagnaro 2015-11-17 17:01:03 -05:00
parent 964bf3b6d5
commit 92062113d5

818
bin/selecta Executable file
View File

@ -0,0 +1,818 @@
#!/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