Version 0.4.0: mask, eine Eingabemaske mit eigener Oberfläche

This commit is contained in:
wkh
2026-10-11 22:08:38 +02:00
parent 837a73366d
commit e7a7577900
12 changed files with 592 additions and 4 deletions
+275
View File
@@ -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
View File
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Rdb
VERSION = '0.3.0'
VERSION = '0.4.0'
end