1package ui23import (4 "math"5 "path/filepath"6 "slices"7 "strconv"8 "strings"9 "time"1011 tea "charm.land/bubbletea/v2"12 "charm.land/lipgloss/v2"13 "github.com/charmbracelet/x/ansi"1415 "src.rest/cat/012/internal/numfmt"16 "src.rest/cat/012/internal/sheet"17 "src.rest/cat/012/internal/telemetry"18 "src.rest/cat/012/internal/ui/overlay"19 "src.rest/cat/012/internal/ui/suggest"20 "src.rest/cat/012/internal/ui/tabstrip"21 "src.rest/cat/012/internal/ui/theme"22)2324// The control panel is three lines: the menu bar with the mode indicator,25// the formula bar, and the context line. Below the grid, the status line26// shows the file and selection statistics.2728// frame draws the screen (View).29func (m *Model) frame() tea.View {30 if telemetry.Enabled() {31 defer m.timeFrame(time.Now())32 }33 clear(m.painted) // the theme may have changed since the last frame34 m.nb.frame++ // and outputs' grids clear theirs: nbgrid.go35 clear(m.mergeLines)36 clear(m.shaded)37 m.frameShare()38 var lines []string39 if v := m.nbView(); v != nil {40 lines = m.notebookLines(v)41 } else if v := m.srcView(); v != nil {42 lines = m.sourceLines(v)43 } else {44 lines = []string{m.menuBarLine(), m.formulaBar(), m.contextLineText(), m.headerRow()}45 for _, b := range m.bands() {46 lines = m.appendBand(lines, b)47 }48 }49 for len(lines) < gridTop+m.visibleRows() {50 lines = append(lines, "")51 }52 lines = append(lines, m.statusLine())53 for i, l := range lines {54 lines[i] = ansi.Truncate(l, m.width, "")55 }56 m.drawBars(lines)5758 content := strings.Join(lines, "\n")59 if boxes := m.floating(); len(boxes) > 0 || hasCharts(m) {60 content = m.compose(content, boxes)61 }62 content = m.fillScreen(content)63 m.term.six.noteFrame(content)64 v := tea.NewView(content)65 v.AltScreen = true66 v.MouseMode = tea.MouseModeAllMotion // hover feedback; see mouse.go67 v.ReportFocus = true // notifications only when the window is in the background68 // Key releases, for keys held to preview: keyboard.go.69 v.KeyboardEnhancements.ReportEventTypes = true70 v.WindowTitle = "012 - " + m.displayName()71 if m.jev.busy() != "" {72 // Terminals that support it (OSC 9;4) show activity in the tab.73 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, 0)74 }75 if bar := m.xfer.ProgressBar(); bar != nil {76 v.ProgressBar = bar77 }78 if m.changed {79 v.WindowTitle += " (modified)"80 }81 if x, y, ok := m.cursorPos(); ok {82 v.Cursor = tea.NewCursor(x, y)83 v.Cursor.Shape = tea.CursorBar84 }85 return v86}8788// drawBars draws the menu bar, formula bar, context line, column header89// row and status line as full-width bands in the theme's bar roles,90// before overlays are composited over them.91func (m *Model) drawBars(lines []string) {92 bands := []struct {93 line int94 role lipgloss.Style95 }{96 {menuLine, m.th.MenuBarRow}, {formulaLine, m.th.FormulaBarRow}, {contextLine, m.th.ContextRow},97 {headerLine, m.th.ColumnHeaderRow}, {len(lines) - 1, m.th.StatusBarRow},98 }99 for _, b := range bands {100 if b.line == headerLine && m.nbView() != nil {101 continue // a notebook's cells start under the context line102 }103 if b.line < len(lines) {104 lines[b.line] = theme.Fill(lines[b.line], m.width, b.role)105 }106 }107}108109// fillScreen draws everything else on the theme's background, out to the110// edges, when the theme has one of its own.111func (m *Model) fillScreen(content string) string {112 if isEmpty(m.th.Screen) {113 return content114 }115 lines := strings.Split(content, "\n")116 for len(lines) < m.height {117 lines = append(lines, "")118 }119 for i, l := range lines {120 lines[i] = theme.Fill(l, m.width, m.th.Screen)121 }122 return strings.Join(lines, "\n")123}124125func isEmpty(s lipgloss.Style) bool {126 _, noBg := s.GetBackground().(lipgloss.NoColor)127 _, noFg := s.GetForeground().(lipgloss.NoColor)128 return noBg && noFg129}130131// timeFrame reports a frame begun at start to telemetry, with how long132// ago the key press it answers came in.133func (m *Model) timeFrame(start time.Time) {134 var key time.Duration135 if !m.keyAt.IsZero() {136 key, m.keyAt = time.Since(m.keyAt), time.Time{}137 }138 telemetry.Frame(time.Since(start), key)139}140141func (m *Model) displayName() string {142 if m.filename == "" && m.xfer.Source != "" {143 return filepath.Base(m.xfer.Source)144 }145 if m.filename == "" && m.share.seat != nil && !m.share.local && strings.HasPrefix(m.share.seat.Key(), "@") {146 return m.share.seat.Key() // a named room not saved yet147 }148 if m.filename == "" {149 return "untitled"150 }151 return filepath.Base(m.filename)152}153154// indicator is the mode shown at the top right.155func (m *Model) indicator() string {156 switch {157 case m.xfer.Busy():158 return "WAIT"159 case m.macros.run != nil:160 return "CMD" // 1-2-3's indicator while a macro runs161 case m.overlay != nil:162 return m.overlay.Indicator()163 case m.mode == modePrompt:164 return m.prompt.indicator165 case m.nbView() != nil && m.mode == modeReady:166 return m.notebookIndicator(m.nbView())167 case m.srcView() != nil && m.mode == modeReady:168 return "SOURCE"169 case m.vimActive() && m.visual() != visualNone:170 return "VISUAL"171 case m.vimActive():172 return "NORMAL"173 }174 return m.mode.String()175}176177// menuBarLine is the menu bar with the mode indicator on the right.178func (m *Model) menuBarLine() string {179 left := m.menuBarTitles()180 ind := m.th.Indicator.Render(" " + m.indicator() + " ")181 if m.rec != nil {182 ind = m.th.Recording.Render(" REC ") + " " + ind183 }184 gap := m.width - ansi.StringWidth(left) - ansi.StringWidth(ind)185 return left + strings.Repeat(" ", max(gap, 1)) + ind186}187188// formulaBarTextX is the screen column where the formula bar's text (the189// cell's contents or the entry being typed) starts, after the name box.190func formulaBarTextX() int { return nameBoxW + 1 }191192// formulaBar shows the name box (the active cell, or the selected range)193// and the active cell's contents as typed. While typing, the entry is194// edited here with the terminal cursor, and shown in the cell too.195func (m *Model) formulaBar() string {196 if v := m.nbView(); v != nil && m.mode != modePrompt {197 if b, ok := m.overlay.(overlay.Bar); ok {198 return m.th.Header.Render(theme.PadRight(" ", nameBoxW)) + " " + b.FormulaBar()199 }200 if g := m.gridIn(); g != nil {201 if _, ok := g.child.overlay.(overlay.Bar); ok {202 return g.child.formulaBar() // its find bar's field203 }204 }205 return m.notebookToolbar(v)206 }207 if v := m.srcView(); v != nil && m.mode != modePrompt {208 return m.sourceBar(v)209 }210 name := m.cur.String()211 if m.away() {212 name = sheet.Qualified(m.entry.home.Name(), sheet.Rect{From: m.cur, To: m.cur})213 }214 if m.hasRange() && (m.mode == modeReady || m.mode == modeMenu) {215 name = m.selection().String()216 }217 if n, ok := m.namedSelection(); ok && (m.mode == modeReady || m.mode == modeMenu) {218 name = ansi.Truncate(n, nameBoxW-1, "…")219 }220 box := m.th.Header.Render(theme.PadRight(" "+ansi.Truncate(name, nameBoxW-1, "…"), nameBoxW)) + " "221 switch m.mode {222 case modeEnter, modeEdit:223 return box + m.line.Text()224 case modePoint:225 return box + m.entry.prefix + m.th.Selection.Render(m.pointRef()) + m.entry.suffix226 }227 if b, ok := m.overlay.(overlay.Bar); ok {228 return box + b.FormulaBar()229 }230 if anchor, ok := m.sheet.SpillAnchor(m.cur); ok && m.sheet.HasSpills() {231 // A spilled cell shows the formula it spills from, dimmed, as232 // Sheets does.233 return box + m.th.Muted.Render(m.shownEntry(m.sheet.Cell(anchor).Input))234 }235 if c := m.sheet.Cell(m.cur); c != nil {236 return box + m.shownEntry(c.Input)237 }238 return box239}240241// contextLineText says what's going on: a prompt, a formula error, or the242// keys that apply in the current mode.243func (m *Model) contextLineText() string {244 var left, right string245 switch {246 case m.xfer.Busy():247 left = m.xfer.Line(&m.th)248 case m.mouse.drag == dragResize:249 left = m.th.Key.Render("Column "+sheet.ColName(m.mouse.resizeCol)) + m.th.Muted.Render(" width ") +250 strconv.Itoa(m.sheet.ColWidth(m.mouse.resizeCol)) + m.th.Muted.Render(" double-click the border to fit")251 case m.mouse.drag == dragRowResize:252 h := m.shape(m.mouse.resizeRow).Lines253 left = m.th.Key.Render("Row "+strconv.Itoa(m.mouse.resizeRow+1)) + m.th.Muted.Render(" height ") +254 strconv.Itoa(h) + m.th.Muted.Render(plural(h, " line", " lines")+" double-click the corner to fit")255 case m.mouse.drag == dragFill:256 left = m.fillLine()257 case m.entry.hint != "":258 left = m.th.Warning.Render(m.entry.hint)259 case m.macros.run != nil:260 left, right = "Running "+m.macros.run.name+"…", m.th.KeyHints("Esc", "stop")261 case m.vimActive() && (m.vim.pending() != "" || m.visual() != visualNone):262 left = m.vimLine()263 case m.mode == modeReady:264 left, right = m.readyContext()265 case m.mode == modeMenu:266 if o, ok := m.overlay.(overlay.Liner); ok {267 left, right = o.ContextLine()268 }269 case m.mode == modePrompt:270 left, right = m.prompt.line(m)271 case m.mode == modePoint:272 prefix := []rune(m.entry.prefix)273 var ok bool274 if left, right, ok = suggest.SignatureLine(&m.th, m.width, m.storedFormula(prefix), len(prefix), m.locale().ArgSep(), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {275 left = m.th.KeyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")276 }277 case (m.mode == modeEnter || m.mode == modeEdit) && m.line.IsFormula() && m.inTable():278 left, right, _ = suggest.TableLine(&m.th, m.width, m.entrySheet(), m.storedFormula(m.line.Buf), m.line.Pos, m.th.KeyHints("Enter", "accept", "Esc", "cancel"))279 case (m.mode == modeEnter || m.mode == modeEdit) && m.line.IsFormula() && m.inFunction():280 left, right, _ = suggest.SignatureLine(&m.th, m.width, m.storedFormula(m.line.Buf), m.line.Pos, m.locale().ArgSep(), m.th.KeyHints("Enter", "accept", "Esc", "cancel"))281 case m.mode == modeEnter && m.line.IsFormula():282 left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "pick cells after an operator", "Esc", "cancel")283 case m.mode == modeEnter:284 left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "accept and move", "Esc", "cancel")285 case m.mode == modeEdit:286 left = m.th.KeyHints("Enter", "accept", "Left/Right", "move the caret", "Esc", "cancel")287 }288 return m.spread(left, right)289}290291// readyContext is the context line in READY: a notebook's, a trace's, or292// the first thing to say about the active cell.293func (m *Model) readyContext() (left, right string) {294 switch {295 case m.nbView() != nil:296 return m.notebookContext(m.nbView())297 case m.srcView() != nil && m.note == "" && m.warn == "":298 return m.sourceContext()299 case m.trace != nil:300 return m.trace.line(&m.th, m.width, m.sheet)301 }302 if left = m.readyLine(); left != "" {303 return left, ""304 }305 if m.tview != nil {306 return m.traceLine()307 }308 if left = m.jev.line(&m.th, m.sheet.RemoteCalls(m.cur)); left != "" {309 return left, ""310 }311 if left, right = m.errorLine(); left != "" {312 return left, right313 }314 for _, f := range []func() string{m.shareLine, m.validationLine, m.noteLine, m.spillLine, m.regionLine, m.tableLine, m.recordingLine} {315 if left = f(); left != "" {316 return left, ""317 }318 }319 return "", ""320}321322// cursorPos returns where the terminal cursor goes: in the formula bar323// while typing an entry, on the context line in a text prompt, or in an324// overlay's search field.325func (m *Model) cursorPos() (x, y int, ok bool) {326 if o, isText := m.overlay.(overlay.Text); isText {327 x, y = o.Cursor()328 return x, y, x >= 0329 }330 if v := m.nbView(); v != nil && m.mode == modeReady && m.overlay == nil {331 if g := m.gridIn(); g != nil {332 return m.gridCursor(g, v)333 }334 return m.notebookCursor(v)335 }336 switch {337 case m.mode == modeEnter, m.mode == modeEdit:338 return formulaBarTextX() + ansi.StringWidth(m.line.Head()), formulaLine, true339 case m.mode == modePrompt && !m.pointing():340 return ansi.StringWidth(m.prompt.prefix() + m.prompt.head(m)), contextLine, true341 }342 return 0, 0, false343}344345func (m *Model) statusLine() string {346 line, _ := m.statusLayout()347 return line348}349350// statusLayout is the status line and where its sheet tabs are. From the351// left: the tabs, a divider, the file name and its state, then selection352// statistics or the ways in to everything else on the right. While a353// menu, picker or suggestion list is open, it says what the highlighted354// item does instead.355func (m *Model) statusLayout() (string, []tabstrip.Span) {356 if m.mode == modeError {357 return m.th.Error.Render(m.errMsg) + m.th.Muted.Render(" press any key"), nil358 }359 if g := m.gridIn(); g != nil && g.child.mode == modeError {360 return g.child.statusLayout()361 }362 if m.xfer.Busy() {363 return m.spread(m.xfer.Status(&m.th, m.width)), nil364 }365 if line, ok := m.floatingStatus(); ok {366 return line, nil367 }368 return m.fileStatus()369}370371// floatingStatus is what the highlighted item of the open overlay or the372// formula suggestions does, and the keys that apply.373func (m *Model) floatingStatus() (string, bool) {374 desc, keys, floating := m.entry.assist.Status(m.host())375 if g := m.gridIn(); g != nil && m.overlay == nil && g.child.overlay != nil {376 return g.child.floatingStatus() // the grid's menu or filter377 }378 if m.overlay != nil {379 desc, keys = m.overlay.Status()380 floating = true381 }382 if !floating || desc == "" && keys == "" {383 return "", false384 }385 if room := m.width - ansi.StringWidth(keys) - 3; room >= 12 {386 desc = ansi.Truncate(desc, room, "…")387 }388 return m.spread(desc, keys), true389}390391// fileStatus is the tabs, the file and the right side. When space runs392// out, the right side gives up detail first, then the file name, then393// tabs scroll: first every tab is tried, then half the line of them, then394// just the one shown.395func (m *Model) fileStatus() (string, []tabstrip.Span) {396 v := m.tabView()397 state := m.statusState()398 infos := []string{m.displayName() + state, strings.TrimPrefix(state, " ")}399 rights := m.statusRights()400 for _, need := range []int{v.FullWidth(), min(v.FullWidth(), m.width/2), v.MinWidth()} {401 for _, info := range infos {402 if info != "" {403 info = m.th.FrozenLine.Render(" │ ") + info // like a tmux pane border404 }405 if line, spans, ok := m.statusFits(v, need, info, rights); ok {406 return line, spans407 }408 }409 }410 return "", nil // not reached: the last choice has no right side or info411}412413// statusFits lays out the status line with info after the tabs and the414// most detailed of rights that leaves the tabs need columns.415func (m *Model) statusFits(v tabstrip.View, need int, info string, rights []string) (string, []tabstrip.Span, bool) {416 for _, right := range rights {417 room := m.width - ansi.StringWidth(info)418 if right != "" {419 room -= ansi.StringWidth(right) + 3420 }421 if room < need && (right != "" || info != "") {422 continue423 }424 tabs, spans := m.tabs.Layout(&m.th, v, room)425 left := tabs + info426 gap := max(m.width-ansi.StringWidth(left)-ansi.StringWidth(right), 1)427 return left + strings.Repeat(" ", gap) + right, spans, true428 }429 return "", nil, false430}431432// statusState is what the status line says about the file after its433// name: modified, decimal arithmetic, a circular reference, rows hidden434// by the filter, JEV at work. Each part starts with two spaces.435func (m *Model) statusState() string {436 var b strings.Builder437 if m.changed {438 b.WriteString(m.th.Muted.Render(" modified"))439 }440 b.WriteString(m.peersStatus())441 if m.book().Decimal() {442 b.WriteString(m.th.Muted.Render(" decimal"))443 }444 if m.book().Circular {445 b.WriteString(" " + m.th.Warning.Render("Circular reference"))446 }447 if n := m.sheet.HiddenRows(); n > 0 {448 b.WriteString(" " + m.th.Hint.Render("Filter hides "+rowCount(n)))449 }450 if busy := m.jev.busy(); busy != "" {451 b.WriteString(" " + m.th.Hint.Render(busy))452 }453 b.WriteString(m.pipeStatus())454 return b.String()455}456457// statusRights are the choices for the right of the status line, most458// detailed first, ending with nothing: statistics of the selection, or459// the keys that open the palette, the shortcuts and the menu.460func (m *Model) statusRights() []string {461 var out []string462 if g := m.gridIn(); g != nil && g.child.hasRange() {463 return g.child.statusRights() // the grid's selection464 }465 if m.hasRange() && m.mode == modeReady {466 r := m.selection()467 st := m.sheet.RangeStats(r)468 rng := m.th.Key.Render(m.rangeLabel(r))469 sum, avg := "", ""470 if st.Nums > 0 {471 sum = m.th.Muted.Render("Sum ") + m.fmtStat(st.Sum)472 avg = m.th.Muted.Render("Avg ") + m.fmtStat(st.Sum/float64(st.Nums))473 }474 count := m.th.Muted.Render("Count ") + strconv.Itoa(st.Count)475 // As many stats as fit: Avg goes first, then Sum, then Count.476 for _, parts := range [][]string{{rng, sum, avg, count}, {rng, sum, count}, {rng, count}, {rng}} {477 out = append(out, strings.Join(slices.DeleteFunc(parts, func(p string) bool { return p == "" }), " "))478 }479 return append(out, "")480 }481 pairs := []string{m.shortcut("palette"), "search", m.shortcut("help"), "shortcuts", m.shortcut("menu"), "menu"}482 if m.prefs.vim {483 pairs = append([]string{":", "command"}, pairs...)484 }485 for ; len(pairs) > 0; pairs = pairs[:len(pairs)-2] {486 out = append(out, m.th.KeyHints(pairs...))487 }488 return append(out, "")489}490491// fmtStat formats a status line statistic with at most two decimals, as492// Sheets does.493func (m *Model) fmtStat(v float64) string {494 return numfmt.Localize(strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64), m.locale())495}