cat / 012
clone

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

Errors: an explanation too long for the context line says the rest on F1

Dami committed e12837c
The context line cuts a long error explanation at the last sentence
that fits, with an F1 chip; F1 shows it whole in a box beside the cell
(or on a source's tab, why it can't be read). F1 elsewhere still opens
the shortcuts. The max-cells message over a source leads with the
remedy: raise max-cells to N, then what it is and what streams instead.
A test holds every explanation at 80 columns to being whole or readable
with F1.
commit
e12837c9408c2197df6933ec6ef3a1c981566772
parent
ecffc0b3ee5f4523b8c5caf3fdb213dc0ebc0300
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.8 of 3, Jev's estimate
13 files changed, +375 -41
Mdocs/formulas/README.md5+-
Mdocs/reference/config.md2+-
Mdocs/reference/keys.md2+-
Minternal/config/registry.go2+-
Minternal/functions/stream.go4+-
Minternal/paged/compute.go4+-
Minternal/ui/commands.go1-
Ainternal/ui/errbox.go176++++++++++++++++++++++++++++++
Ainternal/ui/errbox_test.go146+++++++++++++++++++++++++
Minternal/ui/help.go1-
Minternal/ui/links.go11--
Minternal/ui/panel.go19+--
Minternal/ui/srcscreen.go43++++++-
M docs/formulas/README.md +4 -1
M docs/reference/config.md +1 -1
M docs/reference/keys.md +1 -1
M internal/config/registry.go +1 -1
M internal/functions/stream.go +2 -2
M internal/paged/compute.go +2 -2
M internal/ui/commands.go +0 -1
A internal/ui/errbox.go +176 -0
@@ -0,0 +1,176 @@
1+package ui2+3+import (4+ "strings"5+6+ tea "charm.land/bubbletea/v2"7+ "charm.land/lipgloss/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/overlay"12+ "github.com/FelineStateMachine/012/internal/ui/theme"13+)14+15+// The context line explains the active cell's error, or why a linked16+// source's tab can't show its rows, in one line. An explanation longer17+// than the line is cut where a sentence ends when one fits, else with …,18+// and an F1 chip at the right says how to read the rest: F1 there opens19+// the whole explanation in a box (beside the cell, as a note shows on20+// hover), and anywhere else the keyboard shortcuts, as F1 at a word of a21+// notebook's cell does.22+23+func init() {24+ register(&command{id: "help.here", macro: macroNever, title: "Help here",25+ desc: "Explain the active cell's error in full, or else show the keyboard shortcuts",26+ run: func(m *Model) tea.Cmd {27+ if b := m.explained(); b != nil {28+ m.openOverlay(b)29+ return nil30+ }31+ return m.runCommand("help")32+ }})33+ keymap["f1"] = "help.here"34+}35+36+// explained is the box explaining what the context line explains, or37+// nil when it explains nothing.38+func (m *Model) explained() *errorBox {39+ if m.srcView() != nil {40+ if title, why := m.sourceTrouble(); why != "" {41+ return &errorBox{m: m, title: title, why: why, beside: false}42+ }43+ return nil44+ }45+ if v, why := m.errorWhy(); why != "" {46+ return &errorBox{m: m, title: v.Str + " in " + m.cur.String(), why: why, at: m.cur, beside: true}47+ }48+ return nil49+}50+51+// errorWhy is the active cell's error and why it shows it, "" when it52+// shows none or there's nothing to say.53+func (m *Model) errorWhy() (sheet.Value, string) {54+ if m.srcView() != nil || m.nbView() != nil {55+ return sheet.Value{}, ""56+ }57+ v := m.sheet.Value(m.cur)58+ if v.Kind != sheet.Error {59+ return v, ""60+ }61+ return v, m.sheet.ExplainError(m.cur)62+}63+64+// errorLine explains the active cell's error on the context line, e.g.65+// "#DIV/0! Division by zero in B3/0", and when that is too long for the66+// line, the part that fits and an F1 chip for the rest.67+func (m *Model) errorLine() (string, string) {68+ v, why := m.errorWhy()69+ if why == "" {70+ return "", ""71+ }72+ return m.explainLine(m.th.ErrorCell.Render(v.Str)+" ", why, m.th.Muted)73+}74+75+// explainLine is head then why in style, on one line: the part of why76+// that fits and an F1 chip for the rest when it doesn't.77+func (m *Model) explainLine(head, why string, style lipgloss.Style) (string, string) {78+ if ansi.StringWidth(head)+ansi.StringWidth(why) <= m.width {79+ return head + style.Render(why), ""80+ }81+ chip := m.th.KeyHints("F1", "more")82+ room := m.width - ansi.StringWidth(head) - ansi.StringWidth(chip) - 383+ return head + style.Render(cutExplanation(why, room)), chip84+}85+86+// spread puts right at the right edge after left, dropping it if there87+// isn't room for both.88+func (m *Model) spread(left, right string) string {89+ gap := m.width - ansi.StringWidth(left) - ansi.StringWidth(right)90+ if right == "" || gap < 3 {91+ return left92+ }93+ return left + strings.Repeat(" ", gap) + right94+}95+96+// cutExplanation is the start of why in at most room columns: its97+// sentences that fit, when at least one does, followed by …; else as98+// much of it as fits.99+func cutExplanation(why string, room int) string {100+ if room < 2 {101+ return ""102+ }103+ end := -1104+ for i := 0; i < len(why); i++ {105+ if (why[i] == '.' || why[i] == ';') && i+1 < len(why) && why[i+1] == ' ' && ansi.StringWidth(why[:i+1])+2 <= room {106+ end = i + 1107+ }108+ }109+ if end > 0 {110+ return why[:end] + " …"111+ }112+ return ansi.Truncate(why, room, "…")113+}114+115+// errorBox is an error's whole explanation in a box over the grid,116+// beside its cell or under the context line; any key closes it.117+type errorBox struct {118+ m *Model119+ title string120+ why string121+ at sheet.Addr122+ beside bool // beside the cell at, else at the grid's top left123+}124+125+const errorBoxID = "error"126+127+// errorBoxWidth is the widest the explanation's lines run, for reading.128+const errorBoxWidth = 48129+130+func (b *errorBox) Indicator() string { return "HELP" }131+132+// Layout places the box as a note's: right of the cell, or left of it133+// when there's no room, wrapped to its width.134+func (b *errorBox) Layout() []overlay.Box {135+ m := b.m136+ inner := min(errorBoxWidth, m.width-2)137+ lines := strings.Split(ansi.Wordwrap(b.why, inner-2, ""), "\n")138+ rows := make([]string, len(lines))139+ for i, line := range lines {140+ rows[i] = m.th.MenuBar.Render(theme.PadRight(" "+strings.TrimRight(line, " "), inner))141+ }142+ framed := m.th.Frame(inner, b.title, "", rows)143+ if !b.beside {144+ return []overlay.Box{{ID: errorBoxID, X: 0, Y: gridTop, Lines: framed}}145+ }146+ x, y := m.cellPos(b.at)147+ x += m.sheet.ColWidth(b.at.Col)148+ if x+inner+2 > m.width {149+ x = max(x-m.sheet.ColWidth(b.at.Col)-inner-2, 0)150+ }151+ x, y = m.clampBox(x, y, inner+2, len(framed))152+ return []overlay.Box{{ID: errorBoxID, X: x, Y: max(y, gridTop), Lines: framed}}153+}154+155+// Key closes the box, and a key that moves or runs a command does so156+// too, as it would have without the box.157+func (b *errorBox) Key(k tea.KeyPressMsg) tea.Cmd {158+ b.m.closeOverlay()159+ switch k.String() {160+ case "esc", "enter", "f1":161+ return nil162+ }163+ return b.m.handleKey(k)164+}165+166+// Mouse closes the box on a click anywhere.167+func (b *errorBox) Mouse(e overlay.MouseEvent) tea.Cmd {168+ if e.Kind == overlay.MousePress {169+ b.m.closeOverlay()170+ }171+ return nil172+}173+174+func (b *errorBox) Status() (string, string) {175+ return b.title, b.m.th.KeyHints("Esc", "close")176+}
A internal/ui/errbox_test.go +146 -0
@@ -0,0 +1,146 @@
1+package ui2+3+import (4+ "os"5+ "strings"6+ "testing"7+8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// errorCases are formulas whose errors are explained, short and long:14+// arithmetic, unknown names, lookups, a chain through other cells and15+// sheets, a cycle, and a function past max-cells over a linked source.16+var errorCases = []string{17+ "=1/0", `="a"+1`, "=SQRT(-1)", "=nosuchname+1", `=VLOOKUP("x",B20:C21,2,FALSE)`,18+ "='Quarterly results by region'!B3", "=nowhere!A1+'Another sheet that is not there at all'!B2",19+ "=E1", "=TEXTJOIN(\",\",TRUE,sales[region])", "=SORT(sales[amount])", "=SUM(sales[amount]*2)",20+}21+22+// TestErrorExplainedAt80Columns checks that at 80 columns the context23+// line explains every error whole, or says how to read the rest: F1,24+// which shows all of it.25+func TestErrorExplainedAt80Columns(t *testing.T) {26+ t.Chdir(t.TempDir())27+ defer sheet.SetMaxCells(0)28+ sheet.SetMaxCells(1000)29+ m := newModel()30+ linkSales(t, m, 3000)31+ m.runCommand("sheet.prev")32+ press(t, m, "<ctrl+home>")33+ s := m.sheet34+ long, err := s.Book().AddSheet("Quarterly results by region", -1)35+ if err != nil {36+ t.Fatal(err)37+ }38+ long.Set(addr("B3"), "=1/0")39+ for c, f := range map[string]string{"E1": "=F1", "F1": "=G1", "G1": "=H1", "H1": "=E1"} {40+ s.Set(addr(c), f) // a cycle41+ }42+ for i, f := range errorCases {43+ if err := s.Set(sheet.Addr{Row: i}, f); err != nil {44+ t.Fatalf("%s: %v", f, err)45+ }46+ }47+ pumpSources(t, m)48+ cut := 049+ for i, f := range errorCases {50+ m.cur = sheet.Addr{Row: i}51+ why := s.ExplainError(m.cur)52+ if why == "" {53+ t.Errorf("%s: %v unexplained", f, s.Value(m.cur))54+ continue55+ }56+ ctx := line(m, contextLine)57+ if strings.Contains(ctx, why) {58+ continue59+ }60+ cut++61+ if !strings.Contains(ctx, "…") || !strings.HasSuffix(ctx, "F1 more") {62+ t.Errorf("%s: cut without F1: %q (%s)", f, ctx, why)63+ continue64+ }65+ press(t, m, "<f1>")66+ box := errorBoxText(m)67+ if box != why {68+ t.Errorf("%s: F1 shows %q, want %q", f, box, why)69+ }70+ press(t, m, "<esc>")71+ if m.overlay != nil {72+ t.Fatalf("%s: Esc left the box open", f)73+ }74+ }75+ if cut == 0 {76+ t.Error("no explanation was long enough to cut")77+ }78+}79+80+// errorBoxText is the open error box's text, its lines joined.81+func errorBoxText(m *Model) string {82+ b, ok := m.overlay.(*errorBox)83+ if !ok {84+ return ""85+ }86+ var words []string87+ for _, l := range b.Layout()[0].Lines[1:] {88+ l = strings.Trim(ansi.Strip(l), "│ ")89+ if !strings.HasPrefix(l, "└") {90+ words = append(words, l)91+ }92+ }93+ return strings.Join(words, " ")94+}95+96+func TestF1ElsewhereShowsShortcuts(t *testing.T) {97+ m := newModel()98+ press(t, m, "<f1>")99+ if m.overlay == nil || strings.Contains(screen(m), " in A1") {100+ t.Fatalf("F1 on a blank cell: %T", m.overlay)101+ }102+ press(t, m, "<esc>", "=1/0", "<enter>", "<up>", "<f1>")103+ if box := errorBoxText(m); box != "Division by zero in 1/0" {104+ t.Errorf("F1 on #DIV/0!: %q", box)105+ }106+ press(t, m, "<down>")107+ if m.overlay != nil || m.cur != addr("A2") {108+ t.Errorf("a key closes the box and does what it does: %T at %v", m.overlay, m.cur)109+ }110+}111+112+func TestF1OnAMissingSource(t *testing.T) {113+ t.Chdir(t.TempDir())114+ m := newModel()115+ linkSales(t, m, 300)116+ if err := os.Remove("sales.parquet"); err != nil {117+ t.Fatal(err)118+ }119+ m.runCommand("data.source_reload")120+ pumpSources(t, m)121+ m.note = ""122+ if l := line(m, contextLine); !strings.Contains(l, "! sales.parquet: the file isn't there") {123+ t.Fatalf("context line %q", l)124+ }125+ press(t, m, "<f1>")126+ if box := errorBoxText(m); box != "! sales.parquet: the file isn't there" {127+ t.Errorf("F1 on the tab: %q", box)128+ }129+}130+131+func TestCutExplanation(t *testing.T) {132+ for _, c := range []struct {133+ why string134+ room int135+ want string136+ }{137+ {"Raise max-cells to 2,000 to SORT x (it's 1,000). SORT holds what it reads.", 60, "Raise max-cells to 2,000 to SORT x (it's 1,000). …"},138+ {"Raise max-cells to 2,000 to SORT x (it's 1,000). SORT holds what it reads.", 30, "Raise max-cells to 2,000 to S…"},139+ {"One. Two. Three is long.", 12, "One. Two. …"},140+ {"x", 1, ""},141+ } {142+ if got := cutExplanation(c.why, c.room); got != c.want || ansi.StringWidth(got) > c.room {143+ t.Errorf("cut %q to %d: %q, want %q", c.why, c.room, got, c.want)144+ }145+ }146+}
M internal/ui/help.go +0 -1
M internal/ui/links.go +0 -11
M internal/ui/panel.go +7 -12
M internal/ui/srcscreen.go +35 -8
@@ -138,21 +138,48 @@ func (m *Model) sourceBar(v *srcview.View) string {
138138 }139139 140140 // sourceContext is the context line on a source tab: what it is, how141-// many rows it has, and how they're ordered.141+// many rows it has, and how they're ordered, or why it can't be read.142142 func (m *Model) sourceContext() (string, string) {143143 info, _ := m.sheet.Source()144- name := filepath.Base(info.Source.Path)145- if info.Source.Table != "" {146- name += " " + info.Source.Table147- }144+ name := sourceLabel(info)148145 switch {149146 case info.Err != "":150- return m.th.Warning.Render("! " + name + ": " + info.Err), ""147+ return m.explainLine("", "! "+name+": "+info.Err, m.th.Warning)151148 case !info.Known:152149 return m.th.Muted.Render(sourceMark+" Opening ") + m.th.Key.Render(name) + m.th.Muted.Render("…"), ""153150 }154- left := m.th.Muted.Render(sourceMark+" ") + m.th.Key.Render(name) + m.th.Muted.Render(" "+m.sourceCounts(info))155- return left, m.th.KeyHints("Ctrl+↓", "last row", "Alt+D", "sort, filter")151+ head := m.th.Muted.Render(sourceMark+" ") + m.th.Key.Render(name) + m.th.Muted.Render(" ")152+ counts, hints := m.sourceCounts(info), m.th.KeyHints("Ctrl+↓", "last row", "Alt+D", "sort, filter")153+ if ansi.StringWidth(head)+ansi.StringWidth(counts)+ansi.StringWidth(hints)+3 <= m.width {154+ return head + m.th.Muted.Render(counts), hints155+ }156+ return m.explainLine(head, counts, m.th.Muted)157+}158+159+// sourceTrouble is what F1 shows on a source's tab: why it can't be160+// read, or its line when the context line cuts it; "" for nothing.161+func (m *Model) sourceTrouble() (title, why string) {162+ info, _ := m.sheet.Source()163+ name := sourceLabel(info)164+ switch {165+ case info.Err != "":166+ return name, "! " + name + ": " + info.Err167+ case !info.Known:168+ return "", ""169+ }170+ if _, chip := m.sourceContext(); chip == m.th.KeyHints("F1", "more") {171+ return name, m.sourceCounts(info)172+ }173+ return "", ""174+}175+176+// sourceLabel names a source's file, and its table when it has one.177+func sourceLabel(info sheet.SourceInfo) string {178+ name := filepath.Base(info.Source.Path)179+ if info.Source.Table != "" {180+ name += " " + info.Source.Table181+ }182+ return name156183 }157184 158185 // sourceMark is the glyph a source's tab shows beside its name and on