cat / 012
clone

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

Notebook outputs: tables and records are 012's own grid

Dami committed 17f88db
A table or record output is a grid model of its own over the output
imported as sending it would, drawn in the output's window with the
grid's code: named column headers, rows numbered from 1, formats by
type, fitted widths. Enter works in it (select, copy, sort, filter,
find, resize, undo), Enter again shows it full-screen, Esc backs out;
charts and pivots go on the sheet the output is sent to. Adds a speed
gate case for a 100,000-row output.

Co-Authored-By: Claude <noreply@anthropic.com>
commit
17f88dbc71b45d5488c2ccfe7876e449092e82db
parent
27054a2ee38b35ce6f7c7a1f63103a2acef9f76c
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.2 of 3, Jev's estimate
34 files changed, +1546 -517
Mdocs/contributing/limits.md7+-
Minternal/ui/commands.go25++-
Minternal/ui/context.go4+-
Minternal/ui/filter.go19+-
Minternal/ui/grid.go5+
Minternal/ui/gridlines.go3+
Minternal/ui/menuoverlay.go4+-
Minternal/ui/model.go6+
Minternal/ui/monochrome_test.go17+-
Minternal/ui/mouse.go4+-
Minternal/ui/nbcommands.go8+-
Ainternal/ui/nbgrid.go287++++++++++++++++++++++++++++++
Ainternal/ui/nbgrid_test.go200+++++++++++++++++++++
Ainternal/ui/nbgridcmd.go215++++++++++++++++++++++
Ainternal/ui/nbgridin.go275+++++++++++++++++++++++++++++
Minternal/ui/nbrun.go4+
Minternal/ui/nbscreen.go22++-
Minternal/ui/nbview/draw.go4+-
Minternal/ui/nbview/full.go271++++------------------------
Ainternal/ui/nbview/grid.go199+++++++++++++++++++++
Minternal/ui/nbview/keys.go8+-
Minternal/ui/nbview/mouse.go4+
Minternal/ui/nbview/output.go158+++-------------
Minternal/ui/nbview/outwin.go113+++---------
Minternal/ui/nbview/panel.go6+-
Minternal/ui/nbview/view.go7+
Minternal/ui/nbview/view_test.go108+++++++++---
Minternal/ui/notebook.go8+-
Minternal/ui/panel.go22++-
Minternal/ui/pivot.go4+
Minternal/ui/sort.go7+-
Minternal/ui/speed_test.go22++
Minternal/ui/testdata/speed.json5+
Minternal/ui/view.go12+-
M docs/contributing/limits.md +4 -3
M internal/ui/commands.go +20 -5
M internal/ui/context.go +2 -2
M internal/ui/filter.go +12 -7
@@ -9,6 +9,7 @@ import (
99 "strings"1010 1111 tea "charm.land/bubbletea/v2"12+ "github.com/charmbracelet/x/ansi"1213 1314 "github.com/FelineStateMachine/012/internal/sheet"1415 "github.com/FelineStateMachine/012/internal/ui/filterpick"
@@ -49,9 +50,12 @@ func init() {
4950 // createFilter filters the selection, or the data around the active cell.5051 func (m *Model) createFilter() tea.Cmd {5152 r := m.dataRange()53+ if m.out != nil {54+ r = m.out.table() // an output's grid filters the whole table, under its header55+ }5256 m.sheet.CreateFilter(r)5357 m.changed = true54- m.note = "Created a filter on " + r.String() + " " + m.th.KeyHints(m.shortcut("data.filter_column"), "filter the active column")58+ m.note = "Created a filter on " + m.rangeLabel(r) + " " + m.th.KeyHints(m.shortcut("data.filter_column"), "filter the active column")5559 return nil5660 }5761
@@ -100,12 +104,13 @@ func (g *grid) filterMark(c int) (string, bool) {
100104 101105 // filterButtonX is the screen x of column c's filter button, or -1.102106 func (g *grid) filterButtonX(c int) int {103- name := sheet.ColName(c)104107 w := g.sheet.ColWidth(c)105- if mark, _ := g.filterMark(c); mark == "" || w < len(name)+3 {108+ mark, _ := g.filterMark(c)109+ name := g.colLabel(c, w, 2)110+ if mark == "" || w < ansi.StringWidth(name)+3 {106111 return -1107112 }108- lw := len(name) + 2113+ lw := ansi.StringWidth(name) + 2109114 return g.colStart(c) + (w-lw)/2 + lw - 1110115 }111116
@@ -115,8 +120,8 @@ func (m *Model) openFilterPicker(col int) *filterpick.Picker {
115120 if !on || col < r.From.Col || col > r.To.Col {116121 return nil117122 }118- title := "Filter " + sheet.ColName(col)119- if h := m.sheet.LocalText(sheet.Addr{Col: col, Row: r.From.Row}); h != "" {123+ title := "Filter " + m.colName(col)124+ if h := m.sheet.LocalText(sheet.Addr{Col: col, Row: r.From.Row}); h != "" && !m.named {120125 title += " " + h121126 }122127 return m.openValuesPicker(title, m.colStart(col), m.sheet.FilterValues(col), m.sheet.Filter().Cols[col].Cond,
@@ -181,7 +186,7 @@ func (m *Model) filterColumn(col int, cr sheet.Criteria) {
181186 span.End(slog.Int("hidden", m.sheet.HiddenRows()))182187 m.changed = true183188 if n := m.sheet.HiddenRows(); n > 0 {184- m.note = "Filtered column " + sheet.ColName(col) + ": " + rowCount(n) + " hidden"189+ m.note = "Filtered column " + m.colName(col) + ": " + rowCount(n) + " hidden"185190 } else {186191 m.note = "The filter shows every row"187192 }
M internal/ui/grid.go +5 -0
M internal/ui/gridlines.go +3 -0
M internal/ui/menuoverlay.go +2 -2
M internal/ui/model.go +6 -0
M internal/ui/monochrome_test.go +10 -7
@@ -208,10 +208,11 @@ func cellsOf(l []monoCell, text string) []monoCell {
208208 209209 // A notebook's states read without color: the active cell marked by ▌210210 // at its left, the other cells selected with it by ▎, the cell being211-// edited in a heavy box, the mode in words, an output table's header212-// bold and underlined, a stale output and a failure in words.211+// edited in a heavy box, the mode in words, an output's grid entered by212+// its bar, OUTPUT and its pointer, a stale output and a failure in words.213213 func TestMonochromeNotebook(t *testing.T) {214214 m, nu := notebookModel(t, map[string]string{"ls": lsOut, "$files | first": "{name: a.txt}"})215+ m.height = 30215216 write(t, m, "files = ls")216217 write(t, m, "$files | first")217218 write(t, m, "nope")
@@ -225,12 +226,14 @@ func TestMonochromeNotebook(t *testing.T) {
225226 if bar("[1]:") != "▎" || bar("[2]:") != "▎" || bar("[3]:") != "▌" {226227 t.Errorf("the cells selected: %q %q %q", bar("[1]:"), bar("[2]:"), bar("[3]:"))227228 }228- press(t, m, "<home>")229- hdr := findLine(t, m, "name")230- if c := cellsOf(hdr, "name"); !every(c, true, func(c monoCell) bool { return c.bold && c.underline == "1" }) {231- t.Errorf("an output's header: %+v", c)229+ press(t, m, "<home>", "<down>", "<enter>")230+ if c := cellsOf(findLine(t, m, "a.txt"), "a.txt"); !every(c, true, isReverse) {231+ t.Errorf("the active cell of an output's grid: %+v", c)232+ }233+ if !strings.Contains(cellText(monoLine(m, menuLine)), " OUTPUT ") || bar("1 a.txt") != "▌" {234+ t.Error("working in an output isn't in words")232235 }233- press(t, m, "<end>")236+ press(t, m, "<esc>", "<end>")234237 if !strings.Contains(cellText(findLine(t, m, "× Command")), "× Command `nope` not found") {235238 t.Error("the failure isn't in words")236239 }
M internal/ui/mouse.go +2 -2
M internal/ui/nbcommands.go +6 -2
A internal/ui/nbgrid.go +287 -0
@@ -0,0 +1,287 @@
1+package ui2+3+import (4+ "bytes"5+ "context"6+ "slices"7+ "strconv"8+9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/fileio"12+ "github.com/FelineStateMachine/012/internal/sheet"13+ "github.com/FelineStateMachine/012/internal/ui/nbview"14+)15+16+// A notebook cell's table or record output is drawn and worked as 012's17+// own grid. Each such output gets a Model of its own, the grid's child,18+// over a workbook of one sheet holding the output as importing its NUON19+// makes it (as sending it to a sheet does): the header row, frozen, then20+// the rows, each value in the format of its type, the columns fitted.21+// The output's window shows the child's column header, named after the22+// table's columns rather than lettered, and its rows under the header,23+// numbered from 1; the child draws them with the grid's own code24+// (view.go), so borders, formats, the pointer, the selection, the copy25+// marker and search matches look as on a sheet. While the output is26+// entered, keys, the mouse and commands go to the child (nbgridin.go),27+// which refuses anything that would change a value: sorting, filtering,28+// finding, selecting, copying and resizing columns act on its copy, and29+// the output itself never changes.30+31+// outGrids are the outputs drawn as grids, and the one entered.32+type outGrids struct {33+ in *outGrid // the grid with the keys, or nil34+}35+36+// outGrid is an output's grid.37+type outGrid struct {38+ parent *Model39+ id int // the cell's40+ data []byte // the output as a table, NUON (nbview.Host.Grid)41+ rows int // its rows, until the child counts them42+ child *Model // made the first time the grid is drawn43+ err string // why the output couldn't be read as a table44+ cols int // the table's columns45+ // win is the rows the window shows, as last drawn, which the child46+ // is sized to.47+ win int48+ // shown are the rows showing when a filter hides some, for the49+ // sheet's version shownAt; nil when every row shows.50+ shown []int51+ shownAt uint6452+ // frame is the parent's frame the child last drew for.53+ frame int54+}55+56+var _ nbview.Grid = (*outGrid)(nil)57+58+// Grid makes cell id's output a grid (nbview.Host).59+func (h nbHost) Grid(id int, data []byte, rows int) nbview.Grid {60+ return &outGrid{parent: h.m, id: id, data: data, rows: rows}61+}62+63+// ready makes the child if it isn't yet, and sizes it to a window of64+// rows rows width wide: reports false if the output isn't a table.65+func (g *outGrid) ready(width, rows int) bool {66+ if g.child == nil && g.err == "" {67+ g.build()68+ }69+ if g.child == nil {70+ return false71+ }72+ c := g.child73+ if f := g.parent.nb.frame; g.frame != f {74+ g.frame = f75+ c.th = g.parent.th76+ clear(c.painted)77+ clear(c.mergeLines)78+ clear(c.shaded)79+ }80+ // An output shown whole draws its rows one at a time wherever they81+ // are, so the child needn't be taller than a screen.82+ g.win = min(max(rows, 1), maxWin)83+ // The frozen header and its divider take two of the child's lines,84+ // and at least two rows keep the header frozen (grid.frozen).85+ c.width, c.height = width, gridTop+max(g.win, 2)+2+186+ c.clampView()87+ return true88+}89+90+// build reads the output into the child's workbook.91+func (g *outGrid) build() {92+ res, err := fileio.ImportReader(context.Background(), "nu", bytes.NewReader(g.data), fileio.Options{})93+ if err != nil {94+ g.err = err.Error()95+ return96+ }97+ s := res.Sheet98+ s.Book().SetLocale(g.parent.book().LocaleTag())99+ s.LoadFrozen(1, 0)100+ s.LoadFitWidths()101+ s.ClearHistory()102+ used, _ := s.UsedRange()103+ g.cols, g.rows = used.To.Col+1, used.To.Row104+ c := New(s, "")105+ c.out, c.named = g, true106+ c.th = g.parent.th107+ c.prefs = g.parent.prefs108+ c.prefs.vim = false // vim's keys edit cells, and an output's are read-only109+ c.term = terminal{} // no images: its sheet has no charts, and the program draws the screen110+ c.cur, c.top = sheet.Addr{Row: 1}, 1111+ g.child = c112+}113+114+// table is the table on the child's sheet, its header row included.115+func (g *outGrid) table() sheet.Rect {116+ return sheet.Rect{To: sheet.Addr{Col: max(g.cols-1, 0), Row: max(g.rows, 1)}}117+}118+119+// Rows is how many rows show: those the filter keeps.120+func (g *outGrid) Rows() int {121+ if g.child == nil {122+ return g.rows123+ }124+ if s := g.child.sheet; s.HiddenRows() > 0 {125+ return len(g.shownRows())126+ }127+ return g.rows128+}129+130+// shownRows are the rows a filter keeps, worked out once for each131+// version of the sheet.132+func (g *outGrid) shownRows() []int {133+ s := g.child.sheet134+ if g.shown != nil && g.shownAt == s.Version() {135+ return g.shown136+ }137+ g.shown = g.shown[:0]138+ for r := 1; r <= g.rows; r++ {139+ if !s.RowHidden(r) {140+ g.shown = append(g.shown, r)141+ }142+ }143+ g.shownAt = s.Version()144+ return g.shown145+}146+147+// row is the sheet row of the i-th row showing.148+func (g *outGrid) row(i int) (int, bool) {149+ if i < 0 || i >= g.Rows() {150+ return 0, false151+ }152+ if g.child == nil || g.child.sheet.HiddenRows() == 0 {153+ return i + 1, true154+ }155+ return g.shownRows()[i], true156+}157+158+// index is where sheet row r is among the rows showing: the first159+// showing at or after it.160+func (g *outGrid) index(r int) int {161+ if g.child == nil || g.child.sheet.HiddenRows() == 0 {162+ return max(r-1, 0)163+ }164+ i, _ := slices.BinarySearch(g.shownRows(), r)165+ return i166+}167+168+// Top is the first row the window shows.169+func (g *outGrid) Top() int {170+ if g.child == nil {171+ return 0172+ }173+ return min(g.index(g.child.top), max(g.Rows()-g.win, 0))174+}175+176+// Scroll scrolls the window d rows, reporting whether it moved.177+func (g *outGrid) Scroll(d int) bool {178+ if g.child == nil && !g.ready(g.parent.nbGridWidth(), 10) {179+ return false180+ }181+ top := g.Top()182+ to := min(max(top+d, 0), max(g.Rows()-g.win, 0))183+ if to == top {184+ return false185+ }186+ r, _ := g.row(to)187+ g.child.top = r188+ return true189+}190+191+// Line draws line i of the window: the column header, or a row.192+func (g *outGrid) Line(i, from, rows, width int) string {193+ if !g.ready(width, rows) {194+ if i == 0 {195+ return g.parent.th.Warning.Render(ansi.Truncate("The output isn't a table: "+g.err, width, "…"))196+ }197+ return ""198+ }199+ c := g.child200+ if rows > g.win {201+ // Shown whole: the row numbers are as wide as the last one's,202+ // which the child measures from its top.203+ top := c.top204+ c.top, _ = g.row(max(g.Rows()-g.win, 0))205+ defer func() { c.top = top }()206+ }207+ var line string208+ if i == 0 {209+ line = c.headerRow()210+ } else {211+ r, ok := g.row(from + i - 1)212+ if !ok {213+ return ""214+ }215+ line = c.gridRow(r)216+ }217+ return ansi.Truncate(line, min(width, g.right()), "")218+}219+220+// maxWin is the most rows the child is sized to show.221+const maxWin = 500222+223+// right is where the table's last column ends on the child's screen:224+// nothing is drawn past it.225+func (g *outGrid) right() int {226+ c := g.child227+ if g.cols <= c.left {228+ return c.width229+ }230+ return c.colStart(g.cols)231+}232+233+// Hidden is how many columns don't show whole at width.234+func (g *outGrid) Hidden(width int) int {235+ if !g.ready(width, g.win) {236+ return 0237+ }238+ c := g.child239+ shown := 0240+ for col := c.left; col < g.cols && c.colStart(col)+c.sheet.ColWidth(col) <= width; col++ {241+ shown++242+ }243+ return g.cols - shown244+}245+246+// Entered reports whether the grid has the keys.247+func (g *outGrid) Entered() bool { return g.parent.nb.out.in == g }248+249+// unfocused reports whether m is an output's grid without the keys,250+// which draws neither its active cell nor its selection.251+func (m *Model) unfocused() bool { return m.out != nil && !m.out.Entered() }252+253+// colLabel is column c's name in the column header, w wide: its letters,254+// or on an output's grid the table's name for it, cut to fit beside255+// room more columns (a filter's button).256+func (g *grid) colLabel(c, w, room int) string {257+ if !g.named {258+ return sheet.ColName(c)259+ }260+ return ansi.Truncate(g.colName(c), max(w-2-room, 1), "…")261+}262+263+// colName names column c in messages: its letters, or the table's name264+// for it on an output's grid.265+func (g *grid) colName(c int) string {266+ if !g.named {267+ return sheet.ColName(c)268+ }269+ return g.sheet.ShownText(sheet.Addr{Col: c})270+}271+272+// rowLabel is row r's number: on an output's grid, counted from the273+// first row under the header.274+func (g *grid) rowLabel(r int) string {275+ if g.named {276+ return strconv.Itoa(r)277+ }278+ return strconv.Itoa(r + 1)279+}280+281+// nbGridWidth is how wide the notebook shown draws outputs' grids.282+func (m *Model) nbGridWidth() int {283+ if v := m.nbView(); v != nil {284+ return v.GridWidth()285+ }286+ return m.width287+}
A internal/ui/nbgrid_test.go +200 -0
@@ -0,0 +1,200 @@
1+package ui2+3+import (4+ "fmt"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/notebook"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// bigTable is an output of n rows of four columns, as nu prints it.15+func bigTable(n int) string {16+ var b strings.Builder17+ b.WriteString("[[name, size, when, ok]; ")18+ for i := range n {19+ fmt.Fprintf(&b, "[file%d.txt, %dkb, 2026-09-%02dT10:00:00Z, true], ", i, i%900, i%28+1)20+ }21+ b.WriteString("[last, 1b, 2026-09-01T00:00:00Z, false]]")22+ return b.String()23+}24+25+// outputModel is a notebook of one cell named files whose output is26+// out, w by h, the output selected.27+func outputModel(out string, w, h int) *Model {28+ m := sized(sheet.New(), w, h)29+ m.OpenNotebook()30+ id := m.book().NewCellID()31+ m.sheet.SetNotebookCells("cells", []notebook.Cell{{ID: id, Source: "files = ls"}})32+ m.book().SetOutput(id, &notebook.Output{NUON: []byte(out), Count: 1})33+ m.nbView().Select(0, true)34+ return m35+}36+37+// entered is a notebook with lsOut's output, its grid entered.38+func entered(t *testing.T) (*Model, *outGrid) {39+ t.Helper()40+ m := outputModel(lsOut, 80, 24)41+ press(t, m, "<enter>")42+ g := m.gridIn()43+ if g == nil {44+ t.Fatalf("Enter didn't enter the output:\n%s", screen(m))45+ }46+ return m, g47+}48+49+func TestOutputGridLooks(t *testing.T) {50+ m := outputModel(lsOut, 80, 24)51+ s := screen(m)52+ for _, want := range []string{"Out[1]: name size", "1 a.txt 2.0 kB", "3 c.txt 5.0 kB"} {53+ if !strings.Contains(s, want) {54+ t.Errorf("lacks %q:\n%s", want, s)55+ }56+ }57+ if strings.Contains(s, " A ") || strings.Contains(s, " C ") {58+ t.Errorf("the columns are lettered, or drawn past the table:\n%s", s)59+ }60+ rec := outputModel("{name: a.txt, size: 2kb}", 80, 24)61+ if s := screen(rec); !strings.Contains(s, "field value") || !strings.Contains(s, "2 size 2.0 kB") {62+ t.Errorf("a record's grid:\n%s", s)63+ }64+ if strings.Contains(line(m, menuLine), "OUTPUT") {65+ t.Error("selecting the output entered it")66+ }67+}68+69+func TestOutputGridSelectsAndCopies(t *testing.T) {70+ m, g := entered(t)71+ if !strings.Contains(line(m, menuLine), "OUTPUT") {72+ t.Errorf("mode: %q", line(m, menuLine))73+ }74+ press(t, m, "<down>", "<shift+right>", "<shift+down>")75+ if got := g.child.selection(); got.String() != "A3:B4" {76+ t.Errorf("selected %s", got)77+ }78+ if st := line(m, m.height-1); !strings.Contains(st, "name to size, rows 2 to 3") || !strings.Contains(st, "Count 4") {79+ t.Errorf("status: %q", st)80+ }81+ _, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})82+ if got := fmt.Sprint(cmd()); !strings.Contains(got, "b.txt\t10\nc.txt\t5000") {83+ t.Errorf("the system clipboard: %q", got)84+ }85+ // Ctrl+V on a sheet pastes what was copied, formats and all.86+ press(t, m, "<esc>", "<esc>")87+ run(m, m.runCommand("sheet.new"))88+ press(t, m, "<ctrl+v>")89+ if got := m.sheet.ShownText(addr("B2")); got != "5.0 kB" {90+ t.Errorf("pasted %q", got)91+ }92+}93+94+func TestOutputGridSortsFiltersFinds(t *testing.T) {95+ m, g := entered(t)96+ press(t, m, "<right>")97+ run(m, m.runCommand("data.sort_sheet_za"))98+ if got := line(m, 7); !strings.Contains(got, "1 c.txt") {99+ t.Errorf("sorted by size, Z to A:\n%s", screen(m))100+ }101+ if !strings.Contains(string(m.book().Output(m.sheet.NotebookCells()[0].ID).NUON), "a.txt, 2kb") {102+ t.Error("sorting changed the output")103+ }104+ press(t, m, "<ctrl+z>")105+ if got := line(m, 7); !strings.Contains(got, "1 a.txt") {106+ t.Errorf("undo takes back the sort:\n%s", screen(m))107+ }108+ run(m, m.runCommand("data.filter"))109+ var hide []string110+ for _, v := range g.child.sheet.FilterValues(1) {111+ if v.Label != "10 B" {112+ hide = append(hide, v.Text)113+ }114+ }115+ g.child.sheet.FilterColumn(1, sheet.Criteria{Hidden: hide})116+ if s := screen(m); !strings.Contains(s, "2 b.txt") || strings.Contains(s, "a.txt") {117+ t.Errorf("filtered to 10 B:\n%s", s)118+ }119+ run(m, m.runCommand("data.filter_remove"))120+ press(t, m, "<ctrl+f>", "c.t")121+ if !strings.Contains(line(m, menuLine), "FIND") || g.child.cur != addr("A4") {122+ t.Errorf("find: %s at %s\n%s", line(m, menuLine), g.child.cur, screen(m))123+ }124+}125+126+func TestOutputGridRefusesEdits(t *testing.T) {127+ m, g := entered(t)128+ press(t, m, "x")129+ if !strings.Contains(line(m, contextLine), "can't be changed") || g.child.mode != modeReady {130+ t.Errorf("typing: %q", line(m, contextLine))131+ }132+ press(t, m, "<delete>")133+ run(m, m.runCommand("format.bold"))134+ if got := g.child.sheet.ShownText(addr("A2")); got != "a.txt" || g.child.sheet.CellStyle(addr("A2")).Bold {135+ t.Errorf("the output changed: %q", got)136+ }137+ if commands["clear"].available(m) || !commands["data.sort_sheet_az"].available(m) {138+ t.Error("the menus offer what the grid refuses")139+ }140+}141+142+func TestOutputGridFullScreenAndBack(t *testing.T) {143+ m, g := entered(t)144+ press(t, m, "<enter>")145+ if !m.nbView().FullOpen() || !strings.HasPrefix(strings.TrimSpace(line(m, nbBody)), "name") {146+ t.Fatalf("Enter didn't show the grid full-screen:\n%s", screen(m))147+ }148+ press(t, m, "<esc>")149+ if m.nbView().FullOpen() || m.gridIn() != g {150+ t.Error("Esc from full-screen left the grid")151+ }152+ press(t, m, "<esc>")153+ if m.gridIn() != nil || !strings.Contains(line(m, menuLine), "NOTEBOOK") {154+ t.Error("Esc didn't leave the grid")155+ }156+}157+158+func TestOutputGridMouse(t *testing.T) {159+ m := outputModel(lsOut, 80, 24)160+ y := strings.Count(screen(m)[:strings.Index(screen(m), "b.txt")], "\n")161+ x := screenX(t, m, y, "b.txt")162+ send(m, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})163+ send(m, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft})164+ g := m.gridIn()165+ if g == nil || g.child.cur != addr("A3") {166+ t.Fatalf("a click didn't enter the grid at b.txt:\n%s", screen(m))167+ }168+ // Drag the border right of name to widen it.169+ w := g.child.sheet.ColWidth(0)170+ bx := x + w - 2171+ send(m, tea.MouseClickMsg{X: bx, Y: y - 2, Button: tea.MouseLeft})172+ send(m, tea.MouseMotionMsg{X: bx + 4, Y: y - 2, Button: tea.MouseLeft})173+ send(m, tea.MouseReleaseMsg{X: bx + 4, Y: y - 2, Button: tea.MouseLeft})174+ if got := g.child.sheet.ColWidth(0); got != w+4 {175+ t.Errorf("the column is %d wide, was %d", got, w)176+ }177+}178+179+func TestOutputGridChartsOnASheet(t *testing.T) {180+ m, _ := entered(t)181+ run(m, m.runCommand("insert.chart"))182+ if m.sheet.IsNotebook() || m.sheet.Name() != "files" || !strings.Contains(line(m, menuLine), "CHART") {183+ t.Fatalf("the chart isn't on the output's sheet:\n%s", screen(m))184+ }185+ if _, r, ok := m.book().Region("files"); !ok || !r.Output {186+ t.Error("the output wasn't sent to the sheet")187+ }188+}189+190+// A long output scrolls in its window and jumps to its end.191+func TestOutputGridLong(t *testing.T) {192+ if testing.Short() {193+ t.Skip("reads 100,000 rows")194+ }195+ m := outputModel(bigTable(100000), 120, 40)196+ press(t, m, "<enter>", "<ctrl+down>")197+ if s := screen(m); !strings.Contains(s, "100001 last") || !strings.Contains(s, "rows 99,992 to 100,001 of 100,001") {198+ t.Errorf("the end:\n%s", s)199+ }200+}
A internal/ui/nbgridcmd.go +215 -0
@@ -0,0 +1,215 @@
1+package ui2+3+import (4+ "slices"5+ "strconv"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/nbview"12+ "github.com/FelineStateMachine/012/internal/ui/overlay"13+)14+15+// Commands on an output's grid, and what the control panel says of it.16+// The grid runs the commands that look at its values or change how they17+// show (selecting, copying, finding, sorting, filtering, column widths,18+// undoing those) and refuses the rest, which would change a value. A19+// chart, a pivot table or a frequency table from the output is made on20+// a sheet, where they live: the output goes to a sheet as G sends it,21+// so what's made from it follows the cell's next run, and a notebook's22+// cells stay code and what it printed.23+24+// gridCommands are the commands an output's grid runs itself.25+var gridCommands = map[string]bool{26+ "edit.copy": true, "edit.find": true, "edit.find_next": true, "edit.find_prev": true,27+ "select.none": true, "select.all": true, "select.columns": true, "select.rows": true,28+ "column.width": true, "column.reset": true, "menu.context": true, "data.dropdown": true,29+ "data.sort_sheet_az": true, "data.sort_sheet_za": true, "data.sort_range_az": true, "data.sort_range_za": true, "data.sort_range": true,30+ "data.filter": true, "data.filter_column": true, "data.filter_remove": true,31+}32+33+// onSheet are the commands whose result goes on a sheet of its own or34+// beside the data: run on the output sent to a sheet.35+var onSheet = map[string]bool{"insert.chart": true, "data.pivot": true, "data.frequency": true}36+37+// allows reports whether a command runs on the grid.38+func (g *outGrid) allows(id string) bool { return gridCommands[id] || gridTakes(id) || onSheet[id] }39+40+// gridMenu is the cell menu on an output's grid.41+var gridMenu = []menuItem{42+ {cmd: "edit.copy"}, sep, {cmd: "data.sort_sheet_az", title: "Sort A to Z"}, {cmd: "data.sort_sheet_za", title: "Sort Z to A"}, sep,43+ {cmd: "data.filter"}, {cmd: "data.filter_remove"}, sep, {cmd: "insert.chart"}, {cmd: "data.pivot"},44+}45+46+// cellMenu is the right-click menu of the cells.47+func (m *Model) cellMenu() []menuItem {48+ if m.out != nil {49+ return gridMenu50+ }51+ return cellMenu52+}53+54+// offers reports whether a menu on the grid lists a command: those it55+// runs and the program's own. A nil grid, the program's model, lists56+// every one.57+func (g *outGrid) offers(id string) bool { return g == nil || g.allows(id) || notebookSafe(id) }58+59+// command takes a command run on the grid's model, reporting whether it60+// did: the program's own commands go back to it, Edit shows the grid61+// full-screen, a chart or pivot table goes on a sheet, and anything62+// that would change a value is refused.63+func (g *outGrid) command(id string) (tea.Cmd, bool) {64+ p := g.parent65+ switch {66+ case g.allows(id) && !onSheet[id]:67+ return nil, false68+ case id == "edit":69+ p.gridFull()70+ case onSheet[id]:71+ return p.outputOnSheet(g, id), true72+ case notebookSafe(id):73+ return p.runCommand(id), true74+ default:75+ g.child.note = commands[id].title + " would change the output: G sends it to a sheet, where its copy can change"76+ }77+ return nil, true78+}79+80+// outputOnSheet runs command id on the output sent to a sheet: sent to a81+// new sheet first unless it's on one, with the columns selected in the82+// grid selected there, every row under the header.83+func (m *Model) outputOnSheet(g *outGrid, id string) tea.Cmd {84+ nb, v := m.sheet, m.nbView()85+ if v == nil {86+ return nil87+ }88+ name, ok := m.outputName(nb, g.id)89+ if !ok {90+ return nil91+ }92+ if _, _, sent := m.book().Region(name); !sent {93+ m.sendToNewSheet(nb, name)94+ }95+ t, _, ok := m.book().Region(name)96+ if !ok {97+ return nil98+ }99+ table, ok := t.RegionTable(name)100+ if !ok {101+ m.note = "The output sent to " + t.Name() + " has no rows yet"102+ return nil103+ }104+ c := g.child105+ r := table106+ if c.hasRange() {107+ sel := c.selection()108+ r.From.Col = table.From.Col + min(sel.From.Col, g.cols-1)109+ r.To.Col = table.From.Col + min(sel.To.Col, g.cols-1)110+ }111+ v.CloseFull()112+ m.nb.out.in = nil113+ m.showSheet(t)114+ m.selectRect(r)115+ m.cur = sheet.Addr{Col: table.From.Col + min(c.cur.Col, g.cols-1), Row: r.From.Row}116+ return m.runCommand(id)117+}118+119+// outputName is the name of cell id of notebook nb, naming it first as120+// sending it does when it has none.121+func (m *Model) outputName(nb *sheet.Sheet, id int) (string, bool) {122+ for i, c := range nb.NotebookCells() {123+ if c.ID != id {124+ continue125+ }126+ name := c.Name()127+ if name == "" {128+ name = m.book().FreeCellName("cell" + strconv.Itoa(i+1))129+ m.renameCell(nb, c, name)130+ }131+ return name, true132+ }133+ return "", false134+}135+136+// gridIndicator is the mode while a grid is entered: OUTPUT, or its137+// menu's or prompt's.138+func (m *Model) gridIndicator(g *outGrid) string {139+ c := g.child140+ switch {141+ case c.overlay != nil:142+ return c.overlay.Indicator()143+ case c.mode == modePrompt:144+ return c.prompt.indicator145+ }146+ return "OUTPUT"147+}148+149+// gridBusy reports whether the grid's own context line is to be shown:150+// a bar, a prompt, a menu, a resize or a message of its own.151+func (g *outGrid) gridBusy() bool {152+ c := g.child153+ return c.mode != modeReady || c.overlay != nil || c.mouse.drag == dragResize154+}155+156+// gridContext is the context line while grid g is entered: the grid's157+// own when it has something to say, otherwise what's shown and the keys.158+func (m *Model) gridContext(g *outGrid, v *nbview.View) string {159+ c := g.child160+ switch {161+ case g.gridBusy():162+ return c.contextLineText()163+ case c.warn != "":164+ return v.Rule(m.th.Warning.Render(c.warn), "", m.width)165+ case c.note != "":166+ return v.Rule(m.th.Hint.Render(c.note), "", m.width)167+ }168+ name, _ := v.Head()169+ keys := []string{"Shift+arrows", "select", "Ctrl+C", "copy", "Ctrl+F", "find", "Enter", "full-screen", "Esc", "back"}170+ if v.FullOpen() {171+ keys = slices.Delete(keys, 6, 8)172+ }173+ for len(keys) > 4 && ansi.StringWidth("Output of "+name+m.th.KeyHints(keys...))+8 > m.width {174+ keys = keys[2:] // the keys that matter most are last175+ }176+ return v.Rule(m.th.Muted.Render("Output of "+name), m.th.KeyHints(keys...), m.width)177+}178+179+// gridCursor is where the terminal's caret goes while grid g is180+// entered: in its bar or prompt, or its box's search field.181+func (m *Model) gridCursor(g *outGrid, v *nbview.View) (int, int, bool) {182+ x, y, ok := g.child.cursorPos()183+ if !ok || y == formulaLine || y == contextLine {184+ return x, y, ok185+ }186+ ox, oy := g.origin(v)187+ return x + ox, windowY(y) + oy, true188+}189+190+// rangeLabel names a selected range for the status line: its address,191+// or on an output's grid the columns' names and the rows as numbered.192+func (m *Model) rangeLabel(r sheet.Rect) string {193+ if m.out == nil {194+ return r.String()195+ }196+ name := func(c int) string { return ansi.Truncate(m.sheet.ShownText(sheet.Addr{Col: c}), 16, "…") }197+ cols := name(r.From.Col)198+ if r.To.Col > r.From.Col {199+ cols += " to " + name(min(r.To.Col, m.out.cols-1))200+ }201+ from, to := max(r.From.Row, 1), min(r.To.Row, m.out.rows)202+ if from == to {203+ return cols + ", row " + strconv.Itoa(from)204+ }205+ return cols + ", rows " + strconv.Itoa(from) + " to " + strconv.Itoa(to)206+}207+208+// boxWidth is how wide a box is drawn.209+func boxWidth(b overlay.Box) int {210+ w := 0211+ for _, l := range b.Lines {212+ w = max(w, ansi.StringWidth(l))213+ }214+ return w215+}
A internal/ui/nbgridin.go +275 -0
@@ -0,0 +1,275 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+ "github.com/FelineStateMachine/012/internal/ui/nbview"8+ "github.com/FelineStateMachine/012/internal/ui/overlay"9+)10+11+// Working in an output's grid (nbgrid.go). Enter on a table or record12+// output, or a click in it, gives its grid the keys: the window shows13+// the grid's active cell and selection, and keys, the mouse and the14+// commands that act on a sheet's cells go to the grid's own model, so15+// arrows, Shift+arrows, Ctrl+C, sorting and filtering from the menus,16+// Ctrl+F and resizing columns work as on a sheet. What belongs to the17+// notebook and the program (menus, the palette, help, saving, other18+// tabs) stays theirs. Enter shows the grid full-screen, and Esc goes19+// back a level: to the window, then to the notebook.20+21+// gridIn is the grid entered, while the notebook shown still shows it.22+func (m *Model) gridIn() *outGrid {23+ g := m.nb.out.in24+ if g == nil {25+ return nil26+ }27+ if v := m.nbView(); v == nil || !v.Shows(g) {28+ m.nb.out.in = nil29+ return nil30+ }31+ return g32+}33+34+// enterGrid gives grid g the keys, reporting whether it could.35+func (m *Model) enterGrid(g *outGrid) bool {36+ if !g.ready(m.nbGridWidth(), nbview.Window) {37+ m.note = "The output isn't a table: " + g.err38+ return false39+ }40+ m.nb.out.in = g41+ g.child.note, g.child.warn = "", ""42+ return true43+}44+45+// gridBack goes back a level from grid g: from full-screen to the46+// window, and from the window to the notebook.47+func (m *Model) gridBack() {48+ if v := m.nbView(); v != nil && v.FullOpen() {49+ v.CloseFull()50+ v.FollowOutput()51+ return52+ }53+ m.nb.out.in = nil54+}55+56+// gridFull shows the grid entered full-screen.57+func (m *Model) gridFull() {58+ if v := m.nbView(); v != nil && !v.FullOpen() {59+ v.OpenFull()60+ }61+}62+63+// gridKey gives a key to grid g, after the keys the notebook and the64+// program keep while the grid is at rest.65+func (m *Model) gridKey(g *outGrid, k tea.KeyPressMsg) tea.Cmd {66+ c := g.child67+ if c.mode == modeReady && c.overlay == nil && c.mouse.drag == dragNone {68+ if cmd, ok := m.gridOwnKey(g, k.String()); ok {69+ return cmd70+ }71+ }72+ return g.forward(k)73+}74+75+// gridOwnKey takes the keys the grid leaves to the notebook: Esc when76+// nothing is selected, the menus, and the program's own commands.77+func (m *Model) gridOwnKey(g *outGrid, key string) (tea.Cmd, bool) {78+ if key == "esc" && !g.child.hasRange() {79+ m.gridBack()80+ return nil, true81+ }82+ if i := barMenuFor(key); i >= 0 {83+ m.showBarMenu(i)84+ return nil, true85+ }86+ if id, ok := keymap[canonicalKey(key)]; ok && notebookSafe(id) && !gridTakes(id) {87+ return m.runCommand(id), true88+ }89+ return m.runShortcut(key)90+}91+92+// gridTakes reports whether a command the notebook would run acts on93+// the grid entered instead: undo and redo, of its sorts and filters.94+func gridTakes(id string) bool { return id == "edit.undo" || id == "edit.redo" }95+96+// forward hands the grid a message, then keeps its active cell on the97+// table, passes what it copied to the program's clipboard, and keeps98+// its window on screen.99+func (g *outGrid) forward(msg tea.Msg) tea.Cmd {100+ c := g.child101+ clip, focus := c.copied.clip, *c.focus()102+ var cmd tea.Cmd103+ g.roomy(func() { _, cmd = c.Update(msg) })104+ if *c.focus() != focus {105+ c.scrollTo(*c.focus()) // in the window, which is smaller106+ }107+ g.keep()108+ if c.copied.clip != clip && c.copied.clip != nil {109+ g.parent.copied = clipboard{clip: c.copied.clip, sheet: c.sheet}110+ if c.note == "" {111+ c.note = "Copied " + countCells(c.copied.clip.Range) + ": Ctrl+V pastes them on a sheet"112+ }113+ }114+ if v := g.parent.nbView(); v != nil {115+ v.FollowOutput()116+ }117+ return cmd118+}119+120+// run runs a command on the grid.121+func (g *outGrid) run(id string) tea.Cmd {122+ cmd := g.child.runCommand(id)123+ g.keep()124+ return cmd125+}126+127+// keep keeps the active cell and the selection's moving corner on the128+// table's rows and columns, under its header.129+func (g *outGrid) keep() {130+ c := g.child131+ in := func(a sheet.Addr) sheet.Addr {132+ return sheet.Addr{Col: clamp(a.Col, 0, max(g.cols-1, 0)), Row: clamp(a.Row, 1, max(g.rows, 1))}133+ }134+ if c.whole != wholeNone {135+ return136+ }137+ cur, ext := in(c.cur), in(c.ext)138+ if cur != c.cur || ext != c.ext {139+ c.cur, c.ext = cur, ext140+ c.scrollTo(*c.focus())141+ }142+}143+144+// gridMouse takes a mouse message over an output's grid, or any while145+// the grid entered drags or has a menu open, reporting whether it did.146+func (m *Model) gridMouse(v *nbview.View, msg tea.MouseMsg) (tea.Cmd, bool) {147+ mouse := msg.Mouse()148+ in := m.gridIn()149+ if in != nil && (in.child.overlay != nil || in.child.mouse.drag != dragNone || in.child.mode != modeReady) {150+ return in.forward(in.toChild(v, msg)), true151+ }152+ found, _, _, ok := v.GridAt(mouse.X, mouse.Y-nbBody)153+ g, _ := found.(*outGrid)154+ if in != nil && g != in {155+ in.child.mouse.hover = hit{}156+ }157+ if !ok || g == nil {158+ return nil, false159+ }160+ switch msg := msg.(type) {161+ case tea.MouseWheelMsg:162+ if g != in || !sideways(tea.Mouse(msg)) {163+ return nil, false // the notebook scrolls the window164+ }165+ case tea.MouseMotionMsg:166+ if g != in {167+ return nil, true168+ }169+ case tea.MouseClickMsg:170+ if g != in {171+ v.SelectGrid(g)172+ if !m.enterGrid(g) {173+ return nil, true174+ }175+ }176+ }177+ return g.forward(g.toChild(v, msg)), true178+}179+180+// sideways reports whether a wheel turn scrolls sideways.181+func sideways(mouse tea.Mouse) bool {182+ return mouse.Button == tea.MouseWheelLeft || mouse.Button == tea.MouseWheelRight || mouse.Mod.Contains(tea.ModShift)183+}184+185+// roomy runs fn with the child as large as the program's screen leaves186+// it from the window down, so the boxes it opens (a column's filter, a187+// menu) are laid out with the room the screen has rather than the188+// window's.189+func (g *outGrid) roomy(fn func()) {190+ c, v := g.child, g.parent.nbView()191+ if v == nil {192+ fn()193+ return194+ }195+ ox, oy := g.origin(v)196+ w, h := c.width, c.height197+ c.width, c.height = max(g.parent.width-ox, w), max(g.parent.height-oy+gridTop, h)198+ defer func() { c.width, c.height = w, h }()199+ fn()200+}201+202+// origin is where the grid's column header is on the program's screen.203+func (g *outGrid) origin(v *nbview.View) (x, y int) {204+ x, y, _ = v.GridOrigin(g)205+ return x, y + nbBody206+}207+208+// The child's screen has its column header on headerLine, then the209+// frozen header row and the divider under it, which the window leaves210+// out, then its rows: childY and parentY map lines between the two.211+212+// childY is the child's line for line y of the window, 0 its header.213+func childY(y int) int {214+ if y <= 0 {215+ return headerLine + y216+ }217+ return gridTop + 1 + y218+}219+220+// windowY is the window's line for the child's line y.221+func windowY(y int) int {222+ if y <= headerLine {223+ return y - headerLine224+ }225+ return max(y-gridTop-1, 1)226+}227+228+// toChild is a mouse message with its position on the child's screen:229+// in one of the child's boxes, where in the box; elsewhere, where in the230+// window.231+func (g *outGrid) toChild(v *nbview.View, msg tea.MouseMsg) tea.MouseMsg {232+ ox, oy := g.origin(v)233+ mouse := msg.Mouse()234+ x, y := mouse.X-ox, childY(mouse.Y-oy)235+ for _, b := range g.boxes() {236+ bx, by := b.X+ox, windowY(b.Y)+oy237+ if mouse.X >= bx && mouse.Y >= by && len(b.Lines) > 0 && mouse.Y < by+len(b.Lines) && mouse.X < bx+boxWidth(b) {238+ x, y = b.X+mouse.X-bx, b.Y+mouse.Y-by239+ }240+ }241+ at := tea.Mouse{X: x, Y: y, Button: mouse.Button, Mod: mouse.Mod}242+ switch msg.(type) {243+ case tea.MouseClickMsg:244+ return tea.MouseClickMsg(at)245+ case tea.MouseReleaseMsg:246+ return tea.MouseReleaseMsg(at)247+ case tea.MouseWheelMsg:248+ return tea.MouseWheelMsg(at)249+ }250+ return tea.MouseMotionMsg(at)251+}252+253+// boxes are the child's boxes, laid out with the screen's room.254+func (g *outGrid) boxes() []overlay.Box {255+ var out []overlay.Box256+ g.roomy(func() { out = g.child.floating() })257+ return out258+}259+260+// gridBoxes are the child's boxes (a column's filter, a menu) placed on the261+// program's screen over the window.262+func (m *Model) gridBoxes() []overlay.Box {263+ g, v := m.gridIn(), m.nbView()264+ if g == nil || v == nil {265+ return nil266+ }267+ ox, oy := g.origin(v)268+ boxes := g.boxes()269+ out := make([]overlay.Box, 0, len(boxes))270+ for _, b := range boxes {271+ b.X, b.Y = max(b.X+ox, 0), max(windowY(b.Y)+oy, 0)272+ out = append(out, b)273+ }274+ return out275+}
M internal/ui/nbrun.go +4 -0
M internal/ui/nbscreen.go +16 -6
@@ -145,6 +145,10 @@ func (m *Model) notebookContext(v *nbview.View) (string, string) {
145145 left, right = m.th.Warning.Render(m.warn), ""146146 case m.note != "":147147 left, right = m.th.Hint.Render(m.note), ""148+ default:149+ if g := m.gridIn(); g != nil {150+ return m.gridContext(g, v), ""151+ }148152 }149153 if v.FullOpen() {150154 return left, right
@@ -153,8 +157,10 @@ func (m *Model) notebookContext(v *nbview.View) (string, string) {
153157 }154158 155159 // notebookIndicator is the mode on a notebook tab.156-func notebookIndicator(v *nbview.View) string {157- switch {160+func (m *Model) notebookIndicator(v *nbview.View) string {161+ switch g := m.gridIn(); {162+ case g != nil:163+ return m.gridIndicator(g)158164 case v.FullOpen():159165 return "OUTPUT"160166 case v.Editing():
@@ -165,10 +171,7 @@ func notebookIndicator(v *nbview.View) string {
165171 166172 // notebookCursor is where the terminal's caret goes on a notebook tab.167173 func (m *Model) notebookCursor(v *nbview.View) (int, int, bool) {168- if f := v.Full(); f != nil {169- if x, ok := f.Cursor(); ok {170- return x, contextLine, true171- }174+ if v.FullOpen() {172175 return 0, 0, false173176 }174177 x, y, ok := v.Cursor()
@@ -178,6 +181,9 @@ func (m *Model) notebookCursor(v *nbview.View) (int, int, bool) {
178181 // notebookBoxes are the notebook's floating boxes: its completions.179182 func (m *Model) notebookBoxes() []overlay.Box {180183 if v := m.nbView(); v != nil && m.overlay == nil && m.mode == modeReady {184+ if m.gridIn() != nil {185+ return m.gridBoxes()186+ }181187 return v.Boxes(nbBody)182188 }183189 return nil
@@ -191,6 +197,10 @@ func (m *Model) notebookMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
191197 if v == nil || m.mode != modeReady || m.overlay != nil {192198 return nil, false193199 }200+ m.sizeNotebook(v)201+ if cmd, ok := m.gridMouse(v, msg); ok {202+ return cmd, true203+ }194204 if click, ok := msg.(tea.MouseClickMsg); ok && mouse.Y == formulaLine && click.Button == tea.MouseLeft {195205 return m.toolbarClick(v, mouse.X), true196206 }
M internal/ui/nbview/draw.go +2 -2
M internal/ui/nbview/full.go +40 -231
@@ -1,144 +1,49 @@
11 package nbview22 33 import (4- "cmp"5- "slices"6- "strconv"7- "strings"8-94 tea "charm.land/bubbletea/v2"10- "github.com/charmbracelet/x/ansi"115 126 "github.com/FelineStateMachine/012/internal/locale"13- "github.com/FelineStateMachine/012/internal/sheet"14- "github.com/FelineStateMachine/012/internal/ui/lineedit"157 "github.com/FelineStateMachine/012/internal/ui/theme"168 )179 18-// Full is an output shown full-screen, read-only: a table as a grid19-// with a pointer, sorted by a column (s, S) and filtered to the rows20-// holding some text (/), without changing the output; anything else as21-// its lines, scrolled. Esc goes back to the notebook.10+// Full is an output shown full-screen, read-only: a table or record as11+// the UI's grid at the body's size, which the UI gives the keys, and12+// anything else as its lines, scrolled with a pointer. Esc goes back to13+// the notebook.2214 type Full struct {2315 Title string2416 sh *shown25- order []int // the rows shown, in the order shown26- sort int // the column sorted by, plus one; 0 for the output's order27- desc bool28- // filter is the text rows must hold; typing is set while it's typed.29- filter lineedit.Line30- typing bool3117 32- row, col int // the pointer33- top, left int18+ row, top int // the pointer and the first line shown, for lines3419 width, height int3520 }3621 3722 func newFull(title string, sh *shown, width, height int) *Full {3823 f := &Full{Title: title, sh: sh}3924 f.resize(width, height)40- f.apply()4125 return f4226 }4327 4428 func (f *Full) resize(width, height int) { f.width, f.height = width, max(height, 2) }4529 46-// table reports whether the output is a table.47-func (f *Full) table() bool { return f.sh.kind == outTable }48-49-// apply works out the rows shown from the filter and the sort.50-func (f *Full) apply() {51- if !f.table() {52- return53- }54- f.order = f.order[:0]55- needle := strings.ToLower(f.filter.Text())56- for i, row := range f.sh.rows {57- if needle == "" || slices.ContainsFunc(row, func(lc sheet.LiveCell) bool {58- return strings.Contains(strings.ToLower(cellText(lc, locale.Canonical)), needle)59- }) {60- f.order = append(f.order, i)61- }62- }63- if c := f.sort - 1; c >= 0 {64- slices.SortStableFunc(f.order, func(a, b int) int {65- x, y := cellAt(f.sh.rows[a], c), cellAt(f.sh.rows[b], c)66- if f.desc {67- return compareCells(y, x)68- }69- return compareCells(x, y)70- })71- }72- f.row = min(f.row, max(len(f.order)-1, 0))73-}74-75-func cellAt(row []sheet.LiveCell, c int) sheet.LiveCell {76- if c < len(row) {77- return row[c]78- }79- return sheet.LiveCell{}80-}81-82-// compareCells orders values as a sort does: numbers before text,83-// blanks last.84-func compareCells(a, b sheet.LiveCell) int {85- ae, be := a.V.Kind == sheet.Empty, b.V.Kind == sheet.Empty86- switch {87- case ae || be:88- return cmp.Compare(boolInt(ae), boolInt(be))89- case a.V.Kind == sheet.Number && b.V.Kind == sheet.Number:90- return cmp.Compare(a.V.Num, b.V.Num)91- case a.V.Kind == sheet.Number:92- return -193- case b.V.Kind == sheet.Number:94- return 195- }96- return cmp.Compare(strings.ToLower(a.V.String()), strings.ToLower(b.V.String()))97-}98-99-func boolInt(b bool) int {100- if b {101- return 1102- }103- return 0104-}30+// Grid is the output's grid, when it's drawn as one: the UI gives it31+// the keys.32+func (f *Full) Grid() Grid { return f.sh.grid }10533 106-// rowsShown is how many rows (or lines) there are to scroll through.107-func (f *Full) rowsShown() int {108- if f.table() {109- return len(f.order)110- }111- return f.sh.height(true, f.width-1)112-}34+// rowsShown is how many lines there are to scroll through.35+func (f *Full) rowsShown() int { return f.sh.height(true, f.width-1) }11336 11437 // Key takes a key, reporting whether Esc left the view.11538 func (f *Full) Key(k tea.KeyPressMsg) (closed bool) {116- key := k.String()117- if f.typing {118- switch key {119- case "esc":120- f.typing = false121- f.filter.Clear()122- case "enter":123- f.typing = false124- default:125- f.filter.Key(k)126- }127- f.apply()128- return false129- }130- page := max(f.height-2, 1)131- switch key {39+ page := max(f.height-1, 1)40+ switch k.String() {13241 case "esc", "q":13342 return true13443 case "up", "k":13544 f.row--13645 case "down", "j":13746 f.row++138- case "left", "h":139- f.col--140- case "right", "l":141- f.col++14247 case "pgup":14348 f.row -= page14449 case "pgdown", "space":
@@ -147,152 +52,56 @@ func (f *Full) Key(k tea.KeyPressMsg) (closed bool) {
14752 f.row = 014853 case "end", "G":14954 f.row = f.rowsShown() - 1150- case "s", "S":151- f.sortBy(f.col+1, key == "S")152- case "/":153- f.typing = true15455 }15556 f.settle()15657 return false15758 }15859 159-// sortBy sorts by column c (counted from 1), again to go back to the160-// output's order.161-func (f *Full) sortBy(c int, desc bool) {162- if !f.table() {163- return164- }165- if f.sort == c && f.desc == desc {166- f.sort = 0167- } else {168- f.sort, f.desc = c, desc169- }170- f.apply()171-}172-173-// settle keeps the pointer on the table and on screen.60+// settle keeps the pointer on the lines and on screen.17461 func (f *Full) settle() {17562 f.row = max(min(f.row, f.rowsShown()-1), 0)176- if f.table() {177- f.col = max(min(f.col, len(f.sh.cols)-1), 0)178- f.left = min(f.left, f.col)179- for f.left < f.col && f.colX(f.col)+f.sh.fit[f.col] > f.width {180- f.left++181- }182- }183- rows := f.height - 1184- if !f.table() {185- rows = f.height186- }187- f.top = max(min(f.top, f.row), f.row-rows+1, 0)188-}189-190-// numW is the row numbers' width.191-func (f *Full) numW() int { return len(strconv.Itoa(max(len(f.sh.rows), 1))) + 1 }192-193-// colX is where column c starts on screen, with the columns from left.194-func (f *Full) colX(c int) int {195- x := f.numW() + 1196- for i := f.left; i < c; i++ {197- x += f.sh.fit[i] + 2198- }199- return x63+ f.top = max(min(f.top, f.row), f.row-f.height+1, 0)20064 }20165 202-// lines draws the view.66+// lines draws the view: the grid at full size, its header on the first67+// line, or the output's lines with the pointer's marked.20368 func (f *Full) lines(th *theme.Theme, loc *locale.Locale) []string {20469 out := make([]string, 0, f.height)205- if !f.table() {206- for i := f.top; i < f.top+f.height && i < f.rowsShown(); i++ {207- line := f.sh.line(th, loc, i, fold{whole: true}, f.width-1)208- if i == f.row {209- line = th.Selection.Render("▌") + line210- } else {211- line = " " + line212- }213- out = append(out, line)70+ if g := f.sh.grid; g != nil {71+ rows := f.height - 172+ from := g.Top()73+ for i := range min(g.Rows(), rows) + 1 {74+ out = append(out, g.Line(i, from, rows, f.width))21475 }215- return pad2Lines(out, f.height)76+ return padLines(out, f.height)77+ }78+ for i := f.top; i < f.top+f.height && i < f.rowsShown(); i++ {79+ line := f.sh.line(th, loc, i, fold{whole: true}, f.width-1)80+ if i == f.row {81+ line = th.Selection.Render("▌") + line82+ } else {83+ line = " " + line84+ }85+ out = append(out, line)21686 }217- out = append(out, f.header(th))218- for i := f.top; i < f.top+f.height-1 && i < len(f.order); i++ {219- out = append(out, f.rowLine(th, loc, i))87+ return padLines(out, f.height)88+}89+90+func boolInt(b bool) int {91+ if b {92+ return 122093 }221- return pad2Lines(out, f.height)94+ return 022295 }22396 224-func pad2Lines(out []string, n int) []string {97+func padLines(out []string, n int) []string {22598 for len(out) < n {22699 out = append(out, "")227100 }228101 return out229102 }230103 231-// header is the column names, the sorted one marked ▲ or ▼, the232-// pointer's column's in reverse video.233-func (f *Full) header(th *theme.Theme) string {234- var b strings.Builder235- b.WriteString(th.Header.Render(strings.Repeat(" ", f.numW()+1)))236- for c := f.left; c < len(f.sh.cols) && f.colX(c) < f.width; c++ {237- name := f.sh.cols[c]238- if f.sort == c+1 {239- name += map[bool]string{false: " ▲", true: " ▼"}[f.desc]240- }241- style := th.Header242- if c == f.col {243- style = th.HeaderActive244- }245- b.WriteString(style.Render(pad(ansi.Truncate(name, f.sh.fit[c], "…"), f.sh.fit[c], sheet.AlignLeft)))246- b.WriteString(th.Header.Render(" "))247- }248- return ansi.Truncate(b.String(), f.width, "")249-}250-251-// rowLine is the i-th row shown, numbered as the output numbers it.252-func (f *Full) rowLine(th *theme.Theme, loc *locale.Locale, i int) string {253- r := f.order[i]254- num := th.Header255- if i == f.row {256- num = th.HeaderActive257- }258- var b strings.Builder259- b.WriteString(num.Render(pad(strconv.Itoa(r+1), f.numW(), sheet.AlignRight)) + " ")260- row := f.sh.rows[r]261- for c := f.left; c < len(f.sh.cols) && f.colX(c) < f.width; c++ {262- lc := cellAt(row, c)263- text := pad(ansi.Truncate(cellText(lc, loc), f.sh.fit[c], "…"), f.sh.fit[c], align(lc))264- if i == f.row && c == f.col {265- text = th.Pointer.Render(text)266- }267- b.WriteString(text + " ")268- }269- return ansi.Truncate(b.String(), f.width, "")270-}271-272104 // ContextLine says what's shown and the keys.273105 func (f *Full) ContextLine(th *theme.Theme) (string, string) {274- if f.typing {275- return th.Key.Render("Filter: ") + f.filter.Text(), th.KeyHints("Enter", "keep", "Esc", "clear")276- }277- what := f.Title278- if f.table() {279- what += " " + more(len(f.order), "row")280- if f.filter.Text() != "" {281- what += " holding " + strconv.Quote(f.filter.Text())282- }283- if f.sort > 0 {284- what += ", sorted by " + f.sh.cols[f.sort-1]285- }286- return th.Muted.Render(what), th.KeyHints("s", "sort", "S", "descending", "/", "filter", "Esc", "back")287- }288- return th.Muted.Render(what), th.KeyHints("Esc", "back")289-}290-291-// Cursor is where the terminal's caret goes while the filter is typed,292-// on the context line after "Filter: ".293-func (f *Full) Cursor() (int, bool) {294- if !f.typing {295- return 0, false296- }297- return ansi.StringWidth("Filter: " + f.filter.Head()), true106+ return th.Muted.Render(f.Title), th.KeyHints("Esc", "back")298107 }
A internal/ui/nbview/grid.go +199 -0
@@ -0,0 +1,199 @@
1+package nbview2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/012/internal/nuon"7+ "github.com/FelineStateMachine/012/internal/ui/theme"8+)9+10+// A table (a list of records) or a record is drawn by the UI as its own11+// grid, which the output's window shows: the grid's column header, named12+// after the table's columns, then as many of its rows as the window13+// holds. The UI owns everything about the grid (its cells, formats,14+// widths, the active cell, the selection, sorting and filtering); the15+// notebook places it, says how many rows show, and scrolls it.16+17+// Grid is an output drawn as the UI's grid.18+type Grid interface {19+ // Rows is how many rows the grid has showing: the rows a filter20+ // keeps, the header not counted.21+ Rows() int22+ // Top is the first row the window shows, counted among those.23+ Top() int24+ // Scroll scrolls the window d rows, reporting whether it moved.25+ Scroll(d int) bool26+ // Line draws line i of a window of rows rows at width, showing the27+ // rows from row from on: the column header for 0, then the rows.28+ Line(i, from, rows, width int) string29+ // Hidden is how many of the grid's columns don't show at width.30+ Hidden(width int) int31+ // Entered reports whether the grid has the keys: its active cell32+ // and selection show, and arrows move them.33+ Entered() bool34+}35+36+// gridData is what the UI's grid of an output holds, as NUON: a table as37+// it is, and a record as a table of its fields, a column of their names38+// and one of their values.39+func gridData(v nuon.Value, data []byte) []byte {40+ if v.Kind != nuon.Record {41+ return data42+ }43+ list := nuon.Value{Kind: nuon.List}44+ for _, f := range v.Fields {45+ list.List = append(list.List, nuon.Value{Kind: nuon.Record, Fields: []nuon.Field{46+ {Key: "field", Value: nuon.StringValue(f.Key)}, {Key: "value", Value: f.Value}}})47+ }48+ return nuon.Append(nil, list)49+}50+51+// entered reports whether the output's grid has the keys.52+func (sh *shown) entered() bool { return sh.grid != nil && sh.grid.Entered() }53+54+// gridRows is how many of a grid's rows show, and from which: all of55+// them from the first when the output shows whole (unless it's entered,56+// when it scrolls in its window), or a window's worth from where it's57+// scrolled.58+func (sh *shown) gridRows(whole bool) (from, n int) {59+ total := sh.grid.Rows()60+ if whole && !sh.entered() {61+ return 0, total62+ }63+ return sh.grid.Top(), min(total, window)64+}65+66+// gridLine draws line i of a grid's window.67+func (sh *shown) gridLine(th *theme.Theme, i int, f fold, width int) string {68+ from, n := sh.gridRows(f.whole)69+ switch {70+ case i <= n:71+ return sh.grid.Line(i, from, max(n, 1), width)72+ case n == 0 && i == 1:73+ return th.Muted.Render("no rows: the filter hides them all")74+ }75+ return sh.gridFooter(th, from, n, width)76+}77+78+// gridFooter says what a grid's window leaves out.79+func (sh *shown) gridFooter(th *theme.Theme, from, n, width int) string {80+ total := sh.grid.Rows()81+ unit := "row"82+ if sh.kind == outRecord {83+ unit = "field"84+ }85+ var parts []string86+ switch {87+ case n >= total:88+ case from == 0:89+ parts = append(parts, more(total-n, "more "+unit))90+ default:91+ parts = append(parts, unit+"s "+grouped(from+1)+" to "+grouped(from+n)+" of "+grouped(total))92+ }93+ if c := sh.grid.Hidden(width); c > 0 {94+ parts = append(parts, more(c, "more column"))95+ }96+ var hints []string97+ if n < total && !sh.entered() {98+ hints = append(hints, "O shows all")99+ }100+ if !sh.entered() {101+ hints = append(hints, "Enter works in it")102+ }103+ line := "… " + strings.Join(parts, ", ")104+ if len(hints) > 0 {105+ line += " (" + strings.Join(hints, ", ") + ")"106+ }107+ return th.Muted.Render(line)108+}109+110+// SelectedGrid is the selected output's grid, if it's selected and drawn111+// as one.112+func (v *View) SelectedGrid() Grid {113+ c, ok := v.Cell()114+ if !ok || !v.onOut {115+ return nil116+ }117+ return v.shown(c).grid118+}119+120+// Window is how many rows an output's window shows.121+const Window = window122+123+// Shows reports whether grid g is the selected output's, or shown124+// full-screen.125+func (v *View) Shows(g Grid) bool {126+ if v.full != nil {127+ return v.full.Grid() == g128+ }129+ return g != nil && v.SelectedGrid() == g130+}131+132+// SelectGrid selects the output grid g is drawn in.133+func (v *View) SelectGrid(g Grid) {134+ for i, c := range v.h.Cells() {135+ if sh := v.shown(c); g != nil && sh.grid == g {136+ v.StopEdit()137+ v.Select(i, true)138+ return139+ }140+ }141+}142+143+// GridAt is the grid at column x of line y of the body, with where that144+// is in the grid: gy 0 its column header, then its rows as the window145+// shows them.146+func (v *View) GridAt(x, y int) (g Grid, gx, gy int, ok bool) {147+ if v.full != nil {148+ if g = v.full.Grid(); g == nil || y < 0 || y >= v.height {149+ return nil, 0, 0, false150+ }151+ return g, x, y, true152+ }153+ h, ok := v.hitAt(y)154+ if !ok || h.kind != rowOut || x < textX {155+ return nil, 0, 0, false156+ }157+ c := v.h.Cells()[h.cell]158+ f := v.foldOf(c.ID)159+ sh := v.shown(c)160+ if f.hidden || !sh.isGrid() {161+ return nil, 0, 0, false162+ }163+ if _, n := sh.gridRows(f.whole); h.r > n {164+ return nil, 0, 0, false165+ }166+ return sh.grid, x - textX, h.r, true167+}168+169+// GridOrigin is where grid g's column header is drawn in the body: its170+// first column and line, which may be off screen.171+func (v *View) GridOrigin(g Grid) (x, y int, ok bool) {172+ if v.full != nil {173+ return 0, 0, v.full.Grid() == g174+ }175+ for i, c := range v.h.Cells() {176+ if sh := v.shown(c); g != nil && sh.grid == g {177+ b := v.blockOf(i, c)178+ return textX, v.startOf(i) + 2*b.box + b.src - v.top, true179+ }180+ }181+ return 0, 0, false182+}183+184+// GridWidth is how wide an output's grid is drawn: the output's width,185+// or the body's full-screen.186+func (v *View) GridWidth() int {187+ if v.full != nil {188+ return v.width189+ }190+ return v.outWidth()191+}192+193+// FollowOutput scrolls so the selected output's window shows, as far as194+// it fits: its grid's active cell moved, or the grid changed its height.195+func (v *View) FollowOutput() {196+ if v.full == nil && v.onOut {197+ v.follow()198+ }199+}
M internal/ui/nbview/keys.go +6 -2
M internal/ui/nbview/mouse.go +4 -0
M internal/ui/nbview/output.go +33 -125
@@ -1,7 +1,6 @@
11 package nbview22 33 import (4- "slices"54 "strconv"65 "strings"76
@@ -16,26 +15,24 @@ import (
1615 )1716 1817 // Outputs as they show under a code cell. What a cell printed, NUON, is19-// parsed once into a shown output: a table (a list of records), a20-// record, a list, text, or a single value; values are the cells21-// importing them would make, formatted as cells are. A long output22-// scrolls in a window of its own (outwin.go) until O shows it whole; its23-// lines are drawn only when they're on screen, so an output of ten24-// thousand rows costs the rows showing.18+// parsed once into a shown output: a table (a list of records) or a19+// record, which the UI draws as its own grid (grid.go), a list, text,20+// or a single value; values are the cells importing them would make,21+// formatted as cells are. A long output scrolls in a window of its own22+// (outwin.go) until O shows it whole; its lines are drawn only when23+// they're on screen, so an output of ten thousand rows costs the rows24+// showing.2525 2626 // window is how many rows (or lines) an output's window shows.2727 const window = 102828 29-// maxColWidth caps how wide fitting makes a column, as a sheet's.30-const maxColWidth = 3031-3229 // outKind is what an output holds.3330 type outKind int3431 3532 const (3633 outNone outKind = iota // nothing printed37- outTable // a list of records38- outRecord // a record: its fields, one a line34+ outTable // a list of records: a grid35+ outRecord // a record: a grid of its fields3936 outList // a list of values that aren't records4037 outText // a string, its lines4138 outValue // a single value
@@ -47,13 +44,16 @@ const (
4744 // shown is an output parsed for showing.4845 type shown struct {4946 kind outKind50- cols []string // a table's column names, a record's keys51- rows [][]sheet.LiveCell // a table's rows, a list's items, a record's values, a value47+ cols int // a table's columns48+ rows [][]sheet.LiveCell // a list's items, or the one value5249 text []string // text's lines, or an error's53- fit []int // the columns' fitted widths5450 total int // rows or lines in all5551 wrap []string // text wrapped at wrapW5652 wrapW int53+ // data is a table's or record's grid as NUON (gridData), which the54+ // UI makes the grid from.55+ data []byte56+ grid Grid5757 }5858 5959 // parse reads an output for showing.
@@ -77,7 +77,9 @@ func parse(o *notebook.Output) *shown {
7777 return &shown{kind: outBad, text: []string{"nu's output isn't NUON: " + err.Error()}, total: 1}7878 }7979 sh := shownOf(v)80- sh.fitColumns()80+ if sh.kind == outTable || sh.kind == outRecord {81+ sh.data = gridData(v, o.NUON)82+ }8183 return sh8284 }8385
@@ -90,18 +92,16 @@ func shownOf(v nuon.Value) *shown {
9092 lines := strings.Split(strings.TrimRight(v.Str, "\n"), "\n")9193 return &shown{kind: outText, text: lines, total: len(lines)}9294 case nuon.Record:93- sh := &shown{kind: outRecord, total: len(v.Fields)}94- for _, f := range v.Fields {95- sh.cols = append(sh.cols, f.Key)96- sh.rows = append(sh.rows, []sheet.LiveCell{fileio.NUONCell(f.Value)})95+ if len(v.Fields) == 0 {96+ return &shown{}9797 }98- return sh98+ return &shown{kind: outRecord, total: len(v.Fields), cols: 2}9999 case nuon.List:100100 if len(v.List) == 0 {101101 return &shown{}102102 }103103 if isTable(v.List) {104- return tableOf(v.List)104+ return &shown{kind: outTable, total: len(v.List), cols: countCols(v.List)}105105 }106106 sh := &shown{kind: outList, total: len(v.List)}107107 for _, it := range v.List {
@@ -122,58 +122,19 @@ func isTable(list []nuon.Value) bool {
122122 return true123123 }124124 125-// tableOf is a list of records as a table: the columns in the order the126-// records name them.127-func tableOf(list []nuon.Value) *shown {128- sh := &shown{kind: outTable, total: len(list)}129- index := map[string]int{}125+// countCols is how many columns a list of records names.126+func countCols(list []nuon.Value) int {127+ seen := map[string]bool{}130128 for _, rec := range list {131129 for _, f := range rec.Fields {132- if _, ok := index[f.Key]; !ok {133- index[f.Key] = len(sh.cols)134- sh.cols = append(sh.cols, f.Key)135- }136- }137- }138- sh.rows = make([][]sheet.LiveCell, len(list))139- for i, rec := range list {140- row := make([]sheet.LiveCell, len(sh.cols))141- for _, f := range rec.Fields {142- row[index[f.Key]] = fileio.NUONCell(f.Value)130+ seen[f.Key] = true143131 }144- sh.rows[i] = row145132 }146- return sh133+ return len(seen)147134 }148135 149-// fitSample is how many rows at each end fitting the columns reads of a150-// table too long to read whole.151-const fitSample = 1000152-153-// fitColumns sizes a table's columns to their widest text, the header154-// included, at most maxColWidth: all its rows, or the first and last155-// of a long table.156-func (sh *shown) fitColumns() {157- if sh.kind != outTable {158- return159- }160- sh.fit = make([]int, len(sh.cols))161- for c, name := range sh.cols {162- sh.fit[c] = ansi.StringWidth(name)163- }164- rows := sh.rows165- if len(rows) > 20*fitSample {166- rows = append(slices.Clip(rows[:fitSample]), rows[len(rows)-fitSample:]...)167- }168- for _, row := range rows {169- for c, lc := range row {170- sh.fit[c] = max(sh.fit[c], ansi.StringWidth(cellText(lc, locale.Canonical)))171- }172- }173- for c := range sh.fit {174- sh.fit[c] = min(max(sh.fit[c], 1), maxColWidth)175- }176-}136+// isGrid reports whether the output is drawn as the UI's grid.137+func (sh *shown) isGrid() bool { return sh.grid != nil }177138 178139 // cellText is a value as a cell shows it, whole.179140 func cellText(lc sheet.LiveCell, loc *locale.Locale) string {
@@ -183,23 +144,6 @@ func cellText(lc sheet.LiveCell, loc *locale.Locale) string {
183144 return strings.ReplaceAll(sheet.FormatTextIn(lc.V, lc.F, loc), "\n", " ")184145 }185146 186-// shownCols is how many of a table's columns fit in width.187-func (sh *shown) shownCols(width int) int {188- x := 0189- for c, w := range sh.fit {190- if c > 0 {191- x += 2192- }193- if x+w > width && c > 0 {194- return c195- }196- x += w197- }198- return len(sh.fit)199-}200-201-func (sh *shown) hiddenCols(width int) int { return len(sh.fit) - sh.shownCols(width) }202-203147 // more counts what's left out: "9,991 more rows".204148 func more(n int, what string) string {205149 if n != 1 {
@@ -217,50 +161,14 @@ func grouped(n int) string {
217161 return s218162 }219163 220-// align is how a value lines up in its column, as in a cell.221-func align(lc sheet.LiveCell) sheet.Align {222- switch lc.V.Kind {223- case sheet.Number:224- if lc.F.Kind == sheet.FmtText {225- return sheet.AlignLeft226- }227- return sheet.AlignRight228- case sheet.Bool, sheet.Error:229- return sheet.AlignCenter230- }231- return sheet.AlignLeft232-}233-234-func pad(s string, w int, a sheet.Align) string {235- gap := max(w-ansi.StringWidth(s), 0)236- switch a {237- case sheet.AlignRight:238- return strings.Repeat(" ", gap) + s239- case sheet.AlignCenter:240- return strings.Repeat(" ", gap/2) + s + strings.Repeat(" ", gap-gap/2)241- }242- return s + strings.Repeat(" ", gap)243-}244-245-// fieldLine is a record's field, key: value, or a list's item, its246-// index first as nushell shows one.164+// fieldLine is a list's item, its index first as nushell shows one.247165 func (sh *shown) fieldLine(th *theme.Theme, loc *locale.Locale, i, width int) string {248166 if i >= len(sh.rows) {249167 return ""250168 }251- var key string252- if sh.kind == outRecord {253- kw := 0254- for _, k := range sh.cols {255- kw = max(kw, ansi.StringWidth(k))256- }257- kw = min(kw, 24)258- k := ansi.Truncate(sh.cols[i], kw, "…")259- key = th.OutputHead.Render(k) + strings.Repeat(" ", kw-ansi.StringWidth(k)+2)260- } else {261- n := len(strconv.Itoa(sh.total - 1))262- key = th.Muted.Render(pad(strconv.Itoa(i), n, sheet.AlignRight)) + " "263- }169+ n := len(strconv.Itoa(sh.total - 1))170+ num := strconv.Itoa(i)171+ key := th.Muted.Render(strings.Repeat(" ", max(n-len(num), 0))+num) + " "264172 room := max(width-ansi.StringWidth(key), 1)265173 return key + ansi.Truncate(cellText(sh.rows[i][0], loc), room, "…")266174 }
M internal/ui/nbview/outwin.go +27 -86
@@ -1,17 +1,14 @@
11 package nbview22 33 import (4- "strings"5-64 "github.com/charmbracelet/x/ansi"75 86 "github.com/FelineStateMachine/012/internal/locale"9- "github.com/FelineStateMachine/012/internal/sheet"107 "github.com/FelineStateMachine/012/internal/ui/theme"118 )129 13-// An output's window: a table's header, then at most window of its rows14-// (a list's items, a record's fields, text's lines) from the row it's10+// An output's window: at most window of its rows (a grid's rows under11+// its column header, a list's items, text's lines) from the row it's1512 // scrolled to, then a line saying what's left out. Whole shows every row1613 // instead, and hidden folds the output to one line, as Jupyter's1714 // collapsed outputs. The window's height doesn't change as it scrolls,
@@ -21,21 +18,13 @@ import (
2118 type fold struct {2219 hidden bool // o: folded to one line2320 whole bool // O: every row, no window24- scroll int // the window's first row25-}26-27-// head is how many lines stay above the rows: a table's header.28-func (sh *shown) head() int {29- if sh.kind == outTable {30- return 131- }32- return 021+ scroll int // the window's first row, but a grid's, which the grid keeps3322 }3423 3524 // items is how many rows the output scrolls through at width.3625 func (sh *shown) items(width int) int {3726 switch sh.kind {38- case outTable, outRecord, outList:27+ case outList:3928 return sh.total4029 case outText, outError:4130 return len(sh.wrapped(width))
@@ -52,22 +41,27 @@ func (sh *shown) rowsShown(whole bool, width int) (n int, footer bool) {
5241 if !whole && n > window {5342 n, footer = window, true5443 }55- if sh.kind == outTable && sh.hiddenCols(width) > 0 {56- footer = true57- }5844 return n, footer5945 }6046 6147 // height is how many lines the output takes at width.6248 func (sh *shown) height(whole bool, width int) int {63- if sh.kind == outNone {49+ switch {50+ case sh.kind == outNone:6451 return 052+ case sh.isGrid():53+ _, n := sh.gridRows(whole)54+ lines := 1 + max(n, 1)55+ if n < sh.grid.Rows() || sh.grid.Hidden(width) > 0 {56+ lines++57+ }58+ return lines6559 }6660 n, footer := sh.rowsShown(whole, width)6761 if footer {6862 n++6963 }70- return sh.head() + n64+ return n7165 }7266 7367 // maxScroll is how far the window scrolls: 0 when it shows every row.
@@ -78,10 +72,9 @@ func (sh *shown) maxScroll(whole bool, width int) int {
7872 7973 // line draws line i of the output as f shows it, at width.8074 func (sh *shown) line(th *theme.Theme, loc *locale.Locale, i int, f fold, width int) string {81- if i < sh.head() {82- return sh.tableHead(th, width)75+ if sh.isGrid() {76+ return sh.gridLine(th, i, f, width)8377 }84- i -= sh.head()8578 n, _ := sh.rowsShown(f.whole, width)8679 scroll := min(max(f.scroll, 0), sh.maxScroll(f.whole, width))8780 if i < n {
@@ -90,13 +83,10 @@ func (sh *shown) line(th *theme.Theme, loc *locale.Locale, i int, f fold, width
9083 return sh.footer(th, scroll, n, width)9184 }9285 93-// item draws row i: a table's row, a field or an item, a line of text,94-// or the one value.86+// item draws row i: a list's item, a line of text, or the one value.9587 func (sh *shown) item(th *theme.Theme, loc *locale.Locale, i, width int) string {9688 switch sh.kind {97- case outTable:98- return sh.tableRow(loc, i, width)99- case outRecord, outList:89+ case outList:10090 return sh.fieldLine(th, loc, i, width)10191 case outText, outError:10292 return sh.textLine(th, i, width)
@@ -110,68 +100,19 @@ func (sh *shown) item(th *theme.Theme, loc *locale.Locale, i, width int) string
110100 return ""111101 }112102 113-// footer says what the window leaves out: the rows under it (or where114-// it's scrolled to) and a table's columns past the width.103+// footer says what the window leaves out: the rows under it, or where104+// it's scrolled to.115105 func (sh *shown) footer(th *theme.Theme, scroll, n, width int) string {116- unit := map[outKind]string{outTable: "row", outRecord: "field", outList: "item"}[sh.kind]117- if unit == "" {118- unit = "line"106+ unit := "line"107+ if sh.kind == outList {108+ unit = "item"119109 }120110 total := sh.items(width)121- var parts []string122- switch {123- case n >= total:124- case scroll == 0:125- parts = append(parts, more(total-n, "more "+unit))126- default:127- parts = append(parts, unit+"s "+grouped(scroll+1)+" to "+grouped(scroll+n)+" of "+grouped(total))128- }129- if sh.kind == outTable {130- if c := sh.hiddenCols(width); c > 0 {131- parts = append(parts, more(c, "more column"))132- }133- }134- hint := "O shows all"135- if n >= total {136- hint = ""137- }138- if sh.kind == outTable {139- hint = strings.TrimPrefix(hint+", Enter opens it", ", ")140- }141- return ansi.Truncate(th.Muted.Render("… "+strings.Join(parts, ", ")+" ("+hint+")"), width, "…")142-}143-144-// tableHead is a table's header row.145-func (sh *shown) tableHead(th *theme.Theme, width int) string {146- var b strings.Builder147- for c := range sh.shownCols(width) {148- if c > 0 {149- b.WriteString(" ")150- }151- name := ansi.Truncate(sh.cols[c], sh.fit[c], "…")152- b.WriteString(th.OutputHead.Render(name) + strings.Repeat(" ", sh.fit[c]-ansi.StringWidth(name)))153- }154- return b.String()155-}156-157-// tableRow is a table's row i.158-func (sh *shown) tableRow(loc *locale.Locale, i, width int) string {159- if i >= len(sh.rows) {160- return ""161- }162- row := sh.rows[i]163- var b strings.Builder164- for c := range sh.shownCols(width) {165- if c > 0 {166- b.WriteString(" ")167- }168- var lc sheet.LiveCell169- if c < len(row) {170- lc = row[c]171- }172- b.WriteString(pad(ansi.Truncate(cellText(lc, loc), sh.fit[c], "…"), sh.fit[c], align(lc)))111+ part := more(total-n, "more "+unit)112+ if scroll > 0 {113+ part = unit + "s " + grouped(scroll+1) + " to " + grouped(scroll+n) + " of " + grouped(total)173114 }174- return b.String()115+ return ansi.Truncate(th.Muted.Render("… "+part+" (O shows all)"), width, "…")175116 }176117 177118 // textLine is line i of text or an error, wrapped at width.
M internal/ui/nbview/panel.go +5 -1
M internal/ui/nbview/view.go +7 -0
M internal/ui/nbview/view_test.go +84 -24
@@ -9,8 +9,10 @@ import (
99 tea "charm.land/bubbletea/v2"1010 "github.com/charmbracelet/x/ansi"1111 12+ "github.com/FelineStateMachine/012/internal/fileio"1213 "github.com/FelineStateMachine/012/internal/locale"1314 "github.com/FelineStateMachine/012/internal/notebook"15+ "github.com/FelineStateMachine/012/internal/nuon"1416 "github.com/FelineStateMachine/012/internal/ui/theme"1517 )1618
@@ -24,6 +26,7 @@ type fakeHost struct {
2426 edits []string2527 onRun func(id string)2628 kernel Kernel29+ grids []*fakeGrid2730 }2831 2932 func newHost(srcs ...string) *fakeHost {
@@ -56,6 +59,58 @@ func (h *fakeHost) Edit(id int, src string) {
5659 }5760 }5861 62+// Grid is a fake grid: its column names, then its rows' values, spaced.63+func (h *fakeHost) Grid(id int, data []byte, rows int) Grid {64+ v, err := nuon.Parse(data)65+ if err != nil {66+ panic(err)67+ }68+ g := &fakeGrid{}69+ for _, rec := range v.List {70+ var row []string71+ for i, f := range rec.Fields {72+ if len(g.rows) == 0 && i >= len(g.cols) {73+ g.cols = append(g.cols, f.Key)74+ }75+ row = append(row, cellText(fileio.NUONCell(f.Value), locale.Canonical))76+ }77+ g.rows = append(g.rows, strings.Join(row, " "))78+ }79+ h.grids = append(h.grids, g)80+ return g81+}82+83+type fakeGrid struct {84+ cols, rows []string85+ top, win int86+ in bool87+}88+89+func (g *fakeGrid) Rows() int { return len(g.rows) }90+func (g *fakeGrid) Top() int { return g.top }91+func (g *fakeGrid) Entered() bool { return g.in }92+func (g *fakeGrid) Hidden(int) int {93+ return 094+}95+96+func (g *fakeGrid) Scroll(d int) bool {97+ to := min(max(g.top+d, 0), max(len(g.rows)-g.win, 0))98+ moved := to != g.top99+ g.top = to100+ return moved101+}102+103+func (g *fakeGrid) Line(i, from, rows, width int) string {104+ g.win = rows105+ if i == 0 {106+ return strings.Join(g.cols, " ")107+ }108+ if from+i-1 < len(g.rows) {109+ return fmt.Sprintf("%d %s", from+i, g.rows[from+i-1])110+ }111+ return ""112+}113+59114 func newView(h *fakeHost, w, ht int) *View {60115 v := New(h)61116 v.Keys = map[string]string{"enter": "nb.edit", "d d": "nb.delete", "o": "nb.toggle_output", "shift+enter": "nb.run_next"}
@@ -87,8 +142,8 @@ func TestOutputsShow(t *testing.T) {
87142 v := newView(h, 80, 60)88143 got := text(v)89144 for _, want := range []string{90- "[1]:", "─ files ─", "name size ok", "a.txt 2.0 kB TRUE", "bb.txt 10 B FALSE",91- "a 1", "long_key two words", "line one", "line two",145+ "[1]:", "─ files ─", "name size ok", "1 a.txt 2.0 kB TRUE", "2 bb.txt 10 B FALSE",146+ "field value", "1 a 1", "2 long_key two words", "line one", "line two",92147 "× Command `nope` not found", "help: try ls", "not saved; run to see", "0 1", "1 2",93148 } {94149 if !strings.Contains(got, want) {
@@ -207,34 +262,39 @@ func TestMarkdown(t *testing.T) {
207262 }208263 }209264 210-func TestFullSortsAndFilters(t *testing.T) {211- h := newHost("ls")265+// A table's output is the host's grid: its window, then full-screen at266+// the body's size; text full-screen scrolls its lines.267+func TestGridOutputs(t *testing.T) {268+ h := newHost("ls", "text")212269 h.outs[1] = &notebook.Output{NUON: []byte("[[name, n]; [b, 2], [a, 10], [c, 1]]"), Count: 1}213- v := newView(h, 60, 10)270+ h.outs[2] = &notebook.Output{NUON: []byte(`"one\ntwo"`), Count: 2}271+ v := newView(h, 60, 12)272+ if len(h.grids) != 1 || !strings.Contains(text(v), "name n\n") || !strings.Contains(text(v), "1 b 2") {273+ t.Fatalf("the grid's window:\n%s", text(v))274+ }214275 v.Select(0, true)215- if !v.OpenFull() {216- t.Fatal("didn't open")276+ if v.SelectedGrid() != Grid(h.grids[0]) || !v.Shows(h.grids[0]) {277+ t.Error("the output selected isn't its grid")217278 }218- names := func() string {219- var out []string220- for _, i := range v.full.order {221- out = append(out, cellText(v.full.sh.rows[i][0], locale.Canonical))222- }223- return strings.Join(out, "")279+ if g, gx, gy, ok := v.GridAt(textX+3, 4); !ok || g != Grid(h.grids[0]) || gx != 3 || gy != 1 {280+ t.Errorf("GridAt: %v %d %d %v", g, gx, gy, ok)281+ }282+ if x, y, ok := v.GridOrigin(h.grids[0]); !ok || x != textX || y != 3 {283+ t.Errorf("the grid starts at %d, %d", x, y)224284 }225- v.Key(key("l"))226- v.Key(key("S"))227- if got := names(); got != "abc" {228- t.Errorf("sorted by n descending: %q", got)285+ h.grids[0].in = true286+ if bar := v.Lines()[4]; !strings.Contains(bar, "▌") {287+ t.Errorf("the grid entered has no bar: %q", bar)229288 }230- v.Key(key("/"))231- v.Key(key("c"))232- v.Key(key("enter"))233- if got := names(); got != "c" {234- t.Errorf("filtered: %q", got)289+ v.OpenFull()290+ if got := text(v); h.grids[0].win != 11 || !strings.HasPrefix(got, "name n\n1 b 2") {291+ t.Errorf("full-screen, %d rows:\n%s", h.grids[0].win, got)235292 }236293 v.Key(key("esc"))237- if v.FullOpen() {238- t.Error("Esc didn't close it")294+ v.Select(1, true)295+ v.OpenFull()296+ v.Key(key("down"))297+ if got := text(v); !strings.Contains(got, " one\n▌two") {298+ t.Errorf("text full-screen:\n%s", got)239299 }240300 }
M internal/ui/notebook.go +7 -1
M internal/ui/panel.go +20 -2
@@ -31,6 +31,7 @@ func (m *Model) View() tea.View {
3131 defer m.timeFrame(time.Now())3232 }3333 clear(m.painted) // the theme may have changed since the last frame34+ m.nb.frame++ // and outputs' grids clear theirs: nbgrid.go3435 clear(m.mergeLines)3536 clear(m.shaded)3637 var lines []string
@@ -156,7 +157,7 @@ func (m *Model) indicator() string {
156157 case m.mode == modePrompt:157158 return m.prompt.indicator158159 case m.nbView() != nil && m.mode == modeReady:159- return notebookIndicator(m.nbView())160+ return m.notebookIndicator(m.nbView())160161 case m.vimActive() && m.visual() != visualNone:161162 return "VISUAL"162163 case m.vimActive():
@@ -188,6 +189,11 @@ func (m *Model) formulaBar() string {
188189 if b, ok := m.overlay.(overlay.Bar); ok {189190 return m.th.Header.Render(theme.PadRight(" ", nameBoxW)) + " " + b.FormulaBar()190191 }192+ if g := m.gridIn(); g != nil {193+ if _, ok := g.child.overlay.(overlay.Bar); ok {194+ return g.child.formulaBar() // its find bar's field195+ }196+ }191197 return m.notebookToolbar(v)192198 }193199 name := m.cur.String()
@@ -314,6 +320,9 @@ func (m *Model) cursorPos() (x, y int, ok bool) {
314320 return x, y, x >= 0315321 }316322 if v := m.nbView(); v != nil && m.mode == modeReady && m.overlay == nil {323+ if g := m.gridIn(); g != nil {324+ return m.gridCursor(g, v)325+ }317326 return m.notebookCursor(v)318327 }319328 switch {
@@ -339,6 +348,9 @@ func (m *Model) statusLayout() (string, []tabstrip.Span) {
339348 if m.mode == modeError {340349 return m.th.Error.Render(m.errMsg) + m.th.Muted.Render(" press any key"), nil341350 }351+ if g := m.gridIn(); g != nil && g.child.mode == modeError {352+ return g.child.statusLayout()353+ }342354 if m.xfer.Busy() {343355 return m.spread(m.xfer.Status(&m.th, m.width)), nil344356 }
@@ -352,6 +364,9 @@ func (m *Model) statusLayout() (string, []tabstrip.Span) {
352364 // formula suggestions does, and the keys that apply.353365 func (m *Model) floatingStatus() (string, bool) {354366 desc, keys, floating := m.entry.assist.Status(m.host())367+ if g := m.gridIn(); g != nil && m.overlay == nil && g.child.overlay != nil {368+ return g.child.floatingStatus() // the grid's menu or filter369+ }355370 if m.overlay != nil {356371 desc, keys = m.overlay.Status()357372 floating = true
@@ -435,10 +450,13 @@ func (m *Model) statusState() string {
435450 // the keys that open the palette, the shortcuts and the menu.436451 func (m *Model) statusRights() []string {437452 var out []string453+ if g := m.gridIn(); g != nil && g.child.hasRange() {454+ return g.child.statusRights() // the grid's selection455+ }438456 if m.hasRange() && m.mode == modeReady {439457 r := m.selection()440458 st := m.sheet.RangeStats(r)441- rng := m.th.Key.Render(r.String())459+ rng := m.th.Key.Render(m.rangeLabel(r))442460 sum, avg := "", ""443461 if st.Nums > 0 {444462 sum = m.th.Muted.Render("Sum ") + m.fmtStat(st.Sum)
M internal/ui/pivot.go +4 -0
M internal/ui/sort.go +4 -3
M internal/ui/speed_test.go +22 -0
M internal/ui/testdata/speed.json +5 -0
M internal/ui/view.go +8 -4