rowText was one 100-line function with cognitive complexity 52. It is now a function of the sheet alone, split into finding the cells whose text can reach the view, formatting a cell, working out how far its text may run, and cutting it into the columns it covers.
- commit
- 0cbfb0aee8249aa425b68d9b64d98deab2b72376
- parent
- 68508d4c74141033a1a5d2035451c99190c65fd3
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 222 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 173 | +----------------------- |
@@ -0,0 +1,222 @@
1+package ui2+3+import (4+ "strings"5+6+ "github.com/charmbracelet/x/ansi"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// span is what one grid column shows in a row: blank columns, the text,12+// blank columns. Only the text takes the owning cell's text style, so an13+// underline doesn't run into the padding.14+type span struct {15+ lead int16+ text string17+ trail int18+ style sheet.Style19+ owner int // column of the cell the text belongs to20+21+ link string // the owner's link target, drawn as a hyperlink22+ error bool // the owner shows an error: its text gets the error mark23+}24+25+// rowText lays out ncols columns of row from lo, each span exactly its26+// column's width. Text may run in from columns between minCol and maxCol,27+// so it stops at the frozen columns' divider. Values are formatted with28+// their cell's number format and aligned as Sheets does: numbers right,29+// text left, booleans and errors centered, unless the cell sets an30+// alignment. Text runs on into blank neighbors: to the right when31+// left-aligned, to the left when right-aligned, both ways when centered.32+// It can come from cells outside the viewport, so the scan starts at the33+// nearest filled cell on each side.34+func rowText(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int) []span {35+ l := rowLayout{s: s, row: row, lo: lo, hi: lo + ncols - 1, out: make([]span, ncols)}36+ for i := range l.out {37+ l.out[i] = span{trail: s.ColWidth(lo + i)}38+ }39+ l.first, l.last = l.reach(minCol, maxCol)40+ // x[k] is where column first+k starts, relative to column first.41+ l.x = make([]int, l.last-l.first+2)42+ for c := l.first; c <= l.last; c++ {43+ l.x[c-l.first+1] = l.x[c-l.first] + s.ColWidth(c)44+ }45+ for c := l.first; c <= l.last; c++ {46+ if cell := l.content(c); cell != nil {47+ l.place(c, cell)48+ }49+ }50+ keepGaps(l.out)51+ return l.out52+}53+54+// rowLayout is rowText's work in progress on one row.55+type rowLayout struct {56+ s *sheet.Sheet57+ row int58+ lo, hi int // the columns laid out59+ first, last int // the columns whose text may reach them60+ x []int61+ claimed int // text of earlier cells reaches up to here62+ out []span63+}64+65+// content is the cell in column c of the row, or nil if it's blank.66+func (l *rowLayout) content(c int) *sheet.Cell {67+ if cell := l.s.Cell(sheet.Addr{Col: c, Row: l.row}); !cell.Blank() {68+ return cell69+ }70+ return nil71+}72+73+// col returns where column c starts and ends, relative to column first.74+func (l *rowLayout) col(c int) (int, int) { return l.x[c-l.first], l.x[c-l.first+1] }75+76+// reach returns the nearest filled columns outside lo..hi, within minCol77+// and maxCol, whose text might run into view; lo and hi if there are none.78+func (l *rowLayout) reach(minCol, maxCol int) (first, last int) {79+ first, last = l.lo, l.hi80+ for c := l.lo - 1; c >= minCol; c-- {81+ if l.content(c) != nil {82+ first = c83+ break84+ }85+ }86+ for c := l.hi + 1; c <= maxCol; c++ {87+ if l.content(c) != nil {88+ last = c89+ break90+ }91+ }92+ return first, last93+}94+95+// place lays out the text of the cell in column c over the columns it96+// reaches.97+func (l *rowLayout) place(c int, cell *sheet.Cell) {98+ x0, x1 := l.col(c)99+ text, align, pad := l.display(c, cell, x1-x0)100+ tw := ansi.StringWidth(text)101+ start := x0 + pad102+ switch align {103+ case sheet.AlignFill:104+ start = x0105+ case sheet.AlignRight:106+ start = x1 - pad - tw107+ case sheet.AlignCenter:108+ start = x0 + (x1-x0-tw)/2109+ }110+ from, to := l.room(c, cell, start, tw)111+ l.claimed = to112+ cut := textCutter{s: text}113+ for k := max(l.first, l.lo); k <= min(l.last, l.hi); k++ {114+ kx0, kx1 := l.col(k)115+ if kx1 <= from || kx0 >= to {116+ continue117+ }118+ sp := span{trail: kx1 - kx0, style: cell.Style, owner: c}119+ if seg0, seg1 := max(start, kx0, from), min(start+tw, kx1, to); seg1 > seg0 {120+ sp.lead, sp.text, sp.trail = seg0-kx0, cut.cut(seg0-start, seg1-start), kx1-seg1121+ }122+ l.out[k-l.lo] = sp123+ }124+}125+126+// display formats the cell in column c for a column w wide, returning127+// the text, its alignment and the padding on its aligned side.128+func (l *rowLayout) display(c int, cell *sheet.Cell, w int) (string, sheet.Align, int) {129+ f := l.s.DisplayFormat(sheet.Addr{Col: c, Row: l.row})130+ text, align := sheet.Display(cell.Value, f, w)131+ pad := 1132+ // A number one character too wide (12/31/2026 in a default column)133+ // may use the padding when nothing is to its right, rather than134+ // turning into #s.135+ if cell.Value.Kind == sheet.Number && strings.Trim(text, "#") == "" && l.content(c+1) == nil {136+ if wider, _ := sheet.Display(cell.Value, f, w+1); strings.Trim(wider, "#") != "" {137+ text, pad = wider, 0138+ }139+ }140+ if a := cell.Style.Align; a != sheet.AlignAuto && align != sheet.AlignFill {141+ align = a142+ }143+ return text, align, pad144+}145+146+// room returns where the text of the cell in column c, tw wide from147+// start, may go: its own column and, for text, the blank neighbors it148+// runs into, but not over text already placed to its left.149+func (l *rowLayout) room(c int, cell *sheet.Cell, start, tw int) (from, to int) {150+ x0, x1 := l.col(c)151+ from, to = max(x0, l.claimed), x1152+ if cell.Value.Kind != sheet.Text {153+ return from, to154+ }155+ for k := c + 1; k <= l.last && start+tw > to && l.content(k) == nil; k++ {156+ _, to = l.col(k)157+ }158+ for k := c - 1; k >= l.first && start < from && l.content(k) == nil; k-- {159+ kx0, _ := l.col(k)160+ if kx0 < l.claimed {161+ return l.claimed, to162+ }163+ from = kx0164+ }165+ return from, to166+}167+168+// keepGaps keeps a gap where text that runs to the edge of its column169+// would touch a neighbor that starts at its own edge170+// ("Groceries9/28/2026").171+func keepGaps(out []span) {172+ for i := 0; i+1 < len(out); i++ {173+ l, r := &out[i], &out[i+1]174+ if l.text != "" && l.trail == 0 && r.text != "" && r.lead == 0 && l.owner != r.owner {175+ l.text = ansi.Truncate(l.text, ansi.StringWidth(l.text)-1, "")176+ l.trail = 1177+ }178+ }179+}180+181+// textCutter cuts successive column ranges, left to right, out of one182+// line of text in a single pass, as ansi.Cut would one at a time: a183+// cluster is in [l, r) when its right edge is past l and not past r.184+// Cutting each column's piece with ansi.Cut rescanned the text from its185+// start, so a screen of 500-character text took 40 ms to draw.186+type textCutter struct {187+ s string188+ i int // byte offset of the next cluster189+ right int // right edge, in columns, of the text before s[i:]190+}191+192+func (c *textCutter) cut(l, r int) string {193+ for c.i < len(c.s) {194+ n, w := nextCluster(c.s[c.i:])195+ if c.right+w > l {196+ break197+ }198+ c.i, c.right = c.i+n, c.right+w199+ }200+ from := c.i201+ for c.i < len(c.s) {202+ n, w := nextCluster(c.s[c.i:])203+ if c.right+w > r {204+ break205+ }206+ c.i, c.right = c.i+n, c.right+w207+ }208+ return c.s[from:c.i]209+}210+211+// nextCluster returns the length in bytes and width in columns of the212+// grapheme cluster s starts with, measured as ansi.Cut measures it.213+func nextCluster(s string) (n, w int) {214+ switch b := s[0]; {215+ case b >= 0x20 && b < 0x7f:216+ return 1, 1217+ case b < 0x80:218+ return 1, 0219+ }220+ g, w := ansi.FirstGraphemeCluster(s, ansi.GraphemeWidth)221+ return len(g), w222+}@@ -134,11 +134,11 @@ func (m *Model) gridRow(row int) string {
134134 135135 _, fc := m.frozen()136136 if fc > 0 {137- b.WriteString(m.cellsText(row, 0, m.rowText(row, 0, fc, 0, fc-1), focus, sel, selecting))137+ b.WriteString(m.cellsText(row, 0, rowText(m.sheet, row, 0, fc, 0, fc-1), focus, sel, selecting))138138 b.WriteString(m.th.FrozenLine.Render("│"))139139 }140140 ncols := m.visibleCols(m.left)141- b.WriteString(m.cellsText(row, m.left, m.rowText(row, m.left, ncols, fc, sheet.MaxCols-1), focus, sel, selecting))141+ b.WriteString(m.cellsText(row, m.left, rowText(m.sheet, row, m.left, ncols, fc, sheet.MaxCols-1), focus, sel, selecting))142142 return b.String()143143 }144144 @@ -199,20 +199,6 @@ func (m *Model) dividerRow() string {
199199 return m.th.FrozenLine.Render(ansi.Truncate(b.String(), m.width, ""))200200 }201201 202-// span is what one grid column shows in a row: blank columns, the text,203-// blank columns. Only the text takes the owning cell's text style, so an204-// underline doesn't run into the padding.205-type span struct {206- lead int207- text string208- trail int209- style sheet.Style210- owner int // column of the cell the text belongs to211-212- link string // the owner's link target, drawn as a hyperlink213- error bool // the owner shows an error: its text gets the error mark214-}215-216202 // renderSpan draws a span on base, one of the cell roles. Plain cells217203 // with no text style are written without escape codes.218204 func (m *Model) renderSpan(sp span, base lipgloss.Style, colored bool) string {@@ -240,158 +226,3 @@ func (m *Model) inCellText(w int) string {
240226 }241227 return theme.PadRight(text, w)242228 }243-244-// rowText lays out ncols columns of row from lo, each span exactly its245-// column's width. Text may run in from columns between minCol and maxCol,246-// so it stops at the frozen columns' divider. Values are formatted with their cell's number format247-// and aligned as Sheets does: numbers right, text left, booleans and248-// errors centered, unless the cell sets an alignment. Text runs on into249-// blank neighbors: to the right when left-aligned, to the left when250-// right-aligned, both ways when centered. It can come from cells outside251-// the viewport, so the scan starts at the nearest filled cell on each252-// side.253-func (m *Model) rowText(row, lo, ncols, minCol, maxCol int) []span {254- hi := lo + ncols - 1255- out := make([]span, ncols)256- for i := range out {257- out[i] = span{trail: m.sheet.ColWidth(lo + i)}258- }259- content := func(c int) *sheet.Cell {260- if c := m.sheet.Cell(sheet.Addr{Col: c, Row: row}); !c.Blank() {261- return c262- }263- return nil264- }265- first, last := lo, hi266- for c := lo - 1; c >= minCol; c-- {267- if content(c) != nil {268- first = c269- break270- }271- }272- for c := hi + 1; c <= maxCol; c++ {273- if content(c) != nil {274- last = c275- break276- }277- }278- // x[k] is where column first+k starts, relative to column first.279- x := make([]int, last-first+2)280- for c := first; c <= last; c++ {281- x[c-first+1] = x[c-first] + m.sheet.ColWidth(c)282- }283- col := func(c int) (int, int) { return x[c-first], x[c-first+1] }284-285- claimed := 0 // text of earlier cells reaches up to here286- for c := first; c <= last; c++ {287- cell := content(c)288- if cell == nil {289- continue290- }291- x0, x1 := col(c)292- f := m.sheet.DisplayFormat(sheet.Addr{Col: c, Row: row})293- text, align := sheet.Display(cell.Value, f, x1-x0)294- pad := 1295- // A number one character too wide (12/31/2026 in a default296- // column) may use the padding when nothing is to its right,297- // rather than turning into #s.298- if cell.Value.Kind == sheet.Number && strings.Trim(text, "#") == "" && content(c+1) == nil {299- if wider, _ := sheet.Display(cell.Value, f, x1-x0+1); strings.Trim(wider, "#") != "" {300- text, pad = wider, 0301- }302- }303- if a := cell.Style.Align; a != sheet.AlignAuto && align != sheet.AlignFill {304- align = a305- }306- tw := ansi.StringWidth(text)307- start := x0 + pad308- switch align {309- case sheet.AlignFill:310- start = x0311- case sheet.AlignRight:312- start = x1 - pad - tw313- case sheet.AlignCenter:314- start = x0 + (x1-x0-tw)/2315- }316- from, to := max(x0, claimed), x1 // where this cell's text may go317- if cell.Value.Kind == sheet.Text {318- for k := c + 1; k <= last && start+tw > to && content(k) == nil; k++ {319- _, to = col(k)320- }321- for k := c - 1; k >= first && start < from && content(k) == nil; k-- {322- if kx0, _ := col(k); kx0 >= claimed {323- from = kx0324- } else {325- from = claimed326- break327- }328- }329- }330- claimed = to331- cut := textCutter{s: text}332- for k := max(first, lo); k <= min(last, hi); k++ {333- kx0, kx1 := col(k)334- if kx1 <= from || kx0 >= to {335- continue336- }337- sp := span{trail: kx1 - kx0, style: cell.Style, owner: c}338- if seg0, seg1 := max(start, kx0, from), min(start+tw, kx1, to); seg1 > seg0 {339- sp.lead, sp.text, sp.trail = seg0-kx0, cut.cut(seg0-start, seg1-start), kx1-seg1340- }341- out[k-lo] = sp342- }343- }344- // Text that runs to the edge of its column would touch a neighbor345- // that starts at its own edge ("Groceries9/28/2026"); keep a gap.346- for i := 0; i+1 < len(out); i++ {347- l, r := &out[i], &out[i+1]348- if l.text != "" && l.trail == 0 && r.text != "" && r.lead == 0 && l.owner != r.owner {349- l.text = ansi.Truncate(l.text, ansi.StringWidth(l.text)-1, "")350- l.trail = 1351- }352- }353- return out354-}355-356-// textCutter cuts successive column ranges, left to right, out of one357-// line of text in a single pass, as ansi.Cut would one at a time: a358-// cluster is in [l, r) when its right edge is past l and not past r.359-// Cutting each column's piece with ansi.Cut rescanned the text from its360-// start, so a screen of 500-character text took 40 ms to draw.361-type textCutter struct {362- s string363- i int // byte offset of the next cluster364- right int // right edge, in columns, of the text before s[i:]365-}366-367-func (c *textCutter) cut(l, r int) string {368- for c.i < len(c.s) {369- n, w := nextCluster(c.s[c.i:])370- if c.right+w > l {371- break372- }373- c.i, c.right = c.i+n, c.right+w374- }375- from := c.i376- for c.i < len(c.s) {377- n, w := nextCluster(c.s[c.i:])378- if c.right+w > r {379- break380- }381- c.i, c.right = c.i+n, c.right+w382- }383- return c.s[from:c.i]384-}385-386-// nextCluster returns the length in bytes and width in columns of the387-// grapheme cluster s starts with, measured as ansi.Cut measures it.388-func nextCluster(s string) (n, w int) {389- switch b := s[0]; {390- case b >= 0x20 && b < 0x7f:391- return 1, 1392- case b < 0x80:393- return 1, 0394- }395- g, w := ansi.FirstGraphemeCluster(s, ansi.GraphemeWidth)396- return len(g), w397-}