cat / 012
clone

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

UI: notebook tabs, Jupyter-style, in package nbview

Dami committed d7774d2
A notebook tab draws its cells one under another: a head with the run
count, name and state, the source soft-wrapped so none of it is hidden
(breaking before pipes), and the output under it: tables fitted and
windowed, records, lists, text and errors, expanded with o and opened
full-screen to sort and filter. Command and edit mode take Jupyter's
keys, each a registered command under Data > Notebook. Cells run one at
a time in the background, reading other cells' outputs, the selection
and ranges of sheets as NUON; stale outputs are marked and a reactive
notebook runs dependents. G sends an output to a sheet, updated through
the change stream whenever the cell runs. The highlighter, completer and
checker are small interfaces with built-in answers.

The nu prompt on the formula bar and its package go away.
commit
d7774d21f7225a03f5b3495f6d28e3f8d01ca242
parent
9d1ef682cdfb1ea09d4e945331f00ac31ac6c280
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.8 of 3, Jev's estimate
44 files changed, +4908 -1420
Mcmd/012/main.go2+-
Mcmd/012/nu_test.go10+-
Mdocs/reference/config.md32+-
Minternal/config/registry.go20+-
Minternal/ui/commands.go7+
Minternal/ui/file.go14+-
Minternal/ui/gridlines.go6-
Minternal/ui/hosts.go16-
Ainternal/ui/lineedit/area.go240+++++++++++
Ainternal/ui/lineedit/area_test.go93++++
Minternal/ui/linked.go2+-
Minternal/ui/menubar.go4+-
Minternal/ui/model.go42+-
Minternal/ui/monochrome_test.go60++-
Ainternal/ui/nbjob.go166++++++++
Ainternal/ui/nbrun.go407+++++++++++++++++++
Ainternal/ui/nbscreen.go290++++++++++++++
Ainternal/ui/nbsend.go201+++++++++
Ainternal/ui/nbview/bench_test.go68+++
Ainternal/ui/nbview/edit.go355+++++++++++++++++
Ainternal/ui/nbview/full.go298++++++++++++++
Ainternal/ui/nbview/keys.go366+++++++++++++++++
Ainternal/ui/nbview/markdown.go167++++++++
Ainternal/ui/nbview/output.go407+++++++++++++++++++
Ainternal/ui/nbview/panel.go112+++++
Ainternal/ui/nbview/syntax.go241+++++++++++
Ainternal/ui/nbview/view.go404+++++++++++++++++++
Ainternal/ui/nbview/view_test.go238+++++++++++
Minternal/ui/notebook.go636++++++++++++++----------------
Minternal/ui/notebook_test.go476++++++++++++++--------
Dinternal/ui/nukey_test.go26-
Dinternal/ui/nuprompt/nuprompt.go331----------------
Dinternal/ui/nuprompt/nuprompt_test.go100-----
Dinternal/ui/nurun.go325---------------
Minternal/ui/overlay.go5+-
Minternal/ui/panel.go67++-
Minternal/ui/pivot.go2+-
Minternal/ui/sort.go6+-
Minternal/ui/tabs.go4+
Minternal/ui/tabstrip/tabstrip.go11+-
Ainternal/ui/theme/code.go42++
Minternal/ui/theme/scheme.go2+-
Minternal/ui/theme/theme.go22+-
Minternal/ui/view.go5+-
M cmd/012/main.go +1 -1
M cmd/012/nu_test.go +5 -5
M docs/reference/config.md +28 -4
@@ -77,6 +77,8 @@ start.
7777 | [`shell`](#shell) | `ask` | `O12_SHELL` |7878 | [`nu-timeout`](#nu-timeout) | `30s` | `O12_NU_TIMEOUT` |7979 | [`nu-config`](#nu-config) | `false` | `O12_NU_CONFIG` |80+| [`nu-save-cell-kb`](#nu-save-cell-kb) | `1024` | `O12_NU_SAVE_CELL_KB` |81+| [`nu-save-notebook-kb`](#nu-save-notebook-kb) | `8192` | `O12_NU_SAVE_NOTEBOOK_KB` |8082 | [`jev-api-key-command`](#jev-api-key-command) | | |8183 | [`jev-credential-store`](#jev-credential-store) | `true` | `O12_JEV_CREDENTIAL_STORE` |8284 | [`jev-base-url`](#jev-base-url) | | `TYPESAFE_BASE_URL` |
@@ -168,7 +170,7 @@ The most cells an import keeps, and a paste or fill writes at once. Numbers and
168170 169171 #### `shell`170172 171-Whether notebook sheets run nushell commands (docs/nushell/notebooks.md). `ask` runs what you type and asks once before running the commands of a file made on another computer; `on` never asks; `off` runs none. Opening a file never runs its commands.173+Whether notebooks run their code cells (docs/nushell/notebooks.md). `ask` runs what you write and asks once before running the cells of a file made on another computer; `on` never asks; `off` runs none. Opening a file never runs its cells.172174 173175 | | |174176 |---|---|
@@ -179,7 +181,7 @@ Whether notebook sheets run nushell commands (docs/nushell/notebooks.md). `ask`
179181 180182 #### `nu-timeout`181183 182-Stop a notebook's command that runs longer than this; 0 lets it run until Esc stops it.184+Stop a notebook cell that runs longer than this; 0 lets it run until Stop (i i) stops it.183185 184186 | | |185187 |---|---|
@@ -190,7 +192,7 @@ Stop a notebook's command that runs longer than this; 0 lets it run until Esc st
190192 191193 #### `nu-config`192194 193-Run notebook commands with your nushell config files (config.nu, env.nu) rather than `nu --no-config-file`, for your own commands and aliases.195+Run notebook cells with your nushell config files (config.nu, env.nu) rather than `nu --no-config-file`, for your own commands and aliases.194196 195197 | | |196198 |---|---|
@@ -199,6 +201,28 @@ Run notebook commands with your nushell config files (config.nu, env.nu) rather
199201 | Environment | `O12_NU_CONFIG` |200202 | Applies | File > Settings > Reload config |201203 204+#### `nu-save-cell-kb`205+206+The largest output of one cell a saved file keeps, in kilobytes of NUON; a larger one is left out and shows `not saved; run to see` when the file is opened.207+208+| | |209+|---|---|210+| Type | number |211+| Default | `1024` |212+| Environment | `O12_NU_SAVE_CELL_KB` |213+| Applies | File > Settings > Reload config |214+215+#### `nu-save-notebook-kb`216+217+How much of all its cells' outputs a saved file keeps, in kilobytes of NUON: the outputs that fit, in the notebook's order.218+219+| | |220+|---|---|221+| Type | number |222+| Default | `8192` |223+| Environment | `O12_NU_SAVE_NOTEBOOK_KB` |224+| Applies | File > Settings > Reload config |225+202226 ### JEV functions203227 204228 #### `jev-api-key-command`
@@ -335,7 +359,7 @@ How many 012 serve sessions may run at once; more are turned away.
335359 336360 #### `serve-shell`337361 338-Let 012 serve sessions run notebooks' nushell commands, as the user 012 serve runs as, following the shell option. Off, served notebooks show their regions but run nothing.362+Let 012 serve sessions run notebooks' code cells, as the user 012 serve runs as, following the shell option. Off, served notebooks show their cells and saved outputs but run nothing.339363 340364 | | |341365 |---|---|
M internal/config/registry.go +13 -7
@@ -119,14 +119,20 @@ var Options = []Option{
119119 "The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is."},120120 121121 {Name: "shell", Kind: Enum, Group: GroupShell, Default: "ask", Values: []string{"off", "ask", "on"}, Env: []string{"O12_SHELL"}, Live: true,122- Desc: "Whether notebook sheets run nushell commands (docs/nushell/notebooks.md). `ask` runs what you type " +123- "and asks once before running the commands of a file made on another computer; `on` never asks; " +124- "`off` runs none. Opening a file never runs its commands."},122+ Desc: "Whether notebooks run their code cells (docs/nushell/notebooks.md). `ask` runs what you write " +123+ "and asks once before running the cells of a file made on another computer; `on` never asks; " +124+ "`off` runs none. Opening a file never runs its cells."},125125 {Name: "nu-timeout", Kind: Duration, Group: GroupShell, Default: "30s", Env: []string{"O12_NU_TIMEOUT"}, Live: true,126- Desc: "Stop a notebook's command that runs longer than this; 0 lets it run until Esc stops it."},126+ Desc: "Stop a notebook cell that runs longer than this; 0 lets it run until Stop (i i) stops it."},127127 {Name: "nu-config", Kind: Bool, Group: GroupShell, Default: "false", Env: []string{"O12_NU_CONFIG"}, Live: true,128- Desc: "Run notebook commands with your nushell config files (config.nu, env.nu) rather than " +128+ Desc: "Run notebook cells with your nushell config files (config.nu, env.nu) rather than " +129129 "`nu --no-config-file`, for your own commands and aliases."},130+ {Name: "nu-save-cell-kb", Kind: Int, Group: GroupShell, Default: "1024", Env: []string{"O12_NU_SAVE_CELL_KB"}, Live: true,131+ Desc: "The largest output of one cell a saved file keeps, in kilobytes of NUON; a larger one is left out " +132+ "and shows `not saved; run to see` when the file is opened."},133+ {Name: "nu-save-notebook-kb", Kind: Int, Group: GroupShell, Default: "8192", Env: []string{"O12_NU_SAVE_NOTEBOOK_KB"}, Live: true,134+ Desc: "How much of all its cells' outputs a saved file keeps, in kilobytes of NUON: the outputs that fit, " +135+ "in the notebook's order."},130136 131137 {Name: "jev-api-key-command", Kind: Command, Group: GroupJEV,132138 Desc: "A command that prints the TypeSafe API key, used when TYPESAFE_API_KEY isn't set and the " +
@@ -165,8 +171,8 @@ var Options = []Option{
165171 {Name: "serve-max-sessions", Kind: Int, Group: GroupServe, Default: "8",166172 Desc: "How many 012 serve sessions may run at once; more are turned away."},167173 {Name: "serve-shell", Kind: Bool, Group: GroupServe, Default: "false",168- Desc: "Let 012 serve sessions run notebooks' nushell commands, as the user 012 serve runs as, " +169- "following the shell option. Off, served notebooks show their regions but run nothing."},174+ Desc: "Let 012 serve sessions run notebooks' code cells, as the user 012 serve runs as, " +175+ "following the shell option. Off, served notebooks show their cells and saved outputs but run nothing."},170176 171177 {Name: "config-file", Kind: Path, Group: GroupFiles, Repeat: true,172178 Desc: "Read another config file after this one, relative to this file's directory. " +
M internal/ui/commands.go +7 -0
M internal/ui/file.go +10 -4
@@ -33,7 +33,7 @@ import (
3333 func (m *Model) Serve(root confine.Root, env []string) {3434 m.root = root3535 m.term = newTerminal(envLookup(env))36- m.shell.served = true36+ m.nb.served = true3737 }3838 3939 // envLookup reads a variable from a list of KEY=value pairs, the last
@@ -123,9 +123,9 @@ func (m *Model) openFile(text string) tea.Cmd {
123123 // session rather than the sheet carries over: the window, theme, terminal124124 // state, served directory and the JEV connection.125125 func (m *Model) reset(s *sheet.Sheet, filename string) {126- m.stopShell() // the old workbook's commands127- sh := shellState{runner: m.shell.runner, served: m.shell.served, words: m.shell.words, asked: m.shell.asked}128- defer func() { m.shell = sh }()126+ m.stopCells() // the old workbook's cells127+ nb := nbState{runner: m.nb.runner, served: m.nb.served, words: m.nb.words, asked: m.nb.asked}128+ defer func() { m.nb = nb; m.bookOpened() }()129129 *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs, session: m.session, pipe: m.pipe,130130 macros: macroState{machine: m.macros.machine, editor: m.macros.editor}, spans: m.spans}131131 s.Book().SetTrace(m.spans)
@@ -194,6 +194,8 @@ func (m *Model) saveAs(name string, check bool) tea.Cmd {
194194 // Linked files are saved relative to the new file's folder, and stay195195 // relative to this one's until the save is done.196196 m.rebaseLinks(m.filename, name)197+ m.book().SetOutputCaps(m.outputCaps())198+ m.nb.saving = m.book().OutputsChanged()197199 cmd := saveCmd(m.sheet, name, p, expect, m.spans)198200 m.rebaseLinks(name, m.filename)199201 return cmd
@@ -345,7 +347,11 @@ func (m *Model) handleSaved(msg savedMsg) tea.Cmd {
345347 m.rebaseLinks(m.filename, msg.name)346348 m.filename, m.disk = msg.name, msg.stamp347349 m.changed = false350+ m.nb.saved = m.nb.saving348351 m.savedRecovered()352+ if left := m.book().UnsavedOutputs(); len(left) > 0 {353+ m.note = "Saved without the outputs of " + strings.Join(left, ", ") + " (over nu-save-cell-kb or nu-save-notebook-kb): run them to see them again"354+ }349355 if quit {350356 return m.exit()351357 }
M internal/ui/gridlines.go +0 -6
M internal/ui/hosts.go +0 -16
@@ -11,7 +11,6 @@ import (
1111 "github.com/FelineStateMachine/012/internal/ui/filterpick"1212 "github.com/FelineStateMachine/012/internal/ui/findbar"1313 "github.com/FelineStateMachine/012/internal/ui/lineedit"14- "github.com/FelineStateMachine/012/internal/ui/nuprompt"1514 "github.com/FelineStateMachine/012/internal/ui/picker"1615 "github.com/FelineStateMachine/012/internal/ui/rules"1716 "github.com/FelineStateMachine/012/internal/ui/shortcuts"
@@ -32,7 +31,6 @@ func (m *Model) host() host { return host{m} }
3231 var (3332 _ picker.Host = host{}3433 _ cmdline.Host = host{}35- _ nuprompt.Host = host{}3634 _ findbar.Host = host{}3735 _ themepicker.Host = host{}3836 _ rules.Host = host{}
@@ -131,20 +129,6 @@ func (h host) Run(text string) (tea.Cmd, bool) { return h.m.runCmdLine(text) }
131129 func (h host) Fail(msg string) { h.m.fail(msg) }132130 func (h host) History() *cmdline.History { return &h.m.session.history }133131 134-// The shell prompt's host: nuprompt.Host.135-136-func (h host) Submit(line string) tea.Cmd { return h.m.submitShell(line) }137-func (h host) Words() []nuprompt.Word { return h.m.shellWords() }138-func (h host) ShellHistory() []string { return h.m.book().ShellHistory() }139-func (h host) Stop() { h.m.stopShell() }140-func (h host) Said() string { return h.m.shell.said }141-func (h host) Running() string {142- if j := h.m.shell.running; j != nil {143- return j.name144- }145- return ""146-}147-148132 // The theme picker.149133 150134 func (h host) Current() string { return h.m.prefs.Config.Theme().Pick(!h.m.prefs.light) }
A internal/ui/lineedit/area.go +240 -0
@@ -0,0 +1,240 @@
1+package lineedit2+3+import (4+ "slices"5+ "unicode"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+)10+11+// Area is the multi-line editor of a notebook's cells: Line's text and12+// caret, with line breaks, soft-wrapped to a width so no text is ever13+// hidden. A line longer than the width breaks before a pipe (|) where it14+// can, then after a space, then anywhere; the rows it continues on are15+// indented by Indent. Up and Down, Home and End move by those rows.16+type Area struct {17+ Line18+ goal int // one past the column Up and Down keep to, 0 for the caret's own19+}20+21+// Indent is how far a wrapped line's continuation rows are indented.22+const Indent = 223+24+// Row is one row of wrapped text: Buf[Start:End], a continuation of the25+// row before it (drawn Indent further in) when Cont is set.26+type Row struct {27+ Start, End int28+ Cont bool29+}30+31+// SetText replaces the text, with the caret at its end.32+func (a *Area) SetText(text string) {33+ a.Buf, a.goal = nil, 034+ a.Pos = 035+ a.InsertText(text)36+}37+38+// InsertText types text at the caret, line breaks included; a tab is39+// two spaces, and other control characters are dropped.40+func (a *Area) InsertText(text string) {41+ a.goal = 042+ for _, r := range text {43+ switch {44+ case r == '\r':45+ continue46+ case r == '\t':47+ a.insert(' ', ' ')48+ case r == '\n' || unicode.IsPrint(r):49+ a.insert(r)50+ }51+ }52+}53+54+func (a *Area) insert(rs ...rune) {55+ a.Buf = slices.Insert(a.Buf, a.Pos, rs...)56+ a.Pos += len(rs)57+}58+59+// Wrap lays buf out in rows at most width columns wide.60+func Wrap(buf []rune, width int) []Row {61+ width = max(width, Indent+4)62+ var rows []Row63+ start := 064+ for i := 0; i <= len(buf); i++ {65+ if i < len(buf) && buf[i] != '\n' {66+ continue67+ }68+ rows = wrapLine(rows, buf, start, i, width)69+ start = i + 170+ }71+ return rows72+}73+74+// wrapLine adds the rows of the line buf[from:to].75+func wrapLine(rows []Row, buf []rune, from, to, width int) []Row {76+ cont := false77+ for {78+ limit := width79+ if cont {80+ limit -= Indent81+ }82+ end, w := from, 083+ for end < to {84+ rw := runeWidth(buf[end])85+ if w+rw > limit {86+ break87+ }88+ w += rw89+ end++90+ }91+ if end >= to {92+ return append(rows, Row{Start: from, End: to, Cont: cont})93+ }94+ end = breakAt(buf, from, end)95+ rows = append(rows, Row{Start: from, End: end, Cont: cont})96+ from, cont = end, true97+ }98+}99+100+// breakAt is where a row that would run to end breaks: before the last101+// pipe, else after the last space, else at end.102+func breakAt(buf []rune, from, end int) int {103+ for i := end; i > from+1; i-- {104+ if buf[i] == '|' && buf[i-1] == ' ' {105+ return i106+ }107+ }108+ for i := end; i > from+1; i-- {109+ if buf[i-1] == ' ' {110+ return i111+ }112+ }113+ return end114+}115+116+func runeWidth(r rune) int { return max(ansi.StringWidth(string(r)), 1) }117+118+// RowWidth is the columns buf[from:to] takes.119+func RowWidth(buf []rune, from, to int) int {120+ w := 0121+ for _, r := range buf[from:to] {122+ w += runeWidth(r)123+ }124+ return w125+}126+127+// Caret is the row the caret is on among rows, and its column there,128+// continuation rows' indent included.129+func Caret(rows []Row, buf []rune, pos int) (row, col int) {130+ for i, r := range rows {131+ last := i == len(rows)-1 || !rows[i+1].Cont132+ if pos >= r.Start && (pos < r.End || pos == r.End && last) {133+ col = RowWidth(buf, r.Start, pos)134+ if r.Cont {135+ col += Indent136+ }137+ return i, col138+ }139+ }140+ return max(len(rows)-1, 0), 0141+}142+143+// posAt is the position in row r nearest column col.144+func posAt(r Row, buf []rune, col int, last bool) int {145+ if r.Cont {146+ col -= Indent147+ }148+ w := 0149+ for i := r.Start; i < r.End; i++ {150+ rw := runeWidth(buf[i])151+ if w+rw > col {152+ return i153+ }154+ w += rw155+ }156+ if !last && r.End > r.Start {157+ return r.End - 1 // the next row starts at End158+ }159+ return r.End160+}161+162+// Key applies an editing key at width, reporting whether it was one.163+// Enter breaks the line, keeping its indent.164+func (a *Area) Key(k tea.KeyPressMsg, width int) bool {165+ key := k.String()166+ rows := Wrap(a.Buf, width)167+ row, col := Caret(rows, a.Buf, a.Pos)168+ switch key {169+ case "up", "down":170+ if a.goal == 0 {171+ a.goal = col + 1172+ }173+ to := row - 1174+ if key == "down" {175+ to = row + 1176+ }177+ if to < 0 || to >= len(rows) {178+ return true179+ }180+ last := to == len(rows)-1 || !rows[to+1].Cont181+ a.Pos = posAt(rows[to], a.Buf, a.goal-1, last)182+ return true183+ case "home":184+ a.Pos = rows[row].Start185+ case "end":186+ last := row == len(rows)-1 || !rows[row+1].Cont187+ a.Pos = rows[row].End188+ if !last {189+ a.Pos = max(rows[row].End-1, rows[row].Start)190+ }191+ case "ctrl+a":192+ a.Pos = a.lineStart()193+ case "ctrl+e":194+ a.Pos = a.lineEnd()195+ case "enter":196+ indent := a.indent()197+ a.insert('\n')198+ a.insert(indent...)199+ case "left", "right", "backspace", "delete":200+ a.Line.Key(k)201+ default:202+ text := Typed(k)203+ if text == "" {204+ return false205+ }206+ a.InsertText(text)207+ }208+ a.goal = 0209+ return true210+}211+212+// lineStart and lineEnd are the ends of the caret's line.213+func (a *Area) lineStart() int {214+ i := a.Pos215+ for i > 0 && a.Buf[i-1] != '\n' {216+ i--217+ }218+ return i219+}220+221+func (a *Area) lineEnd() int {222+ i := a.Pos223+ for i < len(a.Buf) && a.Buf[i] != '\n' {224+ i++225+ }226+ return i227+}228+229+// indent is the spaces the caret's line starts with.230+func (a *Area) indent() []rune {231+ var out []rune232+ for i := a.lineStart(); i < len(a.Buf) && a.Buf[i] == ' '; i++ {233+ out = append(out, ' ')234+ }235+ return out236+}237+238+// Offset is the caret as a byte offset into the text, what a completer239+// or checker is given.240+func (a *Area) Offset() int { return len(string(a.Buf[:a.Pos])) }
A internal/ui/lineedit/area_test.go +93 -0
@@ -0,0 +1,93 @@
1+package lineedit2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+)9+10+func rowsText(buf []rune, rows []Row) []string {11+ var out []string12+ for _, r := range rows {13+ s := string(buf[r.Start:r.End])14+ if r.Cont {15+ s = strings.Repeat(" ", Indent) + s16+ }17+ out = append(out, s)18+ }19+ return out20+}21+22+func TestWrapAtPipes(t *testing.T) {23+ buf := []rune("ls | where size > 1kb | sort-by size | first 3\n$x")24+ got := rowsText(buf, Wrap(buf, 24))25+ want := []string{"ls | where size > 1kb ", " | sort-by size ", " | first 3", "$x"}26+ if strings.Join(got, "/") != strings.Join(want, "/") {27+ t.Errorf("rows %q", got)28+ }29+ // Every character is on a row, once.30+ long := []rune(strings.Repeat("abcdefghij", 20))31+ n := 032+ for _, r := range Wrap(long, 30) {33+ n += r.End - r.Start34+ if w := RowWidth(long, r.Start, r.End); w > 30 {35+ t.Errorf("a row %d wide", w)36+ }37+ }38+ if n != len(long) {39+ t.Errorf("%d of %d characters on rows", n, len(long))40+ }41+}42+43+func key(s string) tea.KeyPressMsg {44+ switch s {45+ case "up":46+ return tea.KeyPressMsg{Code: tea.KeyUp}47+ case "down":48+ return tea.KeyPressMsg{Code: tea.KeyDown}49+ case "home":50+ return tea.KeyPressMsg{Code: tea.KeyHome}51+ case "end":52+ return tea.KeyPressMsg{Code: tea.KeyEnd}53+ case "enter":54+ return tea.KeyPressMsg{Code: tea.KeyEnter}55+ }56+ return tea.KeyPressMsg{Code: rune(s[0]), Text: s}57+}58+59+func TestAreaMovesByRows(t *testing.T) {60+ var a Area61+ a.SetText("ls | where size > 1kb | sort-by size")62+ const w = 2463+ a.Key(key("home"), w)64+ if a.Pos != 22 {65+ t.Errorf("Home on the second row: %d", a.Pos)66+ }67+ a.Key(key("up"), w)68+ if row, _ := Caret(Wrap(a.Buf, w), a.Buf, a.Pos); row != 0 {69+ t.Errorf("Up: row %d", row)70+ }71+ a.Key(key("end"), w)72+ if a.Pos != 21 {73+ t.Errorf("End on a wrapped row stays on it: %d", a.Pos)74+ }75+ a.Key(key("down"), w)76+ a.Key(key("end"), w)77+ if a.Pos != len(a.Buf) {78+ t.Errorf("End on the last row: %d", a.Pos)79+ }80+ a.SetText(" $x")81+ a.Key(key("enter"), w)82+ a.Key(key("y"), w)83+ if a.Text() != " $x\n y" {84+ t.Errorf("a new line keeps the indent: %q", a.Text())85+ }86+ if row, col := Caret(Wrap(a.Buf, w), a.Buf, a.Pos); row != 1 || col != 3 {87+ t.Errorf("caret %d,%d", row, col)88+ }89+ a.InsertText("\ta\x01")90+ if a.Text() != " $x\n y a" || a.Offset() != len(" $x\n y a") {91+ t.Errorf("inserted %q", a.Text())92+ }93+}
M internal/ui/linked.go +1 -1
M internal/ui/menubar.go +1 -3
M internal/ui/model.go +29 -13
@@ -131,7 +131,7 @@ type Model struct {
131131 rec *recorder // a macro being recorded: macrorec.go132132 macros macroState // a macro running, and trust in the file's macros: macrorun.go133133 follow followState // the linked regions followed, and trust in them: follow.go134- shell shellState // a notebook's commands running, and what they said: nurun.go134+ nb nbState // notebooks' views and the cells running: notebook.go, nbrun.go135135 136136 keyAt time.Time // when the key the next frame answers was pressed, for telemetry137137 spans *telemetry.Trace // the spans open, which what the model starts nests in: trace.go
@@ -147,6 +147,7 @@ func New(s *sheet.Sheet, filename string) *Model {
147147 if filename != "" {148148 m.disk = diskStamp(filename)149149 }150+ m.bookOpened()150151 return m151152 }152153
@@ -158,7 +159,7 @@ func (m *Model) TraceUnder(p telemetry.Parent) { m.spans.Enter(p) }
158159 // so the theme can adapt to light terminals.159160 func (m *Model) Init() tea.Cmd {160161 // jev.send starts any questions queued while loading the file.161- return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startStdin(), m.startOpenCmd(), m.startShell(), m.syncFollowers())162+ return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startStdin(), m.startOpenCmd(), m.startNotebook(), m.syncFollowers())162163 }163164 164165 // Update implements tea.Model.
@@ -185,7 +186,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
185186 if mouse, ok := msg.(tea.MouseMsg); ok {186187 var handled bool187188 if cmd, handled = m.shellMouse(mouse); handled {188- msg = nil // taken by the menu bar or an overlay, not the grid189+ msg = nil // taken by the menu bar, an overlay or a notebook, not the grid189190 }190191 }191192 switch msg := msg.(type) {
@@ -195,7 +196,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
195196 case tea.KeyPressMsg:196197 cmd = m.handleKey(msg)197198 case tea.PasteMsg:198- m.handlePaste(msg.Content)199+ cmd = m.pasteMsg(msg.Content)199200 case tea.MouseClickMsg:200201 cmd = m.handlePress(msg.Mouse())201202 case tea.MouseMotionMsg:
@@ -233,10 +234,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
233234 cmd = m.finishMacro(msg.r, msg.done)234235 case macroEditedMsg:235236 m.macroEdited(msg)236- case nuDoneMsg:237- cmd = m.finishRegion(msg)238- case nuWordsMsg:239- m.shell.words = msg.words240237 case tea.KeyReleaseMsg:241238 cmd = m.keyReleased(msg)242239 case tea.KeyboardEnhancementsMsg:
@@ -246,7 +243,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
246243 m.pasteClipboard(msg.Content)247244 }248245 default:249- if !m.handlePrefs(msg) {246+ var ok bool247+ if cmd, ok = m.notebookMsg(msg); !ok && !m.handlePrefs(msg) {250248 cmd = m.term.handle(msg)251249 }252250 }
@@ -261,7 +259,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
261259 // Any edit may have queued JEV questions.262260 // Chart images follow any change, see graphics.go.263261 // Linked regions may have come, gone or changed: follow.go.264- return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans), m.syncSixel(msg), m.syncFollowers())262+ return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans), m.syncSixel(msg), m.syncFollowers(), m.notebookSync())265263 }266264 267265 // beginUpdate prepares for an input event and returns the sheet's state
@@ -328,15 +326,15 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
328326 }329327 330328 func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {329+ if m.sheet.IsNotebook() {330+ return m.notebookReadyKey(k)331+ }331332 if m.prefs.vim {332333 if cmd, ok := m.vimKeyPress(k); ok {333334 return cmd334335 }335336 }336337 key := k.String()337- if cmd, ok := m.notebookKey(key); ok {338- return cmd339- }340338 if m.recordMove(key) {341339 m.entry.tabbing = false342340 return nil
@@ -357,6 +355,24 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
357355 return nil358356 }359357 358+// notebookReadyKey is a key on a notebook tab: the notebook's, or else359+// the UI's (menus, other sheets, Save), which never types into a cell.360+func (m *Model) notebookReadyKey(k tea.KeyPressMsg) tea.Cmd {361+ if cmd, ok := m.notebookKey(k); ok {362+ return cmd363+ }364+ key := k.String()365+ if i := barMenuFor(key); i >= 0 {366+ m.showBarMenu(i)367+ return nil368+ }369+ if id, ok := keymap[canonicalKey(key)]; ok && commands[id].available(m) {370+ return m.runCommand(id)371+ }372+ cmd, _ := m.runShortcut(key)373+ return cmd374+}375+360376 func (m *Model) handleWheel(mouse tea.Mouse) {361377 const step = 3362378 button := mouse.Button
M internal/ui/monochrome_test.go +45 -15
@@ -188,23 +188,53 @@ func TestMonochromeSelection(t *testing.T) {
188188 }189189 }190190 191-// A notebook region's label line is underlined as wide as its table, with192-// its name and command as text; the table's cells are plain.193-func TestMonochromeRegion(t *testing.T) {194- m, _ := notebookModel(t, map[string]string{"ls": lsOut})195- shell(t, m, "ls", "<enter>", "<esc>")196- m.cur = addr("C9")197- for _, a := range []string{"A1", "B1"} {198- c := cellAt(m, addr(a))199- if !every(c, false, func(c monoCell) bool { return c.underline == "1" }) {200- t.Errorf("%s on the label line isn't underlined: %+v", a, c)191+// findLine is the first screen line holding text, drawn in monochrome.192+func findLine(t *testing.T, m *Model, text string) []monoCell {193+ t.Helper()194+ for y := range m.height {195+ if l := monoLine(m, y); strings.Contains(cellText(l), text) {196+ return l201197 }202198 }203- if strings.TrimSpace(cellText(cellAt(m, addr("A1")))) != "r1 ls" {204- t.Errorf("label %q", cellText(cellAt(m, addr("A1"))))205- }206- if c := cellAt(m, addr("A3")); !every(c, false, plain) {207- t.Errorf("a region's cell has attributes: %+v", c)199+ t.Fatalf("no line holds %q:\n%s", text, screen(m))200+ return nil201+}202+203+// cellsOf are the cells of line l holding text.204+func cellsOf(l []monoCell, text string) []monoCell {205+ i := strings.Index(cellText(l), text)206+ return l[len([]rune(cellText(l)[:i])):][:len([]rune(text))]207+}208+209+// A notebook's states read without color: the selected cell's head in210+// reverse video, the mode in words, an output table's header bold and211+// underlined, a stale output and a failure in words.212+func TestMonochromeNotebook(t *testing.T) {213+ m, nu := notebookModel(t, map[string]string{"ls": lsOut, "$files | first": "{name: a.txt}"})214+ write(t, m, "files = ls")215+ write(t, m, "$files | first")216+ write(t, m, "nope")217+ run(m, m.runCommand("nb.run_all"))218+ press(t, m, "<home>")219+ if head := findLine(t, m, "[1] files"); !every(head, false, isReverse) {220+ t.Errorf("the selected cell's head isn't reversed: %+v", head)221+ }222+ if head := findLine(t, m, "[2]"); every(head, true, isReverse) {223+ t.Error("a cell not selected has its head reversed")224+ }225+ hdr := findLine(t, m, "name")226+ if c := cellsOf(hdr, "name"); !every(c, true, func(c monoCell) bool { return c.bold && c.underline == "1" }) {227+ t.Errorf("an output's header: %+v", c)228+ }229+ if !strings.Contains(cellText(findLine(t, m, "× Command")), "× Command `nope` not found") {230+ t.Error("the failure isn't in words")231+ }232+ nu.out["ls"] = "[[name]; [z]]"233+ press(t, m, "<ctrl+enter>")234+ findLine(t, m, "stale")235+ press(t, m, "<enter>")236+ if !strings.Contains(cellText(monoLine(m, menuLine)), " EDIT ") {237+ t.Error("edit mode isn't in words")208238 }209239 }210240
A internal/ui/nbjob.go +166 -0
@@ -0,0 +1,166 @@
1+package ui2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "fmt"8+ "time"9+10+ tea "charm.land/bubbletea/v2"11+12+ "github.com/FelineStateMachine/012/internal/fileio"13+ "github.com/FelineStateMachine/012/internal/notebook"14+ "github.com/FelineStateMachine/012/internal/nushell"15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+// What a cell's run reads: the outputs of the cells it names ($sales),19+// linked files ($app), the selection on the sheet shown last20+// ($selection) and ranges of sheets ($sheet.A1:C9), each handed to nu as21+// NUON, never spliced into the pipeline.22+23+// jobFor is what running cell c takes: the outputs, linked files and24+// ranges it reads, as NUON.25+func (m *Model) jobFor(s *sheet.Sheet, c notebook.Cell, run *nbRun) (nushell.Job, error) {26+ pipeline, ranges := notebook.Bind(c.Pipeline())27+ job := nushell.Job{Command: pipeline, Tables: map[string][]byte{}, Config: m.configBool("nu-config", false)}28+ run.reads = map[string]int{}29+ cells := s.NotebookCells()30+ names := notebook.Names(cells)31+ for _, name := range notebook.Refs(pipeline) {32+ if j, ok := names[name]; ok {33+ o := m.book().Output(cells[j].ID)34+ switch {35+ case o == nil || o.Unsaved:36+ return job, fmt.Errorf("it reads $%s, which hasn't run", name)37+ case o.Failed():38+ return job, fmt.Errorf("it reads $%s, which failed", name)39+ }40+ job.Tables[name], run.reads[name] = o.NUON, o.Seq41+ continue42+ }43+ if t, r, ok := m.book().Region(name); ok && r.Linked() && r.Name == name {44+ data, err := regionNUON(t, r)45+ if err != nil {46+ return job, err47+ }48+ job.Tables[name] = data49+ }50+ }51+ if notebook.ReadsSelection(pipeline) {52+ if err := m.bindSelection(&job, run); err != nil {53+ return job, err54+ }55+ }56+ for _, ref := range ranges {57+ t, rng, err := m.rangeRef(ref.Ref)58+ if err != nil {59+ return job, err60+ }61+ data, err := encodeNUON(fileio.Snap(t, rng, t.Name()))62+ if err != nil {63+ return job, err64+ }65+ job.Tables[ref.Var] = data66+ }67+ return job, nil68+}69+70+// bindSelection gives the job the selection on the sheet shown last.71+func (m *Model) bindSelection(job *nushell.Job, run *nbRun) error {72+ t := m.nb.grid73+ if t == nil || m.book().Index(t) < 0 {74+ return errors.New("it reads $selection: select a range on a sheet first")75+ }76+ data, err := encodeNUON(fileio.Snap(t, m.nb.sel, t.Name()))77+ if err != nil {78+ return err79+ }80+ job.Tables[notebook.Selection] = data81+ run.selection = sheet.Qualified(t.Name(), m.nb.sel)82+ return nil83+}84+85+// rangeRef is the sheet and range $sheet.ref reads: a range of the86+// sheet shown last, or of the sheet it names.87+func (m *Model) rangeRef(ref string) (*sheet.Sheet, sheet.Rect, error) {88+ name, cells := sheet.SplitSheet(ref)89+ t := m.nb.grid90+ if name != "" {91+ t = m.book().Lookup(name)92+ }93+ if t == nil || t.IsNotebook() {94+ for _, s := range m.book().Sheets() {95+ if !s.IsNotebook() && name == "" {96+ t = s97+ break98+ }99+ }100+ }101+ rng, ok := sheet.ParseRange(cells)102+ if t == nil || t.IsNotebook() || !ok {103+ return nil, sheet.Rect{}, fmt.Errorf("$sheet.%s isn't a range of a sheet", ref)104+ }105+ return t, rng, nil106+}107+108+// regionNUON is a linked file's table as NUON, read whole whatever its109+// filter shows.110+func regionNUON(t *sheet.Sheet, r sheet.Region) ([]byte, error) {111+ rng, ok := t.RegionTable(r.Name)112+ if !ok {113+ rng = sheet.Rect{From: r.At, To: r.At}114+ }115+ snap := fileio.Snap(t, rng, r.Name)116+ snap.HiddenRows = nil117+ if !ok {118+ snap.Cells = nil119+ }120+ return encodeNUON(snap)121+}122+123+func encodeNUON(snap *fileio.Snapshot) ([]byte, error) {124+ var b bytes.Buffer125+ if _, err := fileio.Encode(&b, fileio.NUON, snap); err != nil {126+ return nil, err127+ }128+ return b.Bytes(), nil129+}130+131+// loadWords asks nu for its command names, once a session, for132+// completing them.133+func (m *Model) loadWords() tea.Cmd {134+ if m.nb.asked || m.shellOff() != "" {135+ return nil136+ }137+ m.nb.asked = true138+ runner := m.runner()139+ return func() tea.Msg {140+ words, _ := nushell.Commands(context.Background(), runner, 30*time.Second)141+ return nbWordsMsg{words}142+ }143+}144+145+// Config helpers that work without a config, as in tests.146+147+func (m *Model) configString(name, def string) string {148+ if c := m.prefs.Config; c != nil {149+ return c.String(name)150+ }151+ return def152+}153+154+func (m *Model) configBool(name string, def bool) bool {155+ if c := m.prefs.Config; c != nil {156+ return c.Bool(name)157+ }158+ return def159+}160+161+func (m *Model) configDuration(name string, def time.Duration) time.Duration {162+ if c := m.prefs.Config; c != nil {163+ return c.Duration(name)164+ }165+ return def166+}
A internal/ui/nbrun.go +407 -0
@@ -0,0 +1,407 @@
1+package ui2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "log/slog"8+ "slices"9+ "time"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/notebook"14+ "github.com/FelineStateMachine/012/internal/nushell"15+ "github.com/FelineStateMachine/012/internal/sheet"16+ "github.com/FelineStateMachine/012/internal/ui/nbview"17+)18+19+// Running cells. One runs at a time, in the background, so the screen20+// stays live and Stop kills it: the cells asked for are a queue, each21+// started when the one before has finished. What a cell reads (other22+// cells' outputs, ranges of sheets) is taken as it starts; what it23+// printed becomes its output as it ends, and goes on to the sheet it was24+// sent to. A failure stops the queue, as Jupyter's run all stops.25+26+// nbState is the notebooks' part of the model.27+type nbState struct {28+ views map[*sheet.Sheet]*nbview.View29+ runner nushell.Runner // nu, or a fake in tests30+ queue []nbQueued31+ running *nbRun32+ gen int33+ count int // runs this session, for [n]34+ clip []notebook.Cell35+ // words are nu's command names, asked for once (asked).36+ words []string37+ asked bool38+ // served is set in a session of 012 serve, where cells run only39+ // when the server's config allows them.40+ served bool41+ // startup shows the notebook once the program starts (012 nu).42+ startup bool43+ // fromFile is set when the workbook opened with cells of its own,44+ // which may have been made on another computer.45+ fromFile bool46+ // grid is the sheet shown last that isn't a notebook, and sel its47+ // selection then: what $selection reads.48+ grid *sheet.Sheet49+ sel sheet.Rect50+ // stale caches which cells are stale, for the state it was worked51+ // out in.52+ stale map[int]bool53+ staleKey [2]int54+ // still stops the running cell's head turning, so tests that run55+ // commands in line don't wait on its ticks.56+ still bool57+ // saved is the outputs' change count when the file was last saved,58+ // and saving when it was being saved.59+ saved, saving int60+}61+62+// nbQueued is a cell waiting to run.63+type nbQueued struct {64+ s *sheet.Sheet65+ id int66+}67+68+// nbRun is a cell running.69+type nbRun struct {70+ nbQueued71+ gen int72+ cancel context.CancelFunc73+ start time.Time74+ source string75+ reads map[string]int76+ selection string77+}78+79+type (80+ // nbDoneMsg is a run finished: what it printed, or why it failed.81+ nbDoneMsg struct {82+ run *nbRun83+ nuon []byte84+ err error85+ }86+ // nbWordsMsg brings nu's command names.87+ nbWordsMsg struct{ words []string }88+ // nbTickMsg redraws a running cell's head.89+ nbTickMsg struct{ gen int }90+)91+92+// SetShellRunner makes cells run with r rather than nu, for tests.93+func (m *Model) SetShellRunner(r nushell.Runner) { m.nb.runner = r }94+95+func (m *Model) runner() nushell.Runner {96+ if m.nb.runner == nil {97+ return nushell.Nu{}98+ }99+ return m.nb.runner100+}101+102+// cellState is how cell id of notebook s stands.103+func (m *Model) cellState(s *sheet.Sheet, id int) nbview.State {104+ var st nbview.State105+ if r := m.nb.running; r != nil && r.s == s && r.id == id {106+ st.Running, st.Started = true, r.start107+ }108+ st.Waiting = slices.Contains(m.nb.queue, nbQueued{s, id})109+ st.Stale = m.staleCells(s)[id]110+ cells := s.NotebookCells()111+ if i := slices.IndexFunc(cells, func(c notebook.Cell) bool { return c.ID == id }); i >= 0 {112+ st.Problem = notebook.Taken(cells, i)113+ }114+ return st115+}116+117+// staleCells are notebook s's stale cells, worked out once a state.118+func (m *Model) staleCells(s *sheet.Sheet) map[int]bool {119+ key := [2]int{m.book().StateID(), m.book().OutputsChanged()}120+ if m.nb.stale == nil || m.nb.staleKey != key {121+ m.nb.stale = map[int]bool{}122+ for _, t := range m.book().Sheets() {123+ if t.IsNotebook() {124+ for id := range notebook.Stale(t.NotebookCells(), m.book().Output) {125+ m.nb.stale[id] = true126+ }127+ }128+ }129+ m.nb.staleKey = key130+ }131+ return m.nb.stale132+}133+134+// runSelected runs the selected cell: then, with next 1, selects the135+// cell below (adding one at the end), or with 2 adds one under it.136+func (m *Model) runSelected(next int) tea.Cmd {137+ v := m.nbView()138+ v.StopEdit()139+ i, _ := v.Selected()140+ cells := m.sheet.NotebookCells()141+ if i >= len(cells) {142+ return nil143+ }144+ var cmd tea.Cmd145+ if cells[i].Kind == notebook.Code {146+ cmd = m.runCells(m.sheet, i, cells)147+ }148+ switch {149+ case next == 2 || next == 1 && i == len(cells)-1:150+ return tea.Batch(cmd, m.addCell(1, next == 2))151+ case next == 1:152+ v.Select(i+1, false)153+ }154+ return cmd155+}156+157+// runRange runs the code cells from index from up to to (-1 for the158+// last), each after the cells it reads.159+func (m *Model) runRange(from, to int) tea.Cmd {160+ v := m.nbView()161+ v.StopEdit()162+ cells := m.sheet.NotebookCells()163+ if to < 0 {164+ to = len(cells)165+ }166+ var want []int167+ for i := from; i < to && i < len(cells); i++ {168+ want = append(want, i)169+ }170+ return m.queueCells(m.sheet, cells, want)171+}172+173+// runCells runs cell i, after what it reads that hasn't run, and in a174+// reactive notebook then the cells reading it.175+func (m *Model) runCells(s *sheet.Sheet, i int, cells []notebook.Cell) tea.Cmd {176+ want := append(notebook.Inputs(cells, i, m.book().Output), i)177+ if s.Reactive() {178+ want = append(want, notebook.Dependents(cells, i)...)179+ }180+ return m.queueCells(s, cells, want)181+}182+183+// queueCells queues the code cells of want, in the order they run, asking184+// first when the cells came from another computer.185+func (m *Model) queueCells(s *sheet.Sheet, cells []notebook.Cell, want []int) tea.Cmd {186+ order, cycle := notebook.Order(cells, want)187+ for _, i := range cycle {188+ m.fail(fmt.Sprintf("Cell %d reads itself, through the cells it reads: it doesn't run", i+1))189+ }190+ if len(order) == 0 {191+ return nil192+ }193+ return m.trustNotebook(func(m *Model) tea.Cmd {194+ for _, i := range order {195+ q := nbQueued{s, cells[i].ID}196+ if !slices.Contains(m.nb.queue, q) && (m.nb.running == nil || m.nb.running.nbQueued != q) {197+ m.nb.queue = append(m.nb.queue, q)198+ }199+ }200+ if m.nb.running != nil {201+ return nil202+ }203+ return m.nextCell()204+ })205+}206+207+// shellOff says why cells can't run here, or "".208+func (m *Model) shellOff() string {209+ switch {210+ case m.nb.served && !m.configBool("serve-shell", false):211+ return "Notebook cells don't run in 012 serve (serve-shell in the server's config)"212+ case m.configString("shell", "ask") == "off":213+ return "Notebook cells don't run (shell = off in your config)"214+ }215+ return ""216+}217+218+// trustNotebook runs run once cells may run: shell = on, or the file's219+// cells were made or trusted here, or the user agrees now.220+func (m *Model) trustNotebook(run func(*Model) tea.Cmd) tea.Cmd {221+ if why := m.shellOff(); why != "" {222+ m.fail(why)223+ return nil224+ }225+ if m.configString("shell", "ask") == "on" || m.macroTrusted() {226+ return run(m)227+ }228+ m.ask(question{229+ msg: "Run this file's notebook cells?",230+ desc: "It was saved on another computer: its cells run as you, with your files",231+ choices: []choice{232+ {key: "enter", label: "Run", run: func(m *Model) tea.Cmd { m.trustHere(); return run(m) }},233+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},234+ },235+ })236+ return nil237+}238+239+// nextCell starts the first cell queued.240+func (m *Model) nextCell() tea.Cmd {241+ for len(m.nb.queue) > 0 {242+ q := m.nb.queue[0]243+ m.nb.queue = m.nb.queue[1:]244+ c, ok := cellOf(q)245+ if !ok || c.Kind != notebook.Code {246+ continue247+ }248+ cells := q.s.NotebookCells()249+ i := slices.IndexFunc(cells, func(x notebook.Cell) bool { return x.ID == c.ID })250+ if why := notebook.Taken(cells, i); why != "" {251+ m.failCell(q, c, why)252+ continue253+ }254+ run := &nbRun{nbQueued: q, source: c.Source, start: time.Now()}255+ job, err := m.jobFor(q.s, c, run)256+ if err != nil {257+ m.failCell(q, c, err.Error())258+ continue259+ }260+ return m.startCell(run, job)261+ }262+ return nil263+}264+265+// cellOf is the cell q names, if it's still there.266+func cellOf(q nbQueued) (notebook.Cell, bool) {267+ for _, c := range q.s.NotebookCells() {268+ if c.ID == q.id {269+ return c, true270+ }271+ }272+ return notebook.Cell{}, false273+}274+275+// failCell gives a cell that can't run an output saying why, and stops276+// the queue.277+func (m *Model) failCell(q nbQueued, c notebook.Cell, why string) {278+ m.nb.count++279+ m.book().SetOutput(q.id, &notebook.Output{Err: why, Count: m.nb.count, Source: c.Source})280+ m.nb.queue = nil281+}282+283+// startCell runs a cell in the background.284+func (m *Model) startCell(run *nbRun, job nushell.Job) tea.Cmd {285+ m.nb.gen++286+ ctx, cancel := context.WithCancel(context.Background())287+ run.gen, run.cancel = m.nb.gen, cancel288+ m.nb.running = run289+ runner, timeout, parent := m.runner(), m.configDuration("nu-timeout", 30*time.Second), m.spans.Parent()290+ exec := func() tea.Msg {291+ defer cancel()292+ span := parent.Start("nu", slog.Int("tables", len(job.Tables)))293+ out, err := nushell.Exec(ctx, runner, job, timeout, maxOutput)294+ if err != nil {295+ span.Fail(err)296+ } else {297+ span.End(slog.Int("bytes", len(out)))298+ }299+ return nbDoneMsg{run: run, nuon: out, err: err}300+ }301+ return tea.Batch(exec, m.tickCell(run.gen))302+}303+304+// maxOutput is the most a run may print: past it the run is stopped.305+const maxOutput = 512 << 20306+307+// tickCell redraws the running cell's head every tenth of a second.308+func (m *Model) tickCell(gen int) tea.Cmd {309+ if m.nb.still {310+ return nil311+ }312+ return tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { return nbTickMsg{gen} })313+}314+315+// ticked keeps the running cell's head turning.316+func (m *Model) ticked(msg nbTickMsg) tea.Cmd {317+ if r := m.nb.running; r != nil && r.gen == msg.gen {318+ return m.tickCell(msg.gen)319+ }320+ return nil321+}322+323+// finishCell keeps what a run made, sends it on to its sheet, and324+// starts the next cell queued.325+func (m *Model) finishCell(msg nbDoneMsg) tea.Cmd {326+ r := msg.run327+ if m.nb.running != r {328+ return nil // stopped, and another started since329+ }330+ m.nb.running = nil331+ c, ok := cellOf(r.nbQueued)332+ if !ok {333+ return m.nextCell()334+ }335+ m.nb.count++336+ o := &notebook.Output{Count: m.nb.count, Took: time.Since(r.start), Source: r.source, Reads: r.reads, Selection: r.selection}337+ if msg.err != nil {338+ o.Err = runError(msg.err)339+ var nuErr *nushell.Error340+ if errors.As(msg.err, &nuErr) {341+ o.Detail = nuErr.Help()342+ }343+ m.nb.queue = nil344+ } else {345+ o.NUON = msg.nuon346+ if o.NUON == nil {347+ o.NUON = []byte{}348+ }349+ }350+ m.book().SetOutput(c.ID, o)351+ m.feedOutput(c.Name())352+ if msg.err == nil && r.s.Reactive() {353+ cells := r.s.NotebookCells()354+ if i := slices.IndexFunc(cells, func(x notebook.Cell) bool { return x.ID == c.ID }); i >= 0 {355+ for _, j := range notebook.Dependents(cells, i) {356+ if q := (nbQueued{r.s, cells[j].ID}); !slices.Contains(m.nb.queue, q) {357+ m.nb.queue = append(m.nb.queue, q)358+ }359+ }360+ }361+ }362+ if len(m.nb.queue) == 0 && time.Since(r.start) > 5*time.Second {363+ return tea.Batch(m.nextCell(), m.term.notify("Notebook cells finished in "+m.displayName()))364+ }365+ return m.nextCell()366+}367+368+// runError is what a failed run says.369+func runError(err error) string {370+ var nuErr *nushell.Error371+ switch {372+ case errors.Is(err, context.Canceled):373+ return "stopped"374+ case errors.As(err, &nuErr):375+ return nuErr.Msg376+ }377+ return err.Error()378+}379+380+// stopCells stops the cell running, killing its process, and forgets381+// those waiting.382+func (m *Model) stopCells() {383+ if r := m.nb.running; r != nil {384+ r.cancel()385+ }386+ m.nb.queue = nil387+}388+389+// clearOutputs clears every output of the notebook shown; restart also390+// stops what's running and counts runs from 1 again.391+func (m *Model) clearOutputs(restart bool) {392+ if restart {393+ m.stopCells()394+ m.nb.running = nil395+ m.nb.count = 0396+ }397+ for _, c := range m.sheet.NotebookCells() {398+ if m.book().Output(c.ID) != nil {399+ m.book().SetOutput(c.ID, nil)400+ m.feedOutput(c.Name())401+ }402+ }403+ m.note = "Cleared every output"404+ if restart {405+ m.note = "Restarted: every output cleared, runs counted from 1"406+ }407+}
A internal/ui/nbscreen.go +290 -0
@@ -0,0 +1,290 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/notebook"11+ "github.com/FelineStateMachine/012/internal/ui/nbview"12+ "github.com/FelineStateMachine/012/internal/ui/overlay"13+ "github.com/FelineStateMachine/012/internal/ui/theme"14+)15+16+// A notebook tab on the screen: the control panel as for a sheet (the17+// menu bar with NOTEBOOK or EDIT as the mode, the formula bar naming the18+// selected cell and what it reads, the context line with the keys), the19+// tab's own bar where the column letters would be, and the cells where20+// the grid would be.21+22+// nbBody is the screen row the notebook's cells start at, under its bar.23+const nbBody = gridTop24+25+// openNotebook shows the workbook's notebook, making one after the sheet26+// shown if it has none.27+func (m *Model) openNotebook() tea.Cmd {28+ w := m.book()29+ s := w.Notebook()30+ if s == nil {31+ var err error32+ if s, err = w.AddNotebook("", m.sheet); err != nil {33+ m.fail(err.Error())34+ return nil35+ }36+ m.note = "Added " + s.Name() + ": b adds a code cell, Enter edits it, Shift+Enter runs it"37+ }38+ m.showSheet(s)39+ return m.loadWords()40+}41+42+// OpenNotebook starts on the workbook's notebook, as 012 nu does: a new43+// file's empty sheet becomes the notebook, with a code cell ready to44+// type in; a workbook without one gets one.45+func (m *Model) OpenNotebook() {46+ w := m.book()47+ s := w.Notebook()48+ if s == nil {49+ if first := w.Sheet(0); w.Len() == 1 && first.Len() == 0 && !w.CanUndo() && w.RenameSheet(first, "Notebook") == nil {50+ first.MakeNotebook()51+ s = first52+ } else if s, _ = w.AddNotebook("", m.sheet); s == nil {53+ return54+ }55+ }56+ if len(s.NotebookCells()) == 0 {57+ s.SetNotebookCells("add cell", []notebook.Cell{{}})58+ w.ClearHistory()59+ m.saved = w.StateID()60+ }61+ m.showSheet(s)62+ m.nb.startup = true63+}64+65+// startNotebook edits the new notebook's first cell as the program66+// starts, when 012 nu asked.67+func (m *Model) startNotebook() tea.Cmd {68+ if !m.nb.startup {69+ return nil70+ }71+ m.nb.startup = false72+ v := m.nbView()73+ if v == nil {74+ return nil75+ }76+ cmd := m.loadWords()77+ if c, ok := v.Cell(); ok && c.Source == "" && len(m.sheet.NotebookCells()) == 1 {78+ return tea.Batch(cmd, v.StartEdit())79+ }80+ return cmd81+}82+83+// notebookKey gives a key to the notebook shown, reporting whether it84+// took it.85+func (m *Model) notebookKey(k tea.KeyPressMsg) (tea.Cmd, bool) {86+ v := m.nbView()87+ if v == nil {88+ return nil, false89+ }90+ m.sizeNotebook(v)91+ cmd, ok := v.Key(k)92+ if !ok && v.Editing() {93+ v.Commit() // a key of the UI's, such as Ctrl+S, sees what's typed94+ }95+ return cmd, ok96+}97+98+// sizeNotebook tells the view how much room it has.99+func (m *Model) sizeNotebook(v *nbview.View) {100+ v.Resize(m.width, m.height-nbBody-1)101+}102+103+// notebookLines are the screen's lines on a notebook tab, from the104+// menu bar down to the line above the status line.105+func (m *Model) notebookLines(v *nbview.View) []string {106+ m.sizeNotebook(v)107+ lines := []string{m.menuBarLine(), m.formulaBar(), m.contextLineText(), m.notebookBar(v)}108+ return append(lines, v.Lines()...)109+}110+111+// notebookBar is the tab's bar: its name, its cells, what's running,112+// and whether it's reactive.113+func (m *Model) notebookBar(v *nbview.View) string {114+ cells := m.sheet.NotebookCells()115+ parts := []string{" " + m.sheet.Name(), plural(len(cells), strconv.Itoa(len(cells))+" cell", strconv.Itoa(len(cells))+" cells")}116+ if f := v.Full(); f != nil {117+ parts = []string{" " + f.Title + ", full-screen"}118+ }119+ if r := m.nb.running; r != nil {120+ waiting := len(m.nb.queue)121+ run := "running 1"122+ if waiting > 0 {123+ run += ", " + strconv.Itoa(waiting) + " waiting"124+ }125+ parts = append(parts, run)126+ }127+ if m.sheet.Reactive() {128+ parts = append(parts, "reactive")129+ }130+ return strings.Join(parts, " ")131+}132+133+// notebookFormulaBar is the formula bar on a notebook tab: the selected134+// cell's name, then what it reads or what its output holds.135+func (m *Model) notebookFormulaBar(v *nbview.View) string {136+ name, text := v.Head()137+ box := m.th.Header.Render(theme.PadRight(" "+ansi.Truncate(name, nameBoxW-1, "…"), nameBoxW)) + " "138+ return box + m.th.Muted.Render(text)139+}140+141+// pasteMsg takes pasted text: into a notebook's cell being edited, and142+// nowhere else on a notebook tab, or as on a sheet.143+func (m *Model) pasteMsg(text string) tea.Cmd {144+ if v := m.nbView(); v != nil && m.mode == modeReady && m.overlay == nil {145+ return v.Paste(text)146+ }147+ m.handlePaste(text)148+ return nil149+}150+151+// notebookContext is the context line on a notebook tab: what the last152+// action said, or the notebook's own line.153+func (m *Model) notebookContext(v *nbview.View) (string, string) {154+ switch {155+ case m.warn != "":156+ return m.th.Warning.Render(m.warn), ""157+ case m.note != "":158+ return m.th.Hint.Render(m.note), ""159+ }160+ return v.ContextLine()161+}162+163+// notebookIndicator is the mode on a notebook tab.164+func notebookIndicator(v *nbview.View) string {165+ switch {166+ case v.FullOpen():167+ return "OUTPUT"168+ case v.Editing():169+ return "EDIT"170+ }171+ return "NOTEBOOK"172+}173+174+// notebookCursor is where the terminal's caret goes on a notebook tab.175+func (m *Model) notebookCursor(v *nbview.View) (int, int, bool) {176+ if f := v.Full(); f != nil {177+ if x, ok := f.Cursor(); ok {178+ return x, contextLine, true179+ }180+ return 0, 0, false181+ }182+ x, y, ok := v.Cursor()183+ return x, nbBody + y, ok184+}185+186+// notebookBoxes are the notebook's floating boxes: its completions.187+func (m *Model) notebookBoxes() []overlay.Box {188+ if v := m.nbView(); v != nil && m.overlay == nil && m.mode == modeReady {189+ return v.Boxes(nbBody)190+ }191+ return nil192+}193+194+// notebookMouse takes a click or the wheel over the cells.195+func (m *Model) notebookMouse(msg tea.MouseMsg) (tea.Cmd, bool) {196+ v := m.nbView()197+ mouse := msg.Mouse()198+ if v == nil || m.mode != modeReady || m.overlay != nil || mouse.Y < nbBody || mouse.Y >= m.height-1 {199+ return nil, false200+ }201+ switch msg := msg.(type) {202+ case tea.MouseClickMsg:203+ if msg.Button == tea.MouseLeft {204+ return v.Click(mouse.Y - nbBody), true205+ }206+ return nil, true207+ case tea.MouseWheelMsg:208+ switch msg.Button {209+ case tea.MouseWheelUp:210+ v.Wheel(-3)211+ case tea.MouseWheelDown:212+ v.Wheel(3)213+ }214+ return nil, true215+ }216+ return nil, true217+}218+219+// notebookMsg takes the notebooks' own messages.220+func (m *Model) notebookMsg(msg tea.Msg) (tea.Cmd, bool) {221+ switch msg := msg.(type) {222+ case nbDoneMsg:223+ return m.finishCell(msg), true224+ case nbWordsMsg:225+ m.nb.words = msg.words226+ return nil, true227+ case nbTickMsg:228+ return m.ticked(msg), true229+ }230+ for _, v := range m.nb.views {231+ if cmd, ok := v.Update(msg); ok {232+ return cmd, true233+ }234+ }235+ return nil, false236+}237+238+// notebookSync is what follows any message: outputs sent again where239+// they're stale, and the highlighter asked about what was drawn.240+func (m *Model) notebookSync() tea.Cmd {241+ m.syncOutputs()242+ if m.book().OutputsChanged() != m.nb.saved {243+ m.changed = true244+ }245+ if v := m.nbView(); v != nil {246+ return v.Fetch()247+ }248+ return nil249+}250+251+// bookOpened notes what a workbook opened with: whether it has cells of252+// its own, which may come from another computer, and what converting253+// it said.254+func (m *Model) bookOpened() {255+ w := m.book()256+ for _, s := range w.Sheets() {257+ m.nb.fromFile = m.nb.fromFile || len(s.NotebookCells()) > 0258+ }259+ if notes := w.LoadNotes(); len(notes) > 0 {260+ m.note = strings.Join(notes, "; ")261+ }262+}263+264+// outputCaps are the caps on the outputs a file keeps, from the config.265+func (m *Model) outputCaps() notebook.Caps {266+ c := notebook.DefaultCaps267+ if cfg := m.prefs.Config; cfg != nil {268+ c = notebook.Caps{Cell: cfg.Int("nu-save-cell-kb") << 10, Total: cfg.Int("nu-save-notebook-kb") << 10}269+ }270+ return c271+}272+273+// leftGrid notes the sheet left, when it's a grid, for $selection.274+func (m *Model) leftGrid() {275+ if !m.sheet.IsNotebook() {276+ m.nb.grid, m.nb.sel = m.sheet, m.selection()277+ }278+}279+280+// notebookSafe reports whether a command applies on a notebook tab:281+// those about files, sheets, settings, help and the notebook itself,282+// not those acting on a grid's cells.283+func notebookSafe(id string) bool {284+ for _, p := range []string{"file.", "sheet.", "nb.", "settings.", "help", "palette", "menu", "quit", "vim.command", "macro.manage"} {285+ if strings.HasPrefix(id, p) {286+ return id != "sheet.duplicate"287+ }288+ }289+ return id == "edit.undo" || id == "edit.redo"290+}
A internal/ui/nbsend.go +201 -0
@@ -0,0 +1,201 @@
1+package ui2+3+import (4+ "context"5+ "strconv"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/fileio"10+ "github.com/FelineStateMachine/012/internal/notebook"11+ "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/picker"13+)14+15+// Sending a cell's output to a sheet: the output becomes a region of the16+// sheet named after the cell (sheet.Region.Output), which formulas read17+// as nu.name, and whose rows the cell sends as live operations18+// (Workbook.ApplyLive) whenever it runs. The region's rows aren't part19+// of the undo history: when undo puts it back, or it moves, it is stale20+// and the cell's output is sent again (syncOutputs).21+22+// sendCell asks where the selected cell's output goes: a new sheet named23+// after the cell, or a cell of a sheet there is.24+func (m *Model) sendCell() tea.Cmd {25+ nb := m.sheet26+ c, _ := m.nbCell()27+ name := c.Name()28+ if name == "" {29+ i, _ := m.nbView().Selected()30+ name = m.book().FreeCellName("cell" + strconv.Itoa(i+1))31+ m.renameCell(nb, c, name)32+ }33+ if t, r, ok := m.book().Region(name); ok {34+ m.note = name + " is on " + t.Name() + " at " + r.At.String() + ": nu." + name + " reads it"35+ return nil36+ }37+ items := []picker.Item{{Title: "New sheet " + name, Name: len("New sheet " + name), Desc: "Add a sheet named " + name + " with the output at A1",38+ Pick: func() tea.Cmd {39+ m.closeOverlay()40+ return m.sendToNewSheet(nb, name)41+ }}}42+ for _, t := range m.visibleSheets() {43+ if t.IsNotebook() {44+ continue45+ }46+ items = append(items, picker.Item{Title: t.Name(), Name: len(t.Name()), Detail: "pick a cell", Desc: "Put the output on " + t.Name(),47+ Pick: func() tea.Cmd {48+ m.closeOverlay()49+ m.askSendCell(nb, name, t)50+ return nil51+ }})52+ }53+ p := m.newPicker("Send "+name+" to", "Type a sheet name", 60, items)54+ p.Action = "send"55+ m.openOverlay(p)56+ return nil57+}58+59+// sendToNewSheet sends name's output to a new sheet named after it.60+func (m *Model) sendToNewSheet(nb *sheet.Sheet, name string) tea.Cmd {61+ w := m.book()62+ t, err := w.AddSheet(freeSheetName(w, name), w.Index(nb)+1)63+ if err != nil {64+ m.fail(err.Error())65+ return nil66+ }67+ return m.sendTo(nb, name, t, sheet.Addr{})68+}69+70+// askSendCell asks for the cell of t the output starts at.71+func (m *Model) askSendCell(nb *sheet.Sheet, name string, t *sheet.Sheet) {72+ at := m.tabs.Places[t].Cur73+ m.openText("Send "+name+" to "+t.Name()+" at:", at.String(), func(m *Model, text string) tea.Cmd {74+ a, ok := sheet.ParseAddr(text)75+ if !ok {76+ m.fail(text + " isn't a cell")77+ return nil78+ }79+ return m.sendTo(nb, name, t, a)80+ })81+}82+83+// sendTo makes name's output a region of t at a, and sends it there.84+func (m *Model) sendTo(nb *sheet.Sheet, name string, t *sheet.Sheet, a sheet.Addr) tea.Cmd {85+ if err := t.AddRegion(sheet.Region{Name: name, At: a, Output: true}); err != nil {86+ m.fail(err.Error())87+ return nil88+ }89+ m.feedOutput(name)90+ m.note = "Sent " + name + " to " + sheet.Qualified(t.Name(), sheet.Rect{From: a, To: a}) + ": =SUM(nu." + name + ") reads it"91+ if m.sheet != nb {92+ m.showSheet(nb)93+ }94+ return nil95+}96+97+// cellNamed finds the cell named name among the notebooks' cells.98+func (m *Model) cellNamed(name string) (notebook.Cell, bool) {99+ for _, s := range m.book().Sheets() {100+ cells := s.NotebookCells()101+ if i, ok := notebook.Names(cells)[name]; ok {102+ return cells[i], true103+ }104+ }105+ return notebook.Cell{}, false106+}107+108+// feedOutput sends the output of the cell named name to the region it109+// was sent to, if it was: its rows, none when it hasn't run, or why110+// when it failed or is gone.111+func (m *Model) feedOutput(name string) {112+ w := m.book()113+ if name == "" {114+ return115+ }116+ _, r, ok := w.Region(name)117+ if !ok || !r.Output {118+ return119+ }120+ op := sheet.LiveOp{Region: r.Name, Reset: true}121+ c, ok := m.cellNamed(r.Name)122+ switch o := w.Output(c.ID); {123+ case !ok:124+ op = sheet.LiveOp{Region: r.Name, Err: "its cell is gone"}125+ case o == nil || o.Unsaved:126+ case o.Failed():127+ op = sheet.LiveOp{Region: r.Name, Err: o.Err}128+ default:129+ rows, note, err := fileio.NUONRows(context.Background(), o.NUON, 0)130+ if err != nil {131+ op = sheet.LiveOp{Region: r.Name, Err: err.Error()}132+ break133+ }134+ op.Header, op.Rows, op.Note = rows.Header, rows.Rows, note135+ }136+ if err := w.ApplyLive(op); err != nil {137+ m.warn = err.Error()138+ }139+}140+141+// syncOutputs sends again the outputs whose regions are stale.142+func (m *Model) syncOutputs() {143+ for _, name := range m.book().StaleOutputs() {144+ m.feedOutput(name)145+ }146+}147+148+// moveOutput gives a sent output its cell's new name.149+func (m *Model) moveOutput(r sheet.Region, name string) {150+ if err := m.book().RenameRegion(r.Name, name); err != nil {151+ m.warn = err.Error()152+ }153+}154+155+// onOutputRegion reports whether the active cell is in an output sent156+// to the sheet.157+func onOutputRegion(m *Model) bool {158+ r, ok := m.sheet.RegionAt(m.cur)159+ return ok && r.Output160+}161+162+// freezeOutput turns the output sent here into plain values.163+func (m *Model) freezeOutput() tea.Cmd {164+ r, _ := m.sheet.RegionAt(m.cur)165+ if err := m.sheet.FreezeRegion(r.Name); err != nil {166+ m.fail(err.Error())167+ return nil168+ }169+ m.note = "Froze " + r.Name + " into values: the cell no longer updates them"170+ return nil171+}172+173+// deleteOutput removes the output sent here.174+func (m *Model) deleteOutput() tea.Cmd {175+ r, _ := m.sheet.RegionAt(m.cur)176+ if err := m.sheet.DeleteRegion(r.Name); err != nil {177+ m.fail(err.Error())178+ return nil179+ }180+ m.note = "Removed " + r.Name + " from " + m.sheet.Name()181+ return nil182+}183+184+// regionLine is the context line on a region: a linked file's state, or185+// the cell an output comes from.186+func (m *Model) regionLine() string {187+ r, ok := m.sheet.RegionAt(m.cur)188+ if !ok {189+ return ""190+ }191+ if r.Linked() {192+ return m.linkedLine() // linked.go193+ }194+ from := "a notebook"195+ for _, s := range m.book().Sheets() {196+ if _, ok := notebook.Names(s.NotebookCells())[r.Name]; ok {197+ from = s.Name()198+ }199+ }200+ return m.th.Muted.Render("Output of " + r.Name + " from " + from + ", nu." + r.Name + " in formulas")201+}
A internal/ui/nbview/bench_test.go +68 -0
@@ -0,0 +1,68 @@
1+package nbview2+3+import (4+ "fmt"5+ "strings"6+ "testing"7+ "time"8+9+ "github.com/FelineStateMachine/012/internal/notebook"10+)11+12+// bigNotebook is 50 code cells, each with a 10,000-row output of four13+// columns, expanded.14+func bigNotebook() (*fakeHost, *View) {15+ var b strings.Builder16+ b.WriteString("[[name, size, when, ok]; ")17+ for i := range 10000 {18+ fmt.Fprintf(&b, "[file%d.txt, %dkb, 2026-09-%02dT10:00:00Z, true], ", i, i%900, i%28+1)19+ }20+ b.WriteString("[last, 1b, 2026-09-01T00:00:00Z, false]]")21+ out := []byte(b.String())22+ h := newHost()23+ for i := range 50 {24+ h.cells = append(h.cells, notebook.Cell{ID: i + 1, Source: fmt.Sprintf("t%d = ls **/* | where size > %dkb | sort-by size", i, i)})25+ h.outs[i+1] = &notebook.Output{NUON: out, Count: i + 1}26+ }27+ v := newView(h, 120, 40)28+ for _, c := range h.cells {29+ v.expand[c.ID] = true30+ }31+ return h, v32+}33+34+// Scrolling a notebook of 50 cells with 10,000-row outputs draws a35+// frame in well under a frame's time, once the outputs are parsed.36+func TestScrollsAtFrameSpeed(t *testing.T) {37+ if testing.Short() {38+ t.Skip("parses 50 large outputs")39+ }40+ _, v := bigNotebook()41+ v.Lines() // parses what shows42+ for range 50 {43+ v.Wheel(10000) // passes every output, parsing it44+ }45+ v.Wheel(-1 << 30)46+ start := time.Now()47+ const frames = 20048+ for range frames {49+ v.Wheel(97)50+ v.Lines()51+ }52+ if per := time.Since(start) / frames; per > 8*time.Millisecond {53+ t.Errorf("a frame takes %s", per)54+ }55+}56+57+func BenchmarkScroll(b *testing.B) {58+ _, v := bigNotebook()59+ for range 60 {60+ v.Wheel(10000)61+ }62+ v.Wheel(-1 << 30)63+ b.ResetTimer()64+ for b.Loop() {65+ v.Wheel(97)66+ v.Lines()67+ }68+}
A internal/ui/nbview/edit.go +355 -0
@@ -0,0 +1,355 @@
1+package nbview2+3+import (4+ "context"5+ "strings"6+ "time"7+8+ tea "charm.land/bubbletea/v2"9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/notebook"12+ "github.com/FelineStateMachine/012/internal/ui/lineedit"13+ "github.com/FelineStateMachine/012/internal/ui/theme"14+)15+16+// Edit mode: the selected cell's source in a multi-line editor17+// (lineedit.Area), soft-wrapped to the cell's width with the caret18+// always on screen. What the language says of it (highlighting,19+// diagnostics) is asked for in the background once typing pauses;20+// completions when Tab asks.21+22+// pause is how long typing pauses before the language is asked.23+const pause = 80 * time.Millisecond24+25+// editor is the cell being edited.26+type editor struct {27+ on bool28+ id int29+ area lineedit.Area30+ version int // counts changes, so answers to older text are dropped31+ diags []Diagnostic32+ diagFor string // the text the diagnostics are of33+ comp []Completion34+ compSel int35+}36+37+func (e *editor) text() string { return e.area.Text() }38+39+func (e *editor) rows(width int) []lineedit.Row { return lineedit.Wrap(e.area.Buf, width) }40+41+// caret is the caret's row and column at width.42+func (e *editor) caret(width int) (int, int) {43+ return lineedit.Caret(e.rows(width), e.area.Buf, e.area.Pos)44+}45+46+// line draws row r of the text being edited.47+func (e *editor) line(th *theme.Theme, r, width int, roles []int8) string {48+ rows := e.rows(width)49+ if r >= len(rows) {50+ return ""51+ }52+ var marks []bool53+ if e.diagFor == e.text() && len(e.diags) > 0 {54+ marks = diagMarks(e.text(), e.diags)55+ }56+ return drawRow(th, e.area.Buf, rows[r], roles, marks)57+}58+59+// diagMarks marks the runes a diagnostic covers.60+func diagMarks(src string, diags []Diagnostic) []bool {61+ marks := make([]bool, len([]rune(src)))62+ ri := 063+ for bi := range src {64+ for _, d := range diags {65+ if bi >= d.From && bi < max(d.To, d.From+1) {66+ marks[ri] = true67+ }68+ }69+ ri++70+ }71+ return marks72+}73+74+// drawRow draws a row of buf in its syntax roles (a role per rune, -175+// for none), marked runes curly-underlined, a continuation row indented.76+func drawRow(th *theme.Theme, buf []rune, row lineedit.Row, roles []int8, marks []bool) string {77+ var b strings.Builder78+ if row.Cont {79+ b.WriteString(strings.Repeat(" ", lineedit.Indent))80+ }81+ i := row.Start82+ for i < row.End {83+ j := i + 184+ for j < row.End && roleAt(roles, j) == roleAt(roles, i) && markAt(marks, j) == markAt(marks, i) {85+ j++86+ }87+ text := string(buf[i:j])88+ r, marked := roleAt(roles, i), markAt(marks, i)89+ switch {90+ case r < 0 && !marked:91+ b.WriteString(text)92+ case r < 0:93+ b.WriteString(th.ErrorMark.Render(text))94+ case marked:95+ b.WriteString(th.Code[r].Inherit(th.ErrorMark).Render(text))96+ default:97+ b.WriteString(th.Code[r].Render(text))98+ }99+ i = j100+ }101+ return b.String()102+}103+104+func roleAt(roles []int8, i int) int8 {105+ if i < len(roles) {106+ return roles[i]107+ }108+ return -1109+}110+111+func markAt(marks []bool, i int) bool { return i < len(marks) && marks[i] }112+113+// syntaxCache keeps what the highlighter said of each source, as a role114+// per rune, and which sources it has been asked about.115+type syntaxCache struct {116+ spans map[string][]Span117+ roles map[string][]int8118+ asked map[string]bool119+ want []string // sources drawn without an answer yet120+}121+122+// Instant is a highlighter quick enough to ask while drawing, as the123+// built-in tokenizer is.124+type Instant interface{ Instant() bool }125+126+// Instant implements Instant: the tokenizer is.127+func (Tokens) Instant() bool { return true }128+129+// spansFor is src's roles, a role per rune, or nil until the130+// highlighter has answered.131+func (v *View) spansFor(src string, kind notebook.Kind) []int8 {132+ hl := v.Providers.Highlighter133+ if kind != notebook.Code || hl == nil {134+ return nil135+ }136+ c := &v.syntax137+ if roles, ok := c.roles[src]; ok {138+ return roles139+ }140+ if in, ok := hl.(Instant); ok && in.Instant() {141+ v.keepSpans(src, hl.Highlight(context.Background(), src))142+ return c.roles[src]143+ }144+ if c.asked == nil {145+ c.asked = map[string]bool{}146+ }147+ if !c.asked[src] {148+ c.asked[src] = true149+ c.want = append(c.want, src)150+ }151+ return nil152+}153+154+// keepSpans keeps what the highlighter said of src.155+func (v *View) keepSpans(src string, spans []Span) {156+ c := &v.syntax157+ if c.roles == nil || len(c.roles) > 512 {158+ c.roles, c.spans = map[string][]int8{}, map[string][]Span{}159+ }160+ c.spans[src] = spans161+ roles := make([]int8, len([]rune(src)))162+ for i := range roles {163+ roles[i] = -1164+ }165+ ri := 0166+ for bi := range src {167+ for _, s := range spans {168+ if bi >= s.From && bi < s.To {169+ roles[ri] = int8(s.Kind)170+ }171+ }172+ ri++173+ }174+ c.roles[src] = roles175+}176+177+// Messages the view sends itself.178+type (179+ // highlightMsg is the highlighter's answer about src.180+ highlightMsg struct {181+ src string182+ spans []Span183+ }184+ // pausedMsg is typing having paused at version.185+ pausedMsg struct {186+ view *View187+ version int188+ }189+ // checkedMsg is the checker's answer about src.190+ checkedMsg struct {191+ view *View192+ src string193+ diags []Diagnostic194+ }195+ // completedMsg is the completer's answer for version.196+ completedMsg struct {197+ view *View198+ version int199+ comp []Completion200+ }201+)202+203+// Fetch asks the highlighter about the sources drawn without an answer,204+// in the background.205+func (v *View) Fetch() tea.Cmd {206+ hl := v.Providers.Highlighter207+ want := v.syntax.want208+ v.syntax.want = nil209+ if hl == nil || len(want) == 0 {210+ return nil211+ }212+ cmds := make([]tea.Cmd, len(want))213+ for i, src := range want {214+ cmds[i] = func() tea.Msg { return highlightMsg{src: src, spans: hl.Highlight(context.Background(), src)} }215+ }216+ return tea.Batch(cmds...)217+}218+219+// Update takes the view's own messages, reporting whether msg was one.220+func (v *View) Update(msg tea.Msg) (tea.Cmd, bool) {221+ switch msg := msg.(type) {222+ case highlightMsg:223+ v.keepSpans(msg.src, msg.spans)224+ if v.syntax.asked != nil {225+ delete(v.syntax.asked, msg.src)226+ }227+ case pausedMsg:228+ if msg.view == v && v.edit.on && msg.version == v.edit.version {229+ return v.check(), true230+ }231+ case checkedMsg:232+ if msg.view == v {233+ v.edit.diags, v.edit.diagFor = msg.diags, msg.src234+ }235+ case completedMsg:236+ if msg.view == v && v.edit.on && msg.version == v.edit.version {237+ v.edit.comp, v.edit.compSel = msg.comp, 0238+ if len(msg.comp) == 1 {239+ v.complete()240+ }241+ }242+ default:243+ return nil, false244+ }245+ return nil, true246+}247+248+// changed notes that the text changed: the language is asked about it249+// once typing pauses.250+func (v *View) changed() tea.Cmd {251+ v.edit.version++252+ v.edit.comp = nil253+ if v.Providers.Checker == nil && (v.Providers.Highlighter == nil || isInstant(v.Providers.Highlighter)) {254+ return nil255+ }256+ version := v.edit.version257+ return tea.Tick(pause, func(time.Time) tea.Msg { return pausedMsg{view: v, version: version} })258+}259+260+func isInstant(hl Highlighter) bool {261+ in, ok := hl.(Instant)262+ return ok && in.Instant()263+}264+265+// check asks the highlighter and checker about the text being edited.266+func (v *View) check() tea.Cmd {267+ src := v.edit.text()268+ var cmds []tea.Cmd269+ if hl := v.Providers.Highlighter; hl != nil && !isInstant(hl) {270+ cmds = append(cmds, func() tea.Msg { return highlightMsg{src: src, spans: hl.Highlight(context.Background(), src)} })271+ }272+ if ck := v.Providers.Checker; ck != nil {273+ cmds = append(cmds, func() tea.Msg { return checkedMsg{view: v, src: src, diags: ck.Check(context.Background(), src)} })274+ }275+ return tea.Batch(cmds...)276+}277+278+// askCompletions asks what can go at the caret.279+func (v *View) askCompletions() tea.Cmd {280+ cp := v.Providers.Completer281+ if cp == nil {282+ return nil283+ }284+ src, off, version := v.edit.text(), v.edit.area.Offset(), v.edit.version285+ return func() tea.Msg {286+ return completedMsg{view: v, version: version, comp: cp.Complete(context.Background(), src, off)}287+ }288+}289+290+// complete puts the highlighted completion in place.291+func (v *View) complete() {292+ e := &v.edit293+ if len(e.comp) == 0 {294+ return295+ }296+ c := e.comp[e.compSel]297+ src := e.text()298+ from, to := min(c.From, len(src)), min(c.To, len(src))299+ e.area.SetText(src[:from] + c.Text)300+ tail := src[to:]301+ pos := e.area.Pos302+ e.area.InsertText(tail)303+ e.area.Pos = pos304+ e.comp = nil305+ e.version++306+}307+308+// Diagnostic is the problem at the caret, for the context line.309+func (v *View) Diagnostic() string {310+ e := &v.edit311+ if !e.on || e.diagFor != e.text() {312+ return ""313+ }314+ off := e.area.Offset()315+ for _, d := range e.diags {316+ if off >= d.From && off <= max(d.To, d.From+1) {317+ return d.Msg318+ }319+ }320+ if len(e.diags) > 0 {321+ return e.diags[0].Msg322+ }323+ return ""324+}325+326+// Completions are the completions shown, and the highlighted one.327+func (v *View) Completions() ([]Completion, int) { return v.edit.comp, v.edit.compSel }328+329+// completionBox draws the completions under the caret's row: at most330+// eight, the highlighted one in reverse video.331+func (v *View) completionBox(th *theme.Theme) []string {332+ comp, sel := v.edit.comp, v.edit.compSel333+ if len(comp) == 0 {334+ return nil335+ }336+ first := max(0, min(sel-7, len(comp)-8))337+ shown := comp[first:min(first+8, len(comp))]338+ tw, dw := 0, 0339+ for _, c := range shown {340+ tw = max(tw, ansi.StringWidth(c.Text))341+ dw = max(dw, ansi.StringWidth(c.Desc))342+ }343+ inner := min(tw+2+dw+2, max(v.width-gutter-4, 10))344+ lines := make([]string, len(shown))345+ for i, c := range shown {346+ text := ansi.Truncate(" "+c.Text+" "+c.Desc, inner, "…")347+ text += strings.Repeat(" ", max(inner-ansi.StringWidth(text), 0))348+ if first+i == sel {349+ lines[i] = th.MenuSelected.Render(text)350+ } else {351+ lines[i] = th.MenuBar.Render(text)352+ }353+ }354+ return th.Frame(inner, "Completions", "", lines)355+}
A internal/ui/nbview/full.go +298 -0
@@ -0,0 +1,298 @@
1+package nbview2+3+import (4+ "cmp"5+ "slices"6+ "strconv"7+ "strings"8+9+ tea "charm.land/bubbletea/v2"10+ "github.com/charmbracelet/x/ansi"11+12+ "github.com/FelineStateMachine/012/internal/locale"13+ "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/ui/lineedit"15+ "github.com/FelineStateMachine/012/internal/ui/theme"16+)17+18+// Full is an output shown full-screen, read-only: a table as a grid19+// with a pointer, sorted by a column (s, S) and filtered to the rows20+// holding some text (/), without changing the output; anything else as21+// its lines, scrolled. Esc goes back to the notebook.22+type Full struct {23+ Title string24+ sh *shown25+ order []int // the rows shown, in the order shown26+ sort int // the column sorted by, plus one; 0 for the output's order27+ desc bool28+ // filter is the text rows must hold; typing is set while it's typed.29+ filter lineedit.Line30+ typing bool31+32+ row, col int // the pointer33+ top, left int34+ width, height int35+}36+37+func newFull(title string, sh *shown, width, height int) *Full {38+ f := &Full{Title: title, sh: sh}39+ f.resize(width, height)40+ f.apply()41+ return f42+}43+44+func (f *Full) resize(width, height int) { f.width, f.height = width, max(height, 2) }45+46+// table reports whether the output is a table.47+func (f *Full) table() bool { return f.sh.kind == outTable }48+49+// apply works out the rows shown from the filter and the sort.50+func (f *Full) apply() {51+ if !f.table() {52+ return53+ }54+ f.order = f.order[:0]55+ needle := strings.ToLower(f.filter.Text())56+ for i, row := range f.sh.rows {57+ if needle == "" || slices.ContainsFunc(row, func(lc sheet.LiveCell) bool {58+ return strings.Contains(strings.ToLower(cellText(lc, locale.Canonical)), needle)59+ }) {60+ f.order = append(f.order, i)61+ }62+ }63+ if c := f.sort - 1; c >= 0 {64+ slices.SortStableFunc(f.order, func(a, b int) int {65+ x, y := cellAt(f.sh.rows[a], c), cellAt(f.sh.rows[b], c)66+ if f.desc {67+ return compareCells(y, x)68+ }69+ return compareCells(x, y)70+ })71+ }72+ f.row = min(f.row, max(len(f.order)-1, 0))73+}74+75+func cellAt(row []sheet.LiveCell, c int) sheet.LiveCell {76+ if c < len(row) {77+ return row[c]78+ }79+ return sheet.LiveCell{}80+}81+82+// compareCells orders values as a sort does: numbers before text,83+// blanks last.84+func compareCells(a, b sheet.LiveCell) int {85+ ae, be := a.V.Kind == sheet.Empty, b.V.Kind == sheet.Empty86+ switch {87+ case ae || be:88+ return cmp.Compare(boolInt(ae), boolInt(be))89+ case a.V.Kind == sheet.Number && b.V.Kind == sheet.Number:90+ return cmp.Compare(a.V.Num, b.V.Num)91+ case a.V.Kind == sheet.Number:92+ return -193+ case b.V.Kind == sheet.Number:94+ return 195+ }96+ return cmp.Compare(strings.ToLower(a.V.String()), strings.ToLower(b.V.String()))97+}98+99+func boolInt(b bool) int {100+ if b {101+ return 1102+ }103+ return 0104+}105+106+// rowsShown is how many rows (or lines) there are to scroll through.107+func (f *Full) rowsShown() int {108+ if f.table() {109+ return len(f.order)110+ }111+ return f.sh.height(true, f.width-1)112+}113+114+// Key takes a key, reporting whether Esc left the view.115+func (f *Full) Key(k tea.KeyPressMsg) (closed bool) {116+ key := k.String()117+ if f.typing {118+ switch key {119+ case "esc":120+ f.typing = false121+ f.filter.Clear()122+ case "enter":123+ f.typing = false124+ default:125+ f.filter.Key(k)126+ }127+ f.apply()128+ return false129+ }130+ page := max(f.height-2, 1)131+ switch key {132+ case "esc", "q":133+ return true134+ case "up", "k":135+ f.row--136+ case "down", "j":137+ f.row++138+ case "left", "h":139+ f.col--140+ case "right", "l":141+ f.col++142+ case "pgup":143+ f.row -= page144+ case "pgdown", "space":145+ f.row += page146+ case "home", "g":147+ f.row = 0148+ case "end", "G":149+ f.row = f.rowsShown() - 1150+ case "s", "S":151+ f.sortBy(f.col+1, key == "S")152+ case "/":153+ f.typing = true154+ }155+ f.settle()156+ return false157+}158+159+// sortBy sorts by column c (counted from 1), again to go back to the160+// output's order.161+func (f *Full) sortBy(c int, desc bool) {162+ if !f.table() {163+ return164+ }165+ if f.sort == c && f.desc == desc {166+ f.sort = 0167+ } else {168+ f.sort, f.desc = c, desc169+ }170+ f.apply()171+}172+173+// settle keeps the pointer on the table and on screen.174+func (f *Full) settle() {175+ f.row = max(min(f.row, f.rowsShown()-1), 0)176+ if f.table() {177+ f.col = max(min(f.col, len(f.sh.cols)-1), 0)178+ f.left = min(f.left, f.col)179+ for f.left < f.col && f.colX(f.col)+f.sh.fit[f.col] > f.width {180+ f.left++181+ }182+ }183+ rows := f.height - 1184+ if !f.table() {185+ rows = f.height186+ }187+ f.top = max(min(f.top, f.row), f.row-rows+1, 0)188+}189+190+// numW is the row numbers' width.191+func (f *Full) numW() int { return len(strconv.Itoa(max(len(f.sh.rows), 1))) + 1 }192+193+// colX is where column c starts on screen, with the columns from left.194+func (f *Full) colX(c int) int {195+ x := f.numW() + 1196+ for i := f.left; i < c; i++ {197+ x += f.sh.fit[i] + 2198+ }199+ return x200+}201+202+// lines draws the view.203+func (f *Full) lines(th *theme.Theme, loc *locale.Locale) []string {204+ out := make([]string, 0, f.height)205+ if !f.table() {206+ for i := f.top; i < f.top+f.height && i < f.rowsShown(); i++ {207+ line := f.sh.line(th, loc, i, true, f.width-1)208+ if i == f.row {209+ line = th.Selection.Render("▌") + line210+ } else {211+ line = " " + line212+ }213+ out = append(out, line)214+ }215+ return pad2Lines(out, f.height)216+ }217+ out = append(out, f.header(th))218+ for i := f.top; i < f.top+f.height-1 && i < len(f.order); i++ {219+ out = append(out, f.rowLine(th, loc, i))220+ }221+ return pad2Lines(out, f.height)222+}223+224+func pad2Lines(out []string, n int) []string {225+ for len(out) < n {226+ out = append(out, "")227+ }228+ return out229+}230+231+// header is the column names, the sorted one marked ▲ or ▼, the232+// pointer's column's in reverse video.233+func (f *Full) header(th *theme.Theme) string {234+ var b strings.Builder235+ b.WriteString(th.Header.Render(strings.Repeat(" ", f.numW()+1)))236+ for c := f.left; c < len(f.sh.cols) && f.colX(c) < f.width; c++ {237+ name := f.sh.cols[c]238+ if f.sort == c+1 {239+ name += map[bool]string{false: " ▲", true: " ▼"}[f.desc]240+ }241+ style := th.Header242+ if c == f.col {243+ style = th.HeaderActive244+ }245+ b.WriteString(style.Render(pad(ansi.Truncate(name, f.sh.fit[c], "…"), f.sh.fit[c], sheet.AlignLeft)))246+ b.WriteString(th.Header.Render(" "))247+ }248+ return ansi.Truncate(b.String(), f.width, "")249+}250+251+// rowLine is the i-th row shown, numbered as the output numbers it.252+func (f *Full) rowLine(th *theme.Theme, loc *locale.Locale, i int) string {253+ r := f.order[i]254+ num := th.Header255+ if i == f.row {256+ num = th.HeaderActive257+ }258+ var b strings.Builder259+ b.WriteString(num.Render(pad(strconv.Itoa(r+1), f.numW(), sheet.AlignRight)) + " ")260+ row := f.sh.rows[r]261+ for c := f.left; c < len(f.sh.cols) && f.colX(c) < f.width; c++ {262+ lc := cellAt(row, c)263+ text := pad(ansi.Truncate(cellText(lc, loc), f.sh.fit[c], "…"), f.sh.fit[c], align(lc))264+ if i == f.row && c == f.col {265+ text = th.Pointer.Render(text)266+ }267+ b.WriteString(text + " ")268+ }269+ return ansi.Truncate(b.String(), f.width, "")270+}271+272+// ContextLine says what's shown and the keys.273+func (f *Full) ContextLine(th *theme.Theme) (string, string) {274+ if f.typing {275+ return th.Key.Render("Filter: ") + f.filter.Text(), th.KeyHints("Enter", "keep", "Esc", "clear")276+ }277+ what := f.Title278+ if f.table() {279+ what += " " + more(len(f.order), "row")280+ if f.filter.Text() != "" {281+ what += " holding " + strconv.Quote(f.filter.Text())282+ }283+ if f.sort > 0 {284+ what += ", sorted by " + f.sh.cols[f.sort-1]285+ }286+ return th.Muted.Render(what), th.KeyHints("s", "sort", "S", "descending", "/", "filter", "Esc", "back")287+ }288+ return th.Muted.Render(what), th.KeyHints("Esc", "back")289+}290+291+// Cursor is where the terminal's caret goes while the filter is typed,292+// on the context line after "Filter: ".293+func (f *Full) Cursor() (int, bool) {294+ if !f.typing {295+ return 0, false296+ }297+ return ansi.StringWidth("Filter: " + f.filter.Head()), true298+}
A internal/ui/nbview/keys.go +366 -0
@@ -0,0 +1,366 @@
1+package nbview2+3+import (4+ "strconv"5+ "strings"6+ "time"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/notebook"11+)12+13+// Keys. Command mode moves between cells itself (Up and Down, j and k,14+// Home and End, PgUp and PgDn) and runs the commands Keys binds for the15+// rest; a key it doesn't know is the UI's (menus, other sheets, Save).16+// Edit mode types into the cell, with EditKeys' commands (Esc, running)17+// and Tab completing.18+19+// Editing reports whether a cell is being edited.20+func (v *View) Editing() bool { return v.edit.on }21+22+// FullOpen reports whether an output is shown full-screen.23+func (v *View) FullOpen() bool { return v.full != nil }24+25+// Pending is the first key of a pair waiting for its second: "d" or26+// "0".27+func (v *View) Pending() string { return v.pending }28+29+// Key takes a key, reporting whether the notebook took it.30+func (v *View) Key(k tea.KeyPressMsg) (tea.Cmd, bool) {31+ switch {32+ case v.full != nil:33+ if v.full.Key(k) {34+ v.full = nil35+ }36+ return nil, true37+ case v.edit.on:38+ return v.editKey(k)39+ }40+ return v.commandKey(k)41+}42+43+func (v *View) commandKey(k tea.KeyPressMsg) (tea.Cmd, bool) {44+ key := k.String()45+ if p := v.pending; p != "" {46+ v.pending = ""47+ if id, ok := v.Keys[p+" "+key]; ok {48+ return v.h.Run(id), true49+ }50+ }51+ if v.move(key) {52+ return nil, true53+ }54+ for pair := range v.Keys {55+ if first, _, ok := strings.Cut(pair, " "); ok && first == key {56+ v.pending = key57+ return nil, true58+ }59+ }60+ if id, ok := v.Keys[key]; ok {61+ return v.h.Run(id), true62+ }63+ return nil, false64+}65+66+// move moves the selection for a movement key.67+func (v *View) move(key string) bool {68+ n := len(v.h.Cells())69+ switch key {70+ case "up", "k":71+ v.step(-1)72+ case "down", "j":73+ v.step(1)74+ case "home", "ctrl+home":75+ v.Select(0, false)76+ case "end", "ctrl+end":77+ v.Select(n-1, false)78+ case "pgup":79+ v.top = max(v.top-v.height+1, 0)80+ v.selectAtTop()81+ case "pgdown":82+ v.top += v.height - 183+ v.selectAtTop()84+ default:85+ return false86+ }87+ return true88+}89+90+// step moves the selection one place: from a cell to its output, if it91+// shows one, and from an output to the next cell.92+func (v *View) step(d int) {93+ cells := v.h.Cells()94+ if len(cells) == 0 {95+ return96+ }97+ hasOut := func(i int) bool { return v.shown(cells[i]).kind != outNone }98+ switch {99+ case d > 0 && !v.onOut && hasOut(v.sel):100+ v.onOut = true101+ case d > 0 && v.sel < len(cells)-1:102+ v.sel, v.onOut = v.sel+1, false103+ case d < 0 && v.onOut:104+ v.onOut = false105+ case d < 0 && v.sel > 0:106+ v.sel--107+ v.onOut = hasOut(v.sel)108+ }109+ v.follow()110+}111+112+// selectAtTop selects the first cell whose head shows after a page113+// moved the view.114+func (v *View) selectAtTop() {115+ cells := v.h.Cells()116+ total := 0117+ for i, c := range cells {118+ total += v.blockOf(i, c).lines()119+ }120+ v.top = max(min(v.top, total-v.height), 0)121+ at := 0122+ for i, c := range cells {123+ if at >= v.top {124+ v.sel, v.onOut = i, false125+ return126+ }127+ at += v.blockOf(i, c).lines()128+ }129+ v.sel, v.onOut = max(len(cells)-1, 0), false130+}131+132+func (v *View) editKey(k tea.KeyPressMsg) (tea.Cmd, bool) {133+ key := k.String()134+ e := &v.edit135+ if len(e.comp) > 0 {136+ switch key {137+ case "tab", "down", "ctrl+n":138+ e.compSel = (e.compSel + 1) % len(e.comp)139+ return nil, true140+ case "shift+tab", "up", "ctrl+p":141+ e.compSel = (e.compSel + len(e.comp) - 1) % len(e.comp)142+ return nil, true143+ case "enter":144+ v.complete()145+ return v.changed(), true146+ case "esc":147+ e.comp = nil148+ return nil, true149+ }150+ e.comp = nil151+ }152+ if id, ok := v.EditKeys[key]; ok {153+ return v.h.Run(id), true154+ }155+ if key == "tab" {156+ return v.askCompletions(), true157+ }158+ before := e.text()159+ if !e.area.Key(k, v.content()) {160+ return nil, true // a key edit mode doesn't use does nothing161+ }162+ v.follow()163+ if e.text() != before {164+ return v.changed(), true165+ }166+ return nil, true167+}168+169+// Paste types text into the cell being edited.170+func (v *View) Paste(text string) tea.Cmd {171+ if !v.edit.on {172+ return nil173+ }174+ v.edit.area.InsertText(text)175+ v.follow()176+ return v.changed()177+}178+179+// StartEdit edits the selected cell, the caret at the end of its source.180+func (v *View) StartEdit() tea.Cmd {181+ c, ok := v.Cell()182+ if !ok {183+ return nil184+ }185+ v.full, v.onOut = nil, false186+ v.edit = editor{on: true, id: c.ID}187+ v.edit.area.SetText(c.Source)188+ v.follow()189+ return v.changed()190+}191+192+// StopEdit leaves edit mode, keeping what was typed.193+func (v *View) StopEdit() {194+ if !v.edit.on {195+ return196+ }197+ id, text := v.edit.id, v.edit.text()198+ v.edit = editor{}199+ for _, c := range v.h.Cells() {200+ if c.ID == id && c.Source != text {201+ v.h.Edit(id, text)202+ }203+ }204+ v.follow()205+}206+207+// Commit keeps what's typed as the cell's source, still editing.208+func (v *View) Commit() {209+ if !v.edit.on {210+ return211+ }212+ for _, c := range v.h.Cells() {213+ if c.ID == v.edit.id && c.Source != v.edit.text() {214+ v.h.Edit(c.ID, v.edit.text())215+ }216+ }217+}218+219+// OpenFull shows the selected cell's output full-screen.220+func (v *View) OpenFull() bool {221+ c, ok := v.Cell()222+ if !ok {223+ return false224+ }225+ sh := v.shown(c)226+ if sh.kind == outNone {227+ return false228+ }229+ title := "Output"230+ if name := c.Name(); name != "" {231+ title = name232+ }233+ if o := v.h.Output(c.ID); o != nil && o.Count > 0 {234+ title += " [" + itoa(o.Count) + "]"235+ }236+ v.full = newFull(title, sh, v.width, v.height)237+ return true238+}239+240+// CloseFull goes back from a full-screen output.241+func (v *View) CloseFull() { v.full = nil }242+243+// Full is the output shown full-screen, or nil.244+func (v *View) Full() *Full { return v.full }245+246+// Cursor is where the terminal's caret goes in the body, while a cell247+// is edited.248+func (v *View) Cursor() (x, y int, ok bool) {249+ if !v.edit.on {250+ return 0, 0, false251+ }252+ cells := v.h.Cells()253+ at := 0254+ for i := range v.sel {255+ at += v.blockOf(i, cells[i]).lines()256+ }257+ row, col := v.edit.caret(v.content())258+ y = at + 1 + row - v.top259+ if y < 0 || y >= v.height {260+ return 0, 0, false261+ }262+ return gutter + col, y, true263+}264+265+// tap is a click, for telling a double click.266+type tap struct {267+ at time.Time268+ cell int269+}270+271+// Click selects what's under line y of the body, its output or the272+// cell; clicking the cell again quickly edits it.273+func (v *View) Click(y int) tea.Cmd {274+ if v.full != nil || v.edit.on {275+ return nil276+ }277+ cells := v.h.Cells()278+ at := 0279+ for i, c := range cells {280+ b := v.blockOf(i, c)281+ if line := v.top + y - at; line >= 0 && line < b.lines() {282+ double := v.lastTap.cell == i && time.Since(v.lastTap.at) < 400*time.Millisecond283+ v.lastTap = tap{at: time.Now(), cell: i}284+ v.Select(i, line > b.head+b.src-1 && line < b.lines()-1 && b.out > 0)285+ if double && !v.onOut {286+ return v.h.Run("nb.edit")287+ }288+ return nil289+ }290+ at += b.lines()291+ }292+ return nil293+}294+295+// Wheel scrolls the body d lines.296+func (v *View) Wheel(d int) {297+ if v.full != nil {298+ v.full.row += d299+ v.full.settle()300+ return301+ }302+ cells := v.h.Cells()303+ total := 0304+ for i, c := range cells {305+ total += v.blockOf(i, c).lines()306+ }307+ v.top = max(min(v.top+d, total-v.height), 0)308+}309+310+// Head is what the formula bar says of the selection: the cell's name311+// or number, and what it reads, or what its output holds.312+func (v *View) Head() (name, text string) {313+ c, ok := v.Cell()314+ if !ok {315+ return "empty", ""316+ }317+ i, _ := v.Selected()318+ name = "cell " + itoa(i+1)319+ if n := c.Name(); n != "" {320+ name = n321+ }322+ if c.Kind == notebook.Note {323+ return name, "Markdown"324+ }325+ if v.onOut {326+ return name, v.outputSummary(c)327+ }328+ var reads []string329+ for _, r := range notebook.Refs(c.Pipeline()) {330+ reads = append(reads, "$"+r)331+ }332+ if notebook.ReadsSelection(c.Pipeline()) {333+ reads = append(reads, "$selection")334+ }335+ if _, refs := notebook.Bind(c.Pipeline()); len(refs) > 0 {336+ for _, r := range refs {337+ reads = append(reads, "$sheet."+r.Ref)338+ }339+ }340+ if len(reads) == 0 {341+ return name, "nushell"342+ }343+ return name, "reads " + strings.Join(reads, ", ")344+}345+346+// outputSummary says what an output holds.347+func (v *View) outputSummary(c notebook.Cell) string {348+ sh := v.shown(c)349+ switch sh.kind {350+ case outTable:351+ return "a table: " + more(sh.total, "row") + ", " + more(len(sh.cols), "column")352+ case outRecord:353+ return "a record: " + more(sh.total, "field")354+ case outList:355+ return "a list: " + more(sh.total, "item")356+ case outText:357+ return "text: " + more(sh.total, "line")358+ case outError:359+ return "an error"360+ case outUnsaved:361+ return "not saved in the file; run the cell to see it"362+ }363+ return "a value"364+}365+366+func itoa(n int) string { return strconv.Itoa(n) }
A internal/ui/nbview/markdown.go +167 -0
@@ -0,0 +1,167 @@
1+package nbview2+3+import (4+ "strings"5+6+ "charm.land/lipgloss/v2"7+ "github.com/charmbracelet/x/ansi"8+9+ "github.com/FelineStateMachine/012/internal/ui/theme"10+)11+12+// Note cells are Markdown, drawn as terminal text: headings bold (the13+// first level underlined too), **bold**, *italic* and _italic_, `code`14+// in the code's color, links as hyperlinks the terminal opens (OSC 8),15+// list items with a bullet, quotes behind a bar, fenced code as it is,16+// and paragraphs wrapped to the width. Each cue survives without color:17+// the text's own attributes, bullets and bars.18+19+// markdown draws src at width as lines.20+func markdown(th *theme.Theme, src string, width int) []string {21+ var out []string22+ fenced := false23+ for line := range strings.SplitSeq(src, "\n") {24+ trimmed := strings.TrimSpace(line)25+ if strings.HasPrefix(trimmed, "```") {26+ fenced = !fenced27+ continue28+ }29+ if fenced {30+ out = append(out, wrapStyled(th.Code[theme.SyntaxString], line, width)...)31+ continue32+ }33+ out = append(out, mdLine(th, line, trimmed, width)...)34+ }35+ return out36+}37+38+// mdLine draws one line of Markdown outside a fence.39+func mdLine(th *theme.Theme, line, trimmed string, width int) []string {40+ switch {41+ case trimmed == "":42+ return []string{""}43+ case strings.Trim(trimmed, "-*_ ") == "" && len(strings.ReplaceAll(trimmed, " ", "")) >= 3:44+ return []string{th.Muted.Render(strings.Repeat("─", max(width, 1)))}45+ case strings.HasPrefix(trimmed, "#"):46+ level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))47+ text := strings.TrimSpace(trimmed[level:])48+ style := th.Title49+ if level == 1 {50+ style = style.Underline(true)51+ }52+ return prefixed("", "", inline(th, style, text), width)53+ case strings.HasPrefix(trimmed, "> "):54+ return prefixed(th.Muted.Render("│ "), th.Muted.Render("│ "), inline(th, lipgloss.NewStyle(), trimmed[2:]), width)55+ }56+ indent := line[:len(line)-len(strings.TrimLeft(line, " "))]57+ if item, ok := listItem(trimmed); ok {58+ lead := indent + "• "59+ return prefixed(lead, strings.Repeat(" ", ansi.StringWidth(lead)), inline(th, lipgloss.NewStyle(), item), width)60+ }61+ if n, item, ok := numbered(trimmed); ok {62+ lead := indent + n + " "63+ return prefixed(lead, strings.Repeat(" ", ansi.StringWidth(lead)), inline(th, lipgloss.NewStyle(), item), width)64+ }65+ return prefixed("", "", inline(th, lipgloss.NewStyle(), trimmed), width)66+}67+68+func listItem(s string) (string, bool) {69+ for _, m := range []string{"- ", "* ", "+ "} {70+ if rest, ok := strings.CutPrefix(s, m); ok {71+ return rest, true72+ }73+ }74+ return "", false75+}76+77+func numbered(s string) (string, string, bool) {78+ i := 079+ for i < len(s) && isDigit(s[i]) {80+ i++81+ }82+ if i == 0 || i+1 >= len(s) || s[i] != '.' && s[i] != ')' || s[i+1] != ' ' {83+ return "", "", false84+ }85+ return s[:i+1], s[i+2:], true86+}87+88+// prefixed wraps styled text to width after first on its first line and89+// rest on the others.90+func prefixed(first, rest, styled string, width int) []string {91+ room := max(width-ansi.StringWidth(first), 4)92+ lines := strings.Split(ansi.Wrap(styled, room, " -"), "\n")93+ for i := range lines {94+ if i == 0 {95+ lines[i] = first + lines[i]96+ } else {97+ lines[i] = rest + lines[i]98+ }99+ }100+ return lines101+}102+103+func wrapStyled(style lipgloss.Style, s string, width int) []string {104+ var out []string105+ for _, l := range wrapText(s, max(width, 4)) {106+ out = append(out, style.Render(l))107+ }108+ return out109+}110+111+// inline draws a line's inline Markdown on base: emphasis, code spans112+// and links.113+func inline(th *theme.Theme, base lipgloss.Style, s string) string {114+ var b strings.Builder115+ plain := func(t string) {116+ if t != "" {117+ b.WriteString(base.Render(t))118+ }119+ }120+ for s != "" {121+ i := strings.IndexAny(s, "*_`[")122+ if i < 0 {123+ plain(s)124+ break125+ }126+ plain(s[:i])127+ s = s[i:]128+ text, rest, style, link, ok := span(th, base, s)129+ if !ok {130+ plain(s[:1])131+ s = s[1:]132+ continue133+ }134+ if link != "" {135+ style = style.Hyperlink(link)136+ }137+ b.WriteString(style.Render(text))138+ s = rest139+ }140+ return b.String()141+}142+143+// span reads the inline span s starts with: its text, what follows it,144+// its style and a link's URL.145+func span(th *theme.Theme, base lipgloss.Style, s string) (text, rest string, style lipgloss.Style, link string, ok bool) {146+ switch {147+ case strings.HasPrefix(s, "**") || strings.HasPrefix(s, "__"):148+ if j := strings.Index(s[2:], s[:2]); j > 0 {149+ return s[2 : 2+j], s[4+j:], base.Bold(true), "", true150+ }151+ case s[0] == '*' || s[0] == '_':152+ if j := strings.IndexByte(s[1:], s[0]); j > 0 {153+ return s[1 : 1+j], s[2+j:], base.Italic(true), "", true154+ }155+ case s[0] == '`':156+ if j := strings.IndexByte(s[1:], '`'); j >= 0 {157+ return s[1 : 1+j], s[2+j:], th.Code[theme.SyntaxCommand], "", true158+ }159+ case s[0] == '[':160+ if j := strings.Index(s, "]("); j > 0 {161+ if k := strings.IndexByte(s[j:], ')'); k > 0 {162+ return s[1:j], s[j+k+1:], base.Inherit(th.Link), s[j+2 : j+k], true163+ }164+ }165+ }166+ return "", "", base, "", false167+}
A internal/ui/nbview/output.go +407 -0
@@ -0,0 +1,407 @@
1+package nbview2+3+import (4+ "slices"5+ "strconv"6+ "strings"7+8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/fileio"11+ "github.com/FelineStateMachine/012/internal/locale"12+ "github.com/FelineStateMachine/012/internal/notebook"13+ "github.com/FelineStateMachine/012/internal/nuon"14+ "github.com/FelineStateMachine/012/internal/sheet"15+ "github.com/FelineStateMachine/012/internal/ui/theme"16+)17+18+// Outputs as they show under a code cell. What a cell printed, NUON, is19+// parsed once into a shown output: a table (a list of records), a20+// record, a list, text, or a single value; values are the cells21+// importing them would make, formatted as cells are. A long output shows22+// a window of its first rows and says how many more there are, until o23+// expands it; its lines are drawn only when they're on screen, so an24+// expanded output of ten thousand rows costs the rows showing.25+26+// window is how many rows (or lines) an output shows collapsed.27+const window = 1028+29+// maxColWidth caps how wide fitting makes a column, as a sheet's.30+const maxColWidth = 3031+32+// outKind is what an output holds.33+type outKind int34+35+const (36+ outNone outKind = iota // nothing printed37+ outTable // a list of records38+ outRecord // a record: its fields, one a line39+ outList // a list of values that aren't records40+ outText // a string, its lines41+ outValue // a single value42+ outError // the run failed43+ outUnsaved // too large for the file to keep44+ outBad // what nu printed isn't NUON45+)46+47+// shown is an output parsed for showing.48+type shown struct {49+ kind outKind50+ cols []string // a table's column names, a record's keys51+ rows [][]sheet.LiveCell // a table's rows, a list's items, a record's values, a value52+ text []string // text's lines, or an error's53+ fit []int // the columns' fitted widths54+ total int // rows or lines in all55+ wrap []string // text wrapped at wrapW56+ wrapW int57+}58+59+// parse reads an output for showing.60+func parse(o *notebook.Output) *shown {61+ switch {62+ case o == nil:63+ return &shown{}64+ case o.Err != "":65+ text := []string{o.Err}66+ if o.Detail != "" {67+ text = append(text, o.Detail)68+ }69+ return &shown{kind: outError, text: text, total: len(text)}70+ case o.Unsaved:71+ return &shown{kind: outUnsaved, total: 1}72+ case len(o.NUON) == 0:73+ return &shown{}74+ }75+ v, err := nuon.Parse(o.NUON)76+ if err != nil {77+ return &shown{kind: outBad, text: []string{"nu's output isn't NUON: " + err.Error()}, total: 1}78+ }79+ sh := shownOf(v)80+ sh.fitColumns()81+ return sh82+}83+84+// shownOf is how value v shows.85+func shownOf(v nuon.Value) *shown {86+ switch v.Kind {87+ case nuon.Null:88+ return &shown{}89+ case nuon.String:90+ lines := strings.Split(strings.TrimRight(v.Str, "\n"), "\n")91+ return &shown{kind: outText, text: lines, total: len(lines)}92+ case nuon.Record:93+ sh := &shown{kind: outRecord, total: len(v.Fields)}94+ for _, f := range v.Fields {95+ sh.cols = append(sh.cols, f.Key)96+ sh.rows = append(sh.rows, []sheet.LiveCell{fileio.NUONCell(f.Value)})97+ }98+ return sh99+ case nuon.List:100+ if len(v.List) == 0 {101+ return &shown{}102+ }103+ if isTable(v.List) {104+ return tableOf(v.List)105+ }106+ sh := &shown{kind: outList, total: len(v.List)}107+ for _, it := range v.List {108+ sh.rows = append(sh.rows, []sheet.LiveCell{fileio.NUONCell(it)})109+ }110+ return sh111+ }112+ return &shown{kind: outValue, rows: [][]sheet.LiveCell{{fileio.NUONCell(v)}}, total: 1}113+}114+115+// isTable reports whether a list is records, as nushell draws a table.116+func isTable(list []nuon.Value) bool {117+ for _, it := range list {118+ if it.Kind != nuon.Record {119+ return false120+ }121+ }122+ return true123+}124+125+// tableOf is a list of records as a table: the columns in the order the126+// records name them.127+func tableOf(list []nuon.Value) *shown {128+ sh := &shown{kind: outTable, total: len(list)}129+ index := map[string]int{}130+ for _, rec := range list {131+ for _, f := range rec.Fields {132+ if _, ok := index[f.Key]; !ok {133+ index[f.Key] = len(sh.cols)134+ sh.cols = append(sh.cols, f.Key)135+ }136+ }137+ }138+ sh.rows = make([][]sheet.LiveCell, len(list))139+ for i, rec := range list {140+ row := make([]sheet.LiveCell, len(sh.cols))141+ for _, f := range rec.Fields {142+ row[index[f.Key]] = fileio.NUONCell(f.Value)143+ }144+ sh.rows[i] = row145+ }146+ return sh147+}148+149+// fitSample is how many rows at each end fitting the columns reads of a150+// table too long to read whole.151+const fitSample = 1000152+153+// fitColumns sizes a table's columns to their widest text, the header154+// included, at most maxColWidth: all its rows, or the first and last155+// of a long table.156+func (sh *shown) fitColumns() {157+ if sh.kind != outTable {158+ return159+ }160+ sh.fit = make([]int, len(sh.cols))161+ for c, name := range sh.cols {162+ sh.fit[c] = ansi.StringWidth(name)163+ }164+ rows := sh.rows165+ if len(rows) > 20*fitSample {166+ rows = append(slices.Clip(rows[:fitSample]), rows[len(rows)-fitSample:]...)167+ }168+ for _, row := range rows {169+ for c, lc := range row {170+ sh.fit[c] = max(sh.fit[c], ansi.StringWidth(cellText(lc, locale.Canonical)))171+ }172+ }173+ for c := range sh.fit {174+ sh.fit[c] = min(max(sh.fit[c], 1), maxColWidth)175+ }176+}177+178+// cellText is a value as a cell shows it, whole.179+func cellText(lc sheet.LiveCell, loc *locale.Locale) string {180+ if lc.V.Kind == sheet.Empty {181+ return ""182+ }183+ return strings.ReplaceAll(sheet.FormatTextIn(lc.V, lc.F, loc), "\n", " ")184+}185+186+// height is how many lines the output takes, collapsed or expanded, at187+// width.188+func (sh *shown) height(expanded bool, width int) int {189+ switch sh.kind {190+ case outNone:191+ return 0192+ case outTable:193+ n := sh.total194+ if !expanded && n > window {195+ n = window196+ }197+ return 1 + n + sh.footerLines(expanded, width)198+ case outText, outError:199+ n := len(sh.wrapped(width))200+ if !expanded && n > window {201+ return window + 1202+ }203+ return n204+ case outRecord, outList:205+ if !expanded && sh.total > window {206+ return window + 1207+ }208+ return sh.total209+ }210+ return 1211+}212+213+// footerLines is 1 when a table has more to say under its rows: rows or214+// columns it doesn't show.215+func (sh *shown) footerLines(expanded bool, width int) int {216+ if !expanded && sh.total > window || sh.hiddenCols(width) > 0 {217+ return 1218+ }219+ return 0220+}221+222+// shownCols is how many of a table's columns fit in width.223+func (sh *shown) shownCols(width int) int {224+ x := 0225+ for c, w := range sh.fit {226+ if c > 0 {227+ x += 2228+ }229+ if x+w > width && c > 0 {230+ return c231+ }232+ x += w233+ }234+ return len(sh.fit)235+}236+237+func (sh *shown) hiddenCols(width int) int { return len(sh.fit) - sh.shownCols(width) }238+239+// line draws line i of the output at width.240+func (sh *shown) line(th *theme.Theme, loc *locale.Locale, i int, expanded bool, width int) string {241+ switch sh.kind {242+ case outTable:243+ return sh.tableLine(th, loc, i, expanded, width)244+ case outText, outError:245+ return sh.textLine(th, i, expanded, width)246+ case outRecord, outList:247+ if !expanded && sh.total > window && i == window {248+ return th.Muted.Render("… " + more(sh.total-window, "more line") + " (o shows all)")249+ }250+ return sh.fieldLine(th, loc, i, width)251+ case outValue:252+ return ansi.Truncate(cellText(sh.rows[0][0], loc), width, "…")253+ case outUnsaved:254+ return th.Muted.Render("not saved; run to see")255+ case outBad:256+ return th.Warning.Render(ansi.Truncate(sh.text[0], width, "…"))257+ }258+ return ""259+}260+261+// more counts what's left out: "9,991 more rows".262+func more(n int, what string) string {263+ s := strconv.Itoa(n)264+ for i := len(s) - 3; i > 0; i -= 3 {265+ s = s[:i] + "," + s[i:]266+ }267+ if n != 1 {268+ what += "s"269+ }270+ return s + " " + what271+}272+273+// tableLine is a table's header (i 0), a row, or its footer.274+func (sh *shown) tableLine(th *theme.Theme, loc *locale.Locale, i int, expanded bool, width int) string {275+ ncols := sh.shownCols(width)276+ rows := sh.total277+ if !expanded && rows > window {278+ rows = window279+ }280+ switch {281+ case i == 0:282+ var b strings.Builder283+ for c := range ncols {284+ if c > 0 {285+ b.WriteString(" ")286+ }287+ b.WriteString(th.OutputHead.Render(pad(ansi.Truncate(sh.cols[c], sh.fit[c], "…"), sh.fit[c], sheet.AlignLeft)))288+ }289+ return b.String()290+ case i <= rows:291+ row := sh.rows[i-1]292+ var b strings.Builder293+ for c := range ncols {294+ if c > 0 {295+ b.WriteString(" ")296+ }297+ var lc sheet.LiveCell298+ if c < len(row) {299+ lc = row[c]300+ }301+ b.WriteString(pad(ansi.Truncate(cellText(lc, loc), sh.fit[c], "…"), sh.fit[c], align(lc)))302+ }303+ return b.String()304+ }305+ var parts []string306+ if n := sh.total - rows; n > 0 {307+ parts = append(parts, more(n, "more row"))308+ }309+ if n := sh.hiddenCols(width); n > 0 {310+ parts = append(parts, more(n, "more column"))311+ }312+ hint := "Enter opens it"313+ if !expanded && sh.total > window {314+ hint = "o shows all, " + hint315+ }316+ return th.Muted.Render("… " + strings.Join(parts, ", ") + " (" + hint + ")")317+}318+319+// align is how a value lines up in its column, as in a cell.320+func align(lc sheet.LiveCell) sheet.Align {321+ switch lc.V.Kind {322+ case sheet.Number:323+ if lc.F.Kind == sheet.FmtText {324+ return sheet.AlignLeft325+ }326+ return sheet.AlignRight327+ case sheet.Bool, sheet.Error:328+ return sheet.AlignCenter329+ }330+ return sheet.AlignLeft331+}332+333+func pad(s string, w int, a sheet.Align) string {334+ gap := max(w-ansi.StringWidth(s), 0)335+ switch a {336+ case sheet.AlignRight:337+ return strings.Repeat(" ", gap) + s338+ case sheet.AlignCenter:339+ return strings.Repeat(" ", gap/2) + s + strings.Repeat(" ", gap-gap/2)340+ }341+ return s + strings.Repeat(" ", gap)342+}343+344+// fieldLine is a record's field, key: value, or a list's item, its345+// index first as nushell shows one.346+func (sh *shown) fieldLine(th *theme.Theme, loc *locale.Locale, i, width int) string {347+ if i >= len(sh.rows) {348+ return ""349+ }350+ var key string351+ if sh.kind == outRecord {352+ kw := 0353+ for _, k := range sh.cols {354+ kw = max(kw, ansi.StringWidth(k))355+ }356+ kw = min(kw, 24)357+ key = th.OutputHead.Render(pad(ansi.Truncate(sh.cols[i], kw, "…"), kw, sheet.AlignLeft)) + " "358+ } else {359+ n := len(strconv.Itoa(sh.total - 1))360+ key = th.Muted.Render(pad(strconv.Itoa(i), n, sheet.AlignRight)) + " "361+ }362+ room := max(width-ansi.StringWidth(key), 1)363+ return key + ansi.Truncate(cellText(sh.rows[i][0], loc), room, "…")364+}365+366+// textLine is line i of text or an error, wrapped at width.367+func (sh *shown) textLine(th *theme.Theme, i int, expanded bool, width int) string {368+ lines := sh.wrapped(width)369+ if !expanded && len(lines) > window && i == window {370+ return th.Muted.Render("… " + more(len(lines)-window, "more line") + " (o shows all)")371+ }372+ if i >= len(lines) {373+ return ""374+ }375+ if sh.kind == outError {376+ if i == 0 {377+ return th.Warning.Render(lines[i])378+ }379+ return th.Muted.Render(lines[i])380+ }381+ return lines[i]382+}383+384+// wrapped is the text's lines wrapped at width, kept for the width last385+// asked.386+func (sh *shown) wrapped(width int) []string {387+ if sh.wrapW != width || sh.wrap == nil {388+ sh.wrap = sh.wrap[:0]389+ for i, l := range sh.text {390+ if sh.kind == outError && i == 0 {391+ l = "× " + l392+ }393+ sh.wrap = append(sh.wrap, wrapText(l, width)...)394+ }395+ sh.wrapW = width396+ }397+ return sh.wrap398+}399+400+// wrapText breaks a line of text into lines at most width wide, at401+// spaces where it can.402+func wrapText(s string, width int) []string {403+ if width < 1 || ansi.StringWidth(s) <= width {404+ return []string{s}405+ }406+ return strings.Split(ansi.Wrap(s, width, " "), "\n")407+}
A internal/ui/nbview/panel.go +112 -0
@@ -0,0 +1,112 @@
1+package nbview2+3+import (4+ "slices"5+ "strings"6+7+ "github.com/charmbracelet/x/ansi"8+9+ "github.com/FelineStateMachine/012/internal/ui/overlay"10+ "github.com/FelineStateMachine/012/internal/ui/theme"11+)12+13+// What the notebook puts on the control panel: the context line's14+// hints and messages, and the completions' box.15+16+// keyLabel is how a key of Keys shows: "d d" as dd, others as chips do.17+func keyLabel(k string) string {18+ if a, b, ok := strings.Cut(k, " "); ok {19+ return a + b20+ }21+ if len(k) == 1 {22+ return k23+ }24+ return theme.KeyLabel(k)25+}26+27+// keyFor is the key bound to command id in keys, the shortest first.28+func keyFor(keys map[string]string, id string) string {29+ var found []string30+ for k, c := range keys {31+ if c == id {32+ found = append(found, k)33+ }34+ }35+ slices.SortFunc(found, func(a, b string) int {36+ if len(a) != len(b) {37+ return len(a) - len(b)38+ }39+ return strings.Compare(a, b)40+ })41+ if len(found) == 0 {42+ return ""43+ }44+ return keyLabel(found[0])45+}46+47+// hints are key hints for commands, leaving out those without a key.48+func (v *View) hints(keys map[string]string, pairs ...string) string {49+ var out []string50+ for i := 0; i+1 < len(pairs); i += 2 {51+ if k := keyFor(keys, pairs[i]); k != "" {52+ out = append(out, k, pairs[i+1])53+ }54+ }55+ return v.h.Theme().KeyHints(out...)56+}57+58+// ContextLine is what the context line says in the notebook: a problem,59+// a key waiting for its pair, or the keys that apply.60+func (v *View) ContextLine() (string, string) {61+ th := v.h.Theme()62+ switch {63+ case v.full != nil:64+ return v.full.ContextLine(th)65+ case v.edit.on:66+ if d := v.Diagnostic(); d != "" {67+ return th.Warning.Render(d), v.hints(v.EditKeys, "nb.run_next", "run", "nb.command_mode", "done")68+ }69+ return v.hints(v.EditKeys, "nb.run_next", "run", "nb.run", "run here") + " " + th.KeyHints("Tab", "complete"),70+ v.hints(v.EditKeys, "nb.command_mode", "done")71+ case v.pending == "d":72+ return th.Hint.Render("d again deletes the cell"), th.KeyHints("Esc", "cancel")73+ case v.pending == "0":74+ return th.Hint.Render("0 again restarts: every output is cleared"), th.KeyHints("Esc", "cancel")75+ }76+ c, ok := v.Cell()77+ if !ok {78+ return v.hints(v.Keys, "nb.insert_below", "add a cell"), ""79+ }80+ st := v.h.State(c.ID)81+ if st.Problem != "" {82+ return th.Warning.Render(st.Problem), ""83+ }84+ if o := v.h.Output(c.ID); o.Failed() && !v.onOut {85+ return th.Warning.Render("Failed: " + o.Err), v.hints(v.Keys, "nb.edit", "edit")86+ }87+ if v.onOut {88+ return v.hints(v.Keys, "nb.open_output", "open", "nb.toggle_output", "all or less", "nb.send", "send to a sheet"), ""89+ }90+ return v.hints(v.Keys, "nb.edit", "edit", "nb.run_next", "run", "nb.insert_above", "add above", "nb.insert_below", "below",91+ "nb.delete", "delete", "nb.send", "send to a sheet"), ""92+}93+94+// Boxes are the completions' box, under the caret, for a body whose95+// first line is at screen row top.96+func (v *View) Boxes(top int) []overlay.Box {97+ if !v.edit.on || len(v.edit.comp) == 0 {98+ return nil99+ }100+ lines := v.completionBox(v.h.Theme())101+ x, y, ok := v.Cursor()102+ if !ok || len(lines) == 0 {103+ return nil104+ }105+ caret := top + y106+ y = caret + 1107+ if y+len(lines) > top+v.height {108+ y = max(caret-len(lines), 0) // no room below: above the caret109+ }110+ x = max(min(x, v.width-ansi.StringWidth(lines[0])), 0)111+ return []overlay.Box{{ID: "nbcomplete", X: x, Y: y, Lines: lines}}112+}
A internal/ui/nbview/syntax.go +241 -0
@@ -0,0 +1,241 @@
1+package nbview2+3+import (4+ "context"5+ "slices"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/notebook"9+ "github.com/FelineStateMachine/012/internal/ui/theme"10+)11+12+// What the code editor asks of the language, behind small interfaces so13+// better answers (nushell's own --ide-ast, --ide-complete and14+// --ide-check) can replace the built-in ones: highlighting, completion15+// and diagnostics. Each is asked in the background, after typing16+// pauses, never while a key is being answered; until it answers, the17+// code shows plain, or as it last did.18+19+// Span is a stretch of a cell's source, by byte offsets, and what it is.20+type Span struct {21+ From, To int22+ Kind theme.Syntax23+}24+25+// Highlighter says what each part of a pipeline is.26+type Highlighter interface {27+ Highlight(ctx context.Context, src string) []Span28+}29+30+// Completion is a word Tab can put at the caret: Text in place of31+// src[From:To].32+type Completion struct {33+ Text string34+ Desc string35+ From, To int36+}37+38+// Completer lists what can go at the caret, a byte offset into src.39+type Completer interface {40+ Complete(ctx context.Context, src string, offset int) []Completion41+}42+43+// Diagnostic is a problem with part of a pipeline, by byte offsets.44+type Diagnostic struct {45+ From, To int46+ Msg string47+}48+49+// Checker finds the problems in a pipeline.50+type Checker interface {51+ Check(ctx context.Context, src string) []Diagnostic52+}53+54+// Providers are the language's answers for a notebook; any may be nil.55+type Providers struct {56+ Highlighter Highlighter57+ Completer Completer58+ Checker Checker59+}60+61+// Tokens is the built-in highlighter: a small tokenizer of nushell's62+// shapes, which knows commands by where they stand rather than by name.63+type Tokens struct{}64+65+// keywords are nushell's words that aren't commands' names.66+var keywords = []string{"let", "mut", "const", "def", "if", "else", "for", "while", "loop", "match", "try", "catch",67+ "return", "break", "continue", "true", "false", "null", "use", "export", "module", "alias"}68+69+// operators are the words nushell reads as operators.70+var operators = []string{"and", "or", "not", "xor", "in", "not-in", "like", "not-like", "mod", "starts-with", "ends-with", "bit-and", "bit-or"}71+72+// Highlight implements Highlighter.73+func (Tokens) Highlight(_ context.Context, src string) []Span {74+ t := tokenizer{src: src, command: true}75+ if name, _ := notebook.SplitName(src); name != "" {76+ t.i = strings.Index(src, name) + len(name)77+ t.add(t.i-len(name), theme.SyntaxVariable) // the cell's name, as $name reads it78+ }79+ for t.i < len(t.src) {80+ if !t.punct() {81+ t.word()82+ }83+ }84+ return t.out85+}86+87+// tokenizer reads a pipeline's tokens, knowing whether a word where it88+// stands is a command's name.89+type tokenizer struct {90+ src string91+ i int92+ command bool93+ out []Span94+}95+96+func (t *tokenizer) add(from int, k theme.Syntax) { t.out = append(t.out, Span{from, t.i, k}) }97+98+// punct reads what isn't a bare word, reporting whether there was some.99+func (t *tokenizer) punct() bool {100+ src, i := t.src, t.i101+ c := src[i]102+ start := i103+ switch {104+ case c == '\n' || c == ';' || c == '(' || c == '{' || c == '|' && !strings.HasPrefix(src[i:], "||"):105+ t.i++106+ if c == '|' {107+ t.add(start, theme.SyntaxOperator)108+ }109+ t.command = true110+ case c == ' ' || c == '\t' || c == '\r' || c == ')' || c == '}' || c == '[' || c == ']' || c == ',':111+ t.i++112+ case c == '#' && (i == 0 || isSpace(src[i-1])):113+ t.i = lineEnd(src, i)114+ t.add(start, theme.SyntaxComment)115+ case c == '"' || c == '\'' || c == '`':116+ t.i = quoted(src, i)117+ t.add(start, theme.SyntaxString)118+ t.command = false119+ case c == '$':120+ t.i = wordEnd(src, i+1)121+ t.add(start, theme.SyntaxVariable)122+ t.command = false123+ case strings.ContainsRune("=!<>+-*/~", rune(c)) && !isWordStart(src, i):124+ for t.i < len(src) && strings.ContainsRune("=!<>+-*/~", rune(src[t.i])) {125+ t.i++126+ }127+ t.add(start, theme.SyntaxOperator)128+ t.command = src[start:t.i] == "="129+ default:130+ return false131+ }132+ return true133+}134+135+// word reads a bare word: a number, a keyword, an operator's name, a136+// command's name where one stands, or an argument.137+func (t *tokenizer) word() {138+ start := t.i139+ t.i = wordEnd(t.src, t.i)140+ if t.i == start {141+ t.i++142+ return143+ }144+ w := t.src[start:t.i]145+ switch {146+ case isDigit(w[0]) || w[0] == '-' && len(w) > 1 && isDigit(w[1]):147+ t.add(start, theme.SyntaxNumber)148+ case slices.Contains(keywords, w):149+ t.add(start, theme.SyntaxKeyword)150+ t.command = w == "else" || w == "try"151+ return152+ case slices.Contains(operators, w) && !t.command:153+ t.add(start, theme.SyntaxOperator)154+ return155+ case t.command && !strings.HasPrefix(w, "-"):156+ t.add(start, theme.SyntaxCommand)157+ }158+ t.command = false159+}160+161+func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }162+func isDigit(c byte) bool { return c >= '0' && c <= '9' }163+164+// isWordStart reports whether the - or + at i starts a word: a flag or165+// a negative number.166+func isWordStart(src string, i int) bool {167+ return (src[i] == '-' || src[i] == '+') && i+1 < len(src) && !isSpace(src[i+1]) && !strings.ContainsRune("=<>", rune(src[i+1])) &&168+ (i == 0 || isSpace(src[i-1]) || src[i-1] == '(' || src[i-1] == '[')169+}170+171+// wordEnd is where a bare word from i ends.172+func wordEnd(src string, i int) int {173+ for i < len(src) && !isSpace(src[i]) && !strings.ContainsRune("|;(){}[],\"'`", rune(src[i])) {174+ i++175+ }176+ return i177+}178+179+func lineEnd(src string, i int) int {180+ for i < len(src) && src[i] != '\n' {181+ i++182+ }183+ return i184+}185+186+// quoted is where a string starting at i ends: its closing quote, a187+// double-quoted string's escapes skipped, or the end.188+func quoted(src string, i int) int {189+ q := src[i]190+ for i++; i < len(src); i++ {191+ switch {192+ case q == '"' && src[i] == '\\':193+ i++194+ case src[i] == q:195+ return i + 1196+ }197+ }198+ return len(src)199+}200+201+// Word is a completion the built-in completer offers.202+type Word struct {203+ Text string // $sales, or a command: sort-by204+ Desc string205+}206+207+// Words is the built-in completer: the notebook's names at a $, nu's208+// commands where a command goes.209+type Words func() []Word210+211+// Complete implements Completer.212+func (ws Words) Complete(_ context.Context, src string, offset int) []Completion {213+ offset = min(offset, len(src))214+ start := offset215+ for start > 0 && !isSpace(src[start-1]) && !strings.ContainsRune("|(;{[", rune(src[start-1])) {216+ start--217+ }218+ w := src[start:offset]219+ before := strings.TrimRight(src[:start], " \t")220+ command := before == "" || strings.ContainsAny(before[len(before)-1:], "|(;{\n") ||221+ strings.HasSuffix(before, "=") && !strings.HasSuffix(before, "==")222+ if !strings.HasPrefix(w, "$") && (!command || w == "") {223+ return nil224+ }225+ lw := strings.ToLower(w)226+ var out, contains []Completion227+ for _, it := range ws() {228+ if strings.HasPrefix(it.Text, "$") != strings.HasPrefix(w, "$") {229+ continue230+ }231+ c := Completion{Text: it.Text, Desc: it.Desc, From: start, To: offset}232+ switch t := strings.ToLower(it.Text); {233+ case t == lw:234+ case strings.HasPrefix(t, lw):235+ out = append(out, c)236+ case len(lw) > 1 && strings.Contains(t, lw):237+ contains = append(contains, c)238+ }239+ }240+ return append(out, contains...)241+}
A internal/ui/nbview/view.go +404 -0
@@ -0,0 +1,404 @@
1+// Package nbview draws a notebook tab and takes its keys: the cells one2+// under another, each a head line (its run count, name and state), its3+// source (a pipeline, soft-wrapped so none of it is ever hidden, or4+// Markdown drawn as text) and a code cell's output under it. Like5+// Jupyter it has two modes: in command mode keys act on cells (move,6+// add, delete, run), in edit mode they type into the selected cell. It7+// knows the UI only through Host: the cells and outputs, the state of8+// runs, and running the registered commands every action is.9+package nbview10+11+import (12+ "strconv"13+ "strings"14+ "time"15+16+ tea "charm.land/bubbletea/v2"17+ "github.com/charmbracelet/x/ansi"18+19+ "github.com/FelineStateMachine/012/internal/locale"20+ "github.com/FelineStateMachine/012/internal/notebook"21+ "github.com/FelineStateMachine/012/internal/ui/lineedit"22+ "github.com/FelineStateMachine/012/internal/ui/theme"23+)24+25+// Host is what the notebook needs of the UI.26+type Host interface {27+ Theme() *theme.Theme28+ Locale() *locale.Locale29+ // Cells are the notebook's cells, and Output a cell's output.30+ Cells() []notebook.Cell31+ Output(id int) *notebook.Output32+ // State is how a cell's run stands.33+ State(id int) State34+ // Run runs a registered command, as its key would.35+ Run(command string) tea.Cmd36+ // Edit keeps a cell's new source, as one undo step.37+ Edit(id int, source string)38+}39+40+// State is how a cell's run stands, as the runner knows it.41+type State struct {42+ Waiting, Running bool43+ Started time.Time // when it started running44+ Stale bool // what it read, or its source, changed since it ran45+ // Problem says why it can't run as it is: its name is taken.46+ Problem string47+}48+49+// View is a notebook tab's view and its state: the selected cell, the50+// scroll position, the mode, and what's worked out from the cells.51+type View struct {52+ h Host53+ // Keys bind command mode's keys to commands, EditKeys edit mode's;54+ // "d d" is d pressed twice.55+ Keys, EditKeys map[string]string56+ Providers Providers57+58+ sel int // the selected cell59+ onOut bool // the selection is the cell's output60+ top int // the first line of the cells shown61+ // width and height are the body's, as last drawn.62+ width, height int63+ pending string // the first key of a pair: "d" of "d d"64+65+ edit editor // the cell being edited, if any66+ full *Full // an output shown full-screen, if any67+ expand map[int]bool68+ outs map[*notebook.Output]*shown69+ syntax syntaxCache70+ lastTap tap // the last click, for double clicks71+}72+73+// New returns a view of the notebook h shows.74+func New(h Host) *View {75+ return &View{h: h, width: 80, height: 16, expand: map[int]bool{}, outs: map[*notebook.Output]*shown{}, syntax: syntaxCache{spans: map[string][]Span{}}}76+}77+78+// gutter is the columns left of every line: a mark and a space.79+const gutter = 280+81+// Selected is the selected cell's index, and whether its output is82+// selected rather than the cell.83+func (v *View) Selected() (int, bool) {84+ v.clamp()85+ return v.sel, v.onOut86+}87+88+// Select selects cell i, or its output with out.89+func (v *View) Select(i int, out bool) {90+ v.sel, v.onOut = i, out91+ v.clamp()92+ v.follow()93+}94+95+// Cell is the selected cell, if there is one.96+func (v *View) Cell() (notebook.Cell, bool) {97+ cells := v.h.Cells()98+ v.clamp()99+ if len(cells) == 0 {100+ return notebook.Cell{}, false101+ }102+ return cells[v.sel], true103+}104+105+// clamp keeps the selection on a cell there is, and on an output only106+// where one shows.107+func (v *View) clamp() {108+ cells := v.h.Cells()109+ v.sel = max(min(v.sel, len(cells)-1), 0)110+ if v.onOut && (len(cells) == 0 || v.shown(cells[v.sel]).kind == outNone) {111+ v.onOut = false112+ }113+}114+115+// shown is a cell's output as it shows, parsed once.116+func (v *View) shown(c notebook.Cell) *shown {117+ o := v.h.Output(c.ID)118+ if c.Kind != notebook.Code || o == nil {119+ return &shown{}120+ }121+ sh := v.outs[o]122+ if sh == nil {123+ if len(v.outs) > 256 {124+ clear(v.outs) // outputs replaced by runs since125+ }126+ sh = parse(o)127+ v.outs[o] = sh128+ }129+ return sh130+}131+132+// Expanded reports whether a cell's output shows whole.133+func (v *View) Expanded(id int) bool { return v.expand[id] }134+135+// Toggle shows the selected cell's output whole, or its window again.136+func (v *View) Toggle() {137+ if c, ok := v.Cell(); ok {138+ v.expand[c.ID] = !v.expand[c.ID]139+ v.follow()140+ }141+}142+143+// content is how wide a cell's source and output are drawn.144+func (v *View) content() int { return max(v.width-gutter-1, 8) }145+146+// block is what a cell takes on the screen, in lines: its head, its147+// source, its output, and a blank line after.148+type block struct {149+ head, src, out int150+}151+152+func (b block) lines() int { return b.head + b.src + b.out + 1 }153+154+// blockOf lays out cell c.155+func (v *View) blockOf(i int, c notebook.Cell) block {156+ b := block{head: 1}157+ switch {158+ case v.edit.on && i == v.sel:159+ b.src = len(v.edit.rows(v.content()))160+ case c.Kind == notebook.Note:161+ b.src = len(v.noteLines(c))162+ default:163+ b.src = len(lineedit.Wrap([]rune(c.Source), v.content()))164+ }165+ if c.Kind == notebook.Code {166+ b.out = v.shown(c).height(v.expand[c.ID], v.content())167+ }168+ return b169+}170+171+// noteLines are a note cell's lines as drawn.172+func (v *View) noteLines(c notebook.Cell) []string {173+ if strings.TrimSpace(c.Source) == "" {174+ return []string{v.h.Theme().Muted.Render("An empty note: Enter writes it, in Markdown")}175+ }176+ return markdown(v.h.Theme(), c.Source, v.content())177+}178+179+// Resize sets the body's size: the lines under the tab's bar and above180+// the status line.181+func (v *View) Resize(width, height int) {182+ height = max(height, 1)183+ if width == v.width && height == v.height {184+ return185+ }186+ v.width, v.height = width, height187+ if v.full != nil {188+ v.full.resize(width, height)189+ v.full.settle()190+ }191+ v.follow()192+}193+194+// follow scrolls so the selection shows: the selected cell's head, or195+// its output's first lines, or the caret while editing.196+func (v *View) follow() {197+ cells := v.h.Cells()198+ at := 0199+ for i := range cells {200+ if i == v.sel {201+ break202+ }203+ at += v.blockOf(i, cells[i]).lines()204+ }205+ if len(cells) == 0 {206+ v.top = 0207+ return208+ }209+ b := v.blockOf(v.sel, cells[v.sel])210+ first, last := at, at+min(b.lines()-1, v.height-1)211+ switch {212+ case v.edit.on:213+ row, _ := v.edit.caret(v.content())214+ first = at + 1 + row215+ last = first216+ case v.onOut:217+ first = at + b.head + b.src218+ last = min(first+b.out-1, first+v.height-1)219+ }220+ if first < v.top {221+ v.top = first222+ }223+ if last >= v.top+v.height {224+ v.top = last - v.height + 1225+ }226+ v.top = max(v.top, 0)227+}228+229+// Lines draws the body: height lines, each width wide or less.230+func (v *View) Lines() []string {231+ if v.full != nil {232+ return v.full.lines(v.h.Theme(), v.h.Locale())233+ }234+ v.clamp()235+ out := make([]string, 0, v.height)236+ cells := v.h.Cells()237+ if len(cells) == 0 {238+ th := v.h.Theme()239+ out = append(out, "", " "+th.Muted.Render("An empty notebook: ")+th.KeyHints("b", "add a code cell", "m", "make it a note"))240+ }241+ at := 0242+ for i, c := range cells {243+ b := v.blockOf(i, c)244+ if at+b.lines() <= v.top {245+ at += b.lines()246+ continue247+ }248+ for k := range b.lines() {249+ if n := at + k; n >= v.top && len(out) < v.height {250+ out = append(out, v.blockLine(i, c, b, k))251+ }252+ }253+ at += b.lines()254+ if len(out) >= v.height {255+ break256+ }257+ }258+ for len(out) < v.height {259+ out = append(out, "")260+ }261+ return out262+}263+264+// blockLine draws line k of cell i's block.265+func (v *View) blockLine(i int, c notebook.Cell, b block, k int) string {266+ th := v.h.Theme()267+ switch {268+ case k == 0:269+ return v.headLine(i, c)270+ case k <= b.src:271+ return th.Border.Render("│") + " " + v.sourceLine(i, c, k-1)272+ case k <= b.src+b.out:273+ mark := " "274+ if i == v.sel && v.onOut {275+ mark = th.Selection.Render("▌") + " "276+ }277+ return mark + v.shown(c).line(th, v.h.Locale(), k-1-b.src, v.expand[c.ID], v.content())278+ }279+ return ""280+}281+282+// headLine is a cell's head: its run count and name, and at the right283+// its state and how long it took.284+func (v *View) headLine(i int, c notebook.Cell) string {285+ th := v.h.Theme()286+ left, right := v.headText(c)287+ w := max(v.width-1, 1)288+ right = ansi.Truncate(right, max(w-ansi.StringWidth(left)-2, 0), "…")289+ text := left + strings.Repeat(" ", max(w-ansi.StringWidth(left)-ansi.StringWidth(right), 1)) + right290+ switch {291+ case i == v.sel && v.edit.on:292+ return th.Pointer.Render(text)293+ case i == v.sel && !v.onOut:294+ return th.Selection.Render(text)295+ }296+ st := v.h.State(c.ID)297+ if st.Stale && !st.Running && !st.Waiting {298+ k := strings.LastIndex(text, "stale")299+ return th.CellHead.Render(text[:k]) + th.Stale.Render("stale") + th.CellHead.Render(text[k+len("stale"):])300+ }301+ return th.CellHead.Render(text)302+}303+304+// headText is what a cell's head says: [3] sales at the left, and its305+// state at the right.306+func (v *View) headText(c notebook.Cell) (string, string) {307+ if c.Kind == notebook.Note {308+ return " Markdown", ""309+ }310+ st := v.h.State(c.ID)311+ o := v.h.Output(c.ID)312+ count := "[ ]"313+ switch {314+ case st.Running:315+ count = "[*]"316+ case o != nil && o.Count > 0:317+ count = "[" + strconv.Itoa(o.Count) + "]"318+ }319+ left := " " + count320+ if name := c.Name(); name != "" {321+ left += " " + name322+ }323+ var right string324+ switch {325+ case st.Problem != "":326+ right = st.Problem327+ case st.Running:328+ right = running(time.Since(st.Started))329+ case st.Waiting:330+ right = "waiting"331+ case o == nil:332+ right = "not run"333+ case o.Failed():334+ right = "failed" + took(o)335+ case st.Stale:336+ right = "stale" + took(o)337+ case o.Count == 0:338+ right = "saved"339+ default:340+ right = "✓" + took(o)341+ }342+ return left, right + " "343+}344+345+// spinner turns while a cell runs.346+var spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}347+348+// running is what a running cell's head says: after a few seconds, a349+// spinner and how long it has run.350+func running(d time.Duration) string {351+ if d < 3*time.Second {352+ return "running"353+ }354+ return spinner[int(d/(100*time.Millisecond))%len(spinner)] + " running " + duration(d)355+}356+357+func took(o *notebook.Output) string {358+ if o.Took <= 0 {359+ return ""360+ }361+ return " " + duration(o.Took)362+}363+364+// duration is a run's length: under a second as <1s, so a fast cell365+// always reads the same, then seconds, then minutes.366+func duration(d time.Duration) string {367+ switch {368+ case d < time.Second:369+ return "<1s"370+ case d < 10*time.Second:371+ return strconv.FormatFloat(d.Seconds(), 'f', 1, 64) + "s"372+ case d < time.Minute:373+ return strconv.Itoa(int(d.Seconds())) + "s"374+ }375+ return strconv.Itoa(int(d.Minutes())) + "m" + pad2(int(d.Seconds())%60) + "s"376+}377+378+func pad2(n int) string {379+ if n < 10 {380+ return "0" + strconv.Itoa(n)381+ }382+ return strconv.Itoa(n)383+}384+385+// sourceLine is row r of a cell's source: the editor's while it's386+// edited, a note's Markdown, or a pipeline highlighted.387+func (v *View) sourceLine(i int, c notebook.Cell, r int) string {388+ if v.edit.on && i == v.sel {389+ return v.edit.line(v.h.Theme(), r, v.content(), v.spansFor(v.edit.text(), c.Kind))390+ }391+ if c.Kind == notebook.Note {392+ lines := v.noteLines(c)393+ if r < len(lines) {394+ return lines[r]395+ }396+ return ""397+ }398+ buf := []rune(c.Source)399+ rows := lineedit.Wrap(buf, v.content())400+ if r >= len(rows) {401+ return ""402+ }403+ return drawRow(v.h.Theme(), buf, rows[r], v.spansFor(c.Source, c.Kind), nil)404+}
A internal/ui/nbview/view_test.go +238 -0
@@ -0,0 +1,238 @@
1+package nbview2+3+import (4+ "context"5+ "fmt"6+ "strings"7+ "testing"8+9+ tea "charm.land/bubbletea/v2"10+ "github.com/charmbracelet/x/ansi"11+12+ "github.com/FelineStateMachine/012/internal/locale"13+ "github.com/FelineStateMachine/012/internal/notebook"14+ "github.com/FelineStateMachine/012/internal/ui/theme"15+)16+17+// fakeHost is a notebook of cells and outputs; commands run are noted.18+type fakeHost struct {19+ th theme.Theme20+ cells []notebook.Cell21+ outs map[int]*notebook.Output22+ states map[int]State23+ ran []string24+ edits []string25+ onRun func(id string)26+}27+28+func newHost(srcs ...string) *fakeHost {29+ h := &fakeHost{th: theme.New(true), outs: map[int]*notebook.Output{}, states: map[int]State{}}30+ for i, s := range srcs {31+ h.cells = append(h.cells, notebook.Cell{ID: i + 1, Source: s})32+ }33+ return h34+}35+36+func (h *fakeHost) Theme() *theme.Theme { return &h.th }37+func (h *fakeHost) Locale() *locale.Locale { return locale.Canonical }38+func (h *fakeHost) Cells() []notebook.Cell { return h.cells }39+func (h *fakeHost) Output(id int) *notebook.Output { return h.outs[id] }40+func (h *fakeHost) State(id int) State { return h.states[id] }41+func (h *fakeHost) Run(id string) tea.Cmd {42+ h.ran = append(h.ran, id)43+ if h.onRun != nil {44+ h.onRun(id)45+ }46+ return nil47+}48+func (h *fakeHost) Edit(id int, src string) {49+ h.edits = append(h.edits, src)50+ for i := range h.cells {51+ if h.cells[i].ID == id {52+ h.cells[i].Source = src53+ }54+ }55+}56+57+func newView(h *fakeHost, w, ht int) *View {58+ v := New(h)59+ v.Keys = map[string]string{"enter": "nb.edit", "d d": "nb.delete", "o": "nb.toggle_output", "shift+enter": "nb.run_next"}60+ v.EditKeys = map[string]string{"esc": "nb.command_mode", "shift+enter": "nb.run_next"}61+ v.Providers = Providers{Highlighter: Tokens{}}62+ v.Resize(w, ht)63+ return v64+}65+66+func text(v *View) string { return ansi.Strip(strings.Join(v.Lines(), "\n")) }67+68+func key(s string) tea.KeyPressMsg {69+ named := map[string]tea.Key{"enter": {Code: tea.KeyEnter}, "esc": {Code: tea.KeyEscape}, "up": {Code: tea.KeyUp},70+ "down": {Code: tea.KeyDown}, "tab": {Code: tea.KeyTab}, "home": {Code: tea.KeyHome}, "end": {Code: tea.KeyEnd}}71+ if k, ok := named[s]; ok {72+ return tea.KeyPressMsg(k)73+ }74+ return tea.KeyPressMsg{Code: rune(s[0]), Text: s}75+}76+77+func TestOutputsShow(t *testing.T) {78+ h := newHost("files = ls", "{a: 1}", `"x"`, "nope", "big", "[1 2]")79+ h.outs[1] = &notebook.Output{NUON: []byte("[[name, size, ok]; [a.txt, 2kb, true], [bb.txt, 10b, false]]"), Count: 1}80+ h.outs[2] = &notebook.Output{NUON: []byte("{a: 1, long_key: \"two words\"}"), Count: 2}81+ h.outs[3] = &notebook.Output{NUON: []byte(`"line one\nline two"`), Count: 3}82+ h.outs[4] = &notebook.Output{Err: "Command `nope` not found", Detail: "help: try ls", Count: 4}83+ h.outs[5] = &notebook.Output{Unsaved: true}84+ h.outs[6] = &notebook.Output{NUON: []byte("[1, 2]"), Count: 5}85+ v := newView(h, 80, 60)86+ got := text(v)87+ for _, want := range []string{88+ "[1] files", "name size ok", "a.txt 2.0 kB TRUE", "bb.txt 10 B FALSE",89+ "a 1", "long_key two words", "line one", "line two",90+ "× Command `nope` not found", "help: try ls", "not saved; run to see", "0 1", "1 2",91+ } {92+ if !strings.Contains(got, want) {93+ t.Errorf("lacks %q:\n%s", want, got)94+ }95+ }96+}97+98+func TestLongOutputWindow(t *testing.T) {99+ h := newHost("ls")100+ var b strings.Builder101+ b.WriteString("[[n]; ")102+ for i := range 10000 {103+ fmt.Fprintf(&b, "[%d], ", i)104+ }105+ b.WriteString("[x]]")106+ h.outs[1] = &notebook.Output{NUON: []byte(b.String()), Count: 1}107+ v := newView(h, 60, 30)108+ if got := text(v); !strings.Contains(got, "9,991 more rows") || strings.Contains(got, " 10\n") {109+ t.Errorf("collapsed:\n%s", got)110+ }111+ v.Toggle()112+ v.Wheel(100)113+ if got := text(v); strings.Contains(got, "more rows") || !strings.Contains(got, " 98\n") {114+ t.Errorf("expanded, scrolled:\n%s", got)115+ }116+}117+118+func TestMovesBetweenCellsAndOutputs(t *testing.T) {119+ h := newHost("a", "b", "c")120+ h.outs[2] = &notebook.Output{NUON: []byte("1"), Count: 1}121+ v := newView(h, 80, 30)122+ steps := []struct {123+ key string124+ sel int125+ out bool126+ }{{"down", 1, false}, {"j", 1, true}, {"down", 2, false}, {"up", 1, true}, {"k", 1, false}, {"end", 2, false}, {"home", 0, false}}127+ for _, s := range steps {128+ v.Key(key(s.key))129+ if i, out := v.Selected(); i != s.sel || out != s.out {130+ t.Fatalf("after %s: %d %v, want %d %v", s.key, i, out, s.sel, s.out)131+ }132+ }133+ // A pair: d d runs Delete; d then another key doesn't.134+ v.Key(key("d"))135+ if v.Pending() != "d" || len(h.ran) != 0 {136+ t.Fatalf("d: pending %q ran %v", v.Pending(), h.ran)137+ }138+ v.Key(key("d"))139+ if len(h.ran) != 1 || h.ran[0] != "nb.delete" {140+ t.Errorf("d d ran %v", h.ran)141+ }142+ if _, ok := v.Key(key("q")); ok {143+ t.Error("a key the notebook doesn't know was taken")144+ }145+}146+147+func TestEditsWithWrapAndCompletes(t *testing.T) {148+ h := newHost("ls")149+ v := newView(h, 30, 20)150+ v.Providers.Completer = Words(func() []Word { return []Word{{Text: "$files", Desc: "cell"}, {Text: "sort-by", Desc: "command"}} })151+ h.onRun = func(id string) {152+ if id == "nb.command_mode" {153+ v.StopEdit()154+ }155+ }156+ v.StartEdit()157+ if !v.Editing() {158+ t.Fatal("not editing")159+ }160+ for _, r := range " | where size > 1kb | so" {161+ v.Key(key(string(r)))162+ }163+ msg := v.askCompletions()()164+ v.Update(msg)165+ if got := v.edit.text(); got != "ls | where size > 1kb | sort-by" {166+ t.Errorf("completed %q", got)167+ }168+ x, y, ok := v.Cursor()169+ if !ok || y != 2 || x != gutter+2+len("| sort-by") {170+ t.Errorf("caret %d,%d %v:\n%s", x, y, ok, text(v))171+ }172+ lines := strings.Split(text(v), "\n")173+ if lines[1] != "│ ls | where size > 1kb " || lines[2] != "│ | sort-by" {174+ t.Errorf("wrapped:\n%s", text(v))175+ }176+ v.Key(key("esc"))177+ if v.Editing() || h.cells[0].Source != "ls | where size > 1kb | sort-by" {178+ t.Errorf("after Esc: editing %v, source %q", v.Editing(), h.cells[0].Source)179+ }180+}181+182+func TestHighlights(t *testing.T) {183+ spans := Tokens{}.Highlight(context.Background(), `x = ls | where size > 1kb and name =~ "a" # big`)184+ var got []string185+ src := `x = ls | where size > 1kb and name =~ "a" # big`186+ for _, s := range spans {187+ got = append(got, fmt.Sprintf("%s:%d", src[s.From:s.To], s.Kind))188+ }189+ want := fmt.Sprint([]string{"x:2", "=:5", "ls:0", "|:5", "where:0", ">:5", "1kb:3", "and:5", "=~:5", `"a":1`, "# big:6"})190+ if fmt.Sprint(got) != want {191+ t.Errorf("spans %v\nwant %v", got, want)192+ }193+}194+195+func TestMarkdown(t *testing.T) {196+ th := theme.New(true)197+ lines := markdown(&th, "# Title\nSome **bold** and *it* with `code` and [a link](https://x.y).\n- one\n1. first\n> quoted", 40)198+ got := ansi.Strip(strings.Join(lines, "\n"))199+ want := "Title\nSome bold and it with code and a link.\n• one\n1. first\n│ quoted"200+ if got != want {201+ t.Errorf("markdown:\n%s\nwant\n%s", got, want)202+ }203+ if !strings.Contains(strings.Join(lines, ""), "https://x.y") {204+ t.Error("the link isn't a hyperlink")205+ }206+}207+208+func TestFullSortsAndFilters(t *testing.T) {209+ h := newHost("ls")210+ h.outs[1] = &notebook.Output{NUON: []byte("[[name, n]; [b, 2], [a, 10], [c, 1]]"), Count: 1}211+ v := newView(h, 60, 10)212+ v.Select(0, true)213+ if !v.OpenFull() {214+ t.Fatal("didn't open")215+ }216+ names := func() string {217+ var out []string218+ for _, i := range v.full.order {219+ out = append(out, cellText(v.full.sh.rows[i][0], locale.Canonical))220+ }221+ return strings.Join(out, "")222+ }223+ v.Key(key("l"))224+ v.Key(key("S"))225+ if got := names(); got != "abc" {226+ t.Errorf("sorted by n descending: %q", got)227+ }228+ v.Key(key("/"))229+ v.Key(key("c"))230+ v.Key(key("enter"))231+ if got := names(); got != "c" {232+ t.Errorf("filtered: %q", got)233+ }234+ v.Key(key("esc"))235+ if v.FullOpen() {236+ t.Error("Esc didn't close it")237+ }238+}
M internal/ui/notebook.go +299 -337
@@ -2,402 +2,364 @@ package ui
22 33 import (44 "slices"5- "strings"5+ "strconv"66 77 tea "charm.land/bubbletea/v2"88 9+ "github.com/FelineStateMachine/012/internal/locale"10+ "github.com/FelineStateMachine/012/internal/notebook"911 "github.com/FelineStateMachine/012/internal/sheet"10- "github.com/FelineStateMachine/012/internal/ui/nuprompt"12+ "github.com/FelineStateMachine/012/internal/ui/nbview"13+ "github.com/FelineStateMachine/012/internal/ui/theme"1114 )1215 13-// Notebooks: Data > Shell (or ! on a notebook sheet, or 012 nu) opens a nushell prompt on14-// the formula bar. Each line run becomes a region of the notebook sheet15-// (the one shown, or the first, or a new Shell 1): a live table named16-// r1, r2 and so on, or name = pipeline to name it, which later commands17-// read as $name. Refreshing a region runs it again, then what reads it18-// (nurun.go). The engine keeps the regions (sheet/region.go); this is19-// the prompt, the commands and the trust a file's commands need.16+// Notebooks: a notebook tab holds code cells (nushell pipelines) and17+// note cells (Markdown), drawn and keyed by package nbview. Every action18+// on them is a command registered here, so menus (Data > Notebook), the19+// palette and help list them; the keys Jupyter uses reach them through20+// nbKeys and nbEditKeys. The cells are the workbook's (sheet/notebook.go),21+// running them is nbrun.go's, sending an output to a sheet nbsend.go's,22+// and drawing the tab nbscreen.go's.2023 21-func init() {22- onRegion := func(m *Model) bool { r, _, ok := m.sheet.RegionAt(m.cur); return ok && !r.Linked() }23- register(24- &command{id: "nu.prompt", macro: macroNever, title: "Shell",25- desc: "Run a nushell pipeline; its table becomes a live region of the notebook sheet",26- run: func(m *Model) tea.Cmd { return m.openShell("") }},27- &command{id: "nu.refresh", macro: macroNever, title: "Refresh region",28- desc: "Run the region's command again, then every region that reads it",29- enabled: onRegion,30- run: func(m *Model) tea.Cmd { return m.refreshRegion(m.regionHere()) }},31- &command{id: "nu.run_all", macro: macroNever, title: "Run all regions",32- desc: "Run every region's command, each after the regions it reads",33- enabled: func(m *Model) bool { return len(m.book().RunOrder()) > 0 },34- run: (*Model).runAllRegions},35- &command{id: "nu.edit", macro: macroNever, title: "Edit region's command",36- desc: "Open the region's command at the prompt, to change it and run it again",37- enabled: onRegion,38- run: func(m *Model) tea.Cmd {39- r := m.regionHere()40- return m.openShell(r.Name + " = " + r.Command)41- }},42- &command{id: "nu.freeze", macro: macroNever, title: "Freeze region",43- desc: "Turn the region's table into plain values you can edit, and stop running its command",44- enabled: onRegion,45- run: func(m *Model) tea.Cmd { return m.freezeRegion(m.regionHere()) }},46- &command{id: "nu.delete", macro: macroNever, title: "Delete region",47- desc: "Remove the region, its command and its table",48- enabled: onRegion,49- run: func(m *Model) tea.Cmd { return m.deleteRegion(m.regionHere()) }},50- &command{id: "nu.stop", macro: macroNever, title: "Stop shell command",51- desc: "Stop the region's command that's running, and those waiting after it",52- enabled: func(m *Model) bool { return m.shell.running != nil },53- run: func(m *Model) tea.Cmd { m.stopShell(); return nil }},54- )55- // ! opens the prompt only on a notebook sheet; elsewhere it types56- // itself, so an entry can start with "!" as in Sheets.57- register(&command{id: "nu.prompt_key", macro: macroNever, title: "Shell (notebook sheet)",58- desc: "Run a nushell pipeline; its table becomes a live region of this notebook sheet",59- typed: true,60- enabled: func(m *Model) bool { return m.sheet.Notebook() },61- hidden: func(*Model) bool { return true },62- run: func(m *Model) tea.Cmd { return m.openShell("") }})63- keymap["!"] = "nu.prompt_key"64- keymap["f9"] = "nu.run_all"24+// nbKeys are command mode's keys, as Jupyter's; "d d" is d twice.25+var nbKeys = map[string]string{26+ "enter": "nb.edit",27+ "shift+enter": "nb.run_next",28+ "ctrl+enter": "nb.run",29+ "alt+enter": "nb.run_insert",30+ "a": "nb.insert_above",31+ "b": "nb.insert_below",32+ "!": "nb.add_code",33+ "d d": "nb.delete",34+ "m": "nb.to_note",35+ "y": "nb.to_code",36+ "z": "edit.undo",37+ "c": "nb.copy",38+ "x": "nb.cut",39+ "v": "nb.paste",40+ "o": "nb.toggle_output",41+ "n": "nb.name",42+ "G": "nb.send",43+ "i i": "nb.stop",44+ "0 0": "nb.restart",45+ "f9": "nb.run_all",6546 }6647 67-// regionHere is the region at the active cell.68-func (m *Model) regionHere() sheet.Region {69- r, _, _ := m.sheet.RegionAt(m.cur)70- return r48+// nbEditKeys are edit mode's.49+var nbEditKeys = map[string]string{50+ "esc": "nb.command_mode",51+ "shift+enter": "nb.run_next",52+ "ctrl+enter": "nb.run",53+ "alt+enter": "nb.run_insert",7154 }7255 73-// openShell opens the prompt with text on it. What's selected is what74-// the command reads as $in.75-func (m *Model) openShell(text string) tea.Cmd {76- m.shell.input = ""77- if m.hasRange() && !m.sheet.Notebook() {78- m.shell.input = sheet.Qualified(m.sheet.Name(), m.selection())79- }80- m.shell.said = ""81- m.openOverlay(nuprompt.New(m.host(), text))82- return m.loadWords()56+// notebookItems are Data > Notebook's items.57+var notebookItems = []menuItem{58+ {cmd: "nb.open"}, sep,59+ {cmd: "nb.run"}, {cmd: "nb.run_next"}, {cmd: "nb.run_all"}, {cmd: "nb.run_above"}, {cmd: "nb.run_below"}, {cmd: "nb.stop"}, sep,60+ {cmd: "nb.insert_above"}, {cmd: "nb.insert_below"}, {cmd: "nb.delete"}, {cmd: "nb.to_note"}, {cmd: "nb.to_code"}, {cmd: "nb.name"}, sep,61+ {cmd: "nb.copy"}, {cmd: "nb.cut"}, {cmd: "nb.paste"}, sep,62+ {cmd: "nb.toggle_output"}, {cmd: "nb.open_output"}, {cmd: "nb.send"}, sep,63+ {cmd: "nb.clear_outputs"}, {cmd: "nb.restart"}, {cmd: "nb.reactive"}, sep,64+ {cmd: "region.freeze"}, {cmd: "region.delete"},8365 }8466 85-// OpenShell starts on a notebook sheet with the prompt open, as 012 nu86-// does: the workbook's first notebook, or a new sheet made one. A new87-// file's empty sheet becomes the notebook.88-func (m *Model) OpenShell() {89- s := m.notebookSheet(false)90- if s == nil {91- w := m.book()92- if first := w.Sheet(0); w.Len() == 1 && first.Len() == 0 && !w.CanUndo() {93- if w.RenameSheet(first, w.NextNotebookName()) == nil {94- first.MakeNotebook()95- w.ClearHistory()96- s = first97- }98- }99- }100- if s != nil {101- m.showSheet(s)102- }103- m.shell.startup = true67+func init() {68+ onTab := func(m *Model) bool { return m.nbView() != nil }69+ onCell := func(m *Model) bool { _, ok := m.nbCell(); return ok }70+ onCode := func(m *Model) bool { c, ok := m.nbCell(); return ok && c.Kind == notebook.Code }71+ register(72+ &command{id: "nb.open", macro: macroNever, title: "Open notebook", desc: "Show the workbook's notebook, or make one: nushell pipelines in cells, their outputs under them",73+ run: (*Model).openNotebook},74+ &command{id: "nb.edit", macro: macroNever, title: "Edit cell", desc: "Edit the selected cell, or open its output full-screen when the output is selected",75+ enabled: onCell, run: (*Model).nbEnter},76+ &command{id: "nb.command_mode", macro: macroNever, title: "Stop editing", desc: "Keep what was typed and go back to moving between cells",77+ enabled: func(m *Model) bool { v := m.nbView(); return v != nil && v.Editing() },78+ run: func(m *Model) tea.Cmd { m.nbView().StopEdit(); return nil }},79+ &command{id: "nb.run", macro: macroNever, title: "Run cell", desc: "Run the selected cell, and whatever it reads that hasn't run",80+ enabled: onCode, run: func(m *Model) tea.Cmd { return m.runSelected(0) }},81+ &command{id: "nb.run_next", macro: macroNever, title: "Run cell and select next", desc: "Run the selected cell and select the one below, adding one at the end",82+ enabled: onCell, run: func(m *Model) tea.Cmd { return m.runSelected(1) }},83+ &command{id: "nb.run_insert", macro: macroNever, title: "Run cell and insert below", desc: "Run the selected cell and add a code cell under it",84+ enabled: onCell, run: func(m *Model) tea.Cmd { return m.runSelected(2) }},85+ &command{id: "nb.run_all", title: "Run all cells", desc: "Run every code cell, in order, each after the cells it reads",86+ enabled: onTab, run: func(m *Model) tea.Cmd { return m.runRange(0, -1) }},87+ &command{id: "nb.run_above", macro: macroNever, title: "Run cells above", desc: "Run the code cells above the selected one",88+ enabled: onCell, run: func(m *Model) tea.Cmd { i, _ := m.nbView().Selected(); return m.runRange(0, i) }},89+ &command{id: "nb.run_below", macro: macroNever, title: "Run cell and below", desc: "Run the selected cell and every code cell under it",90+ enabled: onCell, run: func(m *Model) tea.Cmd { i, _ := m.nbView().Selected(); return m.runRange(i, -1) }},91+ &command{id: "nb.stop", macro: macroNever, title: "Stop running", desc: "Stop the cell running, killing its process, and those waiting",92+ enabled: func(m *Model) bool { return m.nb.running != nil || len(m.nb.queue) > 0 },93+ run: func(m *Model) tea.Cmd { m.stopCells(); return nil }},94+ &command{id: "nb.clear_outputs", title: "Clear outputs", desc: "Clear every cell's output",95+ enabled: onTab, run: func(m *Model) tea.Cmd { m.clearOutputs(false); return nil }},96+ &command{id: "nb.restart", title: "Restart", desc: "Stop what's running, clear every output and count runs from 1 again",97+ enabled: onTab, run: func(m *Model) tea.Cmd { m.clearOutputs(true); return nil }},98+ &command{id: "nb.reactive", title: "Reactive notebook", desc: "Run the cells reading a cell again whenever it runs",99+ enabled: onTab, checked: func(m *Model) bool { return m.sheet.IsNotebook() && m.sheet.Reactive() },100+ run: func(m *Model) tea.Cmd { m.sheet.SetReactive(!m.sheet.Reactive()); return nil }},101+ )102+ registerCellCommands(onCell, onCode)104103 }105104 106-// notebookSheet is where a command's region goes: the sheet shown when107-// it's a notebook, or the first notebook, or with create a new Shell N108-// after the sheet shown.109-func (m *Model) notebookSheet(create bool) *sheet.Sheet {110- if m.sheet.Notebook() {111- return m.sheet112- }113- for _, s := range m.book().Sheets() {114- if s.Notebook() {115- return s116- }117- }118- if !create {119- return nil120- }121- s, err := m.book().AddNotebook("", m.sheet)122- if err != nil {123- m.shell.said = m.th.Warning.Render(err.Error())105+// registerCellCommands registers the commands that change cells.106+func registerCellCommands(onCell, onCode func(*Model) bool) {107+ register(108+ &command{id: "nb.insert_above", macro: macroNever, title: "Add cell above", desc: "Add a code cell above the selected one",109+ enabled: func(m *Model) bool { return m.nbView() != nil }, run: func(m *Model) tea.Cmd { return m.addCell(0, false) }},110+ &command{id: "nb.insert_below", macro: macroNever, title: "Add cell below", desc: "Add a code cell below the selected one",111+ enabled: func(m *Model) bool { return m.nbView() != nil }, run: func(m *Model) tea.Cmd { return m.addCell(1, false) }},112+ &command{id: "nb.add_code", macro: macroNever, title: "New code cell", desc: "Add a code cell below the selected one and edit it",113+ enabled: func(m *Model) bool { return m.nbView() != nil }, run: func(m *Model) tea.Cmd { return m.addCell(1, true) }},114+ &command{id: "nb.delete", macro: macroNever, title: "Delete cell", desc: "Delete the selected cell; z or Undo brings it back",115+ enabled: onCell, run: func(m *Model) tea.Cmd { m.deleteCell(); return nil }},116+ &command{id: "nb.to_note", macro: macroNever, title: "Make it a note", desc: "Turn the selected cell into a note cell, written in Markdown",117+ enabled: onCell, run: func(m *Model) tea.Cmd { m.setKind(notebook.Note); return nil }},118+ &command{id: "nb.to_code", macro: macroNever, title: "Make it code", desc: "Turn the selected cell into a code cell, a nushell pipeline",119+ enabled: onCell, run: func(m *Model) tea.Cmd { m.setKind(notebook.Code); return nil }},120+ &command{id: "nb.name", macro: macroNever, title: "Name cell", desc: "Name the cell's output, which later cells read as $name and formulas as nu.name",121+ enabled: onCode, run: (*Model).openCellName},122+ &command{id: "nb.copy", macro: macroNever, title: "Copy cell", desc: "Copy the selected cell, to paste with v",123+ enabled: onCell, run: func(m *Model) tea.Cmd { m.copyCell(false); return nil }},124+ &command{id: "nb.cut", macro: macroNever, title: "Cut cell", desc: "Cut the selected cell, to paste with v",125+ enabled: onCell, run: func(m *Model) tea.Cmd { m.copyCell(true); return nil }},126+ &command{id: "nb.paste", macro: macroNever, title: "Paste cell", desc: "Paste the cell copied below the selected one",127+ enabled: func(m *Model) bool { return m.nbView() != nil && len(m.nb.clip) > 0 }, run: func(m *Model) tea.Cmd { m.pasteCells(); return nil }},128+ &command{id: "nb.toggle_output", macro: macroNever, title: "Show all of the output", desc: "Show the selected cell's whole output, or just its first rows again",129+ enabled: onCode, checked: func(m *Model) bool { c, ok := m.nbCell(); return ok && m.nbView().Expanded(c.ID) },130+ run: func(m *Model) tea.Cmd { m.nbView().Toggle(); return nil }},131+ &command{id: "nb.open_output", macro: macroNever, title: "Open output", desc: "Show the selected cell's output full-screen, to sort and filter it",132+ enabled: onCode, run: func(m *Model) tea.Cmd {133+ if !m.nbView().OpenFull() {134+ m.note = "The cell has no output to open"135+ }136+ return nil137+ }},138+ &command{id: "nb.send", macro: macroNever, title: "Send to sheet", desc: "Put the cell's output on a sheet, kept up to date whenever the cell runs",139+ enabled: onCode, run: (*Model).sendCell},140+ &command{id: "region.freeze", title: "Freeze output", desc: "Turn the output sent here into plain values you can edit; the cell no longer updates it",141+ enabled: onOutputRegion, run: func(m *Model) tea.Cmd { return m.freezeOutput() }},142+ &command{id: "region.delete", title: "Remove output", desc: "Remove the output sent here and its values from the sheet",143+ enabled: onOutputRegion, run: func(m *Model) tea.Cmd { return m.deleteOutput() }},144+ )145+}146+147+// nbView is the view of the notebook tab shown, or nil on a sheet.148+func (m *Model) nbView() *nbview.View {149+ if !m.sheet.IsNotebook() {124150 return nil125151 }126- return s152+ return m.viewOf(m.sheet)127153 }128154 129-// splitName splits "name = pipeline" into the name and the pipeline; a130-// line that doesn't start so has no name.131-func splitName(line string) (string, string) {132- name, rest, ok := strings.Cut(line, "=")133- name = strings.TrimSpace(name)134- if !ok || strings.HasPrefix(rest, "=") || sheet.ValidRegionName(name) != nil {135- return "", line136- }137- return name, strings.TrimSpace(rest)155+// viewOf is notebook s's view, made the first time it's asked for.156+func (m *Model) viewOf(s *sheet.Sheet) *nbview.View {157+ if m.nb.views == nil {158+ m.nb.views = map[*sheet.Sheet]*nbview.View{}159+ }160+ v := m.nb.views[s]161+ if v == nil {162+ v = nbview.New(nbHost{m, s})163+ v.Keys, v.EditKeys = nbKeys, nbEditKeys164+ v.Providers = nbview.Providers{Highlighter: nbview.Tokens{}, Completer: nbview.Words(func() []nbview.Word { return m.nbWords(s) })}165+ m.nb.views[s] = v166+ }167+ return v138168 }139169 140-// submitShell runs a line typed at the prompt: a new region, or a named141-// region's new command.142-func (m *Model) submitShell(line string) tea.Cmd {143- w := m.book()144- w.AddShellHistory(line)145- if why := m.shellOff(); why != "" {146- m.shell.said = m.th.Warning.Render(why)147- return nil148- }149- name, command := splitName(line)150- if command == "" {151- m.shell.said = m.th.Warning.Render("Type a pipeline after " + name + " =")152- return nil153- }154- input := ""155- if strings.Contains(command, "$in") {156- input = m.shell.input157- }158- deps := w.RegionDeps(command)159- trusted := m.macroTrusted() || len(w.Macros()) == 0 && len(w.RunOrder()) == 0160- var s *sheet.Sheet161- var err error162- if t, r, ok := w.Region(name); ok && name != "" {163- s, name = t, r.Name164- err = s.EditRegion(name, command, deps, input)165- } else {166- if s = m.notebookSheet(true); s == nil {167- return nil168- }169- if name == "" {170- name = w.NextRegionName()171- }172- err = s.AddRegion(sheet.Region{Name: name, Command: command, Deps: deps, Input: input})173- }174- if err != nil {175- m.shell.said = m.th.Warning.Render(err.Error())176- return nil177- }178- if trusted {179- m.trustHere() // what you type is yours180- }181- m.showSheet(s)182- if _, r, ok := w.Region(name); ok {183- m.clearSelection()184- m.cur = r.At170+// nbCell is the selected cell of the notebook shown.171+func (m *Model) nbCell() (notebook.Cell, bool) {172+ if v := m.nbView(); v != nil {173+ return v.Cell()185174 }186- return m.runRegions(m.withInputs(w.RefreshOrder(name)))175+ return notebook.Cell{}, false187176 }188177 189-// shellOff says why commands can't run here, or "".190-func (m *Model) shellOff() string {191- switch {192- case m.shell.served && !m.configBool("serve-shell", false):193- return "Shell commands are off in 012 serve (serve-shell in the server's config)"194- case m.configString("shell", "ask") == "off":195- return "Shell commands are off (shell = off in your config)"178+// nbEnter is Enter in command mode: edit the cell, or open its output.179+func (m *Model) nbEnter() tea.Cmd {180+ v := m.nbView()181+ if _, out := v.Selected(); out {182+ v.OpenFull()183+ return nil196184 }197- return ""185+ return v.StartEdit()198186 }199187 200-// refreshRegion runs a region's command again and then what reads it,201-// asking first when the file's commands came from elsewhere.202-func (m *Model) refreshRegion(r sheet.Region) tea.Cmd {203- order := m.withInputs(m.book().RefreshOrder(r.Name))204- return m.trustShell(func(m *Model) tea.Cmd { return m.runRegions(order) })188+// setCells replaces the notebook's cells as one undo step, and the user189+// who changes cells of a notebook made here trusts it.190+func (m *Model) setCells(label string, cells []notebook.Cell) {191+ m.sheet.SetNotebookCells(label, cells)192+ m.madeCells()205193 }206194 207-// runAllRegions runs every region, each after those it reads.208-func (m *Model) runAllRegions() tea.Cmd {209- order := m.book().RunOrder()210- return m.trustShell(func(m *Model) tea.Cmd { return m.runRegions(order) })195+// madeCells trusts the workbook's cells on this computer once the user196+// changes them, unless cells or macros came from elsewhere untrusted.197+func (m *Model) madeCells() {198+ if m.macroTrusted() || !m.nb.fromFile && len(m.book().Macros()) == 0 {199+ m.trustHere()200+ }211201 }212202 213-// withInputs adds before order the regions its commands read that214-// haven't run yet, so they have tables to read.215-func (m *Model) withInputs(order []string) []string {216- w := m.book()217- var before []string218- for _, name := range w.RunOrder() {219- if slices.Contains(order, name) {220- continue221- }222- if s, _, ok := w.Region(name); ok && !s.RegionShown(name) && m.readBy(name, order) {223- before = append(before, name)224- }203+// addCell adds an empty code cell above the selected one (at 0) or below204+// it (at 1), editing it with edit.205+func (m *Model) addCell(at int, edit bool) tea.Cmd {206+ v := m.nbView()207+ cells := m.sheet.NotebookCells()208+ i, _ := v.Selected()209+ if len(cells) > 0 {210+ i += at211+ }212+ v.StopEdit()213+ cells = slices.Insert(cells, i, notebook.Cell{})214+ m.setCells("add cell", cells)215+ v.Select(i, false)216+ if edit {217+ return v.StartEdit()225218 }226- return append(before, order...)219+ return nil227220 }228221 229-// readBy reports whether a region of names reads name, directly or230-// through others.231-func (m *Model) readBy(name string, names []string) bool {232- for _, n := range names {233- if slices.Contains(m.book().RefreshOrder(name), n) {234- return true235- }236- }237- return false222+// deleteCell deletes the selected cell.223+func (m *Model) deleteCell() {224+ v := m.nbView()225+ cells := m.sheet.NotebookCells()226+ i, _ := v.Selected()227+ if i >= len(cells) {228+ return229+ }230+ m.setCells("delete cell "+strconv.Itoa(i+1), slices.Delete(cells, i, i+1))231+ v.Select(i, false)232+ m.note = "Deleted cell " + strconv.Itoa(i+1) + ": z brings it back"238233 }239234 240-// trustShell runs run once commands may run: shell = on, or the file's241-// commands were made or trusted here, or the user agrees now.242-func (m *Model) trustShell(run func(*Model) tea.Cmd) tea.Cmd {243- if why := m.shellOff(); why != "" {244- m.fail(why)245- return nil246- }247- if m.configString("shell", "ask") == "on" || m.macroTrusted() {248- return run(m)249- }250- m.ask(question{251- msg: "Run this file's shell commands?",252- desc: "It was saved on another computer: its commands run as you, with your files",253- choices: []choice{254- {key: "enter", label: "Run", run: func(m *Model) tea.Cmd { m.trustHere(); return run(m) }},255- {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},256- },257- })258- return nil235+// setKind turns the selected cell into a code or note cell.236+func (m *Model) setKind(k notebook.Kind) {237+ v := m.nbView()238+ cells := m.sheet.NotebookCells()239+ i, _ := v.Selected()240+ if i >= len(cells) || cells[i].Kind == k {241+ return242+ }243+ cells[i].Kind = k244+ m.setCells("change cell "+strconv.Itoa(i+1), cells)245+ v.Select(i, false)259246 }260247 261-// sortRegion sorts the table of a region a sort's range r reaches, by262-// its columns in keys: the rows below its header, whatever else r263-// holds. The order is the region's, kept when it runs again.264-func (m *Model) sortRegion(reg sheet.Region, r sheet.Rect, keys []sheet.SortKey) tea.Cmd {265- if reg.Linked() {266- m.note = "A linked file's rows keep the file's order: unlink it to sort them"267- return nil268- }269- t, ok := m.sheet.RegionTable(reg.Name)270- inTable := func(k sheet.SortKey) bool { return k.Col >= t.From.Col && k.Col <= t.To.Col }271- if !ok || len(keys) == 0 || !slices.ContainsFunc(keys, inTable) || r.To.Row <= t.From.Row {272- m.note = "Sort a region by a column of its table"273- return nil274- }275- keys = slices.DeleteFunc(slices.Clone(keys), func(k sheet.SortKey) bool { return !inTable(k) })276- if err := m.sheet.SortRegion(reg.Name, keys); err != nil {277- m.fail(err.Error())278- return nil279- }280- m.note = "Sorted " + reg.Name + "; it stays sorted when it runs again"281- return nil248+// copyCell copies the selected cell, and deletes it with cut.249+func (m *Model) copyCell(cut bool) {250+ c, _ := m.nbCell()251+ c.ID = 0252+ m.nb.clip = []notebook.Cell{c}253+ if cut {254+ i, _ := m.nbView().Selected()255+ m.setCells("cut cell "+strconv.Itoa(i+1), slices.Delete(m.sheet.NotebookCells(), i, i+1))256+ m.nbView().Select(i, false)257+ m.note = "Cut cell " + strconv.Itoa(i+1) + ": v pastes it"258+ return259+ }260+ m.note = "Copied the cell: v pastes it"282261 }283262 284-// freezeRegion turns a region's table into plain values.285-func (m *Model) freezeRegion(r sheet.Region) tea.Cmd {286- if m.shell.running != nil && m.shell.running.name == r.Name {287- m.stopShell()288- }289- if err := m.sheet.FreezeRegion(r.Name); err != nil {290- m.fail(err.Error())291- return nil292- }293- m.note = "Froze " + r.Name + " into values" + m.orphans(r.Name)294- return nil263+// pasteCells pastes the cells copied below the selected one.264+func (m *Model) pasteCells() {265+ v := m.nbView()266+ cells := m.sheet.NotebookCells()267+ i, _ := v.Selected()268+ if len(cells) > 0 {269+ i++270+ }271+ cells = slices.Insert(cells, i, m.nb.clip...)272+ m.setCells("paste cell", cells)273+ v.Select(i, false)295274 }296275 297-// deleteRegion removes a region.298-func (m *Model) deleteRegion(r sheet.Region) tea.Cmd {299- if m.shell.running != nil && m.shell.running.name == r.Name {300- m.stopShell()301- }302- if err := m.sheet.DeleteRegion(r.Name); err != nil {303- m.fail(err.Error())276+// openCellName asks for the selected cell's name.277+func (m *Model) openCellName() tea.Cmd {278+ c, _ := m.nbCell()279+ s := m.sheet280+ m.openText("Name the cell:", c.Name(), func(m *Model, text string) tea.Cmd {281+ if text != "" && text != c.Name() {282+ if err := notebook.ValidName(text); err != nil {283+ m.fail(err.Error())284+ return nil285+ }286+ if m.book().FreeCellName(text) != text {287+ m.fail("$" + text + " is taken: another cell or region has that name")288+ return nil289+ }290+ }291+ m.renameCell(s, c, text)304292 return nil305- }306- m.note = "Deleted " + r.Name + m.orphans(r.Name)293+ })307294 return nil308295 }309296 310-// orphans says which regions read one that's gone.311-func (m *Model) orphans(name string) string {312- if by := m.book().DependsOn(name); len(by) > 0 {313- return "; " + strings.Join(by, ", ") + " read it"314- }315- return ""316-}317-318-// notebookKey is Enter and F2 on a region's label (refresh it, edit its319-// command) and Esc while a command runs (stop it), in READY.320-func (m *Model) notebookKey(key string) (tea.Cmd, bool) {321- switch key {322- case "esc":323- if m.shell.running != nil {324- return m.runCommand("nu.stop"), true325- }326- case "enter", "f2":327- if !m.sheet.HasRegions() || m.hasRange() {328- return nil, false329- }330- if _, label, ok := m.sheet.RegionAt(m.cur); ok && label {331- if key == "f2" {332- return m.runCommand("nu.edit"), true333- }334- return m.runCommand("nu.refresh"), true297+// renameCell names cell c of notebook s, and the output it sent to a298+// sheet with it.299+func (m *Model) renameCell(s *sheet.Sheet, c notebook.Cell, name string) {300+ cells := s.NotebookCells()301+ i := slices.IndexFunc(cells, func(x notebook.Cell) bool { return x.ID == c.ID })302+ if i < 0 {303+ return304+ }305+ cells[i].Source = notebook.WithName(c.Source, name)306+ s.SetNotebookCells("name cell "+strconv.Itoa(i+1), cells)307+ m.madeCells()308+ if old := c.Name(); old != "" && name != "" {309+ if _, r, ok := m.book().Region(old); ok && r.Output {310+ m.moveOutput(r, name)335311 }336312 }337- return nil, false338313 }339314 340-// regionLine is the context line on a region: what it is and the keys341-// for it, or, on a notebook with regions not run, the key that runs them.342-func (m *Model) regionLine() string {343- r, label, ok := m.sheet.RegionAt(m.cur)344- if !ok {345- if m.sheet.Notebook() && m.notRun() > 0 {346- return m.th.KeyHints(m.shortcut("nu.run_all"), "run all regions", m.shortcut("nu.prompt"), "shell")315+// nbWords are the completions of notebook s's cells: its names, the316+// workbook's regions, and nu's commands once they're known.317+func (m *Model) nbWords(s *sheet.Sheet) []nbview.Word {318+ var out []nbview.Word319+ for _, c := range s.NotebookCells() {320+ if n := c.Name(); n != "" {321+ out = append(out, nbview.Word{Text: "$" + n, Desc: "a cell's output"})347322 }348- return ""349- }350- if r.Linked() {351- return m.linkedLine() // linked.go352323 }353- if f := m.shell.failed[strings.ToUpper(r.Name)]; f != "" && label {354- return m.th.Warning.Render(r.Name+" failed: "+f) + " " + m.th.KeyHints("F2", "edit")355- }356- what := r.Name357- if n := m.sheet.RegionNote(r.Name); n != "" {358- what += ": " + n324+ for _, t := range m.book().Sheets() {325+ for _, r := range t.Regions() {326+ if r.Linked() {327+ out = append(out, nbview.Word{Text: "$" + r.Name, Desc: "linked file " + r.File.Path})328+ }329+ }359330 }360- if label {361- return m.th.Muted.Render(what+" ") + m.th.KeyHints("Enter", "refresh", "F2", "edit", m.shortcut("nu.run_all"), "run all")331+ out = append(out, nbview.Word{Text: "$selection", Desc: "the selection on the sheet shown last"},332+ nbview.Word{Text: "$sheet.", Desc: "a range of a sheet: $sheet.A1:C9"})333+ for _, c := range m.nb.words {334+ out = append(out, nbview.Word{Text: c, Desc: "command"})362335 }363- return m.th.Muted.Render("Region " + what + " of " + r.Command)336+ return out364337 }365338 366-// notRun counts the sheet's regions that haven't run since the file367-// opened.368-func (m *Model) notRun() int {369- n := 0370- for _, r := range m.sheet.Regions() {371- if !r.Linked() && !m.sheet.RegionShown(r.Name) {372- n++373- }374- }375- return n339+// nbHost is how a notebook's view reaches the model.340+type nbHost struct {341+ m *Model342+ s *sheet.Sheet376343 }377344 378-// regionEntry is what the formula bar shows on a region: its command.379-func (m *Model) regionEntry() (string, bool) {380- if !m.sheet.HasRegions() {381- return "", false382- }383- r, _, ok := m.sheet.RegionAt(m.cur)384- if !ok || r.Linked() {385- return "", false386- }387- return m.th.Muted.Render(nuprompt.Mark + r.Command), true388-}345+var _ nbview.Host = nbHost{}346+347+func (h nbHost) Theme() *theme.Theme { return &h.m.th }348+func (h nbHost) Locale() *locale.Locale { return h.m.locale() }349+func (h nbHost) Cells() []notebook.Cell { return h.s.NotebookCells() }350+func (h nbHost) Output(id int) *notebook.Output { return h.m.book().Output(id) }351+func (h nbHost) State(id int) nbview.State { return h.m.cellState(h.s, id) }352+func (h nbHost) Run(command string) tea.Cmd { return h.m.runCommand(command) }389353 390-// shellWords are the prompt's completions: the regions, and nu's391-// commands once they're known.392-func (m *Model) shellWords() []nuprompt.Word {393- var out []nuprompt.Word394- for _, s := range m.book().Sheets() {395- for _, r := range s.Regions() {396- out = append(out, nuprompt.Word{Text: "$" + r.Name, Desc: r.Command})354+// Edit keeps a cell's new source.355+func (h nbHost) Edit(id int, source string) {356+ cells := h.s.NotebookCells()357+ for i, c := range cells {358+ if c.ID == id {359+ cells[i].Source = source360+ h.s.SetNotebookCells("edit cell "+strconv.Itoa(i+1), cells)361+ h.m.madeCells()362+ return397363 }398364 }399- for _, c := range m.shell.words {400- out = append(out, nuprompt.Word{Text: c, Desc: "command"})401- }402- return out403365 }
M internal/ui/notebook_test.go +296 -180
@@ -1,17 +1,20 @@
11 package ui22 33 import (4+ "bytes"45 "context"56 "io"67 "strings"78 "testing"89 10+ tea "charm.land/bubbletea/v2"11+12+ "github.com/FelineStateMachine/012/internal/notebook"913 "github.com/FelineStateMachine/012/internal/nushell"1014 "github.com/FelineStateMachine/012/internal/sheet"11- "github.com/FelineStateMachine/012/internal/ui/nuprompt"1215 )1316 14-// fakeNu answers commands from a table of outputs, failing those it17+// fakeNu answers pipelines from a table of outputs, failing those it1518 // lacks, and keeps the jobs it ran.1619 type fakeNu struct {1720 out map[string]string
@@ -26,235 +29,348 @@ func (f *fakeNu) Run(_ context.Context, job nushell.Job, _ string, stdout io.Wri
2629 f.jobs = append(f.jobs, job)2730 out, ok := f.out[job.Command]2831 if !ok {29- return &nushell.Error{Msg: "Command `" + job.Command + "` not found"}32+ return &nushell.Error{Msg: "Command `" + job.Command + "` not found", Stderr: " × not found\n help: check the spelling\n"}3033 }3134 _, err := io.WriteString(stdout, out)3235 return err3336 }3437 38+// ran is the pipelines the fake ran, in order.39+func (f *fakeNu) ran() []string {40+ var out []string41+ for _, j := range f.jobs {42+ out = append(out, j.Command)43+ }44+ return out45+}46+47+const lsOut = "[[name, size]; [a.txt, 2kb], [b.txt, 10b], [c.txt, 5kb]]"48+49+// notebookModel is a model on a new notebook, cells running with a fake50+// nu answering from out.3551 func notebookModel(t *testing.T, out map[string]string) (*Model, *fakeNu) {3652 t.Helper()3753 m := newModel()3854 nu := &fakeNu{out: out}3955 m.SetShellRunner(nu)4056 m.SetMachine("here")57+ m.nb.still = true58+ run(m, m.runCommand("nb.open"))59+ if !m.sheet.IsNotebook() {60+ t.Fatal("Open notebook didn't show a notebook")61+ }4162 return m, nu4263 }4364 44-const lsOut = "[[name, size]; [a.txt, 2kb], [b.txt, 10b], [c.txt, 5kb]]"65+// write adds a cell below the selected one holding src, leaving edit66+// mode.67+func write(t *testing.T, m *Model, src string) {68+ t.Helper()69+ press(t, m, "b", "<enter>")70+ send(m, pasteMsg(src))71+ press(t, m, "<esc>")72+}4573 46-func TestShellMakesRegions(t *testing.T) {47- m, nu := notebookModel(t, map[string]string{48- "ls": lsOut,49- "$r1 | where size > 1kb": "[[name, size]; [a.txt, 2kb], [c.txt, 5kb]]",50- })51- shell(t, m)52- if _, ok := m.overlay.(*nuprompt.Prompt); !ok || m.indicator() != "NU" {53- t.Fatalf("no prompt: %T %q", m.overlay, m.indicator())54- }55- if got := bar(m); got != nuprompt.Mark[:len(nuprompt.Mark)-1] {56- t.Errorf("formula bar %q", got)57- }58- press(t, m, "ls", "<enter>")59- if m.sheet.Name() != "Shell 1" || !m.sheet.Notebook() {60- t.Fatalf("on %q, notebook %v", m.sheet.Name(), m.sheet.Notebook())61- }62- if got := input(m, "A1"); got != "r1 ls" {63- t.Errorf("label %q", got)64- }65- if got := m.sheet.ShownText(addr("B3")); got != "2.0 kB" {66- t.Errorf("B3 shows %q", got)74+func pasteMsg(s string) tea.PasteMsg { return tea.PasteMsg{Content: s} }75+76+func cells(m *Model) []string {77+ var out []string78+ for _, c := range m.sheet.NotebookCells() {79+ out = append(out, c.Source)6780 }68- if !strings.Contains(m.shell.said, "r1: 3 rows") {69- t.Errorf("said %q", m.shell.said)81+ return out82+}83+84+func TestNotebookWritesAndRuns(t *testing.T) {85+ m, nu := notebookModel(t, map[string]string{"ls": lsOut})86+ if got := line(m, menuLine); !strings.Contains(got, "NOTEBOOK") {87+ t.Errorf("indicator: %q", got)7088 }71- press(t, m, "$r1 | where size > 1kb", "<enter>")72- if got := input(m, "A7"); got != "r2 $r1 | where size > 1kb" {73- t.Errorf("r2's label %q", got)89+ press(t, m, "b", "<enter>")90+ if got := line(m, menuLine); !strings.HasSuffix(got, " EDIT") {91+ t.Errorf("editing: %q", got)7492 }75- if len(nu.jobs) != 2 || !strings.Contains(string(nu.jobs[1].Tables["r1"]), "a.txt") {76- t.Fatalf("r2 read r1 as %q", nu.jobs[len(nu.jobs)-1].Tables)93+ press(t, m, "files = ls", "<shift+enter>")94+ if got := cells(m); len(got) != 2 || got[0] != "files = ls" || got[1] != "" {95+ t.Fatalf("cells %q", got)7796 }78- // Refreshing r1 runs r2 after it.79- press(t, m, "<esc>")80- m.cur = addr("A1")81- nu.out["ls"] = "[[name, size]; [d.txt, 9kb]]"82- press(t, m, "<enter>")83- if got := input(m, "A3"); got != "d.txt" {84- t.Errorf("r1 refreshed to %q", got)97+ if len(nu.jobs) != 1 {98+ t.Fatalf("ran %v", nu.ran())8599 }86- if got := input(m, "A5"); !strings.HasPrefix(got, "r2 ") {87- t.Errorf("r2 didn't move up: A5 %q", got)100+ s := screen(m)101+ for _, want := range []string{"[1] files", "a.txt", "2.0 kB", "c.txt"} {102+ if !strings.Contains(s, want) {103+ t.Errorf("screen lacks %q:\n%s", want, s)104+ }88105 }89- if last := nu.jobs[len(nu.jobs)-1]; last.Command != "$r1 | where size > 1kb" || !strings.Contains(string(last.Tables["r1"]), "d.txt") {90- t.Errorf("r2 wasn't run after r1: %+v", last)106+ // The new cell is selected, and a run count shows on the first.107+ if i, _ := m.nbView().Selected(); i != 1 {108+ t.Errorf("selected %d after Shift+Enter", i)91109 }92- // Undo takes the refresh back, one region at a time.93- press(t, m, "<ctrl+z>")94- press(t, m, "<ctrl+z>")95- if got := input(m, "A3"); got != "a.txt" {96- t.Errorf("after undo A3 %q", got)110+ // Grid commands don't act on a notebook tab.111+ run(m, m.runCommand("clear"))112+ if !strings.Contains(m.note, "notebook") {113+ t.Errorf("clear on a notebook: %q", m.note)97114 }98115 }99116 100-func TestShellFails(t *testing.T) {101- m, _ := notebookModel(t, map[string]string{})102- shell(t, m, "lss", "<enter>")103- if got := input(m, "A1"); got != "r1 lss failed" {104- t.Errorf("label %q", got)105- }106- if !strings.Contains(line(m, contextLine), "r1 failed: Command `lss` not found") {107- t.Errorf("context line %q", line(m, contextLine))108- }109- press(t, m, "<esc>")110- m.cur = addr("A1")111- if !strings.Contains(line(m, contextLine), "r1 failed") {112- t.Errorf("on the label: %q", line(m, contextLine))113- }114- // Named: name = pipeline, and redefining it.115- m.shell.runner.(*fakeNu).out["ls"] = lsOut116- shell(t, m, "files = ls", "<enter>")117- if _, r, ok := m.book().Region("files"); !ok || r.Command != "ls" {118- t.Fatalf("no region files: %+v", r)119- }120- press(t, m, "r1 = ls", "<enter>")121- if got := input(m, "A1"); got != "r1 ls" {122- t.Errorf("r1 redefined: %q", got)123- }124- if got := m.book().Sheet(1).Value(addr("A3")).String(); got != "a.txt" {125- t.Errorf("r1's table %q", got)117+func TestNotebookReadsNames(t *testing.T) {118+ m, nu := notebookModel(t, map[string]string{"ls": lsOut, "$files | where size > 1kb": "[[name]; [a.txt]]"})119+ write(t, m, "files = ls")120+ write(t, m, "$files | where size > 1kb")121+ // Running the second runs the first, which it reads and which hasn't122+ // run, first.123+ press(t, m, "<ctrl+enter>")124+ if got := nu.ran(); len(got) != 2 || got[0] != "ls" {125+ t.Fatalf("ran %q", got)126+ }127+ if got := string(nu.jobs[1].Tables["files"]); got != lsOut {128+ t.Errorf("$files reached nu as %q", got)129+ }130+ // Running files again makes the cell reading it stale; a reactive131+ // notebook runs it too.132+ press(t, m, "<up>", "<up>", "<up>")133+ if i, _ := m.nbView().Selected(); i != 0 {134+ t.Fatalf("selected %d", i)135+ }136+ cs := m.sheet.NotebookCells()137+ if st := m.cellState(m.sheet, cs[1].ID); st.Stale {138+ t.Error("stale before anything changed")139+ }140+ press(t, m, "<ctrl+enter>")141+ if st := m.cellState(m.sheet, cs[1].ID); !st.Stale {142+ t.Error("a cell reading one that ran again isn't stale")143+ }144+ if !strings.Contains(screen(m), "stale") {145+ t.Error("stale isn't on the screen")146+ }147+ run(m, m.runCommand("nb.reactive"))148+ press(t, m, "<ctrl+enter>")149+ if got := nu.ran(); len(got) != 5 || got[4] != "$files | where size > 1kb" {150+ t.Errorf("reactive ran %q", got)151+ }152+ if st := m.cellState(m.sheet, cs[1].ID); st.Stale {153+ t.Error("stale after the reactive run")126154 }127155 }128156 129-func TestShellCompletes(t *testing.T) {130- m, _ := notebookModel(t, map[string]string{"ls": lsOut})131- shell(t, m, "ls", "<enter>", "$")132- p := m.overlay.(*nuprompt.Prompt)133- if got := p.Shown(); len(got) != 1 || got[0].Text != "$r1" {134- t.Fatalf("completions of $: %+v", got)135- }136- press(t, m, "<tab>")137- if m.line.Text() != "$r1" {138- t.Errorf("tab put %q", m.line.Text())139- }140- press(t, m, " | so")141- if got := p.Shown(); len(got) != 1 || got[0].Text != "sort-by" {142- t.Errorf("completions of so: %+v", got)143- }144- if !strings.Contains(screen(m), "Completions") {145- t.Error("no completions box")146- }147- // History: Up recalls the lines run.148- m.line.Clear()149- p.Changed()157+func TestNotebookRunOrders(t *testing.T) {158+ m, nu := notebookModel(t, map[string]string{"1": "1", "2": "2", "3": "3"})159+ for _, s := range []string{"1", "2", "3"} {160+ write(t, m, s)161+ }150162 press(t, m, "<up>")151- if m.line.Text() != "ls" {152- t.Errorf("up recalled %q", m.line.Text())163+ if i, _ := m.nbView().Selected(); i != 1 {164+ t.Fatalf("selected %d", i)153165 }154- if got := m.book().ShellHistory(); len(got) != 1 || got[0] != "ls" {155- t.Errorf("history %q", got)166+ run(m, m.runCommand("nb.run_above"))167+ run(m, m.runCommand("nb.run_below"))168+ run(m, m.runCommand("nb.run_all"))169+ if got := strings.Join(nu.ran(), " "); got != "1 2 3 1 2 3" {170+ t.Errorf("ran %q", got)156171 }157172 }158173 159-func TestShellTrust(t *testing.T) {174+func TestNotebookFailureStopsTheQueue(t *testing.T) {160175 m, nu := notebookModel(t, map[string]string{"ls": lsOut})161- shell(t, m, "ls", "<enter>", "<esc>")162- if m.book().MacroOrigin() != "here" {163- t.Errorf("origin %q", m.book().MacroOrigin())176+ write(t, m, "nope")177+ write(t, m, "ls")178+ run(m, m.runCommand("nb.run_all"))179+ if got := nu.ran(); len(got) != 1 {180+ t.Errorf("ran %q after a failure", got)164181 }165- // A file from elsewhere asks before its commands run.166- m.book().SetMacroOrigin("elsewhere")167- m.macros.trusted = false168- m.cur = addr("A1")169- n := len(nu.jobs)170- press(t, m, "<enter>")171- if len(nu.jobs) != n || !strings.Contains(line(m, contextLine), "Run this file's shell commands?") {172- t.Fatalf("didn't ask: %q", line(m, contextLine))173- }174- press(t, m, "<esc>")175- if len(nu.jobs) != n {176- t.Error("ran after Esc")177- }178- press(t, m, "<f9>", "<enter>")179- if len(nu.jobs) != n+1 || m.book().MacroOrigin() != "here" {180- t.Errorf("run all after trusting: %d jobs, origin %q", len(nu.jobs)-n, m.book().MacroOrigin())181- }182- // Served, commands are off unless the server allows them.183- m.shell.served = true184- press(t, m, "<f9>")185- if !strings.Contains(m.errMsg, "off in 012 serve") {186- t.Errorf("served: %q", m.errMsg)182+ s := screen(m)183+ if !strings.Contains(s, "× Command `nope` not found") || !strings.Contains(s, "help: check the spelling") || !strings.Contains(s, "failed") {184+ t.Errorf("the error isn't shown:\n%s", s)187185 }188186 }189187 190-func TestShellGuardsAndFreeze(t *testing.T) {191- m, _ := notebookModel(t, map[string]string{"ls": lsOut})192- shell(t, m, "ls", "<enter>", "<esc>")193- m.cur = addr("A3")194- press(t, m, "x", "<enter>")195- if got := input(m, "A3"); got != "a.txt" {196- t.Errorf("typed over a region: %q", got)197- }198- if !strings.Contains(m.note, "region r1") {199- t.Errorf("note %q", m.note)200- }201- if got := bar(m); got != "nu❯ ls" {202- t.Errorf("formula bar on a region %q", got)203- }204- run(m, m.runCommand("nu.freeze"))205- press(t, m, "x", "<enter>")206- if got := input(m, "A3"); got != "x" {207- t.Errorf("frozen A3 %q", got)208- }209- if got := input(m, "B4"); got != "10" || m.sheet.ShownText(addr("B4")) != "10 B" {210- t.Errorf("frozen B4 %q shows %q", got, m.sheet.ShownText(addr("B4")))188+func TestNotebookCellKeys(t *testing.T) {189+ m, _ := notebookModel(t, nil)190+ write(t, m, "a")191+ write(t, m, "b")192+ want := func(step string, srcs ...string) {193+ t.Helper()194+ if got := cells(m); strings.Join(got, ",") != strings.Join(srcs, ",") {195+ t.Fatalf("after %s: %q, want %q", step, got, srcs)196+ }197+ }198+ press(t, m, "d", "d")199+ want("dd", "a")200+ press(t, m, "z")201+ want("z", "a", "b")202+ press(t, m, "<end>", "c", "v")203+ want("c v", "a", "b", "b")204+ press(t, m, "m")205+ if m.sheet.NotebookCells()[2].Kind != notebook.Note {206+ t.Error("m didn't make a note")207+ }208+ press(t, m, "y")209+ if m.sheet.NotebookCells()[2].Kind != notebook.Code {210+ t.Error("y didn't make code")211+ }212+ press(t, m, "x")213+ want("x", "a", "b")214+ press(t, m, "a")215+ want("a", "a", "", "b")216+ press(t, m, "n")217+ press(t, m, "<ctrl+a>", "empty", "<enter>")218+ want("n", "a", "empty = ", "b")219+}220+221+func TestNotebookRestartAndToggle(t *testing.T) {222+ long := "[" + strings.Repeat("{n: 1}, ", 40) + "{n: 2}]"223+ m, _ := notebookModel(t, map[string]string{"long": long})224+ write(t, m, "long")225+ press(t, m, "<ctrl+enter>")226+ if s := screen(m); !strings.Contains(s, "31 more rows") {227+ t.Errorf("a long output isn't cut to its window:\n%s", s)228+ }229+ press(t, m, "o")230+ if s := screen(m); strings.Contains(s, "more rows") {231+ t.Errorf("o didn't show all of it:\n%s", s)232+ }233+ press(t, m, "0", "0")234+ if m.book().Output(m.sheet.NotebookCells()[0].ID) != nil || m.nb.count != 0 {235+ t.Error("0 0 left outputs")211236 }212237 }213238 214-// Sorting a region's table keeps the order through the next run.215-func TestShellSortsRegion(t *testing.T) {216- m, _ := notebookModel(t, map[string]string{"ls": lsOut})217- shell(t, m, "ls", "<enter>", "<esc>")218- m.cur = addr("B3")219- run(m, m.runCommand("data.sort_range_za"))220- if got := input(m, "A3"); got != "c.txt" {221- t.Fatalf("sorted A3 %q, note %q", got, m.note)239+// A 200-character pipeline at 80 columns wraps: every character of it240+// is on the screen, in edit mode and out of it.241+func TestNotebookLongPipelineWraps(t *testing.T) {242+ m, _ := notebookModel(t, nil)243+ var b strings.Builder244+ for i := 0; b.Len() < 200; i++ {245+ b.WriteString("where col" + string(rune('a'+i%26)) + " > " + strings.Repeat("9", i%5+1) + " | ")246+ }247+ src := "ls | " + b.String() + "first"248+ press(t, m, "b", "<enter>")249+ send(m, pasteMsg(src))250+ check := func(when string) {251+ s := strings.ReplaceAll(screen(m), "\n", "")252+ var joined strings.Builder253+ for _, l := range strings.Split(screen(m), "\n") {254+ if rest, ok := strings.CutPrefix(l, "│ "); ok {255+ joined.WriteString(strings.TrimSpace(rest) + " ")256+ }257+ }258+ got := strings.Join(strings.Fields(joined.String()), " ")259+ if got != strings.Join(strings.Fields(src), " ") {260+ t.Errorf("%s: the source on screen is\n%q\nwant\n%q\n%s", when, got, src, s)261+ }262+ }263+ check("editing")264+ press(t, m, "<esc>")265+ check("in command mode")266+ if _, _, ok := m.cursorPos(); ok {267+ t.Error("a caret out of edit mode")222268 }223- m.cur = addr("A1")224- press(t, m, "<enter>")225- if got := input(m, "A3"); got != "c.txt" {226- t.Errorf("after refresh A3 %q", got)269+}270+271+func TestNotebookSelectionAndRanges(t *testing.T) {272+ m, nu := notebookModel(t, map[string]string{"$selection | math sum": "7", "$__sheet1 | length": "2"})273+ grid := m.book().Sheet(0)274+ grid.Set(addr("A1"), "3")275+ grid.Set(addr("A2"), "4")276+ m.showSheet(grid)277+ m.cur, m.ext, m.selecting = addr("A1"), addr("A2"), true278+ run(m, m.runCommand("nb.open"))279+ write(t, m, "$selection | math sum")280+ write(t, m, "$sheet.Sheet1!A1:A2 | length")281+ run(m, m.runCommand("nb.run_all"))282+ if len(nu.jobs) != 2 {283+ t.Fatalf("ran %q", nu.ran())284+ }285+ if sel := string(nu.jobs[0].Tables["selection"]); !strings.Contains(sel, "3") || !strings.Contains(sel, "4") {286+ t.Errorf("$selection reached nu as %q", sel)287+ }288+ if r := string(nu.jobs[1].Tables["__sheet1"]); !strings.Contains(r, "4") {289+ t.Errorf("$sheet.Sheet1!A1:A2 reached nu as %q", r)290+ }291+ if o := m.book().Output(m.sheet.NotebookCells()[0].ID); o.Selection != "Sheet1!A1:A2" {292+ t.Errorf("the output read %q", o.Selection)227293 }228294 }229295 230-// Opening a saved notebook shows its regions not run, and F9 runs them.231-func TestShellReopened(t *testing.T) {232- m, _ := notebookModel(t, map[string]string{"ls": lsOut, "$r1 | first": "{name: a.txt, size: 2kb}"})233- shell(t, m, "ls", "<enter>", "$r1 | first", "<enter>", "<esc>")234- s := roundTripSheet(t, m.sheet)235- m2, nu := notebookModel(t, map[string]string{"ls": lsOut, "$r1 | first": "{name: a.txt, size: 2kb}"})236- m2.reset(s, "nb.012")237- if got := input(m2, "A1"); got != "r1 ls not run" {238- t.Errorf("label %q", got)239- }240- if !strings.Contains(line(m2, contextLine), "run all") {241- t.Errorf("context line %q", line(m2, contextLine))242- }243- press(t, m2, "<f9>")244- if len(nu.jobs) != 2 || input(m2, "A1") != "r1 ls" {245- t.Errorf("run all: %d jobs, A1 %q", len(nu.jobs), input(m2, "A1"))296+func TestNotebookSendsToSheet(t *testing.T) {297+ m, nu := notebookModel(t, map[string]string{"ls": lsOut})298+ write(t, m, "files = ls")299+ press(t, m, "<ctrl+enter>", "G", "<enter>") // the picker's first choice: a new sheet300+ t2 := m.book().Lookup("files")301+ if t2 == nil {302+ t.Fatalf("no sheet files; note %q", m.note)303+ }304+ if got := t2.Value(addr("A1")).String(); got != "name" {305+ t.Errorf("A1 %q", got)306+ }307+ t2.Set(addr("D1"), "=ROWS(nu.files)")308+ if got := t2.Value(addr("D1")).Num; got != 4 {309+ t.Errorf("ROWS(nu.files) = %v", got)310+ }311+ // Running again updates the sheet through the change stream.312+ nu.out["ls"] = "[[name]; [x]]"313+ press(t, m, "<ctrl+enter>")314+ if got := t2.Value(addr("D1")).Num; got != 2 {315+ t.Errorf("after running again ROWS = %v", got)316+ }317+ // Undo the region's removal: it's fed again from the output.318+ m.showSheet(t2)319+ m.cur = addr("A1")320+ run(m, m.runCommand("region.delete"))321+ run(m, m.runCommand("edit.undo"))322+ send(m, tea.WindowSizeMsg{Width: 80, Height: 20})323+ if got := t2.Value(addr("A2")).String(); got != "x" {324+ t.Errorf("after undo A2 = %q", got)246325 }247326 }248327 249-func roundTripSheet(t *testing.T, s *sheet.Sheet) *sheet.Sheet {250- t.Helper()251- var b strings.Builder252- if err := s.Write(&b); err != nil {328+func TestNotebookTrust(t *testing.T) {329+ m, nu := notebookModel(t, map[string]string{"ls": lsOut})330+ write(t, m, "ls")331+ var b bytes.Buffer332+ if err := m.book().Write(&b); err != nil {253333 t.Fatal(err)254334 }255- got, err := sheet.Read(strings.NewReader(b.String()))335+ text := strings.Replace(b.String(), `"macroOrigin": "here"`, `"macroOrigin": "elsewhere"`, 1)336+ s, err := sheet.Read(strings.NewReader(text))256337 if err != nil {257338 t.Fatal(err)258339 }259- return got340+ m2 := New(s, "")341+ m2.Update(tea.WindowSizeMsg{Width: 80, Height: 20})342+ m2.SetShellRunner(nu)343+ m2.SetMachine("here")344+ m2.nb.still = true345+ run(m2, m2.runCommand("nb.open"))346+ run(m2, m2.runCommand("nb.run_all"))347+ if len(nu.jobs) != 0 || !strings.Contains(line(m2, contextLine), "Run this file's notebook cells?") {348+ t.Fatalf("ran without asking: %q", line(m2, contextLine))349+ }350+ press(t, m2, "<enter>")351+ if len(nu.jobs) != 1 {352+ t.Errorf("didn't run after agreeing")353+ }354+ m2.nb.served = true355+ run(m2, m2.runCommand("nb.run_all"))356+ if len(nu.jobs) != 1 || !strings.Contains(m2.errMsg, "012 serve") {357+ t.Errorf("ran served: %q", m2.errMsg)358+ }359+}360+361+func TestNotebookBangAndTab(t *testing.T) {362+ m := newModel()363+ press(t, m, "!")364+ if m.mode != modeEnter || m.line.Text() != "!" {365+ t.Fatalf("on a sheet, ! starts an entry; mode %v, entry %q", m.mode, m.line.Text())366+ }367+ press(t, m, "<esc>")368+ run(m, m.runCommand("nb.open"))369+ press(t, m, "!")370+ if !m.nbView().Editing() || len(m.sheet.NotebookCells()) != 1 {371+ t.Errorf("! on a notebook adds a cell to edit")372+ }373+ if got := line(m, m.height-1); !strings.Contains(got, "❯ Notebook") {374+ t.Errorf("the tab: %q", got)375+ }260376 }
D internal/ui/nukey_test.go +0 -26
D internal/ui/nuprompt/nuprompt.go +0 -331
@@ -1,331 +0,0 @@
1-// Package nuprompt is the nushell prompt of a notebook: a pipeline typed2-// on the formula bar after nu❯, with the workbook's history on Up and3-// Down and completions of regions ($r1) and nu's commands in a box4-// under the context line. It knows the UI only through Host: running a5-// line, and what's running, are the host's.6-package nuprompt7-8-import (9- "strconv"10- "strings"11-12- tea "charm.land/bubbletea/v2"13- "github.com/charmbracelet/x/ansi"14-15- "github.com/FelineStateMachine/012/internal/ui/lineedit"16- "github.com/FelineStateMachine/012/internal/ui/overlay"17- "github.com/FelineStateMachine/012/internal/ui/theme"18-)19-20-// Host is what the prompt needs of the UI.21-type Host interface {22- Theme() *theme.Theme23- Size() (width, height int)24- // Line is the shared edit line the command is typed in.25- Line() *lineedit.Line26- // Close closes the prompt.27- Close()28- // Submit runs a line; the prompt stays open for the next.29- Submit(line string) tea.Cmd30- // Words are what can be completed: the regions and nu's commands.31- Words() []Word32- // ShellHistory is the workbook's lines, oldest first.33- ShellHistory() []string34- // Running names the region running, or "" when none is.35- Running() string36- // Stop stops what's running.37- Stop()38- // Said is what the last run said: how many rows, or why it failed.39- Said() string40-}41-42-// Word is a completion.43-type Word struct {44- Text string // what Tab puts on the line: $r1, or a command45- Desc string // what it is: a region's command, or "command"46-}47-48-// ID identifies the completions' box in mouse events.49-const ID = "nuprompt"50-51-// Mark starts the prompt on the formula bar.52-const Mark = "nu❯ "53-54-// Prompt is the open prompt.55-type Prompt struct {56- h Host57- overlay.List58- shown []Word59- tabbed bool // the word is a completion Tab put there; Tab again moves on60- hist browse61-}62-63-// New returns a prompt with the edit line holding text.64-func New(h Host, text string) *Prompt {65- p := &Prompt{h: h}66- h.Line().Set(text)67- p.Changed()68- return p69-}70-71-// Shown are the completions of the word at the caret.72-func (p *Prompt) Shown() []Word { return p.shown }73-74-func (p *Prompt) Indicator() string { return "NU" }75-76-// word is the word before the caret, and where it starts: a $name, or a77-// command where one goes (at the start, or after |, ( or ;).78-func (p *Prompt) word() (string, int, bool) {79- head := []rune(p.h.Line().Head())80- i := len(head)81- for i > 0 && !strings.ContainsRune(" \t|(;{[", head[i-1]) {82- i--83- }84- w := string(head[i:])85- if strings.HasPrefix(w, "$") {86- return w, i, true87- }88- before := strings.TrimRight(string(head[:i]), " \t")89- command := before == "" || strings.ContainsAny(before[len(before)-1:], "|(;{") ||90- strings.HasSuffix(before, "=") && !strings.HasSuffix(before, "==")91- return w, i, command && w != ""92-}93-94-// Changed completes the word at the caret.95-func (p *Prompt) Changed() {96- p.Sel, p.Top, p.tabbed = 0, 0, false97- p.shown = p.shown[:0]98- w, _, ok := p.word()99- if !ok {100- return101- }102- lw := strings.ToLower(w)103- var contains []Word104- for _, it := range p.h.Words() {105- if strings.HasPrefix(it.Text, "$") != strings.HasPrefix(w, "$") {106- continue107- }108- switch t := strings.ToLower(it.Text); {109- case t == lw:110- case strings.HasPrefix(t, lw):111- p.shown = append(p.shown, it)112- case len(lw) > 1 && strings.Contains(t, lw):113- contains = append(contains, it)114- }115- }116- p.shown = append(p.shown, contains...)117-}118-119-func (p *Prompt) Key(k tea.KeyPressMsg) tea.Cmd {120- line := p.h.Line()121- switch key := k.String(); key {122- case "esc":123- if p.h.Running() != "" {124- p.h.Stop()125- return nil126- }127- p.h.Close()128- case "enter":129- text := strings.TrimSpace(line.Text())130- if text == "" {131- return nil132- }133- line.Clear()134- p.hist = browse{}135- p.Changed()136- return p.h.Submit(text)137- case "tab":138- p.complete(1)139- case "shift+tab":140- p.complete(-1)141- case "up", "down":142- p.recall(key)143- case "ctrl+p":144- p.Move(-1, len(p.shown))145- case "ctrl+n":146- p.Move(1, len(p.shown))147- default:148- before := line.Text()149- line.Key(k)150- if line.Text() != before {151- p.hist = browse{}152- p.Changed()153- }154- }155- return nil156-}157-158-// recall is Up and Down: an older or newer line of the history, among159-// those starting with what was typed.160-func (p *Prompt) recall(key string) {161- d := -1162- if key == "down" {163- d = 1164- }165- text, ok := p.hist.step(p.h.ShellHistory(), p.h.Line().Text(), d)166- if !ok {167- return168- }169- p.h.Line().Set(text)170- p.Changed()171- p.shown = p.shown[:0] // a line recalled is complete172-}173-174-// complete puts the highlighted completion in place of the word at the175-// caret; pressed again, it moves on to the next (d = 1) or the previous.176-func (p *Prompt) complete(d int) {177- if len(p.shown) == 0 {178- return179- }180- if p.tabbed {181- p.Move(d, len(p.shown))182- }183- p.put(p.shown[p.Sel].Text)184- p.tabbed = true185-}186-187-// put replaces the word at the caret with text.188-func (p *Prompt) put(text string) {189- line := p.h.Line()190- _, start, _ := p.completionStart()191- tail := line.Tail()192- head := string(line.Buf[:start]) + text193- line.Set(head + tail)194- line.Pos = len([]rune(head))195-}196-197-// completionStart is where the word Tab replaces starts: the word at the198-// caret, or where it was before the last Tab.199-func (p *Prompt) completionStart() (string, int, bool) {200- head := []rune(p.h.Line().Head())201- i := len(head)202- for i > 0 && !strings.ContainsRune(" \t|(;{[", head[i-1]) {203- i--204- }205- if p.tabbed { // the word Tab put may hold spaces: str join206- for _, it := range p.shown {207- if n := len([]rune(it.Text)); n <= len(head) && string(head[len(head)-n:]) == it.Text {208- return it.Text, len(head) - n, true209- }210- }211- }212- return string(head[i:]), i, true213-}214-215-// FormulaBar is what the formula bar shows after the name box.216-func (p *Prompt) FormulaBar() string {217- return p.h.Theme().Title.Render(Mark) + p.h.Line().Text()218-}219-220-// Cursor is on the formula bar, after the prompt's mark.221-func (p *Prompt) Cursor() (int, int) {222- return overlay.FormulaBarX + ansi.StringWidth(Mark) + ansi.StringWidth(p.h.Line().Head()), overlay.FormulaLine223-}224-225-// ContextLine says what's running, or what the last run said.226-func (p *Prompt) ContextLine() (string, string) {227- th := p.h.Theme()228- if r := p.h.Running(); r != "" {229- return th.Hint.Render("Running " + r + "…"), th.KeyHints("Esc", "stop")230- }231- if said := p.h.Said(); said != "" {232- return said, th.KeyHints("Esc", "back to the grid")233- }234- return th.KeyHints("Enter", "run", "name = ", "name it", "$r1", "a region", "$in", "the selection"), ""235-}236-237-func (p *Prompt) Status() (string, string) {238- if p.Sel >= len(p.shown) {239- return "", "" // the sheet's tabs, with the notebook240- }241- return p.shown[p.Sel].Desc, p.h.Theme().KeyHints("Tab", "complete", "Ctrl+N", "next", "Enter", "run", "Esc", "grid")242-}243-244-// rows is how many completions show.245-func (p *Prompt) rows() int {246- _, height := p.h.Size()247- return max(min(len(p.shown), 8, height-overlay.ContextLine-4), 0)248-}249-250-// Layout draws the completions in a box under the context line.251-func (p *Prompt) Layout() []overlay.Box {252- rows := p.rows()253- if rows == 0 {254- return nil255- }256- p.Show(rows)257- th := p.h.Theme()258- width, _ := p.h.Size()259- tw, dw := 0, 0260- for _, it := range p.shown {261- tw = max(tw, ansi.StringWidth(it.Text))262- dw = max(dw, ansi.StringWidth(it.Desc))263- }264- inner := min(max(1+tw+2+dw+1, 30), 72, width-2)265- tw = min(tw, inner-2)266- dw = max(inner-1-tw-2-1, 0)267- lines := make([]string, rows)268- for r := range rows {269- i := p.Top + r270- it := p.shown[i]271- base, dim := th.MenuBar, th.Muted272- if i == p.Sel {273- base, dim = th.MenuSelected, th.MenuSelected274- }275- row := base.Render(" "+theme.PadRight(ansi.Truncate(it.Text, tw, "…"), tw)+" ") +276- dim.Render(theme.PadRight(ansi.Truncate(it.Desc, dw, "…"), dw)) + base.Render(" ")277- lines[r] = row278- }279- footer := strconv.Itoa(p.Sel+1) + " of " + strconv.Itoa(len(p.shown))280- x := min(overlay.FormulaBarX, max(width-inner-2, 0))281- return []overlay.Box{{ID: ID, X: x, Y: overlay.ContextLine + 1, Lines: th.Frame(inner, "Completions", footer, lines)}}282-}283-284-func (p *Prompt) Mouse(e overlay.MouseEvent) tea.Cmd {285- if e.Box != ID {286- return nil // the grid stays where it is while typing287- }288- i := p.Top + e.Row - 1 // under the top border289- switch {290- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:291- p.Move(-1, len(p.shown))292- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:293- p.Move(1, len(p.shown))294- case e.Row < 1 || i >= len(p.shown) || i >= p.Top+p.rows():295- case e.Kind == overlay.MouseMotion:296- p.Sel = i297- case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:298- p.Sel = i299- p.put(p.shown[i].Text)300- p.Changed()301- }302- return nil303-}304-305-// browse is a walk through the history with Up and Down: only lines306-// starting with what was typed before the first Up.307-type browse struct {308- on bool309- at int // the line shown, len(lines) for what was typed310- prefix string // what was typed311-}312-313-// step moves d lines through lines (-1 older, 1 newer) from what's314-// shown, skipping lines that don't start with the prefix, and returns315-// the text to show and whether there was a line to move to.316-func (b *browse) step(lines []string, typed string, d int) (string, bool) {317- if !b.on {318- *b = browse{on: true, at: len(lines), prefix: typed}319- }320- for i := b.at + d; i >= 0 && i <= len(lines); i += d {321- if i == len(lines) {322- b.at = i323- return b.prefix, true324- }325- if strings.HasPrefix(lines[i], b.prefix) {326- b.at = i327- return lines[i], true328- }329- }330- return "", false331-}
D internal/ui/nuprompt/nuprompt_test.go +0 -100

Not shown: this page already shows 6000 lines of diff.

D internal/ui/nurun.go +0 -325

Not shown: this page already shows 6000 lines of diff.

M internal/ui/overlay.go +4 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/panel.go +38 -29

Not shown: this page already shows 6000 lines of diff.

M internal/ui/pivot.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/sort.go +5 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/tabs.go +4 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/tabstrip/tabstrip.go +9 -2

Not shown: this page already shows 6000 lines of diff.

A internal/ui/theme/code.go +42 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/theme/scheme.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/theme/theme.go +15 -7

Not shown: this page already shows 6000 lines of diff.

M internal/ui/view.go +1 -4

Not shown: this page already shows 6000 lines of diff.