Rows on screen become bands: a rule line when a border lies along the row's top, then as many lines as its cells take (rowtext.Shapes, kept until the sheet changes), the last one cut off at the grid's bottom. Scrolling still moves a row at a time. Navigation, Page Down, frozen rows, the mouse, drags and charts go through the bands, so they agree on where each row is. A sheet that nothing shapes is a line per row. rowtext lays out one line of a row: values sit on the last line as Sheets aligns them to the bottom, wrapped text fills lines of its own column, clipped text stays in it, and text doesn't run on across a border or into merged cells. The row number goes on the last line. Borders draw with box-drawing characters: vertical edges in the padding column of the cell to their right, horizontal ones on the rule line, joined by theme.Junction (every light, heavy and double joint Unicode has), in a new CellBorder role. Merged cells draw their top-left value centered across them, with no lines inside. Format > Wrapping, Borders (with a line style) and Merge cells, Row height and Fit rows to data are commands with Sheets' border keys (Alt+Shift+1 to 7); the row number's corner drags a row's height and records set_height() in macros. Merging asks before clearing values; sorting over merges is refused. The active cell snaps to a merge's top-left and a selection grows over the merges it touches. The menu bar no longer builds each menu's items every frame to see whether it has any.
- commit
- d85e1478e36026636bbdb06c5c403bf04ab563b4
- parent
- a30d472cb0eb32813a04f52888564a74556bef22
- author
- Dami <dami.etoile@gmail.com>
@@ -73,6 +73,8 @@ copies and which series the fill handle continues.
7373 | Ctrl+Shift+6 | Scientific |7474 | Ctrl+B, Ctrl+I, Ctrl+U, Alt+Shift+5 | Bold, italic, underline, strikethrough |7575 | Ctrl+Shift+L, Ctrl+Shift+E, Ctrl+Shift+R | Align left, center, right |76+| Alt+Shift+1, Alt+Shift+2, Alt+Shift+3, Alt+Shift+4 | Top, right, bottom, left border |77+| Alt+Shift+7, Alt+Shift+6 | Outer borders, clear borders |7678 | Ctrl+\ | Clear formatting |7779 7880 The rest of the formats are in the Format menu.@@ -197,6 +199,7 @@ line isn't a command itself (`:fill d` Enter fills down).
197199 | Double-click | Edit the cell |198200 | Drag the fill handle (▟ at the selection's corner, shown on hover) | Continue a series (1, 2, 3; Jan, Feb; Mon, Tue; dates; Item 1, Item 2) or copy |199201 | Drag a column header's right edge; double-click it | Resize the column; fit it to its contents |202+| Drag the bottom-right corner of a row number (`▄` on hover); double-click it | Resize the row; fit it to its contents |200203 | Right-click | The cell, column or row menu |201204 | Click `▾` in a header, or in a cell | Open the column's filter, or the cell's dropdown |202205 | Click a checkbox | Check or uncheck it |@@ -76,6 +76,7 @@ is already in the formulas that use it.
7676 | `add_sheet(name=None)` | Add a sheet after the one shown and show it; returns its name |7777 | `move_sheet(position)` | Move the sheet shown to a position, counting from 1 |7878 | `set_width(cols, width)` | Set column widths, 1 to 240: `set_width("B", 14)`, `set_width("B:D", 8)` |79+| `set_height(rows, height)` | Set row heights in lines, 1 to 50, or 0 to fit their contents: `set_height("3", 2)`, `set_height("3:5", 0)` |7980 8081 ## Commands8182 @@ -43,6 +43,73 @@ or right. Numbers align right, text left, booleans and errors center, as
4343 in Sheets, until you align them yourself. Format > Clear formatting resets4444 number formats and text styles and keeps the contents.4545 46+## Wrapping47+48+Format > Wrapping says what text wider than its column does, as in49+Sheets:50+51+| Wrapping | Text |52+|---|---|53+| Overflow | Runs on into the blank cells beside it (the default) |54+| Wrap | Breaks into lines that fit the column, at spaces where it can; the row grows to fit them |55+| Clip | Is cut off at the cell's edge |56+57+Values sit on the last line of a tall row, as Sheets aligns them to the58+bottom, and the row's number is on that line too, so a tall row ends on59+the line that numbers it. Text doesn't run on across a60+[border](#borders) or into [merged cells](#merged-cells).61+62+## Row heights63+64+A row is as tall as the text it wraps, up to 50 lines, unless it has a65+height of its own: Format > Row height (1 to 50 lines, with a live66+preview as Left and Right change it), or drag the bottom-right corner of67+the row's number, where `▄` shows on hover. Format > Fit rows to data,68+or double-clicking that corner, has rows fit their contents again.69+Heights follow inserted and deleted rows and travel with whole rows70+copied, cut or moved.71+72+## Borders73+74+Format > Borders draws lines along the edges of the selected cells: all75+of them, the outline (outer), the lines between the cells (inner), or76+one side; None removes them. Lines are thin, thick or double, whichever77+of Thin lines, Thick lines and Double lines was picked last (thin at78+first), as Sheets' border style keeps its choice. Alt+Shift+1 to 4 draw79+the top, right, bottom and left border, Alt+Shift+7 the outline and80+Alt+Shift+6 clears them ([keys](../reference/keys.md#formatting)).81+82+The grid draws borders with box-drawing characters, which terminals83+join where lines meet (`┌─┬─┐`, `╔═╤═╗`). A line down the side of a cell84+takes the first column of the cell to its right, which is padding85+anyway. A terminal has no thinner line between two rows of characters,86+so a line along the top of a row takes a line of the screen above it,87+as tables printed in a terminal do: a bordered table is taller than a88+plain one. Where two cells share an edge, the heavier line shows, and89+drawing an edge again from one side replaces the other side's line.90+Borders are part of a cell's formatting: they fall back on rows and91+columns, travel with copies and go with Clear formatting.92+93+## Merged cells94+95+Format > Merge cells joins the selection into one cell (Merge all), or96+each of its rows or columns into one (Merge horizontally, Merge97+vertically). A merged cell shows its top-left cell's value centered98+across it, unless that cell is aligned, and draws no lines inside.99+Merging clears the values of the other cells, as Sheets does, so when100+any would be lost it asks first: Enter merges, Esc backs out, and undo101+brings them back. Unmerge splits merged cells back into cells.102+103+The active cell moves onto a merged cell's top-left and steps off it104+from its edges; clicking any part of it selects it, typing edits it,105+and a selection that touches it takes in all of it. Merges follow106+inserted and deleted lines, and a copy, cut or move carries the merges107+wholly inside it. Sorting a range that holds merged cells is refused,108+an array can't [spill](../formulas/arrays.md) over them, cells an array109+spills into or a pivot table's results can't be merged, and merging in a110+[protected range](notes-protection.md#protected-sheets-and-ranges) asks111+first.112+46113 ## Whole columns and rows47114 48115 Formatting whole columns or rows (select them with Ctrl+Space or@@ -26,7 +26,7 @@ func (r *Run) builtins() starlark.StringDict {
2626 "move": r.move, "extend": r.extend, "jump": r.jump, "enter": r.enter, "paste_text": r.pasteText,2727 "sheets": r.sheets, "active_sheet": r.activeSheet, "activate_sheet": r.activateSheet,2828 "add_sheet": r.addSheet, "move_sheet": r.moveSheet,29- "run": r.run, "set_width": r.setWidth, "fill": r.fill, "offset": offset,29+ "run": r.run, "set_width": r.setWidth, "set_height": r.setHeight, "fill": r.fill, "offset": offset,3030 }3131 out := make(starlark.StringDict, len(fns))3232 for name, fn := range fns {@@ -369,6 +369,15 @@ func (r *Run) setWidth(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.V
369369 return starlark.None, r.host(func(h Host) error { return h.SetWidth(cols, width) })370370 }371371 372+func (r *Run) setHeight(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {373+ var rows string374+ var height int375+ if err := unpack("set_height", args, kwargs, "rows", &rows, "height", &height); err != nil {376+ return nil, err377+ }378+ return starlark.None, r.host(func(h Host) error { return h.SetHeight(rows, height) })379+}380+372381 func (r *Run) fill(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {373382 var to string374383 var rows, cols int@@ -86,6 +86,9 @@ type Host interface {
8686 Run(id string, answer *string) error8787 // SetWidth sets the width of the columns cols, e.g. "B" or "B:D".8888 SetWidth(cols string, width int) error89+ // SetHeight sets the height of the rows rows, e.g. "3" or "3:5", in90+ // lines; 0 fits them to their contents.91+ SetHeight(rows string, height int) error8992 // Fill fills from the selection as dragging the fill handle does: to9093 // the range to, or by rows (down, or up when negative) or cols.9194 Fill(to string, rows, cols int) error@@ -60,7 +60,8 @@ func (f *fakeHost) Run(id string, answer *string) error {
6060 }6161 return f.log("run %s %q", id, *answer)6262 }63-func (f *fakeHost) SetWidth(cols string, w int) error { return f.log("width %s %d", cols, w) }63+func (f *fakeHost) SetWidth(cols string, w int) error { return f.log("width %s %d", cols, w) }64+func (f *fakeHost) SetHeight(rows string, h int) error { return f.log("height %s %d", rows, h) }6465 func (f *fakeHost) Fill(to string, rows, cols int) error {6566 return f.log("fill %q %d %d", to, rows, cols)6667 }@@ -82,6 +83,7 @@ func TestSourceRunsAsRecorded(t *testing.T) {
8283 Call("run", "format.bold"),8384 Call("activate_sheet", "Q3 plan"),8485 Call("set_width", "B:C", 14),86+ Call("set_height", "3:4", 2),8587 Call("fill").With("rows", 3),8688 Call("move_sheet", 2),8789 }@@ -96,7 +98,7 @@ func TestSourceRunsAsRecorded(t *testing.T) {
9698 want := []string{9799 `select B2 ""`, `enter "=SUM(A1:A3)" false ""`, "move 0 1", `extend 2 0 "columns"`, "jump down true",98100 `enter "total" true ""`, `enter "=A1" false "B2"`, `paste "a\tb\n\"c\""`, `run column.width "12"`, "run format.bold",99- "activate Q3 plan", "width B:C 14", `fill "" 3 0`, "move sheet 2",101+ "activate Q3 plan", "width B:C 14", "height 3:4 2", `fill "" 3 0`, "move sheet 2",100102 }101103 if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {102104 t.Errorf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))@@ -158,12 +158,18 @@ func (s *Sheet) RowHeight(row int) (int, bool) {
158158 }159159 160160 // SetRowHeight sets rows from..to to h lines, as one undo step; h 0161-// has them fit their contents again, as Sheets' Fit to data.161+// has them fit their contents again, as Sheets' Fit to data. Heights are162+// kept row by row, so a range of more than materializeLimit rows (whole163+// columns) stops at the last row holding a cell.162164 func (s *Sheet) SetRowHeight(from, to, h int) {163165 label := "row height"164166 if h <= 0 {165167 label = "fit rows to data"166168 }169+ if to-from >= materializeLimit {170+ used, _ := s.cells.bounds(rowRect(from, to))171+ to = max(from, used.To.Row)172+ }167173 s.change(label, rowRect(from, to), func() {168174 for r := from; r <= to; r++ {169175 s.setHeight(r, h)@@ -171,6 +177,18 @@ func (s *Sheet) SetRowHeight(from, to, h int) {
171177 })172178 }173179 180+// ColsWidth is the width of columns from..to together, at the cost of181+// the columns with a width of their own.182+func (s *Sheet) ColsWidth(from, to int) int {183+ w := (to - from + 1) * DefaultWidth184+ for c, cw := range s.widths {185+ if c >= from && c <= to {186+ w += cw - DefaultWidth187+ }188+ }189+ return w190+}191+174192 // Heights returns the rows that have a height set by hand.175193 func (s *Sheet) Heights() map[int]int { return maps.Clone(s.heights) }176194 @@ -0,0 +1,345 @@
1+package ui2+3+import (4+ "github.com/FelineStateMachine/012/internal/sheet"5+ "github.com/FelineStateMachine/012/internal/ui/rowtext"6+)7+8+// Rows on screen. Each row shown is a band of screen lines: a rule line9+// first when a border lies along its top, then as many lines as its10+// cells take (rowtext.Shapes), the last row cut off at the bottom of the11+// grid. A sheet nothing shapes has a line per row. Scrolling moves a row12+// at a time, so the top row always shows from its first line.13+14+// band is a row's place on screen.15+type band struct {16+ row int // the row, or divider17+ y int // the screen line it starts on18+ rule bool // a border line comes first19+ shown int // lines of the row's cells on screen20+ lines int // lines the row's cells take21+}22+23+// top is the screen line of the band's first line of cells.24+func (b band) top() int {25+ if b.rule {26+ return b.y + 127+ }28+ return b.y29+}30+31+// end is the screen line after the band.32+func (b band) end() int { return b.top() + b.shown }33+34+// divider marks the line between frozen and scrolling rows.35+const divider = -136+37+// shape returns how row r is laid out.38+func (g *grid) shape(r int) rowtext.Shape { return g.shapes.Row(g.sheet, r) }39+40+// span is how many screen lines row r takes, its rule line included.41+func (g *grid) span(r int) int {42+ sh := g.shape(r)43+ if sh.Rule {44+ return sh.Lines + 145+ }46+ return sh.Lines47+}48+49+// frozenLines counts the screen lines frozen rows take: those the filter50+// doesn't hide.51+func (g *grid) frozenLines(rows int) int {52+ n := 053+ for r := range rows {54+ if !g.sheet.RowHidden(r) {55+ n += g.span(r)56+ }57+ }58+ return n59+}60+61+// scrollLines is how many screen lines the scrolling rows have below the62+// frozen ones and their divider.63+func (g *grid) scrollLines() int {64+ fr, _ := g.frozen()65+ if fr == 0 {66+ return g.visibleRows()67+ }68+ return max(g.visibleRows()-g.frozenLines(fr)-1, 1)69+}70+71+// scrollRows is how many scrolling rows fit below the frozen ones and72+// their divider, at least one: a page, for Page Down.73+func (g *grid) scrollRows() int {74+ if !g.sheet.Shaped() {75+ return g.scrollLines()76+ }77+ n := 078+ for _, b := range g.bands() {79+ if b.row != divider && b.row >= g.top && b.shown == b.lines {80+ n++81+ }82+ }83+ return max(n, 1)84+}85+86+// bands lays out the rows shown, top to bottom: frozen rows, the87+// divider, then scrolling rows from top, skipping rows the filter hides.88+// Rows past the end of the sheet are left off.89+func (g *grid) bands() []band {90+ n := g.visibleRows()91+ out := make([]band, 0, n)92+ y := gridTop93+ add := func(r int) bool {94+ sh := g.shape(r)95+ b := band{row: r, y: y, rule: sh.Rule, lines: sh.Lines}96+ b.shown = min(sh.Lines, gridTop+n-b.top())97+ if b.shown <= 0 {98+ return false99+ }100+ out = append(out, b)101+ y = b.end()102+ return y < gridTop+n103+ }104+ fr, _ := g.frozen()105+ for r := 0; r < fr; r++ {106+ if !g.sheet.RowHidden(r) {107+ add(r)108+ }109+ }110+ if fr > 0 {111+ out = append(out, band{row: divider, y: y, shown: 1, lines: 1})112+ y++113+ }114+ r, ok := g.top, y < gridTop+n115+ if g.sheet.RowHidden(r) {116+ r, ok = g.sheet.NextShownRow(r, 1)117+ }118+ for ; ok && add(r); r, ok = g.sheet.NextShownRow(r, 1) {119+ }120+ return out121+}122+123+// screenRows returns the rows shown, top to bottom, with divider where124+// the frozen rows end.125+func (g *grid) screenRows() []int {126+ bands := g.bands()127+ out := make([]int, len(bands))128+ for i, b := range bands {129+ out[i] = b.row130+ }131+ return out132+}133+134+// rowAt returns the row on screen line y, its rule line included, or135+// false for the divider and lines outside the grid.136+func (g *grid) rowAt(y int) (int, bool) {137+ for _, b := range g.bands() {138+ if y >= b.y && y < b.end() {139+ return b.row, b.row != divider140+ }141+ }142+ return 0, false143+}144+145+// bandOf returns the band of row r, or false if it isn't on screen.146+func (g *grid) bandOf(r int) (band, bool) {147+ for _, b := range g.bands() {148+ if b.row == r {149+ return b, true150+ }151+ }152+ return band{}, false153+}154+155+// rowY returns the screen line of row r's first line of cells, or false156+// if it isn't on screen.157+func (g *grid) rowY(r int) (int, bool) {158+ b, ok := g.bandOf(r)159+ return b.top(), ok160+}161+162+// stepRow moves n visible rows from r (up for negative n), skipping163+// hidden rows and stopping at the edges of the sheet.164+func (g *grid) stepRow(r, n int) int {165+ d := 1166+ if n < 0 {167+ d, n = -1, -n168+ }169+ for ; n > 0; n-- {170+ next, ok := g.sheet.NextShownRow(r, d)171+ if !ok {172+ break173+ }174+ r = next175+ }176+ return r177+}178+179+// visibleRow returns r, or the nearest row above it the filter doesn't180+// hide (the header row always shows), so the active cell stays in the181+// data when its row is filtered out.182+func (g *grid) visibleRow(r int) int {183+ if !g.sheet.RowHidden(r) {184+ return r185+ }186+ if prev := g.stepRow(r, -1); prev != r {187+ return prev188+ }189+ return g.stepRow(r, 1)190+}191+192+// scrollTo moves the viewport the minimum amount needed to show a. Cells193+// in the frozen panes are always on screen.194+func (g *grid) scrollTo(a sheet.Addr) {195+ g.clampView()196+ fr, fc := g.frozen()197+ if a.Row >= fr {198+ if a.Row < g.top {199+ g.top = a.Row200+ } else {201+ // Walk up from a over as many rows as fit; if that passes202+ // g.top, a is already on screen.203+ r, used, room := a.Row, g.span(a.Row), g.scrollLines()204+ for r > g.top {205+ prev := g.stepRow(r, -1)206+ if prev == r || used+g.span(prev) > room {207+ break208+ }209+ r, used = prev, used+g.span(prev)210+ }211+ g.top = max(g.top, r)212+ }213+ }214+ if a.Col >= fc {215+ if a.Col < g.left {216+ g.left = a.Col217+ }218+ for a.Col >= g.left+g.visibleCols(g.left) {219+ g.left++220+ }221+ }222+ g.clampView()223+}224+225+// lineOf returns the screen line where row r starts, counting down (or226+// up) from the top of the scrolling rows, as far as limit lines past the227+// grid; rows beyond read as limit away. Charts, which float over the228+// scrolling rows, are placed with it.229+func (g *grid) lineOf(r, limit int) int {230+ y := gridTop231+ if !g.sheet.Shaped() {232+ return clamp(y+r-g.top, -limit, g.height+limit)233+ }234+ for k := g.top; k < r && y < g.height+limit; k = g.stepRow(k, 1) {235+ if next := g.stepRow(k, 1); next == k {236+ break237+ }238+ y += g.span(k)239+ }240+ for k := g.top; k > r && y > -limit; {241+ prev := g.stepRow(k, -1)242+ if prev == k {243+ break244+ }245+ k = prev246+ y -= g.span(k)247+ }248+ return y249+}250+251+// rowAtLine is the inverse of lineOf: the row whose lines hold screen252+// line y, counting from the top of the scrolling rows, for lines past253+// the grid too.254+func (g *grid) rowAtLine(y int) int {255+ if !g.sheet.Shaped() {256+ return max(g.top+y-gridTop, 0)257+ }258+ r, at := g.top, gridTop259+ for at+g.span(r) <= y {260+ next := g.stepRow(r, 1)261+ if next == r {262+ break263+ }264+ at, r = at+g.span(r), next265+ }266+ for at > y {267+ prev := g.stepRow(r, -1)268+ if prev == r {269+ break270+ }271+ r = prev272+ at -= g.span(r)273+ }274+ return r275+}276+277+// cellPos returns the screen position of a visible cell's left edge, on278+// its first line of text.279+func (g *grid) cellPos(a sheet.Addr) (x, y int) {280+ y, _ = g.rowY(a.Row)281+ return g.colStart(a.Col), y282+}283+284+// dragTarget maps a position during a drag to a cell, clamping to the285+// visible grid, and reports which way to autoscroll when it's outside.286+// Dragging from the scrolling area into the frozen panes scrolls back287+// toward them, as in Sheets, until the two meet; anchor is where the288+// drag started.289+func (g *grid) dragTarget(x, y int, anchor sheet.Addr) (a sheet.Addr, dc, dr int) {290+ bands := g.bands()291+ fr, _ := g.frozen()292+ firstRow := g.visibleRow(fr)293+ scrolledDown := g.top > firstRow294+ var last, bottom int295+ for _, b := range bands {296+ if b.row != divider {297+ last = b.row298+ }299+ bottom = b.end()300+ }301+ switch row, ok := g.rowAt(y); {302+ case ok && (row >= fr || !scrolledDown || anchor.Row < fr):303+ a.Row = row304+ case y >= bottom:305+ a.Row, dr = last, 1306+ case y >= gridTop && !scrolledDown: // the divider, with nothing scrolled307+ a.Row = g.top308+ case scrolledDown && anchor.Row >= fr:309+ a.Row, dr = g.top, -1310+ default: // above the grid311+ a.Row = bands[0].row312+ if scrolledDown {313+ dr = -1314+ }315+ }316+ a.Col, dc = g.dragCol(x, anchor)317+ return clampAddr(a), dc, dr318+}319+320+// dragCol is dragTarget's column for x.321+func (g *grid) dragCol(x int, anchor sheet.Addr) (col, dc int) {322+ _, fc := g.frozen()323+ scrolledRight := g.left > fc324+ cols := g.visibleCols(g.left)325+ right := g.colStart(g.left + cols)326+ switch c, _, ok := g.colSpan(x); {327+ case x >= right:328+ return g.left + cols - 1, 1329+ case ok && (c >= fc || !scrolledRight || anchor.Col < fc):330+ return c, 0331+ case x >= g.hdrW() && !scrolledRight:332+ return g.left, 0333+ case scrolledRight && anchor.Col >= fc:334+ return g.left, -1335+ }336+ // Over the row numbers.337+ col = 0338+ if fc == 0 {339+ col = g.left340+ }341+ if scrolledRight || fc == 0 && g.left > 0 {342+ dc = -1343+ }344+ return col, dc345+}@@ -118,9 +118,12 @@ func (g *grid) showChart(i int) {
118118 return119119 }120120 c := charts[i]121- rows := g.visibleRows()122- if c.At.Row < g.top || c.At.Row+c.H > g.top+rows {123- g.top = clamp(c.At.Row+c.H-rows, 0, c.At.Row)121+ if _, y := g.chartScreen(c); c.At.Row < g.top || y+c.H > gridTop+g.visibleRows() {122+ // The chart's bottom at the grid's, or its top at the top.123+ g.top = c.At.Row124+ for g.top > 0 && g.lineOf(c.At.Row, c.H+g.height)+c.H+g.span(g.stepRow(g.top, -1)) <= gridTop+g.visibleRows() {125+ g.top = g.stepRow(g.top, -1)126+ }124127 }125128 if c.At.Col < g.left || g.colStart(c.At.Col)+c.W > g.width {126129 g.left = c.At.Col@@ -160,7 +163,7 @@ func chartInner(c sheet.Chart) (w, h int) { return c.W - 4, c.H - 2 }
160163 // chartScreen returns where chart c's top-left corner is on screen; it161164 // may be off screen.162165 func (g *grid) chartScreen(c sheet.Chart) (x, y int) {163- return g.colStart(c.At.Col), gridTop + c.At.Row - g.top166+ return g.colStart(c.At.Col), g.lineOf(c.At.Row, c.H+g.height)164167 }165168 166169 // chartAt returns the topmost chart drawn under x, y, or -1.@@ -187,7 +187,7 @@ func (s *chartSel) release(m *Model) {
187187 // chartCellAt returns the cell a chart's corner snaps to when dragged to188188 // screen position x, y, which may be past the grid's edges.189189 func (g *grid) chartCellAt(x, y int) sheet.Addr {190- a := sheet.Addr{Row: max(g.top+y-gridTop, 0), Col: g.left}190+ a := sheet.Addr{Row: g.rowAtLine(y), Col: g.left}191191 switch {192192 case x >= g.hdrW():193193 if col, _, ok := g.colSpan(x); ok {@@ -32,6 +32,8 @@ func (m *Model) openWidth() tea.Cmd {
3232 label: "Column width (1-240):",3333 indicator: "WIDTH",3434 fresh: true,35+ resize: (*Model).setWidths,36+ maxSize: 240,3537 onText: func(m *Model, text string) tea.Cmd {3638 w, err := strconv.Atoi(text)3739 if err != nil || w < 1 || w > 240 {@@ -32,7 +32,8 @@ var (
3232 })3333 rowMenu = concat(clipboardItems, []menuItem{sep,3434 {cmd: "insert.row_above", title: "Insert row above"}, {cmd: "insert.row_below", title: "Insert row below"}, sep,35- {cmd: "delete.row", title: "Delete row"}, {cmd: "clear"},35+ {cmd: "delete.row", title: "Delete row"}, {cmd: "clear"}, sep,36+ {cmd: "row.height", title: "Resize row"}, {cmd: "row.fit"},3637 })3738 )3839 @@ -92,7 +92,7 @@ func TestRightClickHeaders(t *testing.T) {
9292 if r := m.selection(); r.From != addr("A4") || r.To.Col != sheet.MaxCols-1 {9393 t.Errorf("row header selected %s", r)9494 }95- if got := menuLabels(t, m); got != "Cut,Copy,Paste,-,Insert row above,Insert row below,-,Delete row,Clear" {95+ if got := menuLabels(t, m); got != "Cut,Copy,Paste,-,Insert row above,Insert row below,-,Delete row,Clear,-,Resize row,Fit rows to data" {9696 t.Errorf("row menu %s", got)9797 }9898 }@@ -140,6 +140,11 @@ func init() {
140140 // Ctrl+Shift+1 arrives as ctrl+! and Alt+Shift+5 as alt+%.141141 var keyAliases = func() map[string]string {142142 a := map[string]string{"alt+%": "alt+shift+5", "alt+shift+%": "alt+shift+5"}143+ for i, sym := range "!@#$^&" { // Sheets' border keys, Alt+Shift+1 to 4, 6 and 7144+ d := string("123467"[i])145+ a["alt+"+string(sym)] = "alt+shift+" + d146+ a["alt+shift+"+string(sym)] = "alt+shift+" + d147+ }143148 for i, sym := range "!@#$%^" {144149 d := string(rune('1' + i))145150 a["ctrl+"+string(sym)] = "ctrl+shift+" + d@@ -2,6 +2,7 @@ package ui
22 33 import (44 "github.com/FelineStateMachine/012/internal/sheet"5+ "github.com/FelineStateMachine/012/internal/ui/rowtext"56 )67 78 // grid is the sheet as the window shows it: which sheet, the active cell,@@ -11,10 +12,11 @@ import (
1112 // or input, which belong to Model. Model embeds it, so m.sheet and m.cur1213 // read as the model's own.1314 type grid struct {14- sheet *sheet.Sheet // the sheet shown; its workbook is the file15- cur sheet.Addr // the active cell16- top, left int // first visible scrolling row and column17- width, height int // the window15+ sheet *sheet.Sheet // the sheet shown; its workbook is the file16+ cur sheet.Addr // the active cell17+ top, left int // first visible scrolling row and column18+ width, height int // the window19+ shapes rowtext.Shapes // how the rows drawn are laid out; see bands.go1820 1921 // The selection: see selection.go.2022 selecting bool@@ -0,0 +1,222 @@
1+package ui2+3+import (4+ "strings"5+6+ "charm.land/lipgloss/v2"7+ "github.com/charmbracelet/x/ansi"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/rowtext"11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// Drawing rows that wrap, draw borders or merge cells. Borders are15+// drawn with box-drawing characters where a terminal can draw lines: a16+// vertical edge in the first column of the cell to its right, which is17+// padding, and a horizontal one on a rule line of its own above the row18+// it tops, where the lines cross in the joints the font has (see19+// theme.Junction). A terminal has no thinner lines between character20+// rows, and an underline can't draw a thick or a top edge nor meet the21+// vertical lines, so a bordered table takes a line per border, as tables22+// printed in a terminal do. Merged cells draw their top-left cell's23+// value across the merge, and no borders inside it.24+25+// lineCtx is what drawing a line of cells needs, worked out once per26+// line.27+type lineCtx struct {28+ row int29+ ln rowtext.Line30+ focus sheet.Addr31+ sel sheet.Rect32+ selecting bool33+ shaped bool // the sheet wraps, draws borders or merges34+ merges []sheet.Rect // merges crossing the row35+}36+37+func (m *Model) lineContext(row int, ln rowtext.Line) lineCtx {38+ lc := lineCtx{row: row, ln: ln, focus: m.active(), shaped: m.sheet.Shaped()}39+ lc.sel, lc.selecting = m.highlight()40+ if lc.shaped {41+ lc.merges = m.sheet.MergesIn(sheet.Rect{From: sheet.Addr{Row: row}, To: sheet.Addr{Col: sheet.MaxCols - 1, Row: row}})42+ }43+ return lc44+}45+46+// bottom reports whether the line is the row's last, where its values47+// sit.48+func (lc *lineCtx) bottom() bool { return lc.ln.K == lc.ln.N-1 }49+50+// mergeOf returns the merge on the row holding column c.51+func (lc *lineCtx) mergeOf(c int) (sheet.Rect, bool) {52+ for _, mg := range lc.merges {53+ if c >= mg.From.Col && c <= mg.To.Col {54+ return mg, true55+ }56+ }57+ return sheet.Rect{}, false58+}59+60+// merged reports whether a is in the merge whose top-left cell is focus.61+func (lc *lineCtx) merged(a, focus sheet.Addr) bool {62+ if len(lc.merges) == 0 {63+ return false64+ }65+ mg, ok := lc.mergeOf(a.Col)66+ return ok && mg.From == focus67+}68+69+// layoutLine lays out ncols columns of the line from lo, with the merges70+// crossing them drawn over.71+func (m *Model) layoutLine(lc *lineCtx, lo, ncols, minCol, maxCol int) []rowtext.Span {72+ spans := rowtext.LayoutLine(m.sheet, lc.row, lo, ncols, minCol, maxCol, lc.ln)73+ hi := lo + ncols - 174+ for _, mg := range lc.merges {75+ from, to := max(lo, mg.From.Col), min(hi, mg.To.Col)76+ if from > to {77+ continue78+ }79+ row, k := m.mergeText(mg)80+ copy(spans[from-lo:], rowtext.Merged(m.sheet, mg, from, to, row == lc.row && k == lc.ln.K))81+ }82+ return spans83+}84+85+// mergeText returns the line that shows a merge's value: the middle line86+// of its middle row among those shown.87+func (m *Model) mergeText(mg sheet.Rect) (row, k int) {88+ row = (mg.From.Row + mg.To.Row) / 289+ if m.sheet.HiddenRows() > 0 && mg.To.Row-mg.From.Row < maxMergeWalk {90+ var shown []int91+ for r := mg.From.Row; r <= mg.To.Row; r++ {92+ if !m.sheet.RowHidden(r) {93+ shown = append(shown, r)94+ }95+ }96+ if len(shown) > 0 {97+ row = shown[(len(shown)-1)/2]98+ }99+ }100+ return row, (m.shape(row).Lines - 1) / 2101+}102+103+// maxMergeWalk is the tallest merge whose shown rows are counted to find104+// its middle; a taller one takes the middle of all its rows.105+const maxMergeWalk = 10000106+107+// appendBand appends the lines of a band to lines.108+func (m *Model) appendBand(lines []string, b band) []string {109+ switch {110+ case b.row == divider:111+ return append(lines, m.dividerRow())112+ case b.row >= sheet.MaxRows:113+ return lines114+ case b.rule:115+ lines = append(lines, m.ruleLine(b.row))116+ }117+ if b.lines == 1 {118+ return append(lines, m.rowLine(b.row, rowtext.Line{N: 1}))119+ }120+ for k := range b.shown {121+ lines = append(lines, m.rowLine(b.row, rowtext.Line{K: k, N: b.lines}))122+ }123+ return lines124+}125+126+// hEdge is the line drawn along the top of the cell at a: none inside a127+// merge.128+func (m *Model) hEdge(a sheet.Addr) sheet.Line {129+ if a.Col < 0 || a.Row < 0 {130+ return sheet.LineNone131+ }132+ if mg, ok := m.sheet.MergeAt(a); ok && a.Row > mg.From.Row {133+ return sheet.LineNone134+ }135+ return m.sheet.EdgeAbove(a)136+}137+138+// vEdge is the line drawn along the left of the cell at a: none inside139+// a merge.140+func (m *Model) vEdge(a sheet.Addr) sheet.Line {141+ if a.Col < 0 || a.Row < 0 {142+ return sheet.LineNone143+ }144+ if mg, ok := m.sheet.MergeAt(a); ok && a.Col > mg.From.Col {145+ return sheet.LineNone146+ }147+ return m.sheet.EdgeLeft(a)148+}149+150+// leftEdge draws the line along the left of the cell at a over the151+// first column of its text, in the cell's colors when it has some.152+func (m *Model) leftEdge(lc *lineCtx, a sheet.Addr, text string, base lipgloss.Style, colored bool) string {153+ e := m.vEdge(a)154+ if e == sheet.LineNone {155+ return text156+ }157+ style := m.th.CellBorder158+ if colored {159+ style = base160+ }161+ return style.Render(theme.Junction(e, e, sheet.LineNone, sheet.LineNone)) + ansi.Cut(text, 1, m.sheet.ColWidth(a.Col))162+}163+164+// ruleLine draws the border line above row: each column's top edge,165+// joined where edges meet, and blank inside merges.166+func (m *Model) ruleLine(row int) string {167+ above := m.stepRow(row, -1)168+ if above == row {169+ above = -1170+ }171+ lc := m.lineContext(row, rowtext.Line{N: 1})172+ var b strings.Builder173+ b.WriteString(m.th.RowHeader.Render(strings.Repeat(" ", m.hdrW())))174+ _, fc := m.frozen()175+ if fc > 0 {176+ m.ruleCols(&b, &lc, above, 0, fc)177+ b.WriteString(m.th.FrozenLine.Render("│"))178+ }179+ m.ruleCols(&b, &lc, above, m.left, m.visibleCols(m.left))180+ return b.String()181+}182+183+// ruleCols draws the rule line of ncols columns from first.184+func (m *Model) ruleCols(b *strings.Builder, lc *lineCtx, above, first, ncols int) {185+ for c := first; c < first+ncols; c++ {186+ a := sheet.Addr{Col: c, Row: lc.row}187+ w := m.sheet.ColWidth(c)188+ style, colored := m.th.CellBorder, false189+ if lc.selecting && lc.sel.Contains(a) && lc.sel.Contains(sheet.Addr{Col: c, Row: above}) {190+ style, colored = m.th.Selection, true191+ }192+ mg, inMerge := lc.mergeOf(c)193+ inside := inMerge && lc.row > mg.From.Row194+ if inside && mg.From == lc.focus {195+ style, colored = m.th.Pointer, true196+ }197+ h := m.hEdge(a)198+ left := sheet.LineNone199+ if c > first {200+ left = m.hEdge(sheet.Addr{Col: c - 1, Row: lc.row})201+ }202+ joint := theme.Junction(m.vEdge(sheet.Addr{Col: c, Row: above}), m.vEdge(a), left, h)203+ if inside && c > mg.From.Col {204+ joint = " "205+ }206+ run := strings.Repeat(theme.Junction(sheet.LineNone, sheet.LineNone, h, h), w-1)207+ if !colored && joint == " " && h == sheet.LineNone {208+ b.WriteString(strings.Repeat(" ", w))209+ continue210+ }211+ b.WriteString(style.Render(joint + run))212+ }213+}214+215+// showRowHandle reports whether row's resize handle is visible: while216+// hovering it or dragging it.217+func (m *Model) showRowHandle(row int) bool {218+ if m.mouse.drag == dragRowResize {219+ return m.mouse.resizeRow == row220+ }221+ return m.mouse.hover.kind == hitRowBorder && m.mouse.hover.addr.Row == row222+}@@ -0,0 +1,302 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// rect parses a range for tests.13+func rect(s string) sheet.Rect {14+ r, ok := sheet.ParseRange(s)15+ if !ok {16+ panic(s)17+ }18+ return r19+}20+21+// gridLines returns the grid's screen lines, right-trimmed.22+func gridLines(m *Model) []string {23+ lines := strings.Split(screen(m), "\n")24+ out := lines[gridTop : gridTop+m.visibleRows()]25+ for i := range out {26+ out[i] = strings.TrimRight(out[i], " ")27+ }28+ return out29+}30+31+func TestWrapGrowsTheRow(t *testing.T) {32+ m := newModel()33+ press(t, m, "one two three four five", "<enter>", "<up>")34+ if got := gridLines(m)[0]; !strings.Contains(got, "one two three four five") {35+ t.Fatalf("overflow: %q", got)36+ }37+ run(m, m.runCommand("format.wrap"))38+ g := gridLines(m)39+ want := []string{"one two", "three", "four", "five"}40+ for i, w := range want {41+ if !strings.Contains(g[i], w) {42+ t.Errorf("line %d = %q, want %q", i, g[i], w)43+ }44+ }45+ // The row number sits on the row's last line, beside its values.46+ if strings.Contains(g[0], " 1 ") || !strings.HasPrefix(strings.TrimSpace(g[3]), "1 ") {47+ t.Errorf("row number: %q", g[:4])48+ }49+ if !strings.HasPrefix(strings.TrimSpace(g[4]), "2") {50+ t.Errorf("row 2 after: %q", g[4])51+ }52+ if !commands["format.wrap"].checked(m) || commands["format.wrap_clip"].checked(m) {53+ t.Error("Wrap not checked")54+ }55+ // Clip keeps the text in its column.56+ run(m, m.runCommand("format.wrap_clip"))57+ if got := gridLines(m)[0]; !strings.Contains(got, "one two t") || strings.Contains(got, "three") {58+ t.Errorf("clip: %q", got)59+ }60+ press(t, m, "<ctrl+z>")61+ if gridLines(m)[1] == "" {62+ t.Error("undo clip: not wrapped")63+ }64+}65+66+func TestRowHeightPromptAndFit(t *testing.T) {67+ m := newModel()68+ press(t, m, "x", "<enter>", "<up>")69+ run(m, m.runCommand("row.height"))70+ if m.mode != modePrompt || m.prompt.indicator != "HEIGHT" {71+ t.Fatalf("mode %v", m.mode)72+ }73+ press(t, m, "<right>", "<right>")74+ if h, _ := m.sheet.RowHeight(0); h != 3 {75+ t.Errorf("preview height %d", h)76+ }77+ press(t, m, "<enter>")78+ if m.shape(0).Lines != 3 || !strings.Contains(gridLines(m)[2], "x") {79+ t.Errorf("rows: %q", gridLines(m)[:4])80+ }81+ press(t, m, "<ctrl+z>")82+ if _, ok := m.sheet.RowHeight(0); ok {83+ t.Error("undo kept the height")84+ }85+ press(t, m, "<ctrl+y>")86+ run(m, m.runCommand("row.fit"))87+ if _, ok := m.sheet.RowHeight(0); ok || m.shape(0).Lines != 1 {88+ t.Error("fit to data")89+ }90+ run(m, m.runCommand("row.height"))91+ press(t, m, "99", "<enter>")92+ if m.mode != modeError {93+ t.Errorf("height 99 accepted: %v", m.mode)94+ }95+}96+97+func TestResizeRowByDragging(t *testing.T) {98+ m := newModel()99+ handle := m.hdrW() - 1100+ send(m, tea.MouseMotionMsg{X: handle, Y: gridTop + 1})101+ if m.mouse.hover.kind != hitRowBorder || !strings.Contains(line(m, gridTop+1), "▄") {102+ t.Fatalf("hover %v line %q", m.mouse.hover.kind, line(m, gridTop+1))103+ }104+ send(m, tea.MouseClickMsg(leftAt(handle, gridTop+1)))105+ send(m, tea.MouseMotionMsg(leftAt(handle, gridTop+3)))106+ send(m, tea.MouseReleaseMsg(leftAt(handle, gridTop+3)))107+ if h, _ := m.sheet.RowHeight(1); h != 3 {108+ t.Errorf("dragged height %d", h)109+ }110+ // Row 2's handle is now on its third line; a double click fits it.111+ click(m, handle, gridTop+3, 0)112+ click(m, handle, gridTop+3, 0)113+ if _, ok := m.sheet.RowHeight(1); ok {114+ t.Error("double-click didn't fit the row")115+ }116+}117+118+func TestBordersDraw(t *testing.T) {119+ m := newModel()120+ press(t, m, "a", "<tab>", "b", "<enter>", "c", "<tab>", "d", "<enter>")121+ m.cur = addr("A1")122+ m.selectRect(rect("A1:B2"))123+ run(m, m.runCommand("format.borders_all"))124+ g := gridLines(m)125+ for i, want := range []string{"┌─────────┬─────────┐", "│a │b │", "├─────────┼─────────┤", "│c │d │", "└─────────┴─────────┘"} {126+ if !strings.Contains(g[i], want) {127+ t.Errorf("line %d = %q, want %q", i, g[i], want)128+ }129+ }130+ run(m, m.runCommand("format.border_double"))131+ run(m, m.runCommand("format.borders_outer"))132+ if g := gridLines(m); !strings.Contains(g[0], "╔═════════╤═════════╗") || !strings.Contains(g[2], "╟─────────┼─────────╢") {133+ t.Errorf("double outer: %q", g[:3])134+ }135+ run(m, m.runCommand("format.borders_clear"))136+ if g := gridLines(m); strings.ContainsAny(strings.Join(g, ""), "─│═║") {137+ t.Errorf("cleared: %q", g[:3])138+ }139+ // Text doesn't run on across a border.140+ m.clearSelection()141+ m.cur = addr("D5")142+ press(t, m, "a long label", "<enter>")143+ m.cur = addr("E5")144+ run(m, m.runCommand("format.border_left"))145+ if got := gridLines(m)[4]; strings.Contains(got, "a long label") || !strings.Contains(got, "a long la║") {146+ t.Errorf("overflow over a border: %q", got)147+ }148+}149+150+func TestMergeCells(t *testing.T) {151+ m := newModel()152+ press(t, m, "Title", "<tab>", "lost", "<enter>")153+ m.cur = addr("A3")154+ press(t, m, "b", "<enter>", "a", "<enter>")155+ m.cur = addr("A1")156+ m.selectRect(rect("A1:C1"))157+ run(m, m.runCommand("format.merge_all"))158+ if _, ok := m.overlay.(*choiceBar); !ok || !strings.Contains(line(m, contextLine), "keeps only the top-left value") {159+ t.Fatalf("no warning: %q", line(m, contextLine))160+ }161+ press(t, m, "<enter>")162+ if mg, ok := m.sheet.MergeAt(addr("C1")); !ok || mg != rect("A1:C1") || !m.sheet.Cell(addr("B1")).Blank() {163+ t.Fatalf("merge %v %v", mg, ok)164+ }165+ // Centered across the merge.166+ if got := gridLines(m)[0]; !strings.Contains(got, strings.Repeat(" ", 12)+"Title") {167+ t.Errorf("centered: %q", got)168+ }169+ // One cell for moving, selecting and clicking.170+ if m.cur != addr("A1") || m.selection() != rect("A1:C1") || m.hasRange() {171+ t.Errorf("cur %v selection %v", m.cur, m.selection())172+ }173+ press(t, m, "<right>")174+ if m.cur != addr("D1") {175+ t.Errorf("right from the merge: %v", m.cur)176+ }177+ press(t, m, "<left>")178+ if m.cur != addr("A1") {179+ t.Errorf("left into the merge: %v", m.cur)180+ }181+ click(m, cellX(2), gridTop, 0)182+ if m.cur != addr("A1") {183+ t.Errorf("click in the merge: %v", m.cur)184+ }185+ press(t, m, "New", "<enter>")186+ if m.sheet.Value(addr("A1")).Str != "New" || m.cur != addr("A2") {187+ t.Errorf("edit: %v at %v", m.sheet.Value(addr("A1")), m.cur)188+ }189+ m.cur = addr("B4")190+ press(t, m, "<shift+up>", "<shift+up>", "<shift+up>")191+ if m.selection() != rect("A1:C4") {192+ t.Errorf("selection grows over the merge: %v", m.selection())193+ }194+ // Sorting over merged cells is refused.195+ run(m, m.runCommand("data.sort_range_az"))196+ if m.mode != modeError || !strings.Contains(m.errMsg, "merged") {197+ t.Errorf("sort: %v %q", m.mode, m.errMsg)198+ }199+ press(t, m, "<esc>")200+ m.cur = addr("B1")201+ m.clearSelection()202+ m.cur = m.snap(m.cur)203+ run(m, m.runCommand("format.unmerge"))204+ if len(m.sheet.Merges()) != 0 {205+ t.Errorf("unmerge left %v", m.sheet.Merges())206+ }207+ press(t, m, "<ctrl+z>")208+ if len(m.sheet.Merges()) != 1 {209+ t.Error("undo unmerge")210+ }211+}212+213+func TestMergeVertically(t *testing.T) {214+ m := newModel()215+ m.selectRect(rect("A1:B3"))216+ run(m, m.runCommand("format.merge_vertical"))217+ m.selectRect(rect("D2:E2"))218+ run(m, m.runCommand("format.merge_all"))219+ if len(m.sheet.Merges()) != 3 {220+ t.Fatalf("merges %v", m.sheet.Merges())221+ }222+ m.cur = addr("A1")223+ m.clearSelection()224+ press(t, m, "<down>")225+ if m.cur != addr("A4") {226+ t.Errorf("down out of a vertical merge: %v", m.cur)227+ }228+ press(t, m, "<up>")229+ if m.cur != addr("A1") {230+ t.Errorf("up into a vertical merge: %v", m.cur)231+ }232+ // A spill can't write over merged cells, and cells an array spills233+ // into can't be merged.234+ m.cur = addr("D1")235+ press(t, m, "=SEQUENCE(3)", "<enter>")236+ if m.sheet.Value(addr("D1")).Kind != sheet.Error {237+ t.Errorf("spill over merged cells: %v", m.sheet.Value(addr("D1")))238+ }239+ m.cur = addr("F1")240+ press(t, m, "=SEQUENCE(3)", "<enter>")241+ m.cur = addr("F2")242+ m.selectRect(rect("F2:G2"))243+ run(m, m.runCommand("format.merge_all"))244+ if _, merged := m.sheet.MergeAt(addr("F2")); merged || !strings.Contains(m.note, "array") {245+ t.Errorf("merged over a spill: %q", m.note)246+ }247+}248+249+func TestTallRowsScroll(t *testing.T) {250+ m := newModel()251+ for r := range 30 {252+ m.sheet.SetRowHeight(r, r, 3)253+ }254+ m.sheet.Set(addr("A30"), "end")255+ // Going down keeps the active cell on screen, a whole row at a time.256+ for range 29 {257+ press(t, m, "<down>")258+ }259+ b, ok := m.bandOf(29)260+ if !ok || b.shown != 3 {261+ t.Fatalf("row 30 shown %v %+v", ok, b)262+ }263+ if m.scrollRows() != m.visibleRows()/3 {264+ t.Errorf("a page is %d rows", m.scrollRows())265+ }266+ top := m.top267+ press(t, m, "<pgup>")268+ if m.top != top-m.scrollRows() {269+ t.Errorf("page up from %d to %d", top, m.top)270+ }271+ // Frozen rows keep their height.272+ m.sheet.SetFrozen(1, 0)273+ if m.frozenLines(1) != 3 || m.screenRows()[1] != divider {274+ t.Errorf("frozen lines %d rows %v", m.frozenLines(1), m.screenRows())275+ }276+ // A click on any line of a tall row picks it.277+ m.top = 5278+ y, _ := m.rowY(5)279+ click(m, cellX(0), y+2, 0)280+ if m.cur.Row != 5 {281+ t.Errorf("clicked row %d", m.cur.Row+1)282+ }283+}284+285+func TestRecordRowHeightDrag(t *testing.T) {286+ m := newModel()287+ run(m, m.runCommand("macro.record"))288+ handle := m.hdrW() - 1289+ send(m, tea.MouseClickMsg(leftAt(handle, gridTop)))290+ send(m, tea.MouseMotionMsg(leftAt(handle, gridTop+1)))291+ send(m, tea.MouseReleaseMsg(leftAt(handle, gridTop+1)))292+ run(m, m.runCommand("macro.stop"))293+ press(t, m, "<enter>", "<enter>")294+ if got := body(mustMacro(t, m, "Macro 1").Source); got != `set_height("1", 2)` {295+ t.Errorf("recorded %q", got)296+ }297+ r := withMacro(t, m, "Macro 1")298+ run(r, r.runMacro(mustMacro(t, r, "Macro 1")))299+ if h, _ := r.sheet.RowHeight(0); h != 2 {300+ t.Errorf("replayed height %d (%q)", h, r.warn)301+ }302+}@@ -0,0 +1,154 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Format > Wrapping, Borders and Merge cells, as in Sheets: each applies13+// to the selection as one undo step and says what it did on the context14+// line. Borders draw with the line style last chosen (thin at first),15+// as Sheets' border style picker keeps its choice.16+17+var wrappings = []struct {18+ id, title, desc string19+ w sheet.Wrap20+}{21+ {"format.wrap_overflow", "Overflow", "Let text run on into the blank cells beside it", sheet.WrapOverflow},22+ {"format.wrap", "Wrap", "Break text into lines that fit the column; the row grows to fit them", sheet.WrapOn},23+ {"format.wrap_clip", "Clip", "Cut text off at the cell's edge", sheet.WrapClip},24+}25+26+var borderKinds = []struct {27+ id, title, desc string28+ k sheet.BorderKind29+}{30+ {"format.borders_all", "All borders", "Draw every edge of the selected cells", sheet.BorderAll},31+ {"format.borders_outer", "Outer borders", "Draw a line around the selection", sheet.BorderOuter},32+ {"format.borders_inner", "Inner borders", "Draw the lines between the selected cells", sheet.BorderInner},33+ {"format.border_top", "Top border", "Draw a line along the top of the selection", sheet.BorderTop},34+ {"format.border_bottom", "Bottom border", "Draw a line along the bottom of the selection", sheet.BorderBottom},35+ {"format.border_left", "Left border", "Draw a line down the left of the selection", sheet.BorderLeft},36+ {"format.border_right", "Right border", "Draw a line down the right of the selection", sheet.BorderRight},37+ {"format.borders_clear", "Clear borders", "Remove the lines of the selected cells", sheet.BorderNone},38+}39+40+var borderLines = []struct {41+ id, title, desc string42+ l sheet.Line43+}{44+ {"format.border_thin", "Thin lines", "Draw borders with thin lines: ─", sheet.LineThin},45+ {"format.border_thick", "Thick lines", "Draw borders with thick lines: ━", sheet.LineThick},46+ {"format.border_double", "Double lines", "Draw borders with double lines: ═", sheet.LineDouble},47+}48+49+var mergeKinds = []struct {50+ id, title, desc string51+ k sheet.MergeKind52+}{53+ {"format.merge_all", "Merge all", "Join the selection into one cell showing its top-left value", sheet.MergeAll},54+ {"format.merge_horizontal", "Merge horizontally", "Join each row of the selection into one cell", sheet.MergeHorizontally},55+ {"format.merge_vertical", "Merge vertically", "Join each column of the selection into one cell", sheet.MergeVertically},56+}57+58+func init() {59+ for _, wr := range wrappings {60+ w, title := wr.w, wr.title61+ register(&command{id: wr.id, title: title, desc: wr.desc, edits: (*Model).selection, keepsSpills: true,62+ checked: func(m *Model) bool { return m.sheet.CellStyle(m.cur).Wrap == w },63+ run: func(m *Model) tea.Cmd {64+ r := m.selection()65+ m.sheet.Batch(sheet.Change{Label: strings.ToLower(title) + " " + r.String(), Focus: r}, func() error {66+ m.sheet.SetStyle(r, func(s *sheet.Style) { s.Wrap = w })67+ return nil68+ })69+ m.formatted("Wrapping: " + strings.ToLower(title))70+ return nil71+ }})72+ }73+ for _, bk := range borderKinds {74+ k, title := bk.k, bk.title75+ register(&command{id: bk.id, title: title, desc: bk.desc, edits: (*Model).selection, keepsSpills: true,76+ run: func(m *Model) tea.Cmd {77+ m.sheet.SetBorders(m.selection(), k, m.lineStyle())78+ m.formatted(title)79+ return nil80+ }})81+ }82+ for _, bl := range borderLines {83+ l := bl.l84+ register(&command{id: bl.id, title: bl.title, desc: bl.desc,85+ checked: func(m *Model) bool { return m.lineStyle() == l },86+ run: func(m *Model) tea.Cmd {87+ m.borderLine = l88+ m.note = "Borders draw " + strings.ToLower(bl.title) + " now"89+ return nil90+ }})91+ }92+ for _, mk := range mergeKinds {93+ k := mk.k94+ register(&command{id: mk.id, title: mk.title, desc: mk.desc, edits: (*Model).selection,95+ enabled: (*Model).hasRange,96+ run: func(m *Model) tea.Cmd { return m.merge(k) }})97+ }98+ register(&command{id: "format.unmerge", title: "Unmerge", desc: "Split the merged cells in the selection back into cells", edits: (*Model).selection, keepsSpills: true,99+ enabled: func(m *Model) bool { return len(m.sheet.MergesIn(m.selection())) > 0 },100+ run: func(m *Model) tea.Cmd {101+ n := m.sheet.Unmerge(m.selection())102+ m.changed = m.changed || n > 0103+ m.note = "Unmerged " + strconv.Itoa(n) + plural(n, " merged cell", " merged cells")104+ return nil105+ }})106+ // Sheets' border shortcuts; terminals without the kitty keyboard107+ // protocol send Alt+Shift+1 as alt+!, which keyAliases maps.108+ for key, id := range map[string]string{109+ "alt+shift+1": "format.border_top",110+ "alt+shift+2": "format.border_right",111+ "alt+shift+3": "format.border_bottom",112+ "alt+shift+4": "format.border_left",113+ "alt+shift+6": "format.borders_clear",114+ "alt+shift+7": "format.borders_outer",115+ } {116+ keymap[key] = id117+ }118+}119+120+// lineStyle is the line Format > Borders draws with.121+func (m *Model) lineStyle() sheet.Line {122+ if m.borderLine == sheet.LineNone {123+ return sheet.LineThin124+ }125+ return m.borderLine126+}127+128+// merge joins the selection as k says, asking first when that clears129+// values other than the top-left ones, as Sheets warns.130+func (m *Model) merge(k sheet.MergeKind) tea.Cmd {131+ r := m.selection()132+ do := func(m *Model) tea.Cmd {133+ if err := m.sheet.Merge(r, k); err != nil {134+ m.fail(err.Error())135+ return nil136+ }137+ m.changed = true138+ m.clearSelection()139+ m.cur = r.From140+ m.note = "Merged " + r.String()141+ return nil142+ }143+ lost, loses := m.sheet.MergeLoses(r, k)144+ if !loses {145+ return do(m)146+ }147+ m.openOverlay(&choiceBar{m: m, msg: "Merging keeps only the top-left value; " + lost.String() + " and others are cleared.", warn: true,148+ desc: "Undo brings the values back",149+ choices: []choice{150+ {key: "enter", label: "Merge anyway", run: do},151+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},152+ }})153+ return nil154+}@@ -4,6 +4,7 @@ import (
44 "errors"55 "fmt"66 "slices"7+ "strconv"78 "strings"89 910 "github.com/FelineStateMachine/012/internal/sheet"@@ -214,6 +215,23 @@ func (h scriptHost) SetWidth(cols string, width int) error {
214215 return nil215216 }216217 218+func (h scriptHost) SetHeight(rows string, height int) error {219+ r, ok := parseWhole(rows)220+ if !ok {221+ if n, err := strconv.Atoi(strings.TrimSpace(rows)); err == nil && n >= 1 && n <= sheet.MaxRows {222+ r, ok = sheet.Rect{From: sheet.Addr{Row: n - 1}, To: sheet.Addr{Row: n - 1}}, true223+ }224+ }225+ if !ok {226+ return fmt.Errorf("not a row or rows: %s", rows)227+ }228+ if height < 0 || height > sheet.MaxRowHeight {229+ return fmt.Errorf("row height must be between 1 and %d lines, or 0 to fit", sheet.MaxRowHeight)230+ }231+ h.m.sheet.SetRowHeight(r.From.Row, r.To.Row, height)232+ return nil233+}234+217235 func (h scriptHost) Fill(to string, rows, cols int) error {218236 m := h.m219237 src := m.selection()@@ -57,7 +57,7 @@ func (g *grid) selState() selState {
5757 }5858 5959 func (s selState) rect() sheet.Rect {60- return (&grid{cur: s.cur, ext: s.ext, selecting: s.selecting, whole: s.whole}).selection()60+ return (&grid{cur: s.cur, ext: s.ext, selecting: s.selecting, whole: s.whole}).corners()6161 }6262 6363 // startRecording starts recording, from the selection as it is.@@ -224,7 +224,8 @@ func (m *Model) recordMove(key string) bool {
224224 // observe follows an input event while recording: with relative225225 // references a new sheet shown is recorded right away (where its226226 // selection starts can't be told later), and a change to the workbook227-// nothing recorded is noted as a comment.227+// nothing recorded is noted as a comment. A border being dragged is228+// recorded when it's let go.228229 func (m *Model) observeRecording(before int) {229230 r := m.rec230231 if r == nil || m.mode != modeReady && m.mode != modeError || m.overlay != nil || m.prompt != nil {@@ -233,7 +234,7 @@ func (m *Model) observeRecording(before int) {
233234 if r.relative && m.sheet != r.base.sheet {234235 r.add(m, macro.Call("activate_sheet", m.sheet.Name()))235236 }236- if m.sheet.StateID() != before && !r.acted {237+ if resizing := m.mouse.drag == dragResize || m.mouse.drag == dragRowResize; m.sheet.StateID() != before && !r.acted && !resizing {237238 what := cmp.Or(m.note, m.book().UndoLabel(), "a change")238239 r.actions = append(r.actions, macro.Note("Not recorded: "+strings.ToLower(what[:1])+what[1:]))239240 }@@ -82,8 +82,22 @@ var menuBar = []menuDef{
8282 }},8383 {cmd: "format.decimals_more", title: "Increase decimal places"}, {cmd: "format.decimals_less", title: "Decrease decimal places"}, sep,8484 {cmd: "format.bold", title: "Bold"}, {cmd: "format.italic", title: "Italic"}, {cmd: "format.underline", title: "Underline"}, {cmd: "format.strikethrough", title: "Strikethrough"}, sep,85- {cmd: "format.align_left", title: "Align left"}, {cmd: "format.align_center", title: "Align center"}, {cmd: "format.align_right", title: "Align right"}, sep,86- {cmd: "column.width"}, {cmd: "column.reset"}, sep,85+ {cmd: "format.align_left", title: "Align left"}, {cmd: "format.align_center", title: "Align center"}, {cmd: "format.align_right", title: "Align right"},86+ {title: "Wrapping", items: []menuItem{87+ {cmd: "format.wrap_overflow"}, {cmd: "format.wrap"}, {cmd: "format.wrap_clip"},88+ }},89+ {title: "Borders", items: []menuItem{90+ {cmd: "format.borders_all", title: "All"}, {cmd: "format.borders_outer", title: "Outer"}, {cmd: "format.borders_inner", title: "Inner"}, sep,91+ {cmd: "format.border_top", title: "Top"}, {cmd: "format.border_bottom", title: "Bottom"},92+ {cmd: "format.border_left", title: "Left"}, {cmd: "format.border_right", title: "Right"}, sep,93+ {cmd: "format.borders_clear", title: "None"}, sep,94+ {cmd: "format.border_thin"}, {cmd: "format.border_thick"}, {cmd: "format.border_double"},95+ }},96+ {title: "Merge cells", items: []menuItem{97+ {cmd: "format.merge_all"}, {cmd: "format.merge_horizontal"}, {cmd: "format.merge_vertical"}, sep,98+ {cmd: "format.unmerge"},99+ }}, sep,100+ {cmd: "column.width"}, {cmd: "column.reset"}, {cmd: "row.height"}, {cmd: "row.fit"}, sep,87101 {cmd: "format.conditional"}, {cmd: "format.conditional_clear"}, sep,88102 {cmd: "format.clear", title: "Clear formatting"},89103 }},@@ -151,6 +165,17 @@ type barMenu struct {
151165 x, w int // the title's span, including one column of padding each side152166 }153167 168+// anyVisible reports whether visibleItems would keep any of items,169+// without making the list: the menu bar asks every frame.170+func anyVisible(items []menuItem) bool {171+ for _, it := range items {172+ if it.items != nil && anyVisible(it.items) || !it.sep && it.items == nil && commands[it.cmd] != nil {173+ return true174+ }175+ }176+ return false177+}178+154179 // barMenus lays out the menus that have visible items. Titles are155180 // separated by two spaces, as elsewhere in the UI.156181 func barMenus() []barMenu {@@ -158,7 +183,7 @@ func barMenus() []barMenu {
158183 x := 0159184 for i := range menuBar {160185 d := &menuBar[i]161- if len(visibleItems(d.items)) == 0 {186+ if !anyVisible(d.items) {162187 continue163188 }164189 w := len(d.title) + 2@@ -84,10 +84,12 @@ type Model struct {
8484 recovered string // the recovery file restored into this book, removed once it's saved: recovery.go8585 8686 mode mode87- protectOK bool // an edit to a protected range was agreed to: protect.go88- note string // feedback on the last action, e.g. "Undid: clear B3"89- warn string // like note, for something that went wrong, e.g. a macro's error90- errMsg string // the message ERROR mode shows87+ protectOK bool // an edit to a protected range was agreed to: protect.go88+ // borderLine is the line Format > Borders draws with: layoutfmt.go.89+ borderLine sheet.Line90+ note string // feedback on the last action, e.g. "Undid: clear B3"91+ warn string // like note, for something that went wrong, e.g. a macro's error92+ errMsg string // the message ERROR mode shows9193 9294 // Components. Each owns its state and the handling of the input it9395 // takes; Model routes messages to them and composes what they draw.@@ -25,6 +25,7 @@ type mouseState struct {
2525 x, y int // last position, for autoscroll2626 autoscrolling bool // an autoscroll tick is pending2727 resizeCol int // the column being resized by its header border28+ resizeRow int // the row being resized by its number's border2829 shape string // the pointer shape last sent to the terminal2930 3031 // A fill handle drag, see fill.go: where it points, and the range it@@ -42,6 +43,7 @@ const (
4243 hitColHeader4344 hitColBorder // the resize handle at the right edge of a column header4445 hitRowHeader46+ hitRowBorder // the resize handle at the bottom right of a row number4547 hitCell4648 hitFormulaBar // the active cell's contents in READY mode4749 hitEditLine // the entry being typed@@ -98,22 +100,7 @@ func (m *Model) hitTest(x, y int) hit {
98100 }99101 return hit{kind: kind, addr: sheet.Addr{Col: col, Row: m.top}}100102 case y >= gridTop && y < gridTop+m.visibleRows():101- row, ok := m.rowAt(y)102- if !ok {103- return hit{}104- }105- if x < m.hdrW() {106- return hit{kind: hitRowHeader, addr: sheet.Addr{Col: m.left, Row: row}}107- }108- col, start, ok := m.colSpan(x)109- if !ok {110- return hit{}111- }112- a := sheet.Addr{Col: col, Row: row}113- if m.mode == modeReady && a == m.fillCorner() && x == start+m.sheet.ColWidth(col)-1 {114- return hit{kind: hitFillHandle, addr: a}115- }116- return hit{kind: m.ruleHit(a, x, start), addr: a}103+ return m.gridHit(x, y)117104 case y == m.height-1:118105 if h, ok := m.tabAt(x); ok {119106 return h@@ -123,6 +110,31 @@ func (m *Model) hitTest(x, y int) hit {
123110 return hit{}124111 }125112 113+// gridHit is hitTest over the grid's rows: a row's number, the resize114+// handle at its corner, or a cell.115+func (m *Model) gridHit(x, y int) hit {116+ row, ok := m.rowAt(y)117+ if !ok {118+ return hit{}119+ }120+ if x < m.hdrW() {121+ kind := hitRowHeader122+ if b, _ := m.bandOf(row); x == m.hdrW()-1 && y == b.end()-1 && b.shown == b.lines {123+ kind = hitRowBorder124+ }125+ return hit{kind: kind, addr: sheet.Addr{Col: m.left, Row: row}}126+ }127+ col, start, ok := m.colSpan(x)128+ if !ok {129+ return hit{}130+ }131+ a := sheet.Addr{Col: col, Row: row}132+ if m.mode == modeReady && a == m.fillCorner() && x == start+m.sheet.ColWidth(col)-1 {133+ return hit{kind: hitFillHandle, addr: a}134+ }135+ return hit{kind: m.ruleHit(a, x, start), addr: a}136+}137+126138 // dragAnchor is where the drag in progress started.127139 func (m *Model) dragAnchor() sheet.Addr {128140 if m.mouse.drag == dragPoint {@@ -228,15 +240,21 @@ func (m *Model) readyPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
228240 return nil229241 }230242 m.mouse.resizeCol, m.mouse.drag = h.addr.Col, dragResize243+ case hitRowBorder:244+ if double {245+ m.fitRows(h.addr.Row)246+ return nil247+ }248+ m.mouse.resizeRow, m.mouse.drag = h.addr.Row, dragRowResize231249 case hitCell:232250 switch {233251 case shift:234252 m.selecting, m.whole, m.ext = true, wholeNone, h.addr235- case double && h.addr == m.cur:253+ case double && m.snap(h.addr) == m.cur:236254 m.clearSelection()237255 return m.runCommand("edit")238256 default:239- m.cur = h.addr257+ m.cur = m.snap(h.addr)240258 m.clearSelection()241259 }242260 m.mouse.drag = dragCells@@ -277,6 +295,13 @@ func (m *Model) dragTo(x, y int) tea.Cmd {
277295 m.sheet.SetColWidth(m.mouse.resizeCol, clamp(x-m.colStart(m.mouse.resizeCol)+1, 1, 240))278296 m.changed = true279297 return nil298+ case dragRowResize:299+ if b, ok := m.bandOf(m.mouse.resizeRow); ok {300+ r := m.mouse.resizeRow301+ m.sheet.SetRowHeight(r, r, clamp(y-b.top()+1, 1, sheet.MaxRowHeight))302+ m.changed = true303+ }304+ return nil280305 case dragTab:281306 return nil // handleMotion tracks the tab under the mouse282307 }@@ -309,7 +334,7 @@ func autoscrollTick() tea.Cmd {
309334 // handleAutoscroll moves a drag that's past the edge of the grid one more310335 // step and keeps ticking until the mouse comes back or is released.311336 func (m *Model) handleAutoscroll() tea.Cmd {312- if m.mouse.drag == dragNone || m.mouse.drag == dragResize || m.mouse.drag == dragTab {337+ if m.mouse.drag == dragNone || m.mouse.drag == dragResize || m.mouse.drag == dragRowResize || m.mouse.drag == dragTab {313338 m.mouse.autoscrolling = false314339 return nil315340 }@@ -344,6 +369,9 @@ func (m *Model) handleRelease() tea.Cmd {
344369 m.dropTab()345370 case dragResize:346371 m.record(widthAction(m.mouse.resizeCol, m.mouse.resizeCol, m.sheet.ColWidth(m.mouse.resizeCol)))372+ case dragRowResize:373+ h, _ := m.sheet.RowHeight(m.mouse.resizeRow)374+ m.record(heightAction(m.mouse.resizeRow, m.mouse.resizeRow, h))347375 }348376 m.mouse.drag, m.mouse.autoscrolling = dragNone, false349377 if m.selecting && m.whole == wholeNone && m.ext == m.cur {@@ -360,6 +388,8 @@ func (m *Model) pointerShape() tea.Cmd {
360388 switch {361389 case m.mouse.drag == dragResize, m.mouse.hover.kind == hitColBorder:362390 shape = "col-resize"391+ case m.mouse.drag == dragRowResize, m.mouse.hover.kind == hitRowBorder:392+ shape = "row-resize"363393 case m.mouse.drag == dragFill, m.mouse.hover.kind == hitFillHandle:364394 shape = "crosshair"365395 case m.mode == modeReady && m.chartAt(m.mouse.x, m.mouse.y) >= 0:@@ -29,13 +29,11 @@ func (m *Model) View() tea.View {
2929 defer m.timeFrame(time.Now())3030 }3131 lines := []string{m.menuBarLine(), m.formulaBar(), m.contextLineText(), m.headerRow()}32- rows := m.screenRows()33- for i := range m.visibleRows() {34- if i < len(rows) {35- lines = append(lines, m.gridRow(rows[i]))36- } else {37- lines = append(lines, "")38- }32+ for _, b := range m.bands() {33+ lines = m.appendBand(lines, b)34+ }35+ for len(lines) < gridTop+m.visibleRows() {36+ lines = append(lines, "")3937 }4038 lines = append(lines, m.statusLine())4139 for i, l := range lines {@@ -32,57 +32,6 @@ func (g *grid) frozen() (rows, cols int) {
3232 return rows, cols3333 }3434 35-// frozenLines counts the screen lines frozen rows take: those the filter36-// doesn't hide.37-func (g *grid) frozenLines(rows int) int {38- n := 039- for r := range rows {40- if !g.sheet.RowHidden(r) {41- n++42- }43- }44- return n45-}46-47-// scrollRows is how many scrolling rows fit below the frozen ones and48-// their divider.49-func (g *grid) scrollRows() int {50- fr, _ := g.frozen()51- if fr == 0 {52- return g.visibleRows()53- }54- return max(g.visibleRows()-g.frozenLines(fr)-1, 1)55-}56-57-// divider marks the line between frozen and scrolling rows in58-// screenRows.59-const divider = -160-61-// screenRows returns the row shown on each grid line, top to bottom:62-// frozen rows, the divider, then scrolling rows from top, skipping63-// rows the filter hides. Rows past the end of the sheet are left off.64-func (g *grid) screenRows() []int {65- n := g.visibleRows()66- out := make([]int, 0, n)67- fr, _ := g.frozen()68- for r := 0; r < fr; r++ {69- if !g.sheet.RowHidden(r) {70- out = append(out, r)71- }72- }73- if fr > 0 {74- out = append(out, divider)75- }76- r, ok := g.top, true77- if g.sheet.RowHidden(r) {78- r, ok = g.sheet.NextShownRow(r, 1)79- }80- for ; ok && len(out) < n; r, ok = g.sheet.NextShownRow(r, 1) {81- out = append(out, r)82- }83- return out84-}85-8635 // hdrW is the width of the row numbers: the widest number on screen with8736 // a space either side, and at least minRowHdrW, so it widens past row8837 // 9999 as Excel's does.@@ -102,56 +51,6 @@ func (g *grid) hdrW() int {
10251 return max(minRowHdrW, len(strconv.Itoa(min(last, sheet.MaxRows)))+2)10352 }10453 105-// rowAt returns the row on screen line y, or false for the divider and106-// lines outside the grid.107-func (g *grid) rowAt(y int) (int, bool) {108- rows := g.screenRows()109- if i := y - gridTop; i >= 0 && i < len(rows) && rows[i] != divider {110- return rows[i], true111- }112- return 0, false113-}114-115-// rowY returns the screen line of row r, or false if it isn't on screen.116-func (g *grid) rowY(r int) (int, bool) {117- for i, row := range g.screenRows() {118- if row == r {119- return gridTop + i, true120- }121- }122- return 0, false123-}124-125-// stepRow moves n visible rows from r (up for negative n), skipping126-// hidden rows and stopping at the edges of the sheet.127-func (g *grid) stepRow(r, n int) int {128- d := 1129- if n < 0 {130- d, n = -1, -n131- }132- for ; n > 0; n-- {133- next, ok := g.sheet.NextShownRow(r, d)134- if !ok {135- break136- }137- r = next138- }139- return r140-}141-142-// visibleRow returns r, or the nearest row above it the filter doesn't143-// hide (the header row always shows), so the active cell stays in the144-// data when its row is filtered out.145-func (g *grid) visibleRow(r int) int {146- if !g.sheet.RowHidden(r) {147- return r148- }149- if prev := g.stepRow(r, -1); prev != r {150- return prev151- }152- return g.stepRow(r, 1)153-}154-15554 // scrollX is the screen x where the scrolling columns start: after the15655 // row numbers, the frozen columns and their divider.15756 func (g *grid) scrollX() int {@@ -174,36 +73,6 @@ func (g *grid) clampView() {
17473 g.left = clamp(g.left, fc, sheet.MaxCols-1)17574 }17675 177-// scrollTo moves the viewport the minimum amount needed to show a. Cells178-// in the frozen panes are always on screen.179-func (g *grid) scrollTo(a sheet.Addr) {180- g.clampView()181- fr, fc := g.frozen()182- if a.Row >= fr {183- if a.Row < g.top {184- g.top = a.Row185- } else {186- // Walk up from a over as many visible rows as fit; if that187- // passes g.top, a is already on screen.188- r, n := a.Row, 1189- for r > g.top && n < g.scrollRows() {190- r = g.stepRow(r, -1)191- n++192- }193- g.top = max(g.top, r)194- }195- }196- if a.Col >= fc {197- if a.Col < g.left {198- g.left = a.Col199- }200- for a.Col >= g.left+g.visibleCols(g.left) {201- g.left++202- }203- }204- g.clampView()205-}206-20776 func (g *grid) visibleRows() int {20877 return max(g.height-gridTop-1, 1)20978 }@@ -270,69 +139,8 @@ func (g *grid) colStart(c int) int {
270139 return x271140 }272141 273-// cellPos returns the screen position of a visible cell's left edge.274-func (g *grid) cellPos(a sheet.Addr) (x, y int) {275- y, _ = g.rowY(a.Row)276- return g.colStart(a.Col), y277-}278-279142 // clampBox keeps a w by h box at x, y on screen, shifting it left and up280143 // as needed.281144 func (g *grid) clampBox(x, y, w, h int) (int, int) {282145 return max(min(x, g.width-w), 0), max(min(y, g.height-h), 0)283146 }284-285-// dragTarget maps a position during a drag to a cell, clamping to the286-// visible grid, and reports which way to autoscroll when it's outside.287-// Dragging from the scrolling area into the frozen panes scrolls back288-// toward them, as in Sheets, until the two meet; anchor is where the289-// drag started.290-func (g *grid) dragTarget(x, y int, anchor sheet.Addr) (a sheet.Addr, dc, dr int) {291- rows := g.screenRows()292- fr, fc := g.frozen()293- firstRow := g.visibleRow(fr)294- scrolledDown := g.top > firstRow295- var last int296- for _, r := range rows {297- if r != divider {298- last = r299- }300- }301- switch row, ok := g.rowAt(y); {302- case ok && (row >= fr || !scrolledDown || anchor.Row < fr):303- a.Row = row304- case y >= gridTop+len(rows):305- a.Row, dr = last, 1306- case y >= gridTop && !scrolledDown: // the divider, with nothing scrolled307- a.Row = g.top308- case scrolledDown && anchor.Row >= fr:309- a.Row, dr = g.top, -1310- default: // above the grid311- a.Row = rows[0]312- if scrolledDown {313- dr = -1314- }315- }316- scrolledRight := g.left > fc317- cols := g.visibleCols(g.left)318- right := g.colStart(g.left + cols)319- switch col, _, ok := g.colSpan(x); {320- case x >= right:321- a.Col, dc = g.left+cols-1, 1322- case ok && (col >= fc || !scrolledRight || anchor.Col < fc):323- a.Col = col324- case x >= g.hdrW() && !scrolledRight:325- a.Col = g.left326- case scrolledRight && anchor.Col >= fc:327- a.Col, dc = g.left, -1328- default: // over the row numbers329- a.Col = 0330- if fc == 0 {331- a.Col = g.left332- }333- if scrolledRight || fc == 0 && g.left > 0 {334- dc = -1335- }336- }337- return clampAddr(a), dc, dr338-}@@ -21,7 +21,7 @@ type promptKind int
2121 const (2222 promptText promptKind = iota2323 promptRange24- promptWidth24+ promptWidth // a size: digits only, and Left/Right preview one less or more2525 )2626 2727 // prompt is a question on the context line, such as a file name or a@@ -37,7 +37,10 @@ type prompt struct {
3737 onText func(m *Model, text string) tea.Cmd3838 onRange func(m *Model, r sheet.Rect) tea.Cmd3939 onCancel func(m *Model)40- files []string // the file list shown by File Open40+ // resize previews a size prompt's answer, from 1 to maxSize.41+ resize func(m *Model, n int)42+ maxSize int43+ files []string // the file list shown by File Open4144 }4245 4346 // pointing reports whether a range prompt is taking the arrows.@@ -150,11 +153,11 @@ func (p *prompt) stepWidth(m *Model, key string) {
150153 } else {151154 w++152155 }153- w = clamp(w, 1, 240)156+ w = clamp(w, 1, p.maxSize)154157 m.line.Clear()155158 p.fresh = false156159 m.line.Insert(strconv.Itoa(w))157- m.setWidths(w) // live preview160+ p.resize(m, w)158161 }159162 160163 // typeKey edits the answer; the first key typed replaces the default.@@ -0,0 +1,109 @@
1+package ui2+3+import (4+ "strconv"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/macro"9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Row heights, in lines: typed on the context line with a live preview,13+// dragged by the bottom-right corner of a row's number (mouse.go), or14+// fitted to the contents again by double-clicking it, as Sheets' Resize15+// row offers a height or Fit to data. A row without a height fits the16+// text it wraps.17+18+func init() {19+ register(20+ &command{id: "row.height", title: "Row height", desc: "Set the height of the selected rows, in lines", run: (*Model).openHeight},21+ &command{id: "row.fit", title: "Fit rows to data", desc: "Size the selected rows to their contents again",22+ run: func(m *Model) tea.Cmd {23+ r := m.selection()24+ m.fitRange(r.From.Row, r.To.Row)25+ return nil26+ }},27+ )28+}29+30+// openHeight asks for the height of the selected rows, previewing it31+// live as the arrows change it. Esc restores the original heights.32+func (m *Model) openHeight() tea.Cmd {33+ r := m.selection()34+ orig := map[int]int{}35+ for row, h := range m.sheet.Heights() {36+ if row >= r.From.Row && row <= r.To.Row {37+ orig[row] = h38+ }39+ }40+ restore := func(m *Model) {41+ m.sheet.Batch(sheet.Change{Label: "row height", Focus: r}, func() error {42+ m.sheet.SetRowHeight(r.From.Row, r.To.Row, 0)43+ for row, h := range orig {44+ m.sheet.SetRowHeight(row, row, h)45+ }46+ return nil47+ })48+ }49+ cur := m.shape(m.cur.Row).Lines50+ m.openPrompt(&prompt{51+ kind: promptWidth,52+ label: "Row height (1-" + strconv.Itoa(sheet.MaxRowHeight) + " lines):",53+ indicator: "HEIGHT",54+ fresh: true,55+ resize: func(m *Model, h int) { m.sheet.SetRowHeight(r.From.Row, r.To.Row, h) },56+ maxSize: sheet.MaxRowHeight,57+ onText: func(m *Model, text string) tea.Cmd {58+ h, err := strconv.Atoi(text)59+ if err != nil || h < 1 || h > sheet.MaxRowHeight {60+ restore(m)61+ m.fail("Row height must be between 1 and " + strconv.Itoa(sheet.MaxRowHeight) + " lines")62+ return nil63+ }64+ m.sheet.SetRowHeight(r.From.Row, r.To.Row, h)65+ m.changed = true66+ return nil67+ },68+ onCancel: restore,69+ }, strconv.Itoa(cur))70+ return nil71+}72+73+// fitRows fits row r to its contents, as double-clicking the border of74+// its number does in Sheets; selected whole rows fit together.75+func (m *Model) fitRows(r int) {76+ from, to := r, r77+ if sel := m.selection(); m.whole == wholeRows && r >= sel.From.Row && r <= sel.To.Row {78+ from, to = sel.From.Row, sel.To.Row79+ }80+ m.fitRange(from, to)81+ m.record(heightAction(from, to, 0))82+}83+84+// fitRange clears the heights of rows from..to, only those that have85+// one, so rows fit their contents.86+func (m *Model) fitRange(from, to int) {87+ m.sheet.Batch(sheet.Change{Label: "fit rows to data", Focus: sheet.Rect{From: sheet.Addr{Row: from}, To: sheet.Addr{Col: sheet.MaxCols - 1, Row: to}}}, func() error {88+ for row := range m.sheet.Heights() {89+ if row >= from && row <= to {90+ m.sheet.SetRowHeight(row, row, 0)91+ }92+ }93+ return nil94+ })95+ m.changed = true96+ m.note = "Rows " + strconv.Itoa(from+1) + ":" + strconv.Itoa(to+1) + " fit their contents"97+ if from == to {98+ m.note = "Row " + strconv.Itoa(from+1) + " fits its contents"99+ }100+}101+102+// heightAction records setting rows from..to to h lines, 0 to fit.103+func heightAction(from, to, h int) macro.Action {104+ rows := strconv.Itoa(from + 1)105+ if to != from {106+ rows += ":" + strconv.Itoa(to+1)107+ }108+ return macro.Call("set_height", rows, h)109+}@@ -41,7 +41,20 @@ type Span struct {
4141 // It can come from cells outside the viewport, so the scan starts at the4242 // nearest filled cell on each side.4343 func Layout(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int) []Span {44- l := rowLayout{s: s, row: row, lo: lo, hi: lo + ncols - 1, out: make([]Span, ncols)}44+ return LayoutLine(s, row, lo, ncols, minCol, maxCol, Line{N: 1})45+}46+47+// Line is one line of a row: the K-th of its N, from 0 at the top.48+type Line struct{ K, N int }49+50+// LayoutLine is Layout for one line of a row N lines tall. Values sit on51+// the row's last line, as Sheets aligns them to the bottom, and text52+// that wraps (sheet.WrapOn) fills lines of its own column up to it, cut53+// at the top when the row is too short. Clipped text (sheet.WrapClip)54+// stays in its column. Text doesn't run on across a border or into55+// merged cells, whose contents the caller draws (see Merged).56+func LayoutLine(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int, ln Line) []Span {57+ l := rowLayout{s: s, row: row, lo: lo, hi: lo + ncols - 1, out: make([]Span, ncols), ln: ln, shaped: s.Shaped()}4558 for i := range l.out {4659 l.out[i] = Span{Trail: s.ColWidth(lo + i)}4760 }@@ -69,6 +82,8 @@ type rowLayout struct {
6982 x []int7083 claimed int // text of earlier cells reaches up to here7184 out []Span85+ ln Line86+ shaped bool // the sheet wraps, draws borders or merges: see sheet.Shaped7287 }7388 7489 // content is the cell in column c of the row, or nil if it's blank.@@ -101,11 +116,38 @@ func (l *rowLayout) reach(minCol, maxCol int) (first, last int) {
101116 return first, last102117 }103118 119+// free reports whether text may run on into column k from the column120+// on side (-1 left, 1 right): it's blank, not merged, and no border121+// lies between.122+func (l *rowLayout) free(k, side int) bool {123+ if l.content(k) != nil {124+ return false125+ }126+ if !l.shaped {127+ return true128+ }129+ a := sheet.Addr{Col: k, Row: l.row}130+ edge := a131+ if side < 0 {132+ edge.Col++133+ }134+ _, merged := l.s.MergeAt(a)135+ return !merged && l.s.EdgeLeft(edge) == sheet.LineNone136+}137+104138 // place lays out the text of the cell in column c over the columns it105139 // reaches.106140 func (l *rowLayout) place(c int, cell *sheet.Cell) {107141 x0, x1 := l.col(c)108142 text, align, pad := l.display(c, cell, x1-x0)143+ style := l.s.CellStyle(sheet.Addr{Col: c, Row: l.row})144+ own := style.Wrap != sheet.WrapOverflow // the text stays in its column145+ if l.shaped {146+ var show bool147+ if text, show = l.shape(c, cell, style, text, x1-x0); !show {148+ return149+ }150+ }109151 tw := ansi.StringWidth(text)110152 start := x0 + pad111153 switch align {@@ -116,10 +158,12 @@ func (l *rowLayout) place(c int, cell *sheet.Cell) {
116158 case sheet.AlignCenter:117159 start = x0 + (x1-x0-tw)/2118160 }119- from, to := l.room(c, cell, start, tw)161+ from, to := max(x0, l.claimed), x1162+ if !own {163+ from, to = l.room(c, cell, start, tw)164+ }120165 l.claimed = to121166 cut := textCutter{s: text}122- style := l.s.CellStyle(sheet.Addr{Col: c, Row: l.row})123167 for k := max(l.first, l.lo); k <= min(l.last, l.hi); k++ {124168 kx0, kx1 := l.col(k)125169 if kx1 <= from || kx0 >= to {@@ -162,10 +206,10 @@ func (l *rowLayout) room(c int, cell *sheet.Cell, start, tw int) (from, to int)
162206 if cell.Value.Kind != sheet.Text {163207 return from, to164208 }165- for k := c + 1; k <= l.last && start+tw > to && l.content(k) == nil; k++ {209+ for k := c + 1; k <= l.last && start+tw > to && l.free(k, 1); k++ {166210 _, to = l.col(k)167211 }168- for k := c - 1; k >= l.first && start < from && l.content(k) == nil; k-- {212+ for k := c - 1; k >= l.first && start < from && l.free(k, -1); k-- {169213 kx0, _ := l.col(k)170214 if kx0 < l.claimed {171215 return l.claimed, to@@ -175,6 +219,24 @@ func (l *rowLayout) room(c int, cell *sheet.Cell, start, tw int) (from, to int)
175219 return from, to176220 }177221 222+// shape fits the text of the cell in column c, w wide and in style st,223+// to the line being laid out, and reports whether it shows there. A224+// merged cell's text is the caller's to draw.225+func (l *rowLayout) shape(c int, cell *sheet.Cell, st sheet.Style, text string, w int) (string, bool) {226+ if _, merged := l.s.MergeAt(sheet.Addr{Col: c, Row: l.row}); merged {227+ return "", false228+ }229+ if st.Wrap == sheet.WrapOn && cell.Value.Kind == sheet.Text {230+ lines := Wrap(text, w-2)231+ i := l.ln.K - max(l.ln.N-len(lines), 0)232+ if i < 0 || i >= len(lines) {233+ return "", false234+ }235+ return lines[i], true236+ }237+ return text, l.ln.K == l.ln.N-1238+}239+178240 // keepGaps keeps a gap where text that runs to the edge of its column179241 // would touch a neighbor that starts at its own edge180242 // ("Groceries9/28/2026").@@ -0,0 +1,151 @@
1+package rowtext2+3+import (4+ "strings"5+6+ "github.com/charmbracelet/x/ansi"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// Wrap breaks text into lines at most w columns wide, as Sheets wraps a12+// cell: at spaces where it can, inside a word longer than a line, and at13+// every line break the text holds. It returns at least one line.14+func Wrap(text string, w int) []string {15+ w = max(w, 1)16+ var out []string17+ for para := range strings.SplitSeq(text, "\n") {18+ out = wrapLine(out, para, w)19+ }20+ return out21+}22+23+// wrapLine appends the lines of one paragraph to out.24+func wrapLine(out []string, para string, w int) []string {25+ line, lw := "", 026+ for word := range strings.FieldsSeq(para) {27+ ww := ansi.StringWidth(word)28+ switch {29+ case lw > 0 && lw+1+ww <= w:30+ line, lw = line+" "+word, lw+1+ww31+ continue32+ case lw > 0:33+ out = append(out, line)34+ }35+ for ww > w { // a word longer than a line breaks where it must36+ c := textCutter{s: word}37+ head := c.cut(0, w)38+ if head == "" { // a character wider than the line39+ n, _ := nextCluster(word)40+ head = word[:n]41+ }42+ out = append(out, head)43+ word = word[len(head):]44+ ww = ansi.StringWidth(word)45+ }46+ line, lw = word, ww47+ }48+ return append(out, line)49+}50+51+// Shape is how a row is laid out: whether a border line comes above it,52+// and how many lines its cells take.53+type Shape struct {54+ Rule bool55+ Lines int56+}57+58+// Shapes works out the shape of rows as the grid asks for them, and59+// keeps them until the sheet changes.60+type Shapes struct {61+ s *sheet.Sheet62+ version uint6463+ rows map[int]Shape64+}65+66+// Row returns the shape of row on s: its height when one is set, or67+// enough lines for the text its cells wrap, and a rule line when a68+// border lies along its top. Rows of a sheet that nothing shapes are a69+// line each.70+func (x *Shapes) Row(s *sheet.Sheet, row int) Shape {71+ if !s.Shaped() {72+ return Shape{Lines: 1}73+ }74+ if x.s != s || x.version != s.Version() || x.rows == nil {75+ x.s, x.version, x.rows = s, s.Version(), map[int]Shape{}76+ }77+ if sh, ok := x.rows[row]; ok {78+ return sh79+ }80+ sh := Shape{Rule: s.RuleAbove(row), Lines: fit(s, row)}81+ x.rows[row] = sh82+ return sh83+}84+85+// fit is how many lines row takes: its height, or enough for the text86+// it wraps.87+func fit(s *sheet.Sheet, row int) int {88+ if h, ok := s.RowHeight(row); ok {89+ return h90+ }91+ n := 192+ for _, c := range s.WrappedIn(row) {93+ a := sheet.Addr{Col: c, Row: row}94+ v := s.Value(a)95+ if v.Kind != sheet.Text {96+ continue97+ }98+ n = max(n, len(Wrap(v.Str, s.ColWidth(c)-2)))99+ }100+ return min(n, sheet.MaxRowHeight)101+}102+103+// Merged lays out a merged cell m over its columns from lo to hi (those104+// on screen): its top-left cell's value when show is set, on the line of105+// the merge that shows it, centered unless that cell is aligned and cut106+// to the merge's width; blank otherwise.107+func Merged(s *sheet.Sheet, m sheet.Rect, lo, hi int, show bool) []Span {108+ x := make([]int, hi-lo+2) // where each column starts, from the merge's first109+ x[0] = s.ColsWidth(m.From.Col, lo-1)110+ for c := lo; c <= hi; c++ {111+ x[c-lo+1] = x[c-lo] + s.ColWidth(c)112+ }113+ w := s.ColsWidth(m.From.Col, m.To.Col)114+ out := make([]Span, hi-lo+1)115+ for c := lo; c <= hi; c++ {116+ out[c-lo] = Span{Trail: s.ColWidth(c), Owner: m.From.Col}117+ }118+ cell := s.Cell(m.From)119+ if !show || cell.Blank() {120+ return out121+ }122+ f := s.DisplayFormat(m.From)123+ st := s.CellStyle(m.From)124+ text, align := sheet.Display(cell.Value, f, w)125+ if st.Align != sheet.AlignAuto && align != sheet.AlignFill {126+ align = st.Align127+ } else if align != sheet.AlignFill {128+ align = sheet.AlignCenter129+ }130+ text = ansi.Truncate(text, max(w-2, 0), "")131+ tw := ansi.StringWidth(text)132+ start := (w - tw) / 2133+ switch align {134+ case sheet.AlignLeft:135+ start = 1136+ case sheet.AlignRight:137+ start = w - 1 - tw138+ case sheet.AlignFill:139+ start = 0140+ }141+ cut := textCutter{s: text}142+ for c := lo; c <= hi; c++ {143+ kx0, kx1 := x[c-lo], x[c-lo+1]144+ sp := Span{Trail: kx1 - kx0, Style: st, Owner: m.From.Col}145+ if seg0, seg1 := max(start, kx0), min(start+tw, kx1); seg1 > seg0 {146+ sp.Lead, sp.Text, sp.Trail = seg0-kx0, cut.cut(seg0-start, seg1-start), kx1-seg1147+ }148+ out[c-lo] = sp149+ }150+ return out151+}@@ -29,15 +29,22 @@ const (
2929 dragCells3030 dragCols3131 dragRows32- dragPoint // dragging out a range in POINT mode or a range prompt33- dragResize // dragging a column header border34- dragFill // dragging the fill handle35- dragTab // dragging a sheet's tab to move it32+ dragPoint // dragging out a range in POINT mode or a range prompt33+ dragResize // dragging a column header border34+ dragRowResize // dragging a row number's bottom border35+ dragFill // dragging the fill handle36+ dragTab // dragging a sheet's tab to move it3637 )3738 3839 // selection returns the selected range; just the active cell when nothing39-// is selected.40+// is selected. A range takes in the merged cells it touches, as in Sheets.4041 func (g *grid) selection() sheet.Rect {42+ return g.sheet.Grow(g.corners())43+}44+45+// corners is the range between the active cell and the selection's46+// moving corner, before merged cells grow it: what a macro records.47+func (g *grid) corners() sheet.Rect {4148 if !g.selecting {4249 return sheet.Rect{From: g.cur, To: g.cur}4350 }@@ -53,9 +60,22 @@ func (g *grid) selection() sheet.Rect {
5360 return r5461 }5562 56-// hasRange reports whether more than one cell is selected.63+// snap returns the cell a stands for: the top-left cell of the merge64+// holding it, where a merged cell's contents are.65+func (g *grid) snap(a sheet.Addr) sheet.Addr {66+ if m, ok := g.sheet.MergeAt(a); ok {67+ return m.From68+ }69+ return a70+}71+72+// hasRange reports whether more than one cell is selected: a merged73+// cell alone counts as one.5774 func (g *grid) hasRange() bool {5875 r := g.selection()76+ if m, ok := g.sheet.MergeAt(g.cur); ok && r == m {77+ return false78+ }5979 return r.From != r.To6080 }6181 @@ -85,6 +105,7 @@ func (g *grid) moveKey(key string) bool {
85105 return g.navigate(base, &g.ext)86106 }87107 if g.navigate(key, &g.cur) {108+ g.cur = g.snap(g.cur)88109 g.clearSelection()89110 return true90111 }@@ -105,6 +126,7 @@ func (m *Model) pointMoveKey(key string) bool {
105126 return m.navigate(base, &m.point.at)106127 }107128 if m.navigate(key, &m.point.at) {129+ m.point.at = m.snap(m.point.at)108130 m.point.anchored = false109131 return true110132 }@@ -145,6 +167,18 @@ func (g *grid) selectAll() tea.Cmd {
145167 // Keys follow Google Sheets.146168 func (g *grid) navigate(key string, a *sheet.Addr) bool {147169 rows, cols := g.scrollRows(), g.visibleCols(g.left)170+ if m, ok := g.sheet.MergeAt(*a); ok { // steps leave a merged cell from its edge171+ switch key {172+ case "up":173+ a.Row = m.From.Row174+ case "down":175+ a.Row = m.To.Row176+ case "left", "shift+tab":177+ a.Col = m.From.Col178+ case "right", "tab":179+ a.Col = m.To.Col180+ }181+ }148182 switch key {149183 case "up":150184 a.Row = g.stepRow(a.Row, -1)@@ -250,7 +284,7 @@ func (m *Model) gotoText(text string) bool {
250284 m.showSheet(target)251285 if r.From == r.To {252286 m.clearSelection()253- m.cur = r.From287+ m.cur = m.snap(r.From)254288 return true255289 }256290 m.selectRect(r)@@ -65,6 +65,10 @@ func (m *Model) sort(r sheet.Rect, keys []sheet.SortKey) tea.Cmd {
6565 if m.refuseEdit(r, false) {6666 return nil6767 }68+ if len(m.sheet.MergesIn(r)) > 0 {69+ m.fail(sheet.ErrSortMerged.Error())70+ return nil71+ }6872 if m.askProtected(r, func(m *Model) tea.Cmd { return m.sort(r, keys) }) {6973 return nil7074 }@@ -0,0 +1,166 @@
1+package theme2+3+import "github.com/FelineStateMachine/012/internal/sheet"4+5+// Cell borders are drawn with box-drawing characters, so lines of6+// different weights meet in the joints a terminal font draws for them.7+8+// Junction returns the character drawn where cell edges meet, given the9+// line of each arm: up, down, left and right (LineNone for no arm). A10+// lone arm draws the whole line through, and no arm a space. Unicode11+// joins double lines only to single ones, so heavy arms meeting a double12+// one draw double, as does a single arm on the same axis as a double.13+func Junction(up, down, left, right sheet.Line) string {14+ arms := joinable([4]sheet.Line{up, down, left, right})15+ n := 016+ for _, a := range arms {17+ if a != sheet.LineNone {18+ n++19+ }20+ }21+ switch {22+ case n == 0:23+ return " "24+ case n == 1 && (arms[0] != sheet.LineNone || arms[1] != sheet.LineNone):25+ arms[0], arms[1] = max(arms[0], arms[1]), max(arms[0], arms[1])26+ case n == 1:27+ arms[2], arms[3] = max(arms[2], arms[3]), max(arms[2], arms[3])28+ }29+ if g, ok := junctions[arms]; ok {30+ return g31+ }32+ for i, a := range arms { // not in Unicode: draw it light33+ arms[i] = min(a, sheet.LineThin)34+ }35+ return junctions[arms]36+}37+38+// joinable turns the arms of a junction with a double line into ones39+// Unicode has a character for.40+func joinable(arms [4]sheet.Line) [4]sheet.Line {41+ double := false42+ for _, a := range arms {43+ double = double || a == sheet.LineDouble44+ }45+ if !double {46+ return arms47+ }48+ for i, a := range arms {49+ if a == sheet.LineThick {50+ arms[i] = sheet.LineDouble51+ }52+ }53+ for _, axis := range [2][2]int{{0, 1}, {2, 3}} {54+ x, y := &arms[axis[0]], &arms[axis[1]]55+ if *x != sheet.LineNone && *y != sheet.LineNone && *x != *y {56+ *x, *y = sheet.LineDouble, sheet.LineDouble57+ }58+ }59+ return arms60+}61+62+// junctions are Unicode's box-drawing characters by the line of their63+// arms: up, down, left, right.64+var junctions = map[[4]sheet.Line]string{65+ {0, 0, 1, 1}: "─",66+ {0, 0, 1, 2}: "╼",67+ {0, 0, 2, 1}: "╾",68+ {0, 0, 2, 2}: "━",69+ {0, 0, 3, 3}: "═",70+ {0, 1, 0, 1}: "┌",71+ {0, 1, 0, 2}: "┍",72+ {0, 1, 0, 3}: "╒",73+ {0, 1, 1, 0}: "┐",74+ {0, 1, 1, 1}: "┬",75+ {0, 1, 1, 2}: "┮",76+ {0, 1, 2, 0}: "┑",77+ {0, 1, 2, 1}: "┭",78+ {0, 1, 2, 2}: "┯",79+ {0, 1, 3, 0}: "╕",80+ {0, 1, 3, 3}: "╤",81+ {0, 2, 0, 1}: "┎",82+ {0, 2, 0, 2}: "┏",83+ {0, 2, 1, 0}: "┒",84+ {0, 2, 1, 1}: "┰",85+ {0, 2, 1, 2}: "┲",86+ {0, 2, 2, 0}: "┓",87+ {0, 2, 2, 1}: "┱",88+ {0, 2, 2, 2}: "┳",89+ {0, 3, 0, 1}: "╓",90+ {0, 3, 0, 3}: "╔",91+ {0, 3, 1, 0}: "╖",92+ {0, 3, 1, 1}: "╥",93+ {0, 3, 3, 0}: "╗",94+ {0, 3, 3, 3}: "╦",95+ {1, 0, 0, 1}: "└",96+ {1, 0, 0, 2}: "┕",97+ {1, 0, 0, 3}: "╘",98+ {1, 0, 1, 0}: "┘",99+ {1, 0, 1, 1}: "┴",100+ {1, 0, 1, 2}: "┶",101+ {1, 0, 2, 0}: "┙",102+ {1, 0, 2, 1}: "┵",103+ {1, 0, 2, 2}: "┷",104+ {1, 0, 3, 0}: "╛",105+ {1, 0, 3, 3}: "╧",106+ {1, 1, 0, 0}: "│",107+ {1, 1, 0, 1}: "├",108+ {1, 1, 0, 2}: "┝",109+ {1, 1, 0, 3}: "╞",110+ {1, 1, 1, 0}: "┤",111+ {1, 1, 1, 1}: "┼",112+ {1, 1, 1, 2}: "┾",113+ {1, 1, 2, 0}: "┥",114+ {1, 1, 2, 1}: "┽",115+ {1, 1, 2, 2}: "┿",116+ {1, 1, 3, 0}: "╡",117+ {1, 1, 3, 3}: "╪",118+ {1, 2, 0, 0}: "╽",119+ {1, 2, 0, 1}: "┟",120+ {1, 2, 0, 2}: "┢",121+ {1, 2, 1, 0}: "┧",122+ {1, 2, 1, 1}: "╁",123+ {1, 2, 1, 2}: "╆",124+ {1, 2, 2, 0}: "┪",125+ {1, 2, 2, 1}: "╅",126+ {1, 2, 2, 2}: "╈",127+ {2, 0, 0, 1}: "┖",128+ {2, 0, 0, 2}: "┗",129+ {2, 0, 1, 0}: "┚",130+ {2, 0, 1, 1}: "┸",131+ {2, 0, 1, 2}: "┺",132+ {2, 0, 2, 0}: "┛",133+ {2, 0, 2, 1}: "┹",134+ {2, 0, 2, 2}: "┻",135+ {2, 1, 0, 0}: "╿",136+ {2, 1, 0, 1}: "┞",137+ {2, 1, 0, 2}: "┡",138+ {2, 1, 1, 0}: "┦",139+ {2, 1, 1, 1}: "╀",140+ {2, 1, 1, 2}: "╄",141+ {2, 1, 2, 0}: "┩",142+ {2, 1, 2, 1}: "╃",143+ {2, 1, 2, 2}: "╇",144+ {2, 2, 0, 0}: "┃",145+ {2, 2, 0, 1}: "┠",146+ {2, 2, 0, 2}: "┣",147+ {2, 2, 1, 0}: "┨",148+ {2, 2, 1, 1}: "╂",149+ {2, 2, 1, 2}: "╊",150+ {2, 2, 2, 0}: "┫",151+ {2, 2, 2, 1}: "╉",152+ {2, 2, 2, 2}: "╋",153+ {3, 0, 0, 1}: "╙",154+ {3, 0, 0, 3}: "╚",155+ {3, 0, 1, 0}: "╜",156+ {3, 0, 1, 1}: "╨",157+ {3, 0, 3, 0}: "╝",158+ {3, 0, 3, 3}: "╩",159+ {3, 3, 0, 0}: "║",160+ {3, 3, 0, 1}: "╟",161+ {3, 3, 0, 3}: "╠",162+ {3, 3, 1, 0}: "╢",163+ {3, 3, 1, 1}: "╫",164+ {3, 3, 3, 0}: "╣",165+ {3, 3, 3, 3}: "╬",166+}@@ -98,7 +98,7 @@ func minContrast(role string) float64 {
9898 case "Disabled":9999 return minDisabled100100 case "Hint", "Muted", "Border", "FrozenLine", "ChartFrame", "ChartAxis", "ChartLabel",101- "Progress", "ProgressTodo", "Copied", "Series", "Match", "Dropdown", "NoteMark":101+ "Progress", "ProgressTodo", "Copied", "Series", "Match", "Dropdown", "NoteMark", "CellBorder":102102 return minSecondary103103 }104104 return minText@@ -69,6 +69,10 @@ type Theme struct {
6969 // NoteMark is the mark in the top-right corner of a cell with a note,7070 // like Sheets' small triangle.7171 NoteMark lipgloss.Style72+ // CellBorder draws the lines of Format > Borders, in the ink of the73+ // text beside them rather than a color of their own, as Sheets draws74+ // borders black.75+ CellBorder lipgloss.Style7276 7377 // Conditional formats (rules.go), indexed by sheet.Color: a rule's7478 // text color on the cell, its fill with text readable on it, and@@ -168,7 +172,9 @@ func New(dark bool) Theme {
168172 bar := lipgloss.Cyan169173 filterFg := lipgloss.Yellow170174 noteFg := lipgloss.Yellow175+ borderFg := lipgloss.White171176 if !dark {177+ borderFg = lipgloss.Black172178 noteFg = lipgloss.Magenta173179 headerBg, headerFg = lipgloss.White, lipgloss.Black174180 selFg, muted, match = lipgloss.BrightWhite, lipgloss.Black, lipgloss.Blue@@ -219,6 +225,7 @@ func New(dark bool) Theme {
219225 TabHover: lipgloss.NewStyle().Foreground(bar).Bold(true).Underline(true),220226 FilterOn: lipgloss.NewStyle().Background(headerBg).Foreground(filterFg).Bold(true),221227 NoteMark: lipgloss.NewStyle().Foreground(noteFg),228+ CellBorder: lipgloss.NewStyle().Foreground(borderFg),222229 223230 MenuBar: lipgloss.NewStyle(),224231 MenuAccel: lipgloss.NewStyle().Underline(true),@@ -113,6 +113,7 @@ func (m *Model) showHandle(c int) bool {
113113 return m.mouse.hover.kind == hitColBorder && m.mouse.hover.addr.Col == c114114 }115115 116+// gridRow draws a row one line tall.116117 func (m *Model) gridRow(row int) string {117118 if row >= sheet.MaxRows {118119 return ""@@ -120,33 +121,48 @@ func (m *Model) gridRow(row int) string {
120121 if row == divider {121122 return m.dividerRow()122123 }123- focus := m.active()124- sel, selecting := m.highlight()124+ return m.rowLine(row, rowtext.Line{N: 1})125+}126+127+// rowLine draws line ln of a row: its number on the last line, beside128+// its values, so a tall row ends on the line that numbers it, then its129+// cells.130+func (m *Model) rowLine(row int, ln rowtext.Line) string {131+ lc := m.lineContext(row, ln)125132 hdr := m.th.RowHeader126133 switch {127- case row == focus.Row:134+ case row == lc.focus.Row:128135 hdr = m.th.HeaderActive129- case selecting && row >= sel.From.Row && row <= sel.To.Row:136+ case lc.selecting && row >= lc.sel.From.Row && row <= lc.sel.To.Row:130137 hdr = m.th.HeaderSel131138 case m.mouse.hover.kind == hitRowHeader && m.mouse.hover.addr.Row == row:132139 hdr = m.th.HeaderHover133140 }134141 var b strings.Builder135- b.WriteString(hdr.Render(theme.PadLeft(strconv.Itoa(row+1), m.hdrW()-1) + " "))142+ num := ""143+ if ln.K == ln.N-1 {144+ num = strconv.Itoa(row + 1)145+ }146+ if m.showRowHandle(row) && ln.K == ln.N-1 {147+ b.WriteString(hdr.Render(theme.PadLeft(num, m.hdrW()-1)) + m.th.Handle.Render("▄"))148+ } else {149+ b.WriteString(hdr.Render(theme.PadLeft(num, m.hdrW()-1) + " "))150+ }136151 137152 _, fc := m.frozen()138153 if fc > 0 {139- b.WriteString(m.cellsText(row, 0, rowtext.Layout(m.sheet, row, 0, fc, 0, fc-1), focus, sel, selecting))154+ b.WriteString(m.cellsText(&lc, 0, m.layoutLine(&lc, 0, fc, 0, fc-1)))140155 b.WriteString(m.th.FrozenLine.Render("│"))141156 }142157 ncols := m.visibleCols(m.left)143- b.WriteString(m.cellsText(row, m.left, rowtext.Layout(m.sheet, row, m.left, ncols, fc, sheet.MaxCols-1), focus, sel, selecting))158+ b.WriteString(m.cellsText(&lc, m.left, m.layoutLine(&lc, m.left, ncols, fc, sheet.MaxCols-1)))144159 return b.String()145160 }146161 147-// cellsText draws the spans of a row's columns from first on, in the148-// roles for the pointer, the selection, search matches and errors.149-func (m *Model) cellsText(row, first int, spans []rowtext.Span, focus sheet.Addr, sel sheet.Rect, selecting bool) string {162+// cellsText draws the spans of a line of cells from column first on, in163+// the roles for the pointer, the selection, search matches and errors.164+func (m *Model) cellsText(lc *lineCtx, first int, spans []rowtext.Span) string {165+ row := lc.row150166 var b strings.Builder151167 spills := m.sheet.HasSpills()152168 rules := m.sheet.HasRules()@@ -156,41 +172,26 @@ func (m *Model) cellsText(row, first int, spans []rowtext.Span, focus sheet.Addr
156172 }157173 for i, sp := range spans {158174 a := sheet.Addr{Col: first + i, Row: row}159- base, colored := m.th.Cell, true160- switch {161- case a == focus:162- base = m.th.Pointer163- case selecting && sel.Contains(a):164- base = m.th.Selection165- case m.found(a):166- base = m.th.Found167- case m.trace.covers(m.sheet, a):168- base = m.th.Traced169- case sheet.IsPending(m.sheet.Value(a)):170- base = m.th.Muted171- case m.sheet.Value(a).Kind == sheet.Error:172- base = m.th.ErrorCell173- case spills && sp.Text != "" && m.sheet.Cell(sheet.Addr{Col: sp.Owner, Row: row}).Spilled():174- base = m.th.Spilled175- default:176- colored = false177- }175+ base, colored := m.cellRole(lc, a, sp, spills)178176 var look sheet.Look179177 var shade theme.Shade180178 shaded := false181- if a == m.cur && (m.mode == modeEnter || m.mode == modeEdit) && !m.away() {179+ if a == m.cur && (m.mode == modeEnter || m.mode == modeEdit) && !m.away() && lc.bottom() {182180 sp = rowtext.Span{Text: m.inCellText(m.sheet.ColWidth(a.Col))}183181 } else {184182 m.decorate(&sp, row) // links and error marks, see links.go185183 if rules {186184 look, shade, shaded = m.ruleSpan(a, &sp, colored, rgb) // looks.go185+ if look.Checkbox && !lc.bottom() {186+ sp = rowtext.Span{Trail: m.sheet.ColWidth(a.Col)}187+ }187188 }188189 }189190 if shaded {190191 base, colored = shade.Style, true191192 }192193 // The copy marker is layered on the cell's own colors.193- if m.copied.marks(m.sheet, a) {194+ if lc.bottom() && m.copied.marks(m.sheet, a) {194195 base, colored, shaded = base.Inherit(m.th.Copied), true, false195196 }196197 var text string@@ -199,20 +200,52 @@ func (m *Model) cellsText(row, first int, spans []rowtext.Span, focus sheet.Addr
199200 } else {200201 text = renderSpan(&m.th, sp, base, colored)201202 }202- w := m.sheet.ColWidth(a.Col)203- switch {204- case m.showFillHandle(a):205- text = ansi.Truncate(text, w-1, "") + base.Render("▟")206- case m.sheet.Note(a) != "":207- text = m.noteMark(text, a, base, colored)208- case look.Dropdown:209- text = m.dropdownMark(text, w, base, colored)210- }211- b.WriteString(text)203+ b.WriteString(m.cellMarks(lc, a, text, look, base, colored))212204 }213205 return b.String()214206 }215207 208+// cellRole is the role a cell is drawn in: the pointer, the selection,209+// a search match, a trace, an error or a spill, or the plain cell role210+// (colored false).211+func (m *Model) cellRole(lc *lineCtx, a sheet.Addr, sp rowtext.Span, spills bool) (lipgloss.Style, bool) {212+ switch {213+ case a == lc.focus || lc.merged(a, lc.focus):214+ return m.th.Pointer, true215+ case lc.selecting && lc.sel.Contains(a):216+ return m.th.Selection, true217+ case m.found(a):218+ return m.th.Found, true219+ case m.trace.covers(m.sheet, a):220+ return m.th.Traced, true221+ case sheet.IsPending(m.sheet.Value(a)):222+ return m.th.Muted, true223+ case m.sheet.Value(a).Kind == sheet.Error:224+ return m.th.ErrorCell, true225+ case spills && sp.Text != "" && m.sheet.Cell(sheet.Addr{Col: sp.Owner, Row: lc.row}).Spilled():226+ return m.th.Spilled, true227+ }228+ return m.th.Cell, false229+}230+231+// cellMarks draws a cell's marks over its text: the fill handle, a232+// note's corner or a dropdown's ▾, and the border along its left.233+func (m *Model) cellMarks(lc *lineCtx, a sheet.Addr, text string, look sheet.Look, base lipgloss.Style, colored bool) string {234+ w := m.sheet.ColWidth(a.Col)235+ switch {236+ case lc.bottom() && m.showFillHandle(a):237+ text = ansi.Truncate(text, w-1, "") + base.Render("▟")238+ case lc.ln.K == 0 && m.sheet.Note(a) != "":239+ text = m.noteMark(text, a, base, colored)240+ case lc.bottom() && look.Dropdown:241+ text = m.dropdownMark(text, w, base, colored)242+ }243+ if lc.shaped {244+ text = m.leftEdge(lc, a, text, base, colored)245+ }246+ return text247+}248+216249 // dividerRow is the line under the frozen rows, crossing the one right of217250 // the frozen columns.218251 func (m *Model) dividerRow() string {