Version 0.5.0: pick, Zeilen auf dem Terminal wählen

This commit is contained in:
wkh
2026-10-11 22:25:45 +02:00
parent e7a7577900
commit d2c4343136
9 changed files with 468 additions and 4 deletions
+218
View File
@@ -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
View File
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Rdb
VERSION = '0.4.0'
VERSION = '0.5.0'
end