1package ui23import (4 "strconv"5 "strings"67 tea "charm.land/bubbletea/v2"8 "charm.land/lipgloss/v2"9 "github.com/charmbracelet/x/ansi"1011 "src.rest/cat/012/internal/chart"12 "src.rest/cat/012/internal/sheet"13 "src.rest/cat/012/internal/ui/overlay"14 "src.rest/cat/012/internal/ui/theme"15)1617// Charts follow Sheets: Insert > Chart charts the selection (or the table18// around the active cell) and opens the chart editor, a bar on the19// context line for the type, series direction, headers, range and title.20// Charts float over the grid anchored at a cell, redraw as their data21// recalculates, and can be dragged, resized from the corner, and deleted22// with Del while selected. On terminals with kitty graphics the plot is an23// image (graphics.go); labels, legend and frame are always text.2425// chartBoxID is the id of chart i's box in hit tests.26func chartBoxID(i int) string { return "chart" + strconv.Itoa(i) }2728// Default size of a new chart, in cells; smaller screens get smaller29// charts.30const newChartW, newChartH = 48, 163132func init() {33 register(34 &command{id: "insert.chart", title: "Chart", desc: "Chart the selected data, or the table around the active cell", run: (*Model).insertChart,35 answer: (*Model).answerInsertChart},36 &command{id: "chart.edit", title: "Edit chart", desc: "Change a chart's type, data and labels",37 enabled: hasCharts, run: func(m *Model) tea.Cmd {38 m.openChartEditor(m.targetChart(), false, m.book().Checkpoint())39 return nil40 },41 answer: (*Model).answerEditChart},42 &command{id: "chart.delete", title: "Delete chart", desc: "Remove a chart from the sheet",43 enabled: hasCharts, run: func(m *Model) tea.Cmd {44 m.deleteChart(m.targetChart())45 return nil46 }},47 &command{id: "chart.select", macro: macroView, title: "Select chart", desc: "Select the next chart, to move, resize or delete it with the keyboard",48 enabled: hasCharts, run: func(m *Model) tea.Cmd {49 i := 050 if s, ok := m.overlay.(*chartSel); ok {51 i = (s.i + 1) % len(m.sheet.Charts())52 }53 m.selectChart(i)54 m.showChart(i)55 return nil56 }},57 )58}5960// chartState is what chart commands remember between selections.61type chartState struct {62 last int // the chart last selected, for chart commands; -1 for none63}6465func hasCharts(m *Model) bool { return len(m.sheet.Charts()) > 0 }6667// targetChart is the chart a chart command acts on: the selected one, the68// last one selected, one charting the active cell, or the newest.69func (m *Model) targetChart() int {70 charts := m.sheet.Charts()71 switch o := m.overlay.(type) {72 case *chartSel:73 return o.i74 case *chartEditor:75 return o.i76 }77 if m.charts.last >= 0 && m.charts.last < len(charts) {78 return m.charts.last79 }80 for i, c := range charts {81 if c.Data.Contains(m.cur) {82 return i83 }84 }85 return len(charts) - 186}8788// insertChart charts the selection and opens the editor.89func (m *Model) insertChart() tea.Cmd {90 r := m.selection()91 if !m.hasRange() {92 r = m.tableAround(m.cur)93 }94 if used, ok := m.sheet.UsedRange(); !ok || r.From.Col > used.To.Col || r.From.Row > used.To.Row {95 m.note = "Select the data to chart first"96 return nil97 }98 start := m.book().Checkpoint()99 c := m.sheet.GuessChart(r)100 c.W = clamp(newChartW, sheet.MinChartW, max(m.width-m.hdrW()-2, sheet.MinChartW))101 c.H = clamp(newChartH, sheet.MinChartH, max(m.visibleRows()-1, sheet.MinChartH))102 // Next to the data when it fits on screen, as Sheets does; otherwise103 // below it.104 c.At = sheet.Addr{Col: c.Data.To.Col + 1, Row: c.Data.From.Row}105 if m.colStart(c.At.Col)+c.W > m.width {106 c.At = sheet.Addr{Col: c.Data.From.Col, Row: c.Data.To.Row + 2}107 }108 i := m.sheet.AddChart(c)109 m.changed = true110 m.clearSelection()111 m.showChart(i)112 m.openChartEditor(i, true, start)113 return nil114}115116// showChart scrolls so chart i is on screen, as far as it fits.117func (g *grid) showChart(i int) {118 charts := g.sheet.Charts()119 if i < 0 || i >= len(charts) {120 return121 }122 c := charts[i]123 if _, y := g.chartScreen(c); c.At.Row < g.top || y+c.H > gridTop+g.visibleRows() {124 // The chart's bottom at the grid's, or its top at the top.125 g.top = c.At.Row126 for g.top > 0 && g.lineOf(c.At.Row, c.H+g.height)+c.H+g.span(g.stepRow(g.top, -1)) <= gridTop+g.visibleRows() {127 g.top = g.stepRow(g.top, -1)128 }129 }130 if c.At.Col < g.left || g.colStart(c.At.Col)+c.W > g.width {131 g.left = c.At.Col132 for g.left > 0 && g.colStart(c.At.Col)+c.W+g.sheet.ColWidth(g.left-1) <= g.width {133 g.left--134 }135 }136}137138func (m *Model) deleteChart(i int) {139 if i < 0 || i >= len(m.sheet.Charts()) {140 return141 }142 m.sheet.DeleteChart(i)143 m.changed = true144 m.charts.last = -1145 if _, ok := m.overlay.(*chartSel); ok {146 m.closeOverlay()147 }148 m.note = "Deleted the chart " + m.th.KeyHints(m.shortcut("edit.undo"), "undo")149}150151// displayCharts are the charts as drawn: with the one being dragged at152// its live position.153func (m *Model) displayCharts() []sheet.Chart {154 charts := m.sheet.Charts()155 if s, ok := m.overlay.(*chartSel); ok && s.preview != nil && s.i < len(charts) {156 charts[s.i] = *s.preview157 }158 return charts159}160161// chartInner is the size of a chart's contents inside its frame and one162// column of padding each side.163func chartInner(c sheet.Chart) (w, h int) { return c.W - 4, c.H - 2 }164165// chartScreen returns where chart c's top-left corner is on screen; it166// may be off screen.167func (g *grid) chartScreen(c sheet.Chart) (x, y int) {168 return g.colStart(c.At.Col), g.lineOf(c.At.Row, c.H+g.height)169}170171// chartAt returns the topmost chart drawn under x, y, or -1.172func (m *Model) chartAt(x, y int) int {173 if y < gridTop || y >= gridTop+m.visibleRows() || x < m.hdrW() {174 return -1175 }176 charts := m.displayCharts()177 for i := len(charts) - 1; i >= 0; i-- {178 cx, cy := m.chartScreen(charts[i])179 if x >= cx && x < cx+charts[i].W && y >= cy && y < cy+charts[i].H {180 return i181 }182 }183 return -1184}185186// selectedChart is the index of the selected chart, or -1.187func (m *Model) selectedChart() int {188 switch o := m.overlay.(type) {189 case *chartSel:190 return o.i191 case *chartEditor:192 return o.i193 }194 return -1195}196197// chartBoxes draws the charts on screen, clipped to the grid, bottom198// first.199func (m *Model) chartBoxes() []overlay.Box {200 var boxes []overlay.Box201 sel := m.selectedChart()202 top, bottom := gridTop, gridTop+m.visibleRows()203 charts := m.displayCharts()204 for _, i := range m.chartOrder(len(charts)) {205 c := charts[i]206 x, y := m.chartScreen(c)207 if x >= m.width || y >= bottom || x+c.W <= m.hdrW() || y+c.H <= top {208 continue209 }210 lines := m.drawChart(i, c, i == sel)211 // Clip to the grid: the headers and panel stay on top.212 from, to := max(m.hdrW()-x, 0), min(c.W, m.width-x)213 b := overlay.Box{ID: chartBoxID(i), X: x + from, Y: max(y, top)}214 for k, l := range lines {215 if y+k >= top && y+k < bottom {216 b.Lines = append(b.Lines, ansi.Cut(l, from, to))217 }218 }219 if len(b.Lines) > 0 && to > from {220 boxes = append(boxes, b)221 }222 }223 return boxes224}225226// drawChart renders chart i in its frame: the title in the top border,227// the data range in the bottom one, and when selected a highlighted228// border with a resize handle in the corner.229func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {230 w, h := chartInner(c)231 // A sixel image covers the text chart drawn under it (see sixel.go),232 // whose bars meet the axis in the row below the image, so the plot233 // stops being text for kitty placeholders only.234 o := m.term.chartOptions()235 o.Chart = c.ChartOptions236 if i >= maxImages {237 o.Image = false238 }239 g := chart.Draw(c.Type, m.sheet.ChartData(c), w, h, o)240 border := m.th.ChartFrame241 if selected {242 border = m.th.ChartSelected243 }244 title := c.Title245 if title == "" {246 title = c.Type.Title() + " chart"247 }248 title = ansi.Truncate(" "+title+" ", c.W-4, "…")249 footer := " " + c.Data.String() + " "250 lines := make([]string, 0, c.H)251 lines = append(lines, border.Render("┌─")+m.th.Title.Render(title)+252 border.Render(strings.Repeat("─", max(c.W-3-ansi.StringWidth(title), 0))+"┐"))253 for y := range h {254 lines = append(lines, border.Render("│")+" "+chartRow(&m.th, g, y, firstImageID+i, o.Image)+" "+border.Render("│"))255 }256 corner := "┘"257 if selected {258 corner = "◢"259 }260 fill := c.W - 2 - ansi.StringWidth(footer) - 1261 if fill < 1 {262 lines = append(lines, border.Render("└"+strings.Repeat("─", c.W-2)+corner))263 } else {264 lines = append(lines, border.Render("└"+strings.Repeat("─", fill))+m.th.Muted.Render(footer)+border.Render("─"+corner))265 }266 return lines267}268269// chartRow renders row y of a drawn chart with the theme; with images,270// the plot area is the image's kitty placeholders.271func chartRow(th *theme.Theme, g *chart.Grid, y, imageID int, image bool) string {272 var b, run strings.Builder273 var style lipgloss.Style274 styled := false275 flush := func() {276 if run.Len() == 0 {277 return278 }279 if styled {280 b.WriteString(style.Render(run.String()))281 } else {282 b.WriteString(run.String())283 }284 run.Reset()285 }286 prev := -1287 for x := range g.W {288 c := g.At(x, y)289 key, st, isStyled := 0, lipgloss.Style{}, true290 text := c.Text291 switch inPlot := y >= g.Plot.Min.Y && y < g.Plot.Max.Y && x >= g.Plot.Min.X && x < g.Plot.Max.X; {292 case image && inPlot:293 key, st = 1, th.ImageID(imageID)294 text = chart.Placeholder(y-g.Plot.Min.Y, x-g.Plot.Min.X)295 case c.Fg == chart.None && c.Bg == chart.None:296 key, isStyled = 2, false297 default:298 key = 3 + int(c.Fg)*32 + int(c.Bg)299 st = chartRole(th, c.Fg)300 if c.Bg >= chart.Series {301 st = st.Inherit(th.SeriesBg[int(c.Bg-chart.Series)%chart.Colors])302 }303 }304 if key != prev {305 flush()306 prev, style, styled = key, st, isStyled307 }308 run.WriteString(text)309 }310 flush()311 return b.String()312}313314func chartRole(th *theme.Theme, r chart.Role) lipgloss.Style {315 switch {316 case r == chart.Axis, r == chart.Gridline:317 return th.ChartAxis318 case r == chart.Label:319 return th.ChartLabel320 case r == chart.Muted:321 return th.Muted322 case r >= chart.Series:323 return th.Series[int(r-chart.Series)%chart.Colors]324 }325 return th.Cell326}327328// chartClick handles a press on the grid in READY: pressing a chart329// selects it and starts dragging it, and right-clicking opens its menu.330func (m *Model) chartClick(mouse tea.Mouse) (tea.Cmd, bool) {331 i := m.chartAt(mouse.X, mouse.Y)332 if i < 0 {333 return nil, false334 }335 s := m.selectChart(i)336 if mouse.Button == tea.MouseRight {337 m.showChartMenu(mouse.X, mouse.Y+1)338 return nil, true339 }340 s.press(mouse.X, mouse.Y)341 return nil, true342}343344var chartMenu = []menuItem{{cmd: "chart.edit"}, {cmd: "chart.delete"}}345346// selectChart selects chart i.347func (m *Model) selectChart(i int) *chartSel {348 m.clearSelection()349 m.charts.last = i350 s := &chartSel{m: m, i: i}351 m.openOverlay(s)352 return s353}354355func (m *Model) forgetChart() { m.charts.last = -1 }356func (m *Model) showChartMenu(x, y int) { m.showContextMenu(chartMenu, x, y) }357func (m *Model) say(msg string) { m.note = msg }358func (m *Model) passKey(k tea.KeyPressMsg) tea.Cmd {359 return m.handleKey(k)360}361362// passMouse hands a press or the wheel to the grid, as if no chart were363// selected.364func (m *Model) passMouse(e overlay.MouseEvent) tea.Cmd {365 mouse := tea.Mouse{X: e.X, Y: e.Y, Button: e.Button}366 switch e.Kind {367 case overlay.MousePress:368 return m.handlePress(mouse)369 case overlay.MouseWheel:370 m.handleWheel(mouse)371 }372 return nil373}374375// setShape sets the terminal's pointer shape, if it changed.376func (m *Model) setShape(shape string) tea.Cmd {377 if shape == m.mouse.shape {378 return nil379 }380 m.mouse.shape = shape381 return tea.Raw(ansi.SetPointerShape(shape))382}