1package ui23import (4 "path/filepath"5 "slices"6 "strconv"7 "strings"89 tea "charm.land/bubbletea/v2"10 "charm.land/lipgloss/v2"11 "github.com/charmbracelet/x/ansi"1213 "src.rest/cat/012/internal/sheet"14 "src.rest/cat/012/internal/ui/theme"15)1617// Stepping through precedents and dependents, after Excel's Ctrl+[ and18// Ctrl+]: the cells a formula reads, or the formulas that read a cell,19// light up and the active cell jumps to the first; pressing the key20// again steps through the rest. Ctrl+[ is Esc to most terminals, so the21// keys are Alt+, and Alt+. (the < and > keys: back to the inputs, on to22// the results). The highlight is transient, as a search's is: any other23// key or click ends it, and Esc returns to the traced cell. Tracing that24// stays on as the pointer moves is traceview.go.2526type trace struct {27 dependents bool28 home *sheet.Sheet // the traced cell's sheet29 origin sheet.Addr30 targets []sheet.Link // on any sheet: stepping to one shows its sheet31 at int // the target the active cell is on32}3334// maxListed caps the dependents found for a cell, so one read by a35// million formulas lists a thousand and says there are more.36const maxListed = 10003738func init() {39 register(40 &command{id: "data.precedents", macro: macroView, title: "Trace precedents", desc: "Highlight the cells the formula reads; press again to jump through them", run: func(m *Model) tea.Cmd {41 m.stepTrace(false)42 return nil43 }},44 &command{id: "data.dependents", macro: macroView, title: "Trace dependents", desc: "Highlight the formulas that read the cell; press again to jump through them", run: func(m *Model) tea.Cmd {45 m.stepTrace(true)46 return nil47 }},48 )49 keymap["alt+,"] = "data.precedents"50 keymap["alt+."] = "data.dependents"51}5253// stepTrace starts a trace from the active cell, or moves to the next54// target of the one showing.55func (m *Model) stepTrace(dependents bool) {56 t := m.trace57 if t == nil || t.dependents != dependents {58 t = &trace{dependents: dependents, home: m.sheet, origin: m.cur, at: -1}59 if dependents {60 t.targets, _ = m.sheet.DependentLinks(m.cur, maxListed)61 } else {62 t.targets = m.sheet.PrecedentLinks(m.cur)63 }64 // Cells on hidden sheets can't be shown, nor a notebook's cell65 // among the grid's, so the trace skips them.66 hidden := hiddenSheets(t.targets)67 t.targets = slices.DeleteFunc(t.targets, func(l sheet.Link) bool { return l.Sheet.Hidden() || !l.OnGrid() })68 if len(t.targets) == 0 {69 m.trace = nil70 m.note = noTrace(dependents, m.cur, hidden)71 return72 }73 m.trace = t74 }75 t.at = (t.at + 1) % len(t.targets)76 m.showSheet(t.targets[t.at].Sheet)77 m.clearSelection()78 m.cur = t.targets[t.at].Range.From79}8081// hiddenSheets names the hidden sheets links are on, in order.82func hiddenSheets(links []sheet.Link) []string {83 var out []string84 for _, l := range links {85 if l.Sheet.Hidden() && !slices.Contains(out, l.Sheet.Name()) {86 out = append(out, l.Sheet.Name())87 }88 }89 return out90}9192// noTrace says why a trace from a found nothing to show: nothing to93// find, or only cells on hidden sheets, which it names.94func noTrace(dependents bool, a sheet.Addr, hidden []string) string {95 if len(hidden) == 0 {96 if dependents {97 return "No formulas read " + a.String()98 }99 return a.String() + " has no formula reading other cells"100 }101 names, what, it := hidden[0], "a hidden sheet", "it"102 if n := len(hidden); n > 1 {103 names = strings.Join(hidden[:n-1], ", ") + " and " + hidden[n-1]104 what, it = "hidden sheets", "them"105 }106 if dependents {107 return "Only formulas on " + names + ", " + what + ", read " + a.String() + "; View > Hidden sheets shows " + it108 }109 return "Reads only " + names + ", " + what + "; View > Hidden sheets shows " + it110}111112// traceKey ends the trace on any key but the trace commands, and returns113// to the traced cell on Esc. It reports whether it used the key.114func (m *Model) traceKey(k tea.KeyPressMsg) bool {115 if m.trace == nil {116 return false117 }118 id := keymap[canonicalKey(k.String())]119 if m.mode == modeReady && (id == "data.precedents" || id == "data.dependents") {120 return false121 }122 t := m.trace123 m.trace = nil124 if m.mode == modeReady && k.String() == "esc" {125 m.showSheet(t.home)126 m.cur = t.origin127 return true128 }129 return false130}131132// role is the role a, on the sheet shown, is drawn in while it's being133// traced, or nil.134func (t *trace) role(th *theme.Theme, shown *sheet.Sheet, a sheet.Addr) *lipgloss.Style {135 if t == nil {136 return nil137 }138 for _, tg := range t.targets {139 if tg.Sheet == shown && tg.Range.Contains(a) {140 if t.dependents {141 return &th.Dependent142 }143 return &th.Precedent144 }145 }146 return nil147}148149// line is the context line during a trace, e.g. "2 precedents of150// C7: B3, Sales", the current one emphasized, and the keys.151func (t *trace) line(th *theme.Theme, width int, shown *sheet.Sheet) (left, right string) {152 noun, id := "precedent", "data.precedents"153 if t.dependents {154 noun, id = "dependent", "data.dependents"155 }156 if len(t.targets) != 1 {157 noun += "s"158 }159 right = th.KeyHints(shortcut(id), "next", "Esc", "back")160 origin := t.origin.String()161 if t.home != shown {162 origin = sheet.Qualified(t.home.Name(), sheet.Rect{From: t.origin, To: t.origin})163 }164 left = strconv.Itoa(len(t.targets)) + " " + noun + " of " + origin + ": "165 room := width - ansi.StringWidth(right) - 3166 if room < ansi.StringWidth(left)+12 {167 right, room = "", width168 }169 labels := make([]string, len(t.targets))170 for i, tg := range t.targets {171 labels[i] = linkLabel(shown, tg, t.home)172 if i == t.at {173 labels[i] = th.Key.Render(labels[i])174 }175 }176 return left + listFit(th, labels, len(labels), room-ansi.StringWidth(left), false), right177}178179// listFit joins labels, the first of total, with commas in room180// columns, ending in "+3 more" when they don't all fit, or "and more"181// when there are more than total (more).182func listFit(th *theme.Theme, labels []string, total, room int, more bool) string {183 var b strings.Builder184 for i, part := range labels {185 if i > 0 {186 part = ", " + part187 }188 rest := " +" + strconv.Itoa(total-i) + " more"189 if more {190 rest = " and more"191 }192 if ansi.StringWidth(b.String()+part)+len(rest) > room && (i < total-1 || more) {193 b.WriteString(th.Muted.Render(rest))194 return b.String()195 }196 b.WriteString(part)197 }198 switch {199 case more:200 b.WriteString(th.Muted.Render(" and more"))201 case len(labels) < total:202 b.WriteString(th.Muted.Render(" +" + strconv.Itoa(total-len(labels)) + " more"))203 }204 return b.String()205}206207// linkLabel names a link the way a formula on from refers to it: by the208// name of its named range or region, with its sheet when it's on209// another, or by what a region's cells come from.210func linkLabel(shown *sheet.Sheet, l sheet.Link, from *sheet.Sheet) string {211 switch l.Kind {212 case sheet.LinkName, sheet.LinkRegion, sheet.LinkTable:213 return l.Name214 case sheet.LinkFile:215 return filepath.Base(l.Name)216 case sheet.LinkOutput:217 return "notebook cell " + l.Name218 }219 for _, n := range shown.Names() {220 if !n.Gone() && n.Sheet == l.Sheet && n.Range == l.Range && l.Range.From != l.Range.To {221 return n.Name222 }223 }224 if l.Sheet != from {225 return sheet.Qualified(l.Sheet.Name(), l.Range)226 }227 return l.Range.String()228}