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 / sixel.go
sixel.go 9.0K
1package ui23import (4 "fmt"5 "hash/fnv"6 "image"7 "image/color"8 "log/slog"9 "slices"10 "strconv"11 "strings"12 "time"1314 tea "charm.land/bubbletea/v2"15 "github.com/charmbracelet/x/ansi"1617 "src.rest/cat/012/internal/chart"18)1920// Sixel chart images, for terminals without kitty graphics. A sixel21// image is pixels the terminal draws at the cursor, which Bubble Tea's22// renderer knows nothing of: it keeps drawing cells, and a cell it23// writes over the image punches a hole in it. So:24//25// - A chart is an image only where the whole of its plot area shows: in26// the grid, under no chart drawn later and no floating box (a menu,27// a dialog, formula suggestions, a note). Elsewhere it is text.28// - Under an image the frame keeps the text chart, which the image29// covers whole (every pixel is set). The renderer writes those cells30// only when the chart changes, and then the image is drawn again.31// DA1 only says a terminal parses sixel: one that lists it without32// showing images, or a recording of the text alone (VHS records33// xterm.js's text layer, not its image layer), shows the text chart34// rather than an empty plot.35// - Images are drawn after the frame: a sixelDrawMsg comes once the36// frame has been still for sixelSettle, longer than a frame, and37// draws each image with the cursor saved and restored around it.38// - When an image moves, changes or goes (scrolling, resizing, an edit39// of its data, an overlay opening over it), the screen is cleared and40// redrawn whole, which erases the images, and they are drawn again.41// When a row an image crosses changes, the images are drawn again.42// - Never inside tmux: tmux answers DA1 for itself, and an image passed43// through to the outer terminal lands where tmux doesn't know, so its44// redraws would erase it.4546// sixelRegistersQuery asks for the number of sixel color registers47// (XTSMGRAPHICS, read).48const sixelRegistersQuery = "\x1b[?1;1;0S"4950// parseRegisters parses the XTSMGRAPHICS reply "CSI ? 1 ; 0 ; n S".51func parseRegisters(s string) (int, bool) {52 s, ok := strings.CutPrefix(s, "\x1b[?1;0;")53 if !ok {54 return 0, false55 }56 s, ok = strings.CutSuffix(s, "S")57 n, err := strconv.Atoi(s)58 if !ok || err != nil || n < 2 {59 return 0, false60 }61 return min(n, 256), true62}6364// sixelSettle is how long the frame stays still before images are drawn65// over it: more than a frame at 60 fps, so the frame is out first.66const sixelSettle = 25 * time.Millisecond6768// sixelState is what the sixel images are and where.69type sixelState struct {70 cache map[int]sixelImage // by chart71 plan []sixelPlace // the images the frame keeps room for72 drawn []sixelPlace // the images on screen73 frame uint64 // the rows of the latest frame the plan crosses, hashed74 drawnFrame uint64 // frame when the images were drawn75 changed time.Time // when frame last changed76 pending bool // a sixelDrawMsg is on its way77 now func() time.Time // the clock; time.Now when nil78 tick func() tea.Cmd // what sends sixelDrawMsg; sixelTick when nil79}8081// sixelImage is a chart's image encoded, and where its plot area is in82// the chart's contents.83type sixelImage struct {84 key string85 plot image.Rectangle86 seq string // "" when there is nothing to plot87}8889// sixelPlace is chart's image at screen cells at.90type sixelPlace struct {91 chart int92 at image.Rectangle93 key string94}9596// sixelDrawMsg draws the images once the frame has settled.97type sixelDrawMsg struct{}9899func sixelTick() tea.Cmd {100 return tea.Tick(sixelSettle, func(time.Time) tea.Msg { return sixelDrawMsg{} })101}102103// later asks for a sixelDrawMsg.104func (s *sixelState) later() tea.Cmd {105 s.pending = true106 if s.tick != nil {107 return s.tick()108 }109 return sixelTick()110}111112func (s *sixelState) clock() time.Time {113 if s.now != nil {114 return s.now()115 }116 return time.Now()117}118119// sixelOn reports whether charts are drawn as sixel images: the120// terminal has sixel and has said its cell size, and chart-images isn't121// off.122func (t *terminal) sixelOn() bool { return t.sixel && !t.noImages && t.cellW > 0 && t.cellH > 0 }123124// syncSixel follows msg, after Update has handled it: it encodes the125// charts that changed, works out where images go, clears the screen when126// one drawn moved, changed or went, and asks for the images to be drawn127// once the frame settles.128func (m *Model) syncSixel(msg tea.Msg) tea.Cmd {129 t := &m.term130 if _, ok := msg.(sixelDrawMsg); ok {131 return m.drawSixels()132 }133 var cmds []tea.Cmd134 if _, ok := msg.(tea.WindowSizeMsg); ok && t.sixel {135 cmds = append(cmds, tea.Raw(ansi.WindowOp(16))) // the font may have changed size too136 }137 if !t.sixelOn() {138 t.six.plan, t.six.cache = nil, nil139 if len(t.six.drawn) > 0 {140 t.six.drawn = nil141 cmds = append(cmds, tea.ClearScreen)142 }143 return tea.Batch(cmds...)144 }145 m.encodeSixels()146 t.six.plan = m.sixelPlan()147 if t.six.gone() {148 t.six.drawn = nil149 cmds = append(cmds, tea.ClearScreen)150 }151 if len(t.six.plan) > 0 && !t.six.pending {152 cmds = append(cmds, t.six.later())153 }154 return tea.Batch(cmds...)155}156157// gone reports whether an image drawn isn't in the plan as it is.158func (s *sixelState) gone() bool {159 for _, d := range s.drawn {160 if !slices.Contains(s.plan, d) {161 return true162 }163 }164 return false165}166167// drawSixels draws the planned images over the settled frame, unless168// they are drawn already over the same rows.169func (m *Model) drawSixels() tea.Cmd {170 s := &m.term.six171 s.pending = false172 if !m.term.sixelOn() || len(s.plan) == 0 || s.frame == s.drawnFrame && slices.Equal(s.drawn, s.plan) {173 return nil174 }175 if s.clock().Sub(s.changed) < sixelSettle {176 return s.later()177 }178 var b strings.Builder179 for _, p := range s.plan {180 b.WriteString(ansi.SaveCursor + ansi.CursorPosition(p.at.Min.X+1, p.at.Min.Y+1))181 b.WriteString(s.cache[p.chart].seq)182 b.WriteString(ansi.RestoreCursor)183 }184 s.drawn, s.drawnFrame = slices.Clone(s.plan), s.frame185 return tea.Raw(b.String())186}187188// noteFrame records the rows of the frame content that the planned189// images cross, so images are drawn again when those rows change.190func (s *sixelState) noteFrame(content string) {191 if len(s.plan) == 0 {192 s.frame = 0193 return194 }195 h := fnv.New64a()196 lines := strings.Split(content, "\n")197 for _, p := range s.plan {198 fmt.Fprintf(h, "%d %v %s\n", p.chart, p.at, p.key)199 for y := p.at.Min.Y; y < p.at.Max.Y && y < len(lines); y++ {200 h.Write([]byte(lines[y]))201 }202 }203 if f := h.Sum64(); f != s.frame {204 s.frame, s.changed = f, s.clock()205 }206}207208// encodeSixels encodes the image of each chart whose data, size, colors209// or cell size changed.210func (m *Model) encodeSixels() {211 t := &m.term212 if t.six.cache == nil {213 t.six.cache = map[int]sixelImage{}214 }215 o := t.chartOptions()216 o.Image = true217 pal, bg := t.chartPalette(&m.th), m.sixelBackground()218 charts := m.displayCharts()219 for i, c := range charts {220 if i >= maxImages {221 break222 }223 w, h := chartInner(c)224 d := m.sheet.ChartData(c)225 o.Chart = c.ChartOptions226 key := fmt.Sprintf("%v %v %d %d %v %v %v %d", c.Type, d, w, h, o, pal, bg, t.registers)227 if t.six.cache[i].key == key {228 continue229 }230 span := m.spans.Start("chart.sixel", slog.String("type", c.Type.String()), slog.Int("w", w), slog.Int("h", h))231 g := chart.Draw(c.Type, d, w, h, o)232 si := sixelImage{key: key, plot: g.Plot}233 if img := chart.Image(c.Type, d, w, h, o, pal); img != nil && !g.Plot.Empty() {234 si.seq = chart.Sixel(img, bg, t.registers)235 }236 span.End(slog.Int("bytes", len(si.seq)))237 t.six.cache[i] = si238 }239 for i := range t.six.cache {240 if i >= len(charts) || i >= maxImages {241 delete(t.six.cache, i)242 }243 }244}245246// sixelBackground is the color images are composited over: the theme's247// background, or the terminal's, or black.248func (m *Model) sixelBackground() color.RGBA {249 c := m.term.bg250 if m.th.Palette != nil && m.th.Palette.Background != nil {251 c = m.th.Palette.Background252 }253 if c == nil {254 return color.RGBA{0, 0, 0, 255}255 }256 r, g, b, _ := c.RGBA()257 return color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), 255}258}259260// sixelPlan is where images go: the charts whose plot area shows whole261// in the grid, under no chart drawn later and no floating box.262func (m *Model) sixelPlan() []sixelPlace {263 var over []image.Rectangle264 for _, b := range m.floating() {265 if len(b.Lines) > 0 {266 over = append(over, image.Rect(b.X, b.Y, b.X+b.Width(), b.Y+b.Height()))267 }268 }269 charts := m.displayCharts()270 boxes := make([]image.Rectangle, len(charts))271 for i, c := range charts {272 x, y := m.chartScreen(c)273 boxes[i] = image.Rect(x, y, x+c.W, y+c.H)274 }275 grid := image.Rect(m.hdrW(), gridTop, m.width, gridTop+m.visibleRows())276 var plan []sixelPlace277 for i := range charts {278 img, ok := m.term.six.cache[i]279 if !ok || img.seq == "" {280 continue281 }282 at := img.plot.Add(boxes[i].Min.Add(image.Pt(2, 1))) // the border and a column of padding283 if !at.In(grid) || overlapsAny(at, over) || overlapsAny(at, boxes[i+1:]) {284 continue285 }286 plan = append(plan, sixelPlace{i, at, img.key})287 }288 return plan289}290291func overlapsAny(r image.Rectangle, rs []image.Rectangle) bool {292 return slices.ContainsFunc(rs, r.Overlaps)293}