cat / 012
clone

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

Float charts over the grid with an editor bar, and images on kitty terminals

Dami committed 6bb40e7
Links render as OSC 8 hyperlinks, errors get a curly underline and an
explanation on the context line, the theme follows live light and dark
changes, and a helper notifies when long jobs finish in the background.
commit
6bb40e768161043ba6dd93e367dfe7b1df4a09df
parent
897b4ce918cc94ae90416a395c533e6fa858ba40
author
Dami <dami.etoile@gmail.com>
14 files changed, +1528 -22
Ainternal/ui/charts.go772++++++++++++++++++++++++++++++
Ainternal/ui/charts_test.go267++++++++++
Minternal/ui/dialog.go4+-
Ainternal/ui/graphics.go212++++++++
Ainternal/ui/links.go54++
Ainternal/ui/links_test.go116+++++
Minternal/ui/menu.go4+-
Minternal/ui/menubar.go3+-
Minternal/ui/model.go16+-
Minternal/ui/mouse.go9+-
Minternal/ui/overlay.go13+-
Minternal/ui/panel.go8+-
Minternal/ui/theme.go58++-
Minternal/ui/view.go14+-
A internal/ui/charts.go +772 -0
@@ -0,0 +1,772 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "charm.land/lipgloss/v2"9+ "github.com/charmbracelet/x/ansi"10+11+ "012/internal/chart"12+ "012/internal/sheet"13+)14+15+// Charts follow Sheets: Insert > Chart charts the selection (or the block16+// of data around the active cell) and opens the chart editor, a bar on the17+// context line for the type, series direction, headers, range and title.18+// Charts float over the grid anchored at a cell, redraw as their data19+// recalculates, and can be dragged, resized from the corner, and deleted20+// with Del while selected. On terminals with kitty graphics the plot is an21+// image (graphics.go); labels, legend and frame are always text.22+23+// chartBoxID is the id of chart i's box in hit tests.24+func chartBoxID(i int) string { return "chart" + strconv.Itoa(i) }25+26+// Default size of a new chart, in cells; smaller screens get smaller27+// charts.28+const newChartW, newChartH = 48, 1629+30+func init() {31+ register(32+ &command{id: "insert.chart", title: "Chart", desc: "Chart the selected data, or the table around the active cell", run: (*Model).insertChart},33+ &command{id: "chart.edit", title: "Edit chart", desc: "Change a chart's type, data and labels",34+ enabled: hasCharts, run: func(m *Model) tea.Cmd {35+ m.openChartEditor(m.targetChart(), false, m.sheet.StateID())36+ return nil37+ }},38+ &command{id: "chart.delete", title: "Delete chart", desc: "Remove a chart from the sheet",39+ enabled: hasCharts, run: func(m *Model) tea.Cmd {40+ m.deleteChart(m.targetChart())41+ return nil42+ }},43+ &command{id: "chart.select", title: "Select chart", desc: "Select the next chart, to move, resize or delete it with the keyboard",44+ enabled: hasCharts, run: func(m *Model) tea.Cmd {45+ i := 046+ if s, ok := m.overlay.(*chartSel); ok {47+ i = (s.i + 1) % len(m.sheet.Charts())48+ }49+ m.selectChart(i)50+ m.showChart(i)51+ return nil52+ }},53+ )54+}55+56+func hasCharts(m *Model) bool { return len(m.sheet.Charts()) > 0 }57+58+// targetChart is the chart a chart command acts on: the selected one, the59+// last one selected, one charting the active cell, or the newest.60+func (m *Model) targetChart() int {61+ charts := m.sheet.Charts()62+ switch o := m.overlay.(type) {63+ case *chartSel:64+ return o.i65+ case *chartEditor:66+ return o.i67+ }68+ if m.lastChart >= 0 && m.lastChart < len(charts) {69+ return m.lastChart70+ }71+ for i, c := range charts {72+ if c.Data.Contains(m.cur) {73+ return i74+ }75+ }76+ return len(charts) - 177+}78+79+// insertChart charts the selection and opens the editor.80+func (m *Model) insertChart() tea.Cmd {81+ r := m.selection()82+ if !m.hasRange() {83+ r = m.sheet.Region(m.cur)84+ }85+ if used, ok := m.sheet.UsedRange(); !ok || r.From.Col > used.To.Col || r.From.Row > used.To.Row {86+ m.note = "Select the data to chart first"87+ return nil88+ }89+ start := m.sheet.StateID()90+ c := m.sheet.GuessChart(r)91+ c.W = clamp(newChartW, sheet.MinChartW, max(m.width-rowHdrW-2, sheet.MinChartW))92+ c.H = clamp(newChartH, sheet.MinChartH, max(m.visibleRows()-1, sheet.MinChartH))93+ // Next to the data when it fits on screen, as Sheets does; otherwise94+ // below it.95+ c.At = sheet.Addr{Col: c.Data.To.Col + 1, Row: c.Data.From.Row}96+ if m.colStart(c.At.Col)+c.W > m.width {97+ c.At = sheet.Addr{Col: c.Data.From.Col, Row: c.Data.To.Row + 2}98+ }99+ i := m.sheet.AddChart(c)100+ m.changed = true101+ m.clearSelection()102+ m.showChart(i)103+ m.openChartEditor(i, true, start)104+ return nil105+}106+107+// showChart scrolls so chart i is on screen, as far as it fits.108+func (m *Model) showChart(i int) {109+ charts := m.sheet.Charts()110+ if i < 0 || i >= len(charts) {111+ return112+ }113+ c := charts[i]114+ rows := m.visibleRows()115+ if c.At.Row < m.top || c.At.Row+c.H > m.top+rows {116+ m.top = clamp(c.At.Row+c.H-rows, 0, c.At.Row)117+ }118+ if c.At.Col < m.left || m.colStart(c.At.Col)+c.W > m.width {119+ m.left = c.At.Col120+ for m.left > 0 && m.colStart(c.At.Col)+c.W+m.sheet.ColWidth(m.left-1) <= m.width {121+ m.left--122+ }123+ }124+}125+126+func (m *Model) deleteChart(i int) {127+ if i < 0 || i >= len(m.sheet.Charts()) {128+ return129+ }130+ m.sheet.DeleteChart(i)131+ m.changed = true132+ m.lastChart = -1133+ if _, ok := m.overlay.(*chartSel); ok {134+ m.closeOverlay()135+ }136+ m.note = "Deleted the chart " + m.keyHints(shortcut("edit.undo"), "undo")137+}138+139+// displayCharts are the charts as drawn: with the one being dragged at140+// its live position.141+func (m *Model) displayCharts() []sheet.Chart {142+ charts := m.sheet.Charts()143+ if s, ok := m.overlay.(*chartSel); ok && s.preview != nil && s.i < len(charts) {144+ charts[s.i] = *s.preview145+ }146+ return charts147+}148+149+// chartInner is the size of a chart's contents inside its frame and one150+// column of padding each side.151+func chartInner(c sheet.Chart) (w, h int) { return c.W - 4, c.H - 2 }152+153+// chartScreen returns where chart c's top-left corner is on screen; it154+// may be off screen.155+func (m *Model) chartScreen(c sheet.Chart) (x, y int) {156+ return m.colStart(c.At.Col), gridTop + c.At.Row - m.top157+}158+159+// chartAt returns the topmost chart drawn under x, y, or -1.160+func (m *Model) chartAt(x, y int) int {161+ if y < gridTop || y >= gridTop+m.visibleRows() || x < rowHdrW {162+ return -1163+ }164+ charts := m.displayCharts()165+ for i := len(charts) - 1; i >= 0; i-- {166+ cx, cy := m.chartScreen(charts[i])167+ if x >= cx && x < cx+charts[i].W && y >= cy && y < cy+charts[i].H {168+ return i169+ }170+ }171+ return -1172+}173+174+// selectedChart is the index of the selected chart, or -1.175+func (m *Model) selectedChart() int {176+ switch o := m.overlay.(type) {177+ case *chartSel:178+ return o.i179+ case *chartEditor:180+ return o.i181+ }182+ return -1183+}184+185+// chartBoxes draws the charts on screen, clipped to the grid, bottom186+// first.187+func (m *Model) chartBoxes() []box {188+ var boxes []box189+ sel := m.selectedChart()190+ top, bottom := gridTop, gridTop+m.visibleRows()191+ for i, c := range m.displayCharts() {192+ x, y := m.chartScreen(c)193+ if x >= m.width || y >= bottom || x+c.W <= rowHdrW || y+c.H <= top {194+ continue195+ }196+ lines := m.drawChart(i, c, i == sel)197+ // Clip to the grid: the headers and panel stay on top.198+ from, to := max(rowHdrW-x, 0), min(c.W, m.width-x)199+ b := box{id: chartBoxID(i), x: x + from, y: max(y, top)}200+ for k, l := range lines {201+ if y+k >= top && y+k < bottom {202+ b.lines = append(b.lines, ansi.Cut(l, from, to))203+ }204+ }205+ if len(b.lines) > 0 && to > from {206+ boxes = append(boxes, b)207+ }208+ }209+ return boxes210+}211+212+// drawChart renders chart i in its frame: the title in the top border,213+// the data range in the bottom one, and when selected a highlighted214+// border with a resize handle in the corner.215+func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {216+ w, h := chartInner(c)217+ o := m.chartOptions()218+ if i >= maxImages {219+ o.Image = false220+ }221+ g := chart.Draw(c.Type, m.sheet.ChartData(c), w, h, o)222+ border := m.th.chartFrame223+ if selected {224+ border = m.th.chartSelected225+ }226+ title := c.Title227+ if title == "" {228+ title = c.Type.Title() + " chart"229+ }230+ title = ansi.Truncate(" "+title+" ", c.W-4, "…")231+ footer := " " + c.Data.String() + " "232+ lines := make([]string, 0, c.H)233+ lines = append(lines, border.Render("┌─")+m.th.title.Render(title)+234+ border.Render(strings.Repeat("─", max(c.W-3-ansi.StringWidth(title), 0))+"┐"))235+ for y := range h {236+ lines = append(lines, border.Render("│")+" "+m.chartRow(g, y, firstImageID+i, o.Image)+" "+border.Render("│"))237+ }238+ corner := "┘"239+ if selected {240+ corner = "◢"241+ }242+ fill := c.W - 2 - ansi.StringWidth(footer) - 1243+ if fill < 1 {244+ lines = append(lines, border.Render("└"+strings.Repeat("─", c.W-2)+corner))245+ } else {246+ lines = append(lines, border.Render("└"+strings.Repeat("─", fill))+m.th.muted.Render(footer)+border.Render("─"+corner))247+ }248+ return lines249+}250+251+// chartRow renders row y of a drawn chart with the theme; with images,252+// the plot area is the image's placeholders.253+func (m *Model) chartRow(g *chart.Grid, y, imageID int, image bool) string {254+ var b, run strings.Builder255+ var style lipgloss.Style256+ styled := false257+ flush := func() {258+ if run.Len() == 0 {259+ return260+ }261+ if styled {262+ b.WriteString(style.Render(run.String()))263+ } else {264+ b.WriteString(run.String())265+ }266+ run.Reset()267+ }268+ prev := -1269+ for x := range g.W {270+ c := g.At(x, y)271+ key, st, isStyled := 0, lipgloss.Style{}, true272+ text := c.Text273+ switch {274+ case image && y >= g.Plot.Min.Y && y < g.Plot.Max.Y && x >= g.Plot.Min.X && x < g.Plot.Max.X:275+ key, st = 1, m.th.imageID(imageID)276+ text = chart.Placeholder(y-g.Plot.Min.Y, x-g.Plot.Min.X)277+ case c.Fg == chart.None && c.Bg == chart.None:278+ key, isStyled = 2, false279+ default:280+ key = 3 + int(c.Fg)*32 + int(c.Bg)281+ st = m.chartRole(c.Fg)282+ if c.Bg >= chart.Series {283+ st = st.Inherit(m.th.seriesBg[int(c.Bg-chart.Series)%chart.Colors])284+ }285+ }286+ if key != prev {287+ flush()288+ prev, style, styled = key, st, isStyled289+ }290+ run.WriteString(text)291+ }292+ flush()293+ return b.String()294+}295+296+func (m *Model) chartRole(r chart.Role) lipgloss.Style {297+ switch {298+ case r == chart.Axis:299+ return m.th.chartAxis300+ case r == chart.Label:301+ return m.th.chartLabel302+ case r == chart.Muted:303+ return m.th.muted304+ case r >= chart.Series:305+ return m.th.series[int(r-chart.Series)%chart.Colors]306+ }307+ return m.th.cell308+}309+310+// chartClick handles a press on the grid in READY: pressing a chart311+// selects it and starts dragging it, and right-clicking opens its menu.312+func (m *Model) chartClick(mouse tea.Mouse) (tea.Cmd, bool) {313+ i := m.chartAt(mouse.X, mouse.Y)314+ if i < 0 {315+ return nil, false316+ }317+ m.selectChart(i)318+ s := m.overlay.(*chartSel)319+ if mouse.Button == tea.MouseRight {320+ m.showContextMenu(chartMenu, mouse.X, mouse.Y+1)321+ return nil, true322+ }323+ s.press(m, mouse.X, mouse.Y)324+ return nil, true325+}326+327+var chartMenu = []menuItem{{cmd: "chart.edit"}, {cmd: "chart.delete"}}328+329+// selectChart selects chart i.330+func (m *Model) selectChart(i int) {331+ m.clearSelection()332+ m.lastChart = i333+ m.openOverlay(&chartSel{i: i})334+}335+336+// setShape sets the terminal's pointer shape, if it changed.337+func (m *Model) setShape(shape string) tea.Cmd {338+ if shape == m.shape {339+ return nil340+ }341+ m.shape = shape342+ return tea.Raw(ansi.SetPointerShape(shape))343+}344+345+// chartSel is a selected chart. Arrows move it a cell at a time, Shift346+// and arrows resize it, Enter edits it and Del deletes it; the mouse347+// drags it or its corner. Other keys deselect it and act as usual.348+type chartSel struct {349+ i int350+ drag int // chartDragNone, chartDragMove or chartDragResize351+ grabX int // where the chart was grabbed, relative to its corner352+ grabY int353+ preview *sheet.Chart // the chart while being dragged354+}355+356+const (357+ chartDragNone = iota358+ chartDragMove359+ chartDragResize360+)361+362+func (s *chartSel) indicator() string { return "CHART" }363+func (s *chartSel) layout(*Model) []box {364+ return nil // drawn with the other charts, see chartBoxes365+}366+367+func (s *chartSel) chart(m *Model) (sheet.Chart, bool) {368+ charts := m.sheet.Charts()369+ if s.i < 0 || s.i >= len(charts) {370+ return sheet.Chart{}, false371+ }372+ return charts[s.i], true373+}374+375+func (s *chartSel) key(m *Model, k tea.KeyPressMsg) tea.Cmd {376+ c, ok := s.chart(m)377+ if !ok {378+ m.closeOverlay()379+ return nil380+ }381+ moved := c382+ switch k.String() {383+ case "esc":384+ m.closeOverlay()385+ return nil386+ case "enter":387+ m.openChartEditor(s.i, false, m.sheet.StateID())388+ return nil389+ case "delete", "backspace":390+ m.deleteChart(s.i)391+ return nil392+ case "tab":393+ m.selectChart((s.i + 1) % len(m.sheet.Charts()))394+ return nil395+ case "up":396+ moved.At.Row--397+ case "down":398+ moved.At.Row++399+ case "left":400+ moved.At.Col--401+ case "right":402+ moved.At.Col++403+ case "shift+up":404+ moved.H--405+ case "shift+down":406+ moved.H++407+ case "shift+left":408+ moved.W -= 2409+ case "shift+right":410+ moved.W += 2411+ default:412+ m.closeOverlay()413+ return m.handleKey(k)414+ }415+ label := "move chart"416+ if moved.W != c.W || moved.H != c.H {417+ label = "resize chart"418+ }419+ m.sheet.SetChart(s.i, moved, label)420+ m.changed = true421+ m.showChart(s.i)422+ return nil423+}424+425+// press starts a drag at x, y: the corner resizes, anywhere else moves.426+func (s *chartSel) press(m *Model, x, y int) {427+ c, ok := s.chart(m)428+ if !ok {429+ return430+ }431+ cx, cy := m.chartScreen(c)432+ s.grabX, s.grabY = x-cx, y-cy433+ s.drag = chartDragMove434+ if x >= cx+c.W-2 && y == cy+c.H-1 {435+ s.drag = chartDragResize436+ }437+ s.preview = &c438+}439+440+func (s *chartSel) mouse(m *Model, e mouseEvent) tea.Cmd {441+ switch e.kind {442+ case mousePress:443+ i := m.chartAt(e.x, e.y)444+ switch {445+ case i < 0:446+ m.closeOverlay()447+ return m.handlePress(tea.Mouse{X: e.x, Y: e.y, Button: e.button})448+ case e.button == tea.MouseRight:449+ m.selectChart(i)450+ m.showContextMenu(chartMenu, e.x, e.y+1)451+ case e.button == tea.MouseLeft:452+ if i != s.i {453+ m.selectChart(i)454+ s = m.overlay.(*chartSel)455+ }456+ s.press(m, e.x, e.y)457+ }458+ case mouseMotion:459+ if s.drag == chartDragNone || s.preview == nil {460+ shape := "default"461+ if i := m.chartAt(e.x, e.y); i >= 0 {462+ shape = "move"463+ if c := m.displayCharts()[i]; i == s.i {464+ cx, cy := m.chartScreen(c)465+ if e.x >= cx+c.W-2 && e.y == cy+c.H-1 {466+ shape = "nwse-resize"467+ }468+ }469+ }470+ return m.setShape(shape)471+ }472+ p := *s.preview473+ if s.drag == chartDragResize {474+ cx, cy := m.chartScreen(p)475+ p.W = clamp(e.x-cx+1, sheet.MinChartW, sheet.MaxChartW)476+ p.H = clamp(e.y-cy+1, sheet.MinChartH, sheet.MaxChartH)477+ } else {478+ p.At = m.chartCellAt(e.x-s.grabX, e.y-s.grabY)479+ }480+ s.preview = &p481+ case mouseRelease:482+ if s.preview != nil && s.drag != chartDragNone {483+ label := "move chart"484+ if s.drag == chartDragResize {485+ label = "resize chart"486+ }487+ before := m.sheet.StateID()488+ m.sheet.SetChart(s.i, *s.preview, label)489+ if m.sheet.StateID() != before {490+ m.changed = true491+ }492+ }493+ s.drag, s.preview = chartDragNone, nil494+ case mouseWheel:495+ m.handleWheel(tea.Mouse{X: e.x, Y: e.y, Button: e.button})496+ }497+ return nil498+}499+500+// chartCellAt returns the cell a chart's corner snaps to when dragged to501+// screen position x, y, which may be past the grid's edges.502+func (m *Model) chartCellAt(x, y int) sheet.Addr {503+ a := sheet.Addr{Row: max(m.top+y-gridTop, 0), Col: m.left}504+ switch {505+ case x >= rowHdrW:506+ if col, _, ok := m.colSpan(x); ok {507+ a.Col = col508+ } else {509+ a.Col = m.left + m.visibleCols(m.left) - 1510+ }511+ default:512+ for cx := rowHdrW; cx > x && a.Col > 0; {513+ a.Col--514+ cx -= m.sheet.ColWidth(a.Col)515+ }516+ }517+ return clampAddr(a)518+}519+520+func (s *chartSel) status(m *Model) (string, string) {521+ c, ok := s.chart(m)522+ if !ok {523+ return "", ""524+ }525+ desc := m.th.muted.Render("Chart of " + c.Data.String())526+ pairs := []string{"Enter", "edit", "Del", "delete", "Arrows", "move", "Shift+arrows", "resize", "Esc", "done"}527+ for {528+ keys := m.keyHints(pairs...)529+ switch {530+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:531+ return desc, keys532+ case desc != "":533+ desc = ""534+ case len(pairs) > 4:535+ pairs = append(pairs[:len(pairs)-4], pairs[len(pairs)-2:]...) // keep Esc536+ default:537+ return "", keys538+ }539+ }540+}541+542+// contextLine says what's selected and how to change it.543+func (s *chartSel) contextLine(m *Model) (string, string) {544+ c, ok := s.chart(m)545+ if !ok {546+ return "", ""547+ }548+ return m.th.key.Render(c.Type.Title()+" chart") + m.th.muted.Render(" of ") + c.Data.String() +549+ m.th.muted.Render(" drag to move, drag the corner to resize"), ""550+}551+552+// chartEditor is the chart editor: a bar on the context line, in the553+// spirit of the find bar, with the type as a row of chips and the options554+// as toggles. Changes show at once; Enter keeps them and Esc undoes them,555+// removing a chart that was just inserted.556+type chartEditor struct {557+ i int558+ start int // the sheet's state before editing, to undo back to559+ isNew bool560+}561+562+// openChartEditor edits chart i. start is the sheet's state to return to563+// on Esc.564+func (m *Model) openChartEditor(i int, isNew bool, start int) {565+ if i < 0 || i >= len(m.sheet.Charts()) {566+ return567+ }568+ m.lastChart = i569+ m.openOverlay(&chartEditor{i: i, start: start, isNew: isNew})570+}571+572+func (e *chartEditor) indicator() string { return "CHART" }573+func (e *chartEditor) layout(*Model) []box { return nil }574+575+func (e *chartEditor) chart(m *Model) sheet.Chart { return m.sheet.Charts()[e.i] }576+577+// set applies a change to the chart as its own undo step.578+func (e *chartEditor) set(m *Model, fn func(c *sheet.Chart)) {579+ c := e.chart(m)580+ fn(&c)581+ m.sheet.SetChart(e.i, c, "edit chart")582+ m.changed = m.sheet.StateID() != m.saved583+}584+585+// Editor keys, also the toggles' mouse targets.586+const (587+ editorSwitch = "s"588+ editorHeader = "h"589+ editorLabels = "l"590+ editorRange = "r"591+ editorTitle = "t"592+)593+594+func (e *chartEditor) key(m *Model, k tea.KeyPressMsg) tea.Cmd {595+ if e.i >= len(m.sheet.Charts()) {596+ m.closeOverlay()597+ return nil598+ }599+ switch key := strings.ToLower(k.String()); key {600+ case "enter":601+ m.selectChart(e.i)602+ if e.isNew {603+ m.note = "Inserted a chart of " + e.chart(m).Data.String()604+ }605+ case "esc":606+ for m.sheet.StateID() != e.start && m.sheet.CanUndo() {607+ m.sheet.Undo()608+ }609+ m.changed = m.sheet.StateID() != m.saved610+ m.closeOverlay()611+ m.lastChart = -1612+ case "left", "right", "shift+tab", "tab":613+ d := 1614+ if key == "left" || key == "shift+tab" {615+ d = -1616+ }617+ e.set(m, func(c *sheet.Chart) {618+ n := len(sheet.ChartTypes)619+ c.Type = sheet.ChartTypes[((int(c.Type)+d)%n+n)%n]620+ })621+ case "1", "2", "3", "4":622+ e.set(m, func(c *sheet.Chart) { c.Type = sheet.ChartTypes[key[0]-'1'] })623+ case editorSwitch:624+ e.set(m, func(c *sheet.Chart) { c.ByRow = !c.ByRow })625+ case editorHeader:626+ e.set(m, func(c *sheet.Chart) { c.Header = !c.Header })627+ case editorLabels:628+ e.set(m, func(c *sheet.Chart) { c.Labels = !c.Labels })629+ case editorRange:630+ e.editRange(m)631+ case editorTitle:632+ e.editTitle(m)633+ }634+ return nil635+}636+637+// reopen returns to the editor after a prompt.638+func (e *chartEditor) reopen(m *Model) {639+ m.openOverlay(e)640+}641+642+// editRange asks for the data range by pointing, starting from the643+// current one.644+func (e *chartEditor) editRange(m *Model) {645+ d := e.chart(m).Data646+ m.closeOverlay()647+ m.cur, m.ext, m.selecting, m.whole = d.From, d.To, true, wholeNone648+ m.openRange("Chart data:", func(m *Model, r sheet.Rect) tea.Cmd {649+ m.clearSelection()650+ e.set(m, func(c *sheet.Chart) { c.Data = r })651+ e.reopen(m)652+ return nil653+ })654+ m.prompt.onCancel = func(m *Model) {655+ m.clearSelection()656+ e.reopen(m)657+ }658+}659+660+func (e *chartEditor) editTitle(m *Model) {661+ title := e.chart(m).Title662+ m.closeOverlay()663+ m.openText("Chart title:", title, func(m *Model, text string) tea.Cmd {664+ e.set(m, func(c *sheet.Chart) { c.Title = text })665+ e.reopen(m)666+ return nil667+ })668+ m.prompt.onCancel = e.reopen669+}670+671+// editorPart is a piece of the editor bar: a type chip or a toggle.672+type editorPart struct {673+ text string674+ key string // the key it stands for675+}676+677+func (e *chartEditor) parts(m *Model) []editorPart {678+ c := e.chart(m)679+ chip := func(on bool, name, key string) editorPart {680+ style := m.th.muted681+ if on {682+ style = m.th.menuSelected683+ }684+ return editorPart{text: style.Render(" " + name + " "), key: key}685+ }686+ var parts []editorPart687+ for i, t := range sheet.ChartTypes {688+ parts = append(parts, chip(t == c.Type, t.Title(), strconv.Itoa(i+1)))689+ }690+ series := "Series in columns"691+ if c.ByRow {692+ series = "Series in rows"693+ }694+ header, labels := "Header row", "Labels in column"695+ if c.ByRow {696+ header, labels = "Header column", "Labels in row"697+ }698+ return append(parts,699+ editorPart{text: m.th.key.Render(c.Data.String()), key: editorRange},700+ chip(true, series, editorSwitch),701+ chip(c.Header, header, editorHeader),702+ chip(c.Labels, labels, editorLabels))703+}704+705+// spans lays the parts out on the context line, dropping options from the706+// right on narrow screens, and returns each part's x.707+func (e *chartEditor) spans(m *Model) ([]editorPart, []int) {708+ parts := e.parts(m)709+ for {710+ xs := make([]int, len(parts))711+ x := 0712+ for i, p := range parts {713+ if i == len(sheet.ChartTypes) {714+ x += 2 // a wider gap between the types and the options715+ }716+ xs[i] = x717+ x += ansi.StringWidth(p.text) + 1718+ }719+ if x-1 <= m.width || len(parts) <= len(sheet.ChartTypes) {720+ return parts, xs721+ }722+ parts = parts[:len(parts)-1]723+ }724+}725+726+func (e *chartEditor) contextLine(m *Model) (string, string) {727+ if e.i >= len(m.sheet.Charts()) {728+ return "", ""729+ }730+ parts, xs := e.spans(m)731+ var b strings.Builder732+ for i, p := range parts {733+ b.WriteString(strings.Repeat(" ", xs[i]-ansi.StringWidth(b.String())))734+ b.WriteString(p.text)735+ }736+ return b.String(), ""737+}738+739+func (e *chartEditor) mouse(m *Model, ev mouseEvent) tea.Cmd {740+ if ev.kind != mousePress {741+ return nil742+ }743+ if ev.y != contextLine {744+ // A click elsewhere keeps the changes and acts as usual.745+ m.closeOverlay()746+ return m.handlePress(tea.Mouse{X: ev.x, Y: ev.y, Button: ev.button})747+ }748+ parts, xs := e.spans(m)749+ for i, p := range parts {750+ if ev.x >= xs[i] && ev.x < xs[i]+ansi.StringWidth(p.text) {751+ return e.key(m, tea.KeyPressMsg{Code: rune(p.key[0]), Text: p.key})752+ }753+ }754+ return nil755+}756+757+func (e *chartEditor) status(m *Model) (string, string) {758+ pairs := []string{"←/→", "type", "S", "switch rows/columns", "H", "header", "L", "labels", "R", "range", "T", "title", "Enter", "done", "Esc", "cancel"}759+ for {760+ keys := m.keyHints(pairs...)761+ if ansi.StringWidth(keys) <= m.width || len(pairs) <= 4 {762+ return "", keys763+ }764+ pairs = append(pairs[:len(pairs)-6], pairs[len(pairs)-4:]...) // keep Enter and Esc765+ }766+}767+768+// contextLiner is an overlay drawn on the context line, like the chart769+// editor.770+type contextLiner interface {771+ contextLine(m *Model) (left, right string)772+}
A internal/ui/charts_test.go +267 -0
@@ -0,0 +1,267 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ uv "github.com/charmbracelet/ultraviolet"9+ "github.com/charmbracelet/x/ansi/kitty"10+11+ "012/internal/sheet"12+)13+14+// chartTable types a small table and leaves A1 active.15+func chartTable(t *testing.T, m *Model) {16+ t.Helper()17+ press(t, m, "Month", "<tab>", "Rent", "<tab>", "Food", "<enter>")18+ press(t, m, "Jan", "<tab>", "1450", "<tab>", "600", "<enter>")19+ press(t, m, "Feb", "<tab>", "1450", "<tab>", "640", "<enter>")20+ press(t, m, "Mar", "<tab>", "1500", "<tab>", "612", "<enter>")21+ press(t, m, "<ctrl+home>")22+}23+24+func wideModel() *Model {25+ m := New(sheet.New(), "")26+ m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})27+ return m28+}29+30+func TestInsertChart(t *testing.T) {31+ m := wideModel()32+ chartTable(t, m)33+ send(m, nil)34+ m.runCommand("insert.chart")35+ charts := m.sheet.Charts()36+ if len(charts) != 1 {37+ t.Fatalf("%d charts", len(charts))38+ }39+ c := charts[0]40+ if c.Data.String() != "A1:C4" || !c.Header || !c.Labels || c.At.String() != "D1" || c.Title != "Rent and Food" {41+ t.Errorf("chart %+v", c)42+ }43+ if m.indicator() != "CHART" || !strings.Contains(line(m, contextLine), "Column") {44+ t.Errorf("editor not open: %q / %q", m.indicator(), line(m, contextLine))45+ }46+ s := screen(m)47+ for _, want := range []string{"Rent and Food", "1,500 ┤", "■ Rent", "A1:C4"} {48+ if !strings.Contains(s, want) {49+ t.Errorf("screen lacks %q:\n%s", want, s)50+ }51+ }52+ // The editor changes the chart live; Enter keeps it selected.53+ press(t, m, "<right>", "s")54+ if c := m.sheet.Charts()[0]; c.Type != sheet.ChartBar || !c.ByRow {55+ t.Errorf("after editing: %+v", c)56+ }57+ press(t, m, "<enter>")58+ if _, ok := m.overlay.(*chartSel); !ok || !strings.Contains(line(m, contextLine), "Bar chart of A1:C4") {59+ t.Errorf("not selected after Enter: %q", line(m, contextLine))60+ }61+ press(t, m, "<esc>")62+ if m.mode != modeReady {63+ t.Errorf("mode %v", m.mode)64+ }65+}66+67+func TestInsertChartEscCancels(t *testing.T) {68+ m := wideModel()69+ chartTable(t, m)70+ m.runCommand("insert.chart")71+ press(t, m, "<right>", "<esc>")72+ if n := len(m.sheet.Charts()); n != 0 || m.changed != true {73+ // The table itself is unsaved; the chart is gone.74+ t.Errorf("%d charts after Esc", n)75+ }76+ // From one blank cell far from data there's nothing to chart.77+ press(t, m, "<f5>", "H20", "<enter>")78+ m.runCommand("insert.chart")79+ if len(m.sheet.Charts()) != 0 || !strings.Contains(line(m, contextLine), "Select the data") {80+ t.Errorf("charted nothing: %q", line(m, contextLine))81+ }82+}83+84+func TestChartEditorPrompts(t *testing.T) {85+ m := wideModel()86+ chartTable(t, m)87+ m.runCommand("insert.chart")88+ press(t, m, "t", "<ctrl+a>")89+ if m.mode != modePrompt {90+ t.Fatalf("title prompt not open, mode %v", m.mode)91+ }92+ m.buf, m.bufPos = []rune("Spend"), 593+ press(t, m, "<enter>")94+ if c := m.sheet.Charts()[0]; c.Title != "Spend" || m.indicator() != "CHART" {95+ t.Errorf("title %q, indicator %q", c.Title, m.indicator())96+ }97+ press(t, m, "r")98+ if !m.pointing() {99+ t.Fatal("range prompt not pointing")100+ }101+ press(t, m, "<shift+up>", "<enter>")102+ if c := m.sheet.Charts()[0]; c.Data.String() != "A1:C3" {103+ t.Errorf("data %s", c.Data)104+ }105+ press(t, m, "r", "<esc>", "<esc>")106+ if _, ok := m.overlay.(*chartEditor); !ok {107+ t.Errorf("Esc in the range prompt left the editor: %T", m.overlay)108+ }109+}110+111+func TestChartMoveResizeDelete(t *testing.T) {112+ m := wideModel()113+ chartTable(t, m)114+ m.runCommand("insert.chart")115+ press(t, m, "<enter>")116+ press(t, m, "<down>", "<right>", "<shift+right>", "<shift+down>")117+ c := m.sheet.Charts()[0]118+ if c.At.String() != "E2" || c.W != newChartW+2 || c.H != newChartH+1 {119+ t.Errorf("after keys: %s %dx%d", c.At, c.W, c.H)120+ }121+ // Mouse: drag the body one column left, then the corner.122+ x, y := m.chartScreen(c)123+ send(m, tea.MouseClickMsg{X: x + 5, Y: y + 2, Button: tea.MouseLeft})124+ send(m, tea.MouseMotionMsg{X: x + 5 - 10, Y: y + 3, Button: tea.MouseLeft})125+ if got := m.sheet.Charts()[0].At; got.String() != "E2" {126+ t.Errorf("moved before release: %s", got)127+ }128+ send(m, tea.MouseReleaseMsg{X: x - 5, Y: y + 3, Button: tea.MouseLeft})129+ c = m.sheet.Charts()[0]130+ if c.At.String() != "D3" {131+ t.Errorf("after drag: %s", c.At)132+ }133+ x, y = m.chartScreen(c)134+ send(m, tea.MouseClickMsg{X: x + c.W - 1, Y: y + c.H - 1, Button: tea.MouseLeft})135+ send(m, tea.MouseMotionMsg{X: x + 29, Y: y + 11, Button: tea.MouseLeft})136+ send(m, tea.MouseReleaseMsg{X: x + 29, Y: y + 11, Button: tea.MouseLeft})137+ if c = m.sheet.Charts()[0]; c.W != 30 || c.H != 12 {138+ t.Errorf("after resize: %dx%d", c.W, c.H)139+ }140+ press(t, m, "<ctrl+z>")141+ if c = m.sheet.Charts()[0]; c.W == 30 {142+ t.Error("undo didn't restore the size")143+ }144+ // Click the chart to select it, Del deletes it; undo brings it back.145+ press(t, m, "<esc>")146+ x, y = m.chartScreen(c)147+ send(m, tea.MouseClickMsg{X: x + 3, Y: y + 3, Button: tea.MouseLeft})148+ send(m, tea.MouseReleaseMsg{X: x + 3, Y: y + 3, Button: tea.MouseLeft})149+ press(t, m, "<delete>")150+ if len(m.sheet.Charts()) != 0 || m.mode != modeReady {151+ t.Fatalf("not deleted: %d, mode %v", len(m.sheet.Charts()), m.mode)152+ }153+ press(t, m, "<ctrl+z>")154+ if len(m.sheet.Charts()) != 1 {155+ t.Error("undo didn't restore the chart")156+ }157+ // Clicking a cell outside a selected chart deselects it and lands there.158+ m.runCommand("chart.select")159+ send(m, tea.MouseClickMsg{X: cellX(0), Y: gridTop + 8, Button: tea.MouseLeft})160+ if m.mode != modeReady || m.cur.String() != "A9" {161+ t.Errorf("mode %v at %s", m.mode, m.cur)162+ }163+}164+165+func TestChartFollowsData(t *testing.T) {166+ m := wideModel()167+ chartTable(t, m)168+ m.runCommand("insert.chart")169+ press(t, m, "<enter>", "<esc>")170+ if !strings.Contains(screen(m), "1,500 ┤") {171+ t.Fatalf("no axis:\n%s", screen(m))172+ }173+ press(t, m, "<f5>", "B4", "<enter>", "3000", "<enter>")174+ if !strings.Contains(screen(m), "3,000 ┤") {175+ t.Errorf("chart didn't follow the edit:\n%s", screen(m))176+ }177+}178+179+func TestChartImages(t *testing.T) {180+ m := wideModel()181+ chartTable(t, m)182+ var raw []string183+ collect := func(cmd tea.Cmd) {184+ for _, msg := range flatten(cmd) {185+ if r, ok := msg.(tea.RawMsg); ok {186+ raw = append(raw, r.Msg.(string))187+ }188+ }189+ }190+ // No kitty reply: text, and nothing sent.191+ m.runCommand("insert.chart")192+ press(t, m, "<enter>", "<esc>")193+ if cmd := m.syncImages(); cmd != nil {194+ t.Error("images sent without kitty graphics")195+ }196+ // The terminal answers the query: images, and placeholders in the view.197+ _, cmd := m.Update(uv.KittyGraphicsEvent{Options: kitty.Options{ID: 31}, Payload: []byte("OK")})198+ collect(cmd)199+ if !m.term.kitty || len(raw) == 0 || !strings.Contains(strings.Join(raw, ""), "\x1b_Ga=T,q=2,f=32,o=z,") {200+ t.Fatalf("no image sent: %q", raw)201+ }202+ if !strings.Contains(m.View().Content, "\x1b[38;5;16m\U0010EEEE\u0305\u0305") {203+ t.Error("no placeholders for image 16")204+ }205+ // Unchanged charts aren't sent again; edits are.206+ raw = nil207+ _, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyRight})208+ collect(cmd)209+ if len(raw) != 0 {210+ t.Errorf("unchanged chart sent again")211+ }212+ press(t, m, "<f5>", "B2", "<enter>", "99", "<enter>")213+ _, cmd = m.Update(nil)214+ raw = nil215+ m.term.sent[firstImageID] = "stale"216+ collect(m.syncImages())217+ if len(raw) != 1 {218+ t.Errorf("changed chart not sent")219+ }220+ // Deleting the chart frees its image.221+ raw = nil222+ m.sheet.DeleteChart(0)223+ collect(m.syncImages())224+ if len(raw) != 1 || !strings.Contains(raw[0], "a=d,d=I,i=16") {225+ t.Errorf("image not freed: %q", raw)226+ }227+}228+229+// flatten runs a command and returns the messages of it and any batch.230+func flatten(cmd tea.Cmd) []tea.Msg {231+ if cmd == nil {232+ return nil233+ }234+ msg := cmd()235+ if batch, ok := msg.(tea.BatchMsg); ok {236+ var out []tea.Msg237+ for _, c := range batch {238+ out = append(out, flatten(c)...)239+ }240+ return out241+ }242+ return []tea.Msg{msg}243+}244+245+func TestPaletteReply(t *testing.T) {246+ tests := []struct {247+ in string248+ i int249+ ok bool250+ }{251+ {"\x1b]4;6;rgb:0000/cdcd/cdcd\x07", 6, true},252+ {"\x1b]4;12;rgb:5c5c/5c5c/ffff\x1b\\", 12, true},253+ {"\x1b]10;rgb:0/0/0\x07", 0, false},254+ {"\x1b]4;x;rgb:0/0/0\x07", 0, false},255+ }256+ for _, tt := range tests {257+ i, c, ok := parsePaletteReply(tt.in)258+ if ok != tt.ok || ok && i != tt.i {259+ t.Errorf("%q: %d %v %v", tt.in, i, c, ok)260+ }261+ }262+ m := newModel()263+ send(m, uv.UnknownOscEvent("\x1b]4;6;rgb:0000/cdcd/cdcd\x07"))264+ if c := m.term.color(6); c.G != 0xcd || c.R != 0 {265+ t.Errorf("palette color %v", c)266+ }267+}
M internal/ui/dialog.go +2 -2
A internal/ui/graphics.go +212 -0
@@ -0,0 +1,212 @@
1+package ui2+3+import (4+ "fmt"5+ "image/color"6+ "os"7+ "strconv"8+ "strings"9+10+ tea "charm.land/bubbletea/v2"11+ uv "github.com/charmbracelet/ultraviolet"12+ "github.com/charmbracelet/x/ansi"13+14+ "012/internal/chart"15+)16+17+// terminal is what 012 knows about the terminal it runs in, learned from18+// replies to queries sent at startup. It survives File New and Open.19+//20+// Charts become images when the terminal answers a kitty graphics query21+// (kitty, Ghostty, WezTerm and others); everywhere else they stay text.22+// Images use the terminal's own palette, asked for with OSC 4 once23+// graphics are known to work, so bars match the text legend.24+type terminal struct {25+ kitty bool // answers kitty graphics queries26+ tmux bool // inside tmux: sequences for the outer terminal need passthrough27+ cellW, cellH int // cell size in pixels, 0 until reported28+ palette map[int]color.RGBA29+ sent map[int]string // image id -> what was sent, to send only changes30+ blurred bool // the terminal window doesn't have focus31+}32+33+func newTerminal() terminal {34+ return terminal{tmux: os.Getenv("TMUX") != "", palette: map[int]color.RGBA{}, sent: map[int]string{}}35+}36+37+// wrap prepares a sequence for the outer terminal.38+func (t *terminal) wrap(seq string) string {39+ if t.tmux {40+ return ansi.TmuxPassthrough(seq)41+ }42+ return seq43+}44+45+// probes are the startup queries: kitty graphics support, and live46+// light and dark changes (mode 2031).47+func (m *Model) probes() tea.Cmd {48+ return tea.Raw(m.term.wrap(chart.Query()) + ansi.SetModeLightDark)49+}50+51+// firstImageID is the image id of the first chart. Placeholders name52+// their image by its 256-color index, and indexes below 16 would be sent53+// as basic colors, which don't carry an id.54+const firstImageID = 1655+56+// maxImages caps how many charts become images; the rest stay text.57+const maxImages = 256 - firstImageID58+59+// handleTerminal handles replies to queries and terminal events.60+func (m *Model) handleTerminal(msg tea.Msg) tea.Cmd {61+ switch msg := msg.(type) {62+ case uv.KittyGraphicsEvent:63+ if msg.Options.ID == chart.QueryID && string(msg.Payload) == "OK" && !m.term.kitty {64+ m.term.kitty = true65+ // Ask for the cell size (for image proportions) and the66+ // palette colors charts draw with.67+ var q strings.Builder68+ q.WriteString(ansi.WindowOp(ansi.RequestCellSizeWinOp))69+ for i := range 16 {70+ fmt.Fprintf(&q, "\x1b]4;%d;?\x07", i)71+ }72+ return tea.Raw(q.String())73+ }74+ case uv.CellSizeEvent:75+ if msg.Width > 0 && msg.Height > 0 {76+ m.term.cellW, m.term.cellH = msg.Width, msg.Height77+ }78+ case uv.UnknownOscEvent:79+ if i, c, ok := parsePaletteReply(string(msg)); ok {80+ m.term.palette[i] = c81+ }82+ case uv.DarkColorSchemeEvent, uv.LightColorSchemeEvent:83+ // The system switched between light and dark. The terminal's84+ // background decides the theme, so ask for it again.85+ return tea.RequestBackgroundColor86+ case tea.FocusMsg:87+ m.term.blurred = false88+ case tea.BlurMsg:89+ m.term.blurred = true90+ }91+ return nil92+}93+94+// parsePaletteReply parses an OSC 4 reply, "ESC ] 4 ; 6 ; rgb:0000/cdcd/cdcd".95+func parsePaletteReply(s string) (int, color.RGBA, bool) {96+ s = strings.TrimPrefix(s, "\x1b]")97+ s = strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(s, "\x07"), "\x1b\\"), "\x9c")98+ parts := strings.Split(s, ";")99+ if len(parts) != 3 || parts[0] != "4" {100+ return 0, color.RGBA{}, false101+ }102+ i, err := strconv.Atoi(parts[1])103+ c := ansi.XParseColor(parts[2])104+ if err != nil || c == nil || i < 0 || i > 255 {105+ return 0, color.RGBA{}, false106+ }107+ r, g, b, _ := c.RGBA()108+ return i, color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), 255}, true109+}110+111+// xtermColors are the 16 colors assumed when the terminal doesn't report112+// its palette.113+var xtermColors = [16]color.RGBA{114+ {0, 0, 0, 255}, {205, 49, 49, 255}, {13, 188, 121, 255}, {229, 229, 16, 255},115+ {36, 114, 200, 255}, {188, 63, 188, 255}, {17, 168, 205, 255}, {229, 229, 229, 255},116+ {102, 102, 102, 255}, {241, 76, 76, 255}, {35, 209, 139, 255}, {245, 245, 67, 255},117+ {59, 142, 234, 255}, {214, 112, 214, 255}, {41, 184, 219, 255}, {255, 255, 255, 255},118+}119+120+func (t *terminal) color(i int) color.RGBA {121+ if c, ok := t.palette[i]; ok {122+ return c123+ }124+ return xtermColors[i%16]125+}126+127+// chartPalette is the theme's series colors as the terminal draws them.128+func (m *Model) chartPalette() chart.Palette {129+ var p chart.Palette130+ for i, idx := range m.th.seriesANSI {131+ p.Series[i] = m.term.color(idx)132+ }133+ p.Grid = m.term.color(8) // bright black, like the text axes134+ p.Grid.A = 90135+ return p136+}137+138+// chartOptions are the drawing options for charts on this terminal.139+func (m *Model) chartOptions() chart.Options {140+ return chart.Options{Image: m.term.kitty, CellW: m.term.cellW, CellH: m.term.cellH}141+}142+143+// syncImages sends the images of charts that changed since they were last144+// sent, and frees those of deleted charts. Update calls it after every145+// message, so images follow edits, recalculation, resizing and the theme.146+func (m *Model) syncImages() tea.Cmd {147+ if !m.term.kitty {148+ return nil149+ }150+ var out strings.Builder151+ charts := m.displayCharts()152+ o := m.chartOptions()153+ pal := m.chartPalette()154+ live := map[int]bool{}155+ for i, c := range charts {156+ if i >= maxImages {157+ break158+ }159+ id := firstImageID + i160+ live[id] = true161+ w, h := chartInner(c)162+ d := m.sheet.ChartData(c)163+ key := fmt.Sprintf("%v %v %d %d %v %v", c.Type, d, w, h, o, pal)164+ if m.term.sent[id] == key {165+ continue166+ }167+ m.term.sent[id] = key168+ g := chart.Draw(c.Type, d, w, h, o)169+ img := chart.Image(c.Type, d, w, h, o, pal)170+ if img == nil || g.Plot.Empty() {171+ out.WriteString(chart.Delete(id, m.term.wrap))172+ continue173+ }174+ out.WriteString(chart.Transmit(id, img, g.Plot.Dx(), g.Plot.Dy(), m.term.wrap))175+ }176+ for id := range m.term.sent {177+ if !live[id] {178+ delete(m.term.sent, id)179+ out.WriteString(chart.Delete(id, m.term.wrap))180+ }181+ }182+ if out.Len() == 0 {183+ return nil184+ }185+ return tea.Raw(out.String())186+}187+188+// releaseTerminal undoes the startup modes and frees the chart images.189+func (m *Model) releaseTerminal() string {190+ var b strings.Builder191+ b.WriteString(ansi.ResetModeLightDark)192+ for id := range m.term.sent {193+ b.WriteString(chart.Delete(id, m.term.wrap))194+ }195+ return b.String()196+}197+198+// notifyDone tells the user a long job finished, with a desktop199+// notification (OSC 9) when the terminal window isn't focused. When it is,200+// the screen already shows the result.201+func (m *Model) notifyDone(msg string) tea.Cmd {202+ if !m.term.blurred {203+ return nil204+ }205+ msg = strings.Map(func(r rune) rune {206+ if r < ' ' || r == 0x7f || r >= 0x80 && r < 0xa0 {207+ return -1208+ }209+ return r210+ }, msg)211+ return tea.Raw(m.term.wrap(ansi.Notify("012: " + msg)))212+}
A internal/ui/links.go +54 -0
@@ -0,0 +1,54 @@
1+package ui2+3+import (4+ "charm.land/lipgloss/v2"5+6+ "012/internal/sheet"7+)8+9+// Cells whose text is a URL, and HYPERLINK formulas, are drawn as OSC 810+// hyperlinks: blue and underlined, and Cmd- or Ctrl-clickable in11+// terminals that support them. Error values get a curly underline on top12+// of their color, and the context line explains the active cell's error.13+14+// decorate marks a span's text as a link or an error, from the cell the15+// text belongs to.16+func (m *Model) decorate(sp *span, row int) {17+ if sp.text == "" {18+ return19+ }20+ a := sheet.Addr{Col: sp.owner, Row: row}21+ switch v := m.sheet.Value(a); {22+ case v.Kind == sheet.Error && !sheet.IsPending(v):23+ sp.error = true24+ case v.Kind == sheet.Text:25+ sp.link = m.sheet.Link(a)26+ }27+}28+29+// textStyle is the style of a span's text on base: the cell's text style,30+// then the link or error mark. On a colored role (the pointer, the31+// selection) a link keeps the role's colors and adds its underline.32+func (m *Model) textStyle(base lipgloss.Style, sp span) lipgloss.Style {33+ st := sp.style34+ st.Align = sheet.AlignAuto35+ s := m.th.text(base, st)36+ switch {37+ case sp.link != "":38+ s = s.Inherit(m.th.link).Hyperlink(sp.link)39+ case sp.error:40+ s = s.Inherit(m.th.errorMark)41+ }42+ return s43+}44+45+// errorLine explains the active cell's error on the context line, e.g.46+// "#DIV/0! Division by zero in B3/0".47+func (m *Model) errorLine() string {48+ v := m.sheet.Value(m.cur)49+ why := m.sheet.ExplainError(m.cur)50+ if v.Kind != sheet.Error || why == "" {51+ return ""52+ }53+ return m.th.errorCell.Render(v.Str) + " " + m.th.muted.Render(why)54+}
A internal/ui/links_test.go +116 -0
@@ -0,0 +1,116 @@
1+package ui2+3+import (4+ "fmt"5+ "image/color"6+ "strings"7+ "testing"8+9+ tea "charm.land/bubbletea/v2"10+ uv "github.com/charmbracelet/ultraviolet"11+ "github.com/charmbracelet/x/ansi"12+)13+14+func TestLinksRenderAsHyperlinks(t *testing.T) {15+ m := newModel()16+ press(t, m, "https://example.com", "<enter>", `=HYPERLINK("example.org", "Docs")`, "<enter>", "plain text", "<enter>")17+ v := m.View().Content18+ for _, want := range []string{"\x1b]8;;https://example.com\x07", "\x1b]8;;https://example.org\x07"} {19+ if !strings.Contains(v, want) {20+ t.Errorf("view lacks hyperlink %q", want)21+ }22+ }23+ if strings.Contains(ansi.Strip(strings.Split(v, "\n")[gridTop+2]), "plain") && strings.Contains(strings.Split(v, "\n")[gridTop+2], "\x1b]8") {24+ t.Error("plain text drawn as a link")25+ }26+ // The link survives the pointer and a composited overlay.27+ press(t, m, "<ctrl+home>", "<ctrl+k>")28+ if !strings.Contains(m.View().Content, "https://example.com") {29+ t.Error("hyperlink lost under the pointer with an overlay open")30+ }31+}32+33+func TestErrorCellsAreMarked(t *testing.T) {34+ m := newModel()35+ press(t, m, "10", "<enter>", "=A1/0", "<enter>", "=A2+1", "<enter>")36+ v := m.View().Content37+ // Curly underline (4:3) under the error's text only.38+ if !strings.Contains(v, "4:3m#") || !strings.Contains(ansi.Strip(v), "#DIV/0!") {39+ t.Errorf("no curly underline on errors: %q", v)40+ }41+ press(t, m, "<up>")42+ if got := line(m, contextLine); got != "#DIV/0! From A2: division by zero in A1/0" {43+ t.Errorf("context line %q", got)44+ }45+ press(t, m, "<up>")46+ if got := line(m, contextLine); got != "#DIV/0! Division by zero in A1/0" {47+ t.Errorf("context line %q", got)48+ }49+ press(t, m, "<up>")50+ if got := line(m, contextLine); got != "" {51+ t.Errorf("context line on a number %q", got)52+ }53+}54+55+func TestCursorOnlyWhileTyping(t *testing.T) {56+ m := newModel()57+ cases := []struct {58+ keys []string59+ shape tea.CursorShape60+ shown bool61+ }{62+ {nil, 0, false},63+ {[]string{"1"}, tea.CursorBar, true},64+ {[]string{"<esc>", "<f5>"}, tea.CursorBar, true},65+ {[]string{"<esc>", "=", "<up>"}, 0, false}, // pointing: the pointer shows where66+ {[]string{"<esc>", "<esc>", "<ctrl+k>"}, tea.CursorBar, true},67+ {[]string{"<esc>", "<alt+f>"}, 0, false},68+ }69+ for i, c := range cases {70+ press(t, m, c.keys...)71+ cur := m.View().Cursor72+ if (cur != nil) != c.shown || cur != nil && cur.Shape != c.shape {73+ t.Errorf("step %d (%v): cursor %+v", i, c.keys, cur)74+ }75+ if cur != nil && cur.Y >= gridTop && cur.Y < m.height-1 {76+ t.Errorf("step %d: cursor in the grid at %d", i, cur.Y)77+ }78+ }79+}80+81+func TestLiveLightDark(t *testing.T) {82+ m := newModel()83+ for _, ev := range []tea.Msg{uv.LightColorSchemeEvent{}, uv.DarkColorSchemeEvent{}} {84+ _, cmd := m.Update(ev)85+ asked := false86+ for _, msg := range flatten(cmd) {87+ asked = asked || strings.Contains(fmt.Sprintf("%T", msg), "ackgroundColor")88+ }89+ if !asked {90+ t.Errorf("%T didn't ask for the background color", ev)91+ }92+ }93+ send(m, tea.BackgroundColorMsg{Color: color.White})94+ if m.th.link.GetForeground() != newTheme(false).link.GetForeground() {95+ t.Error("theme didn't follow a light background")96+ }97+}98+99+func TestNotifyOnlyWhenBlurred(t *testing.T) {100+ m := newModel()101+ if m.notifyDone("done") != nil {102+ t.Error("notified while focused")103+ }104+ send(m, tea.BlurMsg{})105+ msgs := flatten(m.notifyDone("Import done\x1b]0;x\x07"))106+ if len(msgs) != 1 || msgs[0].(tea.RawMsg).Msg != "\x1b]9;012: Import done]0;x\x07" {107+ t.Errorf("notification %q", msgs)108+ }109+ send(m, tea.FocusMsg{})110+ if m.notifyDone("done") != nil {111+ t.Error("notified after focus came back")112+ }113+ if !m.View().ReportFocus {114+ t.Error("focus reporting off")115+ }116+}
M internal/ui/menu.go +2 -2
M internal/ui/menubar.go +2 -1
M internal/ui/model.go +13 -3
@@ -101,19 +101,22 @@ type Model struct {
101101 files []string // file list shown by File Open102102 errMsg string103103 104+ term terminal // what the terminal supports, see graphics.go105+ lastChart int // the chart last selected, for chart commands; -1 for none106+104107 th theme105108 }106109 107110 // New returns a model editing s. filename may be empty.108111 func New(s *sheet.Sheet, filename string) *Model {109- return &Model{sheet: s, filename: filename, width: 80, height: 24, th: newTheme(true)}112+ return &Model{sheet: s, filename: filename, width: 80, height: 24, th: newTheme(true), term: newTerminal(), lastChart: -1}110113 }111114 112115 // Init implements tea.Model. It asks the terminal for its background color113116 // so the theme can adapt to light terminals.114117 func (m *Model) Init() tea.Cmd {115118 // sendJEV starts any questions queued while loading the file.116- return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.sendJEV())119+ return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.probes(), m.sendJEV())117120 }118121 119122 // Update implements tea.Model.
@@ -157,7 +160,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
157160 case filesMsg:158161 m.files = msg159162 case jevAnswerMsg:163+ busy := m.jevBusy() != ""160164 m.handleJEVAnswer(msg)165+ if busy && m.jevBusy() == "" {166+ cmd = m.notifyDone("JEV finished answering in " + m.displayName())167+ }168+ default:169+ cmd = m.handleTerminal(msg)161170 }162171 // Scroll only when the focus moves, so the mouse wheel can look around163172 // without the view snapping back, as in Sheets.
@@ -166,7 +175,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
166175 m.scrollTo(f)167176 }168177 // Any edit may have queued JEV questions.169- return m, tea.Batch(cmd, m.sendJEV())178+ // Chart images follow any change, see graphics.go.179+ return m, tea.Batch(cmd, m.sendJEV(), m.syncImages())170180 }171181 172182 // beginUpdate prepares for an input event and returns the sheet's state
M internal/ui/mouse.go +6 -3
M internal/ui/overlay.go +11 -2
M internal/ui/panel.go +7 -1
M internal/ui/theme.go +54 -4
@@ -2,7 +2,9 @@ package ui
22 33 import (44 "charm.land/lipgloss/v2"5+ "github.com/charmbracelet/x/ansi"56 7+ "012/internal/chart"68 "012/internal/sheet"79 )810
@@ -29,9 +31,14 @@ type theme struct {
2931 key lipgloss.Style // emphasized text in the status line, e.g. a range3032 keyChip lipgloss.Style // a key cap in hints, menus and the palette, e.g. " Enter "3133 errorCell lipgloss.Style // cells whose value is ERR or NA32- found lipgloss.Style // cells matching an open search33- traced lipgloss.Style // precedents or dependents being traced34- argument lipgloss.Style // the argument at the caret in a function's signature34+ // errorMark is layered on an error's text, so errors show beyond35+ // color: a curly underline, in the error color where the terminal36+ // supports colored underlines.37+ errorMark lipgloss.Style38+ link lipgloss.Style // a cell's URL or HYPERLINK label, layered on the cell's role39+ found lipgloss.Style // cells matching an open search40+ traced lipgloss.Style // precedents or dependents being traced41+ argument lipgloss.Style // the argument at the caret in a function's signature3542 // copied marks the range on the clipboard, like Sheets' dashed border:3643 // a dashed underline across every cell, layered on the cell's own3744 // style, with its own text color where the cell has none.
@@ -48,6 +55,26 @@ type theme struct {
4855 match lipgloss.Style // characters matched by a search4956 matchSelected lipgloss.Style // matched characters in the highlighted row5057 cell lipgloss.Style // an ordinary cell: the base for bold, italic and underline58+59+ // Charts: see charts.go.60+ chartFrame lipgloss.Style // a chart's border61+ chartSelected lipgloss.Style // the border of the selected chart and its resize handle62+ chartAxis lipgloss.Style // axis lines and tick marks63+ chartLabel lipgloss.Style // tick values, category labels and legend text64+ // series colors bars, lines, slices and legend swatches, in order;65+ // seriesBg is the same colors as backgrounds, for the lower half of a66+ // pie's half blocks. seriesANSI is the ANSI index of each, so images67+ // can use the terminal's own colors.68+ series [chart.Colors]lipgloss.Style69+ seriesBg [chart.Colors]lipgloss.Style70+ seriesANSI [chart.Colors]int71+}72+73+// imageID is the style of an image's Unicode placeholders: the74+// foreground color is not a color but the image's id, in the 256-color75+// palette, which is how the terminal knows which image to draw there.76+func (t theme) imageID(id int) lipgloss.Style {77+ return lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(id))5178 }5279 5380 // text adds a cell's bold, italic, underline and strikethrough to base,
@@ -78,8 +105,18 @@ func newTheme(dark bool) theme {
78105 headerBg, headerFg = lipgloss.White, lipgloss.Black79106 selFg, muted, match = lipgloss.BrightWhite, lipgloss.Black, lipgloss.Blue80107 }108+ // Links are blue, as in Sheets; plain blue is too dark on a dark109+ // background.110+ link := lipgloss.BrightBlue111+ // Series colors, most distinct first. On light backgrounds yellow and112+ // cyan fade, so they come last or not at all.113+ series := [chart.Colors]ansi.BasicColor{lipgloss.Cyan, lipgloss.Magenta, lipgloss.Yellow, lipgloss.Green, lipgloss.BrightBlue, lipgloss.BrightRed}114+ if !dark {115+ link = lipgloss.Blue116+ series = [chart.Colors]ansi.BasicColor{lipgloss.Blue, lipgloss.Magenta, lipgloss.Green, lipgloss.Red, lipgloss.Cyan, lipgloss.BrightBlack}117+ }81118 accent := lipgloss.NewStyle().Background(lipgloss.Cyan).Foreground(lipgloss.Black)82- return theme{119+ t := theme{83120 indicator: accent.Bold(true),84121 header: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),85122 headerActive: accent.Bold(true),
@@ -95,6 +132,8 @@ func newTheme(dark bool) theme {
95132 key: lipgloss.NewStyle().Bold(true),96133 keyChip: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),97134 errorCell: lipgloss.NewStyle().Foreground(lipgloss.Red),135+ errorMark: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineCurly).UnderlineColor(lipgloss.Red),136+ link: lipgloss.NewStyle().Foreground(link).Underline(true),98137 found: lipgloss.NewStyle().Background(lipgloss.Yellow).Foreground(lipgloss.Black),99138 traced: lipgloss.NewStyle().Background(lipgloss.Green).Foreground(lipgloss.Black),100139 argument: lipgloss.NewStyle().Bold(true).Underline(true),
@@ -111,5 +150,16 @@ func newTheme(dark bool) theme {
111150 match: lipgloss.NewStyle().Foreground(match).Bold(true),112151 matchSelected: accent.Bold(true).Underline(true),113152 cell: lipgloss.NewStyle(),153+154+ chartFrame: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),155+ chartSelected: lipgloss.NewStyle().Foreground(lipgloss.Cyan).Bold(true),156+ chartAxis: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),157+ chartLabel: lipgloss.NewStyle().Foreground(muted),158+ }159+ for i, c := range series {160+ t.series[i] = lipgloss.NewStyle().Foreground(c)161+ t.seriesBg[i] = lipgloss.NewStyle().Background(c)162+ t.seriesANSI[i] = int(c)114163 }164+ return t115165 }
M internal/ui/view.go +10 -4
@@ -109,6 +109,8 @@ func (m *Model) gridRow(row int) string {
109109 }110110 if a == m.cur && (m.mode == modeEnter || m.mode == modeEdit) {111111 sp = span{text: m.inCellText(m.sheet.ColWidth(a.Col))}112+ } else {113+ m.decorate(&sp, row) // links and error marks, see links.go112114 }113115 b.WriteString(m.renderSpan(sp, base, colored))114116 }
@@ -124,6 +126,9 @@ type span struct {
124126 trail int125127 style sheet.Style126128 owner int // column of the cell the text belongs to129+130+ link string // the owner's link target, drawn as a hyperlink131+ error bool // the owner shows an error: its text gets the error mark127132 }128133 129134 // renderSpan draws a span on base, one of the cell roles. Plain cells
@@ -132,15 +137,16 @@ func (m *Model) renderSpan(sp span, base lipgloss.Style, colored bool) string {
132137 lead, trail := strings.Repeat(" ", sp.lead), strings.Repeat(" ", sp.trail)133138 st := sp.style134139 st.Align = sheet.AlignAuto140+ plain := st.IsZero() && sp.link == "" && !sp.error135141 switch {136- case st.IsZero() && !colored:142+ case plain && !colored:137143 return lead + sp.text + trail138- case st.IsZero():144+ case plain:139145 return base.Render(lead + sp.text + trail)140146 case !colored:141- return lead + m.th.text(base, st).Render(sp.text) + trail147+ return lead + m.textStyle(base, sp).Render(sp.text) + trail142148 }143- return base.Render(lead) + m.th.text(base, st).Render(sp.text) + base.Render(trail)149+ return base.Render(lead) + m.textStyle(base, sp).Render(sp.text) + base.Render(trail)144150 }145151 146152 // inCellText shows the entry being typed inside the cell, keeping the end