cat / 012
clone

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

Tables in the UI: Convert to table, Data > Table, banding and header roles, suggestions

Dami committed e4667c6
Format > Convert to table (Ctrl+Alt+T) names the selection or the data
around the cursor; Data > Table renames, resizes, styles and removes the
table the cursor is in and lists every table, notebook outputs too.
Filters and sorts started in a table take its range. Header rows draw in
TableHeader (bold, underlined) and banded rows in TableBand. Formula
suggestions offer tables with named ranges and, inside the brackets,
their columns and items, with the columns on the context line.
commit
e4667c6a8acecb9eab0e4fceb6c6a1e294aebb14
parent
57cabec355f409d36da890d06bf3ae23733a7902
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.9 of 3, Jev's estimate
21 files changed, +855 -24
Mdocs/reference/keys.md5+-
Minternal/sheet/formula.go4+
Minternal/sheet/tablebind.go62+++++++
Minternal/ui/assist.go7+
Minternal/ui/filter.go9+-
Minternal/ui/formula/formula.go47+++++-
Minternal/ui/formula/formula_test.go25+++
Minternal/ui/hosts.go1+
Minternal/ui/menubar.go3+-
Minternal/ui/monochrome_test.go22++
Minternal/ui/nbsend.go2+-
Minternal/ui/panel.go4+-
Minternal/ui/sort.go5+-
Minternal/ui/suggest/suggest.go25++-
Minternal/ui/suggest/suggest_test.go42+++++
Ainternal/ui/suggest/tables.go113++++++++++++
Ainternal/ui/tables.go280++++++++++++++++++++++++++++++
Ainternal/ui/tables_test.go180+++++++++++++++++++
Minternal/ui/theme/scheme.go6+
Minternal/ui/theme/theme.go25++-
Minternal/ui/view.go12+-
M docs/reference/keys.md +4 -1
@@ -96,6 +96,7 @@ The rest of the formats are in the Format menu.
9696 | Space | Check or uncheck the selected checkboxes (Insert > Checkbox); elsewhere Space starts an entry | [Conditional formatting and data validation](../sheets/rules.md#data-validation) |9797 | Alt+, Alt+. | Trace precedents, dependents: highlight the cells a formula reads, or the formulas that read the cell, and jump to the first; again for the next, Esc to go back (Excel's Ctrl+[ and Ctrl+], which terminals send as Esc) | [Building formulas](../formulas/building.md) |9898 | Alt+Shift+F | Frequency table of the active column on a new sheet, as VisiData's Shift+F | [Pivot tables](../sheets/pivots.md#frequency-tables) |99+| Ctrl+Alt+T | Make the selection, or the data around the active cell, a table whose first row names its columns (Format > Convert to table) | [Tables](../sheets/tables.md) |99100 | Shift+F2 | Add or edit the active cell's note; Alt+Enter or Shift+Enter starts a new line | [Notes and protection](../sheets/notes-protection.md#notes) |100101 101102 Tools with no key of their own have their keys on screen while they're
@@ -107,7 +108,9 @@ open, and in their docs: sorting by several columns
107108 ([Charts](../sheets/charts.md)) and linked files (Data > Linked file, where108109 Enter keeps every row and L the last ones:109110 [Following files](../files/following.md)). In Data > Named ranges, Enter goes to a110-range, F2 renames or repoints it and Ctrl+D deletes it.111+range, F2 renames or repoints it and Ctrl+D deletes it; in Data > Table >112+Tables, Enter goes to a table, F2 renames it and Ctrl+D removes it,113+keeping its cells.111114 112115 ## Files, menus and help113116
M internal/sheet/formula.go +4 -0
M internal/sheet/tablebind.go +62 -0
@@ -1,6 +1,7 @@
11 package sheet22 33 import (4+ "slices"45 "strings"56 67 "github.com/FelineStateMachine/012/internal/formula"
@@ -272,3 +273,64 @@ func (w *Workbook) rewriteUsers(k string, rw formula.Rewriter) {
272273 }273274 }274275 }276+277+// TableInfo is a table as formulas find it: a table's, or a region's.278+type TableInfo struct {279+ Name string280+ Sheet *Sheet281+ // Range is its cells, header row included, when Shown.282+ Range Rect283+ shown bool284+ Cols []string285+ // Region is set on a region's table, which its source names and286+ // shapes: the commands that change tables leave it alone.287+ Region bool288+}289+290+// Shown reports whether the table has cells: a region's may have none291+// yet.292+func (t TableInfo) Shown() bool { return t.shown }293+294+// TableInfos returns every table formulas can read, sheet by sheet:295+// each sheet's tables in the order they were made, then its regions'.296+func (w *Workbook) TableInfos() []TableInfo {297+ var out []TableInfo298+ for _, s := range w.sheets {299+ for _, t := range s.view.tables {300+ out = append(out, TableInfo{Name: t.Name, Sheet: s, Range: t.Range, shown: true, Cols: slices.Clone(t.Cols)})301+ }302+ for _, r := range s.regions.list {303+ v, _ := w.findTable(nameKey(r.Name))304+ out = append(out, TableInfo{Name: r.Name, Sheet: s, Range: v.r, shown: v.ok, Cols: v.cols, Region: true})305+ }306+ }307+ return out308+}309+310+// LookupTable finds a table formulas can read by name, ignoring case.311+func (w *Workbook) LookupTable(name string) (TableInfo, bool) {312+ k := nameKey(name)313+ for _, t := range w.TableInfos() {314+ if nameKey(t.Name) == k {315+ return t, true316+ }317+ }318+ return TableInfo{}, false319+}320+321+// TableLook says how the cell at a draws as part of a table: in the322+// header row's style, or on a band (every other data row), when its323+// table has them.324+func (s *Sheet) TableLook(a Addr) (header, band bool) {325+ for i := range s.view.tables {326+ t := &s.view.tables[i]327+ if !t.Range.Contains(a) {328+ continue329+ }330+ if a.Row == t.Range.From.Row {331+ return t.Header, false332+ }333+ return false, t.Banded && (a.Row-t.Range.From.Row)%2 == 0334+ }335+ return false, false336+}
M internal/ui/assist.go +7 -0
M internal/ui/filter.go +6 -3
M internal/ui/formula/formula.go +45 -2
@@ -117,12 +117,15 @@ func (s refScan) corners(pos int) ([][2]int, bool) {
117117 // Caret describes the formula text before the caret: the word being118118 // typed, if it may be a function, name or sheet, and the innermost119119 // function call the caret is in. A sheet name being typed in quotes is a120-// word starting with the quote, e.g. "'Q3 p".120+// word starting with the quote, e.g. "'Q3 p". In a structured121+// reference's brackets, Sales[Am, the word is the column or item being122+// typed and Table the table's name.121123 type Caret struct {122124 Word string123125 WordStart int124126 Fn string // e.g. "SUM", or "" outside any function's parentheses125127 Arg int // index of the argument the caret is in128+ Table string // the table whose brackets the caret is in, as written, or ""126129 }127130 128131 func isWordRune(r rune) bool {
@@ -141,6 +144,11 @@ func ScanCaret(buf []rune, pos int) Caret {
141144 return Caret{}142145 }143146 var c Caret147+ if s.depth > 0 {148+ c.Table, c.WordStart = s.table, s.item149+ c.Word = string(buf[s.item:pos])150+ s.quote = -1151+ }144152 if s.quote >= 0 {145153 c.Word, c.WordStart = string(buf[s.quote:pos]), s.quote146154 }
@@ -150,7 +158,7 @@ func ScanCaret(buf []rune, pos int) Caret {
150158 break151159 }152160 }153- if s.quote < 0 && s.start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {161+ if s.depth == 0 && s.quote < 0 && s.start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {154162 w := []rune(strings.TrimPrefix(string(buf[s.start:pos]), "@"))155163 if len(w) > 0 && (unicode.IsLetter(w[0]) || w[0] == '_') {156164 c.Word, c.WordStart = string(w), pos-len(w)
@@ -166,6 +174,13 @@ type caretScan struct {
166174 quote int // start of a quoted sheet name being read, or -1167175 start int // start of the word being read, or -1168176 prev string // the word just before a space or "(" (SUM ( is allowed)177+ // In a structured reference's brackets: how deep, the table's name,178+ // where the item being typed starts, and whether the next character179+ // is escaped by '.180+ depth int181+ table string182+ item int183+ esc bool169184 }170185 171186 // callFrame is a function call the caret may be in.
@@ -177,6 +192,10 @@ type callFrame struct {
177192 // step reads buf[i].178193 func (s *caretScan) step(buf []rune, i int) {179194 r := buf[i]195+ if s.depth > 0 {196+ s.bracket(r, i)197+ return198+ }180199 if s.inStr {181200 s.inStr = r != '"'182201 return
@@ -203,6 +222,8 @@ func (s *caretScan) step(buf []rune, i int) {
203222 s.inStr = true204223 case '\'':205224 s.quote = i225+ case '[':226+ s.table, s.depth, s.item = s.prev, 1, i+1206227 case '(':207228 s.stack = append(s.stack, callFrame{fn: strings.ToUpper(strings.TrimPrefix(s.prev, "@"))})208229 case ')':
@@ -217,6 +238,28 @@ func (s *caretScan) step(buf []rune, i int) {
217238 s.prev = ""218239 }219240 241+// bracket reads r, at i, inside a structured reference's brackets:242+// Sales[[#Headers],[Amount]]. Each [ or item separator starts an item;243+// ' escapes the character after it.244+func (s *caretScan) bracket(r rune, i int) {245+ switch {246+ case s.esc:247+ s.esc = false248+ case r == '\'':249+ s.esc = true250+ case r == '[':251+ s.depth++252+ s.item = i + 1253+ case r == ']':254+ if s.depth--; s.depth == 0 {255+ s.table = ""256+ }257+ s.item = i + 1258+ case (r == ',' || r == ';') && s.depth == 1:259+ s.item = i + 1260+ }261+}262+220263 // SplitArgs splits a signature's arguments at the top-level commas, so221264 // "[value2, ...]" stays one part.222265 func SplitArgs(args string) []string {
M internal/ui/formula/formula_test.go +25 -0
M internal/ui/hosts.go +1 -0
M internal/ui/menubar.go +2 -1
M internal/ui/monochrome_test.go +22 -0
M internal/ui/nbsend.go +1 -1
M internal/ui/panel.go +3 -1
M internal/ui/sort.go +4 -1
M internal/ui/suggest/suggest.go +20 -5
@@ -66,11 +66,12 @@ type Suggestion struct {
6666 Detail string // the signature, the range a name stands for, or a sheet's cells6767 Desc string6868 Fn bool // a function: accepting it adds "("69+ Column bool // a table's column or item: accepting it adds "]"6970 }7071 71-// For lists the functions, named ranges and sheets for word, typed on72-// sh: those starting with it first (names, then sheets, then73-// functions), then, from two letters on, those containing it, such as72+// For lists the functions, named ranges, tables and sheets for word,73+// typed on sh: those starting with it first (names, then tables, then74+// sheets, then functions), then, from two letters on, those containing it, such as7475 // COUNTIFS for "ifs". A word that is already a cell reference only gets7576 // those that start with it, and a word in quotes ('Q3) only sheets.7677 func For(sh *sheet.Sheet, word string) []Suggestion {
@@ -90,6 +91,9 @@ func For(sh *sheet.Sheet, word string) []Suggestion {
9091 for _, n := range sh.Names() {9192 add(Suggestion{Name: n.Name, Detail: n.Ref(), Desc: "Named range " + n.Name + ": " + n.Ref()}, strings.ToUpper(n.Name))9293 }94+ for _, t := range sh.Book().TableInfos() {95+ add(tableSuggestion(sh, t), strings.ToUpper(t.Name))96+ }9397 for _, s := range otherSheets(sh) {9498 add(sheetSuggestion(s), strings.ToUpper(s.Name()))9599 }
@@ -112,10 +116,18 @@ func (l *List) Shown(h Host) ([]Suggestion, int) {
112116 return nil, 0113117 }114118 c := formula.ScanCaret(h.Stored(line.Buf), line.Pos)115- if c.Word == "" {119+ var list []Suggestion120+ switch {121+ case c.Table != "":122+ if word, at := strings.CutPrefix(c.Word, "@"); at {123+ c.Word, c.WordStart = word, c.WordStart+1124+ }125+ list = columnSuggestions(h.EntrySheet(), c.Table, c.Word)126+ case c.Word == "":116127 return nil, 0128+ default:129+ list = For(h.EntrySheet(), c.Word)117130 }118- list := For(h.EntrySheet(), c.Word)119131 if len(list) == 0 || len(list) == 1 && !list[0].Fn && strings.EqualFold(list[0].Name, c.Word) {120132 return nil, 0121133 }
@@ -154,6 +166,9 @@ func (l *List) accept(h Host, s Suggestion, start int) {
154166 if s.Fn && (len(rest) == 0 || rest[0] != '(') {155167 text = append(text, '(')156168 }169+ if s.Column && (len(rest) == 0 || rest[0] != ']') {170+ text = append(text, ']')171+ }157172 if strings.HasSuffix(s.Name, "!") {158173 rest = trimSheetEnd(rest) // the rest of a sheet name typed before159174 }
M internal/ui/suggest/suggest_test.go +42 -0
@@ -64,6 +64,48 @@ func TestOffersNamesSheetsThenFunctions(t *testing.T) {
6464 }6565 }6666 67+// Tables are offered with named ranges; in a table's brackets its68+// columns and items are, and the context line lists its columns.69+func TestOffersTablesAndColumns(t *testing.T) {70+ h := newHost(t)71+ s := h.s72+ for a, v := range map[string]string{"C1": "Region", "D1": "Unit Price", "E1": "Q[1]"} {73+ s.Set(sheet.Addr{Col: int(a[0] - 'A')}, v)74+ }75+ if err := s.CreateTable("Orders", sheet.Rect{From: sheet.Addr{Col: 2}, To: sheet.Addr{Col: 4, Row: 3}}); err != nil {76+ t.Fatal(err)77+ }78+ if got := names(For(s, "ord")); len(got) == 0 || got[0] != "Orders" {79+ t.Fatalf("for ord: %v", got)80+ }81+ var l List82+ h.line.Set("=Orders[u")83+ l.Active = true84+ list, start := l.Shown(h)85+ if strings.Join(names(list), " ") != "Unit Price" || start != len("=Orders[") {86+ t.Fatalf("columns %v from %d", names(list), start)87+ }88+ l.Key(h, "tab")89+ if h.line.Text() != "=Orders[Unit Price]" {90+ t.Fatalf("accepted %q", h.line.Text())91+ }92+ h.line.Set("=Orders[@q")93+ l.Active = true94+ l.Key(h, "tab")95+ if h.line.Text() != "=Orders[@Q'[1']]" {96+ t.Fatalf("an escaped column after @: %q", h.line.Text())97+ }98+ h.line.Set("=Orders[#")99+ l.Active = true100+ if list, _ := l.Shown(h); strings.Join(names(list), " ") != "#All #Data #Headers #This Row" {101+ t.Fatalf("items %v", names(list))102+ }103+ left, _, ok := TableLine(&h.th, 80, s, []rune("=Orders[Uni"), len("=Orders[Uni"), "")104+ if !ok || !strings.Contains(ansi.Strip(left), "Orders[Region, Unit Price, Q[1]]") || !strings.Contains(left, h.th.Argument.Render("Unit Price")) {105+ t.Errorf("context %q", left)106+ }107+}108+67109 func TestKeysMoveAndAccept(t *testing.T) {68110 h := newHost(t)69111 var l List
A internal/ui/suggest/tables.go +113 -0
@@ -0,0 +1,113 @@
1+package suggest2+3+import (4+ "strings"5+6+ "github.com/charmbracelet/x/ansi"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+ "github.com/FelineStateMachine/012/internal/ui/formula"10+ "github.com/FelineStateMachine/012/internal/ui/theme"11+)12+13+// Tables in formula suggestions: typing =Sa offers the table Sales with14+// the named ranges, and once its "[" is typed, Sales[A offers its15+// columns (Amount) and, after a #, the items (#All, #Headers ...). The16+// context line shows the table's columns with the one being typed17+// marked, as it shows a function's arguments.18+19+// items are the items a structured reference names rows by.20+var items = []struct{ name, desc string }{21+ {"#All", "The whole table, header row included"},22+ {"#Data", "The data rows, as the table's name alone"},23+ {"#Headers", "The header row"},24+ {"#This Row", "The formula's own row, as @ before a column"},25+}26+27+// tableSuggestion is the suggestion for a table formulas can read.28+func tableSuggestion(sh *sheet.Sheet, t sheet.TableInfo) Suggestion {29+ where := "table, no rows yet"30+ if t.Shown() {31+ where = "table, " + t.Range.String()32+ if t.Sheet != sh {33+ where = "table, " + sheet.Qualified(t.Sheet.Name(), t.Range)34+ }35+ }36+ return Suggestion{Name: t.Name, Detail: where, Desc: "Table " + t.Name + ": " + strings.Join(t.Cols, ", ") + "; type [ for its columns"}37+}38+39+// columnSuggestions lists what may go in the brackets of the table40+// named table, for word typed there: its columns starting with it,41+// then from two letters on those containing it, and the items after a42+// #. A column is offered as a structured reference writes it, its [ ] #43+// and ' escaped.44+func columnSuggestions(sh *sheet.Sheet, table, word string) []Suggestion {45+ t, ok := sh.Book().LookupTable(table)46+ if !ok {47+ return nil48+ }49+ w := strings.ToUpper(word)50+ var prefix, inner []Suggestion51+ if strings.HasPrefix(w, "#") {52+ for _, it := range items {53+ if strings.HasPrefix(strings.ToUpper(it.name), w) {54+ prefix = append(prefix, Suggestion{Name: it.name, Detail: "item", Desc: it.desc, Column: true})55+ }56+ }57+ return prefix58+ }59+ for i, col := range t.Cols {60+ s := Suggestion{Name: sheet.EscapeColumn(col), Detail: "column " + sheet.ColName(t.Range.From.Col+i),61+ Desc: "The " + col + " column of " + t.Name, Column: true}62+ switch name := strings.ToUpper(col); {63+ case strings.HasPrefix(name, w):64+ prefix = append(prefix, s)65+ case len(w) >= 2 && strings.Contains(name, w):66+ inner = append(inner, s)67+ }68+ }69+ return append(prefix, inner...)70+}71+72+// InTable reports the table whose brackets the caret is in, if any: buf73+// is the formula as it's parsed (see Host.Stored).74+func InTable(buf []rune, pos int) (string, bool) {75+ c := formula.ScanCaret(buf, pos)76+ return c.Table, c.Table != ""77+}78+79+// TableLine puts the columns of the table whose brackets the caret is80+// in on the context line, Sales[Region, Units, Amount], with the column81+// being typed marked and the items after, and the keys that apply82+// (hints) as room allows. It reports false outside a table's brackets83+// or for a table formulas can't find.84+func TableLine(th *theme.Theme, width int, sh *sheet.Sheet, buf []rune, pos int, hints string) (left, right string, ok bool) {85+ c := formula.ScanCaret(buf, pos)86+ t, found := sh.Book().LookupTable(c.Table)87+ if c.Table == "" || !found {88+ return "", "", false89+ }90+ typed := strings.TrimPrefix(c.Word, "@")91+ var b strings.Builder92+ b.WriteString(th.Key.Render(t.Name) + "[")93+ marked := false94+ for i, col := range t.Cols {95+ if i > 0 {96+ b.WriteString(", ")97+ }98+ if !marked && typed != "" && strings.HasPrefix(strings.ToUpper(col), strings.ToUpper(typed)) {99+ b.WriteString(th.Argument.Render(col))100+ marked = true101+ continue102+ }103+ b.WriteString(col)104+ }105+ b.WriteString("]")106+ full := b.String() + " " + th.Muted.Render("#All #Data #Headers, @ for this row")107+ for _, try := range [][2]string{{full, hints}, {b.String(), hints}, {full, ""}} {108+ if ansi.StringWidth(try[0])+3+ansi.StringWidth(try[1]) <= width {109+ return try[0], try[1], true110+ }111+ }112+ return ansi.Truncate(b.String(), width, "…"), "", true113+}
A internal/ui/tables.go +280 -0
@@ -0,0 +1,280 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/picker"11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// Tables, as Sheets' Format > Convert to table: a range whose first row15+// names its columns, read in formulas by those names (Sales[Amount]).16+// Converting asks for the table's name on the context line; Data > Table17+// renames, resizes (pointed at like any range), styles and removes the18+// table the active cell is in, and lists every table, regions' too, to19+// go to one. Filters and sorts started in a table take its range.20+21+// tableItems is Data > Table.22+var tableItems = []menuItem{23+ {cmd: "data.tables"}, sep,24+ {cmd: "table.rename"}, {cmd: "table.resize"}, sep,25+ {cmd: "table.banded"}, {cmd: "table.header"}, sep,26+ {cmd: "table.remove"},27+}28+29+func init() {30+ inTable := func(m *Model) bool { _, ok := m.sheet.TableAt(m.cur); return ok }31+ register(32+ &command{id: "table.create", title: "Convert to table", desc: "Make the selection, or the data around the active cell, a table whose first row names its columns",33+ run: (*Model).createTable, edits: func(m *Model) sheet.Rect { return m.dataRange() },34+ enabled: func(m *Model) bool { return !inTable(m) }},35+ &command{id: "data.tables", macro: macroView, title: "Tables", desc: "List the tables and notebook outputs formulas read by column name: go to, rename or remove them",36+ run: func(m *Model) tea.Cmd {37+ m.openTables()38+ return nil39+ }},40+ &command{id: "table.rename", title: "Rename table", desc: "Rename the active cell's table, and every formula that reads it",41+ run: func(m *Model) tea.Cmd {42+ t, _ := m.sheet.TableAt(m.cur)43+ m.renameTable(t, false)44+ return nil45+ }, enabled: inTable},46+ &command{id: "table.resize", title: "Resize table", desc: "Point at the range the active cell's table covers, header row first",47+ run: func(m *Model) tea.Cmd {48+ t, _ := m.sheet.TableAt(m.cur)49+ m.resizeTable(t)50+ return nil51+ }, enabled: inTable},52+ &command{id: "table.banded", title: "Banded rows", desc: "Shade every other row of the active cell's table",53+ run: func(m *Model) tea.Cmd { return m.styleTable(func(t *sheet.Table) { t.Banded = !t.Banded }) },54+ enabled: inTable, checked: func(m *Model) bool { t, ok := m.sheet.TableAt(m.cur); return ok && t.Banded }},55+ &command{id: "table.header", title: "Header style", desc: "Draw the header row of the active cell's table bold, underlined and in color",56+ run: func(m *Model) tea.Cmd { return m.styleTable(func(t *sheet.Table) { t.Header = !t.Header }) },57+ enabled: inTable, checked: func(m *Model) bool { t, ok := m.sheet.TableAt(m.cur); return ok && t.Header }},58+ &command{id: "table.remove", title: "Remove table", desc: "Make the active cell's table plain cells again; formulas that read it read the cells by address",59+ run: func(m *Model) tea.Cmd {60+ t, _ := m.sheet.TableAt(m.cur)61+ m.removeTable(t)62+ return nil63+ }, enabled: inTable},64+ )65+ keymap["ctrl+alt+t"] = "table.create"66+}67+68+// createTable asks for a name for a table of the selection, or of the69+// data around the active cell.70+func (m *Model) createTable() tea.Cmd {71+ r := m.dataRange()72+ m.openText("Name for the table of "+r.String()+":", m.book().NextTableName(), func(m *Model, name string) tea.Cmd {73+ if err := m.sheet.CreateTable(name, r); err != nil {74+ m.fail(err.Error())75+ return nil76+ }77+ m.changed = true78+ t, _ := m.sheet.TableAt(r.From)79+ m.selectRect(t.Range)80+ m.note = name + " is a table: read it by column in formulas, e.g. =SUM(" + name + "[" + t.Cols[len(t.Cols)-1] + "])"81+ return nil82+ })83+ m.prompt.indicator = "NAME"84+ return nil85+}86+87+// renameTable asks for a new name for t. From the picker (back), the88+// picker opens again afterwards.89+func (m *Model) renameTable(t sheet.Table, back bool) {90+ m.openText("Rename table "+t.Name+":", t.Name, func(m *Model, name string) tea.Cmd {91+ if err := m.book().RenameTable(t.Name, name); err != nil {92+ m.fail(err.Error())93+ return nil94+ }95+ m.changed = true96+ if back {97+ m.noteUnrecorded()98+ m.openTables()99+ return nil100+ }101+ m.note = "Renamed " + t.Name + " to " + name + " in every formula that reads it"102+ return nil103+ })104+ m.prompt.indicator = "NAME"105+}106+107+// resizeTable asks for t's new range, pointed at from its own.108+func (m *Model) resizeTable(t sheet.Table) {109+ m.openRange("Range for "+t.Name+", header row first:", func(m *Model, r sheet.Rect) tea.Cmd {110+ if err := m.book().ResizeTable(t.Name, r); err != nil {111+ m.fail(err.Error())112+ return nil113+ }114+ m.changed = true115+ nt, _ := m.sheet.TableAt(r.From)116+ m.selectRect(nt.Range)117+ m.note = t.Name + " covers " + nt.Range.String() + ": " + strings.Join(nt.Cols, ", ")118+ return nil119+ })120+ m.point = pointer{anchor: t.Range.From, at: t.Range.To, anchored: true}121+}122+123+// styleTable changes the style of the active cell's table with fn.124+func (m *Model) styleTable(fn func(*sheet.Table)) tea.Cmd {125+ t, ok := m.sheet.TableAt(m.cur)126+ if !ok {127+ return nil128+ }129+ fn(&t)130+ if err := m.book().SetTableStyle(t.Name, t.Banded, t.Header); err != nil {131+ m.fail(err.Error())132+ return nil133+ }134+ m.changed = true135+ return nil136+}137+138+// removeTable makes t plain cells again.139+func (m *Model) removeTable(t sheet.Table) {140+ if err := m.book().RemoveTable(t.Name); err != nil {141+ m.fail(err.Error())142+ return143+ }144+ m.changed = true145+ m.note = "Removed the table " + t.Name + "; its cells stay, and formulas read them by address " +146+ m.th.KeyHints(m.shortcut("edit.undo"), "undo")147+}148+149+// tableLine is the context line in a table: its name and the column's,150+// as formulas read it.151+func (m *Model) tableLine() string {152+ t, ok := m.sheet.TableAt(m.cur)153+ i := m.cur.Col - t.Range.From.Col154+ if !ok || i >= len(t.Cols) {155+ return ""156+ }157+ col := t.Cols[i]158+ ref := t.Name + "[" + col + "]"159+ if strings.ContainsAny(col, " []#',") {160+ ref = t.Name + "[[" + col + "]]"161+ }162+ return m.th.Muted.Render("Table "+t.Name+", column ") + m.th.Key.Render(col) + m.th.Muted.Render(": "+ref+" in formulas")163+}164+165+// tablesHost is what the tables picker acts on. The model implements166+// it. The picker stays in package ui, as the named ranges picker does:167+// its keys open the model's prompts and move its selection.168+type tablesHost interface {169+ styles() *theme.Theme170+ book() *sheet.Workbook171+ closeOverlay()172+ tablesItems() []picker.Item173+ // renameTable asks for a table's new name, then opens the picker174+ // again.175+ renameTable(t sheet.Table, back bool)176+ // tableChanged follows a table changed from the picker.177+ tableChanged()178+}179+180+// tablesPicker is the picker of tables with its extra keys.181+type tablesPicker struct {182+ m tablesHost183+ *picker.Picker184+ msg string // feedback on the last action, e.g. a removal185+}186+187+// openTables opens the picker of tables.188+func (m *Model) openTables() {189+ p := m.newPicker("Tables", "Type a name", 60, m.tablesItems())190+ p.Action = "go to"191+ m.clearSelection()192+ m.openOverlay(&tablesPicker{m: m, Picker: p})193+}194+195+func (m *Model) tableChanged() {196+ m.syncChanged()197+ m.noteUnrecorded()198+}199+200+// tablesItems lists every table formulas can read.201+func (m *Model) tablesItems() []picker.Item {202+ var items []picker.Item203+ for _, t := range m.book().TableInfos() {204+ where := sheet.Qualified(t.Sheet.Name(), t.Range)205+ desc := "Go to it; columns " + strings.Join(t.Cols, ", ")206+ switch {207+ case t.Region && !t.Shown():208+ where, desc = t.Sheet.Name(), "A region with no rows yet"209+ case t.Region:210+ desc = "A region: its source names and shapes it; " + strconv.Itoa(len(t.Cols)) + " columns"211+ }212+ items = append(items, picker.Item{213+ Title: t.Name, Name: len(t.Name), Detail: where, Desc: desc, Off: !t.Shown(),214+ Pick: func() tea.Cmd {215+ m.closeOverlay()216+ if !t.Shown() || m.refuseHidden(t.Sheet) {217+ return nil218+ }219+ m.showSheet(t.Sheet)220+ m.selectRect(t.Range)221+ return nil222+ },223+ })224+ }225+ return items226+}227+228+// current is the table highlighted in the picker, when it's one the229+// table commands change.230+func (p *tablesPicker) current() (sheet.Table, bool) {231+ if p.Picker.Sel >= len(p.Shown()) {232+ return sheet.Table{}, false233+ }234+ _, t, ok := p.m.book().Table(p.Shown()[p.Picker.Sel].Item.Title)235+ return t, ok236+}237+238+func (p *tablesPicker) Key(k tea.KeyPressMsg) tea.Cmd {239+ m := p.m240+ p.msg = ""241+ switch k.String() {242+ case "f2":243+ if t, ok := p.current(); ok {244+ m.closeOverlay()245+ m.renameTable(t, true)246+ }247+ return nil248+ case "ctrl+d":249+ if t, ok := p.current(); ok {250+ m.book().RemoveTable(t.Name)251+ m.tableChanged()252+ p.msg = "Removed the table " + t.Name + "; Ctrl+Z brings it back"253+ p.Items = m.tablesItems()254+ sel := p.Picker.Sel255+ p.Changed()256+ p.Picker.Sel = max(min(sel, len(p.Shown())-1), 0)257+ }258+ return nil259+ }260+ return p.Picker.Key(k)261+}262+263+func (p *tablesPicker) Status() (string, string) {264+ th := p.m.styles()265+ keys := th.KeyHints("Enter", "go to", "F2", "rename", "Ctrl+D", "remove", "Esc", "close")266+ if _, ok := p.current(); !ok {267+ keys = th.KeyHints("Enter", "go to", "Esc", "close")268+ }269+ if len(p.Shown()) == 0 {270+ keys = th.KeyHints("Esc", "close")271+ }272+ if p.msg != "" {273+ return p.msg, keys274+ }275+ if len(p.Items) == 0 {276+ return "No tables yet: Format > Convert to table makes one", keys277+ }278+ desc, _ := p.Picker.Status()279+ return desc, keys280+}
A internal/ui/tables_test.go +180 -0
@@ -0,0 +1,180 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+)7+8+// unitsModel is a model whose sheet holds Region, Units and Amount in9+// A1:C4, the cursor on B2.10+func unitsModel(t *testing.T) *Model {11+ t.Helper()12+ m := tallModel()13+ m.Update(teaSize(100, 30))14+ for a, v := range map[string]string{15+ "A1": "Region", "B1": "Units", "C1": "Amount",16+ "A2": "North", "B2": "2", "C2": "10",17+ "A3": "South", "B3": "3", "C3": "20",18+ "A4": "East", "B4": "5", "C4": "30",19+ } {20+ m.sheet.Set(addr(a), v)21+ }22+ m.selectRect(rectOf("B2"))23+ return m24+}25+26+func TestConvertToTable(t *testing.T) {27+ m := unitsModel(t)28+ press(t, m, "<ctrl+alt+t>")29+ if l := line(m, contextLine); !strings.HasPrefix(l, "Name for the table of A1:C4: Table1") {30+ t.Fatalf("prompt %q", l)31+ }32+ press(t, m, "Sales", "<enter>")33+ tb, ok := m.sheet.TableAt(addr("B2"))34+ if !ok || tb.Name != "Sales" || tb.Range != rectOf("A1:C4") {35+ t.Fatalf("table %+v", tb)36+ }37+ if l := line(m, contextLine); !strings.Contains(l, "=SUM(Sales[Amount])") {38+ t.Errorf("note %q", l)39+ }40+ if m.available("table.create") {41+ t.Error("Convert to table offered inside a table")42+ }43+ // On a table's cell the context line names the table and column.44+ press(t, m, "<esc>", "<right>")45+ if l := line(m, contextLine); !strings.Contains(l, "Table Sales, column Units: Sales[Units] in formulas") {46+ t.Errorf("context %q", l)47+ }48+ // Typing below it grows it; undo takes the row back out.49+ press(t, m, "<ctrl+down>", "<down>", "40", "<enter>")50+ if tb, _ := m.sheet.TableAt(addr("C5")); tb.Range != rectOf("A1:C5") {51+ t.Errorf("grew to %v", tb.Range)52+ }53+ press(t, m, "<ctrl+z>")54+ if _, ok := m.sheet.TableAt(addr("C5")); ok {55+ t.Error("undo kept the row")56+ }57+}58+59+func TestTableCommands(t *testing.T) {60+ m := unitsModel(t)61+ m.sheet.CreateTable("Sales", rectOf("A1:C4"))62+ m.sheet.Set(addr("E1"), "=SUM(Sales[Amount])")63+ m.runCommand("table.rename")64+ press(t, m, "Revenue", "<enter>")65+ if input(m, "E1") != "=SUM(Revenue[Amount])" {66+ t.Errorf("renamed formula %q", input(m, "E1"))67+ }68+ m.runCommand("table.resize")69+ if l := line(m, contextLine); !strings.HasPrefix(l, "Range for Revenue, header row first: A1:C4") {70+ t.Fatalf("resize prompt %q", l)71+ }72+ press(t, m, "<shift+left>", "<enter>")73+ if tb, _ := m.sheet.TableAt(addr("A1")); tb.Range != rectOf("A1:B4") {74+ t.Errorf("resized to %v", tb.Range)75+ }76+ wantShown(t, m, "E1", "#REF!")77+ press(t, m, "<ctrl+z>")78+ for id, want := range map[string]bool{"table.banded": true, "table.header": true} {79+ if c := commands[id]; !c.checked(m) != !want {80+ t.Errorf("%s checked %v", id, c.checked(m))81+ }82+ m.runCommand(id)83+ if commands[id].checked(m) == want {84+ t.Errorf("%s didn't toggle", id)85+ }86+ }87+ m.runCommand("table.remove")88+ if m.sheet.HasTables() || input(m, "E1") != "=SUM($C$2:$C$4)" {89+ t.Errorf("removed: %v %q", m.sheet.HasTables(), input(m, "E1"))90+ }91+ if m.available("table.rename") {92+ t.Error("table commands offered outside a table")93+ }94+}95+96+// Filters and sorts started in a table take its range, header kept.97+func TestTableScopesFilterAndSort(t *testing.T) {98+ m := unitsModel(t)99+ m.sheet.Set(addr("A6"), "apart")100+ m.sheet.CreateTable("Sales", rectOf("A1:C5"))101+ m.selectRect(rectOf("C3"))102+ m.runCommand("data.sort_range_za")103+ if input(m, "C2") != "30" || input(m, "C1") != "Amount" {104+ t.Errorf("sorted: C1 %q C2 %q", input(m, "C1"), input(m, "C2"))105+ }106+ m.runCommand("data.filter")107+ if r, _ := m.sheet.FilterRange(); r != rectOf("A1:C5") {108+ t.Errorf("filter on %v", r)109+ }110+}111+112+func TestTablesPicker(t *testing.T) {113+ m := unitsModel(t)114+ m.sheet.CreateTable("Sales", rectOf("A1:C4"))115+ m.sheet.Set(addr("F1"), "x")116+ m.sheet.CreateTable("Other", rectOf("F1:G3"))117+ m.runCommand("data.tables")118+ scr := screen(m)119+ for _, want := range []string{"Tables", "Sales", "Sheet1!A1:C4", "Other"} {120+ if !strings.Contains(scr, want) {121+ t.Errorf("missing %q in\n%s", want, scr)122+ }123+ }124+ press(t, m, "oth", "<enter>")125+ if m.selection() != rectOf("F1:G3") {126+ t.Errorf("went to %v", m.selection())127+ }128+ m.runCommand("data.tables")129+ press(t, m, "sales", "<ctrl+d>")130+ if _, _, ok := m.book().Table("Sales"); ok {131+ t.Error("not removed")132+ }133+ if st := line(m, m.height-1); !strings.Contains(st, "Removed the table Sales") {134+ t.Errorf("status %q", st)135+ }136+ press(t, m, "<esc>")137+}138+139+func TestTableSuggestions(t *testing.T) {140+ m := unitsModel(t)141+ m.sheet.CreateTable("Sales", rectOf("A1:C4"))142+ m.selectRect(rectOf("E1"))143+ press(t, m, "=SUM(sal")144+ list, _ := m.entry.assist.Shown(m.host())145+ if len(list) == 0 || list[0].Name != "Sales" || !strings.Contains(list[0].Detail, "A1:C4") {146+ t.Fatalf("suggestions %v", list)147+ }148+ press(t, m, "<tab>", "[am")149+ if list, _ = m.entry.assist.Shown(m.host()); len(list) != 1 || list[0].Name != "Amount" {150+ t.Fatalf("columns %v", list)151+ }152+ if ctx := line(m, contextLine); !strings.HasPrefix(ctx, "Sales[Region, Units, Amount]") {153+ t.Errorf("context %q", ctx)154+ }155+ press(t, m, "<tab>")156+ if m.line.Text() != "=SUM(Sales[Amount]" {157+ t.Fatalf("accepted %q", m.line.Text())158+ }159+ press(t, m, ")", "<enter>")160+ wantShown(t, m, "E1", "60")161+ // Items after #, and a column after @.162+ m.selectRect(rectOf("D2"))163+ press(t, m, "=Sales[#h")164+ if list, _ = m.entry.assist.Shown(m.host()); len(list) != 1 || list[0].Name != "#Headers" {165+ t.Fatalf("items %v", list)166+ }167+ press(t, m, "<esc>", "<esc>", "=Sales[@u", "<tab>", "<enter>")168+ if input(m, "D2") != "=Sales[@Units]" {169+ t.Errorf("this row %q", input(m, "D2"))170+ }171+ wantShown(t, m, "D2", "2")172+}173+174+// wantShown checks the value the cell at a shows.175+func wantShown(t *testing.T, m *Model, a, want string) {176+ t.Helper()177+ if got := m.sheet.Value(addr(a)).String(); got != want {178+ t.Errorf("%s = %q, want %q", a, got, want)179+ }180+}
M internal/ui/theme/scheme.go +6 -0
M internal/ui/theme/theme.go +18 -7
@@ -66,6 +66,12 @@ type Theme struct {
6666 // FilterOn is the filter mark in the header of a column whose filter6767 // hides something (the mark itself also changes, from ▾ to ▼).6868 FilterOn lipgloss.Style69+ // TableHeader is a table's header row when the table styles it,70+ // bold and underlined so it reads without color. TableBand is every71+ // other data row of a banded table: a band of the background moved72+ // toward the text, with the text on it readable.73+ TableHeader lipgloss.Style74+ TableBand lipgloss.Style6975 // NoteMark is the mark in the top-right corner of a cell with a note,7076 // like Sheets' small triangle.7177 NoteMark lipgloss.Style
@@ -229,7 +235,10 @@ func roles(dark bool) Theme {
229235 filterFg := lipgloss.Yellow230236 noteFg := lipgloss.Yellow231237 borderFg := lipgloss.White238+ // Bands are the header's gray, lightest on a light terminal.239+ bandBg, bandFg := lipgloss.BrightBlack, lipgloss.BrightWhite232240 if !dark {241+ bandBg, bandFg = lipgloss.White, lipgloss.Black233242 borderFg = lipgloss.Black234243 noteFg = lipgloss.Magenta235244 headerBg, headerFg = lipgloss.White, lipgloss.Black
@@ -275,13 +284,15 @@ func roles(dark bool) Theme {
275284 ProgressTodo: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),276285 Copied: lipgloss.NewStyle().Foreground(lipgloss.Magenta).277286 UnderlineStyle(lipgloss.UnderlineDashed).UnderlineSpaces(true),278- FrozenLine: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),279- Tab: lipgloss.NewStyle(),280- TabActive: accent.Bold(true),281- TabHover: lipgloss.NewStyle().Foreground(bar).Bold(true).Underline(true),282- FilterOn: lipgloss.NewStyle().Background(headerBg).Foreground(filterFg).Bold(true),283- NoteMark: lipgloss.NewStyle().Foreground(noteFg),284- CellBorder: lipgloss.NewStyle().Foreground(borderFg),287+ FrozenLine: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),288+ Tab: lipgloss.NewStyle(),289+ TabActive: accent.Bold(true),290+ TabHover: lipgloss.NewStyle().Foreground(bar).Bold(true).Underline(true),291+ FilterOn: lipgloss.NewStyle().Background(headerBg).Foreground(filterFg).Bold(true),292+ NoteMark: lipgloss.NewStyle().Foreground(noteFg),293+ TableHeader: lipgloss.NewStyle().Foreground(bar).Bold(true).Underline(true),294+ TableBand: lipgloss.NewStyle().Background(bandBg).Foreground(bandFg),295+ CellBorder: lipgloss.NewStyle().Foreground(borderFg),285296 286297 MenuBar: lipgloss.NewStyle(),287298 MenuAccel: lipgloss.NewStyle().Underline(true),
M internal/ui/view.go +10 -2