1package ui23import (4 "strings"56 "charm.land/lipgloss/v2"7 "github.com/charmbracelet/x/ansi"89 "src.rest/cat/012/internal/sheet"10 "src.rest/cat/012/internal/ui/rowtext"11 "src.rest/cat/012/internal/ui/theme"12)1314// 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.2425// lineCtx is what drawing a line of cells needs, worked out once per26// line.27type 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}3637func (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 m.unfocused() {41 lc.focus, lc.selecting = sheet.Addr{Col: -1, Row: -1}, false42 }43 if lc.shaped {44 lc.merges = m.sheet.MergesIn(sheet.Rect{From: sheet.Addr{Row: row}, To: sheet.Addr{Col: sheet.MaxCols - 1, Row: row}})45 }46 return lc47}4849// bottom reports whether the line is the row's last, where values sit50// unless aligned otherwise, and the marks of a cell's box are drawn.51func (lc *lineCtx) bottom() bool { return lc.ln.K == lc.ln.N-1 }5253// valueLine reports whether the line is the one the value of the cell54// at a sits on: the row's last, first or middle, as its vertical55// alignment says.56func (m *Model) valueLine(lc *lineCtx, a sheet.Addr) bool {57 return lc.ln.N == 1 || lc.ln.K == m.sheet.CellStyle(a).VAlign.Offset(1, lc.ln.N, false)58}5960// entryPiece is the part of the entry being typed that the cell at a61// shows on this line, if any: all of it in the active cell, on the line62// its value sits on, or the piece of a merged active cell's columns63// that falls in a's, on the line that shows the merge's value, so the64// entry is as wide as the merge.65func (m *Model) entryPiece(lc *lineCtx, a sheet.Addr) (string, bool) {66 if m.mode != modeEnter && m.mode != modeEdit || a.Row != m.cur.Row && lc.merges == nil || m.away() {67 return "", false68 }69 if mg, ok := lc.mergeOf(a.Col); ok && mg.From == m.cur {70 if row, k := m.mergeText(mg); row != lc.row || k != lc.ln.K {71 return "", false72 }73 x0 := m.sheet.ColsWidth(mg.From.Col, a.Col-1)74 text := m.inCellText(m.sheet.ColsWidth(mg.From.Col, mg.To.Col))75 return ansi.Cut(text, x0, x0+m.sheet.ColWidth(a.Col)), true76 }77 if a != m.cur || !m.valueLine(lc, a) {78 return "", false79 }80 return m.inCellText(m.sheet.ColWidth(a.Col)), true81}8283// mergeOf returns the merge on the row holding column c.84func (lc *lineCtx) mergeOf(c int) (sheet.Rect, bool) {85 for _, mg := range lc.merges {86 if c >= mg.From.Col && c <= mg.To.Col {87 return mg, true88 }89 }90 return sheet.Rect{}, false91}9293// merged reports whether a is in the merge whose top-left cell is focus.94func (lc *lineCtx) merged(a, focus sheet.Addr) bool {95 if len(lc.merges) == 0 {96 return false97 }98 mg, ok := lc.mergeOf(a.Col)99 return ok && mg.From == focus100}101102// layoutLine lays out ncols columns of the line from lo, with the merges103// crossing them drawn over. The spans are the model's scratch, good104// until the next line is laid out.105func (m *Model) layoutLine(lc *lineCtx, lo, ncols, minCol, maxCol int) []rowtext.Span {106 spans := m.rowScratch.LayoutLine(m.sheet, lc.row, lo, ncols, minCol, maxCol, lc.ln)107 hi := lo + ncols - 1108 for _, mg := range lc.merges {109 from, to := max(lo, mg.From.Col), min(hi, mg.To.Col)110 if from > to {111 continue112 }113 row, k := m.mergeText(mg)114 // While its value is being typed, the entry shows in its place.115 typing := (m.mode == modeEnter || m.mode == modeEdit) && !m.away() && mg.From == m.cur116 copy(spans[from-lo:], rowtext.Merged(m.sheet, mg, from, to, row == lc.row && k == lc.ln.K && !typing))117 }118 return spans119}120121// mergeText returns the line that shows a merge's value: among the lines122// its shown rows take on screen, rule lines between them included, the123// middle one (or the top or bottom one, as its top-left cell's vertical124// alignment says), or the nearest line of text above it. Each merge's is125// worked out once a frame.126func (m *Model) mergeText(mg sheet.Rect) (row, k int) {127 if at, ok := m.mergeLines[mg]; ok {128 return at[0], at[1]129 }130 row, k = m.mergeLine(mg)131 if m.mergeLines == nil {132 m.mergeLines = map[sheet.Rect][2]int{}133 }134 m.mergeLines[mg] = [2]int{row, k}135 return row, k136}137138func (m *Model) mergeLine(mg sheet.Rect) (row, k int) {139 va := m.sheet.CellStyle(mg.From).VAlign140 if mg.To.Row-mg.From.Row >= maxMergeWalk {141 row = (mg.From.Row + mg.To.Row) / 2142 return row, va.Offset(1, m.shape(row).Lines, true)143 }144 type spot struct{ row, k, at int }145 var spots []spot146 at := 0 // lines so far147 for r := mg.From.Row; r <= mg.To.Row; r++ {148 if m.sheet.RowHidden(r) {149 continue150 }151 sh := m.shape(r)152 if sh.Rule && len(spots) > 0 {153 at++154 }155 for k := range sh.Lines {156 spots = append(spots, spot{r, k, at})157 at++158 }159 }160 if len(spots) == 0 {161 return mg.From.Row, 0162 }163 target, best := va.Offset(1, at, true), spots[0]164 for _, s := range spots[1:] {165 if s.at <= target {166 best = s167 }168 }169 return best.row, best.k170}171172// maxMergeWalk is the tallest merge whose lines are counted to find its173// middle; a taller one takes the middle of all its rows.174const maxMergeWalk = 10000175176// appendBand appends the lines of a band to lines.177func (m *Model) appendBand(lines []string, b band) []string {178 switch {179 case b.row == divider:180 return append(lines, m.dividerRow())181 case b.row >= sheet.MaxRows:182 return lines183 case b.rule:184 lines = append(lines, m.ruleLine(b.row))185 }186 if b.lines == 1 {187 return append(lines, m.rowLine(b.row, rowtext.Line{N: 1}))188 }189 for k := range b.shown {190 lines = append(lines, m.rowLine(b.row, rowtext.Line{K: k, N: b.lines}))191 }192 return lines193}194195// hEdge is the line drawn along the top of the cell at a, with its196// color: none inside a merge.197func (m *Model) hEdge(a sheet.Addr) sheet.Stroke {198 if a.Col < 0 || a.Row < 0 {199 return sheet.Stroke{}200 }201 if mg, ok := m.sheet.MergeAt(a); ok && a.Row > mg.From.Row {202 return sheet.Stroke{}203 }204 return m.sheet.StrokeAbove(a)205}206207// vEdge is the line drawn along the left of the cell at a, with its208// color: none inside a merge.209func (m *Model) vEdge(a sheet.Addr) sheet.Stroke {210 if a.Col < 0 || a.Row < 0 {211 return sheet.Stroke{}212 }213 if mg, ok := m.sheet.MergeAt(a); ok && a.Col > mg.From.Col {214 return sheet.Stroke{}215 }216 return m.sheet.StrokeLeft(a)217}218219// borderRole is the role a border line of color c is drawn in: the220// text's ink, or its color as a text color of the rules draws it, from221// the terminal's palette.222func (m *Model) borderRole(c sheet.Color) *lipgloss.Style {223 if c == sheet.ColorNone || int(c) >= sheet.NumColors {224 return &m.th.CellBorder225 }226 return &m.th.RuleText[c]227}228229// leftEdge draws the line along the left of the cell at a over the230// first column of its text, in the border's role even in a highlighted231// cell, so the table's lines run on through the pointer and selection.232func (m *Model) leftEdge(a sheet.Addr, text string) string {233 st := m.vEdge(a)234 e := st.Line235 if e == sheet.LineNone {236 return text237 }238 glyph := m.paint(m.borderRole(st.Color), theme.Junction(e, e, sheet.LineNone, sheet.LineNone))239 if strings.HasPrefix(text, " ") { // padding, as it mostly is240 return glyph + text[1:]241 }242 return glyph + ansi.Cut(text, 1, m.sheet.ColWidth(a.Col))243}244245// paintKey is a text drawn in a role.246type paintKey struct {247 role *lipgloss.Style248 text string249}250251// paint draws text in role, as role.Render does, keeping what it drew252// until the next frame: borders draw the same few strings over and over.253func (m *Model) paint(role *lipgloss.Style, text string) string {254 if s, ok := m.painted[paintKey{role, text}]; ok {255 return s256 }257 if m.painted == nil {258 m.painted = map[paintKey]string{}259 }260 s := role.Render(text)261 m.painted[paintKey{role, text}] = s262 return s263}264265// ruleLine draws the border line above row: each column's top edge,266// joined where edges meet, and blank inside merges.267func (m *Model) ruleLine(row int) string {268 above := m.stepRow(row, -1)269 if above == row {270 above = -1271 }272 lc := m.lineContext(row, rowtext.Line{N: 1})273 var b strings.Builder274 b.WriteString(m.th.RowHeader.Render(strings.Repeat(" ", m.hdrW())))275 _, fc := m.frozen()276 if fc > 0 {277 m.ruleCols(&b, &lc, above, 0, fc)278 b.WriteString(m.th.FrozenLine.Render("│"))279 }280 m.ruleCols(&b, &lc, above, m.left, m.visibleCols(m.left))281 return b.String()282}283284// ruleJoint is where the edges at the top-left corner of a cell meet on285// a rule line: its character and color, and whether the double lines to286// its left and right start thick beside it (see theme.Bridges).287type ruleJoint struct {288 glyph string289 color sheet.Color290 bridgeL, bridgeR bool291 hidden bool // inside a merge, where no lines are drawn292 up, down, left, top sheet.Stroke293}294295// ruleJointAt works out the joint at the top-left of the cell at column296// c of lc's row, left being the edge along the top of the column before.297func (m *Model) ruleJointAt(lc *lineCtx, above, c int, left sheet.Stroke) ruleJoint {298 if c >= sheet.MaxCols {299 return ruleJoint{glyph: " "}300 }301 a := sheet.Addr{Col: c, Row: lc.row}302 j := ruleJoint{up: m.vEdge(sheet.Addr{Col: c, Row: above}), down: m.vEdge(a), left: left, top: m.hEdge(a)}303 j.glyph = theme.Junction(j.up.Line, j.down.Line, j.left.Line, j.top.Line)304 j.bridgeL, j.bridgeR = theme.Bridges(j.up.Line, j.down.Line, j.left.Line, j.top.Line)305 // The joint takes the color of the heaviest line it draws: a thick306 // one where it draws double lines thick (see theme.Junction).307 arms := [...]sheet.Stroke{j.up, j.down, j.left, j.top}308 weight := func(l sheet.Line) int {309 if l == sheet.LineDouble && theme.Mixed(j.up.Line, j.down.Line, j.left.Line, j.top.Line) {310 return int(sheet.LineThin)311 }312 return int(l)313 }314 heaviest := sheet.Stroke{}315 for _, st := range arms {316 if weight(st.Line) > weight(heaviest.Line) {317 heaviest = st318 }319 }320 j.color = heaviest.Color321 if mg, ok := lc.mergeOf(c); ok && lc.row > mg.From.Row && c > mg.From.Col {322 j.hidden, j.glyph = true, " "323 }324 return j325}326327// ruleCols draws the rule line of ncols columns from first.328func (m *Model) ruleCols(b *strings.Builder, lc *lineCtx, above, first, ncols int) {329 j := m.ruleJointAt(lc, above, first, sheet.Stroke{})330 for c := first; c < first+ncols; c++ {331 a := sheet.Addr{Col: c, Row: lc.row}332 w := m.sheet.ColWidth(c)333 h := j.top334 style, colored := m.borderRole(h.Color), false335 if lc.selecting && lc.sel.Contains(a) && lc.sel.Contains(sheet.Addr{Col: c, Row: above}) {336 style, colored = &m.th.Selection, true337 }338 if mg, ok := lc.mergeOf(c); ok && lc.row > mg.From.Row && mg.From == lc.focus {339 style, colored = &m.th.Pointer, true340 }341 next := m.ruleJointAt(lc, above, c+1, h)342 switch {343 case !colored && j.glyph == " " && h.Line == sheet.LineNone:344 b.WriteString(strings.Repeat(" ", w))345 case j.glyph == " ":346 b.WriteString(m.paint(style, j.glyph+strings.Repeat(hLine(h.Line), w-1)))347 default: // the joint in the border's role, as leftEdge draws the lines through it348 b.WriteString(m.paint(m.borderRole(j.color), j.glyph))349 m.ruleRun(b, style, colored, h.Line, w-1, j, next)350 }351 j = next352 }353}354355// hLine is the character of a horizontal line drawn with l.356func hLine(l sheet.Line) string { return theme.Junction(sheet.LineNone, sheet.LineNone, l, l) }357358// ruleRun draws the n characters of line l along the top of a cell,359// between joints from and to, in style: a double line meeting a thick360// joint starts or ends with a thick stub in the joint's color, as the361// joint's arm running on (see theme.Bridges).362func (m *Model) ruleRun(b *strings.Builder, style *lipgloss.Style, colored bool, l sheet.Line, n int, from, to ruleJoint) {363 if n <= 0 {364 return365 }366 left, right := from.bridgeR, to.bridgeL && (n > 1 || !from.bridgeR)367 stub := hLine(sheet.LineThick)368 stubStyle := func(j ruleJoint) *lipgloss.Style {369 if colored {370 return style371 }372 return m.borderRole(j.color)373 }374 mid := n375 if left {376 b.WriteString(m.paint(stubStyle(from), stub))377 mid--378 }379 if right {380 mid--381 }382 b.WriteString(m.paint(style, strings.Repeat(hLine(l), max(mid, 0))))383 if right {384 b.WriteString(m.paint(stubStyle(to), stub))385 }386}387388// showRowHandle reports whether row's resize handle is visible: while389// hovering it or dragging it.390func (m *Model) showRowHandle(row int) bool {391 if m.mouse.drag == dragRowResize {392 return m.mouse.resizeRow == row393 }394 return m.mouse.hover.kind == hitRowBorder && m.mouse.hover.addr.Row == row395}