cat / 012
clone

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

Freeze panes, sort, filter and a fill handle in the grid

Dami committed d675b7f
commit
d675b7ffa0a9eb22f87329b362022e403c9fe0ee
parent
45b81b1d62170cb4edba5970c54e069bfbcd8763
author
Dami <dami.etoile@gmail.com>
20 files changed, +1831 -89
MREADME.md6+-
Minternal/sheet/filter.go15+
Minternal/ui/context.go13+-
Minternal/ui/context_test.go2+-
Ainternal/ui/data_test.go221++++++++++++++++
Ainternal/ui/fill.go96+++++++
Ainternal/ui/filter.go426++++++++++++++++++++++++++++++
Ainternal/ui/freeze.go61++++
Minternal/ui/links_test.go4+-
Minternal/ui/menubar.go15+-
Minternal/ui/model.go65++--
Minternal/ui/model_test.go2+-
Minternal/ui/mouse.go133+++++++--
Minternal/ui/panel.go14+-
Ainternal/ui/panes.go185+++++++++++++
Ainternal/ui/panes_test.go216+++++++++++++++
Minternal/ui/selection.go1+
Ainternal/ui/sort.go320+++++++++++++++++++++++
Minternal/ui/theme.go10+
Minternal/ui/view.go115+++++++-
M README.md +5 -1
M internal/sheet/filter.go +15 -0
M internal/ui/context.go +6 -7
M internal/ui/context_test.go +1 -1
A internal/ui/data_test.go +221 -0
@@ -0,0 +1,221 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "012/internal/sheet"11+)12+13+// handleX is the screen x of the fill handle in column c (scrolled to A).14+func handleX(c int) int { return rowHdrW + (c+1)*sheet.DefaultWidth - 1 }15+16+func TestFillHandleDrag(t *testing.T) {17+ tests := []struct {18+ name string19+ cells []string // A1 down20+ to sheet.Addr21+ want map[string]string22+ sel string23+ }{24+ {"down continues a series", []string{"1", "2"}, addr("A5"), map[string]string{"A3": "3", "A5": "5"}, "A1:A5"},25+ {"right, further out than down", []string{"Mon"}, addr("C2"), map[string]string{"B1": "Tue", "C1": "Wed", "A2": ""}, "A1:C1"},26+ {"back inside fills nothing", []string{"x"}, addr("A1"), map[string]string{"A2": ""}, "A1"},27+ }28+ for _, tt := range tests {29+ t.Run(tt.name, func(t *testing.T) {30+ m := newModel()31+ for i, in := range tt.cells {32+ m.sheet.Set(sheet.Addr{Row: i}, in)33+ }34+ m.sheet.ClearHistory()35+ m.cur, m.ext = addr("A1"), sheet.Addr{Row: len(tt.cells) - 1}36+ m.selecting = len(tt.cells) > 137+ y := gridTop + len(tt.cells) - 138+ // Hovering the corner cell shows the handle.39+ send(m, tea.MouseMotionMsg{X: cellX(0), Y: y})40+ if !strings.Contains(line(m, y), "▗") {41+ t.Fatalf("no handle: %q", line(m, y))42+ }43+ if h := m.hitTest(handleX(0), y); h.kind != hitFillHandle {44+ t.Fatalf("hit %+v", h)45+ }46+ send(m, tea.MouseClickMsg(leftAt(handleX(0), y)))47+ send(m, tea.MouseMotionMsg(leftAt(cellX(tt.to.Col), gridTop+tt.to.Row)))48+ if tt.sel != "A1" && !strings.Contains(line(m, contextLine), "Fill "+tt.sel) {49+ t.Errorf("context line %q", line(m, contextLine))50+ }51+ send(m, tea.MouseReleaseMsg(leftAt(cellX(tt.to.Col), gridTop+tt.to.Row)))52+ for a, in := range tt.want {53+ if got := input(m, a); got != in {54+ t.Errorf("%s = %q, want %q", a, got, in)55+ }56+ }57+ if got := m.selection().String(); got != tt.sel {58+ t.Errorf("selection %s, want %s", got, tt.sel)59+ }60+ if tt.sel != "A1" {61+ press(t, m, "<ctrl+z>")62+ if m.sheet.CanUndo() || m.sheet.Len() != len(tt.cells) {63+ t.Error("fill isn't one undo step")64+ }65+ }66+ })67+ }68+}69+70+func TestFillHandleEscCancels(t *testing.T) {71+ m := newModel()72+ press(t, m, "1", "<enter>", "<up>")73+ send(m, tea.MouseClickMsg(leftAt(handleX(0), gridTop)))74+ send(m, tea.MouseMotionMsg(leftAt(cellX(0), gridTop+4)))75+ press(t, m, "<esc>")76+ send(m, tea.MouseReleaseMsg(leftAt(cellX(0), gridTop+4)))77+ if input(m, "A2") != "" || m.drag != dragNone {78+ t.Errorf("A2 %q drag %v", input(m, "A2"), m.drag)79+ }80+}81+82+func TestFillDownKeyContinuesSeries(t *testing.T) {83+ m := newModel()84+ press(t, m, "Item 1", "<enter>", "Item 2", "<enter>", "<up>", "<up>")85+ press(t, m, "<shift+down>", "<shift+down>", "<shift+down>", "<ctrl+d>")86+ if input(m, "A4") != "Item 4" {87+ t.Errorf("A4 = %q", input(m, "A4"))88+ }89+}90+91+func TestSortBar(t *testing.T) {92+ m := newModel()93+ table(t, m, 6) // Qty: 1 2 3 0 1 294+ press(t, m, "<ctrl+home>", "<right>")95+ m.runCommand("data.sort_range")96+ if m.mode != modeMenu || line(m, menuLine)[len(line(m, menuLine))-4:] != "SORT" {97+ t.Fatalf("mode %v, %q", m.mode, line(m, menuLine))98+ }99+ // The header row is found, and the range excludes it.100+ if l := line(m, contextLine); !strings.HasPrefix(l, "Sort A2:B7 by B Qty A→Z") || !strings.Contains(l, "Header row") {101+ t.Errorf("bar %q", l)102+ }103+ press(t, m, "<space>", "<alt+a>")104+ if l := line(m, contextLine); !strings.Contains(l, "B Qty Z→A then A Item A→Z") {105+ t.Errorf("bar %q", l)106+ }107+ press(t, m, "<left>") // stays in the range108+ press(t, m, "<enter>")109+ want := []string{"3", "2", "2", "1", "1", "0"}110+ wantA := []string{"Row 3", "Row 2", "Row 6", "Row 1", "Row 5", "Row 4"}111+ for i := range want {112+ a, b := sheet.Addr{Row: i + 1}, sheet.Addr{Col: 1, Row: i + 1}113+ if input(m, b.String()) != want[i] || input(m, a.String()) != wantA[i] {114+ t.Errorf("row %d: %q %q", i+2, input(m, a.String()), input(m, b.String()))115+ }116+ }117+ if m.note != "Sorted A2:B7 by B Z→A, then A A→Z" || m.mode != modeReady {118+ t.Errorf("note %q mode %v", m.note, m.mode)119+ }120+ // Esc cancels without sorting; Alt+H drops the header row.121+ m.runCommand("data.sort_range")122+ press(t, m, "<alt+h>")123+ if !strings.HasPrefix(line(m, contextLine), "Sort A1:B7 by B ") {124+ t.Errorf("without header %q", line(m, contextLine))125+ }126+ press(t, m, "<esc>")127+ if m.mode != modeReady || input(m, "A1") != "Item" {128+ t.Error("Esc didn't cancel")129+ }130+}131+132+func TestSortSheetKeepsFrozenRows(t *testing.T) {133+ m := newModel()134+ table(t, m, 4) // Qty: 1 2 3 0135+ m.sheet.SetFrozen(1, 0)136+ press(t, m, "<right>", "<down>")137+ m.runCommand("data.sort_sheet_za")138+ if input(m, "B1") != "Qty" || input(m, "B2") != "3" || input(m, "B5") != "0" {139+ t.Errorf("B = %q %q %q", input(m, "B1"), input(m, "B2"), input(m, "B5"))140+ }141+}142+143+func TestFilterPicker(t *testing.T) {144+ m := newModel()145+ table(t, m, 8) // Qty: 1 2 3 0 1 2 3 0146+ press(t, m, "<right>") // B1, inside the data147+ if commands["data.filter_column"].available(m) {148+ t.Error("filter by column available without a filter")149+ }150+ m.runCommand("data.filter")151+ if r, ok := m.sheet.FilterRange(); !ok || r.String() != "A1:B9" {152+ t.Fatalf("filter %v %v", r, ok)153+ }154+ press(t, m, "<alt+down>")155+ p, ok := m.overlay.(*filterPicker)156+ if !ok {157+ t.Fatalf("overlay %T", m.overlay)158+ }159+ out := screen(m)160+ for _, want := range []string{"Filter B Qty", "If ‹ None ›", "[x] Select all", "[x] 0", "4 of 4"} {161+ if !strings.Contains(out, want) {162+ t.Errorf("picker lacks %q:\n%s", want, out)163+ }164+ }165+ // Uncheck 0 and 1; the search narrows the list.166+ press(t, m, "<down>", "<space>", "<down>", "<space>", "3")167+ if len(p.shown) != 1 || !strings.Contains(screen(m), "1 of 4") {168+ t.Errorf("search shows %d", len(p.shown))169+ }170+ press(t, m, "<enter>")171+ if m.sheet.HiddenRows() != 4 || m.note != "Filtered column B: 4 rows hidden" {172+ t.Errorf("hidden %d note %q", m.sheet.HiddenRows(), m.note)173+ }174+ // A condition on another column combines with it.175+ press(t, m, "<left>", "<alt+down>", "<tab>", "<down>", "<down>", "<down>")176+ if !strings.Contains(screen(m), "‹ Text contains ›") {177+ t.Fatalf("condition:\n%s", screen(m))178+ }179+ press(t, m, "Row 2", "<enter>")180+ var shown []int181+ for r := range 10 {182+ if !m.sheet.RowHidden(r) {183+ shown = append(shown, r+1)184+ }185+ }186+ if len(shown) != 3 || shown[1] != 3 || shown[2] != 10 {187+ t.Errorf("shown rows %v", shown)188+ }189+ // Esc leaves the criteria alone; removing the filter shows all.190+ press(t, m, "<alt+down>", "<space>", "<esc>")191+ if m.sheet.HiddenRows() != 7 || m.mode != modeReady {192+ t.Errorf("after Esc: %d hidden", m.sheet.HiddenRows())193+ }194+ m.runCommand("data.filter_remove")195+ if m.sheet.HiddenRows() != 0 {196+ t.Error("remove kept rows hidden")197+ }198+}199+200+func TestFilterButtonClick(t *testing.T) {201+ m := newModel()202+ table(t, m, 4)203+ m.runCommand("data.filter")204+ x := m.filterButtonX(1)205+ if ansi.Cut(line(m, headerLine), x, x+1) != "▾" {206+ t.Fatalf("button at %d in %q", x, line(m, headerLine))207+ }208+ click(m, x, headerLine, 0)209+ if p, ok := m.overlay.(*filterPicker); !ok || p.col != 1 {210+ t.Fatalf("overlay %T", m.overlay)211+ }212+ // Clicking a value toggles it; clicking outside cancels.213+ click(m, x+4, gridTop+filterFirstRow+1, 0)214+ if p := m.overlay.(*filterPicker); p.checked["0"] {215+ t.Error("click didn't uncheck 0")216+ }217+ click(m, 70, 18, 0)218+ if m.overlay != nil || m.sheet.HiddenRows() != 0 {219+ t.Error("click outside applied or stayed open")220+ }221+}
A internal/ui/fill.go +96 -0
@@ -0,0 +1,96 @@
1+package ui2+3+import (4+ "012/internal/sheet"5+)6+7+// The fill handle, as in Sheets: the bottom-right corner of the selection8+// shows a small handle (▗) when the mouse is over that cell. Dragging it9+// down, up, right or left fills the cells it passes over, continuing a10+// series (1, 2, 3; Jan, Feb; Item 1, Item 2) or copying. While dragging,11+// the range that will be filled is highlighted and the context line says12+// what releasing does; Esc cancels.13+14+// fillCorner is the cell with the fill handle: the bottom-right corner of15+// the selection. Whole rows and columns have none.16+func (m *Model) fillCorner() sheet.Addr {17+ if m.whole != wholeNone {18+ return sheet.Addr{Col: -1, Row: -1}19+ }20+ return m.selection().To21+}22+23+// showFillHandle reports whether the fill handle is drawn in cell a:24+// while the mouse is over the corner cell, or dragging the handle.25+func (m *Model) showFillHandle(a sheet.Addr) bool {26+ if m.mode != modeReady || a != m.fillCorner() {27+ return false28+ }29+ switch {30+ case m.drag == dragFill:31+ return true32+ case m.drag != dragNone:33+ return false34+ }35+ return (m.hover.kind == hitCell || m.hover.kind == hitFillHandle) && m.hover.addr == a36+}37+38+// startFill starts dragging the fill handle.39+func (m *Model) startFill() {40+ m.drag = dragFill41+ m.fillAt, m.fillTo = m.fillCorner(), m.selection()42+}43+44+// dragFillTo points the fill at cell a: the selection grows along the45+// axis a is further out on, as in Sheets.46+func (m *Model) dragFillTo(a sheet.Addr) {47+ m.fillAt = a48+ src := m.selection()49+ down, up := a.Row-src.To.Row, src.From.Row-a.Row50+ right, left := a.Col-src.To.Col, src.From.Col-a.Col51+ r := src52+ switch {53+ case max(down, up) <= 0 && max(right, left) <= 0:54+ case max(down, up) >= max(right, left):55+ if down > 0 {56+ r.To.Row = a.Row57+ } else {58+ r.From.Row = a.Row59+ }60+ case right > 0:61+ r.To.Col = a.Col62+ default:63+ r.From.Col = a.Col64+ }65+ m.fillTo = r66+}67+68+// finishFill fills the range the handle was dragged over and selects it.69+func (m *Model) finishFill() {70+ m.drag = dragNone71+ src, dst := m.selection(), m.fillTo72+ if dst == src {73+ return74+ }75+ got, err := m.sheet.FillSeries(src, dst)76+ if err != nil {77+ m.fail(err.Error())78+ return79+ }80+ m.changed = true81+ m.selectRect(got)82+}83+84+// cancelFill stops a fill handle drag without filling.85+func (m *Model) cancelFill() {86+ m.drag, m.autoscrolling = dragNone, false87+ m.fillTo = m.selection()88+}89+90+// fillLine is the context line while dragging the fill handle.91+func (m *Model) fillLine() string {92+ if m.fillTo == m.selection() {93+ return m.th.key.Render("Fill") + m.th.muted.Render(" drag down, up, right or left to fill a series or copy ") + m.keyHints("Esc", "cancel")94+ }95+ return m.th.key.Render("Fill "+m.fillTo.String()) + m.th.muted.Render(" release to fill ") + m.keyHints("Esc", "cancel")96+}
A internal/ui/filter.go +426 -0
@@ -0,0 +1,426 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+ "github.com/sahilm/fuzzy"10+11+ "012/internal/sheet"12+)13+14+// Filters follow Sheets' Data > Create a filter: the headers of the15+// filtered range get a button (▾, ▼ once the column hides something),16+// and clicking it or pressing Alt+Down in the column opens a picker in the17+// manner of fzf: a condition on top, then the column's values with18+// checkboxes, narrowed by a fuzzy search as you type. Rows that don't19+// pass are hidden, not deleted.20+21+func init() {22+ register(23+ &command{id: "data.filter", title: "Create a filter", desc: "Filter the rows of the selection, or of the data around the active cell; the first row holds the headers",24+ run: (*Model).createFilter,25+ enabled: func(m *Model) bool { _, on := m.sheet.FilterRange(); return !on }},26+ &command{id: "data.filter_column", title: "Filter by column", desc: "Choose which values of the active column show, or a condition they must meet",27+ run: func(m *Model) tea.Cmd {28+ m.openFilterPicker(m.cur.Col)29+ return nil30+ },31+ enabled: func(m *Model) bool {32+ r, on := m.sheet.FilterRange()33+ return on && m.cur.Col >= r.From.Col && m.cur.Col <= r.To.Col34+ }},35+ &command{id: "data.filter_remove", title: "Remove filter", desc: "Remove the filter and show every row again",36+ run: func(m *Model) tea.Cmd {37+ m.sheet.RemoveFilter()38+ m.changed = true39+ return nil40+ },41+ enabled: func(m *Model) bool { _, on := m.sheet.FilterRange(); return on }},42+ )43+ keymap["alt+down"] = "data.filter_column"44+}45+46+// createFilter filters the selection, or the data around the active cell.47+func (m *Model) createFilter() tea.Cmd {48+ r := m.dataRange()49+ m.sheet.CreateFilter(r)50+ m.changed = true51+ m.note = "Created a filter on " + r.String() + " " + m.keyHints(shortcut("data.filter_column"), "filter the active column")52+ return nil53+}54+55+// dataRange is the range data commands act on: the selection when there56+// is one, cut to the data in it, or else the block of data around the57+// active cell.58+func (m *Model) dataRange() sheet.Rect {59+ if !m.hasRange() {60+ return m.sheet.Region(m.cur)61+ }62+ r := m.selection()63+ if used, ok := m.sheet.UsedRange(); ok {64+ r.To.Row = max(min(r.To.Row, used.To.Row), r.From.Row)65+ r.To.Col = max(min(r.To.Col, used.To.Col), r.From.Col)66+ }67+ return r68+}69+70+// filterMark is the filter button in column c's header, if c is in the71+// filter's range, and whether the column's filter hides anything.72+func (m *Model) filterMark(c int) (string, bool) {73+ r, on := m.sheet.FilterRange()74+ switch {75+ case !on || c < r.From.Col || c > r.To.Col:76+ return "", false77+ case m.sheet.ColumnFiltered(c):78+ return "▼", true79+ }80+ return "▾", false81+}82+83+// filterButtonX is the screen x of column c's filter button, or -1.84+func (m *Model) filterButtonX(c int) int {85+ name := sheet.ColName(c)86+ w := m.sheet.ColWidth(c)87+ if mark, _ := m.filterMark(c); mark == "" || w < len(name)+3 {88+ return -189+ }90+ lw := len(name) + 291+ return m.colStart(c) + (w-lw)/2 + lw - 192+}93+94+// filterPicker is the values list and condition for one column.95+type filterPicker struct {96+ col int97+ values []sheet.FilterValue98+ checked map[string]bool99+ cond sheet.Condition100+ field int // 0 is the search, 1 the condition's value101+ fields [2]string // the text of each field102+ shown []int // values matching the search, best first103+ list // over the rows: "Select all", then shown104+}105+106+const filterID = "filter"107+108+// Rows of the box: the top border, the condition, a separator, the109+// search, a separator, then the list.110+const filterFirstRow = 5111+112+// openFilterPicker opens the picker for column col of the filter.113+func (m *Model) openFilterPicker(col int) {114+ r, on := m.sheet.FilterRange()115+ if !on || col < r.From.Col || col > r.To.Col {116+ return117+ }118+ f := m.sheet.Filter()119+ p := &filterPicker{col: col, values: m.sheet.FilterValues(col), checked: map[string]bool{}, cond: f.Cols[col].Cond}120+ for _, v := range p.values {121+ p.checked[v.Text] = v.Shown122+ }123+ p.fields[1] = p.cond.Arg124+ m.openOverlay(p)125+ m.buf, m.bufPos = nil, 0126+ p.search()127+}128+129+func (p *filterPicker) indicator() string { return "FILTER" }130+131+// focus moves editing to field i, keeping the other field's text.132+func (p *filterPicker) focus(m *Model, i int) {133+ p.fields[p.field] = string(m.buf)134+ p.field = i135+ m.buf = []rune(p.fields[i])136+ m.bufPos = len(m.buf)137+}138+139+func (p *filterPicker) changed(m *Model) {140+ p.fields[p.field] = string(m.buf)141+ if p.field == 0 {142+ p.search()143+ }144+}145+146+// valueLabel is how a value shows in the list.147+func valueLabel(text string) string {148+ if text == "" {149+ return "(Blanks)"150+ }151+ return text152+}153+154+// search narrows the list to the values matching the search.155+func (p *filterPicker) search() {156+ p.sel, p.top = 0, 0157+ p.shown = p.shown[:0]158+ q := strings.TrimSpace(p.fields[0])159+ if q == "" {160+ for i := range p.values {161+ p.shown = append(p.shown, i)162+ }163+ return164+ }165+ labels := make([]string, len(p.values))166+ for i, v := range p.values {167+ labels[i] = valueLabel(v.Text)168+ }169+ for _, mt := range fuzzy.Find(q, labels) {170+ p.shown = append(p.shown, mt.Index)171+ }172+}173+174+// toggle checks or unchecks list row i: "Select all" (row 0) checks every175+// shown value, or unchecks them if they all are.176+func (p *filterPicker) toggle(i int) {177+ if i > 0 {178+ t := p.values[p.shown[i-1]].Text179+ p.checked[t] = !p.checked[t]180+ return181+ }182+ all := p.allChecked()183+ for _, k := range p.shown {184+ p.checked[p.values[k].Text] = !all185+ }186+}187+188+func (p *filterPicker) allChecked() bool {189+ for _, k := range p.shown {190+ if !p.checked[p.values[k].Text] {191+ return false192+ }193+ }194+ return true195+}196+197+// cycle steps the condition through Sheets' list.198+func (p *filterPicker) cycle(d int) {199+ n := len(sheet.CondOps())200+ p.cond.Op = sheet.CondOp((int(p.cond.Op) + d + n) % n)201+}202+203+// apply sets the column's criteria and closes the picker.204+func (p *filterPicker) apply(m *Model) {205+ p.fields[p.field] = string(m.buf)206+ var cr sheet.Criteria207+ for _, v := range p.values {208+ if !p.checked[v.Text] {209+ cr.Hidden = append(cr.Hidden, v.Text)210+ }211+ }212+ cr.Cond = sheet.Condition{Op: p.cond.Op, Arg: strings.TrimSpace(p.fields[1])}213+ if !cr.Cond.Op.TakesArg() {214+ cr.Cond.Arg = ""215+ } else if cr.Cond.Arg == "" {216+ cr.Cond = sheet.Condition{}217+ }218+ m.closeOverlay()219+ m.sheet.FilterColumn(p.col, cr)220+ m.changed = true221+ if n := m.sheet.HiddenRows(); n > 0 {222+ m.note = "Filtered column " + sheet.ColName(p.col) + ": " + rowCount(n) + " hidden"223+ } else {224+ m.note = "The filter shows every row"225+ }226+}227+228+func rowCount(n int) string {229+ if n == 1 {230+ return "1 row"231+ }232+ return strconv.Itoa(n) + " rows"233+}234+235+func (p *filterPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {236+ n := len(p.shown) + 1237+ switch key := k.String(); {238+ case key == "esc":239+ m.closeOverlay()240+ case key == "enter":241+ p.apply(m)242+ case key == "tab" || key == "shift+tab":243+ p.focus(m, 1-p.field)244+ case p.field == 1 && (key == "up" || key == "down"):245+ if key == "up" {246+ p.cycle(-1)247+ } else {248+ p.cycle(1)249+ }250+ case key == "up" || key == "ctrl+p":251+ p.move(-1, n)252+ case key == "down" || key == "ctrl+n":253+ p.move(1, n)254+ case key == "pgup":255+ p.sel = max(p.sel-p.rows(m), 0)256+ case key == "pgdown":257+ p.sel = min(p.sel+p.rows(m), n-1)258+ case p.field == 0 && key == "space":259+ p.toggle(p.sel)260+ default:261+ before := string(m.buf)262+ m.lineKey(k)263+ if string(m.buf) != before {264+ p.changed(m)265+ }266+ }267+ return nil268+}269+270+func (p *filterPicker) mouse(m *Model, e mouseEvent) tea.Cmd {271+ if e.box != filterID {272+ if e.kind == mousePress {273+ m.closeOverlay()274+ }275+ return nil276+ }277+ n := len(p.shown) + 1278+ i := p.top + e.row - filterFirstRow279+ switch {280+ case e.kind == mouseWheel && e.button == tea.MouseWheelUp:281+ p.move(-1, n)282+ case e.kind == mouseWheel && e.button == tea.MouseWheelDown:283+ p.move(1, n)284+ case e.kind != mousePress && e.kind != mouseMotion:285+ case e.row == 1 && e.kind == mousePress:286+ if p.field != 1 {287+ p.focus(m, 1)288+ }289+ switch x := e.col - 1 - len(" If "); {290+ case x == 0:291+ p.cycle(-1)292+ case x == ansi.StringWidth(p.condChip())-1:293+ p.cycle(1)294+ }295+ case e.row == 3 && e.kind == mousePress:296+ p.focus(m, 0)297+ case e.row < filterFirstRow || i >= n || i >= p.top+p.rows(m):298+ case e.kind == mouseMotion:299+ p.sel = i300+ case e.button == tea.MouseLeft:301+ p.sel = i302+ p.toggle(i)303+ }304+ return nil305+}306+307+func (p *filterPicker) status(m *Model) (string, string) {308+ if p.field == 1 {309+ return "Rows must also meet the condition", m.keyHints("Up/Down", "condition", "Tab", "values", "Enter", "apply", "Esc", "cancel")310+ }311+ pairs := []string{"Space", "check", "Tab", "condition", "Enter", "apply", "Esc", "cancel"}312+ desc := "Type to search the values"313+ for {314+ keys := m.keyHints(pairs...)315+ switch {316+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:317+ return m.th.muted.Render(desc), keys318+ case desc != "":319+ desc = ""320+ case len(pairs) > 4:321+ pairs = append(pairs[:2], pairs[4:]...)322+ default:323+ return "", keys324+ }325+ }326+}327+328+// rows is how many list rows show: all of them if they fit above the329+// status line, at most twelve.330+func (p *filterPicker) rows(m *Model) int {331+ return max(min(len(p.shown)+1, 12, m.height-1-gridTop-filterFirstRow-1), 1)332+}333+334+func (p *filterPicker) box(m *Model) (x, y, inner int) {335+ w := 0336+ for _, v := range p.values {337+ w = max(w, ansi.StringWidth(valueLabel(v.Text))+len(strconv.Itoa(v.Count)))338+ }339+ inner = clamp(w+10, 36, 48)340+ inner = min(inner, m.width-2)341+ h := p.rows(m) + filterFirstRow + 1342+ x, y = m.clampBox(m.colStart(p.col), gridTop, inner+2, h)343+ return x, min(y, max(m.height-1-h, 0)), inner344+}345+346+func (p *filterPicker) cursor(m *Model) (int, int) {347+ x, y, _ := p.box(m)348+ caret := ansi.StringWidth(string(m.buf[:m.bufPos]))349+ if p.field == 1 {350+ return x + 1 + len(" If ") + ansi.StringWidth(p.condChip()) + 2 + caret, y + 1351+ }352+ return x + 1 + ansi.StringWidth(searchPrompt) + caret, y + 3353+}354+355+// condChip is the condition's name between arrows that change it.356+func (p *filterPicker) condChip() string {357+ return "‹ " + p.cond.Op.Title() + " ›"358+}359+360+func (p *filterPicker) layout(m *Model) []box {361+ x, y, inner := p.box(m)362+ rows := p.rows(m)363+ p.show(rows)364+365+ chipStyle := m.th.keyChip366+ if p.field == 1 {367+ chipStyle = m.th.menuSelected368+ }369+ cond := m.th.muted.Render(" If ") + chipStyle.Render(p.condChip())370+ if p.cond.Op.TakesArg() {371+ arg := p.fields[1]372+ if p.field == 1 {373+ arg = string(m.buf)374+ }375+ if arg == "" && p.field != 1 {376+ arg = m.th.muted.Render("value")377+ }378+ cond += " " + arg379+ }380+ search := p.fields[0]381+ if p.field == 0 {382+ search = string(m.buf)383+ }384+ input := m.th.title.Render(searchPrompt) + search385+ if search == "" {386+ input += m.th.muted.Render("Search values")387+ }388+ lines := []string{cells(m.th.menuBar, cond, inner), sepRow, cells(m.th.menuBar, input, inner), sepRow}389+390+ for r := range rows {391+ i := p.top + r392+ if i > len(p.shown) {393+ lines = append(lines, cells(m.th.menuBar, "", inner))394+ continue395+ }396+ label, count, checked := "Select all", "", p.allChecked()397+ dim := false398+ if i > 0 {399+ v := p.values[p.shown[i-1]]400+ label, count, checked, dim = valueLabel(v.Text), strconv.Itoa(v.Count), p.checked[v.Text], v.Text == ""401+ }402+ box := "[ ] "403+ if checked {404+ box = "[x] "405+ }406+ base, muted := m.th.menuBar, m.th.muted407+ if i == p.sel {408+ base, muted = m.th.menuSelected, m.th.menuSelected409+ }410+ labelStyle := base411+ if dim {412+ labelStyle = muted413+ }414+ text := base.Render(" "+box) + cells(labelStyle, label, inner-6-len(count)-1)415+ text += muted.Render(padLeft(count, len(count)+1)) + base.Render(" ")416+ lines = append(lines, ansi.Truncate(text, inner, ""))417+ }418+ title := "Filter " + sheet.ColName(p.col)419+ if r, ok := m.sheet.FilterRange(); ok {420+ if h := m.sheet.ShownText(sheet.Addr{Col: p.col, Row: r.From.Row}); h != "" {421+ title += " " + h422+ }423+ }424+ footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.values))425+ return []box{{id: filterID, x: x, y: y, lines: m.frame(inner, title, footer, lines)}}426+}
A internal/ui/freeze.go +61 -0
@@ -0,0 +1,61 @@
1+package ui2+3+import (4+ "strconv"5+6+ tea "charm.land/bubbletea/v2"7+8+ "012/internal/sheet"9+)10+11+// View > Freeze, as in Sheets: keep the first rows or columns on screen12+// while the rest scrolls. The frozen panes are drawn in panes.go.13+14+func init() {15+ freeze := func(id, title, desc string, rows bool, n func(m *Model) int) *command {16+ return &command{id: id, title: title, desc: desc,17+ run: func(m *Model) tea.Cmd {18+ m.freeze(rows, n(m))19+ return nil20+ },21+ enabled: func(m *Model) bool { return n(m) <= sheet.MaxFrozen },22+ }23+ }24+ fixed := func(k int) func(*Model) int { return func(*Model) int { return k } }25+ register(26+ freeze("view.freeze_rows0", "Unfreeze rows", "Let every row scroll", true, fixed(0)),27+ freeze("view.freeze_rows1", "Freeze 1 row", "Keep the first row on screen while the rest scrolls", true, fixed(1)),28+ freeze("view.freeze_rows2", "Freeze 2 rows", "Keep the first two rows on screen while the rest scrolls", true, fixed(2)),29+ freeze("view.freeze_rows_cur", "Freeze up to current row", "Keep the rows down to the active cell on screen while the rest scrolls", true,30+ func(m *Model) int { return m.cur.Row + 1 }),31+ freeze("view.freeze_cols0", "Unfreeze columns", "Let every column scroll", false, fixed(0)),32+ freeze("view.freeze_cols1", "Freeze 1 column", "Keep the first column on screen while the rest scrolls", false, fixed(1)),33+ freeze("view.freeze_cols2", "Freeze 2 columns", "Keep the first two columns on screen while the rest scrolls", false, fixed(2)),34+ freeze("view.freeze_cols_cur", "Freeze up to current column", "Keep the columns up to the active cell on screen while the rest scrolls", false,35+ func(m *Model) int { return m.cur.Col + 1 }),36+ )37+}38+39+// freeze sets the frozen rows (or columns) to n and says what happened,40+// including when the window is too small to show them all.41+func (m *Model) freeze(rows bool, n int) {42+ fr, fc := m.sheet.Frozen()43+ noun := "column"44+ if rows {45+ fr, noun = n, "row"46+ } else {47+ fc = n48+ }49+ m.sheet.SetFrozen(fr, fc)50+ m.changed = m.sheet.StateID() != m.saved51+ m.clampView()52+ if n == 0 {53+ m.note = "Unfroze " + noun + "s"54+ return55+ }56+ m.note = "Froze " + strconv.Itoa(n) + " " + plural(n, noun, noun+"s")57+ shownR, shownC := m.frozen()58+ if shown := map[bool]int{true: shownR, false: shownC}[rows]; shown < n {59+ m.note += " (" + strconv.Itoa(shown) + " fit in this window)"60+ }61+}
M internal/ui/links_test.go +3 -1
M internal/ui/menubar.go +14 -1
@@ -46,6 +46,12 @@ var menuBar = []menuDef{
4646 {cmd: "delete.row", title: "Delete row"}, {cmd: "delete.col", title: "Delete column"}, {cmd: "delete.selection"},4747 }},4848 {title: "View", accel: 'v', items: []menuItem{49+ {title: "Freeze", items: []menuItem{50+ {cmd: "view.freeze_rows0", title: "No rows"}, {cmd: "view.freeze_rows1", title: "1 row"},51+ {cmd: "view.freeze_rows2", title: "2 rows"}, {cmd: "view.freeze_rows_cur", title: "Up to current row"}, sep,52+ {cmd: "view.freeze_cols0", title: "No columns"}, {cmd: "view.freeze_cols1", title: "1 column"},53+ {cmd: "view.freeze_cols2", title: "2 columns"}, {cmd: "view.freeze_cols_cur", title: "Up to current column"},54+ }}, sep,4955 {cmd: "palette", title: "Command palette"}, {cmd: "help"},5056 }},5157 {title: "Insert", accel: 'i', items: []menuItem{
@@ -68,8 +74,15 @@ var menuBar = []menuDef{
6874 {cmd: "column.width"}, {cmd: "column.reset"}, sep,6975 {cmd: "format.clear", title: "Clear formatting"},7076 }},71- // Data: sort and filter will go here.7277 {title: "Data", accel: 'd', items: []menuItem{78+ {title: "Sort sheet", items: []menuItem{79+ {cmd: "data.sort_sheet_az", title: "A to Z"}, {cmd: "data.sort_sheet_za", title: "Z to A"},80+ }},81+ {title: "Sort range", items: []menuItem{82+ {cmd: "data.sort_range_az", title: "A to Z by the active column"}, {cmd: "data.sort_range_za", title: "Z to A by the active column"}, sep,83+ {cmd: "data.sort_range", title: "Advanced range sorting options"},84+ }}, sep,85+ {cmd: "data.filter"}, {cmd: "data.filter_column"}, {cmd: "data.filter_remove"}, sep,7386 {cmd: "data.named_ranges"}, {cmd: "data.define_name"}, sep,7487 {cmd: "data.precedents"}, {cmd: "data.dependents"}, sep,7588 {cmd: "jev.refresh"},
M internal/ui/model.go +34 -31
@@ -69,11 +69,13 @@ type Model struct {
6969 drag dragKind7070 lastClick time.Time7171 lastHit hit72- hover hit // what's under the mouse, for hover styling73- mouseX, mouseY int // last mouse position, for autoscroll74- autoscrolling bool // an autoscroll tick is pending75- resizeCol int // column being resized by its header border76- shape string // pointer shape last sent to the terminal72+ hover hit // what's under the mouse, for hover styling73+ mouseX, mouseY int // last mouse position, for autoscroll74+ autoscrolling bool // an autoscroll tick is pending75+ resizeCol int // column being resized by its header border76+ fillAt sheet.Addr // where a fill handle drag points, see fill.go77+ fillTo sheet.Rect // the range that drag would fill78+ shape string // pointer shape last sent to the terminal7779 7880 // tabStart remembers where a run of Tab-committed entries began, so7981 // Enter returns to that column on the next row, as in Sheets.
@@ -174,6 +176,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
174176 if f := *m.focus(); f != before || m.mode != beforeMode {175177 m.scrollTo(f)176178 }179+ m.clampView()177180 // Any edit may have queued JEV questions.178181 // Chart images follow any change, see graphics.go.179182 return m, tea.Batch(cmd, m.sendJEV(), m.syncImages())
@@ -202,6 +205,8 @@ func (m *Model) beginUpdate(msg tea.Msg) int {
202205 func (m *Model) endUpdate(state int) {203206 if m.sheet.StateID() != state {204207 m.changed = m.sheet.StateID() != m.saved208+ // An edit, a sort or a filter may hide the active cell's row.209+ m.cur.Row = m.visibleRow(m.cur.Row)205210 if !m.copied.keep {206211 m.clearCopyMark()207212 }
@@ -210,6 +215,12 @@ func (m *Model) endUpdate(state int) {
210215 }211216 212217 func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {218+ if m.drag == dragFill {219+ if k.String() == "esc" {220+ m.cancelFill()221+ }222+ return nil // the mouse is busy filling223+ }213224 if m.traceKey(k) {214225 return nil215226 }
@@ -255,12 +266,12 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
255266 // navigate applies a movement key to a, returning false if it isn't one.256267 // Keys follow Google Sheets.257268 func (m *Model) navigate(key string, a *sheet.Addr) bool {258- rows, cols := m.visibleRows(), m.visibleCols(m.left)269+ rows, cols := m.scrollRows(), m.visibleCols(m.left)259270 switch key {260271 case "up":261- a.Row--272+ a.Row = m.stepRow(a.Row, -1)262273 case "down":263- a.Row++274+ a.Row = m.stepRow(a.Row, 1)264275 case "left", "shift+tab":265276 a.Col--266277 case "right", "tab":
@@ -274,11 +285,11 @@ func (m *Model) navigate(key string, a *sheet.Addr) bool {
274285 case "ctrl+right", "end":275286 *a = m.sheet.Edge(*a, 1, 0)276287 case "pgup":277- a.Row -= rows278- m.top -= rows288+ a.Row = m.stepRow(a.Row, -rows)289+ m.top = m.stepRow(m.top, -rows)279290 case "pgdown":280- a.Row += rows281- m.top += rows291+ a.Row = m.stepRow(a.Row, rows)292+ m.top = m.stepRow(m.top, rows)282293 case "alt+pgup":283294 a.Col -= cols284295 m.left -= cols
@@ -296,8 +307,8 @@ func (m *Model) navigate(key string, a *sheet.Addr) bool {
296307 return false297308 }298309 *a = clampAddr(*a)299- m.top = clamp(m.top, 0, sheet.MaxRows-1)300- m.left = clamp(m.left, 0, sheet.MaxCols-1)310+ a.Row = m.visibleRow(a.Row)311+ m.clampView()301312 return true302313 }303314
@@ -324,14 +335,15 @@ func (m *Model) handleWheel(mouse tea.Mouse) {
324335 }325336 switch button {326337 case tea.MouseWheelUp:327- m.top = max(m.top-step, 0)338+ m.top = m.stepRow(m.top, -step)328339 case tea.MouseWheelDown:329- m.top = min(m.top+step, sheet.MaxRows-m.visibleRows())340+ m.top = min(m.stepRow(m.top, step), sheet.MaxRows-m.scrollRows())330341 case tea.MouseWheelLeft:331- m.left = max(m.left-1, 0)342+ m.left--332343 case tea.MouseWheelRight:333344 m.left = min(m.left+1, sheet.MaxCols-1)334345 }346+ m.clampView()335347 }336348 337349 // focus is the cell the viewport follows: the pointer while pointing, the
@@ -340,6 +352,8 @@ func (m *Model) focus() *sheet.Addr {
340352 switch {341353 case m.mode == modePoint || m.pointing():342354 return &m.point.at355+ case m.drag == dragFill:356+ return &m.fillAt343357 case m.selecting && m.whole == wholeNone:344358 return &m.ext345359 }
@@ -350,9 +364,10 @@ func (m *Model) visibleRows() int {
350364 return max(m.height-gridTop-1, 1)351365 }352366 353-// visibleCols returns how many whole columns fit starting at left.367+// visibleCols returns how many whole scrolling columns fit starting at368+// left.354369 func (m *Model) visibleCols(left int) int {355- n, w := 0, rowHdrW370+ n, w := 0, m.scrollX()356371 for c := left; c < sheet.MaxCols; c++ {357372 w += m.sheet.ColWidth(c)358373 if w > m.width {
@@ -363,18 +378,6 @@ func (m *Model) visibleCols(left int) int {
363378 return max(n, 1)364379 }365380 366-// scrollTo moves the viewport the minimum amount needed to show a.367-func (m *Model) scrollTo(a sheet.Addr) {368- rows := m.visibleRows()369- m.top = clamp(m.top, a.Row-rows+1, a.Row)370- if a.Col < m.left {371- m.left = a.Col372- }373- for a.Col >= m.left+m.visibleCols(m.left) {374- m.left++375- }376-}377-378381 // typed returns the printable text of a key press, if any.379382 func typed(k tea.KeyPressMsg) string {380383 if k.Mod.Contains(tea.ModCtrl) || k.Mod.Contains(tea.ModAlt) {
M internal/ui/model_test.go +1 -1
M internal/ui/mouse.go +106 -27
@@ -29,6 +29,8 @@ const (
2929 hitEditLine // the entry being typed3030 hitPanel3131 hitStatus32+ hitFilterButton // the filter mark in a column header33+ hitFillHandle // the corner of the selection that drags out a fill3234 )3335 3436 type hit struct {
@@ -47,7 +49,6 @@ func (m *Model) editLineAt() (line, x int) { return formulaLine, formulaBarTextX
4749 4850 // hitTest maps a screen position to what's there.4951 func (m *Model) hitTest(x, y int) hit {50- rows := m.visibleRows()5152 switch {5253 case y < panelLines:5354 if line, tx := m.editLineAt(); y == line && m.editing() {
@@ -65,29 +66,49 @@ func (m *Model) hitTest(x, y int) hit {
6566 return hit{}6667 }6768 kind := hitColHeader68- if x == start+m.sheet.ColWidth(col)-1 {69+ switch {70+ case x == start+m.sheet.ColWidth(col)-1:6971 kind = hitColBorder72+ case x == m.filterButtonX(col):73+ kind = hitFilterButton7074 }7175 return hit{kind: kind, addr: sheet.Addr{Col: col, Row: m.top}}72- case y >= gridTop && y < gridTop+rows:73- row := m.top + y - gridTop76+ case y >= gridTop && y < gridTop+m.visibleRows():77+ row, ok := m.rowAt(y)78+ if !ok {79+ return hit{}80+ }7481 if x < rowHdrW {7582 return hit{kind: hitRowHeader, addr: sheet.Addr{Col: m.left, Row: row}}7683 }77- col, _, ok := m.colSpan(x)84+ col, start, ok := m.colSpan(x)7885 if !ok {7986 return hit{}8087 }81- return hit{kind: hitCell, addr: sheet.Addr{Col: col, Row: row}}88+ a := sheet.Addr{Col: col, Row: row}89+ if m.mode == modeReady && a == m.fillCorner() && x == start+m.sheet.ColWidth(col)-1 {90+ return hit{kind: hitFillHandle, addr: a}91+ }92+ return hit{kind: hitCell, addr: a}8293 case y == m.height-1:8394 return hit{kind: hitStatus}8495 }8596 return hit{}8697 }8798 88-// colSpan returns the visible column under x and the x where it starts.99+// colSpan returns the visible column under x and the x where it starts:100+// a frozen column or a scrolling one, but not the divider between them.89101 func (m *Model) colSpan(x int) (col, start int, ok bool) {102+ _, fc := m.frozen()90103 cx := rowHdrW104+ for c := 0; c < fc; c++ {105+ w := m.sheet.ColWidth(c)106+ if x >= cx && x < cx+w {107+ return c, cx, true108+ }109+ cx += w110+ }111+ cx = m.scrollX()91112 for c := m.left; c < sheet.MaxCols && cx < m.width; c++ {92113 w := m.sheet.ColWidth(c)93114 if x >= cx && x < cx+w {
@@ -99,9 +120,18 @@ func (m *Model) colSpan(x int) (col, start int, ok bool) {
99120 }100121 101122 // colStart returns the screen x where column c starts, which may be off102-// screen.123+// screen (or under the frozen columns, for a scrolling column left of124+// m.left).103125 func (m *Model) colStart(c int) int {104- x := rowHdrW126+ _, fc := m.frozen()127+ if c < fc {128+ x := rowHdrW129+ for k := range c {130+ x += m.sheet.ColWidth(k)131+ }132+ return x133+ }134+ x := m.scrollX()105135 if c >= m.left {106136 for k := m.left; k < c; k++ {107137 x += m.sheet.ColWidth(k)
@@ -116,32 +146,67 @@ func (m *Model) colStart(c int) int {
116146 117147 // dragTarget maps a position during a drag to a cell, clamping to the118148 // visible grid, and reports which way to autoscroll when it's outside.149+// Dragging from the scrolling area into the frozen panes scrolls back150+// toward them, as in Sheets, until the two meet.119151 func (m *Model) dragTarget(x, y int) (a sheet.Addr, dc, dr int) {120- rows, cols := m.visibleRows(), m.visibleCols(m.left)121- switch {122- case y < gridTop:123- dr = -1124- case y >= gridTop+rows:125- dr = 1152+ rows := m.screenRows()153+ fr, fc := m.frozen()154+ anchor := m.dragAnchor()155+ firstRow := m.visibleRow(fr)156+ scrolledDown := m.top > firstRow157+ var last int158+ for _, r := range rows {159+ if r != divider {160+ last = r161+ }126162 }163+ switch row, ok := m.rowAt(y); {164+ case ok && (row >= fr || !scrolledDown || anchor.Row < fr):165+ a.Row = row166+ case y >= gridTop+len(rows):167+ a.Row, dr = last, 1168+ case y >= gridTop && !scrolledDown: // the divider, with nothing scrolled169+ a.Row = m.top170+ case scrolledDown && anchor.Row >= fr:171+ a.Row, dr = m.top, -1172+ default: // above the grid173+ a.Row = rows[0]174+ if scrolledDown {175+ dr = -1176+ }177+ }178+ scrolledRight := m.left > fc179+ cols := m.visibleCols(m.left)127180 right := m.colStart(m.left + cols)128- switch {129- case x < rowHdrW:130- dc = -1181+ switch col, _, ok := m.colSpan(x); {131182 case x >= right:132- dc = 1133- }134- a.Row = clamp(m.top+y-gridTop, m.top, m.top+rows-1)135- if col, _, ok := m.colSpan(x); ok && dc == 0 {183+ a.Col, dc = m.left+cols-1, 1184+ case ok && (col >= fc || !scrolledRight || anchor.Col < fc):136185 a.Col = col137- } else if dc < 0 {186+ case x >= rowHdrW && !scrolledRight:138187 a.Col = m.left139- } else {140- a.Col = m.left + cols - 1188+ case scrolledRight && anchor.Col >= fc:189+ a.Col, dc = m.left, -1190+ default: // over the row numbers191+ a.Col = 0192+ if fc == 0 {193+ a.Col = m.left194+ }195+ if scrolledRight || fc == 0 && m.left > 0 {196+ dc = -1197+ }141198 }142199 return clampAddr(a), dc, dr143200 }144201 202+// dragAnchor is where the drag in progress started.203+func (m *Model) dragAnchor() sheet.Addr {204+ if m.drag == dragPoint {205+ return m.point.anchor206+ }207+ return m.cur208+}209+145210 // editing reports whether an entry is being typed.146211 func (m *Model) editing() bool {147212 return m.mode == modeEnter || m.mode == modeEdit || m.mode == modePoint
@@ -214,6 +279,10 @@ func (m *Model) readyPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
214279 case hitCorner:215280 m.cur = sheet.Addr{Col: m.left, Row: m.top}216281 m.selecting, m.whole, m.ext = true, wholeAll, m.cur282+ case hitFillHandle:283+ m.startFill()284+ case hitFilterButton:285+ m.openFilterPicker(h.addr.Col)217286 case hitColBorder:218287 if double {219288 m.autofit(h.addr.Col)
@@ -287,6 +356,8 @@ func (m *Model) dragTo(x, y int) tea.Cmd {
287356 m.ext.Col = a.Col288357 case dragRows:289358 m.ext.Row = a.Row359+ case dragFill:360+ m.dragFillTo(a)290361 }291362 if (dc != 0 || dr != 0) && !m.autoscrolling {292363 m.autoscrolling = true
@@ -314,7 +385,7 @@ func (m *Model) handleAutoscroll() tea.Cmd {
314385 return nil315386 }316387 step := func(a *sheet.Addr) {317- *a = clampAddr(sheet.Addr{Col: a.Col + dc, Row: a.Row + dr})388+ *a = clampAddr(sheet.Addr{Col: a.Col + dc, Row: m.stepRow(a.Row, dr)})318389 }319390 switch m.drag {320391 case dragPoint:
@@ -323,12 +394,18 @@ func (m *Model) handleAutoscroll() tea.Cmd {
323394 case dragCells, dragCols, dragRows:324395 m.selecting = true325396 step(&m.ext)397+ case dragFill:398+ step(&m.fillAt)399+ m.dragFillTo(m.fillAt)326400 }327401 m.scrollTo(*m.focus())328402 return autoscrollTick()329403 }330404 331405 func (m *Model) handleRelease() tea.Cmd {406+ if m.drag == dragFill {407+ m.finishFill()408+ }332409 m.drag, m.autoscrolling = dragNone, false333410 if m.selecting && m.whole == wholeNone && m.ext == m.cur {334411 m.clearSelection()
@@ -368,13 +445,15 @@ func (m *Model) pointerShape() tea.Cmd {
368445 switch {369446 case m.drag == dragResize, m.hover.kind == hitColBorder:370447 shape = "col-resize"448+ case m.drag == dragFill, m.hover.kind == hitFillHandle:449+ shape = "crosshair"371450 case m.mode == modeReady && m.chartAt(m.mouseX, m.mouseY) >= 0:372451 shape = "move"373452 case m.hover.kind == hitCell:374453 shape = "cell"375454 case m.hover.kind == hitFormulaBar, m.hover.kind == hitEditLine:376455 shape = "text"377- case m.hover.kind == hitColHeader, m.hover.kind == hitRowHeader, m.hover.kind == hitCorner:456+ case m.hover.kind == hitColHeader, m.hover.kind == hitRowHeader, m.hover.kind == hitCorner, m.hover.kind == hitFilterButton:378457 shape = "pointer"379458 }380459 if shape == m.shape {
M internal/ui/panel.go +13 -1
@@ -20,8 +20,13 @@ import (
2020 // View implements tea.Model.2121 func (m *Model) View() tea.View {2222 lines := []string{m.menuBarLine(), m.formulaBar(), m.contextLineText(), m.headerRow()}23+ rows := m.screenRows()2324 for i := range m.visibleRows() {24- lines = append(lines, m.gridRow(m.top+i))25+ if i < len(rows) {26+ lines = append(lines, m.gridRow(rows[i]))27+ } else {28+ lines = append(lines, "")29+ }2530 }2631 lines = append(lines, m.statusLine())2732 for i, l := range lines {
@@ -113,6 +118,8 @@ func (m *Model) contextLineText() string {
113118 case m.drag == dragResize:114119 left = m.th.key.Render("Column "+sheet.ColName(m.resizeCol)) + m.th.muted.Render(" width ") +115120 strconv.Itoa(m.sheet.ColWidth(m.resizeCol)) + m.th.muted.Render(" double-click the border to fit")121+ case m.drag == dragFill:122+ left = m.fillLine()116123 case m.hint != "":117124 left = m.th.warning.Render(m.hint)118125 case m.mode == modeReady && m.trace != nil:
@@ -132,6 +139,8 @@ func (m *Model) contextLineText() string {
132139 left, right = o.line(m)133140 case contextLiner:134141 left, right = o.contextLine(m)142+ case *sortBar:143+ left = o.line(m)135144 }136145 case m.mode == modePrompt:137146 left, right = m.promptLine()
@@ -239,6 +248,9 @@ func (m *Model) statusLine() string {
239248 if m.sheet.Circular {240249 left += " " + m.th.warning.Render("Circular reference")241250 }251+ if n := m.sheet.HiddenRows(); n > 0 {252+ left += " " + m.th.hint.Render("Filter hides "+rowCount(n))253+ }242254 if busy := m.jevBusy(); busy != "" {243255 left += " " + m.th.hint.Render(busy)244256 }
A internal/ui/panes.go +185 -0
@@ -0,0 +1,185 @@
1+package ui2+3+import (4+ "012/internal/sheet"5+)6+7+// The viewport: frozen rows and columns stay on screen at the top and8+// left while the rest scrolls, split from it by a thin line like a tmux9+// pane border, and rows the filter hides are skipped. m.top and m.left10+// are the first scrolling row and column, never inside the frozen area.11+// Everything that maps between rows or columns and the screen goes12+// through here, so drawing, navigation and the mouse agree.13+14+// frozen returns the frozen rows and columns that fit on screen: frozen15+// panes too big for the window are shown only in part, leaving room for16+// at least one scrolling row and column.17+func (m *Model) frozen() (rows, cols int) {18+ rows, cols = m.sheet.Frozen()19+ for rows > 0 && m.frozenLines(rows)+1 >= m.visibleRows() {20+ rows--21+ }22+ w := 023+ for c := range cols {24+ w += m.sheet.ColWidth(c)25+ if rowHdrW+w+1+3 > m.width {26+ cols = c27+ break28+ }29+ }30+ return rows, cols31+}32+33+// frozenLines counts the screen lines frozen rows take: those the filter34+// doesn't hide.35+func (m *Model) frozenLines(rows int) int {36+ n := 037+ for r := range rows {38+ if !m.sheet.RowHidden(r) {39+ n++40+ }41+ }42+ return n43+}44+45+// scrollRows is how many scrolling rows fit below the frozen ones and46+// their divider.47+func (m *Model) scrollRows() int {48+ fr, _ := m.frozen()49+ if fr == 0 {50+ return m.visibleRows()51+ }52+ return max(m.visibleRows()-m.frozenLines(fr)-1, 1)53+}54+55+// divider marks the line between frozen and scrolling rows in56+// screenRows.57+const divider = -158+59+// screenRows returns the row shown on each grid line, top to bottom:60+// frozen rows, the divider, then scrolling rows from m.top, skipping61+// rows the filter hides. Rows past the end of the sheet are left off.62+func (m *Model) screenRows() []int {63+ n := m.visibleRows()64+ out := make([]int, 0, n)65+ fr, _ := m.frozen()66+ for r := 0; r < fr; r++ {67+ if !m.sheet.RowHidden(r) {68+ out = append(out, r)69+ }70+ }71+ if fr > 0 {72+ out = append(out, divider)73+ }74+ for r := m.top; len(out) < n && r < sheet.MaxRows; r++ {75+ if !m.sheet.RowHidden(r) {76+ out = append(out, r)77+ }78+ }79+ return out80+}81+82+// rowAt returns the row on screen line y, or false for the divider and83+// lines outside the grid.84+func (m *Model) rowAt(y int) (int, bool) {85+ rows := m.screenRows()86+ if i := y - gridTop; i >= 0 && i < len(rows) && rows[i] != divider {87+ return rows[i], true88+ }89+ return 0, false90+}91+92+// rowY returns the screen line of row r, or false if it isn't on screen.93+func (m *Model) rowY(r int) (int, bool) {94+ for i, row := range m.screenRows() {95+ if row == r {96+ return gridTop + i, true97+ }98+ }99+ return 0, false100+}101+102+// stepRow moves n visible rows from r (up for negative n), skipping103+// hidden rows and stopping at the edges of the sheet.104+func (m *Model) stepRow(r, n int) int {105+ d := 1106+ if n < 0 {107+ d, n = -1, -n108+ }109+ for ; n > 0; n-- {110+ next := r + d111+ for next >= 0 && next < sheet.MaxRows && m.sheet.RowHidden(next) {112+ next += d113+ }114+ if next < 0 || next >= sheet.MaxRows {115+ break116+ }117+ r = next118+ }119+ return r120+}121+122+// visibleRow returns r, or the nearest row above it the filter doesn't123+// hide (the header row always shows), so the active cell stays in the124+// data when its row is filtered out.125+func (m *Model) visibleRow(r int) int {126+ if !m.sheet.RowHidden(r) {127+ return r128+ }129+ if prev := m.stepRow(r, -1); prev != r {130+ return prev131+ }132+ return m.stepRow(r, 1)133+}134+135+// scrollX is the screen x where the scrolling columns start: after the136+// row numbers, the frozen columns and their divider.137+func (m *Model) scrollX() int {138+ _, fc := m.frozen()139+ x := rowHdrW140+ for c := range fc {141+ x += m.sheet.ColWidth(c)142+ }143+ if fc > 0 {144+ x++145+ }146+ return x147+}148+149+// clampView keeps the scrolling area out of the frozen panes, e.g. after150+// freezing more rows than were scrolled past.151+func (m *Model) clampView() {152+ fr, fc := m.frozen()153+ m.top = clamp(m.top, fr, sheet.MaxRows-1)154+ m.left = clamp(m.left, fc, sheet.MaxCols-1)155+}156+157+// scrollTo moves the viewport the minimum amount needed to show a. Cells158+// in the frozen panes are always on screen.159+func (m *Model) scrollTo(a sheet.Addr) {160+ m.clampView()161+ fr, fc := m.frozen()162+ if a.Row >= fr {163+ if a.Row < m.top {164+ m.top = a.Row165+ } else {166+ // Walk up from a over as many visible rows as fit; if that167+ // passes m.top, a is already on screen.168+ r, n := a.Row, 1169+ for r > m.top && n < m.scrollRows() {170+ r = m.stepRow(r, -1)171+ n++172+ }173+ m.top = max(m.top, r)174+ }175+ }176+ if a.Col >= fc {177+ if a.Col < m.left {178+ m.left = a.Col179+ }180+ for a.Col >= m.left+m.visibleCols(m.left) {181+ m.left++182+ }183+ }184+ m.clampView()185+}
A internal/ui/panes_test.go +216 -0
@@ -0,0 +1,216 @@
1+package ui2+3+import (4+ "fmt"5+ "strings"6+ "testing"7+ "time"8+9+ tea "charm.land/bubbletea/v2"10+11+ "012/internal/sheet"12+)13+14+// table fills n rows below a header "Item, Qty": Row 1..n and the row15+// number mod 4.16+func table(t *testing.T, m *Model, n int) {17+ t.Helper()18+ s := m.sheet19+ s.Set(addr("A1"), "Item")20+ s.Set(addr("B1"), "Qty")21+ for i := range n {22+ s.Set(sheet.Addr{Row: i + 1}, fmt.Sprintf("Row %d", i+1))23+ s.Set(sheet.Addr{Col: 1, Row: i + 1}, fmt.Sprint((i+1)%4))24+ }25+ s.ClearHistory()26+}27+28+func TestFrozenPanesStayWhileScrolling(t *testing.T) {29+ m := newModel() // 80x20: 15 grid lines30+ table(t, m, 40)31+ m.runCommand("view.freeze_rows1")32+ m.runCommand("view.freeze_cols1")33+ if m.note != "Froze 1 column" || !m.changed {34+ t.Errorf("note %q changed %v", m.note, m.changed)35+ }36+ press(t, m, "<ctrl+end>")37+ if m.cur != addr("B41") {38+ t.Fatalf("cur %v", m.cur)39+ }40+ // Row 1 and column A stay; a divider line and column separate them.41+ if l := line(m, gridTop); !strings.HasPrefix(l, " 1 Item │ Qty") {42+ t.Errorf("frozen row %q", l)43+ }44+ if l := line(m, gridTop+1); !strings.HasPrefix(l, "────────────────┼───") {45+ t.Errorf("divider %q", l)46+ }47+ if l := line(m, gridTop+14); !strings.HasPrefix(l, " 41 Row 40 │ 0") {48+ t.Errorf("last row %q", l)49+ }50+ if m.top != 28 {51+ t.Errorf("top %d, want 28 (13 scrolling rows ending at 41)", m.top)52+ }53+ // Moving up into the frozen row doesn't scroll.54+ press(t, m, "<ctrl+up>")55+ if m.cur != addr("B1") || m.top != 28 {56+ t.Errorf("ctrl+up: cur %v top %d", m.cur, m.top)57+ }58+ press(t, m, "<down>")59+ if m.cur != addr("B2") || m.top != 1 {60+ t.Errorf("down from the frozen row: cur %v top %d", m.cur, m.top)61+ }62+ // Horizontal scrolling keeps column A.63+ for range 10 {64+ press(t, m, "<right>")65+ }66+ if l := line(m, headerLine); !strings.HasPrefix(l, " A │") || strings.Contains(l, " B ") {67+ t.Errorf("header %q", l)68+ }69+ // Undo unfreezes.70+ press(t, m, "<ctrl+z>")71+ if _, c := m.sheet.Frozen(); c != 0 || m.note != "Undid: freeze 1 column" {72+ t.Errorf("undo: %d frozen columns, note %q", c, m.note)73+ }74+}75+76+func TestFrozenPanesShrinkToFit(t *testing.T) {77+ m := newModel()78+ m.cur = addr("A30")79+ m.runCommand("view.freeze_rows_cur")80+ if r, _ := m.frozen(); r != 13 || !strings.Contains(m.note, "13 fit in this window") {81+ t.Errorf("frozen %d, note %q", r, m.note)82+ }83+ if got := len(m.screenRows()); got != m.visibleRows() {84+ t.Errorf("%d screen rows", got)85+ }86+}87+88+func TestMouseWithFrozenPanes(t *testing.T) {89+ m := newModel()90+ table(t, m, 40)91+ m.sheet.SetFrozen(2, 1)92+ m.top, m.left = 20, 393+ divX := rowHdrW + sheet.DefaultWidth // the column divider94+ tests := []struct {95+ x, y int96+ want hit97+ }{98+ {rowHdrW + 1, gridTop, hit{kind: hitCell, addr: addr("A1")}},99+ {rowHdrW + 1, gridTop + 1, hit{kind: hitCell, addr: addr("A2")}},100+ {rowHdrW + 1, gridTop + 2, hit{}}, // the divider line101+ {rowHdrW + 1, gridTop + 3, hit{kind: hitCell, addr: addr("A21")}},102+ {divX, gridTop + 3, hit{}},103+ {divX + 1, gridTop + 3, hit{kind: hitCell, addr: addr("D21")}},104+ {divX + 1, gridTop, hit{kind: hitCell, addr: addr("D1")}},105+ {2, gridTop + 3, hit{kind: hitRowHeader, addr: sheet.Addr{Col: 3, Row: 20}}},106+ {divX + sheet.DefaultWidth, headerLine, hit{kind: hitColBorder, addr: sheet.Addr{Col: 3, Row: 20}}},107+ {rowHdrW + sheet.DefaultWidth - 1, headerLine, hit{kind: hitColBorder, addr: sheet.Addr{Col: 0, Row: 20}}},108+ }109+ for _, tt := range tests {110+ if got := m.hitTest(tt.x, tt.y); got != tt.want {111+ t.Errorf("hitTest(%d, %d) = %+v, want %+v", tt.x, tt.y, got, tt.want)112+ }113+ }114+ // Resizing a scrolling column measures from where it's drawn.115+ send(m, tea.MouseClickMsg(leftAt(divX+sheet.DefaultWidth, headerLine)))116+ send(m, tea.MouseMotionMsg(leftAt(divX+14, headerLine)))117+ send(m, tea.MouseReleaseMsg(leftAt(divX+14, headerLine)))118+ if w := m.sheet.ColWidth(3); w != 14 {119+ t.Errorf("D width %d", w)120+ }121+ // The context menu opens under the clicked cell.122+ x, y := m.cellPos(addr("D22"))123+ if x != divX+1 || y != gridTop+4 {124+ t.Errorf("cellPos D22 = %d, %d", x, y)125+ }126+}127+128+func TestDragIntoFrozenRowsScrollsBack(t *testing.T) {129+ m := newModel()130+ table(t, m, 40)131+ m.sheet.SetFrozen(1, 0)132+ m.top = 6133+ send(m, tea.MouseClickMsg(leftAt(cellX(1), gridTop+4)))134+ if m.cur != addr("B9") {135+ t.Fatalf("cur %v", m.cur)136+ }137+ // Dragging onto the frozen header row scrolls up instead of jumping138+ // to row 1.139+ send(m, tea.MouseMotionMsg(leftAt(cellX(1), gridTop)))140+ if m.ext != addr("B7") || !m.autoscrolling {141+ t.Fatalf("ext %v autoscrolling %v", m.ext, m.autoscrolling)142+ }143+ for i := 0; i < 10 && m.autoscrolling; i++ {144+ send(m, autoscrollMsg{})145+ }146+ if m.top != 1 || m.ext.Row > 1 {147+ t.Errorf("after autoscroll: top %d ext %v", m.top, m.ext)148+ }149+ send(m, tea.MouseReleaseMsg(leftAt(cellX(1), gridTop)))150+ if r := m.selection(); r.From.Row > 1 || r.To != addr("B9") {151+ t.Errorf("selection %v", r)152+ }153+}154+155+func TestNavigationSkipsFilteredRows(t *testing.T) {156+ m := newModel()157+ table(t, m, 40)158+ m.sheet.CreateFilter(sheet.Rect{To: addr("B41")})159+ m.sheet.FilterColumn(1, sheet.Criteria{Hidden: []string{"1", "2", "3"}}) // rows 5, 9, ..., 41 show160+ press(t, m, "<ctrl+home>", "<down>")161+ if m.cur != addr("A5") {162+ t.Errorf("down: %v", m.cur)163+ }164+ press(t, m, "<down>", "<up>", "<up>")165+ if m.cur != addr("A1") {166+ t.Errorf("down, up, up: %v", m.cur)167+ }168+ press(t, m, "<ctrl+down>")169+ if m.cur != addr("A41") {170+ t.Errorf("ctrl+down: %v", m.cur)171+ }172+ // Row numbers show the gaps; the header marks the filtered column.173+ press(t, m, "<ctrl+home>")174+ var nums []string175+ for i := range 4 {176+ nums = append(nums, strings.Fields(line(m, gridTop+i))[0])177+ }178+ if strings.Join(nums, " ") != "1 5 9 13" {179+ t.Errorf("row numbers %v", nums)180+ }181+ if l := line(m, headerLine); !strings.Contains(l, "A ▾") || !strings.Contains(l, "B ▼") {182+ t.Errorf("header %q", l)183+ }184+ if !strings.Contains(line(m, m.height-1), "Filter hides 30 rows") {185+ t.Errorf("status %q", line(m, m.height-1))186+ }187+ // Page down moves a screenful (15) of visible rows: ten in the188+ // filter, then five below it.189+ press(t, m, "<pgdown>")190+ if m.cur != addr("A46") {191+ t.Errorf("pgdown: %v", m.cur)192+ }193+ // Clicking a row maps through the gaps.194+ press(t, m, "<ctrl+home>")195+ click(m, cellX(0), gridTop+2, 0)196+ if m.cur != addr("A9") {197+ t.Errorf("click: %v", m.cur)198+ }199+}200+201+func TestFilteredNavigationIsFast(t *testing.T) {202+ m := newModel()203+ table(t, m, sheet.MaxRows-2)204+ m.sheet.SetFrozen(1, 1)205+ m.sheet.CreateFilter(sheet.Rect{To: sheet.Addr{Col: 1, Row: sheet.MaxRows - 1}})206+ m.sheet.FilterColumn(1, sheet.Criteria{Hidden: []string{"1", "2", "3"}})207+ m.View()208+ start := time.Now()209+ for _, k := range []string{"<down>", "<pgdown>", "<ctrl+down>", "<ctrl+up>", "<pgup>", "<up>"} {210+ press(t, m, k)211+ m.View()212+ }213+ if d := time.Since(start) / 6; d > 20*time.Millisecond {214+ t.Errorf("a keystroke took %v", d)215+ }216+}
M internal/ui/selection.go +1 -0
A internal/ui/sort.go +320 -0
@@ -0,0 +1,320 @@
1+package ui2+3+import (4+ "strings"5+6+ tea "charm.land/bubbletea/v2"7+ "github.com/charmbracelet/x/ansi"8+9+ "012/internal/sheet"10+)11+12+// Sorting follows Sheets' Data menu: sort the sheet or a range by the13+// active column, A to Z or Z to A, or pick the columns and order on a bar14+// on the context line (Sheets' "Advanced range sorting options" dialog).15+// The bar shows each sort column as a chip; arrows change the focused16+// one, and the range being sorted is highlighted with the sort column's17+// header lit.18+19+func init() {20+ register(21+ &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",22+ run: func(m *Model) tea.Cmd { return m.sortSheet(false) }},23+ &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",24+ run: func(m *Model) tea.Cmd { return m.sortSheet(true) }},25+ &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",26+ run: func(m *Model) tea.Cmd { return m.sortRange(false) }},27+ &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",28+ run: func(m *Model) tea.Cmd { return m.sortRange(true) }},29+ &command{id: "data.sort_range", title: "Sort range", desc: "Sort the selection by one or more columns, with or without a header row",30+ run: func(m *Model) tea.Cmd {31+ m.openSortBar()32+ return nil33+ }},34+ )35+}36+37+// sortSheet sorts all rows below the frozen ones by the active column.38+func (m *Model) sortSheet(desc bool) tea.Cmd {39+ used, ok := m.sheet.UsedRange()40+ fr, _ := m.sheet.Frozen()41+ if !ok || used.To.Row <= fr {42+ m.note = "Nothing to sort"43+ return nil44+ }45+ r := sheet.Rect{From: sheet.Addr{Row: fr}, To: used.To}46+ return m.sort(r, []sheet.SortKey{{Col: m.cur.Col, Desc: desc}})47+}48+49+// sortRange sorts the selection, or the data around the active cell, by50+// the active column, leaving any header row in place.51+func (m *Model) sortRange(desc bool) tea.Cmd {52+ r := m.dataRange()53+ r.From.Row += m.headerRows(r, m.hasRange())54+ col := clamp(m.cur.Col, r.From.Col, r.To.Col)55+ return m.sort(r, []sheet.SortKey{{Col: col, Desc: desc}})56+}57+58+func (m *Model) sort(r sheet.Rect, keys []sheet.SortKey) tea.Cmd {59+ if r.To.Row <= r.From.Row {60+ m.note = "Nothing to sort"61+ return nil62+ }63+ m.sheet.SortRange(r, keys)64+ m.changed = true65+ var by []string66+ for _, k := range keys {67+ by = append(by, sheet.ColName(k.Col)+" "+orderName(k.Desc))68+ }69+ m.note = "Sorted " + r.String() + " by " + strings.Join(by, ", then ")70+ return nil71+}72+73+func orderName(desc bool) string {74+ if desc {75+ return "Z→A"76+ }77+ return "A→Z"78+}79+80+// headerRows is how many rows at the top of r are headers that sorting81+// leaves in place: frozen rows, the filter's header row, and, for a range82+// found around the active cell, a first row of text over numbers.83+func (m *Model) headerRows(r sheet.Rect, selected bool) int {84+ n := 085+ if fr, _ := m.sheet.Frozen(); fr > r.From.Row {86+ n = fr - r.From.Row87+ }88+ if f, ok := m.sheet.FilterRange(); ok && f.From.Row == r.From.Row {89+ n = max(n, 1)90+ }91+ if n == 0 && !selected && m.looksLikeHeader(r) {92+ n = 193+ }94+ return min(n, r.To.Row-r.From.Row)95+}96+97+// looksLikeHeader reports whether r's first row is all text above a row98+// with numbers, like "Item, Amount" above "Rent, 1450".99+func (m *Model) looksLikeHeader(r sheet.Rect) bool {100+ if r.To.Row == r.From.Row {101+ return false102+ }103+ text, numbers := false, false104+ for c := r.From.Col; c <= r.To.Col; c++ {105+ switch m.sheet.Value(sheet.Addr{Col: c, Row: r.From.Row}).Kind {106+ case sheet.Empty:107+ continue108+ case sheet.Text:109+ text = true110+ default:111+ return false112+ }113+ switch m.sheet.Value(sheet.Addr{Col: c, Row: r.From.Row + 1}).Kind {114+ case sheet.Number, sheet.Bool:115+ numbers = true116+ }117+ }118+ return text && numbers119+}120+121+// sortBar picks the columns and order to sort a range by.122+type sortBar struct {123+ rng sheet.Rect // the whole range, header rows included124+ headers int // header rows at the top of rng; 0 when switched off125+ guess int // header rows to use when switched on126+ keys []sheet.SortKey127+ cur int // the key being changed128+}129+130+func (m *Model) openSortBar() {131+ r := m.dataRange()132+ b := &sortBar{rng: r, headers: m.headerRows(r, m.hasRange())}133+ b.guess = max(b.headers, 1)134+ b.keys = []sheet.SortKey{{Col: clamp(m.cur.Col, r.From.Col, r.To.Col)}}135+ m.openOverlay(b)136+}137+138+// data is the range the sort moves: rng without its headers.139+func (b *sortBar) data() sheet.Rect {140+ r := b.rng141+ r.From.Row = min(r.From.Row+b.headers, r.To.Row)142+ return r143+}144+145+// barRange is the range highlighted while a data bar is open.146+func (m *Model) barRange() (sheet.Rect, bool) {147+ if b, ok := m.overlay.(*sortBar); ok {148+ return b.data(), true149+ }150+ return sheet.Rect{}, false151+}152+153+// barActive is the cell drawn as active while a data bar is open: the top154+// of the focused sort column, so its header lights up.155+func (m *Model) barActive() (sheet.Addr, bool) {156+ if b, ok := m.overlay.(*sortBar); ok {157+ return sheet.Addr{Col: b.keys[b.cur].Col, Row: b.data().From.Row}, true158+ }159+ return sheet.Addr{}, false160+}161+162+func (b *sortBar) indicator() string { return "SORT" }163+func (b *sortBar) layout(*Model) []box { return nil }164+165+// setCol points the focused key at column c, if it's in the range.166+func (b *sortBar) setCol(c int) {167+ if c >= b.rng.From.Col && c <= b.rng.To.Col {168+ b.keys[b.cur].Col = c169+ }170+}171+172+// add adds a sort column after the focused one: the next column not yet173+// sorted by.174+func (b *sortBar) add() {175+ used := map[int]bool{}176+ for _, k := range b.keys {177+ used[k.Col] = true178+ }179+ for c := b.rng.From.Col; c <= b.rng.To.Col; c++ {180+ if !used[c] {181+ b.keys = append(b.keys[:b.cur+1], append([]sheet.SortKey{{Col: c}}, b.keys[b.cur+1:]...)...)182+ b.cur++183+ return184+ }185+ }186+}187+188+func (b *sortBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {189+ key := k.String()190+ switch key {191+ case "esc":192+ m.closeOverlay()193+ case "enter":194+ keys, r := b.keys, b.data()195+ m.closeOverlay()196+ return m.sort(r, keys)197+ case "left":198+ b.setCol(b.keys[b.cur].Col - 1)199+ case "right":200+ b.setCol(b.keys[b.cur].Col + 1)201+ case "up", "down", "space":202+ b.keys[b.cur].Desc = !b.keys[b.cur].Desc203+ case "tab":204+ b.cur = (b.cur + 1) % len(b.keys)205+ case "shift+tab":206+ b.cur = (b.cur + len(b.keys) - 1) % len(b.keys)207+ case "alt+a":208+ b.add()209+ case "alt+h":210+ if b.headers > 0 {211+ b.headers = 0212+ } else {213+ b.headers = min(b.guess, b.rng.To.Row-b.rng.From.Row)214+ }215+ case "backspace", "delete":216+ if len(b.keys) > 1 {217+ b.keys = append(b.keys[:b.cur], b.keys[b.cur+1:]...)218+ b.cur = min(b.cur, len(b.keys)-1)219+ }220+ default:221+ // Typing a column's letter sorts by it.222+ if t := typed(k); len(t) == 1 {223+ if c, ok := sheet.ParseCol(t); ok {224+ b.setCol(c)225+ }226+ }227+ }228+ return nil229+}230+231+// sortPart is a piece of the bar: plain text, a sort key's chip, or the232+// header row toggle.233+type sortPart struct {234+ text string235+ key int // index of the sort key, -1 for others236+ toggle bool237+}238+239+func (b *sortBar) parts(m *Model) []sortPart {240+ parts := []sortPart{{text: m.th.key.Render("Sort "+b.data().String()) + m.th.muted.Render(" by "), key: -1}}241+ for i, k := range b.keys {242+ if i > 0 {243+ parts = append(parts, sortPart{text: m.th.muted.Render(" then "), key: -1})244+ }245+ label := sheet.ColName(k.Col)246+ if b.headers > 0 {247+ if h := m.sheet.ShownText(sheet.Addr{Col: k.Col, Row: b.rng.From.Row + b.headers - 1}); h != "" {248+ label += " " + ansi.Truncate(h, 16, "…")249+ }250+ }251+ style := m.th.keyChip252+ if i == b.cur {253+ style = m.th.menuSelected254+ }255+ parts = append(parts, sortPart{text: style.Render(" " + label + " " + orderName(k.Desc) + " "), key: i})256+ }257+ style := m.th.muted258+ if b.headers > 0 {259+ style = m.th.menuSelected260+ }261+ parts = append(parts, sortPart{text: " ", key: -1}, sortPart{text: style.Render(" Header row "), key: -1, toggle: true})262+ return parts263+}264+265+// line renders the bar for the context line.266+func (b *sortBar) line(m *Model) string {267+ var s strings.Builder268+ for _, p := range b.parts(m) {269+ s.WriteString(p.text)270+ }271+ return s.String()272+}273+274+// mouse focuses a key's chip (a second click flips its order) or flips275+// the header row toggle; a click anywhere else cancels.276+func (b *sortBar) mouse(m *Model, e mouseEvent) tea.Cmd {277+ if e.kind != mousePress {278+ return nil279+ }280+ if e.y != contextLine {281+ m.closeOverlay()282+ return nil283+ }284+ x := 0285+ for _, p := range b.parts(m) {286+ w := ansi.StringWidth(p.text)287+ if e.x >= x && e.x < x+w {288+ switch {289+ case p.toggle:290+ return b.key(m, keyFor("alt+h"))291+ case p.key == b.cur:292+ b.keys[b.cur].Desc = !b.keys[b.cur].Desc293+ case p.key >= 0:294+ b.cur = p.key295+ }296+ return nil297+ }298+ x += w299+ }300+ return nil301+}302+303+// status shows the keys, the most useful ones first on narrow screens.304+func (b *sortBar) status(m *Model) (string, string) {305+ pairs := []string{"Left/Right", "column", "Space", "order", "Enter", "sort", "Esc", "cancel"}306+ desc := "Alt+A add a column Alt+H header row Tab next"307+ for {308+ keys := m.keyHints(pairs...)309+ switch {310+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:311+ return m.th.muted.Render(desc), keys312+ case desc != "":313+ desc = ""314+ case len(pairs) > 4:315+ pairs = pairs[2:]316+ default:317+ return "", keys318+ }319+ }320+}
M internal/ui/theme.go +10 -0
@@ -43,6 +43,12 @@ type theme struct {
4343 // a dashed underline across every cell, layered on the cell's own4444 // style, with its own text color where the cell has none.4545 copied lipgloss.Style46+ // frozenLine divides frozen rows and columns from the scrolling ones,47+ // like a tmux pane border.48+ frozenLine lipgloss.Style49+ // filterOn is the filter mark in the header of a column whose filter50+ // hides something (the mark itself also changes, from ▾ to ▼).51+ filterOn lipgloss.Style4652 4753 // Chrome: the menu bar, dropdowns, the palette and dialogs.4854 menuBar lipgloss.Style // menu bar titles
@@ -101,9 +107,11 @@ func newTheme(dark bool) theme {
101107 // colored backgrounds stays at or above roughly 4.5:1.102108 headerBg, headerFg := lipgloss.BrightBlack, lipgloss.BrightWhite103109 selFg, muted, match := lipgloss.Black, lipgloss.BrightBlack, lipgloss.Yellow110+ filterFg := lipgloss.Yellow104111 if !dark {105112 headerBg, headerFg = lipgloss.White, lipgloss.Black106113 selFg, muted, match = lipgloss.BrightWhite, lipgloss.Black, lipgloss.Blue114+ filterFg = lipgloss.Blue107115 }108116 // Links are blue, as in Sheets; plain blue is too dark on a dark109117 // background.
@@ -139,6 +147,8 @@ func newTheme(dark bool) theme {
139147 argument: lipgloss.NewStyle().Bold(true).Underline(true),140148 copied: lipgloss.NewStyle().Foreground(lipgloss.Magenta).141149 UnderlineStyle(lipgloss.UnderlineDashed).UnderlineSpaces(true),150+ frozenLine: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),151+ filterOn: lipgloss.NewStyle().Background(headerBg).Foreground(filterFg).Bold(true),142152 143153 menuBar: lipgloss.NewStyle(),144154 menuAccel: lipgloss.NewStyle().Underline(true),
M internal/ui/view.go +97 -18
@@ -17,42 +17,84 @@ func (m *Model) active() sheet.Addr {
1717 if m.mode == modePoint || m.pointing() {1818 return m.point.at1919 }20+ if a, ok := m.barActive(); ok {21+ return a22+ }2023 return m.cur2124 }2225 2326 // highlight is the range drawn as selected: the pointer's range while2427 // pointing, otherwise the selection.2528 func (m *Model) highlight() (sheet.Rect, bool) {26- if m.mode == modePoint || m.pointing() {29+ switch {30+ case m.mode == modePoint || m.pointing():2731 return m.point.rect(), true32+ case m.drag == dragFill:33+ return m.fillTo, true34+ }35+ if r, ok := m.barRange(); ok {36+ return r, true2837 }2938 return m.selection(), m.hasRange()3039 }3140 41+// screenCols returns the columns drawn left to right, with divider where42+// the frozen columns end.43+func (m *Model) screenCols() []int {44+ _, fc := m.frozen()45+ out := make([]int, 0, fc+1+m.visibleCols(m.left))46+ for c := range fc {47+ out = append(out, c)48+ }49+ if fc > 0 {50+ out = append(out, divider)51+ }52+ for i := range m.visibleCols(m.left) {53+ out = append(out, m.left+i)54+ }55+ return out56+}57+3258 func (m *Model) headerRow() string {3359 var b strings.Builder3460 b.WriteString(m.th.header.Render(strings.Repeat(" ", rowHdrW)))3561 focus := m.active()3662 sel, selecting := m.highlight()37- for i := range m.visibleCols(m.left) {38- c := m.left + i63+ for _, c := range m.screenCols() {64+ if c == divider {65+ b.WriteString(m.th.frozenLine.Render("│"))66+ continue67+ }3968 w := m.sheet.ColWidth(c)40- style := m.th.header69+ style, plain := m.th.header, false4170 switch {4271 case c == focus.Col:4372 style = m.th.headerActive4473 case selecting && c >= sel.From.Col && c <= sel.To.Col:4574 style = m.th.headerSel46- case m.hover.addr.Col == c && (m.hover.kind == hitColHeader || m.hover.kind == hitColBorder):75+ case m.hover.addr.Col == c && (m.hover.kind == hitColHeader || m.hover.kind == hitColBorder || m.hover.kind == hitFilterButton):4776 style = m.th.headerHover77+ default:78+ plain = true79+ }80+ name := sheet.ColName(c)81+ label := style.Render(center(name, w))82+ if mark, on := m.filterMark(c); mark != "" && w >= len(name)+3 {83+ // The filter's button follows the letter, e.g. "B ▾".84+ text := center(name+" "+mark, w)85+ k := strings.Index(text, mark)86+ markStyle := style87+ if on && plain {88+ markStyle = m.th.filterOn89+ }90+ label = style.Render(text[:k]) + markStyle.Render(mark) + style.Render(text[k+len(mark):])4891 }49- label := center(sheet.ColName(c), w)5092 if m.showHandle(c) && w > 1 {5193 // Draw the resize handle in the header's last cell.52- b.WriteString(style.Render(label[:len(label)-1]) + m.th.handle.Render("▐"))94+ b.WriteString(ansi.Truncate(label, w-1, "") + m.th.handle.Render("▐"))5395 continue5496 }55- b.WriteString(style.Render(label))97+ b.WriteString(label)5698 }5799 return b.String()58100 }
@@ -70,6 +112,9 @@ func (m *Model) gridRow(row int) string {
70112 if row >= sheet.MaxRows {71113 return ""72114 }115+ if row == divider {116+ return m.dividerRow()117+ }73118 focus := m.active()74119 sel, selecting := m.highlight()75120 hdr := m.th.header
@@ -84,8 +129,22 @@ func (m *Model) gridRow(row int) string {
84129 var b strings.Builder85130 b.WriteString(hdr.Render(padLeft(strconv.Itoa(row+1), rowHdrW-1) + " "))86131 87- for i, sp := range m.rowText(row) {88- a := sheet.Addr{Col: m.left + i, Row: row}132+ _, fc := m.frozen()133+ if fc > 0 {134+ b.WriteString(m.cellsText(row, 0, m.rowText(row, 0, fc, 0, fc-1), focus, sel, selecting))135+ b.WriteString(m.th.frozenLine.Render("│"))136+ }137+ ncols := m.visibleCols(m.left)138+ b.WriteString(m.cellsText(row, m.left, m.rowText(row, m.left, ncols, fc, sheet.MaxCols-1), focus, sel, selecting))139+ return b.String()140+}141+142+// cellsText draws the spans of a row's columns from first on, in the143+// roles for the pointer, the selection, search matches and errors.144+func (m *Model) cellsText(row, first int, spans []span, focus sheet.Addr, sel sheet.Rect, selecting bool) string {145+ var b strings.Builder146+ for i, sp := range spans {147+ a := sheet.Addr{Col: first + i, Row: row}89148 base, colored := m.th.cell, true90149 switch {91150 case a == focus:
@@ -112,11 +171,31 @@ func (m *Model) gridRow(row int) string {
112171 } else {113172 m.decorate(&sp, row) // links and error marks, see links.go114173 }115- b.WriteString(m.renderSpan(sp, base, colored))174+ text := m.renderSpan(sp, base, colored)175+ if m.showFillHandle(a) {176+ w := m.sheet.ColWidth(a.Col)177+ text = ansi.Truncate(text, w-1, "") + base.Bold(true).Render("▗")178+ }179+ b.WriteString(text)116180 }117181 return b.String()118182 }119183 184+// dividerRow is the line under the frozen rows, crossing the one right of185+// the frozen columns.186+func (m *Model) dividerRow() string {187+ var b strings.Builder188+ b.WriteString(strings.Repeat("─", rowHdrW))189+ for _, c := range m.screenCols() {190+ if c == divider {191+ b.WriteString("┼")192+ continue193+ }194+ b.WriteString(strings.Repeat("─", m.sheet.ColWidth(c)))195+ }196+ return m.th.frozenLine.Render(ansi.Truncate(b.String(), m.width, ""))197+}198+120199 // span is what one grid column shows in a row: blank columns, the text,121200 // blank columns. Only the text takes the owning cell's text style, so an122201 // underline doesn't run into the padding.
@@ -159,17 +238,17 @@ func (m *Model) inCellText(w int) string {
159238 return padRight(text, w)160239 }161240 162-// rowText lays out the visible columns of row, each span exactly its163-// column's width. Values are formatted with their cell's number format241+// rowText lays out ncols columns of row from lo, each span exactly its242+// column's width. Text may run in from columns between minCol and maxCol,243+// so it stops at the frozen columns' divider. Values are formatted with their cell's number format164244 // and aligned as Sheets does: numbers right, text left, booleans and165245 // errors centered, unless the cell sets an alignment. Text runs on into166246 // blank neighbors: to the right when left-aligned, to the left when167247 // right-aligned, both ways when centered. It can come from cells outside168248 // the viewport, so the scan starts at the nearest filled cell on each169249 // side.170-func (m *Model) rowText(row int) []span {171- ncols := m.visibleCols(m.left)172- lo, hi := m.left, m.left+ncols-1250+func (m *Model) rowText(row, lo, ncols, minCol, maxCol int) []span {251+ hi := lo + ncols - 1173252 out := make([]span, ncols)174253 for i := range out {175254 out[i] = span{trail: m.sheet.ColWidth(lo + i)}
@@ -181,13 +260,13 @@ func (m *Model) rowText(row int) []span {
181260 return nil182261 }183262 first, last := lo, hi184- for c := lo - 1; c >= 0; c-- {263+ for c := lo - 1; c >= minCol; c-- {185264 if content(c) != nil {186265 first = c187266 break188267 }189268 }190- for c := hi + 1; c < sheet.MaxCols; c++ {269+ for c := hi + 1; c <= maxCol; c++ {191270 if content(c) != nil {192271 last = c193272 break