diff --git a/CHANGELOG.md b/CHANGELOG.md index ff23f45..50e4ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in ## [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 diff --git a/README.md b/README.md index 3141665..5416364 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,21 @@ In der Vorlage: `` oder `` ist ein Feld (mit Breite), `[ Spei `[ 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). +## 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 | Gruppe | Befehle | @@ -116,7 +131,7 @@ vor, ist es ein Feld. Tab/Pfeile wechseln, Enter bestätigt, Esc bricht ab (Stat | 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` `mask` `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` | diff --git a/bin/pick b/bin/pick new file mode 120000 index 0000000..9570433 --- /dev/null +++ b/bin/pick @@ -0,0 +1 @@ +/home/wkh/Development/Coherent/rdb/bin/rdb \ No newline at end of file diff --git a/demo/testall.cmds b/demo/testall.cmds index 7cfc6a4..bf8b5bc 100644 --- a/demo/testall.cmds +++ b/demo/testall.cmds @@ -265,3 +265,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 $?" diff --git a/demo/testall.old b/demo/testall.old index 48f8312..d1f7060 100644 --- a/demo/testall.old +++ b/demo/testall.old @@ -845,9 +845,9 @@ Item Amount Cost Value Description ## tabletom4 / tabletosed / tabletostruct were shown above ## ===== Help and the terminal ===== $ rdb --version -rdb 0.4.0 +rdb 0.5.0 $ howmany -135 +136 $ whatis row row - make a new table where rows match logical condition row row 'column condition column-or-value ..' < table @@ -1107,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.) $ 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 diff --git a/lib/rdb/cmd/pick.rb b/lib/rdb/cmd/pick.rb new file mode 100644 index 0000000..861724b --- /dev/null +++ b/lib/rdb/cmd/pick.rb @@ -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 diff --git a/lib/rdb/version.rb b/lib/rdb/version.rb index c365d07..ce9e3c4 100644 --- a/lib/rdb/version.rb +++ b/lib/rdb/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Rdb - VERSION = '0.4.0' + VERSION = '0.5.0' end diff --git a/man/pick.man b/man/pick.man new file mode 100644 index 0000000..3487670 --- /dev/null +++ b/man/pick.man @@ -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 diff --git a/test/rdb_test.rb b/test/rdb_test.rb index 0934f44..64e5c7a 100644 --- a/test/rdb_test.rb +++ b/test/rdb_test.rb @@ -618,3 +618,108 @@ class MaskTest < RdbCase 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