cat / 012
clone

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

main
012 / internal / ui / rowtext / rowtext.go
rowtext.go 11.0K
1// Package rowtext lays out the text of one row of cells across the2// columns on screen, as Sheets does: values formatted and aligned, text3// running on into blank neighbors, cut to column boundaries in a single4// pass. It reads the sheet and nothing else, so drawing the grid can be5// measured and tested on its own.6package rowtext78import (9 "strings"1011 "github.com/charmbracelet/x/ansi"1213 "src.rest/cat/012/internal/locale"14 "src.rest/cat/012/internal/sheet"15)1617// Span is what one grid column shows in a row: blank columns, the text,18// blank columns. Only the text takes the owning cell's text style, so an19// underline doesn't run into the padding.20type Span struct {21 Lead int22 Text string23 Trail int24 Style sheet.Style25 Owner int // column of the cell the text belongs to2627 // Left for the caller to fill in from the owner's value.28 Link string // the owner's link target, drawn as a hyperlink29 Error bool // the owner shows an error: its text gets the error mark30 // Invalid is set when the owner fails its data validation: its text31 // gets the invalid mark.32 Invalid bool33}3435// Layout lays out ncols columns of row from lo, each span exactly its36// column's width. Text may run in from columns between minCol and maxCol,37// so it stops at the frozen columns' divider. Values are formatted with38// their cell's number format and aligned as Sheets does: numbers right,39// text left, booleans and errors centered, unless the cell sets an40// alignment. Text runs on into blank neighbors: to the right when41// left-aligned, to the left when right-aligned, both ways when centered.42// It can come from cells outside the viewport, so the scan starts at the43// nearest filled cell on each side.44func Layout(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int) []Span {45 return LayoutLine(s, row, lo, ncols, minCol, maxCol, Line{N: 1})46}4748// Line is one line of a row: the K-th of its N, from 0 at the top.49type Line struct{ K, N int }5051// LayoutLine is Layout for one line of a row N lines tall. Values sit on52// the row's last line, as Sheets aligns them to the bottom, or its first53// or middle one as the cell's vertical alignment says, and text that54// wraps (sheet.WrapOn) fills lines of its own column around there, cut55// at the bottom when the row is too short. Clipped text (sheet.WrapClip)56// stays in its column. Text doesn't run on across a border or into57// merged cells, whose contents the caller draws (see Merged).58func LayoutLine(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int, ln Line) []Span {59 var sc Scratch60 return sc.LayoutLine(s, row, lo, ncols, minCol, maxCol, ln)61}6263// Scratch keeps the slices a layout works in, so the lines of a frame64// reuse them rather than allocate their own. The spans a layout through65// it returns hold until its next layout.66type Scratch struct {67 spans []Span68 x []int69}7071// LayoutLine is the package's LayoutLine, laid out in sc's slices.72func (sc *Scratch) LayoutLine(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int, ln Line) []Span {73 sc.spans = resize(sc.spans, ncols)74 l := rowLayout{s: s, loc: s.Locale(), row: row, lo: lo, hi: lo + ncols - 1, out: sc.spans, ln: ln, shaped: s.Shaped()}75 for i := range l.out {76 l.out[i] = Span{Trail: s.ColWidth(lo + i)}77 }78 l.first, l.last = l.reach(minCol, maxCol)79 // x[k] is where column first+k starts, relative to column first.80 sc.x = resize(sc.x, l.last-l.first+2)81 l.x = sc.x82 l.x[0] = 083 for c := l.first; c <= l.last; c++ {84 l.x[c-l.first+1] = l.x[c-l.first] + s.ColWidth(c)85 }86 for c := l.first; c <= l.last; c++ {87 if v, ok := l.content(c); ok {88 l.place(c, v)89 }90 }91 keepGaps(l.out)92 return l.out93}9495// resize returns s n long, reusing its array when it has room; what it96// holds is the caller's to set.97func resize[T any](s []T, n int) []T {98 if cap(s) < n {99 return make([]T, n)100 }101 return s[:n]102}103104// rowLayout is Layout's work in progress on one row.105type rowLayout struct {106 s *sheet.Sheet107 loc *locale.Locale // what numbers are shown in108 row int109 lo, hi int // the columns laid out110 first, last int // the columns whose text may reach them111 x []int112 claimed int // text of earlier cells reaches up to here113 out []Span114 ln Line115 shaped bool // the sheet wraps, draws borders or merges: see sheet.Shaped116}117118// content is the value in column c of the row, and false if the cell is119// blank.120func (l *rowLayout) content(c int) (sheet.Value, bool) {121 if a := (sheet.Addr{Col: c, Row: l.row}); l.s.Filled(a) {122 return l.s.Value(a), true123 }124 return sheet.Value{}, false125}126127// filled reports whether the cell in column c of the row has contents.128func (l *rowLayout) filled(c int) bool { return l.s.Filled(sheet.Addr{Col: c, Row: l.row}) }129130// col returns where column c starts and ends, relative to column first.131func (l *rowLayout) col(c int) (int, int) { return l.x[c-l.first], l.x[c-l.first+1] }132133// maxReach is how many columns away text may run in from: 2,560134// characters in default columns.135const maxReach = 256136137// reach returns the nearest filled columns outside lo..hi, within minCol138// and maxCol and maxReach of the viewport, whose text might run into139// view; lo and hi if there are none. The filled cells are found through140// the sheet's index, not by looking at every column between.141func (l *rowLayout) reach(minCol, maxCol int) (first, last int) {142 first, last = l.lo, l.hi143 if c, ok := l.s.NextFilledCol(l.row, l.lo-1, -1, max(minCol, l.lo-maxReach)); ok {144 first = c145 }146 if c, ok := l.s.NextFilledCol(l.row, l.hi+1, 1, min(maxCol, l.hi+maxReach)); ok {147 last = c148 }149 return first, last150}151152// free reports whether text may run on into column k from the column153// on side (-1 left, 1 right): it's blank, not merged, and no border154// lies between.155func (l *rowLayout) free(k, side int) bool {156 if l.filled(k) {157 return false158 }159 if !l.shaped {160 return true161 }162 a := sheet.Addr{Col: k, Row: l.row}163 edge := a164 if side < 0 {165 edge.Col++166 }167 _, merged := l.s.MergeAt(a)168 return !merged && l.s.EdgeLeft(edge) == sheet.LineNone169}170171// place lays out the text of the cell in column c over the columns it172// reaches.173func (l *rowLayout) place(c int, v sheet.Value) {174 x0, x1 := l.col(c)175 style := l.s.CellStyle(sheet.Addr{Col: c, Row: l.row})176 text, align, pad := l.display(c, v, style.Align, x1-x0)177 own := style.Wrap != sheet.WrapOverflow // the text stays in its column178 if l.shaped {179 var show bool180 if text, show = l.shape(c, v, style, text, x1-x0); !show {181 return182 }183 }184 tw := textWidth(text)185 start := x0 + pad186 switch align {187 case sheet.AlignFill:188 start = x0189 case sheet.AlignRight:190 start = x1 - pad - tw191 case sheet.AlignCenter:192 start = x0 + (x1-x0-tw)/2193 }194 from, to := max(x0, l.claimed), x1195 if !own {196 from, to = l.room(c, v, start, tw)197 }198 l.claimed = to199 cut := textCutter{s: text}200 for k := max(l.first, l.lo); k <= min(l.last, l.hi); k++ {201 kx0, kx1 := l.col(k)202 if kx1 <= from || kx0 >= to {203 continue204 }205 sp := Span{Trail: kx1 - kx0, Style: style, Owner: c}206 if seg0, seg1 := max(start, kx0, from), min(start+tw, kx1, to); seg1 > seg0 {207 sp.Lead, sp.Text, sp.Trail = seg0-kx0, cut.cut(seg0-start, seg1-start), kx1-seg1208 }209 l.out[k-l.lo] = sp210 }211}212213// display formats the cell in column c, which its style aligns as a214// says, for a column w wide, returning the text, its alignment and the215// padding on its aligned side.216func (l *rowLayout) display(c int, v sheet.Value, a sheet.Align, w int) (string, sheet.Align, int) {217 f := l.s.DisplayFormat(sheet.Addr{Col: c, Row: l.row})218 text, align := sheet.DisplayIn(v, f, w, l.loc)219 pad := 1220 // A number one character too wide (12/31/2026 in a default column)221 // may use the padding when nothing is to its right, rather than222 // turning into #s.223 if v.Kind == sheet.Number && strings.Trim(text, "#") == "" && !l.filled(c+1) {224 if wider, _ := sheet.DisplayIn(v, f, w+1, l.loc); strings.Trim(wider, "#") != "" {225 text, pad = wider, 0226 }227 }228 if a != sheet.AlignAuto && align != sheet.AlignFill {229 align = a230 }231 return text, align, pad232}233234// room returns where the text of the cell in column c, tw wide from235// start, may go: its own column and, for text, the blank neighbors it236// runs into, but not over text already placed to its left.237func (l *rowLayout) room(c int, v sheet.Value, start, tw int) (from, to int) {238 x0, x1 := l.col(c)239 from, to = max(x0, l.claimed), x1240 if v.Kind != sheet.Text {241 return from, to242 }243 for k := c + 1; k <= l.last && start+tw > to && l.free(k, 1); k++ {244 _, to = l.col(k)245 }246 for k := c - 1; k >= l.first && start < from && l.free(k, -1); k-- {247 kx0, _ := l.col(k)248 if kx0 < l.claimed {249 return l.claimed, to250 }251 from = kx0252 }253 return from, to254}255256// shape fits the text of the value v in column c, w wide and in style257// st, to the line being laid out, and reports whether it shows there. A258// merged cell's text is the caller's to draw.259func (l *rowLayout) shape(c int, v sheet.Value, st sheet.Style, text string, w int) (string, bool) {260 if _, merged := l.s.MergeAt(sheet.Addr{Col: c, Row: l.row}); merged {261 return "", false262 }263 if st.Wrap == sheet.WrapOn && v.Kind == sheet.Text {264 lines := Wrap(text, w-2)265 i := l.ln.K - st.VAlign.Offset(len(lines), l.ln.N, false)266 if i < 0 || i >= len(lines) {267 return "", false268 }269 return lines[i], true270 }271 return text, l.ln.K == st.VAlign.Offset(1, l.ln.N, false)272}273274// keepGaps keeps a gap where text that runs to the edge of its column275// would touch a neighbor that starts at its own edge276// ("Groceries9/28/2026").277func keepGaps(out []Span) {278 for i := 0; i+1 < len(out); i++ {279 l, r := &out[i], &out[i+1]280 if l.Text != "" && l.Trail == 0 && r.Text != "" && r.Lead == 0 && l.Owner != r.Owner {281 l.Text = ansi.Truncate(l.Text, ansi.StringWidth(l.Text)-1, "")282 l.Trail = 1283 }284 }285}286287// textCutter cuts successive column ranges, left to right, out of one288// line of text in a single pass, as ansi.Cut would one at a time: a289// cluster is in [l, r) when its right edge is past l and not past r.290// Cutting each column's piece with ansi.Cut rescanned the text from its291// start, so a screen of 500-character text took 40 ms to draw.292type textCutter struct {293 s string294 i int // byte offset of the next cluster295 right int // right edge, in columns, of the text before s[i:]296}297298func (c *textCutter) cut(l, r int) string {299 for c.i < len(c.s) {300 n, w := nextCluster(c.s[c.i:])301 if c.right+w > l {302 break303 }304 c.i, c.right = c.i+n, c.right+w305 }306 from := c.i307 for c.i < len(c.s) {308 n, w := nextCluster(c.s[c.i:])309 if c.right+w > r {310 break311 }312 c.i, c.right = c.i+n, c.right+w313 }314 return c.s[from:c.i]315}316317// nextCluster returns the length in bytes and width in columns of the318// grapheme cluster s starts with, measured as ansi.Cut measures it.319func nextCluster(s string) (n, w int) {320 switch b := s[0]; {321 case b >= 0x20 && b < 0x7f:322 return 1, 1323 case b < 0x80:324 return 1, 0325 }326 g, w := ansi.FirstGraphemeCluster(s, ansi.GraphemeWidth)327 return len(g), w328}329330// textWidth is ansi.StringWidth of s, counted at once when s is331// printable ASCII, as numbers and most text are: one column a byte.332func textWidth(s string) int {333 for i := 0; i < len(s); i++ {334 if b := s[i]; b < 0x20 || b >= 0x7f {335 return ansi.StringWidth(s)336 }337 }338 return len(s)339}