cat / 012
clone

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

main
012 / internal / ui / tables.go
tables.go 9.0K
1package ui23import (4 "strconv"5 "strings"67 tea "charm.land/bubbletea/v2"89 "src.rest/cat/012/internal/sheet"10 "src.rest/cat/012/internal/ui/picker"11 "src.rest/cat/012/internal/ui/theme"12)1314// 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.2021// tableItems is Data > Table.22var 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}2829func 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}6768// createTable asks for a name for a table of the selection, or of the69// data around the active cell.70func (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}8687// renameTable asks for a new name for t. From the picker (back), the88// picker opens again afterwards.89func (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}106107// resizeTable asks for t's new range, pointed at from its own.108func (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}122123// styleTable changes the style of the active cell's table with fn.124func (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}137138// removeTable makes t plain cells again.139func (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}148149// tableLine is the context line in a table: its name and the column's,150// as formulas read it.151func (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}164165// 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.168type 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}179180// tablesPicker is the picker of tables with its extra keys.181type tablesPicker struct {182 m tablesHost183 *picker.Picker184 msg string // feedback on the last action, e.g. a removal185}186187// openTables opens the picker of tables.188func (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}194195func (m *Model) tableChanged() {196 m.syncChanged()197 m.noteUnrecorded()198}199200// tablesItems lists every table formulas can read.201func (m *Model) tablesItems() []picker.Item {202 var items []picker.Item203 for _, t := range m.book().TableInfos() {204 where := t.Range.String()205 if m.book().Len() > 1 {206 where = sheet.Qualified(t.Sheet.Name(), t.Range)207 }208 desc := "Go to it; columns " + strings.Join(t.Cols, ", ")209 switch {210 case t.Region && !t.Shown():211 where, desc = t.Sheet.Name(), "A region with no rows yet"212 case t.Region:213 desc = "A region: its source names and shapes it; " + strconv.Itoa(len(t.Cols)) + " columns"214 }215 items = append(items, picker.Item{216 Title: t.Name, Name: len(t.Name), Detail: where, Desc: desc, Off: !t.Shown(),217 Pick: func() tea.Cmd {218 m.closeOverlay()219 if !t.Shown() || m.refuseHidden(t.Sheet) {220 return nil221 }222 m.showSheet(t.Sheet)223 m.selectRect(t.Range)224 return nil225 },226 })227 }228 return items229}230231// current is the table highlighted in the picker, when it's one the232// table commands change.233func (p *tablesPicker) current() (sheet.Table, bool) {234 if p.Picker.Sel >= len(p.Shown()) {235 return sheet.Table{}, false236 }237 _, t, ok := p.m.book().Table(p.Shown()[p.Picker.Sel].Item.Title)238 return t, ok239}240241func (p *tablesPicker) Key(k tea.KeyPressMsg) tea.Cmd {242 m := p.m243 p.msg = ""244 switch k.String() {245 case "f2":246 if t, ok := p.current(); ok {247 m.closeOverlay()248 m.renameTable(t, true)249 }250 return nil251 case "ctrl+d":252 if t, ok := p.current(); ok {253 m.book().RemoveTable(t.Name)254 m.tableChanged()255 p.msg = "Removed the table " + t.Name + "; Ctrl+Z brings it back"256 p.Items = m.tablesItems()257 sel := p.Picker.Sel258 p.Changed()259 p.Picker.Sel = max(min(sel, len(p.Shown())-1), 0)260 }261 return nil262 }263 return p.Picker.Key(k)264}265266func (p *tablesPicker) Status() (string, string) {267 th := p.m.styles()268 keys := th.KeyHints("Enter", "go to", "F2", "rename", "Ctrl+D", "remove", "Esc", "close")269 if _, ok := p.current(); !ok {270 keys = th.KeyHints("Enter", "go to", "Esc", "close")271 }272 if len(p.Shown()) == 0 {273 keys = th.KeyHints("Esc", "close")274 }275 if p.msg != "" {276 return p.msg, keys277 }278 if len(p.Items) == 0 {279 return "No tables yet: Format > Convert to table makes one", keys280 }281 desc, _ := p.Picker.Status()282 return desc, keys283}