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 / view.go
view.go 10.9K
1package ui23import (4 "image/color"56 "strings"78 "charm.land/lipgloss/v2"9 "github.com/charmbracelet/x/ansi"1011 "src.rest/cat/012/internal/sheet"12 "src.rest/cat/012/internal/ui/rowtext"13 "src.rest/cat/012/internal/ui/theme"14)1516// active is the cell drawn as the cell pointer: the pointer while17// pointing, otherwise the active cell (which stays put while a selection is18// extended, as in Sheets).19func (m *Model) active() sheet.Addr {20 if m.mode == modePoint || m.pointing() || m.away() {21 return m.point.at22 }23 if a, ok := m.barActive(); ok {24 return a25 }26 return m.cur27}2829// highlight is the range drawn as selected: the pointer's range while30// pointing, otherwise the selection.31func (m *Model) highlight() (sheet.Rect, bool) {32 switch {33 case m.mode == modePoint || m.pointing():34 return m.point.rect(), true35 case m.away(): // the selection is on the entry's sheet36 return sheet.Rect{}, false37 case m.mouse.drag == dragFill:38 return m.mouse.fillTo, true39 }40 if r, ok := m.barRange(); ok {41 return r, true42 }43 return m.selection(), m.hasRange()44}4546// screenCols returns the columns drawn left to right, with divider where47// the frozen columns end.48func (m *Model) screenCols() []int {49 _, fc := m.frozen()50 out := make([]int, 0, fc+1+m.visibleCols(m.left))51 for c := range fc {52 out = append(out, c)53 }54 if fc > 0 {55 out = append(out, divider)56 }57 for i := range m.visibleCols(m.left) {58 out = append(out, m.left+i)59 }60 return out61}6263func (m *Model) headerRow() string {64 var b strings.Builder65 b.WriteString(m.th.Header.Render(strings.Repeat(" ", m.hdrW())))66 focus := m.sheet.Grow(sheet.Rect{From: m.active(), To: m.active()}) // a merged cell's columns67 sel, selecting := m.highlight()68 if m.unfocused() {69 focus, selecting = sheet.Rect{From: sheet.Addr{Col: -1}, To: sheet.Addr{Col: -1}}, false70 }71 for _, c := range m.screenCols() {72 if c == divider {73 b.WriteString(m.th.FrozenLine.Render("│"))74 continue75 }76 w := m.sheet.ColWidth(c)77 style, plain := m.th.Header, false78 switch {79 case c >= focus.From.Col && c <= focus.To.Col:80 style = m.th.HeaderActive81 case selecting && c >= sel.From.Col && c <= sel.To.Col:82 style = m.th.HeaderSel83 case m.mouse.hover.addr.Col == c && (m.mouse.hover.kind == hitColHeader || m.mouse.hover.kind == hitColBorder || m.mouse.hover.kind == hitFilterButton):84 style = m.th.HeaderHover85 default:86 plain = true87 }88 mark, on := m.filterMark(c)89 name := m.colLabel(c, w, 2*boolInt(mark != ""))90 label := style.Render(theme.Center(name, w))91 if mark != "" && w >= ansi.StringWidth(name)+3 {92 // The filter's button follows the letter, e.g. "B ▾".93 text := theme.Center(name+" "+mark, w)94 k := strings.Index(text, mark)95 markStyle := style96 if on && plain {97 markStyle = m.th.FilterOn98 }99 label = style.Render(text[:k]) + markStyle.Render(mark) + style.Render(text[k+len(mark):])100 }101 if m.showHandle(c) && w > 1 {102 // Draw the resize handle in the header's last cell.103 b.WriteString(ansi.Truncate(label, w-1, "") + m.th.Handle.Render("▐"))104 continue105 }106 b.WriteString(label)107 }108 return b.String()109}110111// showHandle reports whether column c's resize handle is visible: while112// hovering it or dragging it.113func (m *Model) showHandle(c int) bool {114 if m.mouse.drag == dragResize {115 return m.mouse.resizeCol == c116 }117 return m.mouse.hover.kind == hitColBorder && m.mouse.hover.addr.Col == c118}119120// gridRow draws a row one line tall.121func (m *Model) gridRow(row int) string {122 if row >= sheet.MaxRows {123 return ""124 }125 if row == divider {126 return m.dividerRow()127 }128 return m.rowLine(row, rowtext.Line{N: 1})129}130131// rowLine draws line ln of a row: its number on the last line, beside132// its values, so a tall row ends on the line that numbers it, then its133// cells.134func (m *Model) rowLine(row int, ln rowtext.Line) string {135 lc := m.lineContext(row, ln)136 hdr := m.th.RowHeader137 switch {138 case row == lc.focus.Row || len(lc.merges) > 0 && lc.merged(sheet.Addr{Col: lc.focus.Col, Row: row}, lc.focus):139 hdr = m.th.HeaderActive140 case lc.selecting && row >= lc.sel.From.Row && row <= lc.sel.To.Row:141 hdr = m.th.HeaderSel142 case m.mouse.hover.kind == hitRowHeader && m.mouse.hover.addr.Row == row:143 hdr = m.th.HeaderHover144 }145 var b strings.Builder146 num := ""147 if ln.K == ln.N-1 {148 num = m.rowLabel(row)149 }150 tags, n := "", 0151 if ln.K == ln.N-1 && len(m.share.frame.here) > 0 {152 tags, n = m.rowTags(row)153 }154 if m.showRowHandle(row) && ln.K == ln.N-1 {155 b.WriteString(tags + hdr.Render(theme.PadLeft(num, m.hdrW()-1-n)) + m.th.Handle.Render("▄"))156 } else {157 b.WriteString(tags + hdr.Render(theme.PadLeft(num, m.hdrW()-1-n)+" "))158 }159160 _, fc := m.frozen()161 if fc > 0 {162 b.WriteString(m.cellsText(&lc, 0, m.layoutLine(&lc, 0, fc, 0, fc-1)))163 b.WriteString(m.th.FrozenLine.Render("│"))164 }165 ncols := m.visibleCols(m.left)166 b.WriteString(m.cellsText(&lc, m.left, m.layoutLine(&lc, m.left, ncols, fc, sheet.MaxCols-1)))167 return b.String()168}169170// cellsText draws the spans of a line of cells from column first on, in171// the roles for the pointer, the selection, search matches and errors.172func (m *Model) cellsText(lc *lineCtx, first int, spans []rowtext.Span) string {173 row := lc.row174 var b strings.Builder175 spills := m.sheet.HasSpills()176 rules := m.sheet.HasRules()177 var rgb func(int) color.Color178 if rules {179 rgb = m.slotColor180 }181 // A shade is a whole role, so it is kept across the loop and read182 // through a pointer rather than zeroed and copied for every cell.183 var shade theme.Shade184 for i, sp := range spans {185 a := sheet.Addr{Col: first + i, Row: row}186 base, colored := m.cellRole(lc, a, &sp, spills)187 var look sheet.Look188 shaded := false189 if piece, typing := m.entryPiece(lc, a); typing { // gridlines.go190 sp = rowtext.Span{Text: piece}191 } else {192 m.decorate(&sp, row) // links and error marks, see links.go193 if rules {194 look, shade, shaded = m.ruleSpan(a, &sp, colored, rgb) // looks.go195 if look.Checkbox && !m.valueLine(lc, a) {196 sp = rowtext.Span{Trail: m.sheet.ColWidth(a.Col)}197 }198 }199 }200 if shaded {201 base, colored = &shade.Style, true202 }203 // The copy marker is layered on the cell's own colors.204 if lc.bottom() && m.copied.marks(m.sheet, a) {205 marked := theme.Drawable(base.Inherit(m.th.Copied))206 base, colored, shaded = &marked, true, false207 }208 text := m.cellText(lc, a, sp, &look, base, colored, &shade, shaded)209 b.WriteString(m.cellMarks(lc, a, text, &look, base, colored))210 }211 return b.String()212}213214// cellText draws a cell's span on its role: as a rule's shade, with its215// data bar or icon, as a dropdown's chip (taking the look's ▾, which the216// chip holds), or plainly.217func (m *Model) cellText(lc *lineCtx, a sheet.Addr, sp rowtext.Span, look *sheet.Look, base *lipgloss.Style, colored bool, shade *theme.Shade, shaded bool) string {218 switch {219 case look.Bar || look.Icon != "" || look.ValueHidden:220 return m.barText(sp, m.sheet.ColWidth(a.Col), look, base, colored, m.valueLine(lc, a))221 case look.Dropdown && look.Display != sheet.DropArrow:222 chip, ok := "", false223 if look.Display == sheet.DropChip && !colored && sp.Owner == a.Col && m.valueLine(lc, a) {224 chip, ok = m.chipText(sp, m.sheet.ColWidth(a.Col))225 }226 if ok || look.Display == sheet.DropPlain {227 look.Dropdown = false // no ▾ at the right228 }229 if ok {230 return chip231 }232 case shaded && plainSpan(sp):233 return shade.Wrap(strings.Repeat(" ", sp.Lead) + sp.Text + strings.Repeat(" ", sp.Trail))234 }235 return renderSpan(&m.th, sp, base, colored)236}237238// cellRole is the role a cell is drawn in: the pointer, the selection,239// a search match, a trace, an error or a spill, a table's header or240// band, or the plain cell role (colored false). Roles are large, so it points at the theme's.241func (m *Model) cellRole(lc *lineCtx, a sheet.Addr, sp *rowtext.Span, spills bool) (*lipgloss.Style, bool) {242 switch {243 case a == lc.focus || lc.merges != nil && lc.merged(a, lc.focus):244 return &m.th.Pointer, true245 case lc.selecting && lc.sel.Contains(a):246 return &m.th.Selection, true247 case m.found(a):248 return &m.th.Found, true249 }250 if r, ok := m.peerRole(a); ok {251 return r, true252 }253 if r := m.traceRole(a); r != nil {254 return r, true255 }256 switch v := m.sheet.Value(a); {257 case sheet.IsPending(v):258 return &m.th.Muted, true259 case v.Kind == sheet.Error:260 return &m.th.ErrorCell, true261 case spills && sp.Text != "" && m.sheet.Cell(sheet.Addr{Col: sp.Owner, Row: lc.row}).Spilled():262 return &m.th.Spilled, true263 }264 if m.sheet.HasTables() {265 switch header, band := m.sheet.TableLook(a); {266 case header:267 return &m.th.TableHeader, true268 case band:269 return &m.th.TableBand, true270 }271 }272 return &m.th.Cell, false273}274275// cellMarks draws a cell's marks over its text: the fill handle, a276// note's corner or a dropdown's ▾, and the border along its left.277func (m *Model) cellMarks(lc *lineCtx, a sheet.Addr, text string, look *sheet.Look, base *lipgloss.Style, colored bool) string {278 w := m.sheet.ColWidth(a.Col)279 switch {280 case lc.bottom() && m.showFillHandle(a):281 text = ansi.Truncate(text, w-1, "") + base.Render("▟")282 case lc.ln.K == 0 && m.sheet.Note(a) != "":283 text = m.noteMark(text, a, *base, colored)284 case look.Dropdown && m.valueLine(lc, a):285 text = m.dropdownMark(text, w, *base, colored)286 }287 if lc.ln.K == 0 && len(m.share.frame.fresh) > 0 {288 text = m.changedMark(text, a, *base, colored)289 }290 if lc.ln.K == 0 && len(m.agents.frame.cells) > 0 {291 text = m.suggestedMark(text, a, *base, colored)292 }293 if lc.shaped {294 text = m.leftEdge(a, text)295 }296 return text297}298299// dividerRow is the line under the frozen rows, crossing the one right of300// the frozen columns.301func (m *Model) dividerRow() string {302 var b strings.Builder303 b.WriteString(strings.Repeat("─", m.hdrW()))304 for _, c := range m.screenCols() {305 if c == divider {306 b.WriteString("┼")307 continue308 }309 b.WriteString(strings.Repeat("─", m.sheet.ColWidth(c)))310 }311 return m.th.FrozenLine.Render(ansi.Truncate(b.String(), m.width, ""))312}313314// plainSpan reports whether a span's text has no style of its own: no315// text style, link or mark.316func plainSpan(sp rowtext.Span) bool {317 st := sp.Style318 st.Align = sheet.AlignAuto319 return st.IsZero() && sp.Link == "" && !sp.Error && !sp.Invalid320}321322// renderSpan draws a span on base, one of the cell roles. Plain cells323// with no text style are written without escape codes.324// Roles are large, so base is read through a pointer.325func renderSpan(th *theme.Theme, sp rowtext.Span, base *lipgloss.Style, colored bool) string {326 lead, trail := strings.Repeat(" ", sp.Lead), strings.Repeat(" ", sp.Trail)327 plain := plainSpan(sp)328 switch {329 case plain && !colored:330 return lead + sp.Text + trail331 case plain:332 return base.Render(lead + sp.Text + trail)333 case !colored:334 return lead + spanStyle(th, *base, sp).Render(sp.Text) + trail335 }336 return base.Render(lead) + spanStyle(th, *base, sp).Render(sp.Text) + base.Render(trail)337}338339// inCellText shows the entry being typed inside the cell, keeping the end340// of long entries visible, as Sheets does.341func (m *Model) inCellText(w int) string {342 text := " " + m.line.Text()343 if over := ansi.StringWidth(text) - w; over > 0 {344 text = ansi.TruncateLeft(text, over+1, "…")345 }346 return theme.PadRight(text, w)347}