mirror of
https://github.com/junegunn/fzf
synced 2026-07-19 16:23:18 +00:00
61ba8d5a11
Search is interrupted when the query string has changed. This frequently happens when the user is actively typing in a query. This (rather arbitrary) delay is introduced not to start the next search immediately, which is likely to be interrupted as well. The result of it is that fzf feels more responsive.
1196 lines
32 KiB
Ruby
Executable File
1196 lines
32 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
|
# encoding: utf-8
|
|
#
|
|
# ____ ____
|
|
# / __/___ / __/
|
|
# / /_/_ / / /_
|
|
# / __/ / /_/ __/
|
|
# /_/ /___/_/ Fuzzy finder for your shell
|
|
#
|
|
# Version: 0.8.5 (Jun 15, 2014)
|
|
#
|
|
# 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.
|
|
|
|
require 'thread'
|
|
require 'curses'
|
|
require 'set'
|
|
|
|
unless String.method_defined? :force_encoding
|
|
class String
|
|
def force_encoding *arg
|
|
self
|
|
end
|
|
end
|
|
end
|
|
|
|
class FZF
|
|
C = Curses
|
|
attr_reader :rxflag, :sort, :nth, :color, :black, :ansi256, :reverse,
|
|
:mouse, :multi, :query, :select1, :exit0, :filter, :extended
|
|
|
|
class AtomicVar
|
|
def initialize value
|
|
@value = value
|
|
@mutex = Mutex.new
|
|
end
|
|
|
|
def get
|
|
@mutex.synchronize { @value }
|
|
end
|
|
|
|
def set value = nil
|
|
@mutex.synchronize do
|
|
@value = block_given? ? yield(@value) : value
|
|
end
|
|
end
|
|
|
|
def method_missing sym, *args, &blk
|
|
@mutex.synchronize { @value.send(sym, *args, &blk) }
|
|
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
|
|
@delim = nil
|
|
@reverse = 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 = AtomicVar.new query.dup
|
|
when /^-q(.*)$/, /^--query=(.*)$/
|
|
@query = AtomicVar.new($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 '-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 '-e', '--extended-exact' then @extended = :exact
|
|
when '+e', '--no-extended-exact' then @extended = nil
|
|
else
|
|
usage 1, "illegal option: #{o}"
|
|
end
|
|
end
|
|
|
|
@source = source.clone
|
|
@mtx = Mutex.new
|
|
@cv = ConditionVariable.new
|
|
@events = {}
|
|
@new = []
|
|
@queue = Queue.new
|
|
@pending = nil
|
|
|
|
unless @filter
|
|
@query ||= AtomicVar.new('')
|
|
@cursor_x = AtomicVar.new(@query.length)
|
|
@matches = AtomicVar.new([])
|
|
@count = AtomicVar.new(0)
|
|
@vcursor = AtomicVar.new(0)
|
|
@vcursors = AtomicVar.new(Set.new)
|
|
@spinner = AtomicVar.new('-\|/-\|/'.split(//))
|
|
@selects = AtomicVar.new({}) # ordered >= 1.9
|
|
@main = Thread.current
|
|
@plcount = 0
|
|
end
|
|
end
|
|
|
|
def parse_nth nth
|
|
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 })
|
|
}
|
|
end
|
|
|
|
def FZF.build_delim_regex delim
|
|
Regexp.compile(delim) rescue (delim = Regexp.escape(delim))
|
|
Regexp.compile "(?:.*?#{delim})|(?:.+?$)"
|
|
end
|
|
|
|
def start
|
|
if @filter
|
|
start_reader.join
|
|
filter_list @new
|
|
else
|
|
start_reader
|
|
emit(:key) { q = @query.get; [q, q.length] } unless empty = @query.empty?
|
|
if @select1 || @exit0
|
|
start_search do |loaded, matches|
|
|
len = empty ? @count.get : matches.length
|
|
if loaded
|
|
if @select1 && len == 1
|
|
puts empty ? matches.first : matches.first.first
|
|
exit 0
|
|
elsif @exit0 && len == 0
|
|
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
|
|
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])
|
|
-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
|
|
|
|
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.
|
|
|
|
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
|
|
@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
|
|
C.addstr str.gsub("\0", '')
|
|
end
|
|
|
|
def print_input
|
|
C.setpos cursor_y, 0
|
|
C.clrtoeol
|
|
cprint '> ', color(:prompt, true)
|
|
C.attron(C::A_BOLD) do
|
|
C.addstr @query.get
|
|
end
|
|
end
|
|
|
|
def print_info msg = nil
|
|
C.setpos cursor_y(1), 0
|
|
C.clrtoeol
|
|
prefix =
|
|
if spinner = @spinner.first
|
|
cprint spinner, color(:spinner, true)
|
|
' '
|
|
else
|
|
' '
|
|
end
|
|
C.attron color(:info, false) do
|
|
C.addstr "#{prefix}#{@matches.length}/#{@count.get}"
|
|
if (selected = @selects.length) > 0
|
|
C.addstr " (#{selected})"
|
|
end
|
|
C.addstr msg if msg
|
|
end
|
|
end
|
|
|
|
def refresh
|
|
C.setpos cursor_y, 2 + width(@query[0, @cursor_x.get])
|
|
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 = $stdout.clone
|
|
$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?
|
|
if default_command = ENV['FZF_DEFAULT_COMMAND']
|
|
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
|
|
while line = stream.gets
|
|
emit(:new) { @new << line.chomp }
|
|
end
|
|
emit(:loaded) { true }
|
|
@spinner.clear if @spinner
|
|
end
|
|
end
|
|
|
|
def start_search &callback
|
|
Thread.new do
|
|
lists = []
|
|
events = {}
|
|
fcache = {}
|
|
q = ''
|
|
delay = -5
|
|
|
|
begin
|
|
while true
|
|
@mtx.synchronize do
|
|
while true
|
|
events.merge! @events
|
|
|
|
if @events.empty? # No new events
|
|
@cv.wait @mtx
|
|
next
|
|
end
|
|
@events.clear
|
|
break
|
|
end
|
|
|
|
if events[:new]
|
|
lists << @new
|
|
@count.set { |c| c + @new.length }
|
|
@spinner.set { |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 = @mtx.synchronize { @events[:key] }
|
|
break if skip
|
|
|
|
if !empty && (progress = 100 * cnt / @count.get) < 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
|
|
@matches.set 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
|
|
items = @matches[0, max_items]
|
|
curr = [0, [@vcursor.get, items.length - 1].min].max
|
|
[*items.fetch(curr, [])][0]
|
|
end
|
|
|
|
def update_list wipe
|
|
render do
|
|
items = @matches[0, 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
|
|
|
|
maxc = C.cols - 3
|
|
vcursor = @vcursor.set { |v| [0, [v, items.length - 1].min].max }
|
|
cleanse = Set[vcursor]
|
|
@vcursors.set { |vs|
|
|
cleanse.merge vs
|
|
Set.new
|
|
}
|
|
items.each_with_index do |item, idx|
|
|
next unless wipe || cleanse.include?(idx)
|
|
row = cursor_y(idx + 2)
|
|
chosen = idx == vcursor
|
|
selected = @selects.include?([*item][0])
|
|
line, offsets = item
|
|
tokens = format line, maxc, 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
|
|
@vcursor.set { |v| @vcursors << v; prc.call v }
|
|
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 ||= IO.open(IO.sysopen('/dev/tty'), 'r')
|
|
|
|
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 [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 'f', 102 then :alt_f
|
|
when :esc then :esc
|
|
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 = @query.get.dup
|
|
cursor = input.length
|
|
yanked = ''
|
|
mouse_event = MouseEvent.new
|
|
backword = proc {
|
|
cursor = (input[0, cursor].rindex(/\s\S/) || -1) + 1
|
|
}
|
|
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 - (@reverse ? -1 : 1) } },
|
|
ctrl(:k) => proc { vselect { |v| v + (@reverse ? -1 : 1) } },
|
|
ctrl(:w) => proc {
|
|
pcursor = cursor
|
|
backword.call
|
|
yanked = input[cursor...pcursor] if pcursor > cursor
|
|
input = input[0...cursor] + input[pcursor..-1]
|
|
},
|
|
ctrl(:y) => proc { actions[:default].call yanked },
|
|
ctrl(:h) => proc { input[cursor -= 1] = '' if cursor > 0 },
|
|
ctrl(:i) => proc { |o|
|
|
if @multi && sel = pick
|
|
if @selects.has_key? sel
|
|
@selects.delete sel
|
|
else
|
|
@selects[sel] = 1
|
|
end
|
|
vselect { |v| v + case o
|
|
when :stab then 1
|
|
when :sclick then 0
|
|
else -1
|
|
end * (@reverse ? -1 : 1) }
|
|
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 { |_| max_items } },
|
|
:pgdn => proc { vselect { |_| 0 } },
|
|
:alt_b => proc { backword.call; nil },
|
|
:alt_f => proc {
|
|
cursor += (input[cursor..-1].index(/(\S\s)|(.$)/) || -1) + 1
|
|
nil
|
|
},
|
|
: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 == cursor_y
|
|
cursor = [0, [input.length, x - 2].min].max
|
|
elsif x > 1 && y <= max_items
|
|
tv = 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
|
|
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
|
|
@cursor_x.set cursor
|
|
render { print_input }
|
|
|
|
if key = get_input(actions)
|
|
upd = actions.fetch(key, actions[:default]).call(key)
|
|
|
|
# Dispatch key event
|
|
emit(:key) { [@query.set(input.dup), cursor] } if upd
|
|
end
|
|
end
|
|
ensure
|
|
C.close_screen
|
|
if got
|
|
if @selects.empty?
|
|
@stdout.puts got
|
|
else
|
|
@selects.each do |sel, _|
|
|
@stdout.puts sel
|
|
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] ||=
|
|
unless @delim
|
|
# AWK default
|
|
prefix_length = str[/^\s+/].length rescue 0
|
|
[prefix_length, (str.strip.scan(/\S+\s*/) rescue [])]
|
|
else
|
|
prefix_length = 0
|
|
[prefix_length, (str.scan(@delim) rescue [])]
|
|
end
|
|
end
|
|
|
|
def do_match str, pat
|
|
if @nth
|
|
prefix_length, tokens = tokenize str
|
|
|
|
@nth.each do |n|
|
|
if (range = tokens[n]) && (token = range.join) &&
|
|
(md = token.sub(/\s+$/, '').match(pat) rescue nil)
|
|
prefix_length += (tokens[0...(n.begin)] || []).join.length
|
|
offset = md.offset(0).map { |o| o + prefix_length }
|
|
return MatchData.new(offset)
|
|
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'
|
|
|