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 / sort.go
sort.go 5.8K
1package ui23import (4 "log/slog"5 "strings"67 tea "charm.land/bubbletea/v2"89 "src.rest/cat/012/internal/sheet"10 "src.rest/cat/012/internal/ui/sortbar"11)1213// Sorting follows Sheets' Data menu: sort the sheet or a range by the14// active column, A to Z or Z to A, or pick the columns and order on a bar15// on the context line (Sheets' "Advanced range sorting options" dialog).16// The bar shows each sort column as a chip; arrows change the focused17// one, and the range being sorted is highlighted with the sort column's18// header lit.1920func init() {21 register(22 &command{id: "data.sort_sheet_az", title: "Sort sheet A to Z", desc: "Sort every row by the active column, A to Z; frozen rows stay on top",23 run: func(m *Model) tea.Cmd { return m.sortSheet(false) }},24 &command{id: "data.sort_sheet_za", title: "Sort sheet Z to A", desc: "Sort every row by the active column, Z to A; frozen rows stay on top",25 run: func(m *Model) tea.Cmd { return m.sortSheet(true) }},26 &command{id: "data.sort_range_az", title: "Sort range A to Z", desc: "Sort the selection, or the data around the active cell, by the active column, A to Z",27 run: func(m *Model) tea.Cmd { return m.sortRange(false) }},28 &command{id: "data.sort_range_za", title: "Sort range Z to A", desc: "Sort the selection, or the data around the active cell, by the active column, Z to A",29 run: func(m *Model) tea.Cmd { return m.sortRange(true) }},30 &command{id: "data.sort_range", title: "Sort range", desc: "Sort the selection by one or more columns, with or without a header row",31 run: func(m *Model) tea.Cmd {32 m.openSortBar()33 return nil34 },35 answer: func(m *Model, text string) (tea.Cmd, error) { return m.openSortBar().Answer(text) }},36 )37}3839// sortSheet sorts all rows below the frozen ones by the active column.40func (m *Model) sortSheet(desc bool) tea.Cmd {41 used, ok := m.sheet.UsedRange()42 fr, _ := m.sheet.Frozen()43 if !ok || used.To.Row <= fr {44 m.note = "Nothing to sort"45 return nil46 }47 r := sheet.Rect{From: sheet.Addr{Row: fr}, To: used.To}48 return m.sort(r, []sheet.SortKey{{Col: m.cur.Col, Desc: desc}})49}5051// sortRange sorts the selection, or the data around the active cell, by52// the active column, leaving any header row in place.53func (m *Model) sortRange(desc bool) tea.Cmd {54 r := m.dataRange()55 r.From.Row += m.headerRows(r, m.hasRange())56 col := clamp(m.cur.Col, r.From.Col, r.To.Col)57 return m.sort(r, []sheet.SortKey{{Col: col, Desc: desc}})58}5960func (m *Model) sort(r sheet.Rect, keys []sheet.SortKey) tea.Cmd {61 if r.To.Row <= r.From.Row {62 m.note = "Nothing to sort"63 return nil64 }65 if _, reg, ok := m.sheet.InRegion(r); ok {66 m.note = "A region keeps its source's order: sort it in its cell (sort-by), or freeze it first"67 if reg.Linked() {68 m.note = "A linked file's rows keep the file's order: unlink it to sort them"69 }70 return nil71 }72 if m.out == nil && m.refuseEdit(r, false) { // an output's grid sorts its own copy7374 return nil75 }76 if len(m.sheet.MergesIn(r)) > 0 {77 m.fail(sheet.ErrSortMerged.Error())78 return nil79 }80 retry := func(m *Model) tea.Cmd { return m.sort(r, keys) }81 if m.askProtected(r, retry) || m.askUndoCost(r, retry) {82 return nil83 }84 span := m.spans.Start("sort", slog.Int("rows", r.To.Row-r.From.Row+1), slog.Int("cols", r.To.Col-r.From.Col+1), slog.Int("keys", len(keys)))85 m.sheet.SortRange(r, keys)86 span.End()87 m.changed = true88 var by []string89 for _, k := range keys {90 by = append(by, m.colName(k.Col)+" "+sortbar.OrderName(k.Desc))91 }92 m.note = "Sorted " + m.rangeLabel(r) + " by " + strings.Join(by, ", then ")93 return nil94}9596// headerRows is how many rows at the top of r are headers that sorting97// leaves in place: frozen rows, the filter's or a table's header row, and, for a range98// found around the active cell, a first row of text over numbers.99func (g *grid) headerRows(r sheet.Rect, selected bool) int {100 n := 0101 if fr, _ := g.sheet.Frozen(); fr > r.From.Row {102 n = fr - r.From.Row103 }104 if f, ok := g.sheet.FilterRange(); ok && f.From.Row == r.From.Row {105 n = max(n, 1)106 }107 if t, ok := g.sheet.TableAt(r.From); ok && t.Range.From.Row == r.From.Row {108 n = max(n, 1)109 }110 if n == 0 && !selected && g.looksLikeHeader(r) {111 n = 1112 }113 return min(n, r.To.Row-r.From.Row)114}115116// looksLikeHeader reports whether r's first row is all text above a row117// with numbers, like "Item, Amount" above "Rent, 1450".118func (g *grid) looksLikeHeader(r sheet.Rect) bool {119 if r.To.Row == r.From.Row {120 return false121 }122 text, numbers := false, false123 for c := r.From.Col; c <= r.To.Col; c++ {124 switch g.sheet.Value(sheet.Addr{Col: c, Row: r.From.Row}).Kind {125 case sheet.Empty:126 continue127 case sheet.Text:128 text = true129 default:130 return false131 }132 switch g.sheet.Value(sheet.Addr{Col: c, Row: r.From.Row + 1}).Kind {133 case sheet.Number, sheet.Bool:134 numbers = true135 }136 }137 return text && numbers138}139140// openSortBar opens the sort bar over the selection, or the data around141// the active cell.142func (m *Model) openSortBar() *sortbar.Bar {143 r := m.dataRange()144 b := sortbar.New(m.host(), r, m.headerRows(r, m.hasRange()), m.cur.Col)145 m.openOverlay(b)146 return b147}148149// barRange is the range highlighted while a data bar is open.150func (m *Model) barRange() (sheet.Rect, bool) {151 if b, ok := m.overlay.(*sortbar.Bar); ok {152 return b.Data(), true153 }154 return sheet.Rect{}, false155}156157// barActive is the cell drawn as active while a data bar is open: the top158// of the focused sort column, so its header lights up.159func (m *Model) barActive() (sheet.Addr, bool) {160 if b, ok := m.overlay.(*sortbar.Bar); ok {161 return b.Active(), true162 }163 return sheet.Addr{}, false164}165166// Sort sorts from the sort bar, which a macro records as the bar's167// command answered with its choices.168func (h host) Sort(data sheet.Rect, keys []sheet.SortKey, answer string) tea.Cmd {169 cmd := h.m.sort(data, keys)170 h.m.recordDialog("data.sort_range", answer)171 return cmd172}