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 / graphics.go
graphics.go 9.8K
1package ui23import (4 "fmt"5 "image/color"6 "log/slog"7 "slices"8 "strconv"9 "strings"1011 tea "charm.land/bubbletea/v2"12 uv "github.com/charmbracelet/ultraviolet"13 "github.com/charmbracelet/x/ansi"1415 "src.rest/cat/012/internal/chart"16 "src.rest/cat/012/internal/sheet"17 "src.rest/cat/012/internal/telemetry"18 "src.rest/cat/012/internal/ui/theme"19)2021// terminal is what 012 knows about the terminal it runs in, learned from22// replies to queries sent at startup. It survives File New and Open.23//24// Charts become images when the terminal answers a kitty graphics query25// (kitty, Ghostty, WezTerm and others), or else when its primary device26// attributes (DA1) list sixel graphics (foot, xterm, mlterm, Windows27// Terminal, xterm.js with its image addon; see sixel.go); everywhere28// else they stay text. Images use29// the terminal's own palette, asked for with OSC 4 at startup, so bars30// match the text legend.31type terminal struct {32 noImages bool // chart-images = false in the config33 noNotify bool // notifications = false in the config34 kitty bool // answers kitty graphics queries35 sixel bool // lists sixel in DA1, without kitty graphics, outside tmux36 registers int // sixel color registers, as XTSMGRAPHICS reports them37 tmux bool // inside tmux: sequences for the outer terminal need passthrough38 cellW, cellH int // cell size in pixels, 0 until reported39 palette map[int]color.RGBA40 bg color.Color // the terminal's background, nil until reported41 sent map[int]string // image id -> what was sent, to send only changes42 six sixelState // the sixel images drawn and to draw43 blurred bool // the terminal window doesn't have focus44 waitBG bool // the screen waits for the background (termbg.go)45 bgKnown chan struct{} // closed when it's over46}4748// newTerminal starts with what getenv, the terminal's environment, says:49// the process's own locally, the client's when served over SSH.50func newTerminal(getenv func(string) string) terminal {51 return terminal{tmux: getenv("TMUX") != "", palette: map[int]color.RGBA{}, sent: map[int]string{}, registers: 256}52}5354// wrap prepares a sequence for the outer terminal.55func (t *terminal) wrap(seq string) string {56 if t.tmux {57 return ansi.TmuxPassthrough(seq)58 }59 return seq60}6162// probes are the startup queries: kitty graphics support, live light63// and dark changes (mode 2031), the background color (OSC 11), which64// picks the theme, the 16 palette colors (OSC 4), which chart images65// and color scales draw with, the sixel color registers (XTSMGRAPHICS)66// and, last, the primary device attributes. Terminals answer in order,67// so the other replies, if any, come before DA1's, which then decides68// between kitty images, sixel and text, and ends the wait for the69// background of a terminal that doesn't say it.70func (t *terminal) probes() tea.Cmd {71 return tea.Raw(t.wrap(chart.Query()) + ansi.SetModeLightDark + ansi.RequestBackgroundColor + paletteQuery() + sixelRegistersQuery +72 ansi.RequestPrimaryDeviceAttributes)73}7475// paletteQuery asks for the 16 ANSI colors; terminals that don't answer76// leave xtermColors in use.77func paletteQuery() string {78 var q strings.Builder79 for i := range 16 {80 fmt.Fprintf(&q, "\x1b]4;%d;?\x07", i)81 }82 return q.String()83}8485// firstImageID is the image id of the first chart. Placeholders name86// their image by its 256-color index, and indexes below 16 would be sent87// as basic colors, which don't carry an id.88const firstImageID = 168990// maxImages caps how many charts become images; the rest stay text.91const maxImages = 256 - firstImageID9293// handle handles replies to queries and terminal events.94func (t *terminal) handle(msg tea.Msg) tea.Cmd {95 switch msg := msg.(type) {96 case uv.KittyGraphicsEvent:97 if msg.Options.ID == chart.QueryID && string(msg.Payload) == "OK" && !t.kitty {98 t.kitty = true99 // Ask for the cell size, for image proportions; the palette100 // was asked for at startup.101 return tea.Raw(ansi.WindowOp(16)) // 16: report the cell size in pixels102 }103 case uv.PrimaryDeviceAttributesEvent:104 t.backgroundKnown() // its answer, if any, came first105 if slices.Contains(msg, 4) && !t.kitty && !t.tmux && !t.sixel {106 t.sixel = true107 return tea.Raw(ansi.WindowOp(16)) // sixel images are drawn at the cell size108 }109 case uv.UnknownCsiEvent:110 if n, ok := parseRegisters(string(msg)); ok {111 t.registers = n112 }113 case uv.CellSizeEvent:114 if msg.Width > 0 && msg.Height > 0 {115 t.cellW, t.cellH = msg.Width, msg.Height116 }117 case uv.UnknownOscEvent:118 if i, c, ok := parsePaletteReply(string(msg)); ok {119 t.palette[i] = c120 }121 case uv.DarkColorSchemeEvent, uv.LightColorSchemeEvent:122 // The system switched between light and dark. The terminal's123 // background decides the theme, so ask for it again.124 return tea.RequestBackgroundColor125 case bgWaitedMsg:126 t.backgroundKnown()127 case tea.FocusMsg:128 t.blurred = false129 case tea.BlurMsg:130 t.blurred = true131 }132 return nil133}134135// parsePaletteReply parses an OSC 4 reply, "ESC ] 4 ; 6 ; rgb:0000/cdcd/cdcd".136func parsePaletteReply(s string) (int, color.RGBA, bool) {137 s = strings.TrimPrefix(s, "\x1b]")138 s = strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(s, "\x07"), "\x1b\\"), "\x9c")139 parts := strings.Split(s, ";")140 if len(parts) != 3 || parts[0] != "4" {141 return 0, color.RGBA{}, false142 }143 i, err := strconv.Atoi(parts[1])144 c := ansi.XParseColor(parts[2])145 if err != nil || c == nil || i < 0 || i > 255 {146 return 0, color.RGBA{}, false147 }148 r, g, b, _ := c.RGBA()149 return i, color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), 255}, true150}151152// xtermColors are the 16 colors assumed when the terminal doesn't report153// its palette.154var xtermColors = [16]color.RGBA{155 {0, 0, 0, 255}, {205, 49, 49, 255}, {13, 188, 121, 255}, {229, 229, 16, 255},156 {36, 114, 200, 255}, {188, 63, 188, 255}, {17, 168, 205, 255}, {229, 229, 229, 255},157 {102, 102, 102, 255}, {241, 76, 76, 255}, {35, 209, 139, 255}, {245, 245, 67, 255},158 {59, 142, 234, 255}, {214, 112, 214, 255}, {41, 184, 219, 255}, {255, 255, 255, 255},159}160161func (t *terminal) color(i int) color.RGBA {162 if c, ok := t.palette[i]; ok {163 return c164 }165 return xtermColors[i%16]166}167168// chartPalette is the theme's series colors as the terminal draws them,169// or as the theme's color scheme has them.170func (t *terminal) chartPalette(th *theme.Theme) chart.Palette {171 col := t.color172 if th.Palette != nil {173 col = func(i int) color.RGBA {174 r, g, b, _ := th.Palette.ANSI[i%16].RGBA()175 return color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), 255}176 }177 }178 var p chart.Palette179 for i, idx := range th.SeriesANSI {180 p.Series[i] = col(idx)181 }182 p.Grid = col(8) // bright black, like the text axes183 p.Grid.A = 90184 return p185}186187// images reports whether charts are drawn as kitty images.188func (t *terminal) images() bool { return t.kitty && !t.noImages }189190// chartOptions are the drawing options for charts on this terminal. For191// sixel, whether a chart is an image depends on where it is: see192// Model.drawChart.193func (t *terminal) chartOptions() chart.Options {194 return chart.Options{Image: t.images(), CellW: t.cellW, CellH: t.cellH}195}196197// syncImages sends the images of charts that changed since they were last198// sent, and frees those of deleted charts; shown gives the charts as199// drawn, asked for only on terminals that show images. Update calls it200// after every message, so images follow edits, recalculation, resizing201// and the theme.202func (t *terminal) syncImages(s *sheet.Sheet, shown func() []sheet.Chart, th *theme.Theme, spans *telemetry.Trace) tea.Cmd {203 if !t.images() {204 return t.freeImages()205 }206 var out strings.Builder207 o := t.chartOptions()208 pal := t.chartPalette(th)209 live := map[int]bool{}210 for i, c := range shown() {211 if i >= maxImages {212 break213 }214 id := firstImageID + i215 live[id] = true216 w, h := chartInner(c)217 d := s.ChartData(c)218 o.Chart = c.ChartOptions219 key := fmt.Sprintf("%v %v %d %d %v %v", c.Type, d, w, h, o, pal)220 prev, resent := t.sent[id]221 if prev == key {222 continue223 }224 t.sent[id] = key225 if resent {226 // Free the old image and its placement first: Ghostty keeps227 // every virtual placement of an id and sizes the image by the228 // oldest, so a chart that grew would draw at its old size.229 out.WriteString(chart.Delete(id, t.wrap))230 }231 span := spans.Start("chart.image", slog.String("type", c.Type.String()), slog.Int("w", w), slog.Int("h", h))232 g := chart.Draw(c.Type, d, w, h, o)233 img := chart.Image(c.Type, d, w, h, o, pal)234 if img == nil || g.Plot.Empty() {235 span.End()236 out.WriteString(chart.Delete(id, t.wrap))237 continue238 }239 sent := chart.Transmit(id, img, g.Plot.Dx(), g.Plot.Dy(), t.wrap)240 span.End(slog.Int("bytes", len(sent)))241 out.WriteString(sent)242 }243 for id := range t.sent {244 if !live[id] {245 delete(t.sent, id)246 out.WriteString(chart.Delete(id, t.wrap))247 }248 }249 if out.Len() == 0 {250 return nil251 }252 return tea.Raw(out.String())253}254255// freeImages frees every image sent, when images are turned off.256func (t *terminal) freeImages() tea.Cmd {257 if len(t.sent) == 0 {258 return nil259 }260 var b strings.Builder261 for id := range t.sent {262 b.WriteString(chart.Delete(id, t.wrap))263 delete(t.sent, id)264 }265 return tea.Raw(b.String())266}267268// release undoes the startup modes and frees the chart images.269func (t *terminal) release() string {270 var b strings.Builder271 b.WriteString(ansi.ResetModeLightDark)272 for id := range t.sent {273 b.WriteString(chart.Delete(id, t.wrap))274 }275 return b.String()276}277278// notify tells the user a long job finished, with a desktop279// notification (OSC 9) when the terminal window isn't focused. When it is,280// the screen already shows the result.281func (t *terminal) notify(msg string) tea.Cmd {282 if !t.blurred || t.noNotify {283 return nil284 }285 msg = strings.Map(func(r rune) rune {286 if r < ' ' || r == 0x7f || r >= 0x80 && r < 0xa0 {287 return -1288 }289 return r290 }, msg)291 return tea.Raw(t.wrap(ansi.Notify("012: " + msg)))292}