- commit
- 45b81b1d62170cb4edba5970c54e069bfbcd8763
- parent
- 03a0997260b854a67b9f1313de1954118be5c640
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 76 | +++--- |
| M | internal/ | 23 | +- |
| A | internal/ | 365 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 228 | +++++++++++++++++++ |
| M | internal/ | 14 | +- |
| M | internal/ | 15 | +- |
| M | internal/ | 47 | ++++- |
| A | internal/ | 322 | ++++++++++++++++++++++++++ |
| A | internal/ | 120 | ++++++++++ |
| M | internal/ | 4 | + |
| A | internal/ | 108 | +++++++++ |
| A | internal/ | 123 | ++++++++++ |
| A | internal/ | 193 | ++++++++++++++++ |
@@ -165,48 +165,50 @@ func (s *Sheet) clipToUsed(r Rect) Rect {
165165 return r166166 }167167 168-// Region returns the block of data around a, as Sheets picks the data for169-// a chart inserted from a single cell: the smallest range around a whose170-// border touches no filled cell.168+// Region returns the block of data around a, as Sheets picks the range to169+// sort or filter when a single cell is selected: filled cells connected170+// to a (diagonals count), and anything touching their bounding box, until171+// nothing more touches it. A blank cell with no filled neighbors is a172+// region of its own.171173 func (s *Sheet) Region(a Addr) Rect {172- r := Rect{a, a}173174 filled := func(a Addr) bool { return a.Valid() && !s.cells[a].Blank() }174- for grew := true; grew; {175- grew = false176- for _, side := range []struct{ dc0, dr0, dc1, dr1 int }{{-1, 0, 0, 0}, {0, 0, 1, 0}, {0, -1, 0, 0}, {0, 0, 0, 1}} {177- next := Rect{178- From: Addr{Col: r.From.Col + side.dc0, Row: r.From.Row + side.dr0},179- To: Addr{Col: r.To.Col + side.dc1, Row: r.To.Row + side.dr1},180- }181- if !next.From.Valid() || !next.To.Valid() || next == r {182- continue183- }184- // Only the new line of cells can add data.185- line := next186- switch {187- case side.dc0 < 0:188- line.To.Col = next.From.Col189- case side.dc1 > 0:190- line.From.Col = next.To.Col191- case side.dr0 < 0:192- line.To.Row = next.From.Row193- default:194- line.From.Row = next.To.Row195- }196- // Diagonal neighbors count, as in Sheets.197- if side.dc0 != 0 || side.dc1 != 0 {198- line.From.Row, line.To.Row = max(line.From.Row-1, 0), min(line.To.Row+1, MaxRows-1)199- } else {200- line.From.Col, line.To.Col = max(line.From.Col-1, 0), min(line.To.Col+1, MaxCols-1)201- }202- for _, b := range s.cellsIn(line) {203- if filled(b) && !r.Contains(b) {204- r, grew = union(r, Rect{b, b}), true205- break206- }175+ seen := map[Addr]bool{}176+ var queue []Addr177+ visit := func(p Addr) {178+ if !seen[p] && filled(p) {179+ seen[p] = true180+ queue = append(queue, p)181+ }182+ }183+ around := func(p Addr) {184+ for dr := -1; dr <= 1; dr++ {185+ for dc := -1; dc <= 1; dc++ {186+ visit(Addr{Col: p.Col + dc, Row: p.Row + dr})207187 }208188 }209189 }190+ around(a)191+ if len(queue) == 0 {192+ return Rect{a, a}193+ }194+ r := Rect{queue[0], queue[0]}195+ for len(queue) > 0 {196+ for len(queue) > 0 {197+ p := queue[0]198+ queue = queue[1:]199+ r = union(r, Rect{p, p})200+ around(p)201+ }202+ // Cells touching the bounding box join too.203+ for row := r.From.Row - 1; row <= r.To.Row+1; row++ {204+ visit(Addr{Col: r.From.Col - 1, Row: row})205+ visit(Addr{Col: r.To.Col + 1, Row: row})206+ }207+ for col := r.From.Col; col <= r.To.Col; col++ {208+ visit(Addr{Col: col, Row: r.From.Row - 1})209+ visit(Addr{Col: col, Row: r.To.Row + 1})210+ }211+ }210212 return r211213 }212214 @@ -14,8 +14,8 @@ const FileExt = ".012"
1414 1515 // fileVersion is the newest version Write produces and Read accepts.1616 // Version 1 files (cells as plain strings, no formatting) still load.17-// Version 3 adds named ranges; a sheet without them is written as version18-// 2, so earlier builds can still open it.17+// Version 3 adds named ranges, frozen panes and a filter; a sheet using18+// none of them is written as version 2, so earlier builds can open it.1919 const fileVersion = 32020 2121 // The file is JSON with one entry per cell, keyed by address. A cell@@ -28,11 +28,12 @@ const fileVersion = 3
2828 // Named ranges map each name to its range, or to "#REF!" once its cells2929 // were deleted: "names": {"Sales": "B2:B20"}.3030 type fileFormat struct {31- Version int `json:"version"`32- Widths map[string]int `json:"widths,omitempty"`33- Names map[string]string `json:"names,omitempty"`34- Cells map[string]json.RawMessage `json:"cells"`35- Charts []fileChart `json:"charts,omitempty"`31+ Version int `json:"version"`32+ Widths map[string]int `json:"widths,omitempty"`33+ Names map[string]string `json:"names,omitempty"`34+ fileView35+ Cells map[string]json.RawMessage `json:"cells"`36+ Charts []fileChart `json:"charts,omitempty"`3637 }3738 3839 // fileChart is a chart, one per line after the cells:@@ -146,7 +147,7 @@ func decodeCell(raw json.RawMessage) (string, Format, Style, error) {
146147 func (s *Sheet) Write(w io.Writer) error {147148 var b bytes.Buffer148149 version := 2149- if len(s.names) > 0 {150+ if v := s.view; len(s.names) > 0 || v.frozenRows > 0 || v.frozenCols > 0 || v.filter != nil {150151 version = 3151152 }152153 fmt.Fprintf(&b, "{\n \"version\": %d,\n", version)@@ -170,6 +171,9 @@ func (s *Sheet) Write(w io.Writer) error {
170171 }171172 b.WriteString("},\n")172173 }174+ if err := s.writeView(&b); err != nil {175+ return err176+ }173177 addrs := make([]Addr, 0, len(s.cells))174178 for a := range s.cells {175179 addrs = append(addrs, a)@@ -267,6 +271,9 @@ func Read(r io.Reader) (*Sheet, error) {
267271 }268272 s.charts = append(s.charts, c)269273 }274+ if err := s.readView(f.fileView); err != nil {275+ return nil, err276+ }270277 s.RecalcAll()271278 return s, nil272279 }@@ -0,0 +1,365 @@
1+package sheet2+3+import (4+ "cmp"5+ "maps"6+ "reflect"7+ "slices"8+ "strings"9+)10+11+// A filter hides the rows of a range whose values don't meet criteria set12+// per column, as Sheets' Data > Create a filter. Rows are hidden, never13+// deleted: formulas still see them (SUM over a filtered range includes14+// hidden rows, as in Sheets). The range's first row holds the headers and15+// is never hidden. Criteria are re-applied whenever values change.16+type Filter struct {17+ Range Rect18+ Cols map[int]Criteria // by column; a column without criteria hides nothing19+}20+21+// Criteria is what one column of a filter lets through: its displayed22+// value must not be one of Hidden (Sheets' "Filter by values", unchecked23+// values; "" stands for blanks) and must meet Cond ("Filter by condition").24+type Criteria struct {25+ Hidden []string26+ Cond Condition27+}28+29+// IsZero reports whether the criteria let every row through.30+func (c Criteria) IsZero() bool { return len(c.Hidden) == 0 && c.Cond.Op == CondNone }31+32+// Condition is a test on a cell, e.g. "greater than 100".33+type Condition struct {34+ Op CondOp35+ Arg string // the value compared with, as typed; unused by CondEmpty and CondNotEmpty36+}37+38+// CondOp is one of Sheets' filter conditions.39+type CondOp uint840+41+const (42+ CondNone CondOp = iota43+ CondEmpty44+ CondNotEmpty45+ CondContains46+ CondNotContains47+ CondStartsWith48+ CondEndsWith49+ CondExactly50+ CondGreater51+ CondGreaterEq52+ CondLess53+ CondLessEq54+ CondEqual55+ CondNotEqual56+ numCondOps57+)58+59+var condNames = [numCondOps]string{"", "empty", "not_empty", "contains", "not_contains",60+ "starts_with", "ends_with", "exactly", "gt", "ge", "lt", "le", "eq", "ne"}61+62+var condTitles = [numCondOps]string{"None", "Is empty", "Is not empty", "Text contains",63+ "Text does not contain", "Text starts with", "Text ends with", "Text is exactly",64+ "Greater than", "Greater than or equal to", "Less than", "Less than or equal to",65+ "Is equal to", "Is not equal to"}66+67+// CondOps lists the conditions in the order Sheets' menu shows them.68+func CondOps() []CondOp {69+ out := make([]CondOp, numCondOps)70+ for i := range out {71+ out[i] = CondOp(i)72+ }73+ return out74+}75+76+// String names the condition as stored in files.77+func (op CondOp) String() string { return condNames[op] }78+79+// Title names the condition for people, e.g. "Greater than".80+func (op CondOp) Title() string { return condTitles[op] }81+82+// TakesArg reports whether the condition compares with a value.83+func (op CondOp) TakesArg() bool { return op != CondNone && op != CondEmpty && op != CondNotEmpty }84+85+// ParseCondOp is the inverse of CondOp.String.86+func ParseCondOp(s string) (CondOp, bool) {87+ for i, n := range condNames {88+ if n == s {89+ return CondOp(i), true90+ }91+ }92+ return CondNone, false93+}94+95+// clone returns a deep copy, so a stored filter is never changed in place.96+func (f *Filter) clone() *Filter {97+ if f == nil {98+ return nil99+ }100+ cp := &Filter{Range: f.Range, Cols: make(map[int]Criteria, len(f.Cols))}101+ for c, cr := range f.Cols {102+ if cr.IsZero() {103+ continue104+ }105+ cr.Hidden = slices.Clone(cr.Hidden)106+ cp.Cols[c] = cr107+ }108+ return cp109+}110+111+// Filter returns a copy of the sheet's filter, or nil if it has none.112+func (s *Sheet) Filter() *Filter { return s.view.filter.clone() }113+114+// CreateFilter puts a filter on r, whose first row is the header row.115+func (s *Sheet) CreateFilter(r Rect) {116+ s.setFilter("create a filter on "+r.String(), &Filter{Range: r, Cols: map[int]Criteria{}})117+}118+119+// RemoveFilter removes the filter, showing every row again.120+func (s *Sheet) RemoveFilter() {121+ s.setFilter("remove the filter", nil)122+}123+124+// FilterColumn sets the criteria for column col of the filter.125+func (s *Sheet) FilterColumn(col int, cr Criteria) {126+ f := s.view.filter.clone()127+ if f == nil {128+ return129+ }130+ if cr.IsZero() {131+ delete(f.Cols, col)132+ } else {133+ cr.Hidden = slices.Clone(cr.Hidden)134+ slices.Sort(cr.Hidden)135+ cr.Hidden = slices.Compact(cr.Hidden)136+ f.Cols[col] = cr137+ }138+ s.setFilter("filter column "+ColName(col), f)139+}140+141+func (s *Sheet) setFilter(label string, f *Filter) {142+ if reflect.DeepEqual(f.clone(), s.view.filter.clone()) {143+ return144+ }145+ focus := Rect{}146+ if f != nil {147+ focus = f.Range148+ } else if old := s.view.filter; old != nil {149+ focus = old.Range150+ }151+ s.change(label, focus, func() {152+ s.recordView()153+ s.view.filter = f.clone()154+ s.hidden.valid = false155+ })156+}157+158+// hiddenCache remembers which rows the filter hides until values or the159+// filter change, so navigation and drawing stay cheap.160+type hiddenCache struct {161+ valid bool162+ rows map[int]struct{}163+}164+165+// RowHidden reports whether the filter hides row r.166+func (s *Sheet) RowHidden(r int) bool {167+ if s.view.filter == nil {168+ return false169+ }170+ s.updateHidden()171+ _, hid := s.hidden.rows[r]172+ return hid173+}174+175+// HiddenRows returns how many rows the filter hides.176+func (s *Sheet) HiddenRows() int {177+ if s.view.filter == nil {178+ return 0179+ }180+ s.updateHidden()181+ return len(s.hidden.rows)182+}183+184+func (s *Sheet) updateHidden() {185+ if s.hidden.valid {186+ return187+ }188+ s.hidden = hiddenCache{valid: true, rows: map[int]struct{}{}}189+ f := s.view.filter190+ if f == nil || len(f.Cols) == 0 {191+ return192+ }193+ tests := s.criteriaTests(f, -1)194+ for row := f.Range.From.Row + 1; row <= f.Range.To.Row; row++ {195+ if !s.rowPasses(row, tests) {196+ s.hidden.rows[row] = struct{}{}197+ }198+ }199+}200+201+// colTest is one column's criteria, ready to run.202+type colTest struct {203+ col int204+ hidden map[string]bool205+ cond func(Value, string) bool206+}207+208+// criteriaTests prepares the filter's criteria, leaving out column skip.209+func (s *Sheet) criteriaTests(f *Filter, skip int) []colTest {210+ var tests []colTest211+ for _, c := range slices.Sorted(maps.Keys(f.Cols)) {212+ cr := f.Cols[c]213+ if c == skip || cr.IsZero() {214+ continue215+ }216+ t := colTest{col: c, cond: cr.Cond.test()}217+ if len(cr.Hidden) > 0 {218+ t.hidden = make(map[string]bool, len(cr.Hidden))219+ for _, h := range cr.Hidden {220+ t.hidden[h] = true221+ }222+ }223+ tests = append(tests, t)224+ }225+ return tests226+}227+228+func (s *Sheet) rowPasses(row int, tests []colTest) bool {229+ for _, t := range tests {230+ a := Addr{Col: t.col, Row: row}231+ shown := s.ShownText(a)232+ if t.hidden[shown] || !t.cond(s.Value(a), shown) {233+ return false234+ }235+ }236+ return true237+}238+239+// ShownText is the cell's value as its format displays it, with no width240+// limit: what filters and the values list compare.241+func (s *Sheet) ShownText(a Addr) string {242+ c := s.cells[a]243+ if c.Blank() {244+ return ""245+ }246+ return FormatText(c.Value, s.DisplayFormat(a))247+}248+249+// test compiles the condition. Text conditions ignore case; comparisons250+// need a number for a number (entered as $1,200 or 12% too) and text for251+// text, and never match blanks.252+func (c Condition) test() func(v Value, shown string) bool {253+ arg := strings.ToLower(c.Arg)254+ argVal := Value{Kind: Text, Str: c.Arg}255+ if n, _, ok := ParseValue(strings.TrimSpace(c.Arg)); ok {256+ argVal = Value{Kind: Number, Num: n}257+ }258+ lower := func(s string) string { return strings.ToLower(s) }259+ cmpArg := func(v Value) (int, bool) {260+ if v.Kind != argVal.Kind || (v.Kind != Number && v.Kind != Text) {261+ return 0, false262+ }263+ return compare(v, argVal), true264+ }265+ ordered := func(ok func(int) bool) func(Value, string) bool {266+ return func(v Value, _ string) bool {267+ d, same := cmpArg(v)268+ return same && ok(d)269+ }270+ }271+ switch c.Op {272+ case CondEmpty:273+ return func(v Value, shown string) bool { return shown == "" }274+ case CondNotEmpty:275+ return func(v Value, shown string) bool { return shown != "" }276+ case CondContains:277+ return func(_ Value, shown string) bool { return strings.Contains(lower(shown), arg) }278+ case CondNotContains:279+ return func(_ Value, shown string) bool { return !strings.Contains(lower(shown), arg) }280+ case CondStartsWith:281+ return func(_ Value, shown string) bool { return strings.HasPrefix(lower(shown), arg) }282+ case CondEndsWith:283+ return func(_ Value, shown string) bool { return strings.HasSuffix(lower(shown), arg) }284+ case CondExactly:285+ return func(_ Value, shown string) bool { return lower(shown) == arg }286+ case CondGreater:287+ return ordered(func(d int) bool { return d > 0 })288+ case CondGreaterEq:289+ return ordered(func(d int) bool { return d >= 0 })290+ case CondLess:291+ return ordered(func(d int) bool { return d < 0 })292+ case CondLessEq:293+ return ordered(func(d int) bool { return d <= 0 })294+ case CondEqual:295+ return ordered(func(d int) bool { return d == 0 })296+ case CondNotEqual:297+ return func(v Value, shown string) bool {298+ d, same := cmpArg(v)299+ return !same || d != 0300+ }301+ }302+ return func(Value, string) bool { return true }303+}304+305+// FilterValue is one entry of a filter's values list: a value as shown,306+// how many rows have it, and whether it is checked (shown).307+type FilterValue struct {308+ Text string // "" for blanks309+ Count int310+ Shown bool311+}312+313+// FilterValues lists the distinct values in column col of the filter's314+// data rows, among the rows the other columns' criteria let through, as315+// Sheets' "Filter by values" does. Numbers come first in numeric order,316+// then text alphabetically, then blanks.317+func (s *Sheet) FilterValues(col int) []FilterValue {318+ f := s.view.filter319+ if f == nil {320+ return nil321+ }322+ tests := s.criteriaTests(f, col)323+ hidden := map[string]bool{}324+ for _, h := range f.Cols[col].Hidden {325+ hidden[h] = true326+ }327+ counts := map[string]int{}328+ values := map[string]Value{}329+ for row := f.Range.From.Row + 1; row <= f.Range.To.Row; row++ {330+ if !s.rowPasses(row, tests) {331+ continue332+ }333+ a := Addr{Col: col, Row: row}334+ t := s.ShownText(a)335+ if _, seen := counts[t]; !seen {336+ values[t] = s.Value(a)337+ }338+ counts[t]++339+ }340+ // Unchecked values stay listed even when no row has them any more, so341+ // they can be checked again.342+ for h := range hidden {343+ if _, ok := counts[h]; !ok {344+ counts[h] = 0345+ values[h] = Value{Kind: Text, Str: h}346+ if n, _, ok := ParseValue(h); ok {347+ values[h] = Value{Kind: Number, Num: n}348+ }349+ }350+ }351+ out := make([]FilterValue, 0, len(counts))352+ for t, n := range counts {353+ out = append(out, FilterValue{Text: t, Count: n, Shown: !hidden[t]})354+ }355+ slices.SortFunc(out, func(a, b FilterValue) int {356+ if (a.Text == "") != (b.Text == "") {357+ if a.Text == "" {358+ return 1359+ }360+ return -1361+ }362+ return cmp.Or(sortCompare(values[a.Text], values[b.Text]), cmp.Compare(a.Text, b.Text))363+ })364+ return out365+}@@ -0,0 +1,228 @@
1+package sheet2+3+import (4+ "bytes"5+ "reflect"6+ "testing"7+)8+9+// fruit is a small table with a header row, for filter tests.10+func fruit(t *testing.T) *Sheet {11+ return sheetOf(t, map[string]string{12+ "A1": "Fruit", "B1": "Qty",13+ "A2": "Apple", "B2": "10",14+ "A3": "Banana", "B3": "3",15+ "A4": "Cherry", "B4": "",16+ "A5": "apple pie", "B5": "$25",17+ "A6": "Date", "B6": "3",18+ "B8": "=SUM(B2:B6)",19+ })20+}21+22+// shownRows lists the rows (1-based) of 1..8 the filter doesn't hide.23+func shownRows(s *Sheet) []int {24+ var out []int25+ for r := range 8 {26+ if !s.RowHidden(r) {27+ out = append(out, r+1)28+ }29+ }30+ return out31+}32+33+func TestFilterConditions(t *testing.T) {34+ tests := []struct {35+ name string36+ col int37+ cr Criteria38+ want []int39+ }{40+ {"none", 0, Criteria{}, []int{1, 2, 3, 4, 5, 6, 7, 8}},41+ {"values", 1, Criteria{Hidden: []string{"3"}}, []int{1, 2, 4, 5, 7, 8}},42+ {"values as formatted, blanks as empty", 1, Criteria{Hidden: []string{"$25", ""}}, []int{1, 2, 3, 6, 7, 8}},43+ {"text contains ignores case", 0, Criteria{Cond: Condition{CondContains, "APP"}}, []int{1, 2, 5, 7, 8}},44+ {"text does not contain", 0, Criteria{Cond: Condition{CondNotContains, "app"}}, []int{1, 3, 4, 6, 7, 8}},45+ {"starts with", 0, Criteria{Cond: Condition{CondStartsWith, "b"}}, []int{1, 3, 7, 8}},46+ {"ends with", 0, Criteria{Cond: Condition{CondEndsWith, "e"}}, []int{1, 2, 5, 6, 7, 8}},47+ {"exactly", 0, Criteria{Cond: Condition{CondExactly, "apple"}}, []int{1, 2, 7, 8}},48+ {"greater than", 1, Criteria{Cond: Condition{CondGreater, "3"}}, []int{1, 2, 5, 7, 8}},49+ {"greater than a currency", 1, Criteria{Cond: Condition{CondGreater, "$20"}}, []int{1, 5, 7, 8}},50+ {"at least", 1, Criteria{Cond: Condition{CondGreaterEq, "10"}}, []int{1, 2, 5, 7, 8}},51+ {"less than skips blanks", 1, Criteria{Cond: Condition{CondLess, "10"}}, []int{1, 3, 6, 7, 8}},52+ {"at most", 1, Criteria{Cond: Condition{CondLessEq, "10"}}, []int{1, 2, 3, 6, 7, 8}},53+ {"equal", 1, Criteria{Cond: Condition{CondEqual, "3"}}, []int{1, 3, 6, 7, 8}},54+ {"not equal keeps blanks", 1, Criteria{Cond: Condition{CondNotEqual, "3"}}, []int{1, 2, 4, 5, 7, 8}},55+ {"empty", 1, Criteria{Cond: Condition{CondEmpty, ""}}, []int{1, 4, 7, 8}},56+ {"not empty", 1, Criteria{Cond: Condition{CondNotEmpty, ""}}, []int{1, 2, 3, 5, 6, 7, 8}},57+ {"values and condition together", 1, Criteria{Hidden: []string{"10"}, Cond: Condition{CondNotEmpty, ""}}, []int{1, 3, 5, 6, 7, 8}},58+ }59+ for _, tt := range tests {60+ t.Run(tt.name, func(t *testing.T) {61+ s := fruit(t)62+ s.CreateFilter(Rect{at("A1"), at("B6")})63+ s.FilterColumn(tt.col, tt.cr)64+ if got := shownRows(s); !reflect.DeepEqual(got, tt.want) {65+ t.Errorf("shown rows %v, want %v", got, tt.want)66+ }67+ // Hidden rows still count in formulas, as in Sheets.68+ if v := s.Value(at("B8")).Num; v != 41 {69+ t.Errorf("SUM = %v, want 41", v)70+ }71+ })72+ }73+}74+75+func TestFilterFollowsValuesAndUndo(t *testing.T) {76+ s := fruit(t)77+ s.CreateFilter(Rect{at("A1"), at("B6")})78+ s.FilterColumn(1, Criteria{Cond: Condition{CondGreater, "5"}})79+ if !s.RowHidden(2) || s.HiddenRows() != 3 {80+ t.Fatalf("row 3 shown, %d hidden", s.HiddenRows())81+ }82+ // Changing a value re-applies the filter.83+ s.Set(at("B3"), "30")84+ if s.RowHidden(2) {85+ t.Error("row 3 still hidden after its value passed")86+ }87+ s.Undo()88+ if !s.RowHidden(2) {89+ t.Error("undoing the edit didn't hide row 3 again")90+ }91+ s.Undo() // the condition92+ if s.HiddenRows() != 0 || s.Filter() == nil {93+ t.Errorf("after undoing the condition: %d hidden, filter %v", s.HiddenRows(), s.Filter())94+ }95+ s.Undo() // creating the filter96+ if s.Filter() != nil {97+ t.Error("undo didn't remove the filter")98+ }99+ s.Redo()100+ s.Redo()101+ if s.HiddenRows() != 3 {102+ t.Errorf("redo: %d hidden", s.HiddenRows())103+ }104+ s.RemoveFilter()105+ if s.Filter() != nil || s.HiddenRows() != 0 {106+ t.Error("remove left the filter")107+ }108+}109+110+func TestFilterValues(t *testing.T) {111+ s := fruit(t)112+ s.CreateFilter(Rect{at("A1"), at("B6")})113+ s.FilterColumn(1, Criteria{Hidden: []string{"3", "gone"}})114+ want := []FilterValue{{"3", 2, false}, {"10", 1, true}, {"$25", 1, true}, {"gone", 0, false}, {"", 1, true}}115+ if got := s.FilterValues(1); !reflect.DeepEqual(got, want) {116+ t.Errorf("values\n got %v\nwant %v", got, want)117+ }118+ // Other columns' criteria narrow the list.119+ s.FilterColumn(0, Criteria{Cond: Condition{CondContains, "apple"}})120+ want = []FilterValue{{"3", 0, false}, {"10", 1, true}, {"$25", 1, true}, {"gone", 0, false}}121+ if got := s.FilterValues(1); !reflect.DeepEqual(got, want) {122+ t.Errorf("narrowed values\n got %v\nwant %v", got, want)123+ }124+}125+126+func TestFilterFollowsInsertAndDelete(t *testing.T) {127+ s := fruit(t)128+ s.CreateFilter(Rect{at("A1"), at("B6")})129+ s.FilterColumn(1, Criteria{Hidden: []string{"3"}})130+ s.InsertRows(2, 2) // inside the range: it grows131+ if f := s.Filter(); f.Range.String() != "A1:B8" {132+ t.Errorf("after insert: %v", f.Range)133+ }134+ s.InsertCols(0, 1) // before it: it moves, and so do its columns135+ f := s.Filter()136+ if f.Range.String() != "B1:C8" || f.Cols[2].Hidden[0] != "3" {137+ t.Errorf("after insert column: %v %v", f.Range, f.Cols)138+ }139+ s.DeleteCols(1, 2) // all of it: the filter goes140+ if s.Filter() != nil {141+ t.Error("filter survived deleting its columns")142+ }143+ s.Undo()144+ if s.Filter() == nil {145+ t.Error("undo didn't bring the filter back")146+ }147+}148+149+func TestEdgeSkipsHiddenRows(t *testing.T) {150+ s := fruit(t)151+ s.CreateFilter(Rect{at("A1"), at("B6")})152+ s.FilterColumn(1, Criteria{Cond: Condition{CondEqual, "3"}}) // rows 1, 3 and 6 show153+ tests := []struct {154+ from string155+ dr int156+ want string157+ }{158+ {"A1", 1, "A6"}, // through the visible block 1, 3, 6159+ {"A6", -1, "A1"},160+ {"B1", 1, "B6"},161+ {"B6", 1, "B8"}, // row 7 is blank, then the total162+ }163+ for _, tt := range tests {164+ if got := s.Edge(at(tt.from), 0, tt.dr).String(); got != tt.want {165+ t.Errorf("Edge(%s, %d) = %s, want %s", tt.from, tt.dr, got, tt.want)166+ }167+ }168+}169+170+func TestFreeze(t *testing.T) {171+ s := New()172+ s.SetFrozen(2, 1)173+ if r, c := s.Frozen(); r != 2 || c != 1 {174+ t.Fatalf("frozen %d, %d", r, c)175+ }176+ s.InsertRows(1, 3) // inside the frozen rows: they grow177+ if r, _ := s.Frozen(); r != 5 {178+ t.Errorf("after insert: %d frozen rows", r)179+ }180+ s.DeleteRows(0, 4)181+ if r, _ := s.Frozen(); r != 1 {182+ t.Errorf("after delete: %d frozen rows", r)183+ }184+ s.InsertCols(0, 1) // before the frozen column: still one frozen185+ if _, c := s.Frozen(); c != 1 {186+ t.Errorf("after insert column: %d frozen columns", c)187+ }188+ for s.CanUndo() {189+ s.Undo()190+ }191+ if r, c := s.Frozen(); r != 0 || c != 0 {192+ t.Errorf("undo all: frozen %d, %d", r, c)193+ }194+ c, ok := s.Redo()195+ if !ok || c.Label != "freeze 2 rows" {196+ t.Errorf("redo %v %v", c, ok)197+ }198+}199+200+func TestViewFileRoundTrip(t *testing.T) {201+ s := fruit(t)202+ s.SetFrozen(1, 2)203+ s.CreateFilter(Rect{at("A1"), at("B6")})204+ s.FilterColumn(1, Criteria{Hidden: []string{"3", ""}, Cond: Condition{CondLess, "$20"}})205+ var buf bytes.Buffer206+ if err := s.Write(&buf); err != nil {207+ t.Fatal(err)208+ }209+ for _, want := range []string{`"version": 3`, `"freeze": {"rows":1,"cols":2}`,210+ `"filter": {"range":"A1:B6","columns":{"B":{"hidden":["","3"],"condition":"lt","value":"$20"}}}`} {211+ if !bytes.Contains(buf.Bytes(), []byte(want)) {212+ t.Errorf("file lacks %s:\n%s", want, buf.String())213+ }214+ }215+ back, err := Read(&buf)216+ if err != nil {217+ t.Fatal(err)218+ }219+ if r, c := back.Frozen(); r != 1 || c != 2 {220+ t.Errorf("frozen %d, %d", r, c)221+ }222+ if !reflect.DeepEqual(back.Filter(), s.Filter()) || !reflect.DeepEqual(shownRows(back), shownRows(s)) {223+ t.Errorf("filter %+v, want %+v", back.Filter(), s.Filter())224+ }225+ if back.CanUndo() {226+ t.Error("loading is undoable")227+ }228+}@@ -33,6 +33,7 @@ type step struct {
3333 cells map[Addr]*Cell // before the step; nil for blank3434 widths map[int]int // before the step; 0 for the default width3535 names map[string]*Name // before the step, by key; nil for undefined36+ view *viewState // before the step; nil if the step didn't touch it3637 // charts is every chart before the step, when it changed any.3738 charts []Chart3839 chartsSaved bool@@ -43,7 +44,7 @@ func newStep(label string, focus Rect) *step {
4344 }4445 4546 func (st *step) empty() bool {46- return len(st.cells) == 0 && len(st.widths) == 0 && len(st.names) == 0 && !st.chartsSaved47+ return len(st.cells) == 0 && len(st.widths) == 0 && len(st.names) == 0 && st.view == nil && !st.chartsSaved4748 }4849 4950 func (c *Cell) clone() *Cell {@@ -157,13 +158,16 @@ func (s *Sheet) push(st *step) {
157158 if st.chartsSaved && slices.Equal(st.charts, s.charts) {158159 st.charts, st.chartsSaved = nil, false159160 }161+ if st.view != nil && st.view.equal(s.view) {162+ st.view = nil163+ }160164 if st.empty() {161165 return162166 }163167 h.redo = nil164168 h.lastID++165- widthOnly := len(st.cells) == 0 && len(st.names) == 0 && !st.chartsSaved166- if top := h.top(); widthOnly && h.mergeWidths && top != nil && len(top.cells) == 0 && len(top.names) == 0 && !top.chartsSaved {169+ widthOnly := len(st.cells) == 0 && len(st.names) == 0 && st.view == nil && !st.chartsSaved170+ if top := h.top(); widthOnly && h.mergeWidths && top != nil && len(top.cells) == 0 && len(top.names) == 0 && top.view == nil && !top.chartsSaved {167171 for c, w := range st.widths {168172 if _, ok := top.widths[c]; !ok {169173 top.widths[c] = w@@ -253,6 +257,10 @@ func (s *Sheet) swap(from, to *[]*step) (Change, bool) {
253257 inv.charts, inv.chartsSaved = s.charts, true254258 s.charts = slices.Clone(st.charts)255259 }260+ if st.view != nil {261+ v := s.view262+ inv.view, s.view = &v, *st.view263+ }256264 s.recalc(changed)257265 *to = append(*to, inv)258266 s.hist.mergeWidths = false@@ -20,16 +20,23 @@ func (s *Sheet) UsedRange() (Rect, bool) {
2020 // 1-2-3) from a in direction (dc, dr) lands. Inside a block of filled cells2121 // it stops at the block's last cell; from a blank cell, or at the end of a2222 // block, it goes to the next filled cell. With nothing filled ahead it23-// stops at the edge of the worksheet.23+// stops at the edge of the worksheet. Rows the filter hides are skipped.2424 func (s *Sheet) Edge(a Addr, dc, dr int) Addr {2525 filled := func(a Addr) bool { return !s.cells[a].Blank() }26- next := Addr{Col: a.Col + dc, Row: a.Row + dr}26+ step := func(a Addr) Addr {27+ n := Addr{Col: a.Col + dc, Row: a.Row + dr}28+ for dr != 0 && n.Valid() && s.RowHidden(n.Row) {29+ n.Row += dr30+ }31+ return n32+ }33+ next := step(a)2734 if !next.Valid() {2835 return a2936 }3037 if filled(a) && filled(next) {3138 for {32- n := Addr{Col: next.Col + dc, Row: next.Row + dr}39+ n := step(next)3340 if !n.Valid() || !filled(n) {3441 return next3542 }@@ -40,7 +47,7 @@ func (s *Sheet) Edge(a Addr, dc, dr int) Addr {
4047 if filled(next) {4148 return next4249 }43- n := Addr{Col: next.Col + dc, Row: next.Row + dr}50+ n := step(next)4451 if !n.Valid() {4552 return next4653 }@@ -105,6 +105,7 @@ func (s *Sheet) restructure(rows bool, sp span) {
105105 if !rows {106106 s.shiftWidths(sp)107107 }108+ s.shiftView(rows, sp)108109 })109110 }110111 @@ -261,8 +262,14 @@ func valueInput(v Value) string {
261262 }262263 263264 // FillDown copies the top row of r into the rest of it, adjusting264-// references (Ctrl+D). A single-row range fills from the row above.265+// references (Ctrl+D). A single-row range fills from the row above. When266+// the top rows of r start a series and the rest is blank (1, 2 and then267+// empty cells), it continues the series instead, as dragging the fill268+// handle would.265269 func (s *Sheet) FillDown(r Rect) (Rect, error) {270+ if src, ok := s.seriesStart(r, true); ok {271+ return s.FillSeries(src, r)272+ }266273 src := Rect{r.From, Addr{Col: r.To.Col, Row: r.From.Row}}267274 if r.From.Row == r.To.Row {268275 if r.From.Row == 0 {@@ -278,8 +285,12 @@ func (s *Sheet) FillDown(r Rect) (Rect, error) {
278285 }279286 280287 // FillRight copies the left column of r into the rest of it (Ctrl+R). A281-// single-column range fills from the column to the left.288+// single-column range fills from the column to the left. Like FillDown,289+// it continues a series started in the leftmost columns.282290 func (s *Sheet) FillRight(r Rect) (Rect, error) {291+ if src, ok := s.seriesStart(r, false); ok {292+ return s.FillSeries(src, r)293+ }283294 src := Rect{r.From, Addr{Col: r.From.Col, Row: r.To.Row}}284295 if r.From.Col == r.To.Col {285296 if r.From.Col == 0 {@@ -320,4 +331,36 @@ func (s *Sheet) FillEntry(r Rect, origin Addr, input string) error {
320331 })321332 }322333 334+// seriesStart finds the leading rows (down) or columns of r that hold335+// something, when there are at least two of them and everything after336+// them in r is blank: the start of a series to fill.337+func (s *Sheet) seriesStart(r Rect, down bool) (Rect, bool) {338+ line := func(a Addr) int {339+ if down {340+ return a.Row - r.From.Row341+ }342+ return a.Col - r.From.Col343+ }344+ used := map[int]bool{}345+ for _, a := range s.cellsIn(r) {346+ if !s.cells[a].Blank() {347+ used[line(a)] = true348+ }349+ }350+ n := 0351+ for used[n] {352+ n++353+ }354+ if n < 2 || len(used) != n {355+ return Rect{}, false356+ }357+ src := r358+ if down {359+ src.To.Row = r.From.Row + n - 1360+ } else {361+ src.To.Col = r.From.Col + n - 1362+ }363+ return src, src != r364+}365+323366 func abs(n int) int { return max(n, -n) }@@ -0,0 +1,322 @@
1+package sheet2+3+import (4+ "math"5+ "regexp"6+ "strconv"7+ "strings"8+)9+10+// Fill series: dragging the fill handle in Sheets continues what the11+// selected cells start. 1, 2 goes on 3, 4; 2, 4 goes on 6, 8 (with more12+// cells, along their linear trend); dates go on by day, or by month when13+// they fall on the same day of the month; Jan, Feb goes on Mar and Mon,14+// Tue goes on Wed; "Item 1" goes on "Item 2". A single number, and15+// anything else (text, formulas), is copied, formulas adjusting their16+// references as when pasted.17+18+// FillSeries extends src over dst, which contains it and reaches past it19+// along one axis: down or up, right or left. Each column (or row) of src20+// is continued on its own. It returns the range filled, as one undo step.21+func (s *Sheet) FillSeries(src, dst Rect) (Rect, error) {22+ w, h := dst.To.Col-dst.From.Col+1, dst.To.Row-dst.From.Row+123+ if w*h > maxFill {24+ return Rect{}, ErrFillTooBig25+ }26+ vertical := dst.From.Col == src.From.Col && dst.To.Col == src.To.Col27+ if !vertical && (dst.From.Row != src.From.Row || dst.To.Row != src.To.Row) || dst == src {28+ return src, nil29+ }30+ var lanes [][2][]Addr // per lane: the source cells in fill order, then the targets31+ lane := func(from, step Addr, n int, to Addr, m int) {32+ var seq, targets []Addr33+ for i := range n {34+ seq = append(seq, Addr{Col: from.Col + step.Col*i, Row: from.Row + step.Row*i})35+ }36+ for i := range m {37+ targets = append(targets, Addr{Col: to.Col + step.Col*i, Row: to.Row + step.Row*i})38+ }39+ lanes = append(lanes, [2][]Addr{seq, targets})40+ }41+ n := src.To.Row - src.From.Row + 142+ if !vertical {43+ n = src.To.Col - src.From.Col + 144+ }45+ switch {46+ case vertical && dst.To.Row > src.To.Row:47+ for c := src.From.Col; c <= src.To.Col; c++ {48+ lane(Addr{Col: c, Row: src.From.Row}, Addr{Row: 1}, n, Addr{Col: c, Row: src.To.Row + 1}, dst.To.Row-src.To.Row)49+ }50+ case vertical:51+ for c := src.From.Col; c <= src.To.Col; c++ {52+ lane(Addr{Col: c, Row: src.To.Row}, Addr{Row: -1}, n, Addr{Col: c, Row: src.From.Row - 1}, src.From.Row-dst.From.Row)53+ }54+ case dst.To.Col > src.To.Col:55+ for r := src.From.Row; r <= src.To.Row; r++ {56+ lane(Addr{Col: src.From.Col, Row: r}, Addr{Col: 1}, n, Addr{Col: src.To.Col + 1, Row: r}, dst.To.Col-src.To.Col)57+ }58+ default:59+ for r := src.From.Row; r <= src.To.Row; r++ {60+ lane(Addr{Col: src.To.Col, Row: r}, Addr{Col: -1}, n, Addr{Col: src.From.Col - 1, Row: r}, src.From.Col-dst.From.Col)61+ }62+ }63+ s.change("fill "+dst.String(), dst, func() {64+ for _, l := range lanes {65+ s.fillLane(l[0], l[1])66+ }67+ })68+ return dst, nil69+}70+71+// fillLane continues the cells at seq into targets.72+func (s *Sheet) fillLane(seq, targets []Addr) {73+ cells := make([]*Cell, len(seq))74+ for i, a := range seq {75+ cells[i] = s.cells[a]76+ }77+ next := detectSeries(cells)78+ for i, to := range targets {79+ k := i % len(seq)80+ c := cells[k]81+ switch {82+ case next != nil:83+ nc, err := newCell(next(len(seq)+i), c.Format, c.Style, false)84+ if err == nil {85+ s.place(to, nc)86+ continue87+ }88+ fallthrough89+ case c != nil:90+ s.pasteCell(to, c, to.Col-seq[k].Col, to.Row-seq[k].Row, false)91+ case s.cells[to] != nil:92+ s.place(to, nil)93+ }94+ }95+}96+97+// detectSeries returns the entry for position i of the series cells98+// start, or nil if they should be copied instead.99+func detectSeries(cells []*Cell) func(i int) string {100+ for _, c := range cells {101+ if c.Blank() || c.IsFormula() {102+ return nil103+ }104+ }105+ if f := numberSeries(cells); f != nil {106+ return f107+ }108+ if f := nameSeries(cells); f != nil {109+ return f110+ }111+ return textNumberSeries(cells)112+}113+114+// numberSeries continues numbers along their linear trend (a least115+// squares fit, which for evenly spaced numbers is exact), and dates by116+// day or by month. A single number is copied; a single date goes on by117+// a day.118+func numberSeries(cells []*Cell) func(int) string {119+ vals := make([]float64, len(cells))120+ for i, c := range cells {121+ if _, ok := c.expr.(numLit); !ok || c.Value.Kind != Number {122+ return nil123+ }124+ vals[i] = c.Value.Num125+ }126+ f := cells[0].Format127+ date := f.Kind == FmtDate || f.Kind == FmtDateTime128+ k := len(vals)129+ input := func(i int, v float64) string { return numberInput(v, cells[i%k].Format) }130+ if k == 1 {131+ if !date {132+ return nil133+ }134+ return func(i int) string { return input(i, vals[0]+float64(i)) }135+ }136+ if date {137+ if step, ok := monthStep(vals); ok {138+ y, m, d := civil(int64(vals[0]))139+ return func(i int) string {140+ mm := m + step*i141+ y2, m2 := y+floorDivInt(mm-1, 12), (mm-1)%12+1142+ if m2 <= 0 {143+ m2 += 12144+ }145+ day := min(d, daysIn(y2, m2))146+ return input(i, dateSerial(y2, m2, day)+vals[0]-math.Floor(vals[0]))147+ }148+ }149+ }150+ var mi, mv float64151+ for i, v := range vals {152+ mi += float64(i)153+ mv += v154+ }155+ mi, mv = mi/float64(k), mv/float64(k)156+ var num, den float64157+ for i, v := range vals {158+ num += (float64(i) - mi) * (v - mv)159+ den += (float64(i) - mi) * (float64(i) - mi)160+ }161+ b := num / den162+ a := mv - b*mi163+ return func(i int) string { return input(i, a+b*float64(i)) }164+}165+166+// monthStep reports whether dates fall on the same day of evenly spaced167+// months, and the spacing.168+func monthStep(vals []float64) (int, bool) {169+ months := func(v float64) (int, int) {170+ y, m, d := civil(int64(math.Floor(v)))171+ return y*12 + m - 1, d172+ }173+ m0, d0 := months(vals[0])174+ m1, _ := months(vals[1])175+ step := m1 - m0176+ if step == 0 {177+ return 0, false178+ }179+ for i, v := range vals {180+ m, d := months(v)181+ if d != d0 || m != m0+step*i {182+ return 0, false183+ }184+ }185+ return step, true186+}187+188+// numberInput is the entry for v in format f: as the format shows it189+// when that reads back exactly ($1,300.00, 10/2/2026), else plain.190+func numberInput(v float64, f Format) string {191+ // Fifteen significant digits drop float noise (0.1+0.2).192+ v, _ = strconv.ParseFloat(strconv.FormatFloat(v, 'g', 15, 64), 64)193+ if f.Kind != FmtAuto && f.Kind != FmtText {194+ t := FormatText(Value{Kind: Number, Num: v}, f)195+ if back, _, ok := ParseValue(t); ok && back == v {196+ return t197+ }198+ }199+ return strconv.FormatFloat(v, 'f', -1, 64)200+}201+202+// A list of names that fills in a cycle: month and day names, long or203+// short.204+type nameList struct {205+ long []string206+ short int // letters in the short form207+}208+209+var nameLists = []nameList{{monthNames[:], 3}, {dayNames[:], 3}}210+211+// nameAt finds a month or day name: which list, the index, whether it is212+// long, and whether it could be either (May).213+func nameAt(w string) (list, idx int, long, either, ok bool) {214+ lw := strings.ToLower(w)215+ for li, l := range nameLists {216+ for i, n := range l.long {217+ n = strings.ToLower(n)218+ full, short := lw == n, lw == n[:l.short]219+ if full || short {220+ return li, i, full, full && short, true221+ }222+ }223+ }224+ return 0, 0, false, false, false225+}226+227+// nameSeries continues month or day names in steps, keeping the case and228+// length of the first: Jan, Feb goes on Mar; MONDAY goes on TUESDAY.229+func nameSeries(cells []*Cell) func(int) string {230+ var list, step, first int231+ long, known := true, false232+ idx := make([]int, len(cells))233+ for i, c := range cells {234+ if c.Value.Kind != Text {235+ return nil236+ }237+ l, n, lg, either, ok := nameAt(c.Value.Str)238+ if !ok || i > 0 && l != list {239+ return nil240+ }241+ if !either {242+ if known && lg != long {243+ return nil244+ }245+ long, known = lg, true246+ }247+ list, idx[i] = l, n248+ }249+ size := len(nameLists[list].long)250+ first = idx[0]251+ step = 1252+ if len(idx) > 1 {253+ step = ((idx[1]-idx[0])%size + size) % size254+ for i := 1; i < len(idx); i++ {255+ if ((idx[i]-idx[i-1])%size+size)%size != step {256+ return nil257+ }258+ }259+ }260+ sample := cells[0].Value.Str261+ return func(i int) string {262+ name := nameLists[list].long[((first+step*i)%size+size)%size]263+ if !long {264+ name = name[:nameLists[list].short]265+ }266+ switch {267+ case sample == strings.ToUpper(sample):268+ name = strings.ToUpper(name)269+ case sample == strings.ToLower(sample):270+ name = strings.ToLower(name)271+ }272+ return name273+ }274+}275+276+var trailingNumber = regexp.MustCompile(`^(.*?)(\d+)$`)277+278+// textNumberSeries continues text ending in a number: Item 1, Item 2 goes279+// on Item 3, keeping zero padding (Q01, Q02, Q03). A single one counts up280+// by one.281+func textNumberSeries(cells []*Cell) func(int) string {282+ var prefix, quote string283+ width := 0284+ nums := make([]int, len(cells))285+ for i, c := range cells {286+ if c.Value.Kind != Text || c.expr != nil {287+ return nil288+ }289+ m := trailingNumber.FindStringSubmatch(c.Value.Str)290+ if m == nil || i > 0 && m[1] != prefix {291+ return nil292+ }293+ n, err := strconv.Atoi(m[2])294+ if err != nil {295+ return nil296+ }297+ prefix, nums[i] = m[1], n298+ if strings.HasPrefix(m[2], "0") {299+ width = len(m[2])300+ }301+ if strings.HasPrefix(c.Input, "'") {302+ quote = "'"303+ }304+ }305+ step := 1306+ if len(nums) > 1 {307+ step = nums[1] - nums[0]308+ for i := 2; i < len(nums); i++ {309+ if nums[i]-nums[i-1] != step {310+ return nil311+ }312+ }313+ }314+ return func(i int) string {315+ n := nums[0] + step*i316+ digits := strconv.Itoa(max(n, -n))317+ if len(digits) < width {318+ digits = strings.Repeat("0", width-len(digits)) + digits319+ }320+ return quote + prefix + digits321+ }322+}@@ -0,0 +1,120 @@
1+package sheet2+3+import (4+ "reflect"5+ "testing"6+)7+8+func TestFillSeries(t *testing.T) {9+ tests := []struct {10+ name string11+ src []string // A1 down12+ n int // cells to fill below13+ want []string14+ }{15+ {"counting", []string{"1", "2"}, 3, []string{"3", "4", "5"}},16+ {"even numbers", []string{"2", "4", "6"}, 2, []string{"8", "10"}},17+ {"decimals without float noise", []string{"0.1", "0.2"}, 2, []string{"0.3", "0.4"}},18+ {"linear trend", []string{"1", "2", "4", "5"}, 1, []string{"6.5"}},19+ {"counting down", []string{"10", "7"}, 2, []string{"4", "1"}},20+ {"a single number is copied", []string{"5"}, 2, []string{"5", "5"}},21+ {"currency keeps its format", []string{"$100", "$150"}, 1, []string{"$200"}},22+ {"months", []string{"Jan", "Feb"}, 2, []string{"Mar", "Apr"}},23+ {"months wrap around", []string{"Nov"}, 3, []string{"Dec", "Jan", "Feb"}},24+ {"long months, every other", []string{"January", "March"}, 1, []string{"May"}},25+ {"May reads as long or short", []string{"Apr", "May"}, 1, []string{"Jun"}},26+ {"days keep their case", []string{"MON", "TUE"}, 2, []string{"WED", "THU"}},27+ {"long days", []string{"friday"}, 2, []string{"saturday", "sunday"}},28+ {"text with a number", []string{"Item 1"}, 2, []string{"Item 2", "Item 3"}},29+ {"text with a step", []string{"Q1", "Q3"}, 1, []string{"Q5"}},30+ {"zero padding", []string{"Run 08", "Run 09"}, 2, []string{"Run 10", "Run 11"}},31+ {"dates by day", []string{"9/26/2026"}, 2, []string{"9/27/2026", "9/28/2026"}},32+ {"dates by week", []string{"9/26/2026", "10/3/2026"}, 1, []string{"10/10/2026"}},33+ {"different days of the month go on by days", []string{"1/31/2026", "2/28/2026"}, 1, []string{"3/28/2026"}},34+ {"dates on the same day by month", []string{"1/15/2026", "3/15/2026"}, 2, []string{"5/15/2026", "7/15/2026"}},35+ {"plain text is copied in turn", []string{"a", "b"}, 3, []string{"a", "b", "a"}},36+ {"mixed is copied", []string{"1", "x"}, 2, []string{"1", "x"}},37+ {"formulas are copied with their references", []string{"=B1", "=B2*2"}, 2, []string{"=B3", "=B4*2"}},38+ }39+ for _, tt := range tests {40+ t.Run(tt.name, func(t *testing.T) {41+ s := New()42+ for i, in := range tt.src {43+ s.Set(Addr{Row: i}, in)44+ }45+ s.ClearHistory()46+ src := Rect{To: Addr{Row: len(tt.src) - 1}}47+ dst := Rect{To: Addr{Row: len(tt.src) + tt.n - 1}}48+ got, err := s.FillSeries(src, dst)49+ if err != nil || got != dst {50+ t.Fatalf("FillSeries = %v, %v", got, err)51+ }52+ if got := column(s, "A", len(tt.src)+1, len(tt.src)+tt.n); !reflect.DeepEqual(got, tt.want) {53+ t.Errorf("filled %q, want %q", got, tt.want)54+ }55+ s.Undo()56+ if s.CanUndo() || s.Len() != len(tt.src) {57+ t.Errorf("fill isn't one undo step: %v", inputs(s))58+ }59+ })60+ }61+}62+63+func TestFillSeriesDirections(t *testing.T) {64+ s := sheetOf(t, map[string]string{"C3": "5", "C4": "6", "D3": "Mon", "D4": "Tue"})65+ // Up continues backwards.66+ s.FillSeries(Rect{at("C3"), at("D4")}, Rect{at("C1"), at("D4")})67+ if got := column(s, "C", 1, 2); !reflect.DeepEqual(got, []string{"3", "4"}) {68+ t.Errorf("up: C = %q", got)69+ }70+ if got := column(s, "D", 1, 2); !reflect.DeepEqual(got, []string{"Sat", "Sun"}) {71+ t.Errorf("up: D = %q", got)72+ }73+ // Right and left, per row.74+ s = sheetOf(t, map[string]string{"B1": "1", "C1": "3", "B2": "x"})75+ s.FillSeries(Rect{at("B1"), at("C2")}, Rect{at("A1"), at("C2")})76+ s.FillSeries(Rect{at("A1"), at("C2")}, Rect{at("A1"), at("E2")})77+ want := map[string]string{"A1": "-1", "B1": "1", "C1": "3", "D1": "5", "E1": "7", "A2": "", "B2": "x", "D2": "", "E2": "x"}78+ for a, in := range want {79+ if got := input(s, a); got != in {80+ t.Errorf("%s = %q, want %q", a, got, in)81+ }82+ }83+}84+85+func input(s *Sheet, a string) string {86+ if c := s.Cell(at(a)); c != nil {87+ return c.Input88+ }89+ return ""90+}91+92+func TestFillDownContinuesSeries(t *testing.T) {93+ tests := []struct {94+ name string95+ cells map[string]string96+ fill string97+ want []string98+ }{99+ {"a series with blanks below continues", map[string]string{"A1": "1", "A2": "2"}, "A1:A5", []string{"1", "2", "3", "4", "5"}},100+ {"one row is copied, as Sheets' Ctrl+D", map[string]string{"A1": "Item 1"}, "A1:A3", []string{"Item 1", "Item 1", "Item 1"}},101+ {"filled cells below: copy the top row", map[string]string{"A1": "1", "A2": "2", "A4": "x"}, "A1:A4", []string{"1", "1", "1", "1"}},102+ }103+ for _, tt := range tests {104+ t.Run(tt.name, func(t *testing.T) {105+ s := sheetOf(t, tt.cells)106+ r, _ := ParseRange(tt.fill)107+ if _, err := s.FillDown(r); err != nil {108+ t.Fatal(err)109+ }110+ if got := column(s, "A", 1, len(tt.want)); !reflect.DeepEqual(got, tt.want) {111+ t.Errorf("A = %q, want %q", got, tt.want)112+ }113+ })114+ }115+ s := sheetOf(t, map[string]string{"A1": "Jan", "B1": "Feb"})116+ s.FillRight(Rect{at("A1"), at("D1")})117+ if got := input(s, "D1"); got != "Apr" {118+ t.Errorf("fill right: D1 = %q", got)119+ }120+}@@ -69,6 +69,9 @@ type Sheet struct {
6969 charts []Chart // floating charts, bottom first; see chart.go7070 7171 hist history // undo and redo, see history.go72+73+ view viewState // frozen panes and the filter, see view.go74+ hidden hiddenCache // rows the filter hides, see filter.go7275 }7376 7477 // New returns an empty worksheet.@@ -400,6 +403,7 @@ func (s *Sheet) recalc(changed []Addr) {
400403 }401404 402405 s.Circular = false406+ s.hidden.valid = false // values may have changed what the filter hides403407 var compute func(Addr) Value404408 compute = func(a Addr) Value {405409 c := s.cells[a]@@ -0,0 +1,108 @@
1+package sheet2+3+import (4+ "slices"5+ "strings"6+)7+8+// SortKey is one column to sort by.9+type SortKey struct {10+ Col int11+ Desc bool // Z to A12+}13+14+// sortRank orders kinds as Sheets sorts them A to Z: numbers (dates are15+// numbers), then text, then booleans, then errors. Blanks always go last,16+// in either direction.17+func sortRank(v Value) int {18+ switch v.Kind {19+ case Number:20+ return 021+ case Text:22+ return 123+ case Bool:24+ return 225+ case Error:26+ return 327+ }28+ return 429+}30+31+// sortCompare orders two values A to Z: by kind, then numerically, or32+// alphabetically ignoring case.33+func sortCompare(a, b Value) int {34+ if d := sortRank(a) - sortRank(b); d != 0 {35+ return d36+ }37+ switch a.Kind {38+ case Number, Bool:39+ switch {40+ case a.Num < b.Num:41+ return -142+ case a.Num > b.Num:43+ return 144+ }45+ case Text, Error:46+ return strings.Compare(strings.ToLower(a.Str), strings.ToLower(b.Str))47+ }48+ return 049+}50+51+// SortRange sorts the rows of r by keys, first key first, as Sheets' Data52+// > Sort range does (r excludes any header row). The sort is stable, so53+// rows that tie keep their order. Only the cells inside r move. Formulas54+// move with their rows and their relative references shift by the55+// distance moved, as if copied there; references to the sorted cells56+// from elsewhere are left alone. The whole sort is one undo step.57+func (s *Sheet) SortRange(r Rect, keys []SortKey) {58+ if used, ok := s.UsedRange(); ok {59+ r.To.Row, r.To.Col = min(r.To.Row, used.To.Row), min(r.To.Col, used.To.Col)60+ }61+ if len(keys) == 0 || r.To.Row <= r.From.Row || r.To.Col < r.From.Col {62+ return63+ }64+ order := make([]int, r.To.Row-r.From.Row+1)65+ for i := range order {66+ order[i] = r.From.Row + i67+ }68+ slices.SortStableFunc(order, func(i, j int) int {69+ for _, k := range keys {70+ a, b := s.Value(Addr{Col: k.Col, Row: i}), s.Value(Addr{Col: k.Col, Row: j})71+ if (a.Kind == Empty) != (b.Kind == Empty) { // blanks last either way72+ if a.Kind == Empty {73+ return 174+ }75+ return -176+ }77+ d := sortCompare(a, b)78+ if k.Desc {79+ d = -d80+ }81+ if d != 0 {82+ return d83+ }84+ }85+ return 086+ })87+ old := map[Addr]*Cell{}88+ for _, a := range s.cellsIn(r) {89+ old[a] = s.cells[a]90+ }91+ s.change("sort "+r.String(), r, func() {92+ for k, src := range order {93+ dst := r.From.Row + k94+ if dst == src {95+ continue96+ }97+ for col := r.From.Col; col <= r.To.Col; col++ {98+ to := Addr{Col: col, Row: dst}99+ switch c := old[Addr{Col: col, Row: src}]; {100+ case c != nil:101+ s.place(to, c.rewritten(shiftRefs(0, dst-src)))102+ case s.cells[to] != nil:103+ s.place(to, nil)104+ }105+ }106+ }107+ })108+}@@ -0,0 +1,123 @@
1+package sheet2+3+import (4+ "reflect"5+ "testing"6+)7+8+// column returns the inputs of rows from..to of column col.9+func column(s *Sheet, col string, from, to int) []string {10+ var out []string11+ for r := from; r <= to; r++ {12+ c := s.Cell(at(col + itoa(r)))13+ if c == nil {14+ out = append(out, "")15+ continue16+ }17+ out = append(out, c.Input)18+ }19+ return out20+}21+22+func itoa(n int) string { return Addr{Row: n - 1}.String()[1:] }23+24+func TestSortRange(t *testing.T) {25+ data := map[string]string{26+ "A1": "Name", "B1": "Qty",27+ "A2": "pear", "B2": "3",28+ "A3": "Apple", "B3": "10",29+ "A4": "fig", "B4": "",30+ "A5": "banana", "B5": "3",31+ "A6": "7", "B6": "TRUE",32+ }33+ tests := []struct {34+ name string35+ keys []SortKey36+ a, b []string37+ }{38+ {"by name A to Z: numbers, then text ignoring case", []SortKey{{Col: 0}},39+ []string{"7", "Apple", "banana", "fig", "pear"}, []string{"TRUE", "10", "3", "", "3"}},40+ {"by name Z to A", []SortKey{{Col: 0, Desc: true}},41+ []string{"pear", "fig", "banana", "Apple", "7"}, []string{"3", "", "3", "10", "TRUE"}},42+ {"by qty: stable ties, booleans after numbers, blanks last", []SortKey{{Col: 1}},43+ []string{"pear", "banana", "Apple", "7", "fig"}, []string{"3", "3", "10", "TRUE", ""}},44+ {"by qty Z to A keeps blanks last", []SortKey{{Col: 1, Desc: true}},45+ []string{"7", "Apple", "pear", "banana", "fig"}, []string{"TRUE", "10", "3", "3", ""}},46+ {"by qty then name Z to A", []SortKey{{Col: 1}, {Col: 0, Desc: true}},47+ []string{"pear", "banana", "Apple", "7", "fig"}, []string{"3", "3", "10", "TRUE", ""}},48+ }49+ for _, tt := range tests {50+ t.Run(tt.name, func(t *testing.T) {51+ s := sheetOf(t, data)52+ s.SortRange(Rect{at("A2"), at("B6")}, tt.keys)53+ if got := column(s, "A", 2, 6); !reflect.DeepEqual(got, tt.a) {54+ t.Errorf("A = %q, want %q", got, tt.a)55+ }56+ if got := column(s, "B", 2, 6); !reflect.DeepEqual(got, tt.b) {57+ t.Errorf("B = %q, want %q", got, tt.b)58+ }59+ if got := s.Cell(at("A1")).Input; got != "Name" {60+ t.Errorf("header moved: %q", got)61+ }62+ // One undo step restores everything.63+ s.Undo()64+ if got := column(s, "A", 2, 6); !reflect.DeepEqual(got, []string{"pear", "Apple", "fig", "banana", "7"}) {65+ t.Errorf("after undo A = %q", got)66+ }67+ if s.CanUndo() {68+ t.Error("sort took more than one undo step")69+ }70+ })71+ }72+}73+74+func TestSortMovesFormulasWithTheirRows(t *testing.T) {75+ s := sheetOf(t, map[string]string{76+ "A1": "3", "B1": "=A1*2", "C1": "=$A$1",77+ "A2": "1", "B2": "=A2*2", "C2": "=$A$1",78+ "A3": "2", "B3": "=A3*2", "C3": "=$A$1",79+ "E1": "=A1", // outside the sort: left pointing at A180+ })81+ s.SortRange(Rect{at("A1"), at("C3")}, []SortKey{{Col: 0}})82+ want := map[string]string{"A1": "1", "B1": "=A1*2", "B2": "=A2*2", "B3": "=A3*2", "C1": "=$A$1", "E1": "=A1"}83+ for a, in := range want {84+ if got := s.Cell(at(a)).Input; got != in {85+ t.Errorf("%s = %q, want %q", a, got, in)86+ }87+ }88+ if v := s.Value(at("B3")).Num; v != 6 {89+ t.Errorf("B3 = %v, want 6", v)90+ }91+ if v := s.Value(at("E1")).Num; v != 1 {92+ t.Errorf("E1 = %v, want 1", v)93+ }94+}95+96+func TestSortClampsToData(t *testing.T) {97+ s := sheetOf(t, map[string]string{"A1": "b", "A2": "a"})98+ // A whole-column selection sorts only as far as the data goes.99+ s.SortRange(Rect{at("A1"), Addr{Col: 0, Row: MaxRows - 1}}, []SortKey{{Col: 0}})100+ if got := column(s, "A", 1, 3); !reflect.DeepEqual(got, []string{"a", "b", ""}) {101+ t.Errorf("A = %q", got)102+ }103+}104+105+func TestRegionForSortAndFilter(t *testing.T) {106+ s := sheetOf(t, map[string]string{107+ "B2": "x", "C2": "y", "B3": "1", "D4": "diagonal",108+ "G7": "island",109+ })110+ tests := []struct{ from, want string }{111+ {"B2", "B2:D4"},112+ {"D4", "D4"}, // reaches nothing: only B2:C3 reaches it113+ {"C3", "B2:D4"}, // blank, next to data114+ {"A1", "B2:D4"}, // blank, touching B2 diagonally115+ {"G7", "G7"},116+ {"J10", "J10"},117+ }118+ for _, tt := range tests {119+ if got := s.Region(at(tt.from)).String(); got != tt.want {120+ t.Errorf("Region(%s) = %s, want %s", tt.from, got, tt.want)121+ }122+ }123+}@@ -0,0 +1,193 @@
1+package sheet2+3+import (4+ "bytes"5+ "encoding/json"6+ "fmt"7+ "reflect"8+)9+10+// View state that belongs to the worksheet rather than to a cell: frozen11+// rows and columns, and the filter. It is saved in the file and every12+// change to it is an undo step, as in Sheets.13+type viewState struct {14+ frozenRows, frozenCols int15+ filter *Filter // never modified in place; replaced whole16+}17+18+// Frozen returns how many rows and columns are frozen at the top and left.19+func (s *Sheet) Frozen() (rows, cols int) {20+ return s.view.frozenRows, s.view.frozenCols21+}22+23+// MaxFrozen caps frozen rows and columns, as Sheets' View > Freeze does24+// in practice: more than a screenful can't stay on screen anyway.25+const MaxFrozen = 5026+27+// SetFrozen freezes the first rows rows and cols columns (0 unfreezes).28+func (s *Sheet) SetFrozen(rows, cols int) {29+ rows, cols = clampInt(rows, 0, MaxFrozen), clampInt(cols, 0, MaxFrozen)30+ if rows == s.view.frozenRows && cols == s.view.frozenCols {31+ return32+ }33+ label := "unfreeze"34+ switch {35+ case rows != s.view.frozenRows:36+ label = "freeze " + plural(rows, "row")37+ if rows == 0 {38+ label = "unfreeze rows"39+ }40+ case cols != s.view.frozenCols:41+ label = "freeze " + plural(cols, "column")42+ if cols == 0 {43+ label = "unfreeze columns"44+ }45+ }46+ focus := Rect{To: Addr{Col: max(cols, 1) - 1, Row: max(rows, 1) - 1}}47+ s.change(label, focus, func() {48+ s.recordView()49+ s.view.frozenRows, s.view.frozenCols = rows, cols50+ })51+}52+53+func plural(n int, noun string) string {54+ if n == 1 {55+ return "1 " + noun56+ }57+ return fmt.Sprintf("%d %ss", n, noun)58+}59+60+// recordView saves the view state before its first change in the open61+// step.62+func (s *Sheet) recordView() {63+ st := s.hist.open64+ if st == nil || st.view != nil {65+ return66+ }67+ v := s.view68+ st.view = &v69+}70+71+func (v viewState) equal(w viewState) bool {72+ return v.frozenRows == w.frozenRows && v.frozenCols == w.frozenCols &&73+ (v.filter == w.filter || reflect.DeepEqual(v.filter, w.filter))74+}75+76+// shiftView keeps frozen lines and the filter in step with inserted or77+// deleted rows or columns: inserting inside the frozen area freezes the78+// new lines too, and the filter's range grows, shrinks or moves like a79+// range reference (and goes away when all of it is deleted).80+func (s *Sheet) shiftView(rows bool, sp span) {81+ v := s.view82+ frozen := &v.frozenCols83+ if rows {84+ frozen = &v.frozenRows85+ }86+ if *frozen > 0 {87+ if lo, hi, ok := sp.interval(0, *frozen-1); ok && lo == 0 {88+ *frozen = hi + 189+ } else if !ok {90+ *frozen = 091+ }92+ *frozen = min(*frozen, MaxFrozen)93+ }94+ if f := v.filter; f != nil {95+ _, rng := axisRewrite(rows, sp)96+ if r, ok := rng(f.Range); ok {97+ nf := &Filter{Range: r, Cols: map[int]Criteria{}}98+ for c, cr := range f.Cols {99+ if !rows {100+ var keep bool101+ if c, keep = sp.point(c); !keep || c < r.From.Col || c > r.To.Col {102+ continue103+ }104+ }105+ nf.Cols[c] = cr106+ }107+ v.filter = nf108+ } else {109+ v.filter = nil110+ }111+ }112+ if !v.equal(s.view) {113+ s.recordView()114+ s.view = v115+ }116+}117+118+// fileView is the view state in the file (version 3), e.g.119+//120+// "freeze": {"rows": 1},121+// "filter": {"range": "A1:C20", "columns": {"B": {"condition": "gt", "value": "100"}}},122+type fileView struct {123+ Freeze *fileFreeze `json:"freeze,omitempty"`124+ Filter *fileFilter `json:"filter,omitempty"`125+}126+127+type fileFreeze struct {128+ Rows int `json:"rows,omitempty"`129+ Cols int `json:"cols,omitempty"`130+}131+132+type fileFilter struct {133+ Range string `json:"range"`134+ Columns map[string]fileCriteria `json:"columns,omitempty"`135+}136+137+type fileCriteria struct {138+ Hidden []string `json:"hidden,omitempty"`139+ Condition string `json:"condition,omitempty"`140+ Value string `json:"value,omitempty"`141+}142+143+// writeView adds the frozen panes and the filter to a file being written.144+func (s *Sheet) writeView(b *bytes.Buffer) error {145+ var keys []string146+ var parts []any147+ if v := s.view; v.frozenRows > 0 || v.frozenCols > 0 {148+ keys, parts = append(keys, "freeze"), append(parts, fileFreeze{Rows: v.frozenRows, Cols: v.frozenCols})149+ }150+ if f := s.view.filter; f != nil {151+ ff := fileFilter{Range: f.Range.String(), Columns: map[string]fileCriteria{}}152+ for c, cr := range f.Cols {153+ ff.Columns[ColName(c)] = fileCriteria{Hidden: cr.Hidden, Condition: cr.Cond.Op.String(), Value: cr.Cond.Arg}154+ }155+ keys, parts = append(keys, "filter"), append(parts, ff)156+ }157+ for i, p := range parts {158+ raw, err := json.Marshal(p)159+ if err != nil {160+ return err161+ }162+ fmt.Fprintf(b, " %q: %s,\n", keys[i], raw)163+ }164+ return nil165+}166+167+// readView restores the frozen panes and the filter from a file.168+func (s *Sheet) readView(fv fileView) error {169+ if fz := fv.Freeze; fz != nil {170+ s.view.frozenRows, s.view.frozenCols = clampInt(fz.Rows, 0, MaxFrozen), clampInt(fz.Cols, 0, MaxFrozen)171+ }172+ if ff := fv.Filter; ff != nil {173+ r, ok := ParseRange(ff.Range)174+ if !ok {175+ return fmt.Errorf("invalid filter range %q", ff.Range)176+ }177+ f := &Filter{Range: r, Cols: map[int]Criteria{}}178+ for name, fc := range ff.Columns {179+ c, ok := ParseCol(name)180+ if !ok {181+ return fmt.Errorf("invalid filter column %q", name)182+ }183+ op, ok := ParseCondOp(fc.Condition)184+ if !ok {185+ return fmt.Errorf("unknown filter condition %q", fc.Condition)186+ }187+ f.Cols[c] = Criteria{Hidden: fc.Hidden, Cond: Condition{Op: op, Arg: fc.Value}}188+ }189+ s.view.filter = f.clone()190+ }191+ s.hidden.valid = false192+ return nil193+}