mirror of
https://github.com/junegunn/fzf
synced 2026-07-24 17:13:18 +00:00
39d7177bd3
So that it can be used with the recent Vim plugin although extra key bindings are not available
1345 lines
35 KiB
Ruby
Executable File
1345 lines
35 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
|
# encoding: utf-8
|
|
#
|
|
# ____ ____
|
|
# / __/___ / __/
|
|
# / /_/_ / / /_
|
|
# / __/ / /_/ __/
|
|
# /_/ /___/_/ Fuzzy finder for your shell
|
|
#
|
|
# Version: 0.8.9 (Dec 24, 2014)
|
|
# Deprecation alert:
|
|
# This script is no longer maintained. Use the new Go version.
|
|
#
|
|
# Author: Junegunn Choi
|
|
# URL: https://github.com/junegunn/fzf
|
|
# License: MIT
|
|
#
|
|
# Copyright (c) 2014 Junegunn Choi
|
|
#
|
|
# MIT License
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining
|
|
# a copy of this software and associated documentation files (the
|
|
# "Software"), to deal in the Software without restriction, including
|
|
# without limitation the rights to use, copy, modify, merge, publish,
|
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
|
# permit persons to whom the Software is furnished to do so, subject to
|
|
# the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be
|
|
# included in all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
|
begin
|
|
require 'curses'
|
|
rescue LoadError
|
|
$stderr.puts 'curses gem is not installed. Try `gem install curses`.'
|
|
sleep 1
|
|
exit 1
|
|
end
|
|
require 'thread'
|
|
require 'set'
|
|
|
|
unless String.method_defined? :force_encoding
|
|
class String
|
|
def force_encoding *arg
|
|
self
|
|
end
|
|
end
|
|
end
|
|
|
|
class String
|
|
attr_accessor :orig
|
|
|
|
def tokenize delim, nth
|
|
unless delim
|
|
# AWK default
|
|
prefix_length = (index(/\S/) || 0) rescue 0
|
|
tokens = scan(/\S+\s*/) rescue []
|
|
else
|
|
prefix_length = 0
|
|
tokens = scan(delim) rescue []
|
|
end
|
|
nth.map { |n|
|
|
if n.begin == 0 && n.end == -1
|
|
[prefix_length, tokens.join]
|
|
elsif part = tokens[n]
|
|
[prefix_length + (tokens[0...(n.begin)] || []).join.length,
|
|
part.join]
|
|
end
|
|
}.compact
|
|
end
|
|
end
|
|
|
|
class FZF
|
|
C = Curses
|
|
attr_reader :rxflag, :sort, :nth, :color, :black, :ansi256, :reverse, :prompt,
|
|
:mouse, :multi, :query, :select1, :exit0, :filter, :extended,
|
|
:print_query, :with_nth
|
|
|
|
def sync
|
|
@shr_mtx.synchronize { yield }
|
|
end
|
|
|
|
def get name
|
|
sync { instance_variable_get name }
|
|
end
|
|
|
|
def geta(*names)
|
|
sync { names.map { |name| instance_variable_get name } }
|
|
end
|
|
|
|
def call(name, method, *args)
|
|
sync { instance_variable_get(name).send(method, *args) }
|
|
end
|
|
|
|
def set name, value = nil
|
|
sync do
|
|
instance_variable_set name,
|
|
(block_given? ? yield(instance_variable_get(name)) : value)
|
|
end
|
|
end
|
|
|
|
def initialize argv, source = $stdin
|
|
@rxflag = nil
|
|
@sort = ENV.fetch('FZF_DEFAULT_SORT', 1000).to_i
|
|
@color = true
|
|
@ansi256 = true
|
|
@black = false
|
|
@multi = false
|
|
@mouse = true
|
|
@extended = nil
|
|
@select1 = false
|
|
@exit0 = false
|
|
@filter = nil
|
|
@nth = nil
|
|
@with_nth = nil
|
|
@delim = nil
|
|
@reverse = false
|
|
@prompt = '> '
|
|
@shr_mtx = Mutex.new
|
|
@expect = false
|
|
@print_query = false
|
|
|
|
argv =
|
|
if opts = ENV['FZF_DEFAULT_OPTS']
|
|
require 'shellwords'
|
|
Shellwords.shellwords(opts) + argv
|
|
else
|
|
argv.dup
|
|
end
|
|
while o = argv.shift
|
|
case o
|
|
when '--version' then FZF.version
|
|
when '-h', '--help' then usage 0
|
|
when '-m', '--multi' then @multi = true
|
|
when '+m', '--no-multi' then @multi = false
|
|
when '-x', '--extended' then @extended = :fuzzy
|
|
when '+x', '--no-extended' then @extended = nil
|
|
when '-i' then @rxflag = Regexp::IGNORECASE
|
|
when '+i' then @rxflag = 0
|
|
when '-c', '--color' then @color = true
|
|
when '+c', '--no-color' then @color = false
|
|
when '-2', '--256' then @ansi256 = true
|
|
when '+2', '--no-256' then @ansi256 = false
|
|
when '--black' then @black = true
|
|
when '--no-black' then @black = false
|
|
when '--mouse' then @mouse = true
|
|
when '--no-mouse' then @mouse = false
|
|
when '--reverse' then @reverse = true
|
|
when '--no-reverse' then @reverse = false
|
|
when '+s', '--no-sort' then @sort = nil
|
|
when '-1', '--select-1' then @select1 = true
|
|
when '+1', '--no-select-1' then @select1 = false
|
|
when '-0', '--exit-0' then @exit0 = true
|
|
when '+0', '--no-exit-0' then @exit0 = false
|
|
when '-q', '--query'
|
|
usage 1, 'query string required' unless query = argv.shift
|
|
@query = query
|
|
when /^-q(.*)$/, /^--query=(.*)$/
|
|
@query = $1
|
|
when '-f', '--filter'
|
|
usage 1, 'query string required' unless query = argv.shift
|
|
@filter = query
|
|
when /^-f(.*)$/, /^--filter=(.*)$/
|
|
@filter = $1
|
|
when '-n', '--nth'
|
|
usage 1, 'field expression required' unless nth = argv.shift
|
|
@nth = parse_nth nth
|
|
when /^-n([0-9,-\.]+)$/, /^--nth=([0-9,-\.]+)$/
|
|
@nth = parse_nth $1
|
|
when '--with-nth'
|
|
usage 1, 'field expression required' unless nth = argv.shift
|
|
@with_nth = parse_nth nth
|
|
when /^--with-nth=([0-9,-\.]+)$/
|
|
@with_nth = parse_nth $1
|
|
when '-d', '--delimiter'
|
|
usage 1, 'delimiter required' unless delim = argv.shift
|
|
@delim = FZF.build_delim_regex delim
|
|
when /^-d(.+)$/, /^--delimiter=(.+)$/
|
|
@delim = FZF.build_delim_regex $1
|
|
when '-s', '--sort'
|
|
usage 1, 'sort size required' unless sort = argv.shift
|
|
usage 1, 'invalid sort size' unless sort =~ /^[0-9]+$/
|
|
@sort = sort.to_i
|
|
when /^-s([0-9]+)$/, /^--sort=([0-9]+)$/
|
|
@sort = $1.to_i
|
|
when '--prompt'
|
|
usage 1, 'prompt string required' unless prompt = argv.shift
|
|
@prompt = prompt
|
|
when /^--prompt=(.*)$/
|
|
@prompt = $1
|
|
when '--print-query' then @print_query = true
|
|
when '--no-print-query' then @print_query = false
|
|
when '-e', '--extended-exact' then @extended = :exact
|
|
when '+e', '--no-extended-exact' then @extended = nil
|
|
when '--expect'
|
|
argv.shift
|
|
@expect = true
|
|
when /^--expect=(.*)$/
|
|
@expect = true
|
|
when '--tac', '--sync'
|
|
# XXX
|
|
else
|
|
usage 1, "illegal option: #{o}"
|
|
end
|
|
end
|
|
|
|
@source = source.clone
|
|
@evt_mtx = Mutex.new
|
|
@cv = ConditionVariable.new
|
|
@events = {}
|
|
@new = []
|
|
@queue = Queue.new
|
|
@pending = nil
|
|
@rev_dir = @reverse ? -1 : 1
|
|
@stdout = $stdout.clone
|
|
|
|
unless @filter
|
|
# Shared variables: needs protection
|
|
@query ||= ''
|
|
@matches = []
|
|
@count = 0
|
|
@xcur = @query.length
|
|
@ycur = 0
|
|
@yoff = 0
|
|
@dirty = Set.new
|
|
@spinner = '-\|/-\|/'.split(//)
|
|
@selects = {} # ordered >= 1.9
|
|
|
|
@main = Thread.current
|
|
@plcount = 0
|
|
end
|
|
end
|
|
|
|
def parse_nth nth
|
|
ranges = nth.split(',').map { |expr|
|
|
x = proc { usage 1, "invalid field expression: #{expr}" }
|
|
first, second = expr.split('..', 2)
|
|
x.call if !first.empty? && first.to_i == 0 ||
|
|
second && !second.empty? && (second.to_i == 0 || second.include?('.'))
|
|
|
|
first = first.empty? ? 1 : first.to_i
|
|
second = case second
|
|
when nil then first
|
|
when '' then -1
|
|
else second.to_i
|
|
end
|
|
|
|
Range.new(*[first, second].map { |e| e > 0 ? e - 1 : e })
|
|
}
|
|
ranges == [0..-1] ? nil : ranges
|
|
end
|
|
|
|
def FZF.build_delim_regex delim
|
|
Regexp.compile(delim) rescue (delim = Regexp.escape(delim))
|
|
Regexp.compile "(?:.*?#{delim})|(?:.+?$)"
|
|
end
|
|
|
|
def burp string, orig = nil
|
|
@stdout.puts(orig || string.orig || string)
|
|
end
|
|
|
|
def start
|
|
if @filter
|
|
start_reader.join
|
|
filter_list @new
|
|
else
|
|
start_reader
|
|
query = get(:@query)
|
|
emit(:key) { [query, query.length] } unless empty = query.empty?
|
|
if @select1 || @exit0
|
|
start_search do |loaded, matches|
|
|
len = empty ? get(:@count) : matches.length
|
|
if loaded
|
|
if @select1 && len == 1
|
|
puts @query if @print_query
|
|
puts if @expect
|
|
burp(empty ? matches.first : matches.first.first)
|
|
exit 0
|
|
elsif @exit0 && len == 0
|
|
puts @query if @print_query
|
|
puts if @expect
|
|
exit 0
|
|
end
|
|
end
|
|
|
|
if loaded || len > 1
|
|
start_renderer
|
|
Thread.new { start_loop }
|
|
end
|
|
end
|
|
|
|
sleep
|
|
else
|
|
start_search
|
|
start_renderer
|
|
start_loop
|
|
end
|
|
end
|
|
end
|
|
|
|
def filter_list list
|
|
puts @filter if @print_query
|
|
matches = matcher.match(list, @filter, '', '')
|
|
if @sort && matches.length <= @sort
|
|
matches = FZF.sort(matches)
|
|
end
|
|
matches.each { |m| puts m.first }
|
|
end
|
|
|
|
def matcher
|
|
@matcher ||=
|
|
if @extended
|
|
ExtendedFuzzyMatcher.new @rxflag, @extended, @nth, @delim
|
|
else
|
|
FuzzyMatcher.new @rxflag, @nth, @delim
|
|
end
|
|
end
|
|
|
|
class << self
|
|
def version
|
|
File.open(__FILE__, 'r') do |f|
|
|
f.each_line do |line|
|
|
if line =~ /Version: (.*)/
|
|
$stdout.puts 'fzf ' << $1
|
|
exit
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def sort list
|
|
list.sort_by { |tuple| rank tuple }
|
|
end
|
|
|
|
def rank tuple
|
|
line, offsets = tuple
|
|
matchlen = 0
|
|
pe = 0
|
|
offsets.sort.each do |pair|
|
|
b, e = pair
|
|
b = pe if pe > b
|
|
pe = e if e > pe
|
|
matchlen += e - b if e > b
|
|
end
|
|
[matchlen, line.length, line]
|
|
end
|
|
end
|
|
|
|
def usage x, message = nil
|
|
$stderr.puts message if message
|
|
$stderr.puts %[usage: fzf [options]
|
|
|
|
Search
|
|
-x, --extended Extended-search mode
|
|
-e, --extended-exact Extended-search mode (exact match)
|
|
-i Case-insensitive match (default: smart-case match)
|
|
+i Case-sensitive match
|
|
-n, --nth=N[,..] Comma-separated list of field index expressions
|
|
for limiting search scope. Each can be a non-zero
|
|
integer or a range expression ([BEGIN]..[END])
|
|
--with-nth=N[,..] Transform the item using index expressions for search
|
|
-d, --delimiter=STR Field delimiter regex for --nth (default: AWK-style)
|
|
|
|
Search result
|
|
-s, --sort=MAX Maximum number of matched items to sort (default: 1000)
|
|
+s, --no-sort Do not sort the result. Keep the sequence unchanged.
|
|
|
|
Interface
|
|
-m, --multi Enable multi-select with tab/shift-tab
|
|
--no-mouse Disable mouse
|
|
+c, --no-color Disable colors
|
|
+2, --no-256 Disable 256-color
|
|
--black Use black background
|
|
--reverse Reverse orientation
|
|
--prompt=STR Input prompt (default: '> ')
|
|
|
|
Scripting
|
|
-q, --query=STR Start the finder with the given query
|
|
-1, --select-1 Automatically select the only match
|
|
-0, --exit-0 Exit immediately when there's no match
|
|
-f, --filter=STR Filter mode. Do not start interactive finder.
|
|
--print-query Print query as the first line
|
|
|
|
Environment variables
|
|
FZF_DEFAULT_COMMAND Default command to use when input is tty
|
|
FZF_DEFAULT_OPTS Defaults options. (e.g. "-x -m --sort 10000")] + $/ + $/
|
|
exit x
|
|
end
|
|
|
|
def emit event
|
|
@evt_mtx.synchronize do
|
|
@events[event] = yield
|
|
@cv.broadcast
|
|
end
|
|
end
|
|
|
|
def max_items; C.lines - 2; end
|
|
|
|
def cursor_y offset = 0
|
|
@reverse ? (offset) : (C.lines - 1 - offset)
|
|
end
|
|
|
|
def cprint str, col
|
|
C.attron(col) do
|
|
addstr_safe str
|
|
end if str
|
|
end
|
|
def addstr_safe str
|
|
str = str.gsub("\0", '') rescue str
|
|
C.addstr str
|
|
end
|
|
|
|
def print_input
|
|
C.setpos cursor_y, 0
|
|
C.clrtoeol
|
|
cprint @prompt, color(:prompt, true)
|
|
C.attron(C::A_BOLD) do
|
|
C.addstr get(:@query)
|
|
end
|
|
end
|
|
|
|
def print_info msg = nil
|
|
C.setpos cursor_y(1), 0
|
|
C.clrtoeol
|
|
|
|
prefix =
|
|
if spin_char = call(:@spinner, :first)
|
|
cprint spin_char, color(:spinner, true)
|
|
' '
|
|
else
|
|
' '
|
|
end
|
|
C.attron color(:info, false) do
|
|
sync do
|
|
C.addstr "#{prefix}#{@matches.length}/#{@count}"
|
|
if (selected = @selects.length) > 0
|
|
C.addstr " (#{selected})"
|
|
end
|
|
end
|
|
C.addstr msg if msg
|
|
end
|
|
end
|
|
|
|
def refresh
|
|
query, xcur = geta(:@query, :@xcur)
|
|
C.setpos cursor_y, @prompt.length + width(query[0, xcur])
|
|
C.refresh
|
|
end
|
|
|
|
def ctrl char
|
|
char.to_s.ord - 'a'.ord + 1
|
|
end
|
|
|
|
def format line, limit, offsets
|
|
offsets ||= []
|
|
maxe = offsets.map { |e| e.last }.max || 0
|
|
|
|
# Overflow
|
|
if width(line) > limit
|
|
ewidth = width(line[0...maxe])
|
|
# Stri..
|
|
if ewidth <= limit - 2
|
|
line, _ = trim line, limit - 2, false
|
|
line << '..'
|
|
# ..ring
|
|
else
|
|
# ..ri..
|
|
line = line[0...maxe] + '..' if ewidth < width(line) - 2
|
|
line, diff = trim line, limit - 2, true
|
|
offsets = offsets.map { |pair|
|
|
b, e = pair
|
|
b += 2 - diff
|
|
e += 2 - diff
|
|
b = [2, b].max
|
|
[b, e]
|
|
}
|
|
line = '..' + line
|
|
end
|
|
end
|
|
|
|
tokens = []
|
|
index = 0
|
|
offsets.select { |pair| pair.first < pair.last }.
|
|
sort_by { |pair| pair }.each do |pair|
|
|
b, e = pair.map { |x| [index, x].max }
|
|
tokens << [line[index...b], false]
|
|
tokens << [line[b...e], true]
|
|
index = e
|
|
end
|
|
tokens << [line[index..-1], false] if index < line.length
|
|
tokens.reject { |pair| pair.first.empty? }
|
|
end
|
|
|
|
def print_item row, tokens, chosen, selected
|
|
# Cursor
|
|
C.setpos row, 0
|
|
C.clrtoeol
|
|
cprint chosen ? '>' : ' ', color(:cursor, true)
|
|
cprint selected ? '>' : ' ',
|
|
chosen ? color(:chosen) : (selected ? color(:selected, true) : 0)
|
|
|
|
# Highlighted item
|
|
C.attron color(:chosen, true) if chosen
|
|
tokens.each do |pair|
|
|
token, highlighted = pair
|
|
|
|
if highlighted
|
|
cprint token, color(chosen ? :match! : :match, chosen)
|
|
C.attron color(:chosen, true) if chosen
|
|
else
|
|
addstr_safe token
|
|
end
|
|
end
|
|
C.attroff color(:chosen, true) if chosen
|
|
end
|
|
|
|
AFTER_1_9 = RUBY_VERSION.split('.').map { |e| e.rjust(3, '0') }.join >= '001009'
|
|
|
|
if AFTER_1_9
|
|
@@wrx = Regexp.new '\p{Han}|\p{Katakana}|\p{Hiragana}|\p{Hangul}'
|
|
def width str
|
|
str.gsub(@@wrx, ' ').length rescue str.length
|
|
end
|
|
|
|
def trim str, len, left
|
|
width = width str
|
|
diff = 0
|
|
while width > len
|
|
width -= ((left ? str[0, 1] : str[-1, 1]) =~ @@wrx ? 2 : 1) rescue 1
|
|
str = left ? str[1..-1] : str[0...-1]
|
|
diff += 1
|
|
end
|
|
[str, diff]
|
|
end
|
|
else
|
|
def width str
|
|
str.length
|
|
end
|
|
|
|
def trim str, len, left
|
|
diff = str.length - len
|
|
if diff > 0
|
|
[left ? str[diff..-1] : str[0...-diff], diff]
|
|
else
|
|
[str, 0]
|
|
end
|
|
end
|
|
|
|
class ::String
|
|
def ord
|
|
self.unpack('c').first
|
|
end
|
|
end
|
|
|
|
class ::Fixnum
|
|
def ord
|
|
self
|
|
end
|
|
end
|
|
end
|
|
|
|
def init_screen
|
|
$stdout.reopen($stderr)
|
|
|
|
C.init_screen
|
|
C.mousemask C::ALL_MOUSE_EVENTS if @mouse
|
|
C.start_color
|
|
dbg =
|
|
if !@black && C.respond_to?(:use_default_colors)
|
|
C.use_default_colors
|
|
-1
|
|
else
|
|
C::COLOR_BLACK
|
|
end
|
|
C.raw
|
|
C.noecho
|
|
|
|
if @color
|
|
if @ansi256 && ENV['TERM'].to_s =~ /256/
|
|
C.init_pair 1, 110, dbg
|
|
C.init_pair 2, 108, dbg
|
|
C.init_pair 3, 254, 236
|
|
C.init_pair 4, 151, 236
|
|
C.init_pair 5, 148, dbg
|
|
C.init_pair 6, 144, dbg
|
|
C.init_pair 7, 161, 236
|
|
C.init_pair 8, 168, 236
|
|
else
|
|
C.init_pair 1, C::COLOR_BLUE, dbg
|
|
C.init_pair 2, C::COLOR_GREEN, dbg
|
|
C.init_pair 3, C::COLOR_YELLOW, C::COLOR_BLACK
|
|
C.init_pair 4, C::COLOR_GREEN, C::COLOR_BLACK
|
|
C.init_pair 5, C::COLOR_GREEN, dbg
|
|
C.init_pair 6, C::COLOR_WHITE, dbg
|
|
C.init_pair 7, C::COLOR_RED, C::COLOR_BLACK
|
|
C.init_pair 8, C::COLOR_MAGENTA, C::COLOR_BLACK
|
|
end
|
|
|
|
def self.color sym, bold = false
|
|
C.color_pair([:prompt, :match, :chosen, :match!,
|
|
:spinner, :info, :cursor, :selected].index(sym) + 1) |
|
|
(bold ? C::A_BOLD : 0)
|
|
end
|
|
else
|
|
def self.color sym, bold = false
|
|
case sym
|
|
when :chosen
|
|
bold ? C::A_REVERSE : 0
|
|
when :match
|
|
C::A_UNDERLINE
|
|
when :match!
|
|
C::A_REVERSE | C::A_UNDERLINE
|
|
else
|
|
0
|
|
end | (bold ? C::A_BOLD : 0)
|
|
end
|
|
end
|
|
|
|
C.refresh
|
|
end
|
|
|
|
def start_reader
|
|
stream =
|
|
if @source.tty?
|
|
default_command = ENV['FZF_DEFAULT_COMMAND']
|
|
if default_command && !default_command.empty?
|
|
IO.popen(default_command)
|
|
elsif !`which find`.empty?
|
|
IO.popen("find * -path '*/\\.*' -prune -o -type f -print -o -type l -print 2> /dev/null")
|
|
else
|
|
exit 1
|
|
end
|
|
else
|
|
@source
|
|
end
|
|
|
|
Thread.new do
|
|
if @with_nth
|
|
while line = stream.gets
|
|
emit(:new) { @new << transform(line) }
|
|
end
|
|
else
|
|
while line = stream.gets
|
|
emit(:new) { @new << line.chomp }
|
|
end
|
|
end
|
|
emit(:loaded) { true }
|
|
@spinner.clear if @spinner
|
|
end
|
|
end
|
|
|
|
def transform line
|
|
line = line.chomp
|
|
mut = (line =~ / $/ ? line : line + ' ').
|
|
tokenize(@delim, @with_nth).map { |e| e.last }.join('').sub(/ *$/, '')
|
|
mut.orig = line
|
|
mut
|
|
end
|
|
|
|
def start_search &callback
|
|
Thread.new do
|
|
lists = []
|
|
events = {}
|
|
fcache = {}
|
|
q = ''
|
|
delay = -5
|
|
|
|
begin
|
|
while true
|
|
@evt_mtx.synchronize do
|
|
while true
|
|
events.merge! @events
|
|
|
|
if @events.empty? # No new events
|
|
@cv.wait @evt_mtx
|
|
next
|
|
end
|
|
@events.clear
|
|
break
|
|
end
|
|
|
|
if events[:new]
|
|
lists << @new
|
|
set(:@count) { |c| c + @new.length }
|
|
set(:@spinner) { |spinner|
|
|
if e = spinner.shift
|
|
spinner.push e
|
|
end; spinner
|
|
}
|
|
@new = []
|
|
fcache.clear
|
|
end
|
|
end#mtx
|
|
|
|
new_search = events[:key] || events.delete(:new)
|
|
user_input = events[:key]
|
|
progress = 0
|
|
started_at = Time.now
|
|
|
|
if updated = new_search && !lists.empty?
|
|
q, cx = events.delete(:key) || [q, 0]
|
|
empty = matcher.empty?(q)
|
|
unless matches = fcache[q]
|
|
found = []
|
|
skip = false
|
|
cnt = 0
|
|
lists.each do |list|
|
|
cnt += list.length
|
|
skip = @evt_mtx.synchronize { @events[:key] }
|
|
break if skip
|
|
|
|
if !empty && (progress = 100 * cnt / get(:@count)) < 100 && Time.now - started_at > 0.5
|
|
render { print_info " (#{progress}%)" }
|
|
end
|
|
|
|
found.concat(q.empty? ? list :
|
|
matcher.match(list, q, q[0, cx], q[cx..-1]))
|
|
end
|
|
if skip
|
|
sleep 0.1
|
|
next
|
|
end
|
|
matches = @sort ? found : found.reverse
|
|
if !empty && @sort && matches.length <= @sort
|
|
matches = FZF.sort(matches)
|
|
end
|
|
fcache[q] = matches
|
|
end
|
|
|
|
# Atomic update
|
|
set(:@matches, matches)
|
|
end#new_search
|
|
|
|
callback = nil if callback &&
|
|
(updated || events[:loaded]) &&
|
|
callback.call(events[:loaded], matches)
|
|
|
|
# This small delay reduces the number of partial lists
|
|
sleep((delay = [20, delay + 5].min) * 0.01) unless user_input
|
|
|
|
update_list new_search
|
|
end#while
|
|
rescue Exception => e
|
|
@main.raise e
|
|
end
|
|
end
|
|
end
|
|
|
|
def pick
|
|
sync do
|
|
item = @matches[@ycur]
|
|
item.is_a?(Array) ? item[0] : item
|
|
end
|
|
end
|
|
|
|
def constrain offset, cursor, count, height
|
|
original = [offset, cursor]
|
|
diffpos = cursor - offset
|
|
|
|
# Constrain cursor
|
|
cursor = [0, [cursor, count - 1].min].max
|
|
|
|
# Ceil
|
|
if cursor > offset + (height - 1)
|
|
offset = cursor - (height - 1)
|
|
# Floor
|
|
elsif offset > cursor
|
|
offset = cursor
|
|
end
|
|
|
|
# Adjustment
|
|
if count - offset < height
|
|
offset = [0, count - height].max
|
|
cursor = [0, [offset + diffpos, count - 1].min].max
|
|
end
|
|
|
|
[[offset, cursor] != original, offset, cursor]
|
|
end
|
|
|
|
def update_list wipe
|
|
render do
|
|
pos, items = sync {
|
|
changed, @yoff, @ycur =
|
|
constrain(@yoff, @ycur, @matches.length, max_items)
|
|
wipe ||= changed
|
|
|
|
[@ycur - @yoff, @matches[@yoff, max_items]]
|
|
}
|
|
|
|
# Wipe
|
|
if items.length < @plcount
|
|
@plcount.downto(items.length) do |idx|
|
|
C.setpos cursor_y(idx + 2), 0
|
|
C.clrtoeol
|
|
end
|
|
end
|
|
@plcount = items.length
|
|
|
|
dirty = Set[pos]
|
|
set(:@dirty) do |vs|
|
|
dirty.merge vs
|
|
Set.new
|
|
end
|
|
items.each_with_index do |item, idx|
|
|
next unless wipe || dirty.include?(idx)
|
|
row = cursor_y(idx + 2)
|
|
chosen = idx == pos
|
|
selected = @selects.include?([*item][0])
|
|
line, offsets = item
|
|
tokens = format line, C.cols - 3, offsets
|
|
print_item row, tokens, chosen, selected
|
|
end
|
|
print_info
|
|
print_input
|
|
end
|
|
end
|
|
|
|
def start_renderer
|
|
init_screen
|
|
|
|
Thread.new do
|
|
begin
|
|
while blk = @queue.shift
|
|
blk.call
|
|
refresh
|
|
end
|
|
rescue Exception => e
|
|
@main.raise e
|
|
end
|
|
end
|
|
end
|
|
|
|
def render &blk
|
|
@queue.push blk
|
|
nil
|
|
end
|
|
|
|
def vselect &prc
|
|
sync do
|
|
@dirty << @ycur - @yoff
|
|
@ycur = prc.call @ycur
|
|
end
|
|
update_list false
|
|
end
|
|
|
|
def num_unicode_bytes chr
|
|
# http://en.wikipedia.org/wiki/UTF-8
|
|
if chr & 0b10000000 > 0
|
|
bytes = 0
|
|
7.downto(2) do |shift|
|
|
break if (chr >> shift) & 0x1 == 0
|
|
bytes += 1
|
|
end
|
|
bytes
|
|
else
|
|
1
|
|
end
|
|
end
|
|
|
|
def read_nb chars = 1, default = nil, tries = 10
|
|
tries.times do |_|
|
|
begin
|
|
return @tty.read_nonblock(chars).ord
|
|
rescue Exception
|
|
sleep 0.01
|
|
end
|
|
end
|
|
default
|
|
end
|
|
|
|
def read_nbs
|
|
ords = []
|
|
while ord = read_nb
|
|
ords << ord
|
|
end
|
|
ords
|
|
end
|
|
|
|
def get_mouse
|
|
case ord = read_nb
|
|
when 32, 36, 40, 48, # mouse-down / shift / cmd / ctrl
|
|
35, 39, 43, 51 # mouse-up / shift / cmd / ctrl
|
|
x = read_nb - 33
|
|
y = read_nb - 33
|
|
{ :event => (ord % 2 == 0 ? :click : :release),
|
|
:x => x, :y => y, :shift => ord >= 36 }
|
|
when 96, 100, 104, 112, # scroll-up / shift / cmd / ctrl
|
|
97, 101, 105, 113 # scroll-down / shift / cmd / ctrl
|
|
read_nb(2)
|
|
{ :event => :scroll, :diff => (ord % 2 == 0 ? -1 : 1), :shift => ord >= 100 }
|
|
else
|
|
# e.g. 40, 43, 104, 105
|
|
read_nb(2)
|
|
nil
|
|
end
|
|
end
|
|
|
|
def get_input actions
|
|
@tty ||=
|
|
begin
|
|
require 'io/console'
|
|
IO.console
|
|
rescue LoadError
|
|
IO.open(IO.sysopen('/dev/tty'), 'r')
|
|
end
|
|
|
|
if pending = @pending
|
|
@pending = nil
|
|
return pending
|
|
end
|
|
|
|
str = ''
|
|
while true
|
|
ord =
|
|
if str.empty?
|
|
@tty.getc.ord
|
|
else
|
|
begin
|
|
ord = @tty.read_nonblock(1).ord
|
|
if (nb = num_unicode_bytes(ord)) > 1
|
|
ords = [ord]
|
|
(nb - 1).times do |_|
|
|
ords << @tty.read_nonblock(1).ord
|
|
end
|
|
# UTF-8 TODO Ruby 1.8
|
|
ords.pack('C*').force_encoding('UTF-8')
|
|
else
|
|
ord
|
|
end
|
|
rescue Exception
|
|
return str
|
|
end
|
|
end
|
|
|
|
ord =
|
|
case read_nb(1, :esc)
|
|
when 91, 79
|
|
case read_nb(1, nil)
|
|
when 68 then ctrl(:b)
|
|
when 67 then ctrl(:f)
|
|
when 66 then ctrl(:j)
|
|
when 65 then ctrl(:k)
|
|
when 90 then :stab
|
|
when 50 then read_nb; :ins
|
|
when 51 then read_nb; :del
|
|
when 53 then read_nb; :pgup
|
|
when 54 then read_nb; :pgdn
|
|
when 49
|
|
case read_nbs
|
|
when [59, 50, 68] then ctrl(:a)
|
|
when [59, 50, 67] then ctrl(:e)
|
|
when [59, 53, 68] then :alt_b
|
|
when [59, 53, 67] then :alt_f
|
|
when [126] then ctrl(:a)
|
|
end
|
|
when 52 then read_nb; ctrl(:e)
|
|
when 72 then ctrl(:a)
|
|
when 70 then ctrl(:e)
|
|
when 77
|
|
get_mouse
|
|
end
|
|
when 'b', 98 then :alt_b
|
|
when 'd', 100 then :alt_d
|
|
when 'f', 102 then :alt_f
|
|
when :esc then :esc
|
|
when 127 then :alt_bs
|
|
else next
|
|
end if ord == 27
|
|
|
|
return ord if ord.nil? || ord.is_a?(Hash)
|
|
|
|
if actions.has_key?(ord)
|
|
if str.empty?
|
|
return ord
|
|
else
|
|
@pending = ord
|
|
return str
|
|
end
|
|
else
|
|
unless ord.is_a? String
|
|
ord = [ord].pack('U*')
|
|
end
|
|
str << ord if ord =~ /[[:print:]]/
|
|
end
|
|
end
|
|
end
|
|
|
|
class MouseEvent
|
|
DOUBLE_CLICK_INTERVAL = 0.5
|
|
|
|
attr_reader :v
|
|
|
|
def initialize v = nil
|
|
@c = 0
|
|
@v = v
|
|
@t = Time.at 0
|
|
end
|
|
|
|
def v= v
|
|
@c = (@v == v && within?) ? @c + 1 : 0
|
|
@v = v
|
|
@t = Time.now
|
|
end
|
|
|
|
def double? v
|
|
@c == 1 && @v == v && within?
|
|
end
|
|
|
|
def within?
|
|
(Time.now - @t) < DOUBLE_CLICK_INTERVAL
|
|
end
|
|
end
|
|
|
|
def start_loop
|
|
got = nil
|
|
begin
|
|
input = call(:@query, :dup)
|
|
cursor = input.length
|
|
yanked = ''
|
|
mouse_event = MouseEvent.new
|
|
backword = proc {
|
|
cursor = (input[0, cursor].rindex(/[^[:alnum:]][[:alnum:]]/) || -1) + 1
|
|
nil
|
|
}
|
|
forward = proc {
|
|
cursor += (input[cursor..-1].index(/([[:alnum:]][^[:alnum:]])|(.$)/) || -1) + 1
|
|
nil
|
|
}
|
|
rubout = proc { |regex|
|
|
pcursor = cursor
|
|
cursor = (input[0, cursor].rindex(regex) || -1) + 1
|
|
if pcursor > cursor
|
|
yanked = input[cursor...pcursor]
|
|
input = input[0...cursor] + input[pcursor..-1]
|
|
end
|
|
}
|
|
actions = {
|
|
:esc => proc { exit 1 },
|
|
ctrl(:d) => proc {
|
|
if input.empty?
|
|
exit 1
|
|
elsif cursor < input.length
|
|
input = input[0...cursor] + input[(cursor + 1)..-1]
|
|
end
|
|
},
|
|
ctrl(:m) => proc {
|
|
got = pick
|
|
exit 0
|
|
},
|
|
ctrl(:u) => proc {
|
|
yanked = input[0...cursor] if cursor > 0
|
|
input = input[cursor..-1]
|
|
cursor = 0
|
|
},
|
|
ctrl(:a) => proc { cursor = 0; nil },
|
|
ctrl(:e) => proc { cursor = input.length; nil },
|
|
ctrl(:j) => proc { vselect { |v| v - @rev_dir } },
|
|
ctrl(:k) => proc { vselect { |v| v + @rev_dir } },
|
|
ctrl(:w) => proc { rubout.call /\s\S/ },
|
|
ctrl(:y) => proc { actions[:default].call yanked },
|
|
ctrl(:h) => proc { input[cursor -= 1] = '' if cursor > 0 },
|
|
ctrl(:i) => proc { |o|
|
|
if @multi && sel = pick
|
|
sync do
|
|
if @selects.has_key? sel
|
|
@selects.delete sel
|
|
else
|
|
@selects[sel] = sel.orig
|
|
end
|
|
end
|
|
vselect { |v| v + case o
|
|
when :stab then 1
|
|
when :sclick then 0
|
|
else -1
|
|
end * @rev_dir }
|
|
end
|
|
},
|
|
ctrl(:b) => proc { cursor = [0, cursor - 1].max; nil },
|
|
ctrl(:f) => proc { cursor = [input.length, cursor + 1].min; nil },
|
|
ctrl(:l) => proc { render { C.clear; C.refresh }; update_list true },
|
|
:del => proc { input[cursor] = '' if input.length > cursor },
|
|
:pgup => proc { vselect { |v| v + @rev_dir * (max_items - 1) } },
|
|
:pgdn => proc { vselect { |v| v - @rev_dir * (max_items - 1) } },
|
|
:alt_bs => proc { rubout.call /[^[:alnum:]][[:alnum:]]/ },
|
|
:alt_b => proc { backword.call },
|
|
:alt_d => proc {
|
|
pcursor = cursor
|
|
forward.call
|
|
if cursor > pcursor
|
|
yanked = input[pcursor...cursor]
|
|
input = input[0...pcursor] + input[cursor..-1]
|
|
cursor = pcursor
|
|
end
|
|
},
|
|
:alt_f => proc {
|
|
forward.call
|
|
},
|
|
:default => proc { |val|
|
|
case val
|
|
when String
|
|
input.insert cursor, val
|
|
cursor += val.length
|
|
when Hash
|
|
event = val[:event]
|
|
case event
|
|
when :click, :release
|
|
x, y, shift = val.values_at :x, :y, :shift
|
|
y = @reverse ? (C.lines - 1 - y) : y
|
|
if y == C.lines - 1
|
|
cursor = [0, [input.length, x - @prompt.length].min].max
|
|
elsif x > 1 && y <= max_items
|
|
tv = get(:@yoff) + max_items - y - 1
|
|
|
|
case event
|
|
when :click
|
|
vselect { |_| tv }
|
|
actions[ctrl(:i)].call(:sclick) if shift
|
|
mouse_event.v = tv
|
|
when :release
|
|
if !shift && mouse_event.double?(tv)
|
|
actions[ctrl(:m)].call
|
|
end
|
|
end
|
|
end
|
|
when :scroll
|
|
diff, shift = val.values_at :diff, :shift
|
|
actions[ctrl(:i)].call(:sclick) if shift
|
|
actions[ctrl(diff > 0 ? :j : :k)].call
|
|
end
|
|
nil
|
|
end
|
|
}
|
|
}
|
|
actions[ctrl(:p)] = actions[ctrl(:k)]
|
|
actions[ctrl(:n)] = actions[ctrl(:j)]
|
|
actions[:stab] = actions[ctrl(:i)]
|
|
actions[127] = actions[ctrl(:h)]
|
|
actions[ctrl(:q)] = actions[ctrl(:g)] = actions[ctrl(:c)] = actions[:esc]
|
|
|
|
while true
|
|
set(:@xcur, cursor)
|
|
render { print_input }
|
|
|
|
if key = get_input(actions)
|
|
upd = actions.fetch(key, actions[:default]).call(key)
|
|
|
|
# Dispatch key event
|
|
emit(:key) { [set(:@query, input.dup), cursor] } if upd
|
|
end
|
|
end
|
|
ensure
|
|
C.close_screen
|
|
q, selects = geta(:@query, :@selects)
|
|
@stdout.puts q if @print_query
|
|
@stdout.puts if @expect
|
|
if got
|
|
if selects.empty?
|
|
burp got
|
|
else
|
|
selects.each do |sel, orig|
|
|
burp sel, orig
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
class Matcher
|
|
class MatchData
|
|
def initialize n
|
|
@n = n
|
|
end
|
|
|
|
def offset _
|
|
@n
|
|
end
|
|
end
|
|
|
|
def initialize nth, delim
|
|
@nth = nth
|
|
@delim = delim
|
|
@tokens_cache = {}
|
|
end
|
|
|
|
def tokenize str
|
|
@tokens_cache[str] ||= str.tokenize(@delim, @nth)
|
|
end
|
|
|
|
def do_match str, pat
|
|
if @nth
|
|
tokenize(str).each do |pair|
|
|
prefix_length, token = pair
|
|
if md = token.match(pat) rescue nil
|
|
return MatchData.new(md.offset(0).map { |o| o + prefix_length })
|
|
end
|
|
end
|
|
nil
|
|
else
|
|
str.match(pat) rescue nil
|
|
end
|
|
end
|
|
end
|
|
|
|
class FuzzyMatcher < Matcher
|
|
attr_reader :caches, :rxflag
|
|
|
|
def initialize rxflag, nth = nil, delim = nil
|
|
super nth, delim
|
|
@caches = Hash.new { |h, k| h[k] = {} }
|
|
@regexp = {}
|
|
@rxflag = rxflag
|
|
end
|
|
|
|
def empty? q
|
|
q.empty?
|
|
end
|
|
|
|
def rxflag_for q
|
|
@rxflag || (q =~ /[A-Z]/ ? 0 : Regexp::IGNORECASE)
|
|
end
|
|
|
|
def fuzzy_regex q
|
|
@regexp[q] ||= begin
|
|
q = q.downcase if @rxflag == Regexp::IGNORECASE
|
|
Regexp.new(q.split(//).inject('') { |sum, e|
|
|
e = Regexp.escape e
|
|
sum << (e.length > 1 ? "(?:#{e}).*?" : # FIXME: not equivalent
|
|
"#{e}[^#{e}]*?")
|
|
}, rxflag_for(q))
|
|
end
|
|
end
|
|
|
|
def match list, q, prefix, suffix
|
|
regexp = fuzzy_regex q
|
|
|
|
cache = @caches[list.object_id]
|
|
prefix_cache = nil
|
|
(prefix.length - 1).downto(1) do |len|
|
|
break if prefix_cache = cache[prefix[0, len]]
|
|
end
|
|
|
|
suffix_cache = nil
|
|
0.upto(suffix.length - 1) do |idx|
|
|
break if suffix_cache = cache[suffix[idx..-1]]
|
|
end unless suffix.empty?
|
|
|
|
partial_cache = [prefix_cache,
|
|
suffix_cache].compact.sort_by { |e| e.length }.first
|
|
cache[q] ||= (partial_cache ?
|
|
partial_cache.map { |e| e.first } : list).map { |line|
|
|
# Ignore errors: e.g. invalid byte sequence in UTF-8
|
|
md = do_match(line, regexp)
|
|
md && [line, [md.offset(0)]]
|
|
}.compact
|
|
end
|
|
end
|
|
|
|
class ExtendedFuzzyMatcher < FuzzyMatcher
|
|
def initialize rxflag, mode = :fuzzy, nth = nil, delim = nil
|
|
super rxflag, nth, delim
|
|
@regexps = {}
|
|
@mode = mode
|
|
end
|
|
|
|
def empty? q
|
|
parse(q).empty?
|
|
end
|
|
|
|
def parse q
|
|
q = q.strip
|
|
@regexps[q] ||= q.split(/\s+/).map { |w|
|
|
invert =
|
|
if w =~ /^!/
|
|
w = w[1..-1]
|
|
true
|
|
end
|
|
|
|
[ @regexp[w] ||=
|
|
case w
|
|
when ''
|
|
nil
|
|
when /^\^(.*)\$$/
|
|
Regexp.new('^' << Regexp.escape($1) << '$', rxflag_for(w))
|
|
when /^'/
|
|
if @mode == :fuzzy && w.length > 1
|
|
exact_regex w[1..-1]
|
|
elsif @mode == :exact
|
|
exact_regex w
|
|
end
|
|
when /^\^/
|
|
w.length > 1 ?
|
|
Regexp.new('^' << Regexp.escape(w[1..-1]), rxflag_for(w)) : nil
|
|
when /\$$/
|
|
w.length > 1 ?
|
|
Regexp.new(Regexp.escape(w[0..-2]) << '$', rxflag_for(w)) : nil
|
|
else
|
|
@mode == :fuzzy ? fuzzy_regex(w) : exact_regex(w)
|
|
end, invert ]
|
|
}.select { |pair| pair.first }
|
|
end
|
|
|
|
def exact_regex w
|
|
Regexp.new(Regexp.escape(w), rxflag_for(w))
|
|
end
|
|
|
|
def match list, q, prefix, suffix
|
|
regexps = parse q
|
|
# Look for prefix cache
|
|
cache = @caches[list.object_id]
|
|
prefix = prefix.strip.sub(/\$\S*$/, '').sub(/(^|\s)!\S*$/, '')
|
|
prefix_cache = nil
|
|
(prefix.length - 1).downto(1) do |len|
|
|
break if prefix_cache = cache[Set[@regexps[prefix[0, len]]]]
|
|
end
|
|
|
|
cache[Set[regexps]] ||= (prefix_cache ?
|
|
prefix_cache.map { |e| e.first } :
|
|
list).map { |line|
|
|
offsets = []
|
|
regexps.all? { |pair|
|
|
regexp, invert = pair
|
|
md = do_match(line, regexp)
|
|
if md && !invert
|
|
offsets << md.offset(0)
|
|
elsif !md && invert
|
|
true
|
|
end
|
|
} && [line, offsets]
|
|
}.select { |e| e }
|
|
end
|
|
end
|
|
end#FZF
|
|
|
|
FZF.new(ARGV, $stdin).start if ENV.fetch('FZF_EXECUTABLE', '1') == '1'
|
|
|