Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c66773ff4 | ||
|
|
e1bdb6c703 | ||
|
|
777a3b56e2 | ||
|
|
d5286c3600 | ||
|
|
f83d1bd6f8 | ||
|
|
6c0d675c5d | ||
|
|
d2c4343136 | ||
|
|
e7a7577900 | ||
|
|
837a73366d |
@@ -7,6 +7,29 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.6.0] - 2026-10-11
|
||||
|
||||
### Hinzugefügt
|
||||
- Steuertabellen 2026 für `tax`: `demo/taxDE2026`, `taxAT2026`, `taxCH2026` (nur Bundessteuer), `taxLI2026` (nur Landessteuer), erzeugt mit `tools/gen_tax_tables.rb`, mit Tests.
|
||||
|
||||
### Geändert
|
||||
- `record`: 0 und 1 sind die erste Zeile, negative Zahlen zählen vom Ende (`-1` letzte, `-2` vorletzte ...). Außerhalb der Tabelle kommt eine leere Tabelle, Text und Dezimalzahlen sind ein Fehler. (Vorher lieferten 0, -1 und Text ungewollt Zeilen vom Ende.) `testall.cmds` und `man/record.man` zeigen es.
|
||||
- README und `man/row.man` erklären, warum `select` in bash/zsh/ksh einen Syntaxfehler gibt und wie man es trotzdem aufruft.
|
||||
|
||||
### Behoben
|
||||
- `rdb man` zeigte `\\select` in den Man-Pages als `\elect`: ein doppelter Backslash wird jetzt zuerst geschützt.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -77,6 +77,70 @@ 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.
|
||||
|
||||
## Steuertabellen 2026 für `tax`
|
||||
|
||||
`demo/` enthält vier Tarife für das Steuerjahr 2026 im Format von `tax` (income, tax, percent), jeweils für
|
||||
Alleinstehende (Grundtarif). Erzeugt mit `ruby tools/gen_tax_tables.rb`.
|
||||
|
||||
| Datei | Land | Quelle | Genauigkeit |
|
||||
|---|---|---|---|
|
||||
| `taxDE2026` | Deutschland, Einkommensteuer | § 32a EStG (Grundfreibetrag 12 348 €) | Kurvenstücke durch Geraden genähert, Abweichung unter 0,15 € (die Formel rundet ab, `tax` auf) |
|
||||
| `taxAT2026` | Österreich, Einkommensteuer | § 33 EStG (0 % bis 13 539 €, dann 20/30/40/48/50/55 %) | exakt, vor Absetzbeträgen |
|
||||
| `taxCH2026` | Schweiz, **nur direkte Bundessteuer** | Art. 36 DBG, ESTV-Rundschreiben 2-215-D-2025 | Stufen exakt, das Gesetz rechnet in 100-Franken-Schritten |
|
||||
| `taxLI2026` | Liechtenstein, **nur Landessteuer** | Art. 19 SteG (LR 640.0, Fassung 01.07.2026) | exakt |
|
||||
|
||||
```
|
||||
tax 60000 < demo/taxDE2026 14233
|
||||
tax 60000 < demo/taxAT2026 15447
|
||||
tax 60000 < demo/taxCH2026 671
|
||||
tax 60000 < demo/taxLI2026 1396 (mal 1 + Gemeindezuschlag, 150 % bis 250 %)
|
||||
```
|
||||
|
||||
Der Betrag ist die Steuer auf das **zu versteuernde** Einkommen. Abzüge, Sozialversicherung, Absetzbeträge,
|
||||
Kirchensteuer und Soli sind nicht enthalten. Für die Schweiz kommen Kanton und Gemeinde dazu (sie sind meist
|
||||
höher als der Bund), für Liechtenstein der Gemeindezuschlag: gesamt = Landessteuer × (1 + Zuschlag/100).
|
||||
Ehepaar- und Alleinerziehendentarife fehlen. Das ist keine Steuerberatung.
|
||||
|
||||
## Befehle
|
||||
|
||||
| Gruppe | Befehle |
|
||||
@@ -91,7 +155,7 @@ ein stiller Kommentar. Neue Befehle brauchen hier ein Beispiel, sonst schlägt d
|
||||
| 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` `pick` `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` |
|
||||
@@ -135,6 +199,10 @@ er nicht mehr passt (außer mit `-a`). Beide halten die Indizes aktuell.
|
||||
`row`, `compute`, `column`, `sorttable`, `jointable`, `rename`, `validate` und
|
||||
`rdb` haben dazu den Abschnitt „Case of Column Names“; er ist die einzige
|
||||
Ergänzung gegenüber den Original-Seiten.
|
||||
- `select` ist in bash, zsh und ksh ein reserviertes Wort. `select 'Cost > 5' < inventory` endet dort
|
||||
mit `syntax error near unexpected token '<'`, weil die Shell das Schlüsselwort vor dem `PATH` auswertet.
|
||||
Es gibt dasselbe unter dem Namen `row`. Außerdem gehen `\select ...`, `command select ...` und
|
||||
`rdb select ...`.
|
||||
- `jointable` braucht keine vorsortierten Tabellen.
|
||||
- `ve` ist ein zeilenorientierter Editor, kein Bildschirmeditor.
|
||||
- `soundex` nutzt den Standard-Algorithmus. Die Beispielwerte im Handbuch
|
||||
|
||||
@@ -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 ]
|
||||
@@ -0,0 +1,9 @@
|
||||
income tax percent
|
||||
------ --- -------
|
||||
0 0.00 0
|
||||
13539 0.00 20
|
||||
21992 1690.60 30
|
||||
36458 6030.40 40
|
||||
70365 19593.20 48
|
||||
104859 36150.32 50
|
||||
1000000 483720.82 55
|
||||
@@ -0,0 +1,14 @@
|
||||
income tax percent
|
||||
------ --- -------
|
||||
0 0.00 0
|
||||
15200 0.00 0.77
|
||||
33200 138.60 0.88
|
||||
43500 229.20 2.64
|
||||
58000 612.00 2.97
|
||||
76200 1152.50 5.94
|
||||
82100 1502.95 6.6
|
||||
108900 3271.75 8.8
|
||||
141500 6140.55 11
|
||||
185100 10936.55 13.2
|
||||
793900 91298.15 11.85
|
||||
794000 91310.00 11.5
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
income tax percent
|
||||
------ --- -------
|
||||
0 0.00 0
|
||||
12348 0.00 14.228628
|
||||
12598 35.57 14.685882
|
||||
12848 72.29 15.143138
|
||||
13098 110.14 15.600392
|
||||
13348 149.15 16.057648
|
||||
13598 189.29 16.514902
|
||||
13848 230.58 16.972158
|
||||
14098 273.01 17.429413
|
||||
14348 316.58 17.886668
|
||||
14598 361.30 18.343922
|
||||
14848 407.16 18.801178
|
||||
15098 454.16 19.258432
|
||||
15348 502.31 19.715688
|
||||
15598 551.60 20.172942
|
||||
15848 602.03 20.630198
|
||||
16098 653.60 21.087453
|
||||
16348 706.32 21.544707
|
||||
16598 760.18 22.001963
|
||||
16848 815.19 22.459217
|
||||
17098 871.34 22.916473
|
||||
17348 928.63 23.373727
|
||||
17598 987.06 23.786172
|
||||
17799 1034.87 24.012466
|
||||
18049 1094.90 24.099825
|
||||
18299 1155.15 24.186375
|
||||
18549 1215.62 24.272925
|
||||
18799 1276.30 24.359475
|
||||
19049 1337.20 24.446025
|
||||
19299 1398.31 24.532575
|
||||
19549 1459.65 24.619125
|
||||
19799 1521.19 24.705675
|
||||
20049 1582.96 24.792225
|
||||
20299 1644.94 24.878775
|
||||
20549 1707.14 24.965325
|
||||
20799 1769.55 25.051875
|
||||
21049 1832.18 25.138425
|
||||
21299 1895.02 25.224975
|
||||
21549 1958.09 25.311525
|
||||
21799 2021.37 25.398075
|
||||
22049 2084.86 25.484625
|
||||
22299 2148.57 25.571175
|
||||
22549 2212.50 25.657725
|
||||
22799 2276.64 25.744275
|
||||
23049 2341.01 25.830825
|
||||
23299 2405.58 25.917375
|
||||
23549 2470.38 26.003925
|
||||
23799 2535.39 26.090475
|
||||
24049 2600.61 26.177025
|
||||
24299 2666.05 26.263575
|
||||
24549 2731.71 26.350125
|
||||
24799 2797.59 26.436675
|
||||
25049 2863.68 26.523225
|
||||
25299 2929.99 26.609775
|
||||
25549 2996.51 26.696325
|
||||
25799 3063.25 26.782875
|
||||
26049 3130.21 26.869425
|
||||
26299 3197.38 26.955975
|
||||
26549 3264.77 27.042525
|
||||
26799 3332.38 27.129075
|
||||
27049 3400.20 27.215625
|
||||
27299 3468.24 27.302175
|
||||
27549 3536.50 27.388725
|
||||
27799 3604.97 27.475275
|
||||
28049 3673.66 27.561825
|
||||
28299 3742.56 27.648375
|
||||
28549 3811.68 27.734925
|
||||
28799 3881.02 27.821475
|
||||
29049 3950.57 27.908025
|
||||
29299 4020.34 27.994575
|
||||
29549 4090.33 28.081125
|
||||
29799 4160.53 28.167675
|
||||
30049 4230.95 28.254225
|
||||
30299 4301.59 28.340775
|
||||
30549 4372.44 28.427325
|
||||
30799 4443.51 28.513875
|
||||
31049 4514.79 28.600425
|
||||
31299 4586.29 28.686975
|
||||
31549 4658.01 28.773525
|
||||
31799 4729.95 28.860075
|
||||
32049 4802.10 28.946625
|
||||
32299 4874.46 29.033175
|
||||
32549 4947.05 29.119725
|
||||
32799 5019.84 29.206275
|
||||
33049 5092.86 29.292825
|
||||
33299 5166.09 29.379375
|
||||
33549 5239.54 29.465925
|
||||
33799 5313.21 29.552475
|
||||
34049 5387.09 29.639025
|
||||
34299 5461.18 29.725575
|
||||
34549 5535.50 29.812125
|
||||
34799 5610.03 29.898675
|
||||
35049 5684.78 29.985225
|
||||
35299 5759.74 30.071775
|
||||
35549 5834.92 30.158325
|
||||
35799 5910.31 30.244875
|
||||
36049 5985.93 30.331425
|
||||
36299 6061.75 30.417975
|
||||
36549 6137.80 30.504525
|
||||
36799 6214.06 30.591075
|
||||
37049 6290.54 30.677625
|
||||
37299 6367.23 30.764175
|
||||
37549 6444.14 30.850725
|
||||
37799 6521.27 30.937275
|
||||
38049 6598.61 31.023825
|
||||
38299 6676.17 31.110375
|
||||
38549 6753.95 31.196925
|
||||
38799 6831.94 31.283475
|
||||
39049 6910.15 31.370025
|
||||
39299 6988.57 31.456575
|
||||
39549 7067.22 31.543125
|
||||
39799 7146.07 31.629675
|
||||
40049 7225.15 31.716225
|
||||
40299 7304.44 31.802775
|
||||
40549 7383.95 31.889325
|
||||
40799 7463.67 31.975875
|
||||
41049 7543.61 32.062425
|
||||
41299 7623.76 32.148975
|
||||
41549 7704.14 32.235525
|
||||
41799 7784.73 32.322075
|
||||
42049 7865.53 32.408625
|
||||
42299 7946.55 32.495175
|
||||
42549 8027.79 32.581725
|
||||
42799 8109.24 32.668275
|
||||
43049 8190.92 32.754825
|
||||
43299 8272.80 32.841375
|
||||
43549 8354.91 32.927925
|
||||
43799 8437.23 33.014475
|
||||
44049 8519.76 33.101025
|
||||
44299 8602.51 33.187575
|
||||
44549 8685.48 33.274125
|
||||
44799 8768.67 33.360675
|
||||
45049 8852.07 33.447225
|
||||
45299 8935.69 33.533775
|
||||
45549 9019.52 33.620325
|
||||
45799 9103.57 33.706875
|
||||
46049 9187.84 33.793425
|
||||
46299 9272.32 33.879975
|
||||
46549 9357.02 33.966525
|
||||
46799 9441.94 34.053075
|
||||
47049 9527.07 34.139625
|
||||
47299 9612.42 34.226175
|
||||
47549 9697.99 34.312725
|
||||
47799 9783.77 34.399275
|
||||
48049 9869.77 34.485825
|
||||
48299 9955.98 34.572375
|
||||
48549 10042.41 34.658925
|
||||
48799 10129.06 34.745475
|
||||
49049 10215.92 34.832025
|
||||
49299 10303.00 34.918575
|
||||
49549 10390.30 35.005125
|
||||
49799 10477.81 35.091675
|
||||
50049 10565.54 35.178225
|
||||
50299 10653.49 35.264775
|
||||
50549 10741.65 35.351325
|
||||
50799 10830.03 35.437875
|
||||
51049 10918.62 35.524425
|
||||
51299 11007.43 35.610975
|
||||
51549 11096.46 35.697525
|
||||
51799 11185.71 35.784075
|
||||
52049 11275.17 35.870625
|
||||
52299 11364.84 35.957175
|
||||
52549 11454.74 36.043725
|
||||
52799 11544.84 36.130275
|
||||
53049 11635.17 36.216825
|
||||
53299 11725.71 36.303375
|
||||
53549 11816.47 36.389925
|
||||
53799 11907.45 36.476475
|
||||
54049 11998.64 36.563025
|
||||
54299 12090.04 36.649575
|
||||
54549 12181.67 36.736125
|
||||
54799 12273.51 36.822675
|
||||
55049 12365.57 36.909225
|
||||
55299 12457.84 36.995775
|
||||
55549 12550.33 37.082325
|
||||
55799 12643.03 37.168875
|
||||
56049 12735.96 37.255425
|
||||
56299 12829.09 37.341975
|
||||
56549 12922.45 37.428525
|
||||
56799 13016.02 37.515075
|
||||
57049 13109.81 37.601625
|
||||
57299 13203.81 37.688175
|
||||
57549 13298.03 37.774725
|
||||
57799 13392.47 37.861275
|
||||
58049 13487.12 37.947825
|
||||
58299 13581.99 38.034375
|
||||
58549 13677.08 38.120925
|
||||
58799 13772.38 38.207475
|
||||
59049 13867.90 38.294025
|
||||
59299 13963.63 38.380575
|
||||
59549 14059.59 38.467125
|
||||
59799 14155.75 38.553675
|
||||
60049 14252.14 38.640225
|
||||
60299 14348.74 38.726775
|
||||
60549 14445.56 38.813325
|
||||
60799 14542.59 38.899875
|
||||
61049 14639.84 38.986425
|
||||
61299 14737.30 39.072975
|
||||
61549 14834.99 39.159525
|
||||
61799 14932.89 39.246075
|
||||
62049 15031.00 39.332625
|
||||
62299 15129.33 39.419175
|
||||
62549 15227.88 39.505725
|
||||
62799 15326.64 39.592275
|
||||
63049 15425.63 39.678825
|
||||
63299 15524.82 39.765375
|
||||
63549 15624.24 39.851925
|
||||
63799 15723.87 39.938475
|
||||
64049 15823.71 40.025025
|
||||
64299 15923.77 40.111575
|
||||
64549 16024.05 40.198125
|
||||
64799 16124.55 40.284675
|
||||
65049 16225.26 40.371225
|
||||
65299 16326.19 40.457775
|
||||
65549 16427.33 40.544325
|
||||
65799 16528.69 40.630875
|
||||
66049 16630.27 40.717425
|
||||
66299 16732.06 40.803975
|
||||
66549 16834.07 40.890525
|
||||
66799 16936.30 40.977075
|
||||
67049 17038.74 41.063625
|
||||
67299 17141.40 41.150175
|
||||
67549 17244.28 41.236725
|
||||
67799 17347.37 41.323275
|
||||
68049 17450.68 41.409825
|
||||
68299 17554.20 41.496375
|
||||
68549 17657.94 41.582925
|
||||
68799 17761.90 41.669475
|
||||
69049 17866.07 41.756025
|
||||
69299 17970.46 41.842575
|
||||
69549 18075.07 41.963277
|
||||
69879 18213.55 42.000014
|
||||
277826 105551.32 45
|
||||
@@ -0,0 +1,11 @@
|
||||
income tax percent
|
||||
------ --- -------
|
||||
0 0.00 0
|
||||
15900 0.00 1
|
||||
21100 52.00 3
|
||||
42300 688.00 4
|
||||
74000 1956.00 5
|
||||
105700 3541.00 6
|
||||
137400 5443.00 6.5
|
||||
169200 7510.00 7
|
||||
211400 10464.00 8
|
||||
+36
-3
@@ -23,8 +23,14 @@ 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: print one record. 0 and 1 are the first row, 3 the third, -1 the last, -2 the one before
|
||||
record 3 < inventory
|
||||
record 0 < owners
|
||||
record -1 < owners
|
||||
record -2 < owners
|
||||
## beyond the end: an empty table; not a number: an error
|
||||
record 9 < owners
|
||||
record abc < owners
|
||||
## rmblank: drop empty rows; headon / headoff: add or strip the head
|
||||
column Item Cost < inventory | rmblank
|
||||
headoff < colors
|
||||
@@ -182,8 +188,16 @@ 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
|
||||
screen < form | head -6
|
||||
## 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
|
||||
@@ -215,6 +229,12 @@ act
|
||||
cashflow < cash
|
||||
## tax: look up the tax for an income in a tax table
|
||||
tax 20000 < X83
|
||||
## tax also works with the real 2026 tables of DE, AT, CH (federal tax only) and LI (Landessteuer,
|
||||
## before the municipal surcharge of 150 % to 250 %); see README. Tax on an income of 60000:
|
||||
tax 60000 < taxDE2026
|
||||
tax 60000 < taxAT2026
|
||||
tax 60000 < taxCH2026
|
||||
tax 60000 < taxLI2026
|
||||
## explode: parts list -> all subparts with counts (part, amount)
|
||||
explode 10001 2 parttable
|
||||
## chartdup: look for duplicate account numbers and short names in chart
|
||||
@@ -257,3 +277,16 @@ 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 $?"
|
||||
|
||||
+128
-8
@@ -86,11 +86,29 @@ Number Item Owner
|
||||
1 1 ann
|
||||
2 3 bob
|
||||
3 6 cy
|
||||
## record: print one record (the 3rd row)
|
||||
## record: print one record. 0 and 1 are the first row, 3 the third, -1 the last, -2 the one before
|
||||
$ record 3 < inventory
|
||||
Item Amount Cost Value Description
|
||||
---- ------ ---- ----- -----------
|
||||
3 5 8.00 40.00 clamps
|
||||
$ record 0 < owners
|
||||
Item Owner
|
||||
---- -----
|
||||
1 ann
|
||||
$ record -1 < owners
|
||||
Item Owner
|
||||
---- -----
|
||||
6 cy
|
||||
$ record -2 < owners
|
||||
Item Owner
|
||||
---- -----
|
||||
3 bob
|
||||
## beyond the end: an empty table; not a number: an error
|
||||
$ record 9 < owners
|
||||
Item Owner
|
||||
---- -----
|
||||
$ record abc < owners
|
||||
record: 'abc' is not a record number (1, 2, 3 ... or -1 for the last)
|
||||
## rmblank: drop empty rows; headon / headoff: add or strip the head
|
||||
$ column Item Cost < inventory | rmblank
|
||||
Item Cost
|
||||
@@ -735,14 +753,82 @@ McDonalds
|
||||
123 Mac Attack
|
||||
Memphis, TENN 30000
|
||||
|
||||
## screen turns a form into a shell script that asks for the fields
|
||||
$ screen < form | head -6
|
||||
## 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
|
||||
Mail List Entry Form
|
||||
|
||||
Name : <Name> Company: <Company>
|
||||
Street: <Street>
|
||||
City : <City> State : <State> ZIP: <ZIP>
|
||||
Phone : <Phone>
|
||||
|
||||
[Speichern] [Abbrechen]
|
||||
|
||||
$ screen < entry
|
||||
: paint crt screen
|
||||
exec 1>/dev/tty
|
||||
cat <<SCREEN
|
||||
${CLEAR}
|
||||
|
||||
|
||||
${CLEAR} Mail List Entry Form
|
||||
|
||||
Name : Company:
|
||||
Street:
|
||||
City : State : ZIP:
|
||||
Phone :
|
||||
|
||||
[Speichern] [Abbrechen]
|
||||
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"
|
||||
## 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
|
||||
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 ]
|
||||
$ mask -s -b < maskform
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Mail List Entry │
|
||||
│ │
|
||||
│ Name : ________________________ │
|
||||
│ Company: ________________________ │
|
||||
│ Street : ________________________ │
|
||||
│ City : ________________ State: __________ ZIP: _________ │
|
||||
│ Phone : ________________ │
|
||||
│ │
|
||||
│ [ Save ] [ Cancel ] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
Fields:
|
||||
Name row 4 col 12 width 24
|
||||
Company row 5 col 12 width 24
|
||||
Street row 6 col 12 width 24
|
||||
City row 7 col 12 width 16
|
||||
State row 7 col 37 width 10
|
||||
ZIP row 7 col 54 width 9
|
||||
Phone row 8 col 12 width 16
|
||||
[Save] row 10 col 6
|
||||
[Cancel] row 10 col 17 (cancels)
|
||||
## reportwriter: a sample report with page headings (the date is cut out)
|
||||
$ reportwriter -3 Prices < inventory | grep -v 'Report Date'
|
||||
Prices
|
||||
@@ -777,9 +863,9 @@ Item Amount Cost Value Description
|
||||
## tabletom4 / tabletosed / tabletostruct were shown above
|
||||
## ===== Help and the terminal =====
|
||||
$ rdb --version
|
||||
rdb 0.2.0
|
||||
rdb 0.6.0
|
||||
$ howmany
|
||||
134
|
||||
136
|
||||
$ whatis row
|
||||
row - make a new table where rows match logical condition
|
||||
row row 'column condition column-or-value ..' < table
|
||||
@@ -867,6 +953,16 @@ Date Amount Balance Description
|
||||
## tax: look up the tax for an income in a tax table
|
||||
$ tax 20000 < X83
|
||||
3369
|
||||
## tax also works with the real 2026 tables of DE, AT, CH (federal tax only) and LI (Landessteuer,
|
||||
## before the municipal surcharge of 150 % to 250 %); see README. Tax on an income of 60000:
|
||||
$ tax 60000 < taxDE2026
|
||||
14233
|
||||
$ tax 60000 < taxAT2026
|
||||
15447
|
||||
$ tax 60000 < taxCH2026
|
||||
671
|
||||
$ tax 60000 < taxLI2026
|
||||
1396
|
||||
## explode: parts list -> all subparts with counts (part, amount)
|
||||
$ explode 10001 2 parttable
|
||||
Part Amount
|
||||
@@ -1039,3 +1135,27 @@ Item Number: (or hit Return key to exit.) Item=7 Amount=5 Cost=175.00 Value=8
|
||||
Amount Amount: Item Number: (or hit Return key to exit.)
|
||||
$ grep '^7' inv
|
||||
7 9 175.00 1575 scales
|
||||
## ===== 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
|
||||
Item Amount Cost Value Description
|
||||
---- ------ ---- ----- -----------
|
||||
2 100 0.50 50.00 test tubes
|
||||
5 99 2.45 242.55 cleaning cloth
|
||||
## the same chosen rows go on into total
|
||||
$ pick -m -k 'down space down down space enter' < inventory | total Amount Value
|
||||
Item Amount Cost Value Description
|
||||
---- ------ ---- ----- -----------
|
||||
199 292.55
|
||||
## filter with / (type, enter), then choose the row that is left
|
||||
$ pick -k '/clamps enter enter' < inventory
|
||||
Item Amount Cost Value Description
|
||||
---- ------ ---- ----- -----------
|
||||
3 6 8.00 48.00 clamps
|
||||
## Esc cancels: nothing on stdout, exit status 1
|
||||
$ pick -k esc < inventory ; echo "status $?"
|
||||
pick: nothing chosen
|
||||
status 1
|
||||
|
||||
@@ -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
|
||||
@@ -72,8 +72,12 @@ end
|
||||
|
||||
Rdb.register('record', 'Find and output a record from a table', 'record number < table') do |args|
|
||||
die 'usage: record number < table' unless args.size == 1
|
||||
die "'#{args[0]}' is not a record number (1, 2, 3 ... or -1 for the last)" unless args[0].match?(/\A[+-]?\d+\z/)
|
||||
|
||||
# 0 and 1 are the first record; -1 is the last, -2 the one before it ...
|
||||
t = table_in
|
||||
row = t.rows[args[0].to_i - 1]
|
||||
n = args[0].to_i
|
||||
row = n.negative? ? (t.rows[n] if -n <= t.rows.size) : t.rows[[n, 1].max - 1]
|
||||
t.with(rows: row ? [row] : []).write
|
||||
end
|
||||
|
||||
|
||||
+3
-2
@@ -81,9 +81,10 @@ end
|
||||
# ---- manual pages ---------------------------------------------------------------
|
||||
|
||||
def troff_clean(str)
|
||||
str.gsub(/\\f[BIRP]/, '').gsub('\\e', '\\').gsub('\\(em', ' -- ').gsub('\\(aa', "'")
|
||||
# a literal backslash (\\ or \e) is parked in \0 so that later rules cannot see it
|
||||
str.gsub('\\\\', "\0").gsub(/\\f[BIRP]/, '').gsub('\\e', "\0").gsub('\\(em', ' -- ').gsub('\\(aa', "'")
|
||||
.gsub('\\-', '-').gsub('\\&', '').gsub('\\*(UX', 'UNIX').gsub('``', '"').gsub("''", '"').gsub('\\ ', ' ')
|
||||
.gsub(/\\\(..|\\\*\(..|\\[a-z]/, '').gsub('\\\\', '\\')
|
||||
.gsub(/\\\(..|\\\*\(..|\\[a-z]/, '').gsub("\0", '\\')
|
||||
end
|
||||
|
||||
def render_man(src)
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rdb
|
||||
VERSION = '0.3.0'
|
||||
VERSION = '0.6.0'
|
||||
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
|
||||
@@ -59,6 +59,17 @@ you see output in the following format:
|
||||
.DM
|
||||
Item 1 is rubber gloves
|
||||
.DE
|
||||
.SH "Record Numbers"
|
||||
.I "(Ruby re-creation only.)"
|
||||
Records are counted from 1.
|
||||
The numbers 0 and 1 both mean the first record.
|
||||
A negative number counts from the end:
|
||||
.B \-1
|
||||
is the last record,
|
||||
.B \-2
|
||||
the one before it, and so on.
|
||||
A number beyond either end gives a table without records.
|
||||
Anything that is not a whole number is an error.
|
||||
.SH "See Also"
|
||||
.B
|
||||
row,
|
||||
|
||||
+26
@@ -191,6 +191,32 @@ Names that are also
|
||||
reserved words or are followed by
|
||||
.B (
|
||||
are never taken for columns.
|
||||
.SH "Using select in a Shell"
|
||||
.I "(Ruby re-creation only.)"
|
||||
.B select
|
||||
is another name for
|
||||
.BR row ,
|
||||
but in
|
||||
.BR bash ,
|
||||
.B zsh
|
||||
and
|
||||
.B ksh
|
||||
``select'' is a reserved word of the shell.
|
||||
The shell recognizes it before it looks for a program, so
|
||||
.DM
|
||||
select 'Cost > 5' < inventory
|
||||
.DE
|
||||
.PP
|
||||
stops with a syntax error at the ``<''.
|
||||
Use the name
|
||||
.B row
|
||||
instead, or protect the word from the shell:
|
||||
.DM
|
||||
row 'Cost > 5' < inventory
|
||||
\\select 'Cost > 5' < inventory
|
||||
command select 'Cost > 5' < inventory
|
||||
rdb select 'Cost > 5' < inventory
|
||||
.DE
|
||||
.SH "See Also"
|
||||
.B
|
||||
compute,
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'test_helper'
|
||||
require 'pty'
|
||||
require_relative '../lib/rdb'
|
||||
|
||||
class SelectionTest < RdbCase
|
||||
def test_record_zero_and_one_are_the_first_and_negatives_count_from_the_end
|
||||
{ '0' => '1', '1' => '1', '2' => '3', '3' => '6', '-1' => '6', '-2' => '3', '-3' => '1', '+2' => '3' }.each do |n, item|
|
||||
assert_equal [[item]], data(rdb('record', n, file: 'owners')).map { |r| r.first(1) }, "record #{n}"
|
||||
end
|
||||
end
|
||||
|
||||
def test_record_beyond_either_end_is_an_empty_table
|
||||
%w[4 9 -4 -9].each { |n| assert_equal [], data(rdb('record', n, file: 'owners')), "record #{n}" }
|
||||
end
|
||||
|
||||
def test_record_rejects_junk
|
||||
%w[abc 2.5 1e3].each do |n|
|
||||
out, err, st = run3('record', n, stdin: File.read(File.join(@dir, 'owners')))
|
||||
refute st.success?, "record #{n} should fail"
|
||||
assert_equal '', out
|
||||
assert_match(/not a record number/, err)
|
||||
end
|
||||
end
|
||||
|
||||
def test_row_numeric_condition
|
||||
out = rdb('row', 'Cost > 5', file: 'inventory')
|
||||
assert_equal %w[3 6 7], data(out).map(&:first)
|
||||
@@ -523,3 +544,238 @@ 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
|
||||
|
||||
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
|
||||
|
||||
class TaxTablesTest < RdbCase
|
||||
def tax(income, table) = rdb('tax', income.to_s, file: table).to_i
|
||||
|
||||
def test_at_2026
|
||||
assert_equal 0, tax(13_539, 'taxAT2026')
|
||||
assert_equal 11_447, tax(50_000, 'taxAT2026') # 6 030.40 + 13 542 * 40 %
|
||||
assert_equal 33_818, tax(100_000, 'taxAT2026')
|
||||
end
|
||||
|
||||
def test_ch_federal_2026
|
||||
assert_equal 0, tax(15_200, 'taxCH2026')
|
||||
assert_equal 401, tax(50_000, 'taxCH2026')
|
||||
assert_equal 2684, tax(100_000, 'taxCH2026')
|
||||
assert_equal 91_310, tax(794_000, 'taxCH2026')
|
||||
end
|
||||
|
||||
def test_li_landessteuer_2026
|
||||
assert_equal 996, tax(50_000, 'taxLI2026') # 0,04 * x - 1 004
|
||||
assert_equal 3256, tax(100_000, 'taxLI2026') # 0,05 * x - 1 744
|
||||
assert_equal 10_464, tax(211_400, 'taxLI2026')
|
||||
assert_equal 0, tax(15_855, 'taxLI2026')
|
||||
end
|
||||
|
||||
# § 32a EStG: the table is a straight-line approximation of the curved zones
|
||||
def test_de_2026_follows_the_formula
|
||||
[12_348, 14_000, 17_799, 20_000, 33_333, 50_000, 69_878, 100_000, 277_825, 300_000].each do |x|
|
||||
exact = if x <= 12_348 then 0
|
||||
elsif x <= 17_799 then y = (x - 12_348) / 10_000.0; ((914.51 * y) + 1400) * y
|
||||
elsif x <= 69_878 then z = (x - 17_799) / 10_000.0; (((173.10 * z) + 2397) * z) + 1034.87
|
||||
elsif x <= 277_825 then (0.42 * x) - 11_135.63
|
||||
else (0.45 * x) - 19_470.38
|
||||
end
|
||||
assert_in_delta exact.floor, tax(x, 'taxDE2026'), 1, "income #{x}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Writes the 2026 income tax tables in the format of `tax` (income, tax, percent)
|
||||
# into demo/. Sources: § 32a EStG (DE), § 33 EStG (AT), Tarif 2026 Art. 36 DBG (CH,
|
||||
# ESTV Rundschreiben 2-215-D-2025), Art. 19 SteG LR 640.0, Fassung 01.07.2026 (LI).
|
||||
# ruby tools/gen_tax_tables.rb
|
||||
|
||||
DEMO = File.expand_path('../demo', __dir__)
|
||||
|
||||
def write_table(name, points)
|
||||
rows = points.map { |i, t, p| [i.to_s, format('%.2f', t), format('%.6f', p).sub(/0+\z/, '').sub(/\.\z/, '')] }
|
||||
File.write(File.join(DEMO, name), "income\ttax\tpercent\n------\t---\t-------\n#{rows.map { |r| r.join("\t") }.join("\n")}\n")
|
||||
end
|
||||
|
||||
# --- Germany: single assessment, § 32a Abs. 1 (the zones 2 and 3 are curves, so
|
||||
# the table approximates them with short straight pieces; error below one euro)
|
||||
def de_tax(x)
|
||||
if x <= 12_348 then 0.0
|
||||
elsif x <= 17_799 then y = (x - 12_348) / 10_000.0; ((914.51 * y) + 1400) * y
|
||||
elsif x <= 69_878 then z = (x - 17_799) / 10_000.0; (((173.10 * z) + 2397) * z) + 1034.87
|
||||
elsif x <= 277_825 then (0.42 * x) - 11_135.63
|
||||
else (0.45 * x) - 19_470.38
|
||||
end
|
||||
end
|
||||
|
||||
de = [12_348] + (12_598..17_798).step(250).to_a + [17_799] + (18_049..69_549).step(250).to_a + [69_879, 277_826]
|
||||
pts = de.each_with_index.map do |i, n|
|
||||
nxt = de[n + 1]
|
||||
slope = nxt ? (de_tax(nxt) - de_tax(i)) / (nxt - i) * 100 : 45.0
|
||||
[i, de_tax(i), slope]
|
||||
end
|
||||
write_table('taxDE2026', [[0, 0.0, 0.0]] + pts)
|
||||
|
||||
# --- Austria: § 33 EStG, the tariff before the Absetzbeträge
|
||||
at = [[13_539, 20], [21_992, 30], [36_458, 40], [70_365, 48], [104_859, 50], [1_000_000, 55]]
|
||||
acc = 0.0
|
||||
prev = [0, 0]
|
||||
pts = [[0, 0.0, 0.0]]
|
||||
at.each do |limit, rate|
|
||||
acc += (limit - prev[0]) * prev[1] / 100.0
|
||||
pts << [limit, acc, rate]
|
||||
prev = [limit, rate]
|
||||
end
|
||||
write_table('taxAT2026', pts)
|
||||
|
||||
# --- Switzerland: direct federal tax, Art. 36 DBG, single persons (without canton and municipality)
|
||||
ch = [[0, 0, 0], [15_200, 0, 0.77], [33_200, 138.60, 0.88], [43_500, 229.20, 2.64], [58_000, 612.00, 2.97],
|
||||
[76_200, 1152.50, 5.94], [82_100, 1502.95, 6.60], [108_900, 3271.75, 8.80], [141_500, 6140.55, 11.00],
|
||||
[185_100, 10_936.55, 13.20], [793_900, 91_298.15, 11.85], [794_000, 91_310.00, 11.50]]
|
||||
write_table('taxCH2026', ch)
|
||||
|
||||
# --- Liechtenstein: Landessteuer, Art. 19 Abs. 1 Bst. a SteG; the municipal surcharge
|
||||
# (150 % to 250 % of it) comes on top: total = Landessteuer * (1 + surcharge / 100)
|
||||
li_rates = [[15_900, 1], [21_100, 3], [42_300, 4], [74_000, 5], [105_700, 6], [137_400, 6.5], [169_200, 7], [211_400, 8]]
|
||||
acc = 0.0
|
||||
prev = [15_855, 0]
|
||||
pts = [[0, 0.0, 0.0]]
|
||||
li_rates.each do |limit, rate|
|
||||
acc += (limit - prev[0]) * prev[1] / 100.0 if prev[1].positive?
|
||||
pts << [limit, acc, rate]
|
||||
prev = [limit, rate]
|
||||
end
|
||||
write_table('taxLI2026', pts)
|
||||
Reference in New Issue
Block a user