require_relative './common'
require 'io/console'
RED = "\033[1;31m"
GREEN = "\033[1;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[1;34m"
BLUE_BG = "\033[0;44m"
BLUE_BG_RED = "\033[31;44m\033[1m"
BLUE_BG_YELLOW = "\033[33;44m"
NC = "\033[0m"
HELP_MSG = "#{GREEN}press h/H for help#{NC}".freeze
def erase_lines(line_count)
line_count.times do
$stderr.print "\e[A"
end
$stderr.print "\r\e[J"
end
def get_win_width
IO.console.winsize[1]
end
def compute_lines(len, win_width)
lines = 1 + len / win_width
lines -= 1 if (len % win_width).zero?
lines
end
def search_and_color(s, word, start_color, end_color)
target_s = s.dup
target_s.downcase! unless word =~ /[A-Z]/
extended = 0
target_s.to_enum(:scan, word).each do
s = s.dup if extended.zero? start_pos = Regexp.last_match.pre_match.size + extended
end_pos = start_pos + word.length
s.insert(end_pos, end_color)
s.insert(start_pos, start_color)
extended += start_color.length + end_color.length
end
s
end
class SelectorResult
attr_reader :min, :max, :options
def initialize(min, max, options)
@min = min
@max = max
@options = options
end
end
class Selector
class Empty < StandardError
end
class Quit < StandardError
end
def load(options)
@options = options
end
def register_keys(keys, callback, msg: '', recur: false)
@enter_overriden = true if keys.include?(ENTER)
keys = [keys] unless keys.is_a?(Array)
keys.each { |k| @callbacks.store(k, self.class.make_callback(callback, recur)) }
@helps.push(self.class.make_help(keys, msg, :no, recur)) unless msg.nil?
end
def register_keys_virtual(keys, callback, msg: '', recur: false)
keys = [keys] unless keys.is_a?(Array)
should_register_normal = true
keys.each do |k|
@virtual_callbacks.store(k, self.class.make_callback(callback, recur))
should_register_normal = false if @callbacks.include?(k)
end
if should_register_normal
normal_callback = lambda { |pos, opt|
callback.call(pos, pos + 1, [opt])
}
register_keys(keys, normal_callback, msg: nil, recur: recur)
@helps.push(self.class.make_help(keys, msg, :both, recur))
else
@helps.push(self.class.make_help(keys, msg, :yes, recur))
end
end
def initialize(offset: 1)
@options = []
@display_offset = offset
@search_string = ''
@number = nil
@callbacks = {}
@virtual_callbacks = {}
@helps = []
@enter_overriden = false
@virtual_state = nil
end
def can_virtual?
!@virtual_callbacks.empty?
end
def is_virtual_selected(pos)
@virtual_state.nil? ? false : @virtual_state.in_range?(pos)
end
def print_help
lines = 0
win_width = get_win_width
msgs = collect_help_str
msgs.each do |(msg, len)|
warn msg
lines += compute_lines(len, win_width)
end
warn '(press any key to continue)'
lines + 1
end
def run(sequence: '')
pos = 0
mode = :normal
win_width = get_win_width
help_printed = false
loop do
if sequence.empty? && !help_printed
warn HELP_MSG.to_s if sequence.empty?
help_printed = true
end
option_count = @options.length
raise Empty if option_count.zero?
line_count = 0
@virtual_state&.set_point(pos)
if sequence.empty?
@options.each_with_index do |_, i|
leading = pos == i ? '>' : ' '
option = format_option(i)
gen_line = ->(s) { "#{leading} #{i + @display_offset}. #{s}" }
line_count += compute_lines(gen_line.call(option).length, win_width) option = color_line(i, option)
option = gen_line.call(option)
option = "#{BLUE_BG}#{option}#{NC}" if is_virtual_selected(i)
$stderr.print("#{option}\n")
end
end
case mode
when :search
$stderr.print "/#{@search_string}"
when :number
$stderr.print ":#{@number}"
end
resp = if sequence.empty?
read_char
else
ch = sequence[0]
sequence = sequence[1..]
ch
end
callback = nil
case mode
when :search
case resp
when "\b", "\c?"
if @search_string.empty?
mode = :normal
else
@search_string = @search_string[0..-2]
end
when ENTER
mode = :normal
else
@search_string += resp
new_pos = search_index(pos)
pos = new_pos unless new_pos.nil?
end
when :number
case resp
when "\b", "\c?"
@number /= 10
mode = :normal if @number.zero?
when ENTER
mode = :normal
pos = [@number, @display_offset].max
pos -= @display_offset
pos = [pos, option_count - 1].min
else
@number = @number * 10 + resp.to_i if resp =~ /[0-9]/
end
else
case resp
when 'h', 'H'
lines = print_help
read_char
erase_lines lines
when 'q', 'Q'
raise Quit if @virtual_state.nil?
@virtual_state = nil
when 'j', 'J', "\e[B"
pos = (pos + 1) % option_count
when 'k', 'K', "\e[A"
pos = (pos - 1 + option_count) % option_count
when 'n'
new_pos = search_index(pos + 1)
pos = new_pos unless new_pos.nil?
when 'N'
new_pos = search_index(pos - 1, true)
pos = new_pos unless new_pos.nil?
when '/'
mode = :search
@search_string = ''
when 'v', 'V'
@virtual_state = (VirtualState.new(pos) if @virtual_state.nil? && can_virtual?)
else
if resp =~ /[0-9]/
mode = :number
@number = resp.to_i
elsif ENTER == resp && @virtual_state.nil? && !@enter_overriden
return self.class.make_result(pos, @options[pos])
else
callbacks = @virtual_state.nil? ? @callbacks : @virtual_callbacks
callbacks.each do |key, cur_callback|
next unless key == resp
callback = cur_callback
break
end
end
end
end
erase_lines line_count if callback.nil? || callback.recur
next unless callback
if @virtual_state.nil?
callback.cb.call(pos, @options[pos])
return self.class.make_result(pos, @options[pos]) unless callback.recur
else
min, max = @virtual_state.get_range
opts = @options[min..max - 1]
callback.cb.call(min, max, opts)
return self.class.make_multi_result(min, max, opts) unless callback.recur
end
new_opt_len = @options.length
pos = [new_opt_len - 1, pos].min
@virtual_state&.truncate_by_length(new_opt_len)
end
end
def exit_virtual
@virtual_state = nil
end
def self.make_result(pos, content)
SelectorResult.new(pos, pos + 1, [content])
end
def self.make_multi_result(min, max, options)
SelectorResult.new(min, max, options)
end
def self.make_callback(cb, recur)
ret = Struct.new(:cb, :recur)
ret.new(cb, recur)
end
def self.make_help(keys, msg, virtual, recur)
ret = Struct.new(:keys, :msg, :virtual, :recur)
keys = keys.map do |k|
if k == ENTER
'<Enter>'
else
k
end
end
ret.new(keys, msg, virtual, recur)
end
def format_option(pos)
@options[pos].to_s
end
def color_line(pos, option_str)
return option_str if @search_string.empty?
if is_virtual_selected(pos)
search_and_color(option_str, @search_string, BLUE_BG_RED, BLUE_BG)
else
search_and_color(option_str, @search_string, RED, NC)
end
end
private
def search_index(pos, reverse = false)
len = @options.length
(0..len).each do |i|
i = if reverse
(len + pos - i) % len
else
(i + pos) % len
end
s = format_option(i)
s = s.downcase unless @search_string =~ /[A-Z]/
return i if s.include?(@search_string)
end
nil
end
def collect_help_str
single_help_str = lambda do |plain, h|
c = lambda do |color|
if plain
''
else
color
end
end
s = " * #{c.call(GREEN)}#{h.keys.join('/')}#{c.call(NC)}: #{h.msg}"
s += " #{c.call(RED)}(ends the selector)#{c.call(NC)}" unless h.recur
if can_virtual?
case h.virtual
when :yes
s += " #{c.call(BLUE)}(virtual)#{c.call(NC)}"
when :no
s += " #{c.call(YELLOW)}(non-virtual)#{c.call(NC)}"
end
end
s
end
helps = []
helps.push(self.class.make_help([ENTER], 'select the option', :no, false)) unless @enter_overriden
helps.push(self.class.make_help(%w[v V], 'start or quit virtual mode', :both, true)) if can_virtual?
helps += [
self.class.make_help(['k', 'K', '<Arrow Up>'], 'move up', :both, true),
self.class.make_help(['j', 'J', '<Arrow Down>'], 'move down', :both, true),
self.class.make_help(%w[q Q], 'quit selector or virtual mode', :both, false),
self.class.make_help(['[0~9]'], 'go to the option at given number', :both, true),
self.class.make_help(['/'], 'search for string', :both, true),
self.class.make_help(['n/N'], 'search forwards/search backwards', :both, true)
] + @helps
helps.map do |h|
plain = single_help_str.call(true, h)
colored = single_help_str.call(false, h)
[colored, plain.length]
end
end
end
class VirtualState
def initialize(num)
@fixed = num
@moving = num
end
def set_point(num)
@moving = num
end
def get_range
if @fixed < @moving
[@fixed, @moving + 1]
else
[@moving, @fixed + 1]
end
end
def truncate_by_length(length)
@fixed = [length - 1, @fixed].min
@moving = [length - 1, @moving].min
end
def in_range?(num)
from, to = get_range
num >= from and num < to
end
end
if __FILE__ == $PROGRAM_NAME
selector = Selector.new
selector.load(ARGV)
selector.register_keys_virtual([ENTER], lambda { |_, _, _|
}, msg: 'Select multiple options')
answer = []
result = selector.run
result.options.each { |opt| puts opt }
end