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
| M | docs/ | 5 | +- |
| M | docs/ | 2 | +- |
| M | docs/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 1 | - |
| A | internal/ | 176 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 146 | +++++++++++++++++++++++++ |
| M | internal/ | 1 | - |
| M | internal/ | 11 | -- |
| M | internal/ | 19 | +-- |
| M | internal/ | 43 | ++++++- |
@@ -69,7 +69,10 @@ give the first error in the range. Errors use Sheets' codes:
6969 Error cells are red with a curly underline, and the context line explains the7070 active cell's error and where it came from, e.g.7171 `#DIV/0! From B3: division by zero in B5/0`, or names the chain of a72-circular reference.72+circular reference. An explanation longer than the line stops at the last73+sentence that fits, with `…` and an F1 chip at the right: F1 shows the74+whole of it in a box beside the cell, and any key closes it. F1 on a75+cell without an error shows the keyboard shortcuts.7376 7477 ## Regular expressions7578 @@ -158,7 +158,7 @@ How new sheets and files without a locale of their own are typed and shown: deci
158158 159159 #### `max-cells`160160 161-The most cells an import keeps, and a paste or fill writes at once. Numbers and text take 20 to 60 bytes a cell and formulas about 750, so the default of ten million cells of data is 200 to 600 MB. Imports keep whole rows up to the budget and say how many they left out; larger pastes and fills are refused. A function that holds what it reads (MEDIAN, SORT) is given no more of a linked source. The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is.161+The most cells an import keeps, and a paste or fill writes at once. Numbers and text take 20 to 60 bytes a cell and formulas about 750, so the default of ten million cells of data is 200 to 600 MB. Imports keep whole rows up to the budget and say how many they left out; larger pastes and fills are refused. A function that holds what it reads (SORT, TEXTJOIN) is given no more of a linked source. The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is.162162 163163 | | |164164 |---|---|@@ -126,7 +126,7 @@ keeping its cells.
126126 | F10, Alt+F, Alt+E, Alt+V, Alt+I, Alt+O, Alt+D, Alt+H | Open a menu; arrows move, Enter runs, Esc closes |127127 | Ctrl+K, Alt+/, Ctrl+Shift+P | Search the menus: find and run any command |128128 | Shift+F10, right-click | The cell, column or row menu |129-| F1, Ctrl+/ | Keyboard shortcuts |129+| F1, Ctrl+/ | Keyboard shortcuts; F1 on a cell showing an error, or on a linked source's tab that can't be read, explains it in full ([Values and errors](../formulas/README.md#values-and-errors)) |130130 131131 In a workbook shared over `012 serve`, **File > Who's here** (or `who` in the132132 palette) lists the others and goes to one's cell, and **File > Hand over@@ -116,7 +116,7 @@ var Options = []Option{
116116 "take 20 to 60 bytes a cell and formulas about 750, so the default of ten million cells of " +117117 "data is 200 to 600 MB. Imports keep whole rows " +118118 "up to the budget and say how many they left out; larger pastes and fills are refused. " +119- "A function that holds what it reads (MEDIAN, SORT) is given no more of a linked source. " +119+ "A function that holds what it reads (SORT, TEXTJOIN) is given no more of a linked source. " +120120 "The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is."},121121 122122 {Name: "shell", Kind: Enum, Group: GroupShell, Default: "ask", Values: []string{"off", "ask", "on"}, Env: []string{"O12_SHELL"}, Live: true,@@ -191,8 +191,8 @@ func overBudget(c StreamCall, book Book, budget int) string {
191191 if cells <= budget {192192 return ""193193 }194- return fmt.Sprintf("%s would hold %s cells of %s, past max-cells (%s): SUMIFS, XLOOKUP and the like stream them instead",195- c.Fn, grouped(cells), biggest, grouped(budget))194+ return fmt.Sprintf("Raise max-cells to %s to %s %s. It is %s, and %s holds what it reads; SUMIFS, MEDIAN, XLOOKUP and the like stream a source of any size.",195+ grouped(cells), c.Fn, biggest, grouped(budget), c.Fn)196196 }197197 198198 // grouped is n with thousands separators: 12,000,000.@@ -22,8 +22,8 @@ func (b *book) rangeValues(name string, r sheet.Rect, budget int) sheet.SourceAn
2222 rows, cols := part.To.Row-part.From.Row+1, part.To.Col-part.From.Col+12323 if rows > budget/cols {2424 return sheet.SourceAnswer{V: sheet.ErrValue, Why: fmt.Sprintf(25- "Reading %s whole takes %s cells, more than max-cells (%s): SUM, COUNTIFS, XLOOKUP and the like read a source of any size",26- s.name, grouped(rows*cols), grouped(budget))}25+ "Raise max-cells to %s to read %s whole. It is %s; SUM, COUNTIFS, XLOOKUP and the like read a source of any size.",26+ grouped(rows*cols), s.name, grouped(budget))}2727 }2828 a := functions.NewArray(rows, cols)2929 addrs, vals := make([]sheet.Addr, 1024), make([]sheet.Value, 1024)@@ -116,7 +116,6 @@ func register(cmds ...*command) {
116116 // keymap binds READY-mode keys to command IDs, following Google Sheets117117 // where it has a shortcut. Movement and typing are handled separately.118118 var keymap = map[string]string{119- "f1": "help",120119 "ctrl+/": "help",121120 "enter": "edit",122121 "f2": "edit",@@ -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+}@@ -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+}@@ -35,7 +35,6 @@ func init() {
3535 return nil3636 }},3737 )38- keymap["f1"] = "help"3938 keymap["ctrl+/"] = "help"4039 }4140 @@ -59,14 +59,3 @@ func (m *Model) spillLine() string {
5959 }6060 return ""6161 }62-63-// errorLine explains the active cell's error on the context line, e.g.64-// "#DIV/0! Division by zero in B3/0".65-func (m *Model) errorLine() string {66- v := m.sheet.Value(m.cur)67- why := m.sheet.ExplainError(m.cur)68- if v.Kind != sheet.Error || why == "" {69- return ""70- }71- return m.th.ErrorCell.Render(v.Str) + " " + m.th.Muted.Render(why)72-}@@ -305,8 +305,13 @@ func (m *Model) readyContext() (left, right string) {
305305 if m.tview != nil {306306 return m.traceLine()307307 }308- for _, f := range []func() string{func() string { return m.jev.line(&m.th, m.sheet.RemoteCalls(m.cur)) },309- m.errorLine, m.shareLine, m.validationLine, m.noteLine, m.spillLine, m.regionLine, m.tableLine, m.recordingLine} {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} {310315 if left = f(); left != "" {311316 return left, ""312317 }@@ -314,16 +319,6 @@ func (m *Model) readyContext() (left, right string) {
314319 return "", ""315320 }316321 317-// spread puts right at the right edge after left, dropping it if there318-// isn't room for both.319-func (m *Model) spread(left, right string) string {320- gap := m.width - ansi.StringWidth(left) - ansi.StringWidth(right)321- if right == "" || gap < 3 {322- return left323- }324- return left + strings.Repeat(" ", gap) + right325-}326-327322 // cursorPos returns where the terminal cursor goes: in the formula bar328323 // while typing an entry, on the context line in a text prompt, or in an329324 // overlay's search field.@@ -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