cat / 012
clone

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

main
012 / internal / ui / nbview / view.go
view.go 9.6K
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.11package nbview1213import (14 "strings"15 "time"1617 tea "charm.land/bubbletea/v2"1819 "src.rest/cat/012/internal/locale"20 "src.rest/cat/012/internal/notebook"21 "src.rest/cat/012/internal/ui/theme"22)2324// Host is what the notebook needs of the UI.25type Host interface {26 Theme() *theme.Theme27 Locale() *locale.Locale28 // Cells are the notebook's cells, and Output a cell's output.29 Cells() []notebook.Cell30 Output(id int) *notebook.Output31 // State is how a cell's run stands, and Kernel how running stands.32 State(id int) State33 Kernel() Kernel34 // Run runs a registered command, as its key would.35 Run(command string) tea.Cmd36 // Edit keeps a cell's new source, as one undo step.37 Edit(id int, source string)38 // Grid draws cell id's output, a table or record, as the UI's39 // grid: data is the grid's table as NUON (a record's fields as40 // rows), of rows rows.41 Grid(id int, data []byte, rows int) Grid42}4344// State is how a cell's run stands, as the runner knows it.45type State struct {46 Waiting, Running bool47 Started time.Time // when it started running48 // Live is set while it runs as a stream, and Rows counts the rows49 // the stream has printed.50 Live bool51 Rows int52 Stale bool // what it read, or its source, changed since it ran53 // Problem says why it can't run as it is: its name is taken.54 Problem string55}5657// Kernel is how running cells stands, for the toolbar: Jupyter's58// kernel, which here is nu started for each cell.59type Kernel struct {60 Busy bool // a cell is running61 Waiting int // cells waiting to run62 Live int // cells running as streams63 Off string // why cells don't run here, or ""64 Reactive bool // cells reading a cell run again when it runs65 Clip int // cells copied, for Paste66}6768// View is a notebook tab's view and its state: the selected cells, the69// scroll position, the mode, and what's worked out from the cells.70type View struct {71 h Host72 // Keys bind command mode's keys to commands, EditKeys edit mode's;73 // "d d" is d pressed twice.74 Keys, EditKeys map[string]string75 Providers Providers76 // After is the clock typing's pauses are timed on: a command that77 // gives msg once d has passed, tea.Tick's when nil. Tests give one78 // that hands them msg, to deliver when they want the pause over.79 After func(d time.Duration, msg tea.Msg) tea.Cmd8081 sel int // the active cell82 anchor int // the other end of the cells selected with it, or -183 onOut bool // the selection is the active cell's output84 top int // the first line of the cells shown85 // width and height are the body's, as last drawn.86 width, height int87 pending string // the first key of a pair: "d" of "d d"8889 edit editor // the cell being edited, if any90 full *Full // an output shown full-screen, if any91 folds map[int]*fold92 outs map[*notebook.Output]*shown93 syntax syntaxCache94 lastTap tap // the last click, for double clicks95}9697// New returns a view of the notebook h shows.98func New(h Host) *View {99 return &View{h: h, width: 80, height: 16, anchor: -1, folds: map[int]*fold{}, outs: map[*notebook.Output]*shown{},100 syntax: syntaxCache{spans: map[string][]Span{}}}101}102103// Selected is the active cell's index, and whether its output is104// selected rather than the cell.105func (v *View) Selected() (int, bool) {106 v.clamp()107 return v.sel, v.onOut108}109110// Range is the selected cells, first and last: the active cell alone,111// or the run of cells Shift+Up and Down selected with it.112func (v *View) Range() (from, to int) {113 v.clamp()114 if v.anchor < 0 {115 return v.sel, v.sel116 }117 return min(v.sel, v.anchor), max(v.sel, v.anchor)118}119120// Select makes cell i active and selects it alone, or its output with121// out.122func (v *View) Select(i int, out bool) {123 v.sel, v.onOut, v.anchor = i, out, -1124 v.clamp()125 v.follow()126}127128// SelectRange selects cells from to to, the last one active.129func (v *View) SelectRange(from, to int) {130 v.sel, v.anchor, v.onOut = to, from, false131 if from == to {132 v.anchor = -1133 }134 v.clamp()135 v.follow()136}137138// Cell is the active cell, if there is one.139func (v *View) Cell() (notebook.Cell, bool) {140 cells := v.h.Cells()141 v.clamp()142 if len(cells) == 0 {143 return notebook.Cell{}, false144 }145 return cells[v.sel], true146}147148// clamp keeps the selection on cells there are, and on an output only149// where one shows.150func (v *View) clamp() {151 cells := v.h.Cells()152 v.sel = max(min(v.sel, len(cells)-1), 0)153 if v.anchor >= len(cells) || v.anchor == v.sel {154 v.anchor = -1155 }156 if v.onOut && (len(cells) == 0 || v.outHeight(cells[v.sel]) == 0) {157 v.onOut = false158 }159}160161// shown is a cell's output as it shows, parsed once.162func (v *View) shown(c notebook.Cell) *shown {163 o := v.h.Output(c.ID)164 if c.Kind != notebook.Code || o == nil {165 return &shown{}166 }167 sh := v.outs[o]168 if sh == nil {169 if len(v.outs) > 256 {170 clear(v.outs) // outputs replaced by runs since171 }172 sh = parse(o)173 if sh.data != nil {174 sh.grid = v.h.Grid(c.ID, sh.data, sh.total)175 }176 v.outs[o] = sh177 }178 return sh179}180181// foldOf is how cell id's output shows.182func (v *View) foldOf(id int) fold {183 if f := v.folds[id]; f != nil {184 return *f185 }186 return fold{}187}188189// foldFor is cell id's fold, to change.190func (v *View) foldFor(id int) *fold {191 f := v.folds[id]192 if f == nil {193 f = &fold{}194 v.folds[id] = f195 }196 return f197}198199// Hidden reports whether cell id's output is folded to a line, and200// Whole whether it shows every row rather than a window.201func (v *View) Hidden(id int) bool { return v.foldOf(id).hidden }202func (v *View) Whole(id int) bool { return v.foldOf(id).whole }203204// ToggleHidden folds the selected cells' outputs to a line, or shows205// them again, as Jupyter's o.206func (v *View) ToggleHidden() {207 v.eachFold(func(f *fold, on bool) { f.hidden = !on }, (*fold).isHidden)208}209210// ToggleWhole shows the selected cells' outputs whole, or in their211// windows again, as Jupyter's Shift+O toggles scrolling.212func (v *View) ToggleWhole() {213 v.eachFold(func(f *fold, on bool) { f.whole, f.hidden = !on, false }, (*fold).isWhole)214}215216func (f *fold) isHidden() bool { return f.hidden }217func (f *fold) isWhole() bool { return f.whole }218219// eachFold sets the selected cells' folds, all as the active cell's is220// turned.221func (v *View) eachFold(set func(f *fold, on bool), get func(*fold) bool) {222 cells := v.h.Cells()223 if len(cells) == 0 {224 return225 }226 from, to := v.Range()227 on := get(v.foldFor(cells[v.sel].ID))228 for i := from; i <= to; i++ {229 set(v.foldFor(cells[i].ID), on)230 }231 v.clamp()232 v.follow()233}234235// Resize sets the body's size: the lines under the toolbar and above236// the status line.237func (v *View) Resize(width, height int) {238 height = max(height, 1)239 if width == v.width && height == v.height {240 return241 }242 v.width, v.height = width, height243 if v.full != nil {244 v.full.resize(width, height)245 v.full.settle()246 }247 v.follow()248}249250// startOf is the line cell i's block starts at.251func (v *View) startOf(i int) int {252 cells := v.h.Cells()253 at := 0254 for k := 0; k < i && k < len(cells); k++ {255 at += v.blockOf(k, cells[k]).lines()256 }257 return at258}259260// total is how many lines the cells take.261func (v *View) total() int { return v.startOf(len(v.h.Cells())) }262263// follow scrolls so the selection shows: the active cell's box, or its264// output's first lines, or the caret while editing.265func (v *View) follow() {266 cells := v.h.Cells()267 if len(cells) == 0 {268 v.top = 0269 return270 }271 at := v.startOf(v.sel)272 b := v.blockOf(v.sel, cells[v.sel])273 first, last := at, at+min(b.lines()-1, v.height-1)274 switch {275 case v.edit.on:276 row, _ := v.edit.caret(v.content())277 first = at + b.box + row278 last = first + b.box // the box's bottom too279 case v.onOut:280 first = at + 2*b.box + b.src281 last = min(first+b.out-1, first+v.height-1)282 }283 v.reveal(first, last)284}285286// reveal scrolls so lines first to last show, first when they can't all.287func (v *View) reveal(first, last int) {288 if last >= v.top+v.height {289 v.top = last - v.height + 1290 }291 if first < v.top {292 v.top = first293 }294 v.top = max(v.top, 0)295}296297// Reveal scrolls so cell id's block shows, as far as it fits, without298// selecting it: the cell running while every cell runs.299func (v *View) Reveal(id int) {300 for i, c := range v.h.Cells() {301 if c.ID == id {302 at := v.startOf(i)303 v.reveal(at, at+min(v.blockOf(i, c).lines()-2, v.height-1))304 return305 }306 }307}308309// Lines draws the body: height lines, each width wide or less.310func (v *View) Lines() []string {311 if v.full != nil {312 return v.full.lines(v.h.Theme(), v.h.Locale())313 }314 v.clamp()315 out := make([]string, 0, v.height)316 cells := v.h.Cells()317 if len(cells) == 0 {318 th := v.h.Theme()319 out = append(out, "", strings.Repeat(" ", textX)+th.Muted.Render("An empty notebook: ")+th.KeyHints("b", "add a code cell", "m", "make it a note"))320 }321 at := 0322 for i, c := range cells {323 b := v.blockOf(i, c)324 if at+b.lines() <= v.top {325 at += b.lines()326 continue327 }328 for k := range b.lines() {329 if n := at + k; n >= v.top && len(out) < v.height {330 out = append(out, v.blockLine(i, c, b, k))331 }332 }333 at += b.lines()334 if len(out) >= v.height {335 break336 }337 }338 for len(out) < v.height {339 out = append(out, "")340 }341 return out342}