3 Commits
15 changed files with 1111 additions and 11 deletions
+11
View File
@@ -7,6 +7,17 @@ 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 ## [0.3.0] - 2026-10-11
### Hinzugefügt ### Hinzugefügt
+41 -1
View File
@@ -77,6 +77,46 @@ 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 ein stiller Kommentar. Neue Befehle brauchen hier ein Beispiel, sonst schlägt der Test
`test_testall_mentions_every_command` fehl. `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 |
@@ -91,7 +131,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` | | 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` |
Symlink
+1
View File
@@ -0,0 +1 @@
/home/wkh/Development/Coherent/rdb/bin/rdb
Symlink
+1
View File
@@ -0,0 +1 @@
/home/wkh/Development/Coherent/rdb/bin/rdb
+9
View File
@@ -0,0 +1,9 @@
Mail List Entry Form
Name : <Name> Company: <Company>
Street: <Street>
City : <City> State : <State> ZIP: <ZIP>
Phone : <Phone>
[Speichern] [Abbrechen]
+24
View File
@@ -0,0 +1,24 @@
: paint crt screen
exec 1>/dev/tty
cat <<SCREEN
${CLEAR} Mail List Entry Form
Name : Company:
Street:
City : State : ZIP:
Phone :
SCREEN
: read user input
cursor 3 9 ; read Name;
cursor 3 37 ; read Company;
cursor 4 9 ; read Street;
cursor 5 9 ; read City;
cursor 5 37 ; read State;
cursor 5 56 ; read ZIP;
cursor 6 9 ; read Phone;
: output table head
exec 1>&2
echo "Name Company Street City State ZIP Phone"
echo "---- ------- ------ ---- ----- --- -----"
: append row
echo "$Name $Company $Street $City $State $ZIP $Phone"
+9
View File
@@ -0,0 +1,9 @@
Mail List Entry
Name : <Name:24>
Company: <Company:24>
Street : <Street:24>
City : <City:16> State: <State:10> ZIP: <ZIP:9>
Phone : <Phone:16>
[ Save ] [ Cancel ]
+23 -2
View File
@@ -182,8 +182,16 @@ report form < maillist
## label prints mailing labels, letter prints form letters ## label prints mailing labels, letter prints form letters
label < maillist label < maillist
letter form < maillist | head -5 letter form < maillist | head -5
## screen turns a form into a shell script that asks for the fields ## screen turns a form into a shell script that asks for the fields. Use a form with labels
screen < form | head -6 ## 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: a sample report with page headings (the date is cut out)
reportwriter -3 Prices < inventory | grep -v 'Report Date' reportwriter -3 Prices < inventory | grep -v 'Report Date'
## tabletom4 / tabletosed / tabletostruct were shown above ## tabletom4 / tabletosed / tabletostruct were shown above
@@ -257,3 +265,16 @@ bom ; cat bom
## update.inv: change the stock of one item, with record locking (INV names the table) ## 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 cp inventory inv ; printf '7\n9\n\n' | INV=inv update.inv | tail -n +2
grep '^7' inv 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 $?"
+99 -7
View File
@@ -735,14 +735,82 @@ McDonalds
123 Mac Attack 123 Mac Attack
Memphis, TENN 30000 Memphis, TENN 30000
## screen turns a form into a shell script that asks for the fields ## screen turns a form into a shell script that asks for the fields. Use a form with labels
$ screen < form | head -6 ## 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 : paint crt screen
exec 1>/dev/tty exec 1>/dev/tty
cat <<SCREEN 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: a sample report with page headings (the date is cut out)
$ reportwriter -3 Prices < inventory | grep -v 'Report Date' $ reportwriter -3 Prices < inventory | grep -v 'Report Date'
Prices Prices
@@ -777,9 +845,9 @@ Item Amount Cost Value Description
## tabletom4 / tabletosed / tabletostruct were shown above ## tabletom4 / tabletosed / tabletostruct were shown above
## ===== Help and the terminal ===== ## ===== Help and the terminal =====
$ rdb --version $ rdb --version
rdb 0.2.0 rdb 0.5.0
$ howmany $ howmany
134 136
$ whatis row $ whatis row
row - make a new table where rows match logical condition row - make a new table where rows match logical condition
row row 'column condition column-or-value ..' < table row row 'column condition column-or-value ..' < table
@@ -1039,3 +1107,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.) Amount Amount: Item Number: (or hit Return key to exit.)
$ grep '^7' inv $ grep '^7' inv
7 9 175.00 1575 scales 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
+275
View File
@@ -0,0 +1,275 @@
# frozen_string_literal: true
require 'io/console'
require 'open3'
# mask -- a full-screen input form. It reads the same kind of form as `screen`
# (text with <Column> fields) but draws it itself and writes the row typed in as
# a table on the standard output, so it works in a pipe: mask < entry | append t
#
# Besides <Column> the form may contain
# <Column:20> a field 20 characters wide (otherwise as wide as the <...>)
# [ Save ] a button; Cancel, Abort, Quit or Abbrechen cancel, any other accepts
# <!command!> replaced by the first line of the command's output
# Frames are plain text in the form, or drawn around everything with -b.
module Rdb
module Mask
Slot = Struct.new(:row, :col, :width, :name)
Button = Struct.new(:row, :col, :text, :label)
CANCEL = /\A(cancel|abort|quit|abbrechen)\z/i
TOKEN = /<!(.*?)!>|<([^<>!:\n]+?)(?::(\d+))?>|\[ *([^\[\]<>\n]*[^\[\]<>\s]) *\]/
class Form
attr_reader :lines, :slots, :buttons
def self.parse(text, frame: false)
lines = []
slots = []
buttons = []
text.each_line(chomp: true).with_index do |line, row|
out = +''
pos = 0
while (m = TOKEN.match(line, pos))
out << line[pos...m.begin(0)]
if m[1]
out << Open3.capture2('sh', '-c', m[1]).first.lines.first.to_s.chomp
elsif m[2]
width = m[3] ? m[3].to_i : m[0].size
slots << Slot.new(row, out.size, width, m[2].strip)
out << ' ' * width
else
buttons << Button.new(row, out.size, m[0], m[4].strip)
out << m[0]
end
pos = m.end(0)
end
lines << (out << line[pos..].to_s)
end
new(lines, slots, buttons).then { |f| frame ? f.framed : f }
end
def initialize(lines, slots, buttons)
@lines = lines
@slots = slots
@buttons = buttons
end
def names
@slots.map(&:name).uniq
end
# Fields (first slot of each column) and buttons in reading order.
def items
fields = @slots.uniq(&:name).map { |s| [:field, s] }
(fields + @buttons.map { |b| [:button, b] }).sort_by { |_, o| [o.row, o.col] }
end
def framed
w = @lines.map(&:size).max.to_i
lines = ["┌#{'─' * (w + 2)}┐"] + @lines.map { |l| "│ #{l.ljust(w)} │" } + ["└#{'─' * (w + 2)}┘"]
self.class.new(lines,
@slots.map { |s| Slot.new(s.row + 1, s.col + 2, s.width, s.name) },
@buttons.map { |b| Button.new(b.row + 1, b.col + 2, b.text, b.label) })
end
end
# The state of one input session: values, which item has the focus, and the keys.
class Session
attr_reader :values, :focus, :pos
def initialize(form)
@form = form
@items = form.items
@values = form.names.to_h { |n| [n, +''] }
@focus = 0
reset_pos
end
def kind = @items[@focus]&.first
def current = @items[@focus]&.last
def field? = kind == :field
# Feed one key; returns :accept or :cancel when the session ends.
def feed(key)
case key
when "\t", "\e[B", "\e[6~" then move(1)
when "\e[Z", "\e[A", "\e[5~" then move(-1)
when "\r", "\n" then return enter
when "\x03", "\e", "\x04" then return :cancel
when "\e[C" then @pos = [@pos + 1, text.size].min if field?
when "\e[D" then @pos = [@pos - 1, 0].max if field?
when "\e[H", "\e[1~", "\eOH", "\x01" then @pos = 0
when "\e[F", "\e[4~", "\eOF", "\x05" then @pos = text.size if field?
when "\x7f", "\b" then backspace
when "\e[3~" then delete
when "\x15" then clear_field
when ' '
return press unless field?
insert(key)
else insert(key) if key.size == 1 && key.ord >= 32
end
nil
end
# Plain-text picture of the form (no escapes), for `mask -s` and the tests.
def plain
grid = @form.lines.map(&:dup)
@form.slots.each { |s| grid[s.row][s.col, s.width] = '_' * s.width }
grid.join("\n") + "\n"
end
# Escape sequences that paint the whole screen.
def render
out = +"\e[?25l\e[H\e[2J"
@form.lines.each_with_index { |l, r| out << "\e[#{r + 1};1H#{l}" }
cursor = nil
@form.slots.each do |s|
focused = field? && current.name == s.name
start = focused ? view_start(s.width) : 0
shown = @values[s.name][start, s.width].to_s.ljust(s.width)
out << "\e[#{s.row + 1};#{s.col + 1}H\e[#{focused ? 7 : 4}m#{shown}\e[0m"
cursor = [s.row + 1, s.col + 1 + @pos - start] if focused && current.equal?(s)
end
@form.buttons.each do |b|
focused = kind == :button && current.equal?(b)
out << "\e[#{b.row + 1};#{b.col + 1}H\e[#{focused ? 7 : 1}m#{b.text}\e[0m"
cursor = [b.row + 1, b.col + 1] if focused
end
out << "\e[#{cursor[0]};#{cursor[1]}H" if cursor
out << "\e[?25h" if field?
out
end
private
def text = field? ? @values[current.name] : +''
def reset_pos
@pos = field? ? text.size : 0
end
def move(delta)
@focus = (@focus + delta) % [@items.size, 1].max
reset_pos
end
def enter
return press unless field?
last = @focus == @items.size - 1
return :accept if last && @form.buttons.empty?
move(1)
nil
end
def press
current.label.match?(CANCEL) ? :cancel : :accept
end
def insert(key)
return unless field?
text.insert(@pos, key)
@pos += 1
end
def backspace
return unless field? && @pos.positive?
text.slice!(@pos - 1)
@pos -= 1
end
def delete
text.slice!(@pos) if field?
end
def clear_field
return unless field?
text.clear
@pos = 0
end
def view_start(width)
[@pos - width + 1, 0].max
end
end
def self.read_key(io)
c = io.getch
return c unless c == "\e" && io.wait_readable(0.05)
seq = +"\e#{io.getch}"
if seq == "\e["
loop do
c = io.getch
seq << c
break if c.ord.between?(0x40, 0x7e)
end
elsif seq == "\eO"
seq << io.getch
end
seq
end
# Run the form on a terminal; returns the values, or nil when cancelled.
def self.run(form, tty)
session = Session.new(form)
tty.raw do
tty.write "\e[?1049h"
begin
loop do
tty.write session.render
key = begin
read_key(tty)
rescue EOFError
"\x03"
end
result = session.feed(key)
return (result == :accept ? session.values : nil) if result
end
ensure
tty.write "\e[?25h\e[?1049l"
end
end
end
end
end
Rdb.register('mask', 'Full-screen input form that writes a table row', 'mask [-b] [-s] [form] < form > table') do |args|
frame = !args.delete('-b').nil?
show = !args.delete('-s').nil?
die 'usage: mask [-b] [-s] [form] < form > table' if args.size > 1
file = args.first
file.nil? || file == '-' || File.exist?(file) or die("#{file}: no such form")
text = file && file != '-' ? File.read(file) : $stdin.read
form = Rdb::Mask::Form.parse(text, frame: frame)
die "the form has no <field>" if form.names.empty?
session = Rdb::Mask::Session.new(form)
if show
print session.plain
puts '', 'Fields:'
form.slots.each { |s| puts " #{s.name}\trow #{s.row + 1} col #{s.col + 1} width #{s.width}" }
form.buttons.each { |b| puts " [#{b.label}]\trow #{b.row + 1} col #{b.col + 1}#{b.label.match?(Rdb::Mask::CANCEL) ? ' (cancels)' : ''}" }
next
end
tty = begin
File.open('/dev/tty', 'r+')
rescue SystemCallError
die 'no terminal available (mask draws on /dev/tty)'
end
values = Rdb::Mask.run(form, tty)
tty.close
if values.nil?
warn 'mask: cancelled'
exit 1
end
Rdb::Table.new(form.names, [form.names.map { |n| values[n].tr("\t\r\n", ' ') }]).write
end
+218
View File
@@ -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
+1 -1
View File
@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Rdb module Rdb
VERSION = '0.3.0' VERSION = '0.5.0'
end end
+116
View File
@@ -0,0 +1,116 @@
.TH mask "" "" "/rdb Command"
.PC "Full-screen input form that writes a table row"
\fBmask \fB[ -b ] [ -s ] [ \fIform\fB ] < \fIform \fB> \fItable\fR
.PP
.II "mask^input form"
.II "form^full-screen input"
.I "(Ruby re-creation only; the original /rdb has no such command.)"
.PP
The command
.B mask
draws
.I form
on the terminal, lets the user fill in its fields, and writes the result as
a one-row table on the standard output.
Unlike
.BR screen ,
it does not write a shell script: it runs the form itself.
The screen is drawn on
.BR /dev/tty ,
so the table can be redirected or piped:
.DM
mask < maskform > newrow
mask < maskform | append -h customer
.DE
.PP
.I form
is ordinary text.
Everything is shown as it stands, except:
.IP "\fB<Column>\fR" 1.2i
An input field for column
.IR Column .
It is as wide as the text between the angle brackets, including the brackets.
Blanks around the name are ignored, so \fB< City >\fR is column \fBCity\fR.
.IP "\fB<Column:20>\fR"
The same, but 20 characters wide.
Longer input scrolls inside the field.
.IP "\fB[ Save ]\fR"
A button.
A button whose label is
.BR Cancel ,
.BR Abort ,
.B Quit
or
.B Abbrechen
cancels; any other button accepts.
.IP "\fB<!command!>\fR"
Replaced by the first line the shell
.I command
writes.
.PP
If a column occurs more than once in the form, it is one field,
and every place shows what was typed.
The table has one column per field, in the order of first appearance.
Frames are made of characters in the form, for example \fB+---+\fR or
box-drawing characters.
.SH Keys
.nf
Tab, Down, Enter next field or button
Shift-Tab, Up previous field or button
Left, Right, Home, End move inside a field
Backspace, Delete erase; Ctrl-U clears the field
Enter, Space on a button: press it
Enter on the last field of a form without buttons: accept
Esc, Ctrl-C cancel
.fi
.PP
When the user cancels,
.B mask
writes nothing and exits with status 1.
.SH Options
.IP \fB-b\fR 0.5i
Draw a frame around the whole form.
.IP \fB-s\fR
Show the form as a plain picture (fields as underscores) with the position of
each field and button, and exit.
This needs no terminal and is useful to check a form.
.PP
Like
.BR screen ,
.B mask
reads the form from the standard input.
A file name argument is also accepted, and
.B -
means the standard input.
.SH Example
Form
.BR maskform :
.DM
Mail List Entry
Name : <Name:24>
City : <City:16> State: <State:10>
[ Save ] [ Cancel ]
.DE
.PP
.DM
mask -b < maskform > newrow
.DE
.PP
After typing ``Ann'' and ``Rome'', pressing Tab to the buttons and Enter on
.BR Save ,
.B newrow
contains:
.DM
Name City State
---- ---- -----
Ann Rome
.DE
.SH "See Also"
.B
cursor,
report,
screen
.R
.SO rdb
+83
View File
@@ -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
+200
View File
@@ -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
@@ -523,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