3 Commits
15 changed files with 1914 additions and 77 deletions
+21
View File
@@ -7,6 +7,27 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in
## [Unveröffentlicht]
## [0.4.0] - 2026-10-11
### Hinzugefügt
- `mask`: Eingabemaske mit eigener Oberfläche (Felder mit Breite, Schaltflächen, Rahmen `-b`, `-s` zeigt das Bild ohne Terminal). Schreibt die Eingabe als Tabelle auf die Standardausgabe. Die Vorlage kommt wie bei `screen` von der Standardeingabe (`mask < entry`). Dazu `man/mask.man`, `demo/maskform` und Tests (auch mit Pseudo-Terminal).
- `demo/entry`: beschriftete Eingabemaske für `screen` (README erklärt den Unterschied zu `form`).
## [0.3.0] - 2026-10-11
### Hinzugefügt
- `demo/testall.cmds` enthält zu jedem der 134 Befehle ein ausführbares Beispiel,
gegliedert durch Überschriften (`##`-Zeilen werden mit ausgegeben, `#`-Zeilen
sind stille Kommentare). Ein Test prüft, dass kein Befehl fehlt.
### Geändert
- `testall` läuft in einer temporären Kopie von `demo/`; schreibende Beispiele
(`append`, `delete`, `close` ...) verändern die Demodaten nicht mehr. Die
Befehle lesen von einer leeren Standardeingabe.
- `sale` nimmt das Datum aus `NEXTDATE` (wie `getjournal`, `close`), sonst von heute.
- `lock` führt die Sperrdatei pro Pfad statt pro Dateiname, so stören sich
gleichnamige Tabellen in verschiedenen Ordnern nicht.
## [0.2.0] - 2026-10-11
### Hinzugefügt
+42 -2
View File
@@ -62,6 +62,46 @@ whatwill total Befehle, die zu einem Stichwort passen
commands Tabelle aller Befehle
```
## Beispiele zu allen Befehlen
`demo/testall.cmds` enthält zu jedem Befehl ein Beispiel mit kurzer Erklärung.
`testall` führt es in einer Kopie von `demo/` aus und zeigt jede Zeile samt Ausgabe:
```
testall | less alle Beispiele ansehen
testall > testall.old Lauf als Vergleich festhalten
testall | diff testall.old - später prüfen, was sich geändert hat (mise run testall)
```
Eine Zeile mit `##` erscheint als Überschrift in der Ausgabe, eine mit `#` ist
ein stiller Kommentar. Neue Befehle brauchen hier ein Beispiel, sonst schlägt der Test
`test_testall_mentions_every_command` fehl.
## Vorlagen: `form` und `entry`
- `demo/form` ist eine Vorlage für `report` und `letter`: sie setzt Spaltenwerte in einen Text ein.
Sie hat keine Beschriftungen und nennt `Name` zweimal.
- `demo/entry` ist eine Eingabemaske für `screen`: jedes Feld kommt einmal vor und hat eine
Beschriftung. `screen < entry > maske.sh` erzeugt das Skript. Starten:
`CLEAR=$(clear.rdb) sh maske.sh > tabelle 2>&1`. Die Tabelle geht auf stderr (wie im Original),
daher das `2>&1`. Mit `form` würde die Maske leer und doppelt aussehen.
## Eingabemasken: `mask`
`mask` ist die Variante von `screen` mit eigener Oberfläche (nur Ruby, kein ncurses). Es zeichnet die
Vorlage (von der Standardeingabe, wie `screen`; ein Dateiname geht auch) selbst und schreibt die Eingabe als Tabelle auf die Standardausgabe, also pipefähig:
```
mask -b < demo/maskform Rahmen (-b), Felder, Schaltflächen
mask < demo/maskform > neu Tabelle in eine Datei
mask < demo/maskform | append -h kunden oder direkt in eine Tabelle
mask -s < demo/maskform nur das Bild und die Feldpositionen, ohne Terminal
```
In der Vorlage: `<Spalte>` oder `<Spalte:20>` ist ein Feld (mit Breite), `[ Speichern ]` eine Schaltfläche,
`[ Abbrechen ]` (oder Cancel, Abort, Quit) bricht ab, `<!date!>` fügt Befehlsausgabe ein. Kommt eine Spalte mehrfach
vor, ist es ein Feld. Tab/Pfeile wechseln, Enter bestätigt, Esc bricht ab (Status 1, keine Ausgabe).
## Befehle
| Gruppe | Befehle |
@@ -76,7 +116,7 @@ commands Tabelle aller Befehle
| Text | `cap` `uppercase` `lowercase` `substitute` `trimblank` `padstring` `length` `filesize` `ascii` `chr` `word` `prompt` `not` `path` |
| Umwandeln | `tabletolist` `listtotable` `listtosh` `tabletofix` `fixtotable` `tabletostruct` `tabletotbl` `tabletofact` `tabletorule` `tabletom4` `tabletosed` `translate` `uniondict` |
| Datum, Namen | `julian` `gregorian` `computedate` `todaysdate` `soundex` `like` `hashkey` |
| Berichte | `report` `reportwriter` `screen` `label` `letter` |
| Berichte | `report` `reportwriter` `screen` `mask` `label` `letter` |
| Terminal, Hilfe | `termput` `tset` `clear.rdb` `cursor` `menu` `rdb` `commands` `helpme` `whatis` `whatwill` `howmany` `man` |
| Betrieb | `testall` `testsearch` `timesearch` `rmcore` `cpdir.rdb` |
| Buchhaltung (/act) | `act` `chartdup` `foot` `adjust` `balance` `close` `consolidate` `getjournal` `fillform` `calculate` `tax` `cashflow` `calcpay` `w2` `bom` `explode` `sale` `invoice` `cstate` `update.inv` |
@@ -138,7 +178,7 @@ lib/rdb/table.rb Tabellen und Listen lesen und schreiben
lib/rdb/fast.rb Schnellzugriff über Byte-Offsets
lib/rdb/cmd/*.rb die Befehle, nach Gruppen
man/ Man-Pages (troff-Quellen aus den MWC-Archiven)
demo/ Beispieltabellen, testall.cmds und testall.old
demo/ Beispieltabellen; testall.cmds = je ein Beispiel pro Befehl, testall.old = letzter Lauf
test/ Tests
```
Symlink
+1
View File
@@ -0,0 +1 @@
/home/wkh/Development/Coherent/rdb/bin/rdb
+9
View File
@@ -0,0 +1,9 @@
Mail List Entry Form
Name : <Name> Company: <Company>
Street: <Street>
City : <City> State : <State> ZIP: <ZIP>
Phone : <Phone>
[Speichern] [Abbrechen]
+24
View File
@@ -0,0 +1,24 @@
: paint crt screen
exec 1>/dev/tty
cat <<SCREEN
${CLEAR} Mail List Entry Form
Name : Company:
Street:
City : State : ZIP:
Phone :
SCREEN
: read user input
cursor 3 9 ; read Name;
cursor 3 37 ; read Company;
cursor 4 9 ; read Street;
cursor 5 9 ; read City;
cursor 5 37 ; read State;
cursor 5 56 ; read ZIP;
cursor 6 9 ; read Phone;
: output table head
exec 1>&2
echo "Name Company Street City State ZIP Phone"
echo "---- ------- ------ ---- ----- --- -----"
: append row
echo "$Name $Company $Street $City $State $ZIP $Phone"
+9
View File
@@ -0,0 +1,9 @@
Mail List Entry
Name : <Name:24>
Company: <Company:24>
Street : <Street:24>
City : <City:16> State: <State:10> ZIP: <ZIP:9>
Phone : <Phone:16>
[ Save ] [ Cancel ]
+261 -8
View File
@@ -1,14 +1,267 @@
# One command per line; testall runs them in demo/ and prints the output.
# Save a known-good run: testall > testall.old ; later: testall | diff testall.old -
# testall.cmds -- one shell command per line; `testall` runs them in a scratch copy of demo/.
# ## line is printed as a heading/explanation # line is a silent comment
# Save a known-good run: testall > testall.old later: testall | diff testall.old -
# Tables used here: inventory journal expense carexpense parent female male owners
# chart ledger parttable cash X83 colors orders ed.t maillist form name
##
## ===== Selecting and arranging rows and columns =====
## row (select): keep the rows that match a condition. Column names work like awk variables.
row 'Cost > 5' < inventory
column Cost Amount Description < inventory
rdb select 'Description ~ /gloves/' < inventory
## column (project): pick columns in any order; a name that does not exist becomes a new empty column
column Description Cost < inventory
project Item Notes < owners
## sorttable: -n numeric, -r reverse, -f fold case
sorttable -nr Cost < inventory
## compute: calculate columns from other columns (the new column must exist, so make it with addcol)
addcol Total < inventory | compute 'Total = Amount * Cost' | column Item Total
compute 'Value = sprintf("%5.2f", Cost * Amount)' < inventory
total Amount Value < inventory
datatype -l < inventory
subtotal Date Debit Credit < journal
jointable parent female
## addcol / rename
addcol Supplier < owners
rename Owner Person < owners
## number / Number: number the rows (number: rows only, Number: rows and the head)
number < owners
Number < owners
## record: print one record (the 3rd row)
record 3 < inventory
## rmblank: drop empty rows; headon / headoff: add or strip the head
column Item Cost < inventory | rmblank
headoff < colors
headoff < colors | headon Hue
printf 'a\tb\n1\t2\n' | insertdash
## splittable: cut a table horizontally into pieces of n rows (writes colorsaa, colorsab ...)
splittable -3 colors ; ls colors* ; cat colorsab
## ===== Combining tables =====
## jointable: join on the first column; -a keeps unmatched rows, -j names the key columns
jointable inventory owners
jointable -a inventory owners
jointable -j1 Parent -j2 Name parent female
## union / intersect / difference
union expense carexpense
intersect female male
difference female male
## paste.rdb: put tables side by side
paste.rdb female male
## ===== Statistics: one result row per column =====
total Amount Value < inventory
mean Cost < inventory
maximum Cost < inventory
minimum Cost < inventory
width < inventory
precision Cost < inventory
datatype -l < inventory
widest < inventory
subtotal Date Debit Credit < journal
dbdict < inventory
schema inventory owners
tableorlist inventory maillist
## ===== Checking and displaying =====
check.rdb < inventory
printf 'a\tb\n-\t-\n1\t2\t3\n4\n' | check.rdb
display < owners
see < owners
validate 'Cost > 40 { print "expensive:", Description }' < badinventory
column Colors < orders | domain colors
domain colors black x
## fd: does Description depend on Item? (prints true/false)
fd Item Description < inventory
fd Description Cost < inventory
## ===== Text tools (arguments or standard input) =====
justify < inventory
tabletolist < inventory
justify -r Description < inventory
justify -c Cost < inventory
trim < inventory
compresss < inventory
pad -4 < owners
printf 'hello world\n' | cap
uppercase hello world
lowercase HELLO WORLD
printf 'one two\n' | uppercase
substitute gloves mittens colors ; cat colors
trimblank ' padded '
padstring 8 abc
padstring -8 abc
length 'how long?'
filesize inventory
ascii aA1
ascii ' '
chr 72 105
word < ed.t
cap < ed.t
blank < maillist
printf 'a\n' | prompt 'prompt text'
not false && echo 'false failed, so not succeeded'
path ls > /dev/null && echo 'ls found on the path'
## tabletom4 / tabletosed make translation files from ed.t; translate applies them
tabletom4 < ed.t
tabletosed < ed.t
tabletom4 < ed.t > ed ; echo 'I love you.' | translate ed
## uniondict builds the ed define-file out of ed.low and ed.allcap
printf 'English\tDeutsch\n-------\t-------\nlove\tliebe\n' > ed.low ; printf 'English\tDeutsch\n-------\t-------\nLOVE\tLIEBE\n' > ed.allcap ; uniondict ed
## ===== Conversions =====
tabletolist < owners
tabletolist -l < owners
tabletolist < owners | listtotable
listtotable < maillist
listtosh < maillist
tabletofix Item=4 Owner=6r < owners
tabletofix Item=4 Owner=6r < owners | fixtotable Item=1,4 Owner=5,6
tabletostruct tag owners < owners
tabletotbl < owners
tabletofact parent
tabletorule ruletable
## ===== Dates =====
## julian: dates to day numbers (arithmetic); gregorian: back again (-e European, -u US, -c day count)
julian Date < journal
julian Date < journal | gregorian Date
julian Date < journal | gregorian -e Date
## computedate: a date plus n days
computedate 890331 45
computedate 891231 1
test $(todaysdate | wc -c) -eq 7 && echo 'todaysdate prints YYMMDD'
## ===== Names that sound alike =====
soundex < name
## like: names that sound like Mann (needs the soundex table name.x, sorted by Soundex)
soundex < name > name.x ; like Mann name
hashkey 11 ABCD
hashkey 29 abcdefg
## ===== Fast access: index, search, seek =====
## search without an index scans the table (-ms); keys come from stdin
echo scales | search inventory Description
## -h leaves out the head line of the answer
printf 'Description\n-----------\nscales\n' | search -h inventory Description
## build indexes in each method: b binary, r record, i inverted, h hash
index -mr inventory ; ls inventory.r ; echo 3 | search -mr inventory
index -mi inventory Description ; ls inventory.i ; echo cl | search -mi -x inventory Description
index -mh inventory Item ; ls inventory.h ; echo 5 | search -mh inventory Item
## seek: where does the row start and end? (byte offsets)
echo 5 | seek inventory Item
echo 5 | seek -mh inventory Item
## searchtree: is Sally an ancestor of Jane? (needs a table of Parent, Child)
searchtree parent Sally Jane Parent Child
## vindex: look-up tables for ve
vindex owners Item : Owner ; vindex owners
tableorlist owners maillist
## ===== Changing tables: append, replace, delete, enter =====
## append adds a row and updates the indexes
## -h: the new row has no head line
printf '8\t1\t1.00\t1.00\tnew thing\n' | append -h -mh inventory Item ; echo 8 | search -mh inventory Item
## replace overwrites bytes from..to; take the offsets from seek
set -- $(echo 3 | seek inventory Item) ; printf '3\t6\t8.00\t48.00\tclamps\n' | replace inventory $1 $2 ; echo 3 | search inventory Item
## delete blanks the row and fixes the index
echo 4 | delete -mh inventory Item ; echo 4 | search -mh inventory Item ; echo 5 | search -mh inventory Item
## enter reads rows field by field from the terminal; -1 means one row
printf '9\n2\n3\n4\nnewer thing\n' | enter -1 inventory > /dev/null
tail -1 inventory
## lock / unlock: the second process may not lock the same bytes
lock inventory 111 10 20 0 0 ; echo "first lock status $?"
lock inventory 222 15 25 0 0 ; echo "second lock status $?"
unlock inventory 111 10 20 0 0 ; echo "unlock status $?"
lock inventory 222 15 25 0 0 ; echo "second lock now status $?" ; unlock inventory 222 15 25 0 0
## vilock edits a table under a lock; update.rdb edits records interactively
echo | vilock inventory ; echo "vilock status $?"
## update.rdb with a key on stdin and no changes (an empty line ends the dialog)
printf '3\n\n' | update.rdb inventory Item | head -8
## ve is a line editor in this version
printf 'q\n' | ve inventory | head -3
## ===== Reports and forms =====
## report fills a form with the fields of each list record (<Name>)
cat form
report form < maillist
## label prints mailing labels, letter prints form letters
label < maillist
letter form < maillist | head -5
## screen turns a form into a shell script that asks for the fields. Use a form with labels
## and every column once (entry); the report template `form` would give a blank, doubled screen.
## Run the script with: CLEAR=$(clear.rdb) sh script > table 2>&1 (the table goes to stderr)
cat entry
screen < entry
## mask draws the form itself (fields, buttons, frame) and writes the row as a table on stdout.
## It needs a terminal, so here only the picture of the form and its fields (-s) is shown.
## Try it: mask -b < maskform mask < maskform | append -h t
cat maskform
mask -s -b < maskform
## reportwriter: a sample report with page headings (the date is cut out)
reportwriter -3 Prices < inventory | grep -v 'Report Date'
## tabletom4 / tabletosed / tabletostruct were shown above
## ===== Help and the terminal =====
rdb --version
howmany
whatis row
whatwill total
helpme
commands | head -5
man -g column | head -12
termput cl | cat -v
termput cm | cat -v
tset vt100 | head -3
cursor 3 8 | cat -v
clear.rdb | cat -v
rmcore ; echo "rmcore status $?"
mkdir t1 ; cp owners t1 ; cpdir.rdb t1 t2 ; ls t2
menu < /dev/null | head -3
timesearch > /dev/null ; echo "timesearch status $?"
testsearch | tail -3
act
## ===== /act: a small accounting system =====
## (the commands work on tables in the current directory; NEXTDATE fixes the date they use)
act
## cashflow: compute the Balance column of a cash table
cashflow < cash
## tax: look up the tax for an income in a tax table
tax 20000 < X83
## explode: parts list -> all subparts with counts (part, amount)
explode 10001 2 parttable
## chartdup: look for duplicate account numbers and short names in chart
printf 'Account\tShort\n-------\t-----\n1\ta\n1\tb\n2\ta\n' > chart ; chartdup
## foot: subtotals of Debit and Credit per account in the ledger
foot
## adjust, balance, close: need a trial balance and an adjusting journal
printf 'Account\tDebit\tCredit\tFormat\tTaxline\tShort\tName\n-------\t-----\t------\t------\t-------\t-----\t----\n1010\t11178\t\tB-A\t4L01\tcash\tCash\n3010\t\t13500\tI-I\t101a\tsales\tSales\n' > trialbalance
printf 'Account\tDebit\tCredit\tFormat\tTaxline\tName\n-------\t-----\t------\t------\t-------\t----\n1010\t100\t\tB-A\t4L01\tCash\n' > journaladjust
adjust ; cat adjustedtrial
balance ; sed 2d balancesheet
NEXTDATE=870101 close ; cat journalnext
## fillform: sum the adjusted trial balance per tax line into a tax form
printf 'Taxline\tDescription\n-------\t-----------\n4L01\tCash on hand\n101a\tGross sales\n' > taxform.f1
fillform f1 ; cat f1
## calculate: compute columns of a form; f1.calc has one 'Column = expression' per line
echo 'Amount = Amount * 2' > f1.calc ; calculate f1 ; cat f1
## getjournal / consolidate: collect subsidiary journals into the general journal
printf 'Date\tAmount\tAccount\tRef\tDescription\n----\t------\t-------\t---\t-----------\n870101\t100\t4010\ts1\tshop sale\n' > journalsales
NEXTDATE=870131 getjournal journalsales
printf 'Date\tAmount\tAccount\tRef\tDescription\n----\t------\t-------\t---\t-----------\n870102\t40\t5010\tp1\tpaper\n' > journalexpense
rm journal ; consolidate 870131 ; cat journal
## payroll: calcpay sorts journalpay into ledgerpay, w2 totals per employee
printf 'Number\tGross\tFederal\tState\tNet\n------\t-----\t-------\t-----\t---\n2\t1000\t100\t50\t850\n1\t900\t90\t40\t770\n2\t1000\t100\t50\t850\n' > journalpay
calcpay ; cat ledgerpay
w2
## ===== /act: sales and stock =====
printf 'Code\tName\tPrice\n----\t----\t-----\n10010\twidget\t2.50\n10020\tgadget\t10\n' > product
printf 'Number\tCompany\tLast\n------\t-------\t----\n7\tAcme\tRoe\n' > customer
## sale: enter a sale order (customer, then code and quantity per item, then y to confirm)
printf '7\n10010\n4\n10020\n1\n\ny\n\n' | NEXTDATE=870105 sale
cat salesorder
## invoice / cstate: print an invoice or customer statement for what was sold
printf '1\n\n' | invoice
printf '7\n\n' | cstate
## bom: parts needed for everything sold (needs the table part with Code, Subpart, Count)
printf 'Code\tSubpart\tCount\n----\t-------\t-----\n10010\ts1\t3\n10020\ts1\t1\n10020\ts2\t2\n' > part
bom ; cat bom
## update.inv: change the stock of one item, with record locking (INV names the table)
cp inventory inv ; printf '7\n9\n\n' | INV=inv update.inv | tail -n +2
grep '^7' inv
+1032 -59
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -165,10 +165,12 @@ Rdb.register('enter', 'Add rows to a table or list file without an editor', 'ent
end
end
require 'digest'
# ---- locks -------------------------------------------------------------------
def lock_file(name)
File.join(Dir.tmpdir, "L#{File.basename(name)}")
File.join(Dir.tmpdir, "L#{File.basename(name)}.#{Digest::MD5.hexdigest(File.expand_path(name))[0, 8]}")
end
Rdb.register('lock', 'Lock a record or field of a file', 'lock filename processid from to indexfrom indexto') do |args|
+1 -1
View File
@@ -365,7 +365,7 @@ Rdb.register('sale', 'Enter sale order, and item, update customer', 'sale') do |
next if items.empty?
tax = gross * 0.05
order = [n.to_s, cust, Date.today.strftime('%y%m%d'), '0', format('%.2f', gross), format('%.2f', tax), format('%.2f', gross + tax)]
order = [n.to_s, cust, next_date, '0', format('%.2f', gross), format('%.2f', tax), format('%.2f', gross + tax)]
puts Rdb::Table.new(%w[Number Cust Date Shipped Gross Tax Total], [order]).to_s
if ask('Is this correct? (y, n) ').downcase.start_with?('y')
File.write('salesorder', Rdb::Table.open('salesorder').tap { |t| t.rows << order }.to_s)
+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
+15 -3
View File
@@ -227,12 +227,24 @@ end
Rdb.register('testall', 'Test all /rdb programs in directory $RDB/demo', 'testall [ > testall.new ]') do |_args|
ENV['PATH'] = "#{File.join(Rdb::ROOT, 'bin')}:#{ENV['PATH']}"
demo = File.join(ENV['RDB'] || Rdb::ROOT, 'demo')
Dir.chdir(demo)
script = File.join(demo, 'testall.cmds')
File.exist?(script) or die "#{script}: missing"
File.readlines(script, chomp: true).reject { |l| l.strip.empty? || l.start_with?('#') }.each do |cmd|
# Run in a scratch copy: some commands (append, delete, close ...) change files.
Dir.mktmpdir('rdb-testall') do |dir|
FileUtils.cp_r(Dir[File.join(demo, '*')], dir)
Dir.chdir(dir)
File.readlines(script, chomp: true).each do |cmd|
next if cmd.strip.empty?
if cmd.start_with?('##') # '##' lines are shown, a single '#' is a silent comment
puts cmd
next
end
next if cmd.start_with?('#')
puts "$ #{cmd}"
$stdout.flush
puts `#{cmd} 2>&1`
puts Open3.capture2e('sh', '-c', cmd, stdin_data: '').first
end
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Rdb
VERSION = '0.2.0'
VERSION = '0.4.0'
end
+116
View File
@@ -0,0 +1,116 @@
.TH mask "" "" "/rdb Command"
.PC "Full-screen input form that writes a table row"
\fBmask \fB[ -b ] [ -s ] [ \fIform\fB ] < \fIform \fB> \fItable\fR
.PP
.II "mask^input form"
.II "form^full-screen input"
.I "(Ruby re-creation only; the original /rdb has no such command.)"
.PP
The command
.B mask
draws
.I form
on the terminal, lets the user fill in its fields, and writes the result as
a one-row table on the standard output.
Unlike
.BR screen ,
it does not write a shell script: it runs the form itself.
The screen is drawn on
.BR /dev/tty ,
so the table can be redirected or piped:
.DM
mask < maskform > newrow
mask < maskform | append -h customer
.DE
.PP
.I form
is ordinary text.
Everything is shown as it stands, except:
.IP "\fB<Column>\fR" 1.2i
An input field for column
.IR Column .
It is as wide as the text between the angle brackets, including the brackets.
Blanks around the name are ignored, so \fB< City >\fR is column \fBCity\fR.
.IP "\fB<Column:20>\fR"
The same, but 20 characters wide.
Longer input scrolls inside the field.
.IP "\fB[ Save ]\fR"
A button.
A button whose label is
.BR Cancel ,
.BR Abort ,
.B Quit
or
.B Abbrechen
cancels; any other button accepts.
.IP "\fB<!command!>\fR"
Replaced by the first line the shell
.I command
writes.
.PP
If a column occurs more than once in the form, it is one field,
and every place shows what was typed.
The table has one column per field, in the order of first appearance.
Frames are made of characters in the form, for example \fB+---+\fR or
box-drawing characters.
.SH Keys
.nf
Tab, Down, Enter next field or button
Shift-Tab, Up previous field or button
Left, Right, Home, End move inside a field
Backspace, Delete erase; Ctrl-U clears the field
Enter, Space on a button: press it
Enter on the last field of a form without buttons: accept
Esc, Ctrl-C cancel
.fi
.PP
When the user cancels,
.B mask
writes nothing and exits with status 1.
.SH Options
.IP \fB-b\fR 0.5i
Draw a frame around the whole form.
.IP \fB-s\fR
Show the form as a plain picture (fields as underscores) with the position of
each field and button, and exit.
This needs no terminal and is useful to check a form.
.PP
Like
.BR screen ,
.B mask
reads the form from the standard input.
A file name argument is also accepted, and
.B -
means the standard input.
.SH Example
Form
.BR maskform :
.DM
Mail List Entry
Name : <Name:24>
City : <City:16> State: <State:10>
[ Save ] [ Cancel ]
.DE
.PP
.DM
mask -b < maskform > newrow
.DE
.PP
After typing ``Ann'' and ``Rome'', pressing Tab to the buttons and Enter on
.BR Save ,
.B newrow
contains:
.DM
Name City State
---- ---- -----
Ann Rome
.DE
.SH "See Also"
.B
cursor,
report,
screen
.R
.SO rdb
+102
View File
@@ -1,6 +1,8 @@
# frozen_string_literal: true
require_relative 'test_helper'
require 'pty'
require_relative '../lib/rdb'
class SelectionTest < RdbCase
def test_row_numeric_condition
@@ -445,6 +447,13 @@ class HelpTest < RdbCase
assert_equal "\e[3;8H", rdb('cursor', '3', '8')
end
def test_testall_mentions_every_command
script = File.read(File.join(DEMO, 'testall.cmds'))
names = data(rdb('commands')).map(&:first)
missing = names.reject { |n| script.match?(/(^|[\s|;(])#{Regexp.escape(n)}(\s|$)/) }
assert_empty missing, "testall.cmds lacks an example for: #{missing.join(' ')}"
end
def test_testall_runs_demo_script
out = rdb('testall')
assert_includes out, '$ row'
@@ -516,3 +525,96 @@ class ActTest < RdbCase
assert_equal [%w[b]], data(out)
end
end
class MaskTest < RdbCase
def form(text, frame: false) = Rdb::Mask::Form.parse(text, frame: frame)
def type(session, *keys)
keys.each { |k| k.start_with?("\e") || k.size == 1 ? session.feed(k) : k.each_char { |c| session.feed(c) } }
end
def test_parse_fields_buttons_and_width
f = form("Name: <Name:10> < City >\n[ Save ] [Cancel]\n")
assert_equal %w[Name City], f.names
assert_equal [0, 6, 10], f.slots[0].to_a.first(3)
assert_equal 9, f.slots[1].width # the width of "< City >"
assert_equal %w[Save Cancel], f.buttons.map(&:label)
assert_equal ["Name: #{' ' * 10} #{' ' * 9}", '[ Save ] [Cancel]'], f.lines
end
def test_command_in_form_and_frame
f = form("<!echo hi!> <A>\n", frame: true)
assert_equal '│ hi │', f.lines[1]
assert_equal [1, 5], f.slots.first.to_a.first(2)
end
def test_typing_and_navigation
s = Rdb::Mask::Session.new(form("<A:5> <B:5> [ OK ] [ Cancel ]\n"))
type(s, 'one', "\t", 'two', "\x7f", "\r")
assert_equal({ 'A' => 'one', 'B' => 'tw' }, s.values)
assert_equal :button, s.kind
assert_equal :accept, s.feed("\r")
end
def test_cancel_by_button_and_escape
s = Rdb::Mask::Session.new(form("<A:5> [ Save ] [ Abbrechen ]\n"))
s.feed("\e[Z")
assert_equal :cancel, s.feed(' ')
assert_equal :cancel, Rdb::Mask::Session.new(form("<A>\n")).feed("\e")
end
def test_enter_on_last_field_accepts_without_buttons
s = Rdb::Mask::Session.new(form("<A:3> <B:3>\n"))
assert_nil s.feed("\r")
assert_equal :accept, s.feed("\r")
end
def test_same_column_twice_is_one_field
s = Rdb::Mask::Session.new(form("<A:3> <A:3>\n"))
type(s, 'xy')
assert_equal({ 'A' => 'xy' }, s.values)
assert_equal %w[A], form("<A:3> <A:3>\n").names
end
def test_show_prints_picture_without_terminal
write('f', "Name: <Name:6>\n[ Save ] [ Cancel ]\n")
out = rdb('mask', '-s', file: 'f')
assert_includes out, "Name: ______\n[ Save ] [ Cancel ]"
assert_includes out, " Name\trow 1 col 7 width 6"
assert_includes out, " [Cancel]\trow 2 col 10 (cancels)"
assert_equal out, rdb('mask', '-s', 'f') # a file name works too
end
# Drive mask on a pseudo terminal; its table goes to a file, like in a pipe.
def run_on_pty(*keys)
out = File.join(@dir, 'mask.out')
result = nil
PTY.spawn({ 'PATH' => "#{BIN}:#{ENV['PATH']}" }, 'sh', '-c', "cd #{@dir} && mask < f > mask.out") do |r, w, pid|
sleep 0.5
keys.each do |k|
w.write k
sleep 0.1
r.read_nonblock(65_536) rescue nil
end
sleep 0.3
r.read_nonblock(65_536) rescue nil
_, st = Process.wait2(pid)
result = [File.exist?(out) ? File.read(out) : '', st.exitstatus]
end
result
end
def test_mask_on_a_terminal_writes_a_table
write('f', "Name: <Name:10>\nCity: <City:10>\n[ Save ] [ Cancel ]\n")
table, status = run_on_pty("Ann\r", "Rome\r", "\r")
assert_equal 0, status
assert_equal "Name\tCity\n----\t----\nAnn\tRome\n", table
end
def test_mask_cancel_writes_nothing
write('f', "Name: <Name:10>\n[ Save ] [ Cancel ]\n")
table, status = run_on_pty("Ann", "\e[Z", "\r")
assert_equal 1, status
assert_equal '', table
end
end