mirror of
https://github.com/Llewellynvdm/fzf.git
synced 2024-12-03 18:38:20 +00:00
9904f5354e
See the discussion in #18. Use --black option to use black background regardless of the default background color of the terminal. Also, this option can be used to fix rendering issues on terminals that don't support use_default_colors (man 3 default_colors). Depending on the terminal, use_default_colors may or may not succeed, but the Ruby version of it always returns nil, it's currently not possible to automatically enable this option.
1152 lines
29 KiB
Ruby
Executable File
1152 lines
29 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
|
# encoding: utf-8
|
|
#
|
|
# ____ ____
|
|
# / __/___ / __/
|
|
# / /_/_ / / /_
|
|
# / __/ / /_/ __/
|
|
# /_/ /___/_/ Fuzzy finder for your shell
|
|
#
|
|
# Version: 0.8.1 (March 9, 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, :color, :black, :ansi256, :mouse, :multi, :query, :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
|
|
@filter = nil
|
|
|
|
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 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 '+s', '--no-sort' then @sort = nil
|
|
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 '-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 start
|
|
if @filter
|
|
start_reader(false).join
|
|
filter_list @new
|
|
else
|
|
@stdout = $stdout.clone
|
|
$stdout.reopen($stderr)
|
|
|
|
start_reader true
|
|
init_screen
|
|
start_renderer
|
|
start_search
|
|
start_loop
|
|
end
|
|
end
|
|
|
|
def filter_list list
|
|
matches = get_matcher.match(list, @filter, '', '')
|
|
if @sort && matches.length <= @sort
|
|
matches = sort_by_rank(matches)
|
|
end
|
|
matches.each { |m| puts m.first }
|
|
end
|
|
|
|
def get_matcher
|
|
if @extended
|
|
ExtendedFuzzyMatcher.new @rxflag, @extended
|
|
else
|
|
FuzzyMatcher.new @rxflag
|
|
end
|
|
end
|
|
|
|
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 usage x, message = nil
|
|
$stderr.puts message if message
|
|
$stderr.puts %[usage: fzf [options]
|
|
|
|
Options
|
|
-m, --multi Enable multi-select
|
|
-x, --extended Extended-search mode
|
|
-e, --extended-exact Extended-search mode (exact match)
|
|
-q, --query=STR Initial query
|
|
-f, --filter=STR Filter mode. Do not start interactive finder.
|
|
-s, --sort=MAX Maximum number of matched items to sort (default: 1000)
|
|
+s, --no-sort Do not sort the result. Keep the sequence unchanged.
|
|
-i Case-insensitive match (default: smart-case match)
|
|
+i Case-sensitive match
|
|
+c, --no-color Disable colors
|
|
+2, --no-256 Disable 256-color
|
|
--black Use black background
|
|
--no-mouse Disable mouse
|
|
|
|
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
|
|
|
|
case RUBY_PLATFORM
|
|
when /darwin/
|
|
module UConv
|
|
CHOSUNG = 0x1100
|
|
JUNGSUNG = 0x1161
|
|
JONGSUNG = 0x11A7
|
|
CHOSUNGS = 19
|
|
JUNGSUNGS = 21
|
|
JONGSUNGS = 28
|
|
JJCOUNT = JUNGSUNGS * JONGSUNGS
|
|
NFC_BEGIN = 0xAC00
|
|
NFC_END = NFC_BEGIN + CHOSUNGS * JUNGSUNGS * JONGSUNGS
|
|
|
|
def self.nfd str
|
|
str.split(//).map do |c|
|
|
cp = c.ord
|
|
if cp >= NFC_BEGIN && cp < NFC_END
|
|
chr = ''
|
|
idx = cp - NFC_BEGIN
|
|
cho = CHOSUNG + idx / JJCOUNT
|
|
jung = JUNGSUNG + (idx % JJCOUNT) / JONGSUNGS
|
|
jong = JONGSUNG + idx % JONGSUNGS
|
|
chr << cho << jung
|
|
chr << jong if jong != JONGSUNG
|
|
chr
|
|
else
|
|
c
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.to_nfc arr
|
|
[NFC_BEGIN + arr[0] * JJCOUNT +
|
|
(arr[1] || 0) * JONGSUNGS +
|
|
(arr[2] || 0)].pack('U*')
|
|
end
|
|
|
|
if String.method_defined?(:each_char)
|
|
def self.split str
|
|
str.each_char.to_a
|
|
end
|
|
else
|
|
def self.split str
|
|
str.split('')
|
|
end
|
|
end
|
|
|
|
def self.nfc str, offsets = []
|
|
ret = ''
|
|
omap = []
|
|
pend = []
|
|
split(str).each_with_index do |c, idx|
|
|
cp =
|
|
begin
|
|
c.ord
|
|
rescue Exception
|
|
next
|
|
end
|
|
omap << ret.length
|
|
unless pend.empty?
|
|
if cp >= JUNGSUNG && cp < JUNGSUNG + JUNGSUNGS
|
|
pend << cp - JUNGSUNG
|
|
next
|
|
elsif cp >= JONGSUNG && cp < JONGSUNG + JONGSUNGS
|
|
pend << cp - JONGSUNG
|
|
next
|
|
else
|
|
omap[-1] = omap[-1] + 1
|
|
ret << to_nfc(pend)
|
|
pend.clear
|
|
end
|
|
end
|
|
if cp >= CHOSUNG && cp < CHOSUNG + CHOSUNGS
|
|
pend << cp - CHOSUNG
|
|
else
|
|
ret << c
|
|
end
|
|
end
|
|
ret << to_nfc(pend) unless pend.empty?
|
|
return [ret,
|
|
offsets.map { |pair|
|
|
b, e = pair
|
|
[omap[b] || 0, omap[e] || ((omap.last || 0) + 1)] }]
|
|
end
|
|
end
|
|
|
|
def convert_item item
|
|
UConv.nfc(*item)
|
|
end
|
|
|
|
class Matcher
|
|
def query_chars q
|
|
UConv.nfd(q)
|
|
end
|
|
|
|
def sanitize q
|
|
UConv.nfd(q).join
|
|
end
|
|
end
|
|
else
|
|
def convert_item item
|
|
item
|
|
end
|
|
|
|
class Matcher
|
|
def query_chars q
|
|
q.split(//)
|
|
end
|
|
|
|
def sanitize q
|
|
q
|
|
end
|
|
end
|
|
end
|
|
|
|
def emit event
|
|
@mtx.synchronize do
|
|
@events[event] = yield
|
|
@cv.broadcast
|
|
end
|
|
end
|
|
|
|
def max_items; C.lines - 2; end
|
|
def cursor_y; C.lines - 1; 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
|
|
|
|
def sort_by_rank list
|
|
list.sort_by { |tuple|
|
|
line, offsets = tuple
|
|
matchlen = 0
|
|
pe = nil
|
|
offsets.sort.each do |pair|
|
|
b, e = pair
|
|
b = pe if pe && pe > b
|
|
pe = e
|
|
matchlen += e - b
|
|
end
|
|
[matchlen, line.length, line]
|
|
}
|
|
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
|
|
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
|
|
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 curses
|
|
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 curses
|
|
end
|
|
end
|
|
|
|
def start_search
|
|
matcher = get_matcher
|
|
searcher = Thread.new {
|
|
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 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
|
|
next if skip
|
|
matches = @sort ? found : found.reverse
|
|
if !empty && @sort && matches.length <= @sort
|
|
matches = sort_by_rank(matches)
|
|
end
|
|
fcache[q] = matches
|
|
end
|
|
|
|
# Atomic update
|
|
@matches.set matches
|
|
end#new_search
|
|
|
|
# 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
|
|
|
|
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 = convert_item item
|
|
tokens = format line, maxc, offsets
|
|
print_item row, tokens, chosen, selected
|
|
end
|
|
print_info
|
|
print_input
|
|
end
|
|
end
|
|
|
|
def start_renderer
|
|
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
|
|
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
|
|
backword = proc {
|
|
cursor = (input[0, cursor].rindex(/\s\S/) || -1) + 1
|
|
}
|
|
actions = {
|
|
:esc => proc { exit 1 },
|
|
ctrl(:d) => proc { exit 1 if input.empty? },
|
|
ctrl(:m) => proc {
|
|
got = pick
|
|
exit 0
|
|
},
|
|
ctrl(:u) => proc { 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 - 1 } },
|
|
ctrl(:k) => proc { vselect { |v| v + 1 } },
|
|
ctrl(:w) => proc {
|
|
pcursor = cursor
|
|
backword.call
|
|
input = input[0...cursor] + input[pcursor..-1]
|
|
},
|
|
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 }
|
|
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
|
|
},
|
|
}
|
|
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]
|
|
|
|
emit(:key) { [@query.get, cursor] } unless @query.empty?
|
|
mouse = MouseEvent.new
|
|
while true
|
|
@cursor_x.set cursor
|
|
render { print_input }
|
|
|
|
if key = get_input(actions)
|
|
upd = actions.fetch(key, 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
|
|
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.v = tv
|
|
when :release
|
|
if !shift && mouse.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
|
|
}).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 FuzzyMatcher < Matcher
|
|
attr_reader :caches, :rxflag
|
|
|
|
def initialize rxflag
|
|
@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(query_chars(q).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 = line.match(regexp) rescue nil
|
|
md && [line, [md.offset(0)]]
|
|
}.compact
|
|
end
|
|
end
|
|
|
|
class ExtendedFuzzyMatcher < FuzzyMatcher
|
|
def initialize rxflag, mode = :fuzzy
|
|
super rxflag
|
|
@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('^' << sanitize(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('^' << sanitize(Regexp.escape(w[1..-1])), rxflag_for(w)) : nil
|
|
when /\$$/
|
|
w.length > 1 ?
|
|
Regexp.new(sanitize(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(sanitize(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 = line.match(regexp) rescue nil
|
|
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'
|
|
|