1// Package ui is the Bubble Tea front end. Inside the grid it behaves like2// Google Sheets (keys, selection, entry); around it, the control panel and3// mode indicator give it a 1-2-3 look.4package ui56import (7 "os"8 "time"910 tea "charm.land/bubbletea/v2"11 "charm.land/lipgloss/v2"1213 "src.rest/cat/012/internal/confine"14 "src.rest/cat/012/internal/sheet"15 "src.rest/cat/012/internal/telemetry"16 "src.rest/cat/012/internal/ui/findbar"17 "src.rest/cat/012/internal/ui/lineedit"18 "src.rest/cat/012/internal/ui/overlay"19 "src.rest/cat/012/internal/ui/rowtext"20 "src.rest/cat/012/internal/ui/tabstrip"21 "src.rest/cat/012/internal/ui/theme"22 "src.rest/cat/012/internal/ui/transfer"23)2425// mode is the state shown in the mode indicator.26type mode int2728const (29 modeReady mode = iota30 modeEnter // typing a new entry, which replaces the cell31 modeEdit // editing existing contents with a movable caret32 modePoint // arrowing to a cell or range to insert into a formula33 modeMenu // a menu, the palette or another overlay is open34 modePrompt35 modeError36)3738func (m mode) String() string {39 return [...]string{"READY", "ENTER", "EDIT", "POINT", "MENU", "", "ERROR"}[m]40}4142// Layout: three control panel lines (menu bar, formula bar, context43// line), the column header, the grid, and a status line.44const (45 menuLine = overlay.MenuLine // menu bar on the left, mode indicator on the right46 formulaLine = overlay.FormulaLine // name box, then the cell's contents or the entry47 contextLine = overlay.ContextLine // prompts, key hints and formula errors48 panelLines = 349 headerLine = panelLines50 gridTop = overlay.GridTop51 minRowHdrW = 6 // the row numbers up to 9999; see grid.hdrW52 nameBoxW = overlay.FormulaBarX - 1 // fits most ranges, e.g. "AA100:AB200", without jumping53)5455// doubleClick is the longest gap between two clicks that edits a cell.56const doubleClick = 400 * time.Millisecond5758const (59 // FrameRate is how many frames a second Bubble Tea may draw, its60 // maximum (tea.WithFPS). A key's answer waits for the next frame, so61 // this halves the wait at the default 60. The renderer writes a frame62 // only when the view changed, so an idle screen costs no more.63 FrameRate = 12064 // FrameInterval is the time between frames.65 FrameInterval = time.Second / FrameRate66)6768// Model is the root of the UI, the tea.Model Bubble Tea runs. It holds69// the file, the mode and the grid, owns a component for everything that70// takes input or draws a part of the screen, routes each message to the71// one it's for, and composes the screen from what they draw (panel.go,72// view.go, overlay.go).73type Model struct {74 // grid is the sheet shown, the active cell, the scroll position, the75 // window size and the selection; see grid.go.76 grid7778 // The file.79 filename string80 changed bool81 saved int // the sheet's StateID when last saved or loaded82 quitAfterSave bool // "Save and quit" is waiting for the save to finish83 disk stamp // the file on disk as last opened or saved here, to notice others' saves84 root confine.Root // where file names resolve; confined when served over SSH85 start *string // the file a served session opens first (OpenOnStart); nil once opened86 recovered string // the recovery file restored into this book, removed once it's saved: recovery.go87 offerKept bool // offer the recovery file kept for the file once started (OfferKept)8889 mode mode90 protectOK bool // an edit to a protected range was agreed to: protect.go91 undoOK bool // a change too large to undo was agreed to: undocost.go92 note string // feedback on the last action, e.g. "Undid: clear B3"93 warn string // like note, for something that went wrong, e.g. a macro's error94 errMsg string // the message ERROR mode shows95 // borderLine is the line Format > Borders draws with: layoutfmt.go.96 borderLine sheet.Line97 // borderColor is the color it draws in: alignfmt.go.98 borderColor sheet.Color99 // painted is what borders drew this frame, by role: gridlines.go.100 painted map[paintKey]string101 // shaded are roles with their escape codes, for glyphs drawn one at a102 // time (bars.go); kept for a frame as painted is.103 shaded map[*lipgloss.Style]theme.Shade104 // mergeLines are the lines merges show their values on, worked out105 // once a frame (see mergeText).106 mergeLines map[sheet.Rect][2]int107 // rowScratch holds the spans of the line of cells being drawn, which108 // each line lays out in again (see layoutLine).109 rowScratch rowtext.Scratch110111 // Components. Each owns its state and the handling of the input it112 // takes; Model routes messages to them and composes what they draw.113 line lineedit.Line // the edit line of entries, prompts and search fields: package lineedit114 entry entry // typing into a cell: entry.go115 point pointer // the cell or range pointed at in POINT mode and range prompts116 prompt *prompt // a question on the context line: prompt.go117 overlay overlay.Overlay // the open menu, picker or bar, if any (modeMenu): overlay.go118 mouse mouseState // drags, hover and double clicks: mouse.go119 tabs tabstrip.Strip // the sheet tabs and where each sheet was left: package tabstrip120 find *findbar.Bar // the last search, reopened by Ctrl+F: package findbar121 charts chartState // chart commands' target: charts.go122 copied clipboard // what Ctrl+V pastes: clipboard.go123 trace *trace // precedents or dependents being shown: trace.go124 tview *traceView // tracing that stays on, nil when off: traceview.go125 xfer transfer.Transfer // the import running and the file imported: package transfer126 pipe pipeState // standard input and output, for 012 - and --pipe: pipe.go127 jev *jevRunner // answers JEV functions; nil without an API key: jev.go128 term terminal // what the terminal supports: graphics.go129 prefs prefs // the settings in effect and the theme chosen: prefs.go130 session session // what outlasts the file open: the : history, the keyboard: keyboard.go131 share shareState // the room the workbook is shared in, in 012 serve: share.go132 agents agentState // agents working here, live mode: agents.go133134 vim vimState // a vim key sequence in progress: vim.go135 rec *recorder // a macro being recorded: macrorec.go136 macros macroState // a macro running, and trust in the file's macros: macrorun.go137 follow followState // the linked regions followed, and trust in them: follow.go138 src sourceState // the linked sources read, and their tabs: sources.go, srcscreen.go139 nb nbState // notebooks' views and the cells running: notebook.go, nbrun.go140 // out is the output whose grid this model is, nil for the141 // program's own model: nbgrid.go.142 out *outGrid143144 keyAt time.Time // when the key the next frame answers was pressed, for telemetry145 spans *telemetry.Trace // the spans open, which what the model starts nests in: trace.go146147 th theme.Theme148}149150// New returns a model editing s. filename may be empty.151func New(s *sheet.Sheet, filename string) *Model {152 m := &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(os.Getenv), charts: chartState{last: -1}, nb: nbState{runs: &nbRuns{}},153 spans: telemetry.NewTrace(telemetry.Parent{})}154 s.Book().SetTrace(m.spans)155 if filename != "" {156 m.disk = diskStamp(filename)157 }158 m.bookOpened()159 return m160}161162// TraceUnder nests every span the model starts under p for good: 012163// serve's session span holds each session's commands.164func (m *Model) TraceUnder(p telemetry.Parent) { m.spans.Enter(p) }165166// Init implements tea.Model. It asks the terminal for its background167// color, so the theme can adapt to light terminals, and holds the168// screen until it knows (termbg.go).169func (m *Model) Init() tea.Cmd {170 // jev.send starts any questions queued while loading the file.171 return tea.Batch(tea.Raw(shiftEscapeOn), m.term.probes(), m.term.awaitBackground(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startStdin(), m.startOpenCmd(), m.startNotebook(), m.syncFollowers())172}173174// Update implements tea.Model.175func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {176 if _, key := msg.(tea.KeyPressMsg); key && m.keyAt.IsZero() && telemetry.Enabled() {177 m.keyAt = time.Now()178 }179 if _, release := msg.(tea.KeyReleaseMsg); release && !m.held() {180 return m, nil // a release matters only to a key held: keyboard.go181 }182 if cmd, ok := m.importing(msg); ok {183 return m, cmd184 }185 if m.macroBusy(msg) {186 return m, nil187 }188 if m.rec != nil {189 m.rec.acted = false190 }191 before, beforeMode := *m.focus(), m.mode192 state := m.beginUpdate(msg)193 m.vimCapture(msg)194 var cmd tea.Cmd195 if mouse, ok := msg.(tea.MouseMsg); ok {196 var handled bool197 if cmd, handled = m.shellMouse(mouse); handled {198 msg = nil // taken by the menu bar, an overlay or a notebook, not the grid199 }200 }201 switch msg := msg.(type) {202 case nil: // taken above, cmd holds what it does203 case tea.WindowSizeMsg:204 m.width, m.height = msg.Width, msg.Height205 beforeMode = -1 // keep the focus visible after a resize206 case tea.KeyPressMsg:207 cmd = m.handleKey(msg)208 case tea.PasteMsg:209 cmd = m.pasteMsg(msg.Content)210 case tea.MouseClickMsg:211 cmd = m.handlePress(msg.Mouse())212 case tea.MouseMotionMsg:213 cmd = m.handleMotion(msg.Mouse())214 case tea.MouseReleaseMsg:215 cmd = m.handleRelease()216 case autoscrollMsg:217 cmd = m.handleAutoscroll()218 case tea.MouseWheelMsg:219 m.handleWheel(msg.Mouse())220 case savedMsg:221 if msg.err == nil && !msg.conflict {222 m.saved = m.sheet.StateID()223 }224 cmd = m.handleSaved(msg)225 case loadedMsg:226 m.handleLoaded(msg)227 case restoredMsg:228 m.restored(msg)229 case filesMsg:230 if m.prompt != nil {231 m.prompt.files = msg232 }233 case jevAnswerMsg:234 cmd = m.answerJEV(msg)235 case jevRecalcMsg:236 cmd = m.recalcAnswered()237 case transfer.ImportedMsg, transfer.TickMsg, exportedMsg:238 cmd = m.handleTransfer(msg)239 case followTickMsg, followMsg:240 cmd = m.handleFollow(msg)241 case sourceJobMsg, sourcePageMsg, sourceTickMsg:242 cmd = m.handleSource(msg)243 case roomMsg:244 cmd = m.roomKicked()245 case shareTickMsg:246 m.share.ticking = false247 case macroCallMsg:248 cmd = m.serveMacro(msg)249 case macroDoneMsg:250 cmd = m.finishMacro(msg.r, msg.done)251 case macroEditedMsg:252 m.macroEdited(msg)253 case tea.KeyReleaseMsg:254 cmd = m.keyReleased(msg)255 case tea.KeyboardEnhancementsMsg:256 m.keyboard(msg)257 case tea.ClipboardMsg:258 if m.vim.clipPaste {259 m.pasteClipboard(msg.Content)260 }261 default:262 var ok bool263 if cmd, ok = m.notebookMsg(msg); !ok && !m.handlePrefs(msg) {264 cmd = m.term.handle(msg)265 }266 }267 // Scroll only when the focus moves, so the mouse wheel can look around268 // without the view snapping back, as in Sheets.269 m.vimSettle()270 m.endUpdate(state)271 if f := *m.focus(); f != before || m.mode != beforeMode {272 m.scrollTo(f)273 }274 m.clampView()275 m.syncTrace()276 // Any edit may have queued JEV questions.277 // Chart images follow any change, see graphics.go.278 // Linked regions may have come, gone or changed: follow.go.279 return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans), m.syncSixel(msg), m.syncFollowers(), m.syncSources(), m.notebookSync(), m.shareTick(), m.agentAsks())280}281282// beginUpdate prepares for an input event and returns the sheet's state283// before it. Each user action outside a prompt ends any run of column284// width changes in the undo history, and clears the last action's note.285func (m *Model) beginUpdate(msg tea.Msg) int {286 switch msg.(type) {287 case tea.KeyPressMsg, tea.MouseClickMsg, tea.PasteMsg:288 m.note, m.warn = "", ""289 if _, key := msg.(tea.KeyPressMsg); !key {290 m.trace = nil // keys end a trace in handleKey291 }292 if m.mode != modePrompt {293 m.sheet.Seal()294 }295 }296 return m.sheet.StateID()297}298299// endUpdate reacts to edits made while handling an event: the modified300// flag follows the undo history, and any edit but a paste clears the copy301// marker, as in Sheets.302func (m *Model) endUpdate(state int) {303 m.observeRecording(state)304 if m.sheet.StateID() != state {305 m.changed = m.sheet.StateID() != m.saved306 // An edit, a sort or a filter may hide the active cell's row.307 m.cur.Row = m.visibleRow(m.cur.Row)308 if !m.copied.keep {309 m.copied.clearMark()310 }311 }312 m.copied.keep = false313}314315func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {316 if m.mouse.drag == dragFill {317 if k.String() == "esc" {318 m.cancelFill()319 }320 return nil // the mouse is busy filling321 }322 if m.macroKey(k) || m.traceKey(k) {323 return nil324 }325 switch m.mode {326 case modeReady:327 return m.readyKey(k)328 case modeEnter:329 return m.enterKey(k)330 case modeEdit:331 return m.editKey(k)332 case modePoint:333 return m.pointKey(k)334 case modeMenu:335 return m.overlay.Key(k)336 case modePrompt:337 return m.prompt.key(m, k)338 case modeError:339 m.errMsg = ""340 m.mode = modeReady341 }342 return nil343}344345func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {346 if m.sheet.IsNotebook() {347 return m.notebookReadyKey(k)348 }349 if v := m.srcView(); v != nil {350 return m.sourceReadyKey(v, k)351 }352 if m.prefs.vim {353 if cmd, ok := m.vimKeyPress(k); ok {354 return cmd355 }356 }357 key := k.String()358 if m.recordMove(key) {359 m.entry.tabbing = false360 return nil361 }362 if i := barMenuFor(key); i >= 0 {363 m.showBarMenu(i)364 return nil365 }366 if id, ok := keymap[canonicalKey(key)]; ok && (!commands[id].typed || commands[id].available(m)) {367 return m.runCommand(id)368 }369 if cmd, ok := m.runShortcut(key); ok {370 return cmd371 }372 if text := typed(k); text != "" {373 m.startEntry(modeEnter, text)374 }375 return nil376}377378// notebookReadyKey is a key on a notebook tab: the notebook's, or else379// the UI's (menus, other sheets, Save), which never types into a cell.380func (m *Model) notebookReadyKey(k tea.KeyPressMsg) tea.Cmd {381 if g := m.gridIn(); g != nil {382 return m.gridKey(g, k) // an output's grid entered: nbgridin.go383 }384 if cmd, ok := m.notebookKey(k); ok {385 return cmd386 }387 key := k.String()388 if i := barMenuFor(key); i >= 0 {389 m.showBarMenu(i)390 return nil391 }392 if id, ok := keymap[canonicalKey(key)]; ok && commands[id].available(m) {393 return m.runCommand(id)394 }395 cmd, _ := m.runShortcut(key)396 return cmd397}398399func (m *Model) handleWheel(mouse tea.Mouse) {400 const step = 3401 button := mouse.Button402 if mouse.Mod.Contains(tea.ModShift) { // Shift+wheel scrolls sideways403 switch button {404 case tea.MouseWheelUp:405 button = tea.MouseWheelLeft406 case tea.MouseWheelDown:407 button = tea.MouseWheelRight408 }409 }410 switch button {411 case tea.MouseWheelUp:412 m.top = m.stepRow(m.top, -step)413 case tea.MouseWheelDown:414 m.top = min(m.stepRow(m.top, step), sheet.MaxRows-m.scrollRows())415 case tea.MouseWheelLeft:416 m.left--417 case tea.MouseWheelRight:418 m.left = min(m.left+1, sheet.MaxCols-1)419 }420 m.clampView()421}422423// focus is the cell the viewport follows: the pointer while pointing, the424// moving corner while extending a selection, otherwise the active cell.425func (m *Model) focus() *sheet.Addr {426 switch {427 case m.mode == modePoint || m.pointing() || m.away():428 return &m.point.at429 case m.mouse.drag == dragFill:430 return &m.mouse.fillAt431 case m.selecting && (m.whole == wholeNone || m.vim.visual == visualRows):432 return &m.ext433 }434 return &m.cur435}436437// typed returns the printable text of a key press, if any.438func typed(k tea.KeyPressMsg) string { return lineedit.Typed(k) }439440func clamp(v, lo, hi int) int {441 return max(lo, min(v, hi))442}443444func clampAddr(a sheet.Addr) sheet.Addr {445 return sheet.Addr{446 Col: clamp(a.Col, 0, sheet.MaxCols-1),447 Row: clamp(a.Row, 0, sheet.MaxRows-1),448 }449}450451// fail shows msg in ERROR mode. A served session isn't told where its452// directory is on the server: errors name its files relative to it.453func (m *Model) fail(msg string) {454 m.mode = modeError455 m.errMsg = m.root.Scrub(msg)456}