diff --git a/CHANGELOG.md b/CHANGELOG.md index 476457a..ff23f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in ## [Unveröffentlicht] +## [0.4.0] - 2026-10-11 + ### Hinzugefügt +- `mask`: Eingabemaske mit eigener Oberfläche (Felder mit Breite, Schaltflächen, Rahmen `-b`, `-s` zeigt das Bild ohne Terminal). Schreibt die Eingabe als Tabelle auf die Standardausgabe. Die Vorlage kommt wie bei `screen` von der Standardeingabe (`mask < entry`). Dazu `man/mask.man`, `demo/maskform` und Tests (auch mit Pseudo-Terminal). - `demo/entry`: beschriftete Eingabemaske für `screen` (README erklärt den Unterschied zu `form`). ## [0.3.0] - 2026-10-11 diff --git a/README.md b/README.md index 608b1c7..3141665 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,22 @@ ein stiller Kommentar. Neue Befehle brauchen hier ein Beispiel, sonst schlägt d `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: `` oder `` ist ein Feld (mit Breite), `[ Speichern ]` eine Schaltfläche, +`[ Abbrechen ]` (oder Cancel, Abort, Quit) bricht ab, `` fügt Befehlsausgabe ein. Kommt eine Spalte mehrfach +vor, ist es ein Feld. Tab/Pfeile wechseln, Enter bestätigt, Esc bricht ab (Status 1, keine Ausgabe). + ## Befehle | Gruppe | Befehle | @@ -100,7 +116,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` `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` | diff --git a/bin/mask b/bin/mask new file mode 120000 index 0000000..9570433 --- /dev/null +++ b/bin/mask @@ -0,0 +1 @@ +/home/wkh/Development/Coherent/rdb/bin/rdb \ No newline at end of file diff --git a/demo/entry b/demo/entry index d489ff0..a27c03f 100644 --- a/demo/entry +++ b/demo/entry @@ -4,3 +4,6 @@ Name : Company: Street: City : State : ZIP: Phone : + +[Speichern] [Abbrechen] + diff --git a/demo/maske.sh b/demo/maske.sh new file mode 100644 index 0000000..08cbd04 --- /dev/null +++ b/demo/maske.sh @@ -0,0 +1,24 @@ +: paint crt screen +exec 1>/dev/tty +cat <&2 +echo "Name Company Street City State ZIP Phone" +echo "---- ------- ------ ---- ----- --- -----" +: append row +echo "$Name $Company $Street $City $State $ZIP $Phone" diff --git a/demo/maskform b/demo/maskform new file mode 100644 index 0000000..b6de3ff --- /dev/null +++ b/demo/maskform @@ -0,0 +1,9 @@ +Mail List Entry + +Name : +Company: +Street : +City : State: ZIP: +Phone : + + [ Save ] [ Cancel ] diff --git a/demo/testall.cmds b/demo/testall.cmds index 9c2c381..7cfc6a4 100644 --- a/demo/testall.cmds +++ b/demo/testall.cmds @@ -187,6 +187,11 @@ letter form < maillist | head -5 ## 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 diff --git a/demo/testall.old b/demo/testall.old index 1c3194c..48f8312 100644 --- a/demo/testall.old +++ b/demo/testall.old @@ -745,6 +745,9 @@ Name : Company: Street: City : State : ZIP: Phone : + +[Speichern] [Abbrechen] + $ screen < entry : paint crt screen exec 1>/dev/tty @@ -755,6 +758,8 @@ Name : Company: Street: City : State : ZIP: Phone : + +[Speichern] [Abbrechen] SCREEN : read user input cursor 3 9 ; read Name; @@ -770,6 +775,42 @@ 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 : +Company: +Street : +City : State: ZIP: +Phone : + + [ 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 @@ -804,9 +845,9 @@ Item Amount Cost Value Description ## tabletom4 / tabletosed / tabletostruct were shown above ## ===== Help and the terminal ===== $ rdb --version -rdb 0.3.0 +rdb 0.4.0 $ howmany -134 +135 $ whatis row row - make a new table where rows match logical condition row row 'column condition column-or-value ..' < table diff --git a/lib/rdb/cmd/mask.rb b/lib/rdb/cmd/mask.rb new file mode 100644 index 0000000..ef49ec0 --- /dev/null +++ b/lib/rdb/cmd/mask.rb @@ -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 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 the form may contain +# a field 20 characters wide (otherwise as wide as the <...>) +# [ Save ] a button; Cancel, Abort, Quit or Abbrechen cancel, any other accepts +# 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 " 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 diff --git a/lib/rdb/version.rb b/lib/rdb/version.rb index a6d6fb7..c365d07 100644 --- a/lib/rdb/version.rb +++ b/lib/rdb/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Rdb - VERSION = '0.3.0' + VERSION = '0.4.0' end diff --git a/man/mask.man b/man/mask.man new file mode 100644 index 0000000..8703758 --- /dev/null +++ b/man/mask.man @@ -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\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\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\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 : +City : State: + + [ 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 diff --git a/test/rdb_test.rb b/test/rdb_test.rb index 15aa290..0934f44 100644 --- a/test/rdb_test.rb +++ b/test/rdb_test.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require_relative 'test_helper' +require 'pty' +require_relative '../lib/rdb' class SelectionTest < RdbCase def test_row_numeric_condition @@ -523,3 +525,96 @@ class ActTest < RdbCase assert_equal [%w[b]], data(out) end end + +class MaskTest < RdbCase + def form(text, frame: false) = Rdb::Mask::Form.parse(text, frame: frame) + + def type(session, *keys) + keys.each { |k| k.start_with?("\e") || k.size == 1 ? session.feed(k) : k.each_char { |c| session.feed(c) } } + end + + def test_parse_fields_buttons_and_width + f = form("Name: < 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(" \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(" [ 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(" [ Save ] [ Abbrechen ]\n")) + s.feed("\e[Z") + assert_equal :cancel, s.feed(' ') + assert_equal :cancel, Rdb::Mask::Session.new(form("\n")).feed("\e") + end + + def test_enter_on_last_field_accepts_without_buttons + s = Rdb::Mask::Session.new(form(" \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(" \n")) + type(s, 'xy') + assert_equal({ 'A' => 'xy' }, s.values) + assert_equal %w[A], form(" \n").names + end + + def test_show_prints_picture_without_terminal + write('f', "Name: \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: \nCity: \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: \n[ Save ] [ Cancel ]\n") + table, status = run_on_pty("Ann", "\e[Z", "\r") + assert_equal 1, status + assert_equal '', table + end +end