cat / 012
clone

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

cat/module-path
012 / internal / sheet / view.go
view.go 7.4K
1package sheet23import (4 "bufio"5 "encoding/json"6 "fmt"7 "reflect"8 "slices"910 "src.rest/cat/012/internal/formula"11)1213// View state that belongs to the worksheet rather than to a cell: frozen14// rows and columns, the filter, the protected ranges, the merged cells15// and the tables. It is saved in the file and every change to it is an undo step,16// as in Sheets.17type viewState struct {18 frozenRows, frozenCols int19 filter *Filter // never modified in place; replaced whole20 protected []Protection // see protect.go; replaced whole too21 merges []Rect // see merge.go; replaced whole too22 tables []Table // see table.go; replaced whole too23}2425// Frozen returns how many rows and columns are frozen at the top and left.26func (s *Sheet) Frozen() (rows, cols int) {27 return s.view.frozenRows, s.view.frozenCols28}2930// MaxFrozen caps frozen rows and columns, as Sheets' View > Freeze does31// in practice: more than a screenful can't stay on screen anyway.32const MaxFrozen = 503334// SetFrozen freezes the first rows rows and cols columns (0 unfreezes).35func (s *Sheet) SetFrozen(rows, cols int) {36 rows, cols = clampInt(rows, 0, MaxFrozen), clampInt(cols, 0, MaxFrozen)37 if rows == s.view.frozenRows && cols == s.view.frozenCols {38 return39 }40 label := "unfreeze"41 switch {42 case rows != s.view.frozenRows:43 label = "freeze " + plural(rows, "row")44 if rows == 0 {45 label = "unfreeze rows"46 }47 case cols != s.view.frozenCols:48 label = "freeze " + plural(cols, "column")49 if cols == 0 {50 label = "unfreeze columns"51 }52 }53 focus := Rect{To: Addr{Col: max(cols, 1) - 1, Row: max(rows, 1) - 1}}54 s.change(label, focus, func() {55 s.recordView()56 s.view.frozenRows, s.view.frozenCols = rows, cols57 })58}5960func plural(n int, noun string) string {61 if n == 1 {62 return "1 " + noun63 }64 return fmt.Sprintf("%d %ss", n, noun)65}6667// recordView saves the view state before its first change in the open68// step.69func (s *Sheet) recordView() {70 st := s.wb.hist.open71 if st == nil {72 return73 }74 if _, seen := st.views[s]; seen {75 return76 }77 v := s.view78 st.views[s] = &v79}8081func (v viewState) equal(w viewState) bool {82 return v.frozenRows == w.frozenRows && v.frozenCols == w.frozenCols &&83 (v.filter == w.filter || reflect.DeepEqual(v.filter, w.filter)) &&84 slices.Equal(v.protected, w.protected) && slices.Equal(v.merges, w.merges) &&85 slices.EqualFunc(v.tables, w.tables, Table.equal)86}8788// shiftView keeps frozen lines, the filter and protected ranges in step89// with inserted or deleted rows or columns: inserting inside the frozen90// area freezes the new lines too, and the filter's and protections'91// ranges grow, shrink or move like range references (and go away when92// all of one is deleted).93func (s *Sheet) shiftView(rows bool, sp formula.Span) {94 v := s.view95 v.protected = shiftProtected(v.protected, rows, sp)96 v.merges = shiftMerges(v.merges, rows, sp)97 v.tables = shiftTables(v.tables, rows, sp)98 frozen := &v.frozenCols99 if rows {100 frozen = &v.frozenRows101 }102 if *frozen > 0 {103 if lo, hi, ok := sp.Interval(0, *frozen-1); ok && lo == 0 {104 *frozen = hi + 1105 } else if !ok {106 *frozen = 0107 }108 *frozen = min(*frozen, MaxFrozen)109 }110 if v.filter != nil {111 v.filter = shiftFilter(v.filter, rows, sp)112 }113 if !v.equal(s.view) {114 s.recordView()115 merged := !slices.Equal(v.merges, s.view.merges)116 if w := s.wb; w.hist.open != nil {117 w.hist.dirty = append(w.hist.dirty, w.tableUsers(s.view.tables, v.tables)...)118 }119 s.view = v120 if merged { // a merge gone may free an array to spill121 s.respill(Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}})122 }123 }124}125126// shiftFilter is f with rows or columns inserted or deleted, nil once127// its range is all deleted.128func shiftFilter(f *Filter, rows bool, sp formula.Span) *Filter {129 _, rng := formula.AxisMaps(rows, sp)130 r, ok := rng(f.Range)131 if !ok {132 return nil133 }134 nf := &Filter{Range: r, Cols: map[int]Criteria{}}135 for c, cr := range f.Cols {136 if !rows {137 var keep bool138 if c, keep = sp.Point(c); !keep || c < r.From.Col || c > r.To.Col {139 continue140 }141 }142 nf.Cols[c] = cr143 }144 return nf145}146147// fileView is the view state in the file (version 3), e.g.148//149// "freeze": {"rows": 1},150// "filter": {"range": "A1:C20", "columns": {"B": {"condition": "gt", "value": "100"}}},151type fileView struct {152 Freeze *fileFreeze `json:"freeze,omitempty"`153 Filter *fileFilter `json:"filter,omitempty"`154 Protected []fileProtection `json:"protected,omitempty"`155 // Merges need no version: earlier builds ignore them and show the156 // cells unmerged.157 Merges []string `json:"merges,omitempty"`158 Tables []fileTable `json:"tables,omitempty"` // version 6, see tablefile.go159}160161type fileFreeze struct {162 Rows int `json:"rows,omitempty"`163 Cols int `json:"cols,omitempty"`164}165166type fileFilter struct {167 Range string `json:"range"`168 Columns map[string]fileCriteria `json:"columns,omitempty"`169}170171type fileCriteria struct {172 Hidden []string `json:"hidden,omitempty"`173 Condition string `json:"condition,omitempty"`174 Value string `json:"value,omitempty"`175}176177// writeView adds the frozen panes, the filter and the protected ranges to178// a file being written.179func (s *Sheet) writeView(b *bufio.Writer, indent string) error {180 var keys []string181 var parts []any182 if v := s.view; v.frozenRows > 0 || v.frozenCols > 0 {183 keys, parts = append(keys, "freeze"), append(parts, fileFreeze{Rows: v.frozenRows, Cols: v.frozenCols})184 }185 if f := s.view.filter; f != nil {186 ff := fileFilter{Range: f.Range.String(), Columns: map[string]fileCriteria{}}187 for c, cr := range f.Cols {188 ff.Columns[ColName(c)] = fileCriteria{Hidden: cr.Hidden, Condition: cr.Cond.Op.String(), Value: cr.Cond.Arg}189 }190 keys, parts = append(keys, "filter"), append(parts, ff)191 }192 if ps := s.view.protected; len(ps) > 0 {193 keys, parts = append(keys, "protected"), append(parts, encodeProtections(ps))194 }195 if ms := s.view.merges; len(ms) > 0 {196 names := make([]string, len(ms))197 for i, m := range ms {198 names[i] = m.String()199 }200 keys, parts = append(keys, "merges"), append(parts, names)201 }202 if ts := s.view.tables; len(ts) > 0 {203 keys, parts = append(keys, "tables"), append(parts, encodeTables(ts))204 }205 for i, p := range parts {206 raw, err := json.Marshal(p)207 if err != nil {208 return err209 }210 fmt.Fprintf(b, "%s%q: %s,\n", indent, keys[i], raw)211 }212 return nil213}214215// readView restores the frozen panes, the filter and the protected216// ranges from a file.217func (s *Sheet) readView(fv fileView) error {218 ps, err := decodeProtections(fv.Protected)219 if err != nil {220 return err221 }222 s.view.protected = ps223 for _, name := range fv.Merges {224 r, ok := ParseRange(name)225 if !ok {226 return fmt.Errorf("invalid merged range %q", name)227 }228 s.LoadMerge(r)229 }230 if err := s.readTables(fv.Tables); err != nil {231 return err232 }233 if fz := fv.Freeze; fz != nil {234 s.view.frozenRows, s.view.frozenCols = clampInt(fz.Rows, 0, MaxFrozen), clampInt(fz.Cols, 0, MaxFrozen)235 }236 if ff := fv.Filter; ff != nil {237 r, ok := ParseRange(ff.Range)238 if !ok {239 return fmt.Errorf("invalid filter range %q", ff.Range)240 }241 f := &Filter{Range: r, Cols: map[int]Criteria{}}242 for name, fc := range ff.Columns {243 c, ok := ParseCol(name)244 if !ok {245 return fmt.Errorf("invalid filter column %q", name)246 }247 op, ok := ParseCondOp(fc.Condition)248 if !ok {249 return fmt.Errorf("unknown filter condition %q", fc.Condition)250 }251 f.Cols[c] = Criteria{Hidden: fc.Hidden, Cond: Condition{Op: op, Arg: fc.Value}}252 }253 s.view.filter = f.clone()254 }255 s.hidden.valid = false256 return nil257}