Version 0.5.0: pick, Zeilen auf dem Terminal wählen
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'io/console'
|
||||
|
||||
# pick -- look at a table on the terminal, scroll through it and choose one or
|
||||
# several rows. The table comes in on the standard input, the chosen rows go
|
||||
# out on the standard output as a table, so it sits in the middle of a pipe:
|
||||
# column Item Description < inventory | pick -m | total Item
|
||||
# The keys are read from /dev/tty (or, for scripts and tests, from -k).
|
||||
module Rdb
|
||||
module Pick
|
||||
KEYS = {
|
||||
'up' => "\e[A", 'down' => "\e[B", 'pgup' => "\e[5~", 'pgdn' => "\e[6~",
|
||||
'home' => "\e[H", 'end' => "\e[F", 'enter' => "\r", 'space' => ' ',
|
||||
'esc' => "\e", 'backspace' => "\x7f"
|
||||
}.freeze
|
||||
|
||||
# The state of one session: which rows are shown, where the cursor is,
|
||||
# which rows are marked. Knows nothing about the terminal.
|
||||
class Session
|
||||
attr_reader :head, :rows, :cursor, :top, :filter, :marked
|
||||
|
||||
def initialize(head, rows, multi: false)
|
||||
@head = head
|
||||
@rows = rows
|
||||
@multi = multi
|
||||
@marked = []
|
||||
@filter = +''
|
||||
@typing = false
|
||||
@cursor = 0
|
||||
@top = 0
|
||||
@height = 20
|
||||
@width = 80
|
||||
refilter
|
||||
end
|
||||
|
||||
def resize(height, width)
|
||||
@height = [height, 1].max
|
||||
@width = [width, 20].max
|
||||
scroll
|
||||
end
|
||||
|
||||
def typing? = @typing
|
||||
|
||||
# Indices (into rows) of the rows that pass the filter.
|
||||
def visible = @visible
|
||||
|
||||
def current = @visible[@cursor]
|
||||
|
||||
# Rows to hand on: the marked ones, else the row under the cursor.
|
||||
def selected
|
||||
idx = @marked.empty? ? [current].compact : @marked.sort
|
||||
idx.map { |i| @rows[i] }
|
||||
end
|
||||
|
||||
# Returns :accept or :cancel when the session is over.
|
||||
def feed(key)
|
||||
@typing ? type(key) : browse(key)
|
||||
end
|
||||
|
||||
def render
|
||||
widths = @head.each_index.map { |i| ([@head[i]] + @rows.map { |r| r[i].to_s }).map(&:size).max }
|
||||
line = ->(cells) { cells.each_with_index.map { |c, i| c.to_s.ljust(widths[i]) }.join(' ').rstrip }
|
||||
out = +"\e[?25l\e[H"
|
||||
out << "\e[K \e[1m#{fit(line.(@head))}\e[0m\r\n"
|
||||
out << "\e[K #{fit(widths.map { |w| '-' * w }.join(' '))}\r\n"
|
||||
@height.times do |n|
|
||||
pos = @top + n
|
||||
i = @visible[pos]
|
||||
out << "\e[K"
|
||||
if i
|
||||
text = "#{@marked.include?(i) ? '*' : ' '} #{fit(line.(@rows[i]))}"
|
||||
out << (pos == @cursor ? "\e[7m#{text}\e[0m" : text)
|
||||
end
|
||||
out << "\r\n"
|
||||
end
|
||||
out << "\e[K#{status}"
|
||||
out
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fit(text) = text[0, @width - 2]
|
||||
|
||||
def status
|
||||
keys = @typing ? 'type to filter Enter keep Esc clear' : "Enter ok#{' Space mark a all' if @multi} / filter Esc cancel"
|
||||
filter = @filter.empty? && !@typing ? '' : " /#{@filter}#{'_' if @typing}"
|
||||
"\e[7m #{@visible.empty? ? 0 : @cursor + 1}/#{@visible.size} of #{@rows.size}" \
|
||||
"#{" marked #{@marked.size}" if @multi}#{filter} #{keys} \e[0m"
|
||||
end
|
||||
|
||||
def browse(key)
|
||||
case key
|
||||
when "\e[A" then move(-1)
|
||||
when "\e[B" then move(1)
|
||||
when "\e[5~" then move(-@height)
|
||||
when "\e[6~" then move(@height)
|
||||
when "\e[H", "\e[1~", "\eOH" then move(-@visible.size)
|
||||
when "\e[F", "\e[4~", "\eOF" then move(@visible.size)
|
||||
when "\r", "\n" then return selected.empty? ? :cancel : :accept
|
||||
when ' ' then toggle
|
||||
when 'a' then mark_all if @multi
|
||||
when '/' then @typing = true
|
||||
when "\x03", "\e", 'q' then return :cancel
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def type(key)
|
||||
case key
|
||||
when "\r", "\n" then @typing = false
|
||||
when "\e" then @filter.clear; @typing = false; refilter
|
||||
when "\x03" then return :cancel
|
||||
when "\x7f", "\b" then @filter.chop!; refilter
|
||||
else
|
||||
if key.size == 1 && key.ord >= 32
|
||||
@filter << key
|
||||
refilter
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def refilter
|
||||
needle = @filter.downcase
|
||||
@visible = @rows.each_index.select { |i| needle.empty? || @rows[i].join("\t").downcase.include?(needle) }
|
||||
@cursor = [@cursor, [@visible.size - 1, 0].max].min
|
||||
scroll
|
||||
end
|
||||
|
||||
def move(delta)
|
||||
@cursor = (@cursor + delta).clamp(0, [@visible.size - 1, 0].max)
|
||||
scroll
|
||||
end
|
||||
|
||||
def scroll
|
||||
@top = @cursor if @cursor < @top
|
||||
@top = @cursor - @height + 1 if @cursor >= @top + @height
|
||||
@top = 0 if @top.negative?
|
||||
end
|
||||
|
||||
def toggle
|
||||
return unless @multi && current
|
||||
|
||||
@marked.include?(current) ? @marked.delete(current) : @marked << current
|
||||
move(1)
|
||||
end
|
||||
|
||||
def mark_all
|
||||
all = @visible.all? { |i| @marked.include?(i) }
|
||||
all ? @marked -= @visible : @marked |= @visible
|
||||
end
|
||||
end
|
||||
|
||||
def self.run(session, tty)
|
||||
tty.raw do
|
||||
tty.write "\e[?1049h"
|
||||
begin
|
||||
loop do
|
||||
rows, cols = tty.winsize
|
||||
session.resize((rows.zero? ? 24 : rows) - 3, cols.zero? ? 80 : cols)
|
||||
tty.write session.render
|
||||
key = begin
|
||||
Rdb::Mask.read_key(tty)
|
||||
rescue EOFError
|
||||
"\x03"
|
||||
end
|
||||
result = session.feed(key)
|
||||
return result if result
|
||||
end
|
||||
ensure
|
||||
tty.write "\e[?25h\e[?1049l"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Words of a -k script: a name from KEYS, or else every character is one key.
|
||||
def self.script(words)
|
||||
words.split.flat_map { |w| KEYS.key?(w) ? [KEYS[w]] : w.chars }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('pick', 'Scroll through a table and choose one or more rows', 'pick [-m] [-k keys] < table > chosenrows') do |args|
|
||||
multi = !args.delete('-m').nil?
|
||||
keys = nil
|
||||
if (i = args.index('-k'))
|
||||
keys = args[i + 1] or die 'usage: pick [-m] [-k keys] < table'
|
||||
args.slice!(i, 2)
|
||||
end
|
||||
die 'usage: pick [-m] [-k "down space enter"] < table > chosenrows' unless args.empty?
|
||||
|
||||
t = table_in
|
||||
die 'pick: the table is empty' if t.rows.empty?
|
||||
session = Rdb::Pick::Session.new(t.head, t.rows, multi: multi)
|
||||
|
||||
result = nil
|
||||
if keys
|
||||
session.resize(21, 80)
|
||||
Rdb::Pick.script(keys).each do |k|
|
||||
result = session.feed(k)
|
||||
break if result
|
||||
end
|
||||
else
|
||||
tty = begin
|
||||
File.open('/dev/tty', 'r+')
|
||||
rescue SystemCallError
|
||||
die 'no terminal available (pick takes its keys from /dev/tty; see -k)'
|
||||
end
|
||||
result = Rdb::Pick.run(session, tty)
|
||||
tty.close
|
||||
end
|
||||
unless result == :accept
|
||||
warn 'pick: nothing chosen'
|
||||
exit 1
|
||||
end
|
||||
Rdb::Table.new(t.head, session.selected).write
|
||||
end
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rdb
|
||||
VERSION = '0.4.0'
|
||||
VERSION = '0.5.0'
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user