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 / sortbar / sortbar.go
sortbar.go 8.2K
1// Package sortbar is the bar that picks the columns and order to sort a2// range by, as Sheets' "Advanced range sorting options" dialog, on the3// context line. It shows each sort column as a chip; arrows change the4// focused one, and the model highlights the range being sorted with the5// sort column's header lit. It knows the UI only through Host.6package sortbar78import (9 "encoding/json"10 "errors"11 "fmt"12 "strings"1314 tea "charm.land/bubbletea/v2"15 "github.com/charmbracelet/x/ansi"1617 "src.rest/cat/012/internal/sheet"18 "src.rest/cat/012/internal/ui/lineedit"19 "src.rest/cat/012/internal/ui/overlay"20 "src.rest/cat/012/internal/ui/theme"21)2223// Host is what the sort bar needs of the UI.24type Host interface {25 Theme() *theme.Theme26 Size() (width, height int)27 // Sheet is the sheet sorted, for the headers' text.28 Sheet() *sheet.Sheet29 // Close closes the open overlay.30 Close()31 // Sort sorts data by keys, after the bar has closed. answer is the32 // bar's choices as a macro records them, the answer Answer takes.33 Sort(data sheet.Rect, keys []sheet.SortKey, answer string) tea.Cmd34}3536// Bar is the sort bar over a range.37type Bar struct {38 h Host39 rng sheet.Rect // the whole range, header rows included40 headers int // header rows at the top of rng; 0 when switched off41 guess int // header rows to use when switched on42 keys []sheet.SortKey43 cur int // the key being changed44}4546// New returns a bar sorting rng, whose first headers rows are headers,47// by column col.48func New(h Host, rng sheet.Rect, headers, col int) *Bar {49 b := &Bar{h: h, rng: rng, headers: headers, guess: max(headers, 1)}50 b.keys = []sheet.SortKey{{Col: min(max(col, rng.From.Col), rng.To.Col)}}51 return b52}5354// Data is the range the sort moves: the range without its headers.55func (b *Bar) Data() sheet.Rect {56 r := b.rng57 r.From.Row = min(r.From.Row+b.headers, r.To.Row)58 return r59}6061// Active is the cell drawn as active while the bar is open: the top of62// the focused sort column, so its header lights up.63func (b *Bar) Active() sheet.Addr {64 return sheet.Addr{Col: b.keys[b.cur].Col, Row: b.Data().From.Row}65}6667func (b *Bar) Indicator() string { return "SORT" }68func (b *Bar) Layout() []overlay.Box { return nil }6970// setCol points the focused key at column c, if it's in the range.71func (b *Bar) setCol(c int) {72 if c >= b.rng.From.Col && c <= b.rng.To.Col {73 b.keys[b.cur].Col = c74 }75}7677// add adds a sort column after the focused one: the next column not yet78// sorted by.79func (b *Bar) add() {80 used := map[int]bool{}81 for _, k := range b.keys {82 used[k.Col] = true83 }84 for c := b.rng.From.Col; c <= b.rng.To.Col; c++ {85 if !used[c] {86 b.keys = append(b.keys[:b.cur+1], append([]sheet.SortKey{{Col: c}}, b.keys[b.cur+1:]...)...)87 b.cur++88 return89 }90 }91}9293// toggleHeader switches the header rows off, or on at the guess.94func (b *Bar) toggleHeader() {95 if b.headers > 0 {96 b.headers = 097 } else {98 b.headers = min(b.guess, b.rng.To.Row-b.rng.From.Row)99 }100}101102// sort closes the bar and sorts.103func (b *Bar) sort() tea.Cmd {104 keys, r := b.keys, b.Data()105 b.h.Close()106 return b.h.Sort(r, keys, b.answer())107}108109func (b *Bar) Key(k tea.KeyPressMsg) tea.Cmd {110 switch k.String() {111 case "esc":112 b.h.Close()113 case "enter":114 return b.sort()115 case "left":116 b.setCol(b.keys[b.cur].Col - 1)117 case "right":118 b.setCol(b.keys[b.cur].Col + 1)119 case "up", "down", "space":120 b.keys[b.cur].Desc = !b.keys[b.cur].Desc121 case "tab":122 b.cur = (b.cur + 1) % len(b.keys)123 case "shift+tab":124 b.cur = (b.cur + len(b.keys) - 1) % len(b.keys)125 case "alt+a":126 b.add()127 case "alt+h":128 b.toggleHeader()129 case "backspace", "delete":130 if len(b.keys) > 1 {131 b.keys = append(b.keys[:b.cur], b.keys[b.cur+1:]...)132 b.cur = min(b.cur, len(b.keys)-1)133 }134 default:135 // Typing a column's letter sorts by it.136 if t := lineedit.Typed(k); len(t) == 1 {137 if c, ok := sheet.ParseCol(t); ok {138 b.setCol(c)139 }140 }141 }142 return nil143}144145// OrderName is a sort order as the bar and notes show it.146func OrderName(desc bool) string {147 if desc {148 return "Z→A"149 }150 return "A→Z"151}152153// part is a piece of the bar: plain text, a sort key's chip, or the154// header row toggle.155type part struct {156 text string157 key int // index of the sort key, -1 for others158 toggle bool159}160161func (b *Bar) parts() []part {162 th := b.h.Theme()163 parts := []part{{text: th.Key.Render("Sort "+b.Data().String()) + th.Muted.Render(" by "), key: -1}}164 for i, k := range b.keys {165 if i > 0 {166 parts = append(parts, part{text: th.Muted.Render(" then "), key: -1})167 }168 label := sheet.ColName(k.Col)169 if b.headers > 0 {170 if h := b.h.Sheet().ShownText(sheet.Addr{Col: k.Col, Row: b.rng.From.Row + b.headers - 1}); h != "" {171 label += " " + ansi.Truncate(h, 16, "…")172 }173 }174 style := th.KeyChip175 if i == b.cur {176 style = th.MenuSelected177 }178 parts = append(parts, part{text: style.Render(" " + label + " " + OrderName(k.Desc) + " "), key: i})179 }180 style := th.Muted181 if b.headers > 0 {182 style = th.MenuSelected183 }184 return append(parts, part{text: " ", key: -1}, part{text: style.Render(" Header row "), key: -1, toggle: true})185}186187// ContextLine renders the bar for the context line.188func (b *Bar) ContextLine() (string, string) {189 var s strings.Builder190 for _, p := range b.parts() {191 s.WriteString(p.text)192 }193 return s.String(), ""194}195196// Mouse focuses a key's chip (a second click flips its order) or flips197// the header row toggle; a click anywhere else cancels.198func (b *Bar) Mouse(e overlay.MouseEvent) tea.Cmd {199 if e.Kind != overlay.MousePress {200 return nil201 }202 if e.Y != overlay.ContextLine {203 b.h.Close()204 return nil205 }206 x := 0207 for _, p := range b.parts() {208 w := ansi.StringWidth(p.text)209 if e.X >= x && e.X < x+w {210 switch {211 case p.toggle:212 b.toggleHeader()213 case p.key == b.cur:214 b.keys[b.cur].Desc = !b.keys[b.cur].Desc215 case p.key >= 0:216 b.cur = p.key217 }218 return nil219 }220 x += w221 }222 return nil223}224225// Status shows the keys, the most useful ones first on narrow screens.226func (b *Bar) Status() (string, string) {227 th := b.h.Theme()228 width, _ := b.h.Size()229 pairs := []string{"Left/Right", "column", "Space", "order", "Enter", "sort", "Esc", "cancel"}230 desc := "Alt+A add Alt+H header Tab next"231 for {232 keys := th.KeyHints(pairs...)233 switch {234 case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:235 return th.Muted.Render(desc), keys236 case desc != "":237 desc = ""238 case len(pairs) > 4:239 pairs = pairs[2:]240 default:241 return "", keys242 }243 }244}245246// answerJSON is the bar's choices as a macro answers them:247//248// {"by": [{"column": "B"}, {"column": "C", "order": "desc"}], "header": true}249//250// the sort columns in order, as a pivot's rows are written, and whether251// the first rows are headers left in place. Without "header", the bar's252// own guess stands.253type answerJSON struct {254 By []answerKey `json:"by"`255 Header *bool `json:"header,omitempty"`256}257258type answerKey struct {259 Column string `json:"column"`260 Order string `json:"order,omitempty"` // "desc" for Z to A261}262263// answer is the bar's choices as a macro records them.264func (b *Bar) answer() string {265 a := answerJSON{Header: new(bool)}266 *a.Header = b.headers > 0267 for _, k := range b.keys {268 ak := answerKey{Column: sheet.ColName(k.Col)}269 if k.Desc {270 ak.Order = "desc"271 }272 a.By = append(a.By, ak)273 }274 raw, _ := json.Marshal(a)275 return string(raw)276}277278// Answer makes the choices a macro recorded (see answerJSON) and sorts,279// as if they were picked and Enter pressed.280func (b *Bar) Answer(text string) (tea.Cmd, error) {281 var a answerJSON282 if err := json.Unmarshal([]byte(text), &a); err != nil {283 return nil, fmt.Errorf("the sort's answer: %w", err)284 }285 if len(a.By) == 0 {286 return nil, errors.New(`the sort's answer names no columns: give "by"`)287 }288 var keys []sheet.SortKey289 for _, k := range a.By {290 c, ok := sheet.ParseCol(strings.ToUpper(k.Column))291 if !ok || c < b.rng.From.Col || c > b.rng.To.Col {292 return nil, fmt.Errorf("column %q isn't in %s", k.Column, b.rng)293 }294 if k.Order != "" && k.Order != "asc" && k.Order != "desc" {295 return nil, fmt.Errorf("the order of %s is %q: asc or desc", k.Column, k.Order)296 }297 keys = append(keys, sheet.SortKey{Col: c, Desc: k.Order == "desc"})298 }299 b.keys, b.cur = keys, 0300 if a.Header != nil && *a.Header != (b.headers > 0) {301 b.toggleHeader()302 }303 return b.sort(), nil304}