cat / 012
clone

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

Notebook: Jupyter's look and ergonomics

Dami committed 275c3f2
Code cells in boxes with [n]: and Out[n]: prompts and a ▶ that runs
them; notes drawn as text, in a box only while edited; a bar by the
active cell, blue in command mode and green in edit mode (▌, ▎ for the
other cells selected, a heavy box while edited, so it reads without
color). A toolbar where the formula bar is: run, stop, restart, run
all, add, cut, copy, paste, the cell's kind, and nu's status, clicked or
keyed, with chips where they fit; the context line closes it off as a
rule.

Several cells selected with Shift+Up and Down (J, K, Shift+click) run,
delete, copy, cut, move (Alt+Up and Down, Ctrl+Shift+Up and Down) and
change kind together; V pastes above. Outputs scroll in a window of
their own (arrows, the wheel), O shows them whole, o folds them, and
Clear output clears one. Go to cell (Ctrl+G) and a table of contents
from the notes' headings (View menu). Clicks select, edit where they
land, run, fold and open; right-click opens the cell menu. Run all
scrolls to each cell as it runs.

A mouse action's command is no longer dropped by Update.
commit
275c3f2fdd2c177e9234bf1bd0a1b5f05b056c6f
parent
89ca25dd4e0ee089e709d2b9a2d7e31f9a87bd80
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.4 of 3, Jev's estimate
30 files changed, +2067 -676
Mdocs/reference/keys.md29++-
Minternal/ui/lineedit/area.go14+
Minternal/ui/menubar.go2+-
Minternal/ui/model.go1+
Minternal/ui/monochrome_test.go21+-
Ainternal/ui/nbcells.go144++++++++++++
Ainternal/ui/nbcells_test.go152+++++++++++++
Ainternal/ui/nbcommands.go120++++++++++
Minternal/ui/nbrun.go31++-
Minternal/ui/nbscreen.go113++++++----
Minternal/ui/nbview/bench_test.go14+-
Ainternal/ui/nbview/draw.go265++++++++++++++++++++++
Minternal/ui/nbview/full.go2+-
Ainternal/ui/nbview/jupyter_test.go175++++++++++++++
Minternal/ui/nbview/keys.go139+++++------
Ainternal/ui/nbview/layout.go112+++++++++
Ainternal/ui/nbview/mouse.go160+++++++++++++
Ainternal/ui/nbview/outline.go67++++++
Minternal/ui/nbview/output.go142+-----------
Ainternal/ui/nbview/outwin.go190++++++++++++++++
Minternal/ui/nbview/panel.go54++++-
Ainternal/ui/nbview/toolbar.go182+++++++++++++++
Minternal/ui/nbview/view.go363++++++++++++------------------
Minternal/ui/nbview/view_test.go14+-
Minternal/ui/notebook.go185++++------------
Minternal/ui/notebook_test.go20+-
Minternal/ui/panel.go5+-
Minternal/ui/theme/code.go2+
Minternal/ui/theme/scheme.go2+-
Minternal/ui/theme/theme.go23+-
M docs/reference/keys.md +23 -6
@@ -138,15 +138,19 @@ on a sheet, `!` starts an entry as any character does.
138138 139139 | Key | In command mode |140140 |---|---|141-| Up, Down, `j`, `k`, Home, End, PgUp, PgDn | Move between cells and their outputs |141+| Up, Down, `j`, `k`, Home, End, PgUp, PgDn | Move between cells and their outputs; on an output, Up and Down scroll its window first |142+| Shift+Up, Shift+Down, `K`, `J` | Select the cells passed over too, to run, delete, copy or move together |143+| Alt+Up, Alt+Down, Ctrl+Shift+Up, Ctrl+Shift+Down | Move the selected cells up or down |142144 | Enter | Edit the cell; on an output, open it full-screen |143-| Shift+Enter, Ctrl+Enter, `r`, Alt+Enter | Run the cell and select the next; run it in place (`r` too); run it and add a cell under it |145+| Shift+Enter, Ctrl+Enter, `r`, Alt+Enter | Run the cells and select the next; run them in place (`r` too); run them and add a cell under them |144146 | F9 | Run every cell |145147 | `a`, `b`, `!` | Add a code cell above, below; add one below and edit it |146-| `dd`, `z` | Delete the cell; undo |147-| `m`, `y` | Make the cell a note, or code |148-| `c`, `x`, `v` | Copy, cut, paste a cell |149-| `n`, `o`, `G` | Name the cell; show all of its output or its first rows; send the output to a sheet |148+| `dd`, `z` | Delete the selected cells; undo |149+| `m`, `y` | Make the cells notes, or code |150+| `c`, `x`, `v`, `V` | Copy, cut the cells; paste below, above |151+| `o`, `O` | Hide the output, or show it again; show all of it rather than a window |152+| `n`, `G` | Name the cell; send the output to a sheet |153+| Ctrl+G | Go to a cell by its number, name, code or heading |150154 | `ii`, `00` | Stop what's running; restart, clearing every output |151155 152156 | Key | In edit mode |
@@ -306,6 +310,19 @@ own.
306310 | Cmd- or Ctrl-click a link | Open a URL in a cell or a `=HYPERLINK(url, [label])` |307311 | Hover a cell with a note | Show the note beside it |308312 313+On a notebook's tab, as in JupyterLab:314+315+| Gesture | Action |316+|---|---|317+| Click a cell; Shift+click | Select it; select the cells between too |318+| Click in a code cell's box; double-click a note | Edit it, the caret where you clicked |319+| Click `▶` (`■` while it runs) | Run the cell (stop it) |320+| Click an output; double-click it | Select it; open it full-screen |321+| Click left of an output | Hide it, or show it again |322+| Wheel over an output's window | Scroll the output, then the notebook |323+| Click a toolbar button | Run, stop, restart, run all, add, cut, copy, paste, or pick the cell's kind |324+| Right-click a cell | The cell menu |325+309326 The sheet tabs take the mouse too: see [sheets and tabs](../sheets/sheets-and-tabs.md).310327 While typing a formula, clicking a cell or a tab points at it, as in311328 Sheets.
M internal/ui/lineedit/area.go +14 -0
M internal/ui/menubar.go +1 -1
M internal/ui/model.go +1 -0
M internal/ui/monochrome_test.go +14 -7
@@ -206,9 +206,10 @@ func cellsOf(l []monoCell, text string) []monoCell {
206206 return l[len([]rune(cellText(l)[:i])):][:len([]rune(text))]207207 }208208 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.209+// A notebook's states read without color: the active cell marked by ▌210+// at its left, the other cells selected with it by ▎, the cell being211+// edited in a heavy box, the mode in words, an output table's header212+// bold and underlined, a stale output and a failure in words.212213 func TestMonochromeNotebook(t *testing.T) {213214 m, nu := notebookModel(t, map[string]string{"ls": lsOut, "$files | first": "{name: a.txt}"})214215 write(t, m, "files = ls")
@@ -216,19 +217,24 @@ func TestMonochromeNotebook(t *testing.T) {
216217 write(t, m, "nope")217218 run(m, m.runCommand("nb.run_all"))218219 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)220+ bar := func(text string) string { return string([]rune(cellText(findLine(t, m, text)))[:1]) }221+ if bar("[1]:") != "▌" || bar("[2]:") != " " {222+ t.Errorf("the active cell's bar: %q %q", bar("[1]:"), bar("[2]:"))221223 }222- if head := findLine(t, m, "[2]"); every(head, true, isReverse) {223- t.Error("a cell not selected has its head reversed")224+ press(t, m, "<shift+down>", "<shift+down>")225+ if bar("[1]:") != "▎" || bar("[2]:") != "▎" || bar("[3]:") != "▌" {226+ t.Errorf("the cells selected: %q %q %q", bar("[1]:"), bar("[2]:"), bar("[3]:"))224227 }228+ press(t, m, "<home>")225229 hdr := findLine(t, m, "name")226230 if c := cellsOf(hdr, "name"); !every(c, true, func(c monoCell) bool { return c.bold && c.underline == "1" }) {227231 t.Errorf("an output's header: %+v", c)228232 }233+ press(t, m, "<end>")229234 if !strings.Contains(cellText(findLine(t, m, "× Command")), "× Command `nope` not found") {230235 t.Error("the failure isn't in words")231236 }237+ press(t, m, "<home>")232238 nu.out["ls"] = "[[name]; [z]]"233239 press(t, m, "<ctrl+enter>")234240 findLine(t, m, "stale")
@@ -236,6 +242,7 @@ func TestMonochromeNotebook(t *testing.T) {
236242 if !strings.Contains(cellText(monoLine(m, menuLine)), " EDIT ") {237243 t.Error("edit mode isn't in words")238244 }245+ findLine(t, m, "┏━")239246 }240247 241248 func TestMonochromeError(t *testing.T) {
A internal/ui/nbcells.go +144 -0
@@ -0,0 +1,144 @@
1+package ui2+3+import (4+ "slices"5+ "strconv"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/notebook"10+)11+12+// Changing a notebook's cells, as Jupyter does, on the cells selected:13+// the active cell, or the run Shift+Up and Down selected with it. Each14+// change is one undo step (setCells), so z brings back what d d deleted.15+16+// nbRange is the selected cells of the notebook shown, first and last,17+// and the cells.18+func (m *Model) nbRange() (from, to int, cells []notebook.Cell) {19+ from, to = m.nbView().Range()20+ return from, to, m.sheet.NotebookCells()21+}22+23+// cellWords names cells from to to for a note: "cell 3", "cells 2 to 4".24+func cellWords(from, to int) string {25+ if from == to {26+ return "cell " + strconv.Itoa(from+1)27+ }28+ return "cells " + strconv.Itoa(from+1) + " to " + strconv.Itoa(to+1)29+}30+31+// addCell adds an empty code cell above the selected ones (at 0) or32+// below them (at 1), editing it with edit.33+func (m *Model) addCell(at int, edit bool) tea.Cmd {34+ v := m.nbView()35+ from, to, cells := m.nbRange()36+ i := from37+ if at > 0 && len(cells) > 0 {38+ i = to + 139+ }40+ v.StopEdit()41+ cells = slices.Insert(cells, i, notebook.Cell{})42+ m.setCells("add cell", cells)43+ v.Select(i, false)44+ if edit {45+ return v.StartEdit()46+ }47+ return nil48+}49+50+// deleteCells deletes the selected cells.51+func (m *Model) deleteCells() {52+ from, to, cells := m.nbRange()53+ if len(cells) == 0 {54+ return55+ }56+ m.setCells("delete "+cellWords(from, to), slices.Delete(cells, from, to+1))57+ m.nbView().Select(from, false)58+ m.note = "Deleted " + cellWords(from, to) + ": z brings them back"59+ if from == to {60+ m.note = "Deleted " + cellWords(from, to) + ": z brings it back"61+ }62+}63+64+// setKind turns the selected cells into code or note cells.65+func (m *Model) setKind(k notebook.Kind) {66+ v := m.nbView()67+ from, to, cells := m.nbRange()68+ changed := false69+ for i := from; i <= to && i < len(cells); i++ {70+ if cells[i].Kind != k {71+ cells[i].Kind, changed = k, true72+ }73+ }74+ if changed {75+ v.StopEdit()76+ m.setCells("change "+cellWords(from, to), cells)77+ v.SelectRange(from, to)78+ }79+}80+81+// copyCells copies the selected cells, and deletes them with cut.82+func (m *Model) copyCells(cut bool) {83+ from, to, cells := m.nbRange()84+ if len(cells) == 0 {85+ return86+ }87+ m.nb.clip = slices.Clone(cells[from : to+1])88+ for i := range m.nb.clip {89+ m.nb.clip[i].ID = 090+ }91+ if cut {92+ m.setCells("cut "+cellWords(from, to), slices.Delete(cells, from, to+1))93+ m.nbView().Select(from, false)94+ m.note = "Cut " + cellWords(from, to) + ": v pastes below, V above"95+ return96+ }97+ m.note = "Copied " + cellWords(from, to) + ": v pastes below, V above"98+}99+100+// pasteCells pastes the cells copied below the selected ones, or above101+// them, selecting what it pasted.102+func (m *Model) pasteCells(above bool) {103+ v := m.nbView()104+ from, to, cells := m.nbRange()105+ i := from106+ if !above && len(cells) > 0 {107+ i = to + 1108+ }109+ cells = slices.Insert(cells, i, m.nb.clip...)110+ m.setCells("paste "+plural(len(m.nb.clip), "cell", strconv.Itoa(len(m.nb.clip))+" cells"), cells)111+ v.SelectRange(i, i+len(m.nb.clip)-1)112+}113+114+// moveCells moves the selected cells one place up (d -1) or down (1),115+// together, still selected.116+func (m *Model) moveCells(d int) {117+ v := m.nbView()118+ from, to, cells := m.nbRange()119+ if len(cells) == 0 || from+d < 0 || to+d >= len(cells) {120+ return121+ }122+ v.StopEdit()123+ moved := slices.Clone(cells[from : to+1])124+ cells = slices.Delete(cells, from, to+1)125+ cells = slices.Insert(cells, from+d, moved...)126+ m.setCells("move "+cellWords(from, to), cells)127+ if sel, _ := v.Selected(); sel == to {128+ v.SelectRange(from+d, to+d) // the active cell stays the one it was129+ } else {130+ v.SelectRange(to+d, from+d)131+ }132+}133+134+// clearCellOutputs clears the selected cells' outputs.135+func (m *Model) clearCellOutputs() {136+ from, to, cells := m.nbRange()137+ for i := from; i <= to && i < len(cells); i++ {138+ if m.book().Output(cells[i].ID) != nil {139+ m.book().SetOutput(cells[i].ID, nil)140+ m.feedOutput(cells[i].Name())141+ }142+ }143+ m.note = "Cleared the output of " + cellWords(from, to)144+}
A internal/ui/nbcells_test.go +152 -0
@@ -0,0 +1,152 @@
1+package ui2+3+import (4+ "slices"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/notebook"12+)13+14+// notebookOf is a notebook model with code cells of srcs, the first15+// selected, in command mode.16+func notebookOf(t *testing.T, out map[string]string, srcs ...string) (*Model, *fakeNu) {17+ t.Helper()18+ m, nu := notebookModel(t, out)19+ var cells []notebook.Cell20+ for _, s := range srcs {21+ cells = append(cells, notebook.Cell{Source: s})22+ }23+ m.setCells("cells", cells)24+ m.nbView().Select(0, false)25+ return m, nu26+}27+28+// screenX is the column text starts at on screen line y.29+func screenX(t *testing.T, m *Model, y int, text string) int {30+ t.Helper()31+ l := line(m, y)32+ i := strings.Index(l, text)33+ if i < 0 {34+ t.Fatalf("line %d lacks %q: %q", y, text, l)35+ }36+ return ansi.StringWidth(l[:i])37+}38+39+func TestNotebookMovesCells(t *testing.T) {40+ m, _ := notebookOf(t, nil, "a", "b", "c")41+ press(t, m, "<down>", "<alt+down>")42+ if got := cells(m); !slices.Equal(got, []string{"a", "c", "b"}) {43+ t.Fatalf("Alt+Down: %q", got)44+ }45+ if i, _ := m.nbView().Selected(); i != 2 {46+ t.Errorf("the cell moved isn't selected: %d", i)47+ }48+ press(t, m, "<shift+up>", "<alt+up>")49+ if got := cells(m); !slices.Equal(got, []string{"c", "b", "a"}) {50+ t.Fatalf("two cells up: %q", got)51+ }52+ if from, to := m.nbView().Range(); from != 0 || to != 1 {53+ t.Errorf("the cells moved aren't selected: %d to %d", from, to)54+ }55+ press(t, m, "z")56+ if got := cells(m); !slices.Equal(got, []string{"a", "c", "b"}) {57+ t.Errorf("undo: %q", got)58+ }59+}60+61+func TestNotebookActsOnSeveralCells(t *testing.T) {62+ m, nu := notebookOf(t, map[string]string{"a": "1", "b": "2"}, "a", "b", "c")63+ press(t, m, "<shift+down>", "c", "<end>", "V")64+ if got := cells(m); !slices.Equal(got, []string{"a", "b", "a", "b", "c"}) {65+ t.Fatalf("copy, paste above: %q", got)66+ }67+ if from, to := m.nbView().Range(); from != 2 || to != 3 {68+ t.Errorf("what was pasted isn't selected: %d to %d", from, to)69+ }70+ press(t, m, "d", "d")71+ if got := cells(m); !slices.Equal(got, []string{"a", "b", "c"}) || !strings.Contains(m.note, "cells 3 to 4") {72+ t.Fatalf("d d: %q, %q", got, m.note)73+ }74+ press(t, m, "z")75+ if got := cells(m); len(got) != 5 {76+ t.Errorf("z: %q", got)77+ }78+ press(t, m, "<home>", "<shift+down>", "<ctrl+enter>")79+ if got := nu.ran(); !slices.Equal(got, []string{"a", "b"}) {80+ t.Errorf("running two cells ran %q", got)81+ }82+ press(t, m, "m")83+ if c := m.sheet.NotebookCells(); c[0].Kind != notebook.Note || c[1].Kind != notebook.Note || c[2].Kind != notebook.Code {84+ t.Error("m made the two cells notes")85+ }86+}87+88+// The toolbar's buttons run their commands; Code ▾ opens the kinds.89+func TestNotebookToolbar(t *testing.T) {90+ m, nu := notebookOf(t, map[string]string{"ls": lsOut}, "ls")91+ if got := line(m, formulaLine); !strings.Contains(got, "▶ Run") || !strings.Contains(got, "nu ○ idle") {92+ t.Fatalf("the toolbar: %q", got)93+ }94+ send(m, tea.MouseClickMsg{X: screenX(t, m, formulaLine, "▶ Run") + 1, Y: formulaLine, Button: tea.MouseLeft})95+ if len(nu.jobs) != 1 || !strings.Contains(screen(m), "Out[1]:") {96+ t.Fatalf("▶ Run ran %v:\n%s", nu.ran(), screen(m))97+ }98+ send(m, tea.MouseClickMsg{X: screenX(t, m, formulaLine, "Code ▾"), Y: formulaLine, Button: tea.MouseLeft})99+ if m.overlay == nil {100+ t.Fatal("Code ▾ didn't open the kinds")101+ }102+ press(t, m, "<down>", "<enter>")103+ if c := m.sheet.NotebookCells(); c[len(c)-1].Kind != notebook.Note {104+ t.Errorf("Markdown didn't make the cell a note: %+v", c)105+ }106+}107+108+// A click on a cell's ▶ runs it, and a click in its box edits it.109+func TestNotebookMouse(t *testing.T) {110+ m, nu := notebookOf(t, map[string]string{"ls": lsOut}, "ls", "b")111+ y := nbBody + 1 // the first cell's code112+ send(m, tea.MouseClickMsg{X: m.width - 1, Y: y, Button: tea.MouseLeft})113+ if len(nu.jobs) != 1 {114+ t.Fatalf("▶ ran %v", nu.ran())115+ }116+ send(m, tea.MouseClickMsg{X: 14, Y: y, Button: tea.MouseLeft})117+ if !m.nbView().Editing() || !strings.Contains(line(m, menuLine), "EDIT") {118+ t.Error("a click in the box didn't edit the cell")119+ }120+ send(m, tea.MouseClickMsg{X: 14, Y: y, Button: tea.MouseRight})121+ if m.overlay == nil {122+ t.Error("a right click didn't open the cell menu")123+ }124+}125+126+func TestNotebookGoToCell(t *testing.T) {127+ m, _ := notebookOf(t, nil, "a", "sales = open x.csv", "c")128+ press(t, m, "<ctrl+g>")129+ if m.overlay == nil {130+ t.Fatal("Ctrl+G opened nothing")131+ }132+ m.line.Set("sales")133+ m.overlay.(interface{ Changed() }).Changed()134+ press(t, m, "<enter>")135+ if i, _ := m.nbView().Selected(); i != 1 || m.overlay != nil {136+ t.Errorf("went to cell %d", i+1)137+ }138+ run(m, m.runCommand("nb.toc"))139+ if !strings.Contains(m.note, "no headings") {140+ t.Errorf("a table of contents without headings: %q", m.note)141+ }142+}143+144+func TestNotebookClearsOneOutput(t *testing.T) {145+ m, _ := notebookOf(t, map[string]string{"a": "1", "b": "2"}, "a", "b")146+ run(m, m.runCommand("nb.run_all"))147+ run(m, m.runCommand("nb.clear_output"))148+ c := m.sheet.NotebookCells()149+ if m.book().Output(c[0].ID) != nil || m.book().Output(c[1].ID) == nil {150+ t.Error("Clear output cleared the wrong outputs")151+ }152+}
A internal/ui/nbcommands.go +120 -0
@@ -0,0 +1,120 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/notebook"10+ "github.com/FelineStateMachine/012/internal/ui/picker"11+)12+13+// The commands that change a notebook's cells and how their outputs14+// show, and finding a cell: each acts on the cells selected (nbcells.go).15+16+// registerCellCommands registers the commands that change cells.17+func registerCellCommands(onCell, onCode func(*Model) bool) {18+ onTab := func(m *Model) bool { return m.nbView() != nil }19+ isKind := func(k notebook.Kind) func(m *Model) bool {20+ return func(m *Model) bool { c, ok := m.nbCell(); return ok && c.Kind == k }21+ }22+ register(23+ &command{id: "nb.insert_above", macro: macroNever, title: "Add cell above", desc: "Add a code cell above the selected ones",24+ enabled: onTab, run: func(m *Model) tea.Cmd { return m.addCell(0, false) }},25+ &command{id: "nb.insert_below", macro: macroNever, title: "Add cell below", desc: "Add a code cell below the selected ones",26+ enabled: onTab, run: func(m *Model) tea.Cmd { return m.addCell(1, false) }},27+ &command{id: "nb.add_code", macro: macroNever, title: "New code cell", desc: "Add a code cell below the selected ones and edit it",28+ enabled: onTab, run: func(m *Model) tea.Cmd { return m.addCell(1, true) }},29+ &command{id: "nb.delete", macro: macroNever, title: "Delete cells", desc: "Delete the selected cells; z or Undo brings them back",30+ enabled: onCell, run: func(m *Model) tea.Cmd { m.deleteCells(); return nil }},31+ &command{id: "nb.move_up", macro: macroNever, title: "Move cells up", desc: "Move the selected cells above the cell over them",32+ enabled: func(m *Model) bool { from, _ := m.nbRangeIf(); return from > 0 }, run: func(m *Model) tea.Cmd { m.moveCells(-1); return nil }},33+ &command{id: "nb.move_down", macro: macroNever, title: "Move cells down", desc: "Move the selected cells below the cell under them",34+ enabled: func(m *Model) bool { _, to := m.nbRangeIf(); return to >= 0 && to < len(m.sheet.NotebookCells())-1 },35+ run: func(m *Model) tea.Cmd { m.moveCells(1); return nil }},36+ &command{id: "nb.to_note", macro: macroNever, title: "Markdown", desc: "Make the selected cells notes, written in Markdown",37+ enabled: onCell, checked: isKind(notebook.Note), run: func(m *Model) tea.Cmd { m.setKind(notebook.Note); return nil }},38+ &command{id: "nb.to_code", macro: macroNever, title: "Code", desc: "Make the selected cells code, nushell pipelines",39+ enabled: onCell, checked: isKind(notebook.Code), run: func(m *Model) tea.Cmd { m.setKind(notebook.Code); return nil }},40+ &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",41+ enabled: onCode, run: (*Model).openCellName},42+ &command{id: "nb.copy", macro: macroNever, title: "Copy cells", desc: "Copy the selected cells, to paste with v or V",43+ enabled: onCell, run: func(m *Model) tea.Cmd { m.copyCells(false); return nil }},44+ &command{id: "nb.cut", macro: macroNever, title: "Cut cells", desc: "Cut the selected cells, to paste with v or V",45+ enabled: onCell, run: func(m *Model) tea.Cmd { m.copyCells(true); return nil }},46+ &command{id: "nb.paste", macro: macroNever, title: "Paste cells below", desc: "Paste the cells copied below the selected ones",47+ enabled: func(m *Model) bool { return m.nbView() != nil && len(m.nb.clip) > 0 }, run: func(m *Model) tea.Cmd { m.pasteCells(false); return nil }},48+ &command{id: "nb.paste_above", macro: macroNever, title: "Paste cells above", desc: "Paste the cells copied above the selected ones",49+ enabled: func(m *Model) bool { return m.nbView() != nil && len(m.nb.clip) > 0 }, run: func(m *Model) tea.Cmd { m.pasteCells(true); return nil }},50+ &command{id: "nb.goto", macro: macroNever, title: "Go to cell", desc: "Jump to a cell by its number, name, code or heading",51+ enabled: onCell, run: func(m *Model) tea.Cmd { m.openCellPicker(false); return nil }},52+ &command{id: "nb.toc", macro: macroNever, title: "Table of contents", desc: "Jump to a heading of the notebook's notes",53+ enabled: onTab, run: func(m *Model) tea.Cmd { m.openCellPicker(true); return nil }},54+ )55+}56+57+// registerOutputCommands registers the commands for outputs.58+func registerOutputCommands(onCode func(*Model) bool) {59+ register(60+ &command{id: "nb.toggle_output", macro: macroNever, title: "Hide output", desc: "Fold the selected cells' outputs to a line, or show them again",61+ enabled: onCode, checked: func(m *Model) bool { c, ok := m.nbCell(); return ok && m.nbView().Hidden(c.ID) },62+ run: func(m *Model) tea.Cmd { m.nbView().ToggleHidden(); return nil }},63+ &command{id: "nb.toggle_whole", macro: macroNever, title: "Show whole output", desc: "Show every row of the selected cells' outputs, or scroll them in a window again",64+ enabled: onCode, checked: func(m *Model) bool { c, ok := m.nbCell(); return ok && m.nbView().Whole(c.ID) },65+ run: func(m *Model) tea.Cmd { m.nbView().ToggleWhole(); return nil }},66+ &command{id: "nb.clear_output", macro: macroNever, title: "Clear output", desc: "Clear the selected cells' outputs",67+ enabled: onCode, run: func(m *Model) tea.Cmd { m.clearCellOutputs(); return nil }},68+ &command{id: "nb.open_output", macro: macroNever, title: "Open output", desc: "Show the selected cell's output full-screen, to sort and filter it",69+ enabled: onCode, run: func(m *Model) tea.Cmd {70+ if !m.nbView().OpenFull() {71+ m.note = "The cell has no output to open"72+ }73+ return nil74+ }},75+ &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",76+ enabled: onCode, run: (*Model).sendCell},77+ &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",78+ enabled: onOutputRegion, run: func(m *Model) tea.Cmd { return m.freezeOutput() }},79+ &command{id: "region.delete", title: "Remove output", desc: "Remove the output sent here and its values from the sheet",80+ enabled: onOutputRegion, run: func(m *Model) tea.Cmd { return m.deleteOutput() }},81+ )82+}83+84+// nbRangeIf is the selected cells of the notebook shown, or -1, -1 on a85+// sheet.86+func (m *Model) nbRangeIf() (from, to int) {87+ v := m.nbView()88+ if v == nil || len(m.sheet.NotebookCells()) == 0 {89+ return -1, -190+ }91+ return v.Range()92+}93+94+// openCellPicker opens a picker of the notebook's cells to jump to, or95+// of its headings alone, a table of contents.96+func (m *Model) openCellPicker(toc bool) {97+ v := m.nbView()98+ var items []picker.Item99+ for _, e := range v.Outline(!toc) {100+ title := e.Title101+ detail := "cell " + strconv.Itoa(e.Cell+1)102+ if e.Level > 0 {103+ title = strings.Repeat(" ", e.Level-1) + title104+ }105+ i := e.Cell106+ items = append(items, picker.Item{Title: title, Name: len(title), Detail: detail, Desc: "Go to " + detail,107+ Pick: func() tea.Cmd { m.closeOverlay(); v.Select(i, false); return nil }})108+ }109+ title, empty := "Go to cell", "a cell's number, name or code"110+ if toc {111+ title, empty = "Table of contents", "a heading"112+ if len(items) == 0 {113+ m.note = "The notes have no headings: # starts one"114+ return115+ }116+ }117+ p := m.newPicker(title, empty, 60, items)118+ p.Action = "go"119+ m.openOverlay(p)120+}
M internal/ui/nbrun.go +22 -9
@@ -61,6 +61,9 @@ type nbState struct {
6161 // langKey is what the words it was last given were worked out from.6262 lang *nbview.NuSession6363 langKey nbLangKey64+ // follow scrolls the view to each cell as it starts, while a run of65+ // several cells goes on and the user hasn't scrolled away.66+ follow bool6467 }6568 6669 // nbQueued is a cell waiting to run.
@@ -141,31 +144,36 @@ func (m *Model) staleCells(s *sheet.Sheet) map[int]bool {
141144 return m.nb.stale142145 }143146 144-// runSelected runs the selected cell: then, with next 1, selects the145-// cell below (adding one at the end), or with 2 adds one under it.147+// runSelected runs the selected cells: then, with next 1, selects the148+// cell below them (adding one at the end), or with 2 adds one under149+// them, as Jupyter's Shift+Enter and Alt+Enter.146150 func (m *Model) runSelected(next int) tea.Cmd {147151 v := m.nbView()148152 v.StopEdit()149- i, _ := v.Selected()153+ from, to := v.Range()150154 cells := m.sheet.NotebookCells()151- if i >= len(cells) {155+ if from >= len(cells) {152156 return nil153157 }154158 var cmd tea.Cmd155- if cells[i].Kind == notebook.Code {156- cmd = m.runCells(m.sheet, i, cells)159+ switch {160+ case from < to:161+ cmd = m.runRange(from, to+1)162+ case cells[from].Kind == notebook.Code:163+ cmd = m.runCells(m.sheet, from, cells)157164 }158165 switch {159- case next == 2 || next == 1 && i == len(cells)-1:166+ case next == 2 || next == 1 && to == len(cells)-1:160167 return tea.Batch(cmd, m.addCell(1, next == 2))161168 case next == 1:162- v.Select(i+1, false)169+ v.Select(to+1, false)163170 }164171 return cmd165172 }166173 167174 // runRange runs the code cells from index from up to to (-1 for the168-// last), each after the cells it reads.175+// last), each after the cells it reads, the view following the cell176+// running.169177 func (m *Model) runRange(from, to int) tea.Cmd {170178 v := m.nbView()171179 v.StopEdit()
@@ -177,6 +185,7 @@ func (m *Model) runRange(from, to int) tea.Cmd {
177185 for i := from; i < to && i < len(cells); i++ {178186 want = append(want, i)179187 }188+ m.nb.follow = len(want) > 1180189 return m.queueCells(m.sheet, cells, want)181190 }182191
@@ -269,6 +278,7 @@ func (m *Model) nextCell() tea.Cmd {
269278 }270279 return m.startCell(run, job)271280 }281+ m.nb.follow = false // nothing left to follow272282 return nil273283 }274284
@@ -296,6 +306,9 @@ func (m *Model) startCell(run *nbRun, job nushell.Job) tea.Cmd {
296306 ctx, cancel := context.WithCancel(context.Background())297307 run.gen, run.cancel = m.nb.gen, cancel298308 m.nb.running = run309+ if m.nb.follow {310+ m.viewOf(run.s).Reveal(run.id) // Run all: the view follows the cell running311+ }299312 runner, timeout, parent := m.runner(), m.configDuration("nu-timeout", 30*time.Second), m.spans.Parent()300313 exec := func() tea.Msg {301314 defer cancel()
M internal/ui/nbscreen.go +68 -45
@@ -1,26 +1,25 @@
11 package ui22 33 import (4- "strconv"54 "strings"65 76 tea "charm.land/bubbletea/v2"8- "github.com/charmbracelet/x/ansi"97 108 "github.com/FelineStateMachine/012/internal/notebook"9+ "github.com/FelineStateMachine/012/internal/sheet"1110 "github.com/FelineStateMachine/012/internal/ui/nbview"1211 "github.com/FelineStateMachine/012/internal/ui/overlay"13- "github.com/FelineStateMachine/012/internal/ui/theme"1412 )1513 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.14+// A notebook tab on the screen, as Jupyter's: the menu bar with15+// NOTEBOOK or EDIT as the mode, the notebook's toolbar where a sheet's16+// formula bar is, the context line (what the selection is and the keys17+// that apply, or prompts and messages) drawn as a rule closing off the18+// toolbar, and the cells under it where the grid would be.2119 22-// nbBody is the screen row the notebook's cells start at, under its bar.23-const nbBody = gridTop20+// nbBody is the screen row the notebook's cells start at, under the21+// context line.22+const nbBody = contextLine + 12423 2524 // openNotebook shows the workbook's notebook, making one after the sheet2625 // shown if it has none.
@@ -104,38 +103,27 @@ func (m *Model) sizeNotebook(v *nbview.View) {
104103 // menu bar down to the line above the status line.105104 func (m *Model) notebookLines(v *nbview.View) []string {106105 m.sizeNotebook(v)107- lines := []string{m.menuBarLine(), m.formulaBar(), m.contextLineText(), m.notebookBar(v)}106+ lines := []string{m.menuBarLine(), m.formulaBar(), m.contextLineText()}108107 return append(lines, v.Lines()...)109108 }110109 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"}110+// notebookToolbar is the toolbar, where a sheet's formula bar is.111+func (m *Model) notebookToolbar(v *nbview.View) string {112+ return v.Toolbar(m.width)113+}114+115+// nbKernel is how running stands in notebook s, for its toolbar.116+func (m *Model) nbKernel(s *sheet.Sheet) nbview.Kernel {117+ k := nbview.Kernel{Off: m.shellOff(), Reactive: s.Reactive(), Clip: len(m.nb.clip)}118+ if r := m.nb.running; r != nil && r.s == s {119+ k.Busy = true118120 }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"121+ for _, q := range m.nb.queue {122+ if q.s == s {123+ k.Waiting++124124 }125- parts = append(parts, run)126125 }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(ansi.Truncate(text, max(m.width-nameBoxW-1, 1), "…"))126+ return k139127 }140128 141129 // pasteMsg takes pasted text: into a notebook's cell being edited, and
@@ -151,13 +139,17 @@ func (m *Model) pasteMsg(text string) tea.Cmd {
151139 // notebookContext is the context line on a notebook tab: what the last152140 // action said, or the notebook's own line.153141 func (m *Model) notebookContext(v *nbview.View) (string, string) {142+ left, right := v.ContextLine()154143 switch {155144 case m.warn != "":156- return m.th.Warning.Render(m.warn), ""145+ left, right = m.th.Warning.Render(m.warn), ""157146 case m.note != "":158- return m.th.Hint.Render(m.note), ""147+ left, right = m.th.Hint.Render(m.note), ""148+ }149+ if v.FullOpen() {150+ return left, right159151 }160- return v.ContextLine()152+ return v.Rule(left, right, m.width), ""161153 }162154 163155 // notebookIndicator is the mode on a notebook tab.
@@ -191,31 +183,62 @@ func (m *Model) notebookBoxes() []overlay.Box {
191183 return nil192184 }193185 194-// notebookMouse takes a click or the wheel over the cells.186+// notebookMouse takes a click on the toolbar, or a click or the wheel187+// over the cells.195188 func (m *Model) notebookMouse(msg tea.MouseMsg) (tea.Cmd, bool) {196189 v := m.nbView()197190 mouse := msg.Mouse()198- if v == nil || m.mode != modeReady || m.overlay != nil || mouse.Y < nbBody || mouse.Y >= m.height-1 {191+ if v == nil || m.mode != modeReady || m.overlay != nil {192+ return nil, false193+ }194+ if click, ok := msg.(tea.MouseClickMsg); ok && mouse.Y == formulaLine && click.Button == tea.MouseLeft {195+ return m.toolbarClick(v, mouse.X), true196+ }197+ if mouse.Y < nbBody || mouse.Y >= m.height-1 {199198 return nil, false200199 }200+ m.sizeNotebook(v)201+ y := mouse.Y - nbBody201202 switch msg := msg.(type) {202203 case tea.MouseClickMsg:203- if msg.Button == tea.MouseLeft {204- return v.Click(mouse.Y - nbBody), true204+ switch msg.Button {205+ case tea.MouseLeft:206+ return v.Click(mouse.X, y, mouse.Mod.Contains(tea.ModShift)), true207+ case tea.MouseRight:208+ v.RightClick(y)209+ m.showContextMenu(nbCellMenu, mouse.X, mouse.Y+1)205210 }206211 return nil, true207212 case tea.MouseWheelMsg:213+ m.nb.follow = false // the user looks elsewhere while cells run208214 switch msg.Button {209215 case tea.MouseWheelUp:210- v.Wheel(-3)216+ v.Wheel(y, -3)211217 case tea.MouseWheelDown:212- v.Wheel(3)218+ v.Wheel(y, 3)213219 }214220 return nil, true215221 }216222 return nil, true217223 }218224 225+// toolbarClick runs the toolbar's button at column x: the cell's kind226+// opens a menu of the kinds under it.227+func (m *Model) toolbarClick(v *nbview.View, x int) tea.Cmd {228+ switch id := v.ToolbarAt(x, m.width); id {229+ case "":230+ return nil231+ case nbview.Back:232+ v.CloseFull()233+ return nil234+ case "nb.kind":235+ m.showContextMenu([]menuItem{{cmd: "nb.to_code"}, {cmd: "nb.to_note"}}, x, formulaLine+1)236+ return nil237+ default:238+ return m.runCommand(id)239+ }240+}241+219242 // notebookMsg takes the notebooks' own messages.220243 func (m *Model) notebookMsg(msg tea.Msg) (tea.Cmd, bool) {221244 switch msg := msg.(type) {
M internal/ui/nbview/bench_test.go +7 -7
A internal/ui/nbview/draw.go +265 -0
@@ -0,0 +1,265 @@
1+package nbview2+3+import (4+ "strconv"5+ "strings"6+ "time"7+8+ "charm.land/lipgloss/v2"9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/notebook"12+ "github.com/FelineStateMachine/012/internal/ui/lineedit"13+)14+15+// Drawing a cell's block line by line (layout.go says where).16+17+// blockLine draws line k of cell i's block.18+func (v *View) blockLine(i int, c notebook.Cell, b block, k int) string {19+ kind, r := b.row(k)20+ if kind == rowGap {21+ return ""22+ }23+ lead := v.bar(i, kind) + v.prompt(c, kind, r) + " "24+ switch kind {25+ case rowTop:26+ return lead + v.boxTop(i, c)27+ case rowBottom:28+ return lead + v.boxEdge(i, "╰", "╯", "┗", "┛")29+ case rowOut:30+ return lead + " " + v.outLine(c, r)31+ }32+ if b.box == 0 {33+ return lead + " " + v.sourceLine(i, c, r)34+ }35+ side := v.boxStyle(i).Render(v.edges(i, "│", "┃"))36+ text := v.sourceLine(i, c, r)37+ text += strings.Repeat(" ", max(v.content()-ansi.StringWidth(text), 0))38+ line := lead + side + " " + text + " " + side39+ if r == 0 {40+ line += " " + v.runButton(i, c)41+ }42+ return line43+}44+45+// bar is the column left of a cell: ▌ by the active cell, blue in46+// command mode and green in edit mode, and ▎ by the other cells47+// selected with it. With the active cell's output selected, the bar is48+// by the output alone.49+func (v *View) bar(i int, kind rowKind) string {50+ from, to := v.Range()51+ if i < from || i > to || kind == rowGap || i == v.sel && v.onOut && kind != rowOut {52+ return " "53+ }54+ th := v.h.Theme()55+ style := th.CellBar56+ if v.edit.on {57+ style = th.CellBarEdit58+ }59+ if i != v.sel {60+ return style.Render("▎")61+ }62+ return style.Render("▌")63+}64+65+// prompt is the prompt column: [n]: by a code cell's first line, Out[n]:66+// by its output's, [*]: while it runs and [ ]: before it has.67+func (v *View) prompt(c notebook.Cell, kind rowKind, r int) string {68+ text := ""69+ if c.Kind == notebook.Code && r == 0 {70+ o := v.h.Output(c.ID)71+ switch {72+ case kind == rowSrc && v.h.State(c.ID).Running:73+ text = "[*]:"74+ case kind == rowSrc:75+ text = "[" + count(o, " ") + "]:"76+ case kind == rowOut && v.shown(c).kind != outError:77+ text = "Out[" + count(o, " ") + "]:"78+ if len(text) > promptW {79+ text = "[" + count(o, " ") + "]:"80+ }81+ }82+ }83+ return v.h.Theme().CellHead.Render(strings.Repeat(" ", max(promptW-len(text), 0)) + text)84+}85+86+// count is the output's run number, or blank for none or one read from87+// the file.88+func count(o *notebook.Output, blank string) string {89+ if o == nil || o.Count == 0 {90+ return blank91+ }92+ return strconv.Itoa(o.Count)93+}94+95+// edges picks a box's line: light, or heavy for the cell being edited,96+// which reads without color.97+func (v *View) edges(i int, light, heavy string) string {98+ if v.edit.on && i == v.sel {99+ return heavy100+ }101+ return light102+}103+104+// boxStyle is a box's lines: in the bar's color for the active cell.105+func (v *View) boxStyle(i int) lipgloss.Style {106+ th := v.h.Theme()107+ switch {108+ case v.edit.on && i == v.sel:109+ return th.CellBarEdit110+ case i == v.sel && !v.onOut:111+ return th.CellBar112+ }113+ return th.Border114+}115+116+// boxTop is a box's top border, with the cell's name at the left and117+// how its run stands at the right.118+func (v *View) boxTop(i int, c notebook.Cell) string {119+ th := v.h.Theme()120+ style := v.boxStyle(i)121+ h := v.edges(i, "─", "━")122+ w := v.content() + 2 // between the corners123+ title := c.Name()124+ if c.Kind == notebook.Note {125+ title = "Markdown"126+ }127+ state, stateStyle := v.state(c)128+ room := w - 2129+ if state != "" {130+ state = ansi.Truncate(state, max(room/2, 8), "…")131+ room -= ansi.StringWidth(state) + 3132+ }133+ var left, right string134+ used := 0135+ if title != "" && room > 4 {136+ title = ansi.Truncate(title, room-2, "…")137+ left = style.Render(h+" ") + th.CellHead.Render(title) + style.Render(" ")138+ used += ansi.StringWidth(title) + 3139+ }140+ if state != "" {141+ right = style.Render(" ") + stateStyle.Render(state) + style.Render(" "+h)142+ used += ansi.StringWidth(state) + 3143+ }144+ fill := style.Render(strings.Repeat(h, max(w-used, 0)))145+ return style.Render(v.edges(i, "╭", "┏")) + left + fill + right + style.Render(v.edges(i, "╮", "┓"))146+}147+148+// boxEdge is a box's bottom border.149+func (v *View) boxEdge(i int, left, right, heavyLeft, heavyRight string) string {150+ return v.boxStyle(i).Render(v.edges(i, left, heavyLeft) + strings.Repeat(v.edges(i, "─", "━"), v.content()+2) + v.edges(i, right, heavyRight))151+}152+153+// runButton is ▶, which runs the cell, or ■ while it runs, which stops154+// it.155+func (v *View) runButton(i int, c notebook.Cell) string {156+ th := v.h.Theme()157+ style := th.CellHead158+ if i == v.sel {159+ style = v.boxStyle(i)160+ }161+ if v.h.State(c.ID).Running {162+ return style.Render("■")163+ }164+ return style.Render("▶")165+}166+167+// state is what a cell's box says of its run, and in what style: empty168+// before it has run.169+func (v *View) state(c notebook.Cell) (string, lipgloss.Style) {170+ th := v.h.Theme()171+ if c.Kind != notebook.Code {172+ return "", th.CellHead173+ }174+ st := v.h.State(c.ID)175+ o := v.h.Output(c.ID)176+ switch {177+ case st.Problem != "":178+ return st.Problem, th.Warning179+ case st.Running:180+ return running(time.Since(st.Started)), th.CellHead181+ case st.Waiting:182+ return "waiting", th.CellHead183+ case o == nil:184+ return "", th.CellHead185+ case o.Failed():186+ return "failed" + took(o), th.Warning187+ case st.Stale:188+ return "stale" + took(o), th.Stale189+ case o.Count == 0:190+ return "saved", th.CellHead191+ }192+ return "✓" + took(o), th.CellHead193+}194+195+// outLine is line r of cell c's output: its window, or one line saying196+// it's folded.197+func (v *View) outLine(c notebook.Cell, r int) string {198+ th := v.h.Theme()199+ f := v.foldOf(c.ID)200+ if f.hidden {201+ return th.Muted.Render("⋯ output hidden (o shows it)")202+ }203+ return v.shown(c).line(th, v.h.Locale(), r, f, v.outWidth())204+}205+206+// sourceLine is row r of a cell's source: the editor's while it's207+// edited, a note's Markdown, or a pipeline highlighted.208+func (v *View) sourceLine(i int, c notebook.Cell, r int) string {209+ if v.edit.on && i == v.sel {210+ return v.edit.line(v.h.Theme(), r, v.content(), v.spansFor(v.edit.text(), c.Kind, true))211+ }212+ if c.Kind == notebook.Note {213+ lines := v.noteLines(c)214+ if r < len(lines) {215+ return lines[r]216+ }217+ return ""218+ }219+ buf := []rune(c.Source)220+ rows := lineedit.Wrap(buf, v.content())221+ if r >= len(rows) {222+ return ""223+ }224+ return drawRow(v.h.Theme(), buf, rows[r], v.spansFor(c.Source, c.Kind, false), nil)225+}226+227+// spinner turns while a cell runs.228+var spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}229+230+// running is what a running cell's box says: after a few seconds, a231+// spinner and how long it has run.232+func running(d time.Duration) string {233+ if d < 3*time.Second {234+ return "running"235+ }236+ return spinner[int(d/(100*time.Millisecond))%len(spinner)] + " running " + duration(d)237+}238+239+func took(o *notebook.Output) string {240+ if o.Took <= 0 {241+ return ""242+ }243+ return " " + duration(o.Took)244+}245+246+// duration is a run's length: under a second as <1s, so a fast cell247+// always reads the same, then seconds, then minutes.248+func duration(d time.Duration) string {249+ switch {250+ case d < time.Second:251+ return "<1s"252+ case d < 10*time.Second:253+ return strconv.FormatFloat(d.Seconds(), 'f', 1, 64) + "s"254+ case d < time.Minute:255+ return strconv.Itoa(int(d.Seconds())) + "s"256+ }257+ return strconv.Itoa(int(d.Minutes())) + "m" + pad2(int(d.Seconds())%60) + "s"258+}259+260+func pad2(n int) string {261+ if n < 10 {262+ return "0" + strconv.Itoa(n)263+ }264+ return strconv.Itoa(n)265+}
M internal/ui/nbview/full.go +1 -1
A internal/ui/nbview/jupyter_test.go +175 -0
@@ -0,0 +1,175 @@
1+package nbview2+3+import (4+ "fmt"5+ "slices"6+ "strings"7+ "testing"8+9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/notebook"12+)13+14+// at is the body line holding text and the column it starts at.15+func at(t *testing.T, v *View, want string) (x, y int) {16+ t.Helper()17+ for y, l := range v.Lines() {18+ if i := strings.Index(ansi.Strip(l), want); i >= 0 {19+ return ansi.StringWidth(ansi.Strip(l)[:i]), y20+ }21+ }22+ t.Fatalf("no line holds %q:\n%s", want, text(v))23+ return 0, 024+}25+26+27+func TestToolbarFitsAndClicks(t *testing.T) {28+ h := newHost("ls")29+ v := newView(h, 120, 20)30+ wide := ansi.Strip(v.Toolbar(120))31+ for _, want := range []string{"▶ Run", "Shift+Enter", "■ Stop", "▶▶ Run all", "+ Add", "✂ Cut", "Code ▾", "nu ○ idle"} {32+ if !strings.Contains(wide, want) {33+ t.Errorf("at 120 the toolbar lacks %q: %q", want, wide)34+ }35+ }36+ narrow := ansi.Strip(v.Toolbar(60))37+ if ansi.StringWidth(narrow) > 60 || !strings.Contains(narrow, "▶ Run") || !strings.Contains(narrow, "nu ○ idle") || strings.Contains(narrow, "Shift+Enter") {38+ t.Errorf("at 60: %q", narrow)39+ }40+ x := strings.Index(wide, "▶ Run")41+ if got := v.ToolbarAt(x+2, 120); got != "nb.run_next" {42+ t.Errorf("Run: %q", got)43+ }44+ stop := ansi.StringWidth(wide[:strings.Index(wide, "■ Stop")])45+ if got := v.ToolbarAt(stop, 120); got != "" {46+ t.Errorf("Stop with nothing running: %q", got)47+ }48+ h.kernel = Kernel{Busy: true, Waiting: 2}49+ if got := v.ToolbarAt(stop, 120); got != "nb.stop" || !strings.Contains(ansi.Strip(v.Toolbar(120)), "nu ● busy, 2 waiting") {50+ t.Errorf("Stop while busy: %q", got)51+ }52+ h.cells[0].Kind = notebook.Note53+ if !strings.Contains(ansi.Strip(v.Toolbar(120)), "Markdown ▾") {54+ t.Error("the kind isn't the cell's")55+ }56+}57+58+func TestClicksSelectEditRunAndFold(t *testing.T) {59+ h := newHost("files = ls", "$files | first", "# Title")60+ h.cells[2].Kind = notebook.Note61+ h.outs[1] = &notebook.Output{NUON: []byte("[[name]; [a], [b]]"), Count: 1}62+ v := newView(h, 80, 40)63+ x, y := at(t, v, "first")64+ v.Click(x+2, y, false) // into the second cell's code65+ if i, _ := v.Selected(); i != 1 || !v.Editing() {66+ t.Fatalf("a click in the box: cell %d, editing %v", i, v.Editing())67+ }68+ if got := v.edit.area.Pos; got != len([]rune("$files | fi")) {69+ t.Errorf("the caret went to %d", got)70+ }71+ _, y = at(t, v, "files = ls")72+ v.Click(v.width-1, y, false) // ▶73+ if v.Editing() || !slices.Contains(h.ran, "nb.run") {74+ t.Errorf("▶: editing %v, ran %v", v.Editing(), h.ran)75+ }76+ _, y = at(t, v, "Out[1]:")77+ v.Click(2, y, false) // left of the output78+ if !v.Hidden(1) || !strings.Contains(text(v), "output hidden") {79+ t.Errorf("folding:\n%s", text(v))80+ }81+ v.Click(2, y, false)82+ if v.Hidden(1) {83+ t.Error("unfolding")84+ }85+ _, y = at(t, v, "Title")86+ v.Click(20, y, true)87+ if from, to := v.Range(); from != 0 || to != 2 {88+ t.Errorf("Shift+click: %d to %d", from, to)89+ }90+ v.lastTap = tap{} // a while later91+ v.Click(20, y, false)92+ v.Click(20, y, false)93+ if i, _ := v.Selected(); i != 2 || !v.Editing() || !strings.Contains(text(v), "# Title") {94+ t.Errorf("a double click on a note edits its Markdown:\n%s", text(v))95+ }96+}97+98+func TestOutputScrollsInItsWindow(t *testing.T) {99+ h := newHost("ls", "after")100+ var b strings.Builder101+ b.WriteString("[[n]; ")102+ for i := range 45 {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, 80, 40)108+ _, afterY := at(t, v, "after")109+ if !strings.Contains(text(v), "36 more rows") {110+ t.Fatalf("the window:\n%s", text(v))111+ }112+ v.Key(key("down")) // onto the output113+ v.Key(key("down")) // scrolls it114+ v.Key(key("down"))115+ if got := text(v); !strings.Contains(got, "rows 3 to 12 of 46") || !strings.Contains(got, " 11\n") && !strings.Contains(got, " 11 ") {116+ t.Errorf("scrolled by keys:\n%s", got)117+ }118+ if _, y := at(t, v, "after"); y != afterY {119+ t.Errorf("the cell under moved from %d to %d", afterY, y)120+ }121+ _, y := at(t, v, "rows 3 to 12")122+ v.Wheel(y-2, 3)123+ if !strings.Contains(text(v), "rows 6 to 15 of 46") {124+ t.Errorf("scrolled by the wheel:\n%s", text(v))125+ }126+ v.Key(key("O"))127+ v.ToggleWhole()128+ if strings.Contains(text(v), "more rows") || strings.Contains(text(v), "rows 6") {129+ t.Errorf("whole:\n%s", text(v))130+ }131+ v.ToggleHidden()132+ if !strings.Contains(text(v), "output hidden") {133+ t.Errorf("hidden:\n%s", text(v))134+ }135+}136+137+func TestSelectsSeveralCells(t *testing.T) {138+ h := newHost("a", "b", "c", "d")139+ v := newView(h, 80, 40)140+ v.Key(key("down"))141+ v.Key(key("J"))142+ v.Key(key("J"))143+ if from, to := v.Range(); from != 1 || to != 3 {144+ t.Errorf("J J: %d to %d", from, to)145+ }146+ v.Key(key("K"))147+ if from, to := v.Range(); from != 1 || to != 2 {148+ t.Errorf("K: %d to %d", from, to)149+ }150+ v.Key(key("up"))151+ if from, to := v.Range(); from != 1 || to != 1 {152+ t.Errorf("up selects one: %d to %d", from, to)153+ }154+}155+156+func TestOutline(t *testing.T) {157+ h := newHost("# Intro\ntext\n## Part", "files = ls", "no heading")158+ h.cells[0].Kind = notebook.Note159+ h.cells[2].Kind = notebook.Note160+ v := newView(h, 80, 40)161+ var got []string162+ for _, e := range v.Outline(false) {163+ got = append(got, fmt.Sprintf("%d %d %s", e.Cell, e.Level, e.Title))164+ }165+ if !slices.Equal(got, []string{"0 1 Intro", "0 2 Part"}) {166+ t.Errorf("contents %q", got)167+ }168+ got = nil169+ for _, e := range v.Outline(true) {170+ got = append(got, fmt.Sprintf("%d %d %s", e.Cell, e.Level, e.Title))171+ }172+ if !slices.Equal(got, []string{"0 1 Intro", "0 2 Part", "1 0 files = ls", "2 0 no heading"}) {173+ t.Errorf("cells %q", got)174+ }175+}
M internal/ui/nbview/keys.go +62 -77
@@ -3,7 +3,6 @@ package nbview
33 import (44 "strconv"55 "strings"6- "time"76 87 tea "charm.land/bubbletea/v2"98
@@ -66,14 +65,24 @@ func (v *View) commandKey(k tea.KeyPressMsg) (tea.Cmd, bool) {
6665 return nil, false6766 }6867 69-// move moves the selection for a movement key.68+// move moves the selection for a movement key: Up and Down (j and k)69+// scroll a selected output's window before they leave it, and with70+// Shift (or J and K) select the cells passed over, as JupyterLab does.7071 func (v *View) move(key string) bool {7172 n := len(v.h.Cells())7273 switch key {7374 case "up", "k":74- v.step(-1)75+ if !v.scrollOut(-1) {76+ v.step(-1)77+ }7578 case "down", "j":76- v.step(1)79+ if !v.scrollOut(1) {80+ v.step(1)81+ }82+ case "shift+up", "K":83+ v.extend(-1)84+ case "shift+down", "J":85+ v.extend(1)7786 case "home", "ctrl+home":7887 v.Select(0, false)7988 case "end", "ctrl+end":
@@ -91,13 +100,14 @@ func (v *View) move(key string) bool {
91100 }92101 93102 // step moves the selection one place: from a cell to its output, if it94-// shows one, and from an output to the next cell.103+// shows one, and from an output to the next cell. It selects one cell.95104 func (v *View) step(d int) {96105 cells := v.h.Cells()97106 if len(cells) == 0 {98107 return99108 }100- hasOut := func(i int) bool { return v.shown(cells[i]).kind != outNone }109+ v.anchor = -1110+ hasOut := func(i int) bool { return v.outHeight(cells[i]) > 0 }101111 switch {102112 case d > 0 && !v.onOut && hasOut(v.sel):103113 v.onOut = true
@@ -112,16 +122,55 @@ func (v *View) step(d int) {
112122 v.follow()113123 }114124 115-// selectAtTop selects the first cell whose head shows after a page125+// extend moves the active cell one cell, keeping the cells passed over126+// selected with it.127+func (v *View) extend(d int) {128+ n := len(v.h.Cells())129+ if n == 0 || v.sel+d < 0 || v.sel+d >= n {130+ return131+ }132+ if v.anchor < 0 {133+ v.anchor = v.sel134+ }135+ v.sel += d136+ v.onOut = false137+ v.clamp()138+ v.follow()139+}140+141+// scrollOut scrolls the selected output's window d rows, reporting142+// whether it could.143+func (v *View) scrollOut(d int) bool {144+ c, ok := v.Cell()145+ if !ok || !v.onOut {146+ return false147+ }148+ return v.scrollBy(c, d)149+}150+151+// scrollBy scrolls cell c's output window d rows, as far as it goes,152+// reporting whether it moved.153+func (v *View) scrollBy(c notebook.Cell, d int) bool {154+ f := v.foldOf(c.ID)155+ if f.hidden || f.whole {156+ return false157+ }158+ limit := v.shown(c).maxScroll(false, v.outWidth())159+ to := min(max(f.scroll+d, 0), limit)160+ if to == f.scroll {161+ return false162+ }163+ v.foldFor(c.ID).scroll = to164+ return true165+}166+167+// selectAtTop selects the first cell whose top shows after a page116168 // moved the view.117169 func (v *View) selectAtTop() {118170 cells := v.h.Cells()119- total := 0120- for i, c := range cells {121- total += v.blockOf(i, c).lines()122- }123- v.top = max(min(v.top, total-v.height), 0)171+ v.top = max(min(v.top, v.total()-v.height), 0)124172 at := 0173+ v.anchor = -1125174 for i, c := range cells {126175 if at >= v.top {127176 v.sel, v.onOut = i, false
@@ -185,7 +234,7 @@ func (v *View) StartEdit() tea.Cmd {
185234 if !ok {186235 return nil187236 }188- v.full, v.onOut = nil, false237+ v.full, v.onOut, v.anchor = nil, false, -1189238 v.edit.cancel()190239 v.edit = editor{on: true, id: c.ID}191240 v.edit.area.SetText(c.Source)
@@ -248,70 +297,6 @@ func (v *View) CloseFull() { v.full = nil }
248297 // Full is the output shown full-screen, or nil.249298 func (v *View) Full() *Full { return v.full }250299 251-// Cursor is where the terminal's caret goes in the body, while a cell252-// is edited.253-func (v *View) Cursor() (x, y int, ok bool) {254- if !v.edit.on {255- return 0, 0, false256- }257- cells := v.h.Cells()258- at := 0259- for i := range v.sel {260- at += v.blockOf(i, cells[i]).lines()261- }262- row, col := v.edit.caret(v.content())263- y = at + 1 + row - v.top264- if y < 0 || y >= v.height {265- return 0, 0, false266- }267- return gutter + col, y, true268-}269-270-// tap is a click, for telling a double click.271-type tap struct {272- at time.Time273- cell int274-}275-276-// Click selects what's under line y of the body, its output or the277-// cell; clicking the cell again quickly edits it.278-func (v *View) Click(y int) tea.Cmd {279- if v.full != nil || v.edit.on {280- return nil281- }282- cells := v.h.Cells()283- at := 0284- for i, c := range cells {285- b := v.blockOf(i, c)286- if line := v.top + y - at; line >= 0 && line < b.lines() {287- double := v.lastTap.cell == i && time.Since(v.lastTap.at) < 400*time.Millisecond288- v.lastTap = tap{at: time.Now(), cell: i}289- v.Select(i, line > b.head+b.src-1 && line < b.lines()-1 && b.out > 0)290- if double && !v.onOut {291- return v.h.Run("nb.edit")292- }293- return nil294- }295- at += b.lines()296- }297- return nil298-}299-300-// Wheel scrolls the body d lines.301-func (v *View) Wheel(d int) {302- if v.full != nil {303- v.full.row += d304- v.full.settle()305- return306- }307- cells := v.h.Cells()308- total := 0309- for i, c := range cells {310- total += v.blockOf(i, c).lines()311- }312- v.top = max(min(v.top+d, total-v.height), 0)313-}314-315300 // Head is what the formula bar says of the selection: the cell's name316301 // or number, and what it reads, or what its output holds.317302 func (v *View) Head() (name, text string) {
A internal/ui/nbview/layout.go +112 -0
@@ -0,0 +1,112 @@
1+package nbview2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/notebook"7+ "github.com/FelineStateMachine/012/internal/ui/lineedit"8+)9+10+// Where things go across the body, as in Jupyter: a bar marking the11+// active cell, the prompts ([1]: and Out[1]:) right-aligned in a column12+// of their own, then a code cell's box with its text two columns in,13+// and a ▶ after the box. Outputs and notes' text line up with the code.14+//15+// ▌ [1]: ╭─ files ──────────────── ✓ <1s ─╮16+// │ ls │ ▶17+// ╰─────────────────────────────────╯18+// Out[1]: name type size19+const (20+ promptW = 8 // [12]: and Out[12]:, right-aligned21+ gutter = 1 + promptW + 1 // the bar, the prompt, a space: where a box starts22+ textX = gutter + 2 // where code, notes and outputs start23+ runW = 2 // " ▶" after a box24+ boxW = 4 // a box's sides and the spaces inside them25+)26+27+// content is how wide a cell's code is drawn: inside its box.28+func (v *View) content() int { return max(v.width-gutter-boxW-runW, 8) }29+30+// outWidth is how wide outputs and notes are drawn.31+func (v *View) outWidth() int { return max(v.width-textX-1, 8) }32+33+// block is what a cell takes on the screen, in lines: a box's borders34+// (box is 1 for a code cell, or a note being edited), its source, its35+// output, and a blank line after.36+type block struct {37+ box, src, out int38+}39+40+func (b block) lines() int { return 2*b.box + b.src + b.out + 1 }41+42+// rowKind is what a line of a block is.43+type rowKind int44+45+const (46+ rowTop rowKind = iota // the box's top border47+ rowSrc // a line of source, or of a note48+ rowBottom // the box's bottom border49+ rowOut // a line of output50+ rowGap // the blank line after the cell51+)52+53+// row is what line k of the block is, and which of its kind.54+func (b block) row(k int) (rowKind, int) {55+ switch {56+ case k < b.box:57+ return rowTop, 058+ case k < b.box+b.src:59+ return rowSrc, k - b.box60+ case k < 2*b.box+b.src:61+ return rowBottom, 062+ case k < 2*b.box+b.src+b.out:63+ return rowOut, k - 2*b.box - b.src64+ }65+ return rowGap, 066+}67+68+// boxed reports whether cell i is drawn in a box: code, or a note being69+// edited.70+func (v *View) boxed(i int, c notebook.Cell) bool {71+ return c.Kind == notebook.Code || v.edit.on && i == v.sel72+}73+74+// blockOf lays out cell i.75+func (v *View) blockOf(i int, c notebook.Cell) block {76+ var b block77+ if v.boxed(i, c) {78+ b.box = 179+ }80+ switch {81+ case v.edit.on && i == v.sel:82+ b.src = len(v.edit.rows(v.content()))83+ case c.Kind == notebook.Note:84+ b.src = len(v.noteLines(c))85+ default:86+ b.src = len(lineedit.Wrap([]rune(c.Source), v.content()))87+ }88+ b.out = v.outHeight(c)89+ return b90+}91+92+// outHeight is how many lines cell c's output takes: none, one while93+// it's folded, or its window.94+func (v *View) outHeight(c notebook.Cell) int {95+ sh := v.shown(c)96+ switch f := v.foldOf(c.ID); {97+ case sh.kind == outNone:98+ return 099+ case f.hidden:100+ return 1101+ default:102+ return sh.height(f.whole, v.outWidth())103+ }104+}105+106+// noteLines are a note cell's lines as drawn.107+func (v *View) noteLines(c notebook.Cell) []string {108+ if strings.TrimSpace(c.Source) == "" {109+ return []string{v.h.Theme().Muted.Render("An empty note: Enter writes it, in Markdown")}110+ }111+ return markdown(v.h.Theme(), c.Source, v.outWidth())112+}
A internal/ui/nbview/mouse.go +160 -0
@@ -0,0 +1,160 @@
1+package nbview2+3+import (4+ "time"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/notebook"9+)10+11+// The mouse, as in JupyterLab: a click selects a cell, a click in its12+// box edits it with the caret where it landed, ▶ runs it (■ stops it),13+// Shift+click selects the cells between, a click on an output selects14+// it and a double click opens it full-screen, a click left of an output15+// folds it, and the wheel over an output's window scrolls it.16+17+// hit is what's at a line of the body.18+type hit struct {19+ cell int20+ kind rowKind21+ r int // which line of its kind22+}23+24+// hitAt is what's at line y of the body, if a cell is there.25+func (v *View) hitAt(y int) (hit, bool) {26+ line := v.top + y27+ at := 028+ for i, c := range v.h.Cells() {29+ b := v.blockOf(i, c)30+ if line >= at && line < at+b.lines() {31+ kind, r := b.row(line - at)32+ return hit{cell: i, kind: kind, r: r}, true33+ }34+ at += b.lines()35+ }36+ return hit{}, false37+}38+39+// inBox reports whether column x is inside a box's sides.40+func (v *View) inBox(x int) bool { return x > gutter && x < gutter+v.content()+boxW-1 }41+42+// onRun reports whether column x is a box's ▶.43+func (v *View) onRun(x int) bool {44+ at := gutter + v.content() + boxW + 145+ return x >= at-1 && x <= at46+}47+48+// tap is a click, for telling a double click.49+type tap struct {50+ at time.Time51+ cell int52+ kind rowKind53+}54+55+// Click takes a left click at column x of line y of the body; shift56+// extends the selection to the cell clicked.57+func (v *View) Click(x, y int, shift bool) tea.Cmd {58+ if v.full != nil {59+ return nil60+ }61+ h, ok := v.hitAt(y)62+ if !ok {63+ return nil64+ }65+ cells := v.h.Cells()66+ c := cells[h.cell]67+ boxed := v.boxed(h.cell, c)68+ if v.edit.on && h.cell == v.sel && h.kind == rowSrc && boxed && v.inBox(x) {69+ v.edit.area.Place(v.content(), h.r, x-textX)70+ v.follow()71+ return nil72+ }73+ double := !shift && v.lastTap.cell == h.cell && v.lastTap.kind == h.kind && time.Since(v.lastTap.at) < 400*time.Millisecond74+ v.lastTap = tap{at: time.Now(), cell: h.cell, kind: h.kind}75+ v.StopEdit()76+ if shift {77+ from := v.sel78+ if v.anchor >= 0 {79+ from = v.anchor80+ }81+ v.SelectRange(from, h.cell)82+ return nil83+ }84+ return v.clickCell(h, c, x, boxed, double)85+}86+87+// clickCell is a plain click on cell c.88+func (v *View) clickCell(h hit, c notebook.Cell, x int, boxed, double bool) tea.Cmd {89+ switch {90+ case h.kind == rowSrc && h.r == 0 && boxed && v.onRun(x):91+ v.Select(h.cell, false)92+ if v.h.State(c.ID).Running {93+ return v.h.Run("nb.stop")94+ }95+ return v.h.Run("nb.run")96+ case h.kind == rowOut && x < gutter:97+ v.Select(h.cell, true)98+ v.ToggleHidden()99+ case h.kind == rowOut:100+ v.Select(h.cell, true)101+ if double {102+ v.OpenFull()103+ }104+ case h.kind == rowSrc && boxed && v.inBox(x):105+ v.Select(h.cell, false)106+ cmd := v.StartEdit()107+ v.edit.area.Place(v.content(), h.r, x-textX)108+ v.follow()109+ return cmd110+ default:111+ v.Select(h.cell, false)112+ if double && c.Kind == notebook.Note {113+ return v.StartEdit()114+ }115+ }116+ return nil117+}118+119+// RightClick selects the cell at line y for its context menu, unless120+// it's one of the cells selected.121+func (v *View) RightClick(y int) {122+ h, ok := v.hitAt(y)123+ if !ok {124+ return125+ }126+ if from, to := v.Range(); h.cell < from || h.cell > to {127+ v.StopEdit()128+ v.Select(h.cell, false)129+ }130+}131+132+// Wheel scrolls d lines with the mouse at line y: the output's window133+// under it while it has more that way, or the body.134+func (v *View) Wheel(y, d int) {135+ if v.full != nil {136+ v.full.row += d137+ v.full.settle()138+ return139+ }140+ if h, ok := v.hitAt(y); ok && y >= 0 && h.kind == rowOut {141+ if v.scrollBy(v.h.Cells()[h.cell], d) {142+ return143+ }144+ }145+ v.top = max(min(v.top+d, v.total()-v.height), 0)146+}147+148+// Cursor is where the terminal's caret goes in the body, while a cell149+// is edited.150+func (v *View) Cursor() (x, y int, ok bool) {151+ if !v.edit.on {152+ return 0, 0, false153+ }154+ row, col := v.edit.caret(v.content())155+ y = v.startOf(v.sel) + 1 + row - v.top156+ if y < 0 || y >= v.height {157+ return 0, 0, false158+ }159+ return textX + col, y, true160+}
A internal/ui/nbview/outline.go +67 -0
@@ -0,0 +1,67 @@
1+package nbview2+3+import (4+ "strings"5+6+ "github.com/charmbracelet/x/ansi"7+8+ "github.com/FelineStateMachine/012/internal/notebook"9+)10+11+// Entry is a place in the notebook to jump to: a heading of a note, or12+// a cell.13+type Entry struct {14+ Cell int // the cell's index15+ Level int // a heading's level, 1 for #; 0 for a cell16+ Title string // the heading's text, or the cell's first line17+}18+19+// Outline is the notebook's headings in order, from its notes'20+// Markdown: its table of contents. With cells, every cell is there too,21+// a code cell by its first line and a note by its first heading or line.22+func (v *View) Outline(cells bool) []Entry {23+ var out []Entry24+ for i, c := range v.h.Cells() {25+ heads := headings(c)26+ for _, h := range heads {27+ out = append(out, Entry{Cell: i, Level: h.Level, Title: h.Title})28+ }29+ if cells && (c.Kind == notebook.Code || len(heads) == 0) {30+ out = append(out, Entry{Cell: i, Title: firstLine(c.Source)})31+ }32+ }33+ return out34+}35+36+// headings are a note's Markdown headings, outside code fences.37+func headings(c notebook.Cell) []Entry {38+ if c.Kind != notebook.Note {39+ return nil40+ }41+ var out []Entry42+ fenced := false43+ for line := range strings.SplitSeq(c.Source, "\n") {44+ t := strings.TrimSpace(line)45+ if strings.HasPrefix(t, "```") {46+ fenced = !fenced47+ }48+ level := len(t) - len(strings.TrimLeft(t, "#"))49+ if fenced || level == 0 || level > 6 || len(t) > level && t[level] != ' ' {50+ continue51+ }52+ if title := strings.TrimSpace(t[level:]); title != "" {53+ out = append(out, Entry{Level: level, Title: title})54+ }55+ }56+ return out57+}58+59+// firstLine is a source's first line that isn't blank, shortened.60+func firstLine(src string) string {61+ for line := range strings.SplitSeq(src, "\n") {62+ if t := strings.TrimSpace(line); t != "" {63+ return ansi.Truncate(t, 56, "…")64+ }65+ }66+ return "(empty)"67+}
M internal/ui/nbview/output.go +12 -130
@@ -18,12 +18,12 @@ import (
1818 // Outputs as they show under a code cell. What a cell printed, NUON, is1919 // parsed once into a shown output: a table (a list of records), a2020 // 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.21+// importing them would make, formatted as cells are. A long output22+// scrolls in a window of its own (outwin.go) until O shows it whole; its23+// lines are drawn only when they're on screen, so an output of ten24+// thousand rows costs the rows showing.2525 26-// window is how many rows (or lines) an output shows collapsed.26+// window is how many rows (or lines) an output's window shows.2727 const window = 102828 2929 // maxColWidth caps how wide fitting makes a column, as a sheet's.
@@ -183,42 +183,6 @@ func cellText(lc sheet.LiveCell, loc *locale.Locale) string {
183183 return strings.ReplaceAll(sheet.FormatTextIn(lc.V, lc.F, loc), "\n", " ")184184 }185185 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-222186 // shownCols is how many of a table's columns fit in width.223187 func (sh *shown) shownCols(width int) int {224188 x := 0
@@ -236,85 +200,21 @@ func (sh *shown) shownCols(width int) int {
236200 237201 func (sh *shown) hiddenCols(width int) int { return len(sh.fit) - sh.shownCols(width) }238202 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-261203 // more counts what's left out: "9,991 more rows".262204 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- }267205 if n != 1 {268206 what += "s"269207 }270- return s + " " + what208+ return grouped(n) + " " + what271209 }272210 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- name := ansi.Truncate(sh.cols[c], sh.fit[c], "…")288- b.WriteString(th.OutputHead.Render(name) + strings.Repeat(" ", sh.fit[c]-ansi.StringWidth(name)))289- }290- return b.String()291- case i <= rows:292- row := sh.rows[i-1]293- var b strings.Builder294- for c := range ncols {295- if c > 0 {296- b.WriteString(" ")297- }298- var lc sheet.LiveCell299- if c < len(row) {300- lc = row[c]301- }302- b.WriteString(pad(ansi.Truncate(cellText(lc, loc), sh.fit[c], "…"), sh.fit[c], align(lc)))303- }304- return b.String()305- }306- var parts []string307- if n := sh.total - rows; n > 0 {308- parts = append(parts, more(n, "more row"))309- }310- if n := sh.hiddenCols(width); n > 0 {311- parts = append(parts, more(n, "more column"))312- }313- hint := "Enter opens it"314- if !expanded && sh.total > window {315- hint = "o shows all, " + hint211+// grouped is n with its thousands apart: 9,991.212+func grouped(n int) string {213+ s := strconv.Itoa(n)214+ for i := len(s) - 3; i > 0; i -= 3 {215+ s = s[:i] + "," + s[i:]316216 }317- return th.Muted.Render("… " + strings.Join(parts, ", ") + " (" + hint + ")")217+ return s318218 }319219 320220 // align is how a value lines up in its column, as in a cell.
@@ -365,24 +265,6 @@ func (sh *shown) fieldLine(th *theme.Theme, loc *locale.Locale, i, width int) st
365265 return key + ansi.Truncate(cellText(sh.rows[i][0], loc), room, "…")366266 }367267 368-// textLine is line i of text or an error, wrapped at width.369-func (sh *shown) textLine(th *theme.Theme, i int, expanded bool, width int) string {370- lines := sh.wrapped(width)371- if !expanded && len(lines) > window && i == window {372- return th.Muted.Render("… " + more(len(lines)-window, "more line") + " (o shows all)")373- }374- if i >= len(lines) {375- return ""376- }377- if sh.kind == outError {378- if i == 0 {379- return th.Warning.Render(lines[i])380- }381- return th.Muted.Render(lines[i])382- }383- return lines[i]384-}385-386268 // wrapped is the text's lines wrapped at width, kept for the width last387269 // asked.388270 func (sh *shown) wrapped(width int) []string {
A internal/ui/nbview/outwin.go +190 -0
@@ -0,0 +1,190 @@
1+package nbview2+3+import (4+ "strings"5+6+ "github.com/charmbracelet/x/ansi"7+8+ "github.com/FelineStateMachine/012/internal/locale"9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/theme"11+)12+13+// An output's window: a table's header, then at most window of its rows14+// (a list's items, a record's fields, text's lines) from the row it's15+// scrolled to, then a line saying what's left out. Whole shows every row16+// instead, and hidden folds the output to one line, as Jupyter's17+// collapsed outputs. The window's height doesn't change as it scrolls,18+// so nothing under it moves.19+20+// fold is how a cell's output shows.21+type fold struct {22+ hidden bool // o: folded to one line23+ whole bool // O: every row, no window24+ scroll int // the window's first row25+}26+27+// head is how many lines stay above the rows: a table's header.28+func (sh *shown) head() int {29+ if sh.kind == outTable {30+ return 131+ }32+ return 033+}34+35+// items is how many rows the output scrolls through at width.36+func (sh *shown) items(width int) int {37+ switch sh.kind {38+ case outTable, outRecord, outList:39+ return sh.total40+ case outText, outError:41+ return len(sh.wrapped(width))42+ case outNone:43+ return 044+ }45+ return 146+}47+48+// rowsShown is how many rows show, and whether a line saying what's left49+// out follows them.50+func (sh *shown) rowsShown(whole bool, width int) (n int, footer bool) {51+ n = sh.items(width)52+ if !whole && n > window {53+ n, footer = window, true54+ }55+ if sh.kind == outTable && sh.hiddenCols(width) > 0 {56+ footer = true57+ }58+ return n, footer59+}60+61+// height is how many lines the output takes at width.62+func (sh *shown) height(whole bool, width int) int {63+ if sh.kind == outNone {64+ return 065+ }66+ n, footer := sh.rowsShown(whole, width)67+ if footer {68+ n++69+ }70+ return sh.head() + n71+}72+73+// maxScroll is how far the window scrolls: 0 when it shows every row.74+func (sh *shown) maxScroll(whole bool, width int) int {75+ n, _ := sh.rowsShown(whole, width)76+ return max(sh.items(width)-n, 0)77+}78+79+// line draws line i of the output as f shows it, at width.80+func (sh *shown) line(th *theme.Theme, loc *locale.Locale, i int, f fold, width int) string {81+ if i < sh.head() {82+ return sh.tableHead(th, width)83+ }84+ i -= sh.head()85+ n, _ := sh.rowsShown(f.whole, width)86+ scroll := min(max(f.scroll, 0), sh.maxScroll(f.whole, width))87+ if i < n {88+ return sh.item(th, loc, scroll+i, width)89+ }90+ return sh.footer(th, scroll, n, width)91+}92+93+// item draws row i: a table's row, a field or an item, a line of text,94+// or the one value.95+func (sh *shown) item(th *theme.Theme, loc *locale.Locale, i, width int) string {96+ switch sh.kind {97+ case outTable:98+ return sh.tableRow(loc, i, width)99+ case outRecord, outList:100+ return sh.fieldLine(th, loc, i, width)101+ case outText, outError:102+ return sh.textLine(th, i, width)103+ case outValue:104+ return ansi.Truncate(cellText(sh.rows[0][0], loc), width, "…")105+ case outUnsaved:106+ return th.Muted.Render("not saved; run to see")107+ case outBad:108+ return th.Warning.Render(ansi.Truncate(sh.text[0], width, "…"))109+ }110+ return ""111+}112+113+// footer says what the window leaves out: the rows under it (or where114+// it's scrolled to) and a table's columns past the width.115+func (sh *shown) footer(th *theme.Theme, scroll, n, width int) string {116+ unit := map[outKind]string{outTable: "row", outRecord: "field", outList: "item"}[sh.kind]117+ if unit == "" {118+ unit = "line"119+ }120+ total := sh.items(width)121+ var parts []string122+ switch {123+ case n >= total:124+ case scroll == 0:125+ parts = append(parts, more(total-n, "more "+unit))126+ default:127+ parts = append(parts, unit+"s "+grouped(scroll+1)+" to "+grouped(scroll+n)+" of "+grouped(total))128+ }129+ if sh.kind == outTable {130+ if c := sh.hiddenCols(width); c > 0 {131+ parts = append(parts, more(c, "more column"))132+ }133+ }134+ hint := "O shows all"135+ if n >= total {136+ hint = ""137+ }138+ if sh.kind == outTable {139+ hint = strings.TrimPrefix(hint+", Enter opens it", ", ")140+ }141+ return ansi.Truncate(th.Muted.Render("… "+strings.Join(parts, ", ")+" ("+hint+")"), width, "…")142+}143+144+// tableHead is a table's header row.145+func (sh *shown) tableHead(th *theme.Theme, width int) string {146+ var b strings.Builder147+ for c := range sh.shownCols(width) {148+ if c > 0 {149+ b.WriteString(" ")150+ }151+ name := ansi.Truncate(sh.cols[c], sh.fit[c], "…")152+ b.WriteString(th.OutputHead.Render(name) + strings.Repeat(" ", sh.fit[c]-ansi.StringWidth(name)))153+ }154+ return b.String()155+}156+157+// tableRow is a table's row i.158+func (sh *shown) tableRow(loc *locale.Locale, i, width int) string {159+ if i >= len(sh.rows) {160+ return ""161+ }162+ row := sh.rows[i]163+ var b strings.Builder164+ for c := range sh.shownCols(width) {165+ if c > 0 {166+ b.WriteString(" ")167+ }168+ var lc sheet.LiveCell169+ if c < len(row) {170+ lc = row[c]171+ }172+ b.WriteString(pad(ansi.Truncate(cellText(lc, loc), sh.fit[c], "…"), sh.fit[c], align(lc)))173+ }174+ return b.String()175+}176+177+// textLine is line i of text or an error, wrapped at width.178+func (sh *shown) textLine(th *theme.Theme, i, width int) string {179+ lines := sh.wrapped(width)180+ if i >= len(lines) {181+ return ""182+ }183+ if sh.kind == outError {184+ if i == 0 {185+ return th.Warning.Render(lines[i])186+ }187+ return th.Muted.Render(lines[i])188+ }189+ return lines[i]190+}
M internal/ui/nbview/panel.go +43 -11
@@ -56,27 +56,32 @@ func (v *View) hints(keys map[string]string, pairs ...string) string {
5656 return v.h.Theme().KeyHints(out...)5757 }5858 59-// ContextLine is what the context line says in the notebook: a problem,60-// a key waiting for its pair, or the keys that apply.59+// ContextLine is what the context line says in the notebook, at its60+// left and its right: a problem, a key waiting for its pair, or what61+// the selection is, and the keys that apply that the toolbar doesn't62+// show.6163 func (v *View) ContextLine() (string, string) {6264 th := v.h.Theme()6365 switch {6466 case v.full != nil:6567 return v.full.ContextLine(th)6668 case v.edit.on:69+ right := v.hints(v.EditKeys, "nb.command_mode", "done") + " " + th.KeyHints("Tab", "complete")6770 if d := v.Diagnostic(); d != "" {68- return th.Warning.Render(d), v.hints(v.EditKeys, "nb.run_next", "run", "nb.command_mode", "done")71+ return th.Warning.Render(d), right6972 }70- return v.hints(v.EditKeys, "nb.run_next", "run", "nb.run", "run here") + " " + th.KeyHints("Tab", "complete"),71- v.hints(v.EditKeys, "nb.command_mode", "done")73+ return "", right7274 case v.pending == "d":73- return th.Hint.Render("d again deletes the cell"), th.KeyHints("Esc", "cancel")75+ return th.Hint.Render("d again deletes " + v.selectedWords()), th.KeyHints("Esc", "cancel")7476 case v.pending == "0":7577 return th.Hint.Render("0 again restarts: every output is cleared"), th.KeyHints("Esc", "cancel")7678 }7779 c, ok := v.Cell()7880 if !ok {79- return v.hints(v.Keys, "nb.insert_below", "add a cell"), ""81+ return "", v.hints(v.Keys, "nb.insert_below", "add a cell")82+ }83+ if from, to := v.Range(); to > from {84+ return th.Hint.Render(v.selectedWords() + " selected"), v.hints(v.Keys, "nb.delete", "delete", "nb.move_up", "move up", "nb.move_down", "down")8085 }8186 st := v.h.State(c.ID)8287 if st.Problem != "" {
@@ -86,13 +91,40 @@ func (v *View) ContextLine() (string, string) {
8691 return th.Warning.Render("Failed: " + o.Err), v.hints(v.Keys, "nb.edit", "edit")8792 }8893 if v.onOut {89- return v.hints(v.Keys, "nb.open_output", "open", "nb.toggle_output", "all or less", "nb.send", "send to a sheet"), ""94+ return th.Muted.Render(v.outputSummary(c)), v.hints(v.Keys, "nb.open_output", "full-screen", "nb.toggle_output", "hide",95+ "nb.toggle_whole", "whole", "nb.send", "to a sheet")96+ }97+ name, text := v.Head()98+ return th.Muted.Render(name + ": " + text), v.hints(v.Keys, "nb.edit", "edit") + " " + th.KeyHints("Shift+↑↓", "select")99+}100+101+// selectedWords names the selected cells: "the cell", "3 cells".102+func (v *View) selectedWords() string {103+ if from, to := v.Range(); to > from {104+ return itoa(to-from+1) + " cells"105+ }106+ return "the cell"107+}108+109+// Rule is the context line drawn at width: left, then a rule, then110+// right, the rule closing off the toolbar above the cells.111+func (v *View) Rule(left, right string, width int) string {112+ th := v.h.Theme()113+ lw, rw := ansi.StringWidth(left), ansi.StringWidth(right)114+ if lw+rw+4 > width {115+ right, rw = "", 0116+ }117+ if left != "" {118+ left = " " + ansi.Truncate(left, width-2, "…") + " "119+ lw = ansi.StringWidth(left)120+ }121+ if right != "" {122+ right = " " + right + " "123+ rw += 290124 }91- return v.hints(v.Keys, "nb.edit", "edit", "nb.run_next", "run", "nb.insert_above", "add above", "nb.insert_below", "below",92- "nb.delete", "delete", "nb.send", "send to a sheet"), ""125+ return left + th.Border.Render(strings.Repeat("─", max(width-lw-rw, 0))) + right93126 }94127 95-// Boxes are the completions' box, under the caret, for a body whose96128 // first line is at screen row top.97129 func (v *View) Boxes(top int) []overlay.Box {98130 if !v.edit.on || len(v.edit.comp) == 0 {
A internal/ui/nbview/toolbar.go +182 -0
@@ -0,0 +1,182 @@
1+package nbview2+3+import (4+ "strings"5+6+ "github.com/charmbracelet/x/ansi"7+8+ "github.com/FelineStateMachine/012/internal/notebook"9+)10+11+// The toolbar over the cells, as Jupyter's: run, stop, restart, run12+// all; add, cut, copy, paste; the active cell's kind; and at the right13+// how running stands, as Jupyter's kernel status. Each button runs its14+// command when clicked and shows its key as a chip where there's room:15+// the chips go first, from the right, then the words, the icons stay.16+17+// Back is what ToolbarAt says of the button that leaves a full-screen18+// output, which has no command.19+const Back = "back"20+21+const backButton = "◀ Back"22+23+// button is a toolbar button.24+type button struct {25+ icon, label, cmd string26+ on func(v *View, k Kernel) bool27+}28+29+func always(*View, Kernel) bool { return true }30+func hasCell(v *View, _ Kernel) bool { return len(v.h.Cells()) > 0 }31+func busy(_ *View, k Kernel) bool { return k.Busy || k.Waiting > 0 }32+func canPaste(_ *View, k Kernel) bool { return k.Clip > 0 }33+func canRun(v *View, k Kernel) bool { return hasCell(v, k) && k.Off == "" }34+func separator(*View, Kernel) bool { return false }35+func (b button) sep() bool { return b.cmd == "" }36+func (b button) text(level int, chip string) string {37+ s := b.icon38+ if level > 0 && b.label != "" {39+ s += " " + b.label40+ }41+ if level > 1 && chip != "" {42+ s += " " + chip43+ }44+ return s45+}46+47+// buttons are the toolbar's, a blank cmd between groups; the kind's48+// label is the active cell's.49+func (v *View) buttons() []button {50+ kind := "Code ▾"51+ if c, ok := v.Cell(); ok && c.Kind == notebook.Note {52+ kind = "Markdown ▾"53+ }54+ return []button{55+ {"▶", "Run", "nb.run_next", canRun}, {"■", "Stop", "nb.stop", busy}, {"↻", "Restart", "nb.restart", hasCell},56+ {"▶▶", "Run all", "nb.run_all", canRun}, {"│", "", "", separator},57+ {"+", "Add", "nb.insert_below", always}, {"✂", "Cut", "nb.cut", hasCell}, {"⧉", "Copy", "nb.copy", hasCell},58+ {"⎘", "Paste", "nb.paste", canPaste}, {"│", "", "", separator},59+ {kind, "", "nb.kind", hasCell},60+ }61+}62+63+// toolbarGap is the space between buttons.64+const toolbarGap = 265+66+// layout fits the buttons in width, beside the status: how much of each67+// shows (2 with its key, 1 with its word, 0 its icon alone).68+func (v *View) layout(bs []button, width int) []int {69+ levels := make([]int, len(bs))70+ chips := make([]string, len(bs))71+ for i, b := range bs {72+ levels[i] = 273+ chips[i] = keyFor(v.Keys, b.cmd)74+ }75+ fits := func() bool {76+ w := 177+ for i, b := range bs {78+ w += ansi.StringWidth(b.text(levels[i], chips[i])) + 2*boolInt(levels[i] > 1 && chips[i] != "") + toolbarGap79+ }80+ return w <= width81+ }82+ for level := 2; level > 0 && !fits(); level-- {83+ for i := len(bs) - 1; i >= 0 && !fits(); i-- {84+ if levels[i] == level {85+ levels[i] = level - 186+ }87+ }88+ }89+ return levels90+}91+92+// Toolbar draws the toolbar at width: the full-screen output's title93+// while one is open.94+func (v *View) Toolbar(width int) string {95+ th := v.h.Theme()96+ if v.full != nil {97+ return " " + backButton + " " + th.Chip("Esc") + " " + th.Title.Render(v.full.Title) + th.Muted.Render(", full-screen")98+ }99+ k := v.h.Kernel()100+ status := v.status(k)101+ bs := v.buttons()102+ levels := v.layout(bs, width-ansi.StringWidth(status)-2)103+ var b strings.Builder104+ b.WriteString(" ")105+ for i, bt := range bs {106+ if i > 0 {107+ b.WriteString(strings.Repeat(" ", toolbarGap))108+ }109+ b.WriteString(v.drawButton(bt, levels[i], k))110+ }111+ left := b.String()112+ gap := width - ansi.StringWidth(left) - ansi.StringWidth(status) - 1113+ if gap < 1 {114+ return left115+ }116+ return left + strings.Repeat(" ", gap) + status117+}118+119+// drawButton draws a button as much as level says: a disabled one120+// dimmed, a separator as a rule.121+func (v *View) drawButton(bt button, level int, k Kernel) string {122+ th := v.h.Theme()123+ if bt.sep() {124+ return th.Border.Render(bt.icon)125+ }126+ chip := keyFor(v.Keys, bt.cmd)127+ if !bt.on(v, k) {128+ return th.Disabled.Render(bt.text(min(level, 1), ""))129+ }130+ s := bt.text(min(level, 1), "")131+ if level > 1 && chip != "" {132+ s += " " + th.Chip(chip)133+ }134+ return s135+}136+137+// status is how running stands: nu ○ idle, nu ● busy, nu ⊘ off, after138+// reactive when the notebook is.139+func (v *View) status(k Kernel) string {140+ th := v.h.Theme()141+ var s string142+ switch {143+ case k.Off != "":144+ s = th.Warning.Render("nu ⊘ off")145+ case k.Busy && k.Waiting > 0:146+ s = th.Hint.Render("nu ● busy, " + itoa(k.Waiting) + " waiting")147+ case k.Busy || k.Waiting > 0:148+ s = th.Hint.Render("nu ● busy")149+ default:150+ s = th.Muted.Render("nu ○ idle")151+ }152+ if k.Reactive {153+ s = th.Muted.Render("reactive") + " " + s154+ }155+ return s156+}157+158+// ToolbarAt is the command of the button at column x of the toolbar,159+// or "".160+func (v *View) ToolbarAt(x, width int) string {161+ if v.full != nil {162+ if x >= 1 && x <= ansi.StringWidth(backButton) {163+ return Back164+ }165+ return ""166+ }167+ k := v.h.Kernel()168+ bs := v.buttons()169+ levels := v.layout(bs, width-ansi.StringWidth(v.status(k))-2)170+ at := 1171+ for i, bt := range bs {172+ if i > 0 {173+ at += toolbarGap174+ }175+ w := ansi.StringWidth(v.drawButton(bt, levels[i], k))176+ if x >= at && x < at+w && !bt.sep() && bt.on(v, k) {177+ return bt.cmd178+ }179+ at += w180+ }181+ return ""182+}
M internal/ui/nbview/view.go +142 -221
@@ -1,24 +1,23 @@
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.1+// Package nbview draws a notebook tab and takes its keys and clicks, as2+// Jupyter does: cells one under another, a code cell's pipeline in a box3+// with its [n]: prompt and a ▶ that runs it, its output under it after4+// Out[n]:, and a note cell's Markdown drawn as text. The active cell has5+// a bar at its left. Like Jupyter it has two modes: in command mode keys6+// act on cells (move, select several, add, delete, move them, run), in7+// edit mode they type into the active cell. A toolbar over the cells8+// runs the same commands. It knows the UI only through Host: the cells9+// and outputs, the state of runs, and running the registered commands10+// every action is.911 package nbview1012 1113 import (12- "strconv"1314 "strings"1415 "time"1516 1617 tea "charm.land/bubbletea/v2"17- "github.com/charmbracelet/x/ansi"1818 1919 "github.com/FelineStateMachine/012/internal/locale"2020 "github.com/FelineStateMachine/012/internal/notebook"21- "github.com/FelineStateMachine/012/internal/ui/lineedit"2221 "github.com/FelineStateMachine/012/internal/ui/theme"2322 )2423
@@ -29,8 +28,9 @@ type Host interface {
2928 // Cells are the notebook's cells, and Output a cell's output.3029 Cells() []notebook.Cell3130 Output(id int) *notebook.Output32- // State is how a cell's run stands.31+ // State is how a cell's run stands, and Kernel how running stands.3332 State(id int) State33+ Kernel() Kernel3434 // Run runs a registered command, as its key would.3535 Run(command string) tea.Cmd3636 // Edit keeps a cell's new source, as one undo step.
@@ -46,7 +46,17 @@ type State struct {
4646 Problem string4747 }4848 49-// View is a notebook tab's view and its state: the selected cell, the49+// Kernel is how running cells stands, for the toolbar: Jupyter's50+// kernel, which here is nu started for each cell.51+type Kernel struct {52+ Busy bool // a cell is running53+ Waiting int // cells waiting to run54+ Off string // why cells don't run here, or ""55+ Reactive bool // cells reading a cell run again when it runs56+ Clip int // cells copied, for Paste57+}58+59+// View is a notebook tab's view and its state: the selected cells, the5060 // scroll position, the mode, and what's worked out from the cells.5161 type View struct {5262 h Host
@@ -55,16 +65,17 @@ type View struct {
5565 Keys, EditKeys map[string]string5666 Providers Providers5767 58- sel int // the selected cell59- onOut bool // the selection is the cell's output60- top int // the first line of the cells shown68+ sel int // the active cell69+ anchor int // the other end of the cells selected with it, or -170+ onOut bool // the selection is the active cell's output71+ top int // the first line of the cells shown6172 // width and height are the body's, as last drawn.6273 width, height int6374 pending string // the first key of a pair: "d" of "d d"6475 6576 edit editor // the cell being edited, if any6677 full *Full // an output shown full-screen, if any67- expand map[int]bool78+ folds map[int]*fold6879 outs map[*notebook.Output]*shown6980 syntax syntaxCache7081 lastTap tap // the last click, for double clicks
@@ -72,27 +83,46 @@ type View struct {
7283 7384 // New returns a view of the notebook h shows.7485 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{}}}86+ return &View{h: h, width: 80, height: 16, anchor: -1, folds: map[int]*fold{}, outs: map[*notebook.Output]*shown{},87+ syntax: syntaxCache{spans: map[string][]Span{}}}7688 }7789 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 is90+// Selected is the active cell's index, and whether its output is8291 // selected rather than the cell.8392 func (v *View) Selected() (int, bool) {8493 v.clamp()8594 return v.sel, v.onOut8695 }8796 88-// Select selects cell i, or its output with out.97+// Range is the selected cells, first and last: the active cell alone,98+// or the run of cells Shift+Up and Down selected with it.99+func (v *View) Range() (from, to int) {100+ v.clamp()101+ if v.anchor < 0 {102+ return v.sel, v.sel103+ }104+ return min(v.sel, v.anchor), max(v.sel, v.anchor)105+}106+107+// Select makes cell i active and selects it alone, or its output with108+// out.89109 func (v *View) Select(i int, out bool) {90- v.sel, v.onOut = i, out110+ v.sel, v.onOut, v.anchor = i, out, -1111+ v.clamp()112+ v.follow()113+}114+115+// SelectRange selects cells from to to, the last one active.116+func (v *View) SelectRange(from, to int) {117+ v.sel, v.anchor, v.onOut = to, from, false118+ if from == to {119+ v.anchor = -1120+ }91121 v.clamp()92122 v.follow()93123 }94124 95-// Cell is the selected cell, if there is one.125+// Cell is the active cell, if there is one.96126 func (v *View) Cell() (notebook.Cell, bool) {97127 cells := v.h.Cells()98128 v.clamp()
@@ -102,12 +132,15 @@ func (v *View) Cell() (notebook.Cell, bool) {
102132 return cells[v.sel], true103133 }104134 105-// clamp keeps the selection on a cell there is, and on an output only135+// clamp keeps the selection on cells there are, and on an output only106136 // where one shows.107137 func (v *View) clamp() {108138 cells := v.h.Cells()109139 v.sel = max(min(v.sel, len(cells)-1), 0)110- if v.onOut && (len(cells) == 0 || v.shown(cells[v.sel]).kind == outNone) {140+ if v.anchor >= len(cells) || v.anchor == v.sel {141+ v.anchor = -1142+ }143+ if v.onOut && (len(cells) == 0 || v.outHeight(cells[v.sel]) == 0) {111144 v.onOut = false112145 }113146 }
@@ -129,54 +162,61 @@ func (v *View) shown(c notebook.Cell) *shown {
129162 return sh130163 }131164 132-// Expanded reports whether a cell's output shows whole.133-func (v *View) Expanded(id int) bool { return v.expand[id] }165+// foldOf is how cell id's output shows.166+func (v *View) foldOf(id int) fold {167+ if f := v.folds[id]; f != nil {168+ return *f169+ }170+ return fold{}171+}134172 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()173+// foldFor is cell id's fold, to change.174+func (v *View) foldFor(id int) *fold {175+ f := v.folds[id]176+ if f == nil {177+ f = &fold{}178+ v.folds[id] = f140179 }180+ return f141181 }142182 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) }183+// Hidden reports whether cell id's output is folded to a line, and184+// Whole whether it shows every row rather than a window.185+func (v *View) Hidden(id int) bool { return v.foldOf(id).hidden }186+func (v *View) Whole(id int) bool { return v.foldOf(id).whole }145187 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 int188+// ToggleHidden folds the selected cells' outputs to a line, or shows189+// them again, as Jupyter's o.190+func (v *View) ToggleHidden() {191+ v.eachFold(func(f *fold, on bool) { f.hidden = !on }, (*fold).isHidden)150192 }151193 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 b194+// ToggleWhole shows the selected cells' outputs whole, or in their195+// windows again, as Jupyter's Shift+O toggles scrolling.196+func (v *View) ToggleWhole() {197+ v.eachFold(func(f *fold, on bool) { f.whole, f.hidden = !on, false }, (*fold).isWhole)169198 }170199 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")}200+func (f *fold) isHidden() bool { return f.hidden }201+func (f *fold) isWhole() bool { return f.whole }202+203+// eachFold sets the selected cells' folds, all as the active cell's is204+// turned.205+func (v *View) eachFold(set func(f *fold, on bool), get func(*fold) bool) {206+ cells := v.h.Cells()207+ if len(cells) == 0 {208+ return209+ }210+ from, to := v.Range()211+ on := get(v.foldFor(cells[v.sel].ID))212+ for i := from; i <= to; i++ {213+ set(v.foldFor(cells[i].ID), on)175214 }176- return markdown(v.h.Theme(), c.Source, v.content())215+ v.clamp()216+ v.follow()177217 }178218 179-// Resize sets the body's size: the lines under the tab's bar and above219+// Resize sets the body's size: the lines under the toolbar and above180220 // the status line.181221 func (v *View) Resize(width, height int) {182222 height = max(height, 1)
@@ -191,41 +231,65 @@ func (v *View) Resize(width, height int) {
191231 v.follow()192232 }193233 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() {234+// startOf is the line cell i's block starts at.235+func (v *View) startOf(i int) int {197236 cells := v.h.Cells()198237 at := 0199- for i := range cells {200- if i == v.sel {201- break202- }203- at += v.blockOf(i, cells[i]).lines()238+ for k := 0; k < i && k < len(cells); k++ {239+ at += v.blockOf(k, cells[k]).lines()204240 }241+ return at242+}243+244+// total is how many lines the cells take.245+func (v *View) total() int { return v.startOf(len(v.h.Cells())) }246+247+// follow scrolls so the selection shows: the active cell's box, or its248+// output's first lines, or the caret while editing.249+func (v *View) follow() {250+ cells := v.h.Cells()205251 if len(cells) == 0 {206252 v.top = 0207253 return208254 }255+ at := v.startOf(v.sel)209256 b := v.blockOf(v.sel, cells[v.sel])210257 first, last := at, at+min(b.lines()-1, v.height-1)211258 switch {212259 case v.edit.on:213260 row, _ := v.edit.caret(v.content())214- first = at + 1 + row215- last = first261+ first = at + b.box + row262+ last = first + b.box // the box's bottom too216263 case v.onOut:217- first = at + b.head + b.src264+ first = at + 2*b.box + b.src218265 last = min(first+b.out-1, first+v.height-1)219266 }220- if first < v.top {221- v.top = first222- }267+ v.reveal(first, last)268+}269+270+// reveal scrolls so lines first to last show, first when they can't all.271+func (v *View) reveal(first, last int) {223272 if last >= v.top+v.height {224273 v.top = last - v.height + 1225274 }275+ if first < v.top {276+ v.top = first277+ }226278 v.top = max(v.top, 0)227279 }228280 281+// Reveal scrolls so cell id's block shows, as far as it fits, without282+// selecting it: the cell running while every cell runs.283+func (v *View) Reveal(id int) {284+ for i, c := range v.h.Cells() {285+ if c.ID == id {286+ at := v.startOf(i)287+ v.reveal(at, at+min(v.blockOf(i, c).lines()-2, v.height-1))288+ return289+ }290+ }291+}292+229293 // Lines draws the body: height lines, each width wide or less.230294 func (v *View) Lines() []string {231295 if v.full != nil {
@@ -236,7 +300,7 @@ func (v *View) Lines() []string {
236300 cells := v.h.Cells()237301 if len(cells) == 0 {238302 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"))303+ out = append(out, "", strings.Repeat(" ", textX)+th.Muted.Render("An empty notebook: ")+th.KeyHints("b", "add a code cell", "m", "make it a note"))240304 }241305 at := 0242306 for i, c := range cells {
@@ -260,146 +324,3 @@ func (v *View) Lines() []string {
260324 }261325 return out262326 }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- left = ansi.Truncate(left, w, "…") // a long name on a narrow screen289- right = ansi.Truncate(right, max(w-ansi.StringWidth(left)-2, 0), "…")290- text := left + strings.Repeat(" ", max(w-ansi.StringWidth(left)-ansi.StringWidth(right), 1)) + right291- switch {292- case i == v.sel && v.edit.on:293- return th.Pointer.Render(text)294- case i == v.sel && !v.onOut:295- return th.Selection.Render(text)296- }297- st := v.h.State(c.ID)298- if st.Stale && !st.Running && !st.Waiting {299- k := strings.LastIndex(text, "stale")300- return th.CellHead.Render(text[:k]) + th.Stale.Render("stale") + th.CellHead.Render(text[k+len("stale"):])301- }302- return th.CellHead.Render(text)303-}304-305-// headText is what a cell's head says: [3] sales at the left, and its306-// state at the right.307-func (v *View) headText(c notebook.Cell) (string, string) {308- if c.Kind == notebook.Note {309- return " Markdown", ""310- }311- st := v.h.State(c.ID)312- o := v.h.Output(c.ID)313- count := "[ ]"314- switch {315- case st.Running:316- count = "[*]"317- case o != nil && o.Count > 0:318- count = "[" + strconv.Itoa(o.Count) + "]"319- }320- left := " " + count321- if name := c.Name(); name != "" {322- left += " " + name323- }324- var right string325- switch {326- case st.Problem != "":327- right = st.Problem328- case st.Running:329- right = running(time.Since(st.Started))330- case st.Waiting:331- right = "waiting"332- case o == nil:333- right = "not run"334- case o.Failed():335- right = "failed" + took(o)336- case st.Stale:337- right = "stale" + took(o)338- case o.Count == 0:339- right = "saved"340- default:341- right = "✓" + took(o)342- }343- return left, right + " "344-}345-346-// spinner turns while a cell runs.347-var spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}348-349-// running is what a running cell's head says: after a few seconds, a350-// spinner and how long it has run.351-func running(d time.Duration) string {352- if d < 3*time.Second {353- return "running"354- }355- return spinner[int(d/(100*time.Millisecond))%len(spinner)] + " running " + duration(d)356-}357-358-func took(o *notebook.Output) string {359- if o.Took <= 0 {360- return ""361- }362- return " " + duration(o.Took)363-}364-365-// duration is a run's length: under a second as <1s, so a fast cell366-// always reads the same, then seconds, then minutes.367-func duration(d time.Duration) string {368- switch {369- case d < time.Second:370- return "<1s"371- case d < 10*time.Second:372- return strconv.FormatFloat(d.Seconds(), 'f', 1, 64) + "s"373- case d < time.Minute:374- return strconv.Itoa(int(d.Seconds())) + "s"375- }376- return strconv.Itoa(int(d.Minutes())) + "m" + pad2(int(d.Seconds())%60) + "s"377-}378-379-func pad2(n int) string {380- if n < 10 {381- return "0" + strconv.Itoa(n)382- }383- return strconv.Itoa(n)384-}385-386-// sourceLine is row r of a cell's source: the editor's while it's387-// edited, a note's Markdown, or a pipeline highlighted.388-func (v *View) sourceLine(i int, c notebook.Cell, r int) string {389- if v.edit.on && i == v.sel {390- return v.edit.line(v.h.Theme(), r, v.content(), v.spansFor(v.edit.text(), c.Kind, true))391- }392- if c.Kind == notebook.Note {393- lines := v.noteLines(c)394- if r < len(lines) {395- return lines[r]396- }397- return ""398- }399- buf := []rune(c.Source)400- rows := lineedit.Wrap(buf, v.content())401- if r >= len(rows) {402- return ""403- }404- return drawRow(v.h.Theme(), buf, rows[r], v.spansFor(c.Source, c.Kind, false), nil)405-}
M internal/ui/nbview/view_test.go +8 -6
@@ -23,6 +23,7 @@ type fakeHost struct {
2323 ran []string2424 edits []string2525 onRun func(id string)26+ kernel Kernel2627 }2728 2829 func newHost(srcs ...string) *fakeHost {
@@ -38,6 +39,7 @@ func (h *fakeHost) Locale() *locale.Locale { return locale.Canonical }
3839 func (h *fakeHost) Cells() []notebook.Cell { return h.cells }3940 func (h *fakeHost) Output(id int) *notebook.Output { return h.outs[id] }4041 func (h *fakeHost) State(id int) State { return h.states[id] }42+func (h *fakeHost) Kernel() Kernel { return h.kernel }4143 func (h *fakeHost) Run(id string) tea.Cmd {4244 h.ran = append(h.ran, id)4345 if h.onRun != nil {
@@ -85,7 +87,7 @@ func TestOutputsShow(t *testing.T) {
8587 v := newView(h, 80, 60)8688 got := text(v)8789 for _, want := range []string{88- "[1] files", "name size ok", "a.txt 2.0 kB TRUE", "bb.txt 10 B FALSE",90+ "[1]:", "─ files ─", "name size ok", "a.txt 2.0 kB TRUE", "bb.txt 10 B FALSE",8991 "a 1", "long_key two words", "line one", "line two",9092 "× Command `nope` not found", "help: try ls", "not saved; run to see", "0 1", "1 2",9193 } {
@@ -108,8 +110,8 @@ func TestLongOutputWindow(t *testing.T) {
108110 if got := text(v); !strings.Contains(got, "9,991 more rows") || strings.Contains(got, " 10\n") {109111 t.Errorf("collapsed:\n%s", got)110112 }111- v.Toggle()112- v.Wheel(100)113+ v.ToggleWhole()114+ v.Wheel(-1, 100)113115 if got := text(v); strings.Contains(got, "more rows") || !strings.Contains(got, " 98\n") {114116 t.Errorf("expanded, scrolled:\n%s", got)115117 }
@@ -146,7 +148,7 @@ func TestMovesBetweenCellsAndOutputs(t *testing.T) {
146148 147149 func TestEditsWithWrapAndCompletes(t *testing.T) {148150 h := newHost("ls")149- v := newView(h, 30, 20)151+ v := newView(h, 38, 20)150152 v.Providers.Completer = Words(func() []Word { return []Word{{Text: "$files", Desc: "cell"}, {Text: "sort-by", Desc: "command"}} })151153 h.onRun = func(id string) {152154 if id == "nb.command_mode" {
@@ -166,11 +168,11 @@ func TestEditsWithWrapAndCompletes(t *testing.T) {
166168 t.Errorf("completed %q", got)167169 }168170 x, y, ok := v.Cursor()169- if !ok || y != 2 || x != gutter+2+len("| sort-by") {171+ if !ok || y != 2 || x != textX+2+len("| sort-by") {170172 t.Errorf("caret %d,%d %v:\n%s", x, y, ok, text(v))171173 }172174 lines := strings.Split(text(v), "\n")173- if lines[1] != "│ ls | where size > 1kb " || lines[2] != "│ | sort-by" {175+ if !strings.Contains(lines[1], "[ ]: ┃ ls | where size > 1kb ┃ ▶") || !strings.Contains(lines[2], "┃ | sort-by") {174176 t.Errorf("wrapped:\n%s", text(v))175177 }176178 v.Key(key("esc"))
M internal/ui/notebook.go +45 -140
@@ -24,27 +24,34 @@ import (
2424 2525 // nbKeys are command mode's keys, as Jupyter's; "d d" is d twice.2626 var nbKeys = map[string]string{27- "enter": "nb.edit",28- "shift+enter": "nb.run_next",29- "ctrl+enter": "nb.run",30- "r": "nb.run", // runs where Ctrl+Enter arrives as Enter31- "alt+enter": "nb.run_insert",32- "a": "nb.insert_above",33- "b": "nb.insert_below",34- "!": "nb.add_code",35- "d d": "nb.delete",36- "m": "nb.to_note",37- "y": "nb.to_code",38- "z": "edit.undo",39- "c": "nb.copy",40- "x": "nb.cut",41- "v": "nb.paste",42- "o": "nb.toggle_output",43- "n": "nb.name",44- "G": "nb.send",45- "i i": "nb.stop",46- "0 0": "nb.restart",47- "f9": "nb.run_all",27+ "enter": "nb.edit",28+ "shift+enter": "nb.run_next",29+ "ctrl+enter": "nb.run",30+ "r": "nb.run", // runs where Ctrl+Enter arrives as Enter31+ "alt+enter": "nb.run_insert",32+ "a": "nb.insert_above",33+ "b": "nb.insert_below",34+ "!": "nb.add_code",35+ "d d": "nb.delete",36+ "m": "nb.to_note",37+ "y": "nb.to_code",38+ "z": "edit.undo",39+ "c": "nb.copy",40+ "x": "nb.cut",41+ "v": "nb.paste",42+ "o": "nb.toggle_output",43+ "n": "nb.name",44+ "G": "nb.send",45+ "i i": "nb.stop",46+ "0 0": "nb.restart",47+ "f9": "nb.run_all",48+ "V": "nb.paste_above",49+ "O": "nb.toggle_whole",50+ "alt+up": "nb.move_up", // as an editor moves lines; JupyterLab's Ctrl+Shift+Up too51+ "alt+down": "nb.move_down",52+ "ctrl+shift+up": "nb.move_up",53+ "ctrl+shift+down": "nb.move_down",54+ "ctrl+g": "nb.goto",4855 }4956 5057 // nbEditKeys are edit mode's.
@@ -87,7 +94,8 @@ func nbKeyLabels(id string) []string {
8794 8895 // nbHelpRows are the notebook's keys for help, in the order of its menu.8996 func nbHelpRows(listed map[string]bool) []helpRow {90- rows := []helpRow{{keys: []string{"Up", "Down", "j", "k"}, action: "Move between cells and outputs"}}97+ rows := []helpRow{{keys: []string{"Up", "Down", "j", "k"}, action: "Move between cells and outputs"},98+ {keys: []string{"Shift+Up", "Shift+Down", "K", "J"}, action: "Select several cells"}}9199 for _, it := range append([]menuItem{{cmd: "nb.edit"}, {cmd: "nb.command_mode"}, {cmd: "nb.add_code"}}, notebookItems...) {92100 keys := nbKeyLabels(it.cmd)93101 if it.sep || keys[0] == "" || listed[it.cmd] || commands[it.cmd] == nil {
@@ -103,13 +111,23 @@ func nbHelpRows(listed map[string]bool) []helpRow {
103111 var notebookItems = []menuItem{104112 {cmd: "nb.open"}, sep,105113 {cmd: "nb.run"}, {cmd: "nb.run_next"}, {cmd: "nb.run_all"}, {cmd: "nb.run_above"}, {cmd: "nb.run_below"}, {cmd: "nb.stop"}, sep,106- {cmd: "nb.insert_above"}, {cmd: "nb.insert_below"}, {cmd: "nb.delete"}, {cmd: "nb.to_note"}, {cmd: "nb.to_code"}, {cmd: "nb.name"}, sep,107- {cmd: "nb.copy"}, {cmd: "nb.cut"}, {cmd: "nb.paste"}, sep,108- {cmd: "nb.toggle_output"}, {cmd: "nb.open_output"}, {cmd: "nb.send"}, sep,114+ {cmd: "nb.insert_above"}, {cmd: "nb.insert_below"}, {cmd: "nb.delete"}, {cmd: "nb.move_up"}, {cmd: "nb.move_down"}, sep,115+ {cmd: "nb.to_code"}, {cmd: "nb.to_note"}, {cmd: "nb.name"}, sep,116+ {cmd: "nb.copy"}, {cmd: "nb.cut"}, {cmd: "nb.paste"}, {cmd: "nb.paste_above"}, sep,117+ {cmd: "nb.toggle_output"}, {cmd: "nb.toggle_whole"}, {cmd: "nb.open_output"}, {cmd: "nb.clear_output"}, {cmd: "nb.send"}, sep,118+ {cmd: "nb.goto"}, {cmd: "nb.toc"}, sep,109119 {cmd: "nb.clear_outputs"}, {cmd: "nb.restart"}, {cmd: "nb.reactive"}, sep,110120 {cmd: "region.freeze"}, {cmd: "region.delete"},111121 }112122 123+// nbCellMenu is a cell's context menu, on a right click.124+var nbCellMenu = []menuItem{125+ {cmd: "nb.run"}, {cmd: "nb.run_below"}, sep,126+ {cmd: "nb.cut"}, {cmd: "nb.copy"}, {cmd: "nb.paste"}, {cmd: "nb.paste_above"}, {cmd: "nb.delete"}, sep,127+ {cmd: "nb.move_up"}, {cmd: "nb.move_down"}, {cmd: "nb.to_code"}, {cmd: "nb.to_note"}, {cmd: "nb.name"}, sep,128+ {cmd: "nb.toggle_output"}, {cmd: "nb.toggle_whole"}, {cmd: "nb.clear_output"}, {cmd: "nb.open_output"}, {cmd: "nb.send"},129+}130+113131 func init() {114132 onTab := func(m *Model) bool { return m.nbView() != nil }115133 onCell := func(m *Model) bool { _, ok := m.nbCell(); return ok }
@@ -146,48 +164,7 @@ func init() {
146164 run: func(m *Model) tea.Cmd { m.sheet.SetReactive(!m.sheet.Reactive()); return nil }},147165 )148166 registerCellCommands(onCell, onCode)149-}150-151-// registerCellCommands registers the commands that change cells.152-func registerCellCommands(onCell, onCode func(*Model) bool) {153- register(154- &command{id: "nb.insert_above", macro: macroNever, title: "Add cell above", desc: "Add a code cell above the selected one",155- enabled: func(m *Model) bool { return m.nbView() != nil }, run: func(m *Model) tea.Cmd { return m.addCell(0, false) }},156- &command{id: "nb.insert_below", macro: macroNever, title: "Add cell below", desc: "Add a code cell below the selected one",157- enabled: func(m *Model) bool { return m.nbView() != nil }, run: func(m *Model) tea.Cmd { return m.addCell(1, false) }},158- &command{id: "nb.add_code", macro: macroNever, title: "New code cell", desc: "Add a code cell below the selected one and edit it",159- enabled: func(m *Model) bool { return m.nbView() != nil }, run: func(m *Model) tea.Cmd { return m.addCell(1, true) }},160- &command{id: "nb.delete", macro: macroNever, title: "Delete cell", desc: "Delete the selected cell; z or Undo brings it back",161- enabled: onCell, run: func(m *Model) tea.Cmd { m.deleteCell(); return nil }},162- &command{id: "nb.to_note", macro: macroNever, title: "Make it a note", desc: "Turn the selected cell into a note cell, written in Markdown",163- enabled: onCell, run: func(m *Model) tea.Cmd { m.setKind(notebook.Note); return nil }},164- &command{id: "nb.to_code", macro: macroNever, title: "Make it code", desc: "Turn the selected cell into a code cell, a nushell pipeline",165- enabled: onCell, run: func(m *Model) tea.Cmd { m.setKind(notebook.Code); return nil }},166- &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",167- enabled: onCode, run: (*Model).openCellName},168- &command{id: "nb.copy", macro: macroNever, title: "Copy cell", desc: "Copy the selected cell, to paste with v",169- enabled: onCell, run: func(m *Model) tea.Cmd { m.copyCell(false); return nil }},170- &command{id: "nb.cut", macro: macroNever, title: "Cut cell", desc: "Cut the selected cell, to paste with v",171- enabled: onCell, run: func(m *Model) tea.Cmd { m.copyCell(true); return nil }},172- &command{id: "nb.paste", macro: macroNever, title: "Paste cell", desc: "Paste the cell copied below the selected one",173- enabled: func(m *Model) bool { return m.nbView() != nil && len(m.nb.clip) > 0 }, run: func(m *Model) tea.Cmd { m.pasteCells(); return nil }},174- &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",175- enabled: onCode, checked: func(m *Model) bool { c, ok := m.nbCell(); return ok && m.nbView().Expanded(c.ID) },176- run: func(m *Model) tea.Cmd { m.nbView().Toggle(); return nil }},177- &command{id: "nb.open_output", macro: macroNever, title: "Open output", desc: "Show the selected cell's output full-screen, to sort and filter it",178- enabled: onCode, run: func(m *Model) tea.Cmd {179- if !m.nbView().OpenFull() {180- m.note = "The cell has no output to open"181- }182- return nil183- }},184- &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",185- enabled: onCode, run: (*Model).sendCell},186- &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",187- enabled: onOutputRegion, run: func(m *Model) tea.Cmd { return m.freezeOutput() }},188- &command{id: "region.delete", title: "Remove output", desc: "Remove the output sent here and its values from the sheet",189- enabled: onOutputRegion, run: func(m *Model) tea.Cmd { return m.deleteOutput() }},190- )167+ registerOutputCommands(onCode)191168 }192169 193170 // nbView is the view of the notebook tab shown, or nil on a sheet.
@@ -247,79 +224,6 @@ func (m *Model) madeCells() {
247224 }248225 }249226 250-// addCell adds an empty code cell above the selected one (at 0) or below251-// it (at 1), editing it with edit.252-func (m *Model) addCell(at int, edit bool) tea.Cmd {253- v := m.nbView()254- cells := m.sheet.NotebookCells()255- i, _ := v.Selected()256- if len(cells) > 0 {257- i += at258- }259- v.StopEdit()260- cells = slices.Insert(cells, i, notebook.Cell{})261- m.setCells("add cell", cells)262- v.Select(i, false)263- if edit {264- return v.StartEdit()265- }266- return nil267-}268-269-// deleteCell deletes the selected cell.270-func (m *Model) deleteCell() {271- v := m.nbView()272- cells := m.sheet.NotebookCells()273- i, _ := v.Selected()274- if i >= len(cells) {275- return276- }277- m.setCells("delete cell "+strconv.Itoa(i+1), slices.Delete(cells, i, i+1))278- v.Select(i, false)279- m.note = "Deleted cell " + strconv.Itoa(i+1) + ": z brings it back"280-}281-282-// setKind turns the selected cell into a code or note cell.283-func (m *Model) setKind(k notebook.Kind) {284- v := m.nbView()285- cells := m.sheet.NotebookCells()286- i, _ := v.Selected()287- if i >= len(cells) || cells[i].Kind == k {288- return289- }290- cells[i].Kind = k291- m.setCells("change cell "+strconv.Itoa(i+1), cells)292- v.Select(i, false)293-}294-295-// copyCell copies the selected cell, and deletes it with cut.296-func (m *Model) copyCell(cut bool) {297- c, _ := m.nbCell()298- c.ID = 0299- m.nb.clip = []notebook.Cell{c}300- if cut {301- i, _ := m.nbView().Selected()302- m.setCells("cut cell "+strconv.Itoa(i+1), slices.Delete(m.sheet.NotebookCells(), i, i+1))303- m.nbView().Select(i, false)304- m.note = "Cut cell " + strconv.Itoa(i+1) + ": v pastes it"305- return306- }307- m.note = "Copied the cell: v pastes it"308-}309-310-// pasteCells pastes the cells copied below the selected one.311-func (m *Model) pasteCells() {312- v := m.nbView()313- cells := m.sheet.NotebookCells()314- i, _ := v.Selected()315- if len(cells) > 0 {316- i++317- }318- cells = slices.Insert(cells, i, m.nb.clip...)319- m.setCells("paste cell", cells)320- v.Select(i, false)321-}322-323227 // openCellName asks for the selected cell's name.324228 func (m *Model) openCellName() tea.Cmd {325229 c, _ := m.nbCell()
@@ -372,6 +276,7 @@ func (h nbHost) Locale() *locale.Locale { return h.m.locale() }
372276 func (h nbHost) Cells() []notebook.Cell { return h.s.NotebookCells() }373277 func (h nbHost) Output(id int) *notebook.Output { return h.m.book().Output(id) }374278 func (h nbHost) State(id int) nbview.State { return h.m.cellState(h.s, id) }279+func (h nbHost) Kernel() nbview.Kernel { return h.m.nbKernel(h.s) }375280 func (h nbHost) Run(command string) tea.Cmd { return h.m.runCommand(command) }376281 377282 // Edit keeps a cell's new source.
M internal/ui/notebook_test.go +16 -4
M internal/ui/panel.go +4 -1
M internal/ui/theme/code.go +2 -0
M internal/ui/theme/scheme.go +1 -1
M internal/ui/theme/theme.go +14 -9