Version 0.4.0: mask, eine Eingabemaske mit eigener Oberfläche
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'io/console'
|
||||
require 'open3'
|
||||
|
||||
# mask -- a full-screen input form. It reads the same kind of form as `screen`
|
||||
# (text with <Column> fields) but draws it itself and writes the row typed in as
|
||||
# a table on the standard output, so it works in a pipe: mask < entry | append t
|
||||
#
|
||||
# Besides <Column> the form may contain
|
||||
# <Column:20> a field 20 characters wide (otherwise as wide as the <...>)
|
||||
# [ Save ] a button; Cancel, Abort, Quit or Abbrechen cancel, any other accepts
|
||||
# <!command!> replaced by the first line of the command's output
|
||||
# Frames are plain text in the form, or drawn around everything with -b.
|
||||
module Rdb
|
||||
module Mask
|
||||
Slot = Struct.new(:row, :col, :width, :name)
|
||||
Button = Struct.new(:row, :col, :text, :label)
|
||||
CANCEL = /\A(cancel|abort|quit|abbrechen)\z/i
|
||||
TOKEN = /<!(.*?)!>|<([^<>!:\n]+?)(?::(\d+))?>|\[ *([^\[\]<>\n]*[^\[\]<>\s]) *\]/
|
||||
|
||||
class Form
|
||||
attr_reader :lines, :slots, :buttons
|
||||
|
||||
def self.parse(text, frame: false)
|
||||
lines = []
|
||||
slots = []
|
||||
buttons = []
|
||||
text.each_line(chomp: true).with_index do |line, row|
|
||||
out = +''
|
||||
pos = 0
|
||||
while (m = TOKEN.match(line, pos))
|
||||
out << line[pos...m.begin(0)]
|
||||
if m[1]
|
||||
out << Open3.capture2('sh', '-c', m[1]).first.lines.first.to_s.chomp
|
||||
elsif m[2]
|
||||
width = m[3] ? m[3].to_i : m[0].size
|
||||
slots << Slot.new(row, out.size, width, m[2].strip)
|
||||
out << ' ' * width
|
||||
else
|
||||
buttons << Button.new(row, out.size, m[0], m[4].strip)
|
||||
out << m[0]
|
||||
end
|
||||
pos = m.end(0)
|
||||
end
|
||||
lines << (out << line[pos..].to_s)
|
||||
end
|
||||
new(lines, slots, buttons).then { |f| frame ? f.framed : f }
|
||||
end
|
||||
|
||||
def initialize(lines, slots, buttons)
|
||||
@lines = lines
|
||||
@slots = slots
|
||||
@buttons = buttons
|
||||
end
|
||||
|
||||
def names
|
||||
@slots.map(&:name).uniq
|
||||
end
|
||||
|
||||
# Fields (first slot of each column) and buttons in reading order.
|
||||
def items
|
||||
fields = @slots.uniq(&:name).map { |s| [:field, s] }
|
||||
(fields + @buttons.map { |b| [:button, b] }).sort_by { |_, o| [o.row, o.col] }
|
||||
end
|
||||
|
||||
def framed
|
||||
w = @lines.map(&:size).max.to_i
|
||||
lines = ["┌#{'─' * (w + 2)}┐"] + @lines.map { |l| "│ #{l.ljust(w)} │" } + ["└#{'─' * (w + 2)}┘"]
|
||||
self.class.new(lines,
|
||||
@slots.map { |s| Slot.new(s.row + 1, s.col + 2, s.width, s.name) },
|
||||
@buttons.map { |b| Button.new(b.row + 1, b.col + 2, b.text, b.label) })
|
||||
end
|
||||
end
|
||||
|
||||
# The state of one input session: values, which item has the focus, and the keys.
|
||||
class Session
|
||||
attr_reader :values, :focus, :pos
|
||||
|
||||
def initialize(form)
|
||||
@form = form
|
||||
@items = form.items
|
||||
@values = form.names.to_h { |n| [n, +''] }
|
||||
@focus = 0
|
||||
reset_pos
|
||||
end
|
||||
|
||||
def kind = @items[@focus]&.first
|
||||
|
||||
def current = @items[@focus]&.last
|
||||
|
||||
def field? = kind == :field
|
||||
|
||||
# Feed one key; returns :accept or :cancel when the session ends.
|
||||
def feed(key)
|
||||
case key
|
||||
when "\t", "\e[B", "\e[6~" then move(1)
|
||||
when "\e[Z", "\e[A", "\e[5~" then move(-1)
|
||||
when "\r", "\n" then return enter
|
||||
when "\x03", "\e", "\x04" then return :cancel
|
||||
when "\e[C" then @pos = [@pos + 1, text.size].min if field?
|
||||
when "\e[D" then @pos = [@pos - 1, 0].max if field?
|
||||
when "\e[H", "\e[1~", "\eOH", "\x01" then @pos = 0
|
||||
when "\e[F", "\e[4~", "\eOF", "\x05" then @pos = text.size if field?
|
||||
when "\x7f", "\b" then backspace
|
||||
when "\e[3~" then delete
|
||||
when "\x15" then clear_field
|
||||
when ' '
|
||||
return press unless field?
|
||||
|
||||
insert(key)
|
||||
else insert(key) if key.size == 1 && key.ord >= 32
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
# Plain-text picture of the form (no escapes), for `mask -s` and the tests.
|
||||
def plain
|
||||
grid = @form.lines.map(&:dup)
|
||||
@form.slots.each { |s| grid[s.row][s.col, s.width] = '_' * s.width }
|
||||
grid.join("\n") + "\n"
|
||||
end
|
||||
|
||||
# Escape sequences that paint the whole screen.
|
||||
def render
|
||||
out = +"\e[?25l\e[H\e[2J"
|
||||
@form.lines.each_with_index { |l, r| out << "\e[#{r + 1};1H#{l}" }
|
||||
cursor = nil
|
||||
@form.slots.each do |s|
|
||||
focused = field? && current.name == s.name
|
||||
start = focused ? view_start(s.width) : 0
|
||||
shown = @values[s.name][start, s.width].to_s.ljust(s.width)
|
||||
out << "\e[#{s.row + 1};#{s.col + 1}H\e[#{focused ? 7 : 4}m#{shown}\e[0m"
|
||||
cursor = [s.row + 1, s.col + 1 + @pos - start] if focused && current.equal?(s)
|
||||
end
|
||||
@form.buttons.each do |b|
|
||||
focused = kind == :button && current.equal?(b)
|
||||
out << "\e[#{b.row + 1};#{b.col + 1}H\e[#{focused ? 7 : 1}m#{b.text}\e[0m"
|
||||
cursor = [b.row + 1, b.col + 1] if focused
|
||||
end
|
||||
out << "\e[#{cursor[0]};#{cursor[1]}H" if cursor
|
||||
out << "\e[?25h" if field?
|
||||
out
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def text = field? ? @values[current.name] : +''
|
||||
|
||||
def reset_pos
|
||||
@pos = field? ? text.size : 0
|
||||
end
|
||||
|
||||
def move(delta)
|
||||
@focus = (@focus + delta) % [@items.size, 1].max
|
||||
reset_pos
|
||||
end
|
||||
|
||||
def enter
|
||||
return press unless field?
|
||||
|
||||
last = @focus == @items.size - 1
|
||||
return :accept if last && @form.buttons.empty?
|
||||
|
||||
move(1)
|
||||
nil
|
||||
end
|
||||
|
||||
def press
|
||||
current.label.match?(CANCEL) ? :cancel : :accept
|
||||
end
|
||||
|
||||
def insert(key)
|
||||
return unless field?
|
||||
|
||||
text.insert(@pos, key)
|
||||
@pos += 1
|
||||
end
|
||||
|
||||
def backspace
|
||||
return unless field? && @pos.positive?
|
||||
|
||||
text.slice!(@pos - 1)
|
||||
@pos -= 1
|
||||
end
|
||||
|
||||
def delete
|
||||
text.slice!(@pos) if field?
|
||||
end
|
||||
|
||||
def clear_field
|
||||
return unless field?
|
||||
|
||||
text.clear
|
||||
@pos = 0
|
||||
end
|
||||
|
||||
def view_start(width)
|
||||
[@pos - width + 1, 0].max
|
||||
end
|
||||
end
|
||||
|
||||
def self.read_key(io)
|
||||
c = io.getch
|
||||
return c unless c == "\e" && io.wait_readable(0.05)
|
||||
|
||||
seq = +"\e#{io.getch}"
|
||||
if seq == "\e["
|
||||
loop do
|
||||
c = io.getch
|
||||
seq << c
|
||||
break if c.ord.between?(0x40, 0x7e)
|
||||
end
|
||||
elsif seq == "\eO"
|
||||
seq << io.getch
|
||||
end
|
||||
seq
|
||||
end
|
||||
|
||||
# Run the form on a terminal; returns the values, or nil when cancelled.
|
||||
def self.run(form, tty)
|
||||
session = Session.new(form)
|
||||
tty.raw do
|
||||
tty.write "\e[?1049h"
|
||||
begin
|
||||
loop do
|
||||
tty.write session.render
|
||||
key = begin
|
||||
read_key(tty)
|
||||
rescue EOFError
|
||||
"\x03"
|
||||
end
|
||||
result = session.feed(key)
|
||||
return (result == :accept ? session.values : nil) if result
|
||||
end
|
||||
ensure
|
||||
tty.write "\e[?25h\e[?1049l"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('mask', 'Full-screen input form that writes a table row', 'mask [-b] [-s] [form] < form > table') do |args|
|
||||
frame = !args.delete('-b').nil?
|
||||
show = !args.delete('-s').nil?
|
||||
die 'usage: mask [-b] [-s] [form] < form > table' if args.size > 1
|
||||
file = args.first
|
||||
file.nil? || file == '-' || File.exist?(file) or die("#{file}: no such form")
|
||||
text = file && file != '-' ? File.read(file) : $stdin.read
|
||||
form = Rdb::Mask::Form.parse(text, frame: frame)
|
||||
die "the form has no <field>" if form.names.empty?
|
||||
|
||||
session = Rdb::Mask::Session.new(form)
|
||||
if show
|
||||
print session.plain
|
||||
puts '', 'Fields:'
|
||||
form.slots.each { |s| puts " #{s.name}\trow #{s.row + 1} col #{s.col + 1} width #{s.width}" }
|
||||
form.buttons.each { |b| puts " [#{b.label}]\trow #{b.row + 1} col #{b.col + 1}#{b.label.match?(Rdb::Mask::CANCEL) ? ' (cancels)' : ''}" }
|
||||
next
|
||||
end
|
||||
|
||||
tty = begin
|
||||
File.open('/dev/tty', 'r+')
|
||||
rescue SystemCallError
|
||||
die 'no terminal available (mask draws on /dev/tty)'
|
||||
end
|
||||
values = Rdb::Mask.run(form, tty)
|
||||
tty.close
|
||||
if values.nil?
|
||||
warn 'mask: cancelled'
|
||||
exit 1
|
||||
end
|
||||
Rdb::Table.new(form.names, [form.names.map { |n| values[n].tr("\t\r\n", ' ') }]).write
|
||||
end
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rdb
|
||||
VERSION = '0.3.0'
|
||||
VERSION = '0.4.0'
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user