cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Pipe mode in the UI: standard input as a sheet, quitting sends a table

Dami committed 0cb910c
012 - reads standard input in the background into an unsaved sheet named
stdin. In a pipeline, the status line says what Ctrl+Q sends and as
what, and Quit asks: send the selection or the sheet, or quit without
sending; File and the palette offer Quit and send selection and Quit and
send sheet only there.
commit
0cb910c76f100bac97c7f2bce9c1de028cb9e91f
parent
d270e2425ec48e0befda0172c5f99e544e2ee2e3
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.6 of 3, Jev's estimate
13 files changed, +361 -11
Mdocs/contributing/architecture.md10+-
Minternal/ui/commands.go4+
Minternal/ui/dialog.go6+-
Minternal/ui/file.go2+-
Minternal/ui/menubar.go2+-
Minternal/ui/menuoverlay.go17+++-
Minternal/ui/model.go3+-
Minternal/ui/palette.go4+-
Minternal/ui/panel.go1+
Ainternal/ui/pipe.go146+++++++++++++++++++++++++++++
Ainternal/ui/pipe_test.go152++++++++++++++++++++++++++++++
Minternal/ui/transfer.go4+
Minternal/ui/transfer/transfer.go21++++
M docs/contributing/architecture.md +6 -4
@@ -9,7 +9,7 @@ sidebar_position: 2
99 Lip Gloss v2.1010 1111 ```12-cmd/012 entry point: flags and config, 012 config, 012 serve, JEV setup, opening or importing a file12+cmd/012 entry point: flags and config, 012 config, 012 serve, JEV setup, opening or importing a file, 012 - and --pipe on the terminal1313 internal/config the config file and the registry of options1414 internal/sheet the engine: cells, recalculation, undo, files, names, pivots1515 internal/functions the function library: the FuncDef table, evaluation, decimal arithmetic, JEV questions
@@ -17,7 +17,8 @@ internal/value cell values, number formats, typed-entry parsing, the clock
1717 internal/formula the formula language: references, lexer, parser, printer, rewriting1818 internal/numfmt number formats, rounding, General, date serials1919 internal/locale the locales: separators, date order, currency, formula separators20-internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk120+internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk1, JSON, NUON; tables on streams21+internal/nuon nushell's object notation: typed values, tables read a row at a time, written back2122 internal/chart chart layout, text rendering and kitty image encoding2223 internal/jev the API key's resolution, answer cache and TypeSafe client2324 internal/keyring the OS credential store the API key lives in
@@ -374,7 +375,7 @@ draw. The components:
374375 | overlays | `overlay.Overlay` | whatever has taken over input: menus (`menuoverlay.go`), the palette and pickers (package `picker`, with `palette.go`, `names.go`), the command line (package `cmdline`), the theme picker (package `themepicker`), the find bar (package `findbar`), the filter picker (package `filterpick`, with `filter.go`), the sort bar (package `sortbar`, with `sort.go`), choice bars (package `choicebar`, with `dialog.go`), the chart editor and selection (`charteditor.go`, `chartsel.go`), the pivot editor (`pivoteditor.go`, `pivotactions.go`), the rules panel (package `rules`, with `rules.go`), the shortcuts (package `shortcuts`, with `help.go`) |375376 | sheet tabs | `tabstrip.Strip` | where each sheet was left, the tab strip's scroll and layout (package `tabstrip`); what clicks on it do (`tabstrip.go`) |376377 | mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |377-| import | `transfer.Transfer` | the import in progress, its progress display and cancelling (package `transfer`); choosing and placing imports (`transfer.go`, `importplace.go`) |378+| import | `transfer.Transfer`, `pipeState` | the import in progress, its progress display and cancelling (package `transfer`); choosing and placing imports (`transfer.go`, `importplace.go`); standard input read as a sheet, and what a pipeline gets on quitting (`pipe.go`) |378379 | macros | `recorder`, `macroState` | a recording in progress (`macrorec.go`); a macro running, trust in the file's macros (`macrorun.go`); what scripts act on (`macrohost.go`, `macrohostnav.go`); Data > Macros and the manager (`macro.go`, `macromanage.go`) |379380 | others | `clipboard`, `trace`, `chartState`, `jevRunner`, `terminal`, `session` | what Ctrl+V pastes, a trace being shown, chart commands' target, JEV questions in flight, what the terminal supports and the chart images sent to it, what outlasts the file open (the `:` history, whether keys can be held: `keyboard.go`) |380381
@@ -466,7 +467,8 @@ menu all come from the table. Importers build a new workbook through the
466467 engine's public API with a shared `builder`, which keeps text as text,467468 falls back to a formula's cached value when it can't be translated, and468469 counts what didn't fit. They stream: CSV and TSV are read record by469-record, Parquet a batch of rows at a time, SQLite a row at a time, and470+record, NUON and JSON a row at a time (`nuon.Reader`, which yields rows471+from a pipe as they arrive), Parquet a batch of rows at a time, SQLite a row at a time, and470472 Parquet files and SQLite tables stop at the sheet's last row, taking the471473 number of rows left out from the file. XLSX is read by 012's own472474 SpreadsheetML reader on `archive/zip` and `encoding/xml`: the workbook,
M internal/ui/commands.go +4 -0
M internal/ui/dialog.go +5 -1
M internal/ui/file.go +1 -1
M internal/ui/menubar.go +1 -1
M internal/ui/menuoverlay.go +16 -1
M internal/ui/model.go +2 -1
M internal/ui/palette.go +2 -2
M internal/ui/panel.go +1 -0
A internal/ui/pipe.go +146 -0
@@ -0,0 +1,146 @@
1+package ui2+3+import (4+ "io"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/fileio"10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/transfer"12+)13+14+// 012 as a stage in a pipeline: 012 - reads a table from standard input15+// into a new, unsaved sheet named stdin, and 012 --pipe also writes a16+// table to standard output when it quits: the selection or the sheet,17+// as the user chooses, in the input's format unless another was asked18+// for. The screen is the terminal's own (cmd/012 opens it), so nothing19+// else reaches standard output. Quitting asks what to send, and20+// quitting without sending lets the pipeline know by the exit status.21+22+// StdinName is what a table read from standard input is called: its23+// sheet's name.24+const StdinName = "stdin"25+26+// pipeState is the pipeline 012 is a stage of, if any.27+type pipeState struct {28+ in io.Reader // standard input, read as the program starts; nil once read29+30+ on bool // --pipe: quitting writes a table to standard output31+ to fileio.Kind // the format asked for (--to), or 0 for the input's32+33+ // Set by a quit that sends: what to write, and in which format.34+ sent *fileio.Snapshot35+ kind fileio.Kind36+}37+38+// ReadStdin sets a table to read from r, standard input, as the program39+// starts, into a new unsaved sheet named stdin.40+func (m *Model) ReadStdin(r io.Reader) { m.pipe.in = r }41+42+// SetPipe makes quitting send a table to standard output, in format to43+// (a text format; 0 for the input's, or NUON when the input isn't text).44+func (m *Model) SetPipe(to fileio.Kind) { m.pipe.on, m.pipe.to = true, to }45+46+// Piped is what quitting sent: the table and its format, or false when47+// the user quit without sending.48+func (m *Model) Piped() (*fileio.Snapshot, fileio.Kind, bool) {49+ return m.pipe.sent, m.pipe.kind, m.pipe.sent != nil50+}51+52+func init() {53+ register(54+ &command{id: "pipe.send_selection", macro: macroNever, title: "Quit and send selection",55+ desc: "Quit, writing the selection to standard output for the next command in the pipeline",56+ enabled: func(m *Model) bool { return m.pipe.on && m.hasRange() },57+ hidden: func(m *Model) bool { return !m.pipe.on },58+ run: func(m *Model) tea.Cmd { return m.send(m.selection()) }},59+ &command{id: "pipe.send_sheet", macro: macroNever, title: "Quit and send sheet",60+ desc: "Quit, writing the sheet's data to standard output for the next command in the pipeline",61+ enabled: func(m *Model) bool { return m.pipe.on },62+ hidden: func(m *Model) bool { return !m.pipe.on },63+ run: func(m *Model) tea.Cmd { return m.send(sheet.Rect{}) }},64+ )65+}66+67+// startStdin starts reading standard input, if it's to be read.68+func (m *Model) startStdin() tea.Cmd {69+ r := m.pipe.in70+ if r == nil {71+ return nil72+ }73+ m.pipe.in = nil74+ return m.xfer.StartReader(StdinName, r, fileio.Options{Locale: m.locale()}, m.spans.Parent())75+}76+77+// streamed opens a table read from standard input: a new spreadsheet78+// with no file, unsaved.79+func (m *Model) streamed(msg transfer.ImportedMsg) {80+ m.reset(msg.Res.Sheet, "")81+ m.xfer.Kind = msg.Res.Kind82+ m.changed, m.saved = true, -183+ m.note = "Read " + transfer.Rows(msg.Res.Rows) + " of " + msg.Res.Kind.String() + " from standard input"84+ if len(msg.Res.Notes) > 0 {85+ m.note += "; " + strings.Join(msg.Res.Notes, "; ")86+ }87+}88+89+// pipeKind is the format sent: the one asked for, or the input's when90+// it's text, or NUON.91+func (m *Model) pipeKind() fileio.Kind {92+ switch {93+ case m.pipe.to != 0:94+ return m.pipe.to95+ case m.xfer.Kind.IsText():96+ return m.xfer.Kind97+ }98+ return fileio.NUON99+}100+101+// sendWhat names what quitting would send: "B2:D9" or "the sheet".102+func (m *Model) sendWhat() string {103+ if m.hasRange() {104+ return m.selection().String()105+ }106+ return "the sheet"107+}108+109+// askSend is Quit in a pipeline: send the selection, if there is one,110+// or the sheet, or quit without sending.111+func (m *Model) askSend() tea.Cmd {112+ var choices []choice113+ if m.hasRange() {114+ choices = append(choices,115+ choice{key: "enter", label: "Send selection", run: func(m *Model) tea.Cmd { return m.send(m.selection()) }},116+ choice{key: "s", label: "Send sheet", run: func(m *Model) tea.Cmd { return m.send(sheet.Rect{}) }})117+ } else {118+ choices = append(choices, choice{key: "enter", label: "Send sheet", run: func(m *Model) tea.Cmd { return m.send(sheet.Rect{}) }})119+ }120+ choices = append(choices,121+ choice{key: "d", label: "Don't send", run: (*Model).exit},122+ choice{key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }})123+ m.ask(question{124+ msg: "Send " + m.sendWhat() + " to the pipeline as " + m.pipeKind().String() + "?",125+ desc: "Quitting without sending exits with status 1, so the pipeline stops",126+ choices: choices,127+ })128+ return nil129+}130+131+// send quits, keeping r (the sheet's data when zero) to write to132+// standard output once the terminal is given back.133+func (m *Model) send(r sheet.Rect) tea.Cmd {134+ m.pipe.sent = fileio.Snap(m.sheet, r, m.sheet.Name())135+ m.pipe.kind = m.pipeKind()136+ return m.exit()137+}138+139+// pipeStatus is what the status line says in a pipeline: the key that140+// sends, what and as what.141+func (m *Model) pipeStatus() string {142+ if !m.pipe.on {143+ return ""144+ }145+ return " " + m.th.KeyHints(m.shortcut("quit"), "send "+m.sendWhat()+" as "+m.pipeKind().String())146+}
A internal/ui/pipe_test.go +152 -0
@@ -0,0 +1,152 @@
1+package ui2+3+import (4+ "slices"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/fileio"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+const filesNUON = "[[name, size]; [a, 1646b], [b, 2kb]]"15+16+// stdinModel is a model that has read text from standard input, in a17+// pipeline (to, or the input's format when 0) when pipe is set.18+func stdinModel(t *testing.T, text string, pipe bool, to fileio.Kind) *Model {19+ t.Helper()20+ m := newModel()21+ if pipe {22+ m.SetPipe(to)23+ }24+ m.ReadStdin(strings.NewReader(text))25+ run(m, m.startStdin())26+ if m.xfer.Busy() {27+ t.Fatal("still reading")28+ }29+ return m30+}31+32+func TestStdinOpensAnUnsavedSheet(t *testing.T) {33+ m := stdinModel(t, filesNUON, false, 0)34+ if m.sheet.Name() != StdinName || !m.changed || m.filename != "" || m.xfer.Source != "" {35+ t.Errorf("sheet %q, changed %v, file %q, source %q", m.sheet.Name(), m.changed, m.filename, m.xfer.Source)36+ }37+ if got := line(m, contextLine); !strings.Contains(got, "Read 3 rows of NUON from standard input") {38+ t.Errorf("context line %q", got)39+ }40+ if got := sheet.FormatText(m.sheet.Value(addr("B2")), m.sheet.DisplayFormat(addr("B2"))); got != "1.6 kB" {41+ t.Errorf("B2 shows %q", got)42+ }43+ press(t, m, "<ctrl+q>")44+ if got := line(m, contextLine); !strings.Contains(got, "You have unsaved changes.") {45+ t.Errorf("quitting outside a pipeline: %q", got)46+ }47+ if st := line(m, m.height-1); strings.Contains(st, "send") {48+ t.Errorf("status line outside a pipeline: %q", st)49+ }50+}51+52+func TestPipeSendsTheSheet(t *testing.T) {53+ m := stdinModel(t, filesNUON, true, 0)54+ if st := line(m, m.height-1); !strings.Contains(st, "Ctrl+Q send the sheet as NUON") {55+ t.Errorf("status line %q", st)56+ }57+ press(t, m, "<ctrl+q>")58+ if got := line(m, contextLine); !strings.Contains(got, "Send the sheet to the pipeline as NUON?") ||59+ !strings.Contains(got, "Enter Send sheet") || !strings.Contains(got, "D Don't send") || strings.Contains(got, "Send selection") {60+ t.Errorf("question %q", got)61+ }62+ if _, quit := press(t, m, "<enter>").(tea.QuitMsg); !quit {63+ t.Fatal("didn't quit")64+ }65+ snap, k, ok := m.Piped()66+ if !ok || k != fileio.NUON || snap.Range.String() != "A1:B3" {67+ t.Errorf("sent %v %v %v", snap, k, ok)68+ }69+}70+71+func TestPipeSendsTheSelection(t *testing.T) {72+ m := stdinModel(t, filesNUON, true, fileio.CSV)73+ press(t, m, "<shift+down>")74+ if st := line(m, m.height-1); !strings.Contains(st, "send A1:A2 as CSV") {75+ t.Errorf("status line %q", st)76+ }77+ press(t, m, "<ctrl+q>")78+ if got := line(m, contextLine); !strings.Contains(got, "Send A1:A2 to the pipeline as CSV?") || !strings.Contains(got, "S Send sheet") {79+ t.Errorf("question %q", got)80+ }81+ press(t, m, "<enter>")82+ if snap, k, ok := m.Piped(); !ok || k != fileio.CSV || snap.Range.String() != "A1:A2" {83+ t.Errorf("sent %v %v %v", snap.Range, k, ok)84+ }85+86+ m = stdinModel(t, "a\tb\n1\t2\n", true, 0)87+ press(t, m, "<shift+down>", "<ctrl+q>", "s")88+ if snap, k, ok := m.Piped(); !ok || k != fileio.TSV || snap.Range.String() != "A1:B2" {89+ t.Errorf("sent the sheet? %v %v %v", snap.Range, k, ok)90+ }91+}92+93+func TestPipeQuitWithoutSending(t *testing.T) {94+ m := stdinModel(t, filesNUON, true, 0)95+ press(t, m, "<ctrl+q>", "<esc>")96+ if m.mode != modeReady {97+ t.Errorf("Esc left mode %v", m.mode)98+ }99+ if _, quit := press(t, m, "<ctrl+q>", "d").(tea.QuitMsg); !quit {100+ t.Fatal("didn't quit")101+ }102+ if _, _, ok := m.Piped(); ok {103+ t.Error("sent anyway")104+ }105+}106+107+// TestPipeCommands: the send commands are in File and the palette only108+// in a pipeline, and run from there.109+func TestPipeCommands(t *testing.T) {110+ titles := func(m *Model) []string {111+ var out []string112+ for _, it := range paletteItems(m) {113+ out = append(out, it.Title)114+ }115+ return out116+ }117+ m := newModel()118+ if slices.Contains(titles(m), "Quit and send sheet") || len(m.applicable(menuBar[0].items)) != len(menuBar[0].items)-2 {119+ t.Error("send commands outside a pipeline")120+ }121+ m = stdinModel(t, filesNUON, true, 0)122+ if !slices.Contains(titles(m), "Quit and send sheet") || !slices.Contains(titles(m), "Quit and send selection") {123+ t.Error("send commands missing in a pipeline")124+ }125+ if commands["pipe.send_selection"].available(m) {126+ t.Error("send selection without a selection")127+ }128+ if _, quit := run(m, m.runCommand("pipe.send_sheet")).(tea.QuitMsg); !quit {129+ t.Fatal("didn't quit")130+ }131+ if _, _, ok := m.Piped(); !ok {132+ t.Error("nothing sent")133+ }134+}135+136+// TestPipeSendsAFileArgument: 012 --pipe data.nuon sends in the file's137+// format, and a .012 file as NUON.138+func TestPipeFormatOfAFile(t *testing.T) {139+ m := newModel()140+ m.SetPipe(0)141+ if m.pipeKind() != fileio.NUON {142+ t.Errorf("a .012 sheet sends %v", m.pipeKind())143+ }144+ m.xfer.Kind = fileio.JSON145+ if m.pipeKind() != fileio.JSON {146+ t.Errorf("a JSON file sends %v", m.pipeKind())147+ }148+ m.xfer.Kind = fileio.XLSX149+ if m.pipeKind() != fileio.NUON {150+ t.Errorf("an Excel file sends %v", m.pipeKind())151+ }152+}
M internal/ui/transfer.go +4 -0
M internal/ui/transfer/transfer.go +21 -0