Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2c4343136 | ||
|
|
e7a7577900 | ||
|
|
837a73366d | ||
|
|
32c1f2be28 |
@@ -7,6 +7,32 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-10-11
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- `pick`: Tabelle auf dem Terminal durchblättern, filtern und eine oder mehrere Zeilen (`-m`) auswählen; die Auswahl geht als Tabelle auf die Standardausgabe. `-k` nimmt die Tasten aus einem String. Dazu `man/pick.man`, Beispiele in `testall.cmds` und Tests (auch mit Pseudo-Terminal).
|
||||||
|
|
||||||
|
## [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
|
## [0.2.0] - 2026-10-11
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -62,6 +62,61 @@ whatwill total Befehle, die zu einem Stichwort passen
|
|||||||
commands Tabelle aller Befehle
|
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).
|
||||||
|
|
||||||
|
## Zeilen auswählen: `pick`
|
||||||
|
|
||||||
|
`pick` zeigt eine Tabelle auf dem Terminal, man scrollt, filtert und wählt eine oder mehrere Zeilen. Die Tabelle
|
||||||
|
kommt von der Standardeingabe, die gewählten Zeilen gehen als Tabelle auf die Standardausgabe, also mitten in der Pipe:
|
||||||
|
|
||||||
|
```
|
||||||
|
pick < inventory eine Zeile wählen
|
||||||
|
pick -m < inventory | total Amount Value mehrere (Leertaste markiert, a alle)
|
||||||
|
column Item Description < inventory | pick -m | jointable - owners
|
||||||
|
pick -m -k 'down space down enter' < inventory Tasten aus einem String (Skripte, Tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pfeile, Bild auf/ab, Pos1/Ende scrollen; `/` filtert (Enter behält den Filter, Esc löscht ihn); Enter wählt;
|
||||||
|
Esc bricht ab (Status 1, keine Ausgabe). Ohne Markierung gilt die Zeile unter dem Cursor.
|
||||||
|
|
||||||
## Befehle
|
## Befehle
|
||||||
|
|
||||||
| Gruppe | Befehle |
|
| Gruppe | Befehle |
|
||||||
@@ -76,7 +131,7 @@ commands Tabelle aller Befehle
|
|||||||
| Text | `cap` `uppercase` `lowercase` `substitute` `trimblank` `padstring` `length` `filesize` `ascii` `chr` `word` `prompt` `not` `path` |
|
| 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` |
|
| Umwandeln | `tabletolist` `listtotable` `listtosh` `tabletofix` `fixtotable` `tabletostruct` `tabletotbl` `tabletofact` `tabletorule` `tabletom4` `tabletosed` `translate` `uniondict` |
|
||||||
| Datum, Namen | `julian` `gregorian` `computedate` `todaysdate` `soundex` `like` `hashkey` |
|
| Datum, Namen | `julian` `gregorian` `computedate` `todaysdate` `soundex` `like` `hashkey` |
|
||||||
| Berichte | `report` `reportwriter` `screen` `label` `letter` |
|
| Berichte | `report` `reportwriter` `screen` `mask` `pick` `label` `letter` |
|
||||||
| Terminal, Hilfe | `termput` `tset` `clear.rdb` `cursor` `menu` `rdb` `commands` `helpme` `whatis` `whatwill` `howmany` `man` |
|
| Terminal, Hilfe | `termput` `tset` `clear.rdb` `cursor` `menu` `rdb` `commands` `helpme` `whatis` `whatwill` `howmany` `man` |
|
||||||
| Betrieb | `testall` `testsearch` `timesearch` `rmcore` `cpdir.rdb` |
|
| 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` |
|
| 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 +193,7 @@ lib/rdb/table.rb Tabellen und Listen lesen und schreiben
|
|||||||
lib/rdb/fast.rb Schnellzugriff über Byte-Offsets
|
lib/rdb/fast.rb Schnellzugriff über Byte-Offsets
|
||||||
lib/rdb/cmd/*.rb die Befehle, nach Gruppen
|
lib/rdb/cmd/*.rb die Befehle, nach Gruppen
|
||||||
man/ Man-Pages (troff-Quellen aus den MWC-Archiven)
|
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
|
test/ Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Mail List Entry Form
|
||||||
|
|
||||||
|
Name : <Name> Company: <Company>
|
||||||
|
Street: <Street>
|
||||||
|
City : <City> State : <State> ZIP: <ZIP>
|
||||||
|
Phone : <Phone>
|
||||||
|
|
||||||
|
[Speichern] [Abbrechen]
|
||||||
|
|
||||||
@@ -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"
|
||||||
@@ -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 ]
|
||||||
+274
-8
@@ -1,14 +1,280 @@
|
|||||||
# One command per line; testall runs them in demo/ and prints the output.
|
# testall.cmds -- one shell command per line; `testall` runs them in a scratch copy of demo/.
|
||||||
# Save a known-good run: testall > testall.old ; later: testall | diff testall.old -
|
# ## 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
|
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
|
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
|
compute 'Value = sprintf("%5.2f", Cost * Amount)' < inventory
|
||||||
total Amount Value < inventory
|
## addcol / rename
|
||||||
datatype -l < inventory
|
addcol Supplier < owners
|
||||||
subtotal Date Debit Credit < journal
|
rename Owner Person < owners
|
||||||
jointable parent female
|
## 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
|
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
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
## ===== Choosing rows on the terminal =====
|
||||||
|
## pick shows a table, lets you scroll, filter (/) and mark rows (Space, -m), and writes the
|
||||||
|
## chosen rows as a table, so it can sit in a pipe. It needs a terminal; -k feeds the keys instead.
|
||||||
|
## Try it: pick -m < inventory | total Amount Value
|
||||||
|
## mark the 2nd and 5th row (down, space, down, down, space) and finish with enter
|
||||||
|
pick -m -k 'down space down down space enter' < inventory
|
||||||
|
## the same chosen rows go on into total
|
||||||
|
pick -m -k 'down space down down space enter' < inventory | total Amount Value
|
||||||
|
## filter with / (type, enter), then choose the row that is left
|
||||||
|
pick -k '/clamps enter enter' < inventory
|
||||||
|
## Esc cancels: nothing on stdout, exit status 1
|
||||||
|
pick -k esc < inventory ; echo "status $?"
|
||||||
|
|||||||
+1056
-59
File diff suppressed because it is too large
Load Diff
@@ -165,10 +165,12 @@ Rdb.register('enter', 'Add rows to a table or list file without an editor', 'ent
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
require 'digest'
|
||||||
|
|
||||||
# ---- locks -------------------------------------------------------------------
|
# ---- locks -------------------------------------------------------------------
|
||||||
|
|
||||||
def lock_file(name)
|
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
|
end
|
||||||
|
|
||||||
Rdb.register('lock', 'Lock a record or field of a file', 'lock filename processid from to indexfrom indexto') do |args|
|
Rdb.register('lock', 'Lock a record or field of a file', 'lock filename processid from to indexfrom indexto') do |args|
|
||||||
|
|||||||
+1
-1
@@ -365,7 +365,7 @@ Rdb.register('sale', 'Enter sale order, and item, update customer', 'sale') do |
|
|||||||
next if items.empty?
|
next if items.empty?
|
||||||
|
|
||||||
tax = gross * 0.05
|
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
|
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')
|
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)
|
File.write('salesorder', Rdb::Table.open('salesorder').tap { |t| t.rows << order }.to_s)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
+17
-5
@@ -227,12 +227,24 @@ end
|
|||||||
Rdb.register('testall', 'Test all /rdb programs in directory $RDB/demo', 'testall [ > testall.new ]') do |_args|
|
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']}"
|
ENV['PATH'] = "#{File.join(Rdb::ROOT, 'bin')}:#{ENV['PATH']}"
|
||||||
demo = File.join(ENV['RDB'] || Rdb::ROOT, 'demo')
|
demo = File.join(ENV['RDB'] || Rdb::ROOT, 'demo')
|
||||||
Dir.chdir(demo)
|
|
||||||
script = File.join(demo, 'testall.cmds')
|
script = File.join(demo, 'testall.cmds')
|
||||||
File.exist?(script) or die "#{script}: missing"
|
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.
|
||||||
puts "$ #{cmd}"
|
Dir.mktmpdir('rdb-testall') do |dir|
|
||||||
$stdout.flush
|
FileUtils.cp_r(Dir[File.join(demo, '*')], dir)
|
||||||
puts `#{cmd} 2>&1`
|
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 Open3.capture2e('sh', '-c', cmd, stdin_data: '').first
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Rdb
|
module Rdb
|
||||||
VERSION = '0.2.0'
|
VERSION = '0.5.0'
|
||||||
end
|
end
|
||||||
|
|||||||
+116
@@ -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
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
.TH pick "" "" "/rdb Command"
|
||||||
|
.PC "Scroll through a table and choose one or more rows"
|
||||||
|
\fBpick \fB[ -m ] [ -k \fIkeys\fB ] < \fItable \fB> \fIchosenrows\fR
|
||||||
|
.PP
|
||||||
|
.II "pick^choose rows"
|
||||||
|
.II "table^scroll"
|
||||||
|
.I "(Ruby re-creation only; the original /rdb has no such command.)"
|
||||||
|
.PP
|
||||||
|
The command
|
||||||
|
.B pick
|
||||||
|
shows
|
||||||
|
.I table
|
||||||
|
on the terminal, lets the user scroll through it, and writes the rows
|
||||||
|
that were chosen as a table on the standard output.
|
||||||
|
It therefore fits into the middle of a pipe:
|
||||||
|
.DM
|
||||||
|
column Item Description < inventory | pick -m | jointable - owners
|
||||||
|
pick -m < inventory | total Amount Value
|
||||||
|
.DE
|
||||||
|
.PP
|
||||||
|
The table is read from the standard input;
|
||||||
|
the keys are read from the terminal (\fB/dev/tty\fR).
|
||||||
|
A list is read like a table, and the output is always a table.
|
||||||
|
The head and the dash line stay at the top while the rows scroll.
|
||||||
|
.SH Keys
|
||||||
|
.nf
|
||||||
|
Up, Down move the cursor one row
|
||||||
|
PgUp, PgDn move a page
|
||||||
|
Home, End first or last row
|
||||||
|
Enter choose and finish
|
||||||
|
Space mark the row, move down (with -m)
|
||||||
|
a mark all shown rows; again, unmark them (with -m)
|
||||||
|
/ filter: type text, shown rows must contain it
|
||||||
|
(Enter keeps the filter, Esc clears it)
|
||||||
|
Esc, q, Ctrl-C cancel
|
||||||
|
.fi
|
||||||
|
.PP
|
||||||
|
.B pick
|
||||||
|
writes the marked rows in table order.
|
||||||
|
If no row is marked, it writes the row under the cursor.
|
||||||
|
Marks stay when a filter hides the row.
|
||||||
|
If the user cancels, or the filter leaves no row to choose,
|
||||||
|
.B pick
|
||||||
|
writes nothing and exits with status 1.
|
||||||
|
.SH Options
|
||||||
|
.IP \fB-m\fR 0.5i
|
||||||
|
Allow several rows: Space marks, \fBa\fR marks all.
|
||||||
|
Without it, Enter chooses the row under the cursor.
|
||||||
|
.IP "\fB-k\fR \fIkeys\fR"
|
||||||
|
Take the keys from this string instead of the terminal.
|
||||||
|
Words are key names:
|
||||||
|
.BR up ,
|
||||||
|
.BR down ,
|
||||||
|
.BR pgup ,
|
||||||
|
.BR pgdn ,
|
||||||
|
.BR home ,
|
||||||
|
.BR end ,
|
||||||
|
.BR enter ,
|
||||||
|
.BR space ,
|
||||||
|
.BR esc ,
|
||||||
|
.BR backspace ;
|
||||||
|
any other word is typed character by character.
|
||||||
|
This is for scripts, demonstrations and tests.
|
||||||
|
.SH Example
|
||||||
|
.DM
|
||||||
|
pick -m -k 'down space down down space enter' < inventory
|
||||||
|
.DE
|
||||||
|
.PP
|
||||||
|
marks the second and the fifth row and prints:
|
||||||
|
.DM
|
||||||
|
Item Amount Cost Value Description
|
||||||
|
---- ------ ---- ------ --------------
|
||||||
|
2 100 0.50 50.00 test tubes
|
||||||
|
5 99 2.45 242.55 cleaning cloth
|
||||||
|
.DE
|
||||||
|
.SH "See Also"
|
||||||
|
.B
|
||||||
|
display,
|
||||||
|
mask,
|
||||||
|
row,
|
||||||
|
search
|
||||||
|
.R
|
||||||
|
.SO rdb
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require_relative 'test_helper'
|
require_relative 'test_helper'
|
||||||
|
require 'pty'
|
||||||
|
require_relative '../lib/rdb'
|
||||||
|
|
||||||
class SelectionTest < RdbCase
|
class SelectionTest < RdbCase
|
||||||
def test_row_numeric_condition
|
def test_row_numeric_condition
|
||||||
@@ -445,6 +447,13 @@ class HelpTest < RdbCase
|
|||||||
assert_equal "\e[3;8H", rdb('cursor', '3', '8')
|
assert_equal "\e[3;8H", rdb('cursor', '3', '8')
|
||||||
end
|
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
|
def test_testall_runs_demo_script
|
||||||
out = rdb('testall')
|
out = rdb('testall')
|
||||||
assert_includes out, '$ row'
|
assert_includes out, '$ row'
|
||||||
@@ -516,3 +525,201 @@ class ActTest < RdbCase
|
|||||||
assert_equal [%w[b]], data(out)
|
assert_equal [%w[b]], data(out)
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|
||||||
|
class PickTest < RdbCase
|
||||||
|
def session(multi: true, height: 3)
|
||||||
|
t = Rdb::Table.read(File.open(File.join(DEMO, 'inventory')))
|
||||||
|
Rdb::Pick::Session.new(t.head, t.rows, multi: multi).tap { |x| x.resize(height, 80) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def keys(sess, words) = Rdb::Pick.script(words).map { |k| sess.feed(k) }.compact.last
|
||||||
|
|
||||||
|
def test_mark_and_select
|
||||||
|
s = session
|
||||||
|
assert_equal :accept, keys(s, 'down space down down space enter')
|
||||||
|
assert_equal %w[2 5], s.selected.map(&:first)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_single_mode_takes_the_cursor_row_and_ignores_space
|
||||||
|
s = session(multi: false)
|
||||||
|
assert_equal :accept, keys(s, 'down down space enter')
|
||||||
|
assert_equal %w[3], s.selected.map(&:first)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_scrolling_keeps_the_cursor_in_view
|
||||||
|
s = session(height: 3)
|
||||||
|
keys(s, 'down down down down')
|
||||||
|
assert_equal [4, 2], [s.cursor, s.top]
|
||||||
|
keys(s, 'end')
|
||||||
|
assert_equal [6, 4], [s.cursor, s.top]
|
||||||
|
keys(s, 'pgup')
|
||||||
|
assert_equal 3, s.cursor
|
||||||
|
keys(s, 'home')
|
||||||
|
assert_equal [0, 0], [s.cursor, s.top]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_filter_and_clear
|
||||||
|
s = session
|
||||||
|
keys(s, '/clam')
|
||||||
|
assert_predicate s, :typing?
|
||||||
|
keys(s, 'enter')
|
||||||
|
assert_equal [2], s.visible
|
||||||
|
keys(s, '/ esc')
|
||||||
|
assert_equal 7, s.visible.size
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_marks_survive_a_filter_and_a_all_toggles
|
||||||
|
s = session
|
||||||
|
keys(s, 'space /scales enter')
|
||||||
|
assert_equal [0], s.marked
|
||||||
|
keys(s, 'a')
|
||||||
|
assert_equal [0, 6], s.marked.sort
|
||||||
|
keys(s, 'a')
|
||||||
|
assert_equal [0], s.marked
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cancel_and_empty_choice
|
||||||
|
assert_equal :cancel, keys(session, 'esc')
|
||||||
|
assert_equal :cancel, keys(session, '/zzz enter enter')
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_command_with_keys_script_is_a_filter
|
||||||
|
out = rdb('pick', '-m', '-k', 'down space down down space enter', file: 'inventory')
|
||||||
|
assert_equal %w[2 5], data(out).map(&:first)
|
||||||
|
assert_equal '292.55', data(rdb('total', 'Value', stdin: out)).first[3]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_command_cancelled_exits_1_with_no_output
|
||||||
|
out, _err, st = run3('pick', '-k', 'esc', stdin: File.read(File.join(@dir, 'inventory')))
|
||||||
|
refute st.success?
|
||||||
|
assert_equal '', out
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_command_reads_a_list
|
||||||
|
out = rdb('pick', '-k', 'down enter', stdin: rdb('tabletolist', file: 'owners'))
|
||||||
|
assert_equal [%w[3 bob]], data(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
def run_on_pty(*key_strokes)
|
||||||
|
out = File.join(@dir, 'pick.out')
|
||||||
|
result = nil
|
||||||
|
PTY.spawn({ 'PATH' => "#{BIN}:#{ENV['PATH']}" }, 'sh', '-c', "cd #{@dir} && pick -m < inventory > pick.out") do |r, w, pid|
|
||||||
|
sleep 0.5
|
||||||
|
key_strokes.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_pick_on_a_terminal
|
||||||
|
table, status = run_on_pty("\e[B", ' ', "\e[B", ' ', "\r")
|
||||||
|
assert_equal 0, status
|
||||||
|
assert_equal %w[2 4], data(table).map(&:first)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_pick_on_a_terminal_cancel
|
||||||
|
table, status = run_on_pty("\e[B", "\e")
|
||||||
|
assert_equal 1, status
|
||||||
|
assert_equal '', table
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user