mirror of
https://github.com/junegunn/fzf.git
synced 2026-04-26 17:30:32 +08:00
f2a79ab8aa
Adds a new BorderShape, BorderInline, accepted as a value for
--header-border, --header-lines-border, and --footer-border. When the
surrounding --list-border has both top and bottom horizontals (rounded,
sharp, bold, double, thinblock, block, horizontal), the corresponding
section is rendered inside the list frame separated from the list
content by a horizontal line whose endpoints join the list border as
T-junctions. Without a compatible list border, the shape falls back to
BorderLine.
Supports:
- All three layouts (default, reverse, reverse-list).
- Any combination of the three inline sections, producing stacked
separators.
- --header-label and --footer-label rendered on their separator row.
- Section colors: the portion of the list frame adjacent to an inline
section (left/right verticals on the section's content rows plus the
outer top/bottom edge + corners when the section is at the edge)
inherits the section's --color *-border and *-bg, giving each section
a uniform color block. The separator itself carries the section's
colors since it acts as the section's inner edge.
- When --color header-border / --color footer-border is not set, the
inline section inherits --color list-border so the default palette
stays coherent.
- thinblock / block styles pick the horizontal char (top vs bottom)
based on which side of the list content the separator sits on, so
the thin line visually hugs the list content.
Rejects combinations that do not make sense:
- --input-border=inline / --list-border=inline / --preview-border=inline
- --header-first + (--header-border=inline | --header-lines-border=inline)
- --header-border=inline with a non-inline --header-lines-border
(inline has to propagate inward toward the list content).
329 lines
8.0 KiB
Ruby
329 lines
8.0 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'bundler/setup'
|
|
require 'minitest/autorun'
|
|
require 'fileutils'
|
|
require 'English'
|
|
require 'shellwords'
|
|
require 'erb'
|
|
require 'tempfile'
|
|
require 'net/http'
|
|
require 'json'
|
|
|
|
TEMPLATE = File.read(File.expand_path('common.sh', __dir__))
|
|
FISH_TEMPLATE = File.read(File.expand_path('common.fish', __dir__))
|
|
UNSETS = %w[
|
|
FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS
|
|
FZF_TMUX FZF_TMUX_OPTS
|
|
FZF_CTRL_T_COMMAND FZF_CTRL_T_OPTS
|
|
FZF_ALT_C_COMMAND
|
|
FZF_ALT_C_OPTS FZF_CTRL_R_OPTS
|
|
FZF_API_KEY
|
|
].freeze
|
|
DEFAULT_TIMEOUT = 10
|
|
|
|
FILE = File.expand_path(__FILE__)
|
|
BASE = File.expand_path('../..', __dir__)
|
|
Dir.chdir(BASE)
|
|
FZF = %(FZF_DEFAULT_OPTS="--no-scrollbar --gutter ' ' --pointer '>' --marker '>'" FZF_DEFAULT_COMMAND= #{BASE}/bin/fzf).freeze
|
|
|
|
def wait(timeout = DEFAULT_TIMEOUT)
|
|
since = Time.now
|
|
begin
|
|
yield or raise Minitest::Assertion, 'Assertion failure'
|
|
rescue Minitest::Assertion
|
|
raise if Time.now - since > timeout
|
|
|
|
sleep(0.05)
|
|
retry
|
|
end
|
|
end
|
|
|
|
class Shell
|
|
class << self
|
|
def bash
|
|
@bash ||=
|
|
begin
|
|
bashrc = '/tmp/fzf.bash'
|
|
File.open(bashrc, 'w') do |f|
|
|
f.puts ERB.new(TEMPLATE).result(binding)
|
|
end
|
|
|
|
"bash --rcfile #{bashrc}"
|
|
end
|
|
end
|
|
|
|
def zsh
|
|
@zsh ||=
|
|
begin
|
|
zdotdir = '/tmp/fzf-zsh'
|
|
FileUtils.rm_rf(zdotdir)
|
|
FileUtils.mkdir_p(zdotdir)
|
|
File.open("#{zdotdir}/.zshrc", 'w') do |f|
|
|
f.puts ERB.new(TEMPLATE).result(binding)
|
|
end
|
|
"ZDOTDIR=#{zdotdir} zsh"
|
|
end
|
|
end
|
|
|
|
def fish
|
|
@fish ||=
|
|
begin
|
|
confdir = '/tmp/fzf-fish'
|
|
FileUtils.rm_rf(confdir)
|
|
FileUtils.mkdir_p("#{confdir}/fish/conf.d")
|
|
File.open("#{confdir}/fish/conf.d/fzf.fish", 'w') do |f|
|
|
f.puts ERB.new(FISH_TEMPLATE).result(binding)
|
|
end
|
|
"rm -f ~/.local/share/fish/fzf_test_history; XDG_CONFIG_HOME=#{confdir} fish"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
class Tmux
|
|
attr_reader :win
|
|
|
|
def initialize(shell = :bash)
|
|
@win = go(%W[new-window -d -P -F #I #{Shell.send(shell)}]).first
|
|
go(%W[set-window-option -t #{@win} pane-base-index 0])
|
|
return unless shell == :fish
|
|
|
|
send_keys 'function fish_prompt; end; clear', :Enter
|
|
self.until(&:empty?)
|
|
end
|
|
|
|
def kill
|
|
go(%W[kill-window -t #{win}])
|
|
end
|
|
|
|
def focus
|
|
go(%W[select-window -t #{win}])
|
|
end
|
|
|
|
def send_keys(*args)
|
|
go(%W[send-keys -t #{win}] + args.map(&:to_s))
|
|
end
|
|
|
|
# Simulate a mouse click at the given 1-based column and row using the SGR mouse protocol
|
|
# (xterm mouse mode 1006, which fzf enables). The escape sequence is injected as literal
|
|
# keystrokes via tmux, and fzf parses it like a real terminal mouse event.
|
|
#
|
|
# tmux's own mouse handling intercepts these sequences when `set -g mouse on`, so we toggle
|
|
# mouse off for the duration of the click and restore the previous state afterwards.
|
|
def click(col, row, button: 0)
|
|
prev = go(%w[show-options -gv mouse]).first
|
|
go(%w[set-option -g mouse off])
|
|
begin
|
|
seq = "\e[<#{button};#{col};#{row}M\e[<#{button};#{col};#{row}m"
|
|
go(%W[send-keys -t #{win} -l #{seq}])
|
|
ensure
|
|
go(%W[set-option -g mouse #{prev}]) if prev && !prev.empty?
|
|
end
|
|
end
|
|
|
|
def paste(str)
|
|
system('tmux', 'setb', str, ';', 'pasteb', '-t', win, ';', 'send-keys', '-t', win, 'Enter')
|
|
end
|
|
|
|
def capture
|
|
go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse
|
|
end
|
|
|
|
# 3-bit ANSI bg code (40..47) -> color name used in --color options.
|
|
BG_NAMES = %w[black red green yellow blue magenta cyan white].freeze
|
|
|
|
# Parse `tmux capture-pane -e` output into per-row bg ranges. Each row is an
|
|
# array of [col_start, col_end, bg] tuples where bg is one of:
|
|
# 'default'
|
|
# 'red' / 'green' / 'blue' / ... (3-bit names)
|
|
# 'bright-red' / ... (bright variants)
|
|
# '256:<n>' (256-color fallback)
|
|
# ANSI state persists across rows, matching real terminal behavior.
|
|
def bg_ranges
|
|
raw = go(%W[capture-pane -p -J -e -t #{win}])
|
|
bg = 'default'
|
|
raw.map do |row|
|
|
cells = []
|
|
i = 0
|
|
len = row.length
|
|
while i < len
|
|
c = row[i]
|
|
if c == "\e" && row[i + 1] == '['
|
|
j = i + 2
|
|
j += 1 while j < len && row[j] != 'm'
|
|
parts = row[i + 2...j].split(';')
|
|
k = 0
|
|
while k < parts.length
|
|
p = parts[k].to_i
|
|
case p
|
|
when 0, 49 then bg = 'default'
|
|
when 40..47 then bg = BG_NAMES[p - 40]
|
|
when 100..107 then bg = "bright-#{BG_NAMES[p - 100]}"
|
|
when 48
|
|
if parts[k + 1] == '5'
|
|
bg = "256:#{parts[k + 2]}"
|
|
k += 2
|
|
elsif parts[k + 1] == '2'
|
|
bg = "rgb:#{parts[k + 2]}:#{parts[k + 3]}:#{parts[k + 4]}"
|
|
k += 4
|
|
end
|
|
end
|
|
k += 1
|
|
end
|
|
i = j + 1
|
|
else
|
|
cells << bg
|
|
i += 1
|
|
end
|
|
end
|
|
ranges = []
|
|
start = 0
|
|
cells.each_with_index do |b, idx|
|
|
if idx.positive? && b != cells[idx - 1]
|
|
ranges << [start, idx - 1, cells[idx - 1]]
|
|
start = idx
|
|
end
|
|
end
|
|
ranges << [start, cells.length - 1, cells.last] unless cells.empty?
|
|
ranges
|
|
end
|
|
end
|
|
|
|
def until(refresh = false, timeout: DEFAULT_TIMEOUT)
|
|
lines = nil
|
|
begin
|
|
wait(timeout) do
|
|
lines = capture
|
|
class << lines
|
|
def counts
|
|
lazy
|
|
.map { |l| l.scan(%r{^. ([0-9]+)/([0-9]+)( \(([0-9]+)\))?}) }
|
|
.reject(&:empty?)
|
|
.first&.first&.map(&:to_i)&.values_at(0, 1, 3) || [0, 0, 0]
|
|
end
|
|
|
|
def match_count
|
|
counts[0]
|
|
end
|
|
|
|
def item_count
|
|
counts[1]
|
|
end
|
|
|
|
def select_count
|
|
counts[2]
|
|
end
|
|
|
|
def any_include?(val)
|
|
method = val.is_a?(Regexp) ? :match : :include?
|
|
find { |line| line.send(method, val) }
|
|
end
|
|
end
|
|
yield(lines).tap do |ok|
|
|
send_keys 'C-l' if refresh && !ok
|
|
end
|
|
end
|
|
rescue Minitest::Assertion
|
|
puts $ERROR_INFO.backtrace
|
|
puts '>' * 80
|
|
puts lines
|
|
puts '<' * 80
|
|
raise
|
|
end
|
|
lines
|
|
end
|
|
|
|
def prepare
|
|
tries = 0
|
|
begin
|
|
self.until(true) do |lines|
|
|
message = "Prepare[#{tries}]"
|
|
send_keys ' ', 'C-u', :Enter, message, :Left, :Right
|
|
sleep(0.15)
|
|
lines[-1] == message
|
|
end
|
|
rescue Minitest::Assertion
|
|
(tries += 1) < 5 ? retry : raise
|
|
end
|
|
send_keys 'C-u', 'C-l'
|
|
end
|
|
|
|
private
|
|
|
|
def go(args)
|
|
IO.popen(%w[tmux] + args) { |io| io.readlines(chomp: true) }
|
|
end
|
|
end
|
|
|
|
class TestBase < Minitest::Test
|
|
TEMPNAME = Dir::Tmpname.create(%w[fzf]) {}
|
|
FIFONAME = Dir::Tmpname.create(%w[fzf-fifo]) {}
|
|
|
|
def writelines(lines)
|
|
File.write(TEMPNAME, lines.join("\n"))
|
|
end
|
|
|
|
def tempname
|
|
TEMPNAME
|
|
end
|
|
|
|
def fzf_output
|
|
@thread.join.value.chomp.tap { @thread = nil }
|
|
end
|
|
|
|
def fzf_output_lines
|
|
fzf_output.lines(chomp: true)
|
|
end
|
|
|
|
def setup
|
|
File.mkfifo(FIFONAME)
|
|
end
|
|
|
|
def teardown
|
|
FileUtils.rm_f([TEMPNAME, FIFONAME])
|
|
end
|
|
|
|
alias assert_equal_org assert_equal
|
|
def assert_equal(expected, actual)
|
|
# Ignore info separator
|
|
actual = actual&.sub(/\s*─+$/, '') if actual.is_a?(String) && actual&.match?(%r{\d+/\d+})
|
|
assert_equal_org(expected, actual)
|
|
end
|
|
|
|
# Run fzf with its output piped to a fifo
|
|
def fzf(*opts)
|
|
raise 'fzf_output not taken' if @thread
|
|
|
|
@thread = Thread.new { File.read(FIFONAME) }
|
|
fzf!(*opts) + " > #{FIFONAME.shellescape}"
|
|
end
|
|
|
|
def fzf!(*opts)
|
|
opts = opts.filter_map do |o|
|
|
case o
|
|
when Symbol
|
|
o = o.to_s
|
|
o.length > 1 ? "--#{o.tr('_', '-')}" : "-#{o}"
|
|
when String, Numeric
|
|
o.to_s
|
|
end
|
|
end
|
|
"#{FZF} #{opts.join(' ')}"
|
|
end
|
|
end
|
|
|
|
class TestInteractive < TestBase
|
|
attr_reader :tmux
|
|
|
|
def setup
|
|
super
|
|
@tmux = Tmux.new
|
|
end
|
|
|
|
def teardown
|
|
super
|
|
@tmux.kill
|
|
end
|
|
end
|