1package ui23import (4 "cmp"5 "slices"6 "strconv"78 tea "charm.land/bubbletea/v2"910 "src.rest/cat/012/internal/locale"11 "src.rest/cat/012/internal/notebook"12 "src.rest/cat/012/internal/sheet"13 "src.rest/cat/012/internal/ui/nbview"14 "src.rest/cat/012/internal/ui/theme"15)1617// Notebooks: a notebook tab holds code cells (nushell pipelines) and18// note cells (Markdown), drawn and keyed by package nbview. Every action19// on them is a command registered here, so menus (Data > Shell), the20// palette and help list them; the keys Jupyter uses reach them through21// nbKeys and nbEditKeys. The cells are the workbook's (sheet/notebook.go),22// running them is nbrun.go's, sending an output to a sheet nbsend.go's,23// and drawing the tab nbscreen.go's.2425// nbKeys are command mode's keys, as Jupyter's; "d d" is d twice.26var 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 "f": "nb.stream", // follow47 "0 0": "nb.restart",48 "f9": "nb.run_all",49 "V": "nb.paste_above",50 "O": "nb.toggle_whole",51 "alt+up": "nb.move_up", // as an editor moves lines; JupyterLab's Ctrl+Shift+Up too52 "alt+down": "nb.move_down",53 "ctrl+shift+up": "nb.move_up",54 "ctrl+shift+down": "nb.move_down",55 "ctrl+g": "nb.goto",56}5758// nbEditKeys are edit mode's.59var nbEditKeys = map[string]string{60 "esc": "nb.command_mode",61 "shift+enter": "nb.run_next",62 "ctrl+enter": "nb.run",63 "alt+enter": "nb.run_insert",64 "f1": "nb.word_help",65}6667// nbShortcut is the notebook's key for command id, as shown: its68// command mode's, or edit mode's, the shortest first.69func nbShortcut(id string) string {70 return nbKeyLabels(id)[0]71}7273// nbKeyLabels are the notebook's keys for id, shown, command mode's74// first; "" alone for none.75func nbKeyLabels(id string) []string {76 var out []string77 for _, keys := range []map[string]string{nbKeys, nbEditKeys} {78 var found []string79 for k, c := range keys {80 if c == id {81 found = append(found, nbview.KeyLabel(k))82 }83 }84 slices.SortFunc(found, func(a, b string) int { return cmp.Or(cmp.Compare(len(a), len(b)), cmp.Compare(a, b)) })85 for _, k := range found {86 if !slices.Contains(out, k) {87 out = append(out, k)88 }89 }90 }91 if len(out) == 0 {92 return []string{""}93 }94 return out95}9697// nbHelpRows are the notebook's keys for help, in the order of its menu.98func nbHelpRows(listed map[string]bool) []helpRow {99 rows := []helpRow{{keys: []string{"Up", "Down", "j", "k"}, action: "Move between cells and outputs"},100 {keys: []string{"Shift+Up", "Shift+Down", "K", "J"}, action: "Select several cells"}}101 for _, it := range append([]menuItem{{cmd: "nb.edit"}, {cmd: "nb.command_mode"}, {cmd: "nb.add_code"}, {cmd: "nb.word_help"}}, notebookItems...) {102 keys := nbKeyLabels(it.cmd)103 if it.sep || keys[0] == "" || listed[it.cmd] || commands[it.cmd] == nil {104 continue105 }106 listed[it.cmd] = true107 rows = append(rows, helpRow{keys: keys, action: commands[it.cmd].title})108 }109 return rows110}111112// notebookItems are Data > Shell's items.113var notebookItems = []menuItem{114 {cmd: "nb.open"}, sep,115 {cmd: "nb.run"}, {cmd: "nb.run_next"}, {cmd: "nb.run_all"}, {cmd: "nb.run_above"}, {cmd: "nb.run_below"}, {cmd: "nb.stream"}, {cmd: "nb.stop"}, sep,116 {cmd: "nb.insert_above"}, {cmd: "nb.insert_below"}, {cmd: "nb.delete"}, {cmd: "nb.move_up"}, {cmd: "nb.move_down"}, sep,117 {cmd: "nb.to_code"}, {cmd: "nb.to_note"}, {cmd: "nb.name"}, sep,118 {cmd: "nb.copy"}, {cmd: "nb.cut"}, {cmd: "nb.paste"}, {cmd: "nb.paste_above"}, sep,119 {cmd: "nb.toggle_output"}, {cmd: "nb.toggle_whole"}, {cmd: "nb.open_output"}, {cmd: "nb.clear_output"}, {cmd: "nb.send"}, sep,120 {cmd: "nb.goto"}, {cmd: "nb.toc"}, sep,121 {cmd: "nb.clear_outputs"}, {cmd: "nb.restart"}, {cmd: "nb.reactive"}, sep,122 {cmd: "region.freeze"}, {cmd: "region.delete"},123}124125// nbCellMenu is a cell's context menu, on a right click.126var nbCellMenu = []menuItem{127 {cmd: "nb.run"}, {cmd: "nb.run_below"}, {cmd: "nb.stream"}, sep,128 {cmd: "nb.cut"}, {cmd: "nb.copy"}, {cmd: "nb.paste"}, {cmd: "nb.paste_above"}, {cmd: "nb.delete"}, sep,129 {cmd: "nb.move_up"}, {cmd: "nb.move_down"}, {cmd: "nb.to_code"}, {cmd: "nb.to_note"}, {cmd: "nb.name"}, sep,130 {cmd: "nb.toggle_output"}, {cmd: "nb.toggle_whole"}, {cmd: "nb.clear_output"}, {cmd: "nb.open_output"}, {cmd: "nb.send"},131}132133func init() {134 onTab := func(m *Model) bool { return m.nbView() != nil }135 onCell := func(m *Model) bool { _, ok := m.nbCell(); return ok }136 onCode := func(m *Model) bool { c, ok := m.nbCell(); return ok && c.Kind == notebook.Code }137 register(138 &command{id: "nb.open", macro: macroNever, title: "Open notebook", desc: "Show the workbook's notebook, or make one: nushell pipelines in cells, their outputs under them",139 run: (*Model).openNotebook},140 &command{id: "nb.edit", macro: macroNever, title: "Edit cell", desc: "Edit the selected cell, or open its output full-screen when the output is selected",141 enabled: onCell, run: (*Model).nbEnter},142 &command{id: "nb.command_mode", macro: macroNever, title: "Stop editing", desc: "Keep what was typed and go back to moving between cells",143 enabled: func(m *Model) bool { v := m.nbView(); return v != nil && v.Editing() },144 run: func(m *Model) tea.Cmd { m.nbView().StopEdit(); return nil }},145 &command{id: "nb.run", macro: macroNever, title: "Run cell", desc: "Run the selected cell, and whatever it reads that hasn't run",146 enabled: onCode, run: func(m *Model) tea.Cmd { return m.runSelected(0) }},147 &command{id: "nb.run_next", macro: macroNever, title: "Run cell and select next", desc: "Run the selected cell and select the one below, adding one at the end",148 enabled: onCell, run: func(m *Model) tea.Cmd { return m.runSelected(1) }},149 &command{id: "nb.run_insert", macro: macroNever, title: "Run cell and insert below", desc: "Run the selected cell and add a code cell under it",150 enabled: onCell, run: func(m *Model) tea.Cmd { return m.runSelected(2) }},151 &command{id: "nb.run_all", title: "Run all cells", desc: "Run every code cell, in order, each after the cells it reads",152 enabled: onTab, run: func(m *Model) tea.Cmd { return m.runRange(0, -1) }},153 &command{id: "nb.run_above", macro: macroNever, title: "Run cells above", desc: "Run the code cells above the selected one",154 enabled: onCell, run: func(m *Model) tea.Cmd { i, _ := m.nbView().Selected(); return m.runRange(0, i) }},155 &command{id: "nb.run_below", macro: macroNever, title: "Run cell and below", desc: "Run the selected cell and every code cell under it",156 enabled: onCell, run: func(m *Model) tea.Cmd { i, _ := m.nbView().Selected(); return m.runRange(i, -1) }},157 &command{id: "nb.stream", macro: macroNever, title: "Run as stream", desc: "Run the selected cell until it's stopped, its rows arriving as the pipeline prints them (tail -f, watch)",158 enabled: onCode, run: (*Model).streamSelected},159 &command{id: "nb.stop", macro: macroNever, title: "Stop running", desc: "Stop the cell running, killing its process, those waiting and the cells running as streams",160 enabled: func(m *Model) bool {161 return m.nb.runs.running != nil || len(m.nb.runs.queue) > 0 || len(m.nb.runs.streams) > 0162 },163 run: func(m *Model) tea.Cmd { m.stopCells(); return nil }},164 &command{id: "nb.clear_outputs", title: "Clear outputs", desc: "Clear every cell's output",165 enabled: onTab, run: func(m *Model) tea.Cmd { m.clearOutputs(false); return nil }},166 &command{id: "nb.restart", title: "Restart", desc: "Stop what's running, clear every output and count runs from 1 again",167 enabled: onTab, run: func(m *Model) tea.Cmd { m.clearOutputs(true); return nil }},168 &command{id: "nb.reactive", title: "Reactive notebook", desc: "Run the cells reading a cell again whenever it runs",169 enabled: onTab, checked: func(m *Model) bool { return m.sheet.IsNotebook() && m.sheet.Reactive() },170 run: func(m *Model) tea.Cmd { m.sheet.SetReactive(!m.sheet.Reactive()); return nil }},171 )172 registerCellCommands(onCell, onCode)173 registerOutputCommands(onCode)174}175176// nbView is the view of the notebook tab shown, or nil on a sheet.177func (m *Model) nbView() *nbview.View {178 if !m.sheet.IsNotebook() {179 return nil180 }181 return m.viewOf(m.sheet)182}183184// viewOf is notebook s's view, made the first time it's asked for.185func (m *Model) viewOf(s *sheet.Sheet) *nbview.View {186 if m.nb.views == nil {187 m.nb.views = map[*sheet.Sheet]*nbview.View{}188 }189 v := m.nb.views[s]190 if v == nil {191 v = nbview.New(nbHost{m, s})192 v.Keys, v.EditKeys, v.After = nbKeys, nbEditKeys, after193 lang := m.nbLang().For()194 v.Providers = nbview.Providers{Highlighter: lang, Completer: lang, Checker: lang, Hoverer: lang}195 m.nb.views[s] = v196 }197 return v198}199200// nbCell is the selected cell of the notebook shown.201func (m *Model) nbCell() (notebook.Cell, bool) {202 if v := m.nbView(); v != nil {203 return v.Cell()204 }205 return notebook.Cell{}, false206}207208// nbEnter is Enter in command mode: edit the cell, or work in its209// output's grid, or open an output that isn't one.210func (m *Model) nbEnter() tea.Cmd {211 v := m.nbView()212 if _, out := v.Selected(); out {213 if g, ok := v.SelectedGrid().(*outGrid); ok {214 m.sizeNotebook(v)215 m.enterGrid(g)216 return nil217 }218 v.OpenFull()219 return nil220 }221 return v.StartEdit()222}223224// setCells replaces the notebook's cells as one undo step, and the user225// who changes cells of a notebook made here trusts it.226func (m *Model) setCells(label string, cells []notebook.Cell) {227 m.sheet.SetNotebookCells(label, cells)228 m.madeCells()229}230231// madeCells trusts the workbook's cells on this computer once the user232// changes them, unless cells or macros came from elsewhere untrusted.233func (m *Model) madeCells() {234 if m.macroTrusted() || !m.nb.fromFile && len(m.book().Macros()) == 0 {235 m.trustHere()236 }237}238239// openCellName asks for the selected cell's name.240func (m *Model) openCellName() tea.Cmd {241 c, _ := m.nbCell()242 s := m.sheet243 m.openText("Name the cell:", c.Name(), func(m *Model, text string) tea.Cmd {244 if text != "" && text != c.Name() {245 if err := notebook.ValidName(text); err != nil {246 m.fail(err.Error())247 return nil248 }249 if m.book().FreeCellName(text) != text {250 m.fail("$" + text + " is taken: another cell or region has that name")251 return nil252 }253 }254 m.renameCell(s, c, text)255 return nil256 })257 return nil258}259260// renameCell names cell c of notebook s, and the output it sent to a261// sheet with it.262func (m *Model) renameCell(s *sheet.Sheet, c notebook.Cell, name string) {263 cells := s.NotebookCells()264 i := slices.IndexFunc(cells, func(x notebook.Cell) bool { return x.ID == c.ID })265 if i < 0 {266 return267 }268 cells[i].Source = notebook.WithName(c.Source, name)269 s.SetNotebookCells("name cell "+strconv.Itoa(i+1), cells)270 m.madeCells()271 if old := c.Name(); old != "" && name != "" {272 if _, r, ok := m.book().Region(old); ok && r.Output {273 m.moveOutput(r, name)274 }275 }276}277278// nbHost is how a notebook's view reaches the model.279type nbHost struct {280 m *Model281 s *sheet.Sheet282}283284var _ nbview.Host = nbHost{}285286func (h nbHost) Theme() *theme.Theme { return &h.m.th }287func (h nbHost) Locale() *locale.Locale { return h.m.locale() }288func (h nbHost) Cells() []notebook.Cell { return h.s.NotebookCells() }289func (h nbHost) Output(id int) *notebook.Output { return h.m.book().Output(id) }290func (h nbHost) State(id int) nbview.State { return h.m.cellState(h.s, id) }291func (h nbHost) Kernel() nbview.Kernel { return h.m.nbKernel(h.s) }292func (h nbHost) Run(command string) tea.Cmd { return h.m.runCommand(command) }293294// Edit keeps a cell's new source.295func (h nbHost) Edit(id int, source string) {296 cells := h.s.NotebookCells()297 for i, c := range cells {298 if c.ID == id {299 cells[i].Source = source300 h.s.SetNotebookCells("edit cell "+strconv.Itoa(i+1), cells)301 h.m.madeCells()302 return303 }304 }305}