Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions internal/tui/embeds.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ func (a *App) mermaidLines(src string, srcLine, width int) []previewLine {
key := contentKey("mermaid", src)
if a.imageMode() == imagesKitty {
if img := a.externalImage(key, width-2, "mermaid", src); img != nil && img.err == nil {
return a.pictureLines(img, srcLine, width, "mermaid")
return a.pictureLines(a.fitted(key, img, width-2), srcLine, width, "mermaid")
}
}
d := a.diagrams()
Expand Down Expand Up @@ -447,15 +447,18 @@ func (a *App) mathLines(body string, srcLine, width int) []previewLine {
out := []previewLine{}
if a.imageMode() == imagesKitty {
var img *termImage
var key string
if a.prefs.TerminalMath == "latex" {
src := strings.TrimSpace(body)
img = a.externalImage(latexKey(string(th.Fg), currentLatexFormat(), src), width-2, "latex", src)
key = latexKey(string(th.Fg), currentLatexFormat(), src)
img = a.externalImage(key, width-2, "latex", src)
out = append(out, a.latexErrorRows(img, srcLine, width)...)
} else {
img = a.externalImage(contentKey("math", fmt.Sprintf("typst\x00%t\x00%s", th.Dark, inner)), width-2, "math", inner)
key = contentKey("math", fmt.Sprintf("typst\x00%t\x00%s", th.Dark, inner))
img = a.externalImage(key, width-2, "math", inner)
}
if img != nil && img.err == nil {
return a.pictureLines(img, srcLine, width, "math")
return a.pictureLines(a.fitted(key, img, width-2), srcLine, width, "math")
}
}
for _, l := range strings.Split(inner, "\n") {
Expand All @@ -471,13 +474,25 @@ func (a *App) tikzLines(src string, srcLine, width int) ([]previewLine, bool) {
if a.imageMode() != imagesKitty {
return nil, false
}
img := a.externalImage(latexKey(string(a.theme.Fg), currentLatexFormat(), latexKeySrc("tikz", src)), width-2, "tikz", src)
key := latexKey(string(a.theme.Fg), currentLatexFormat(), latexKeySrc("tikz", src))
img := a.externalImage(key, width-2, "tikz", src)
if img != nil && img.err == nil {
return a.pictureLines(img, srcLine, width, "tikz"), true
return a.pictureLines(a.fitted(key, img, width-2), srcLine, width, "tikz"), true
}
return a.latexErrorRows(img, srcLine, width), false
}

// fitted is a rendered picture at the size that fits maxCols now. The
// background render sends it at the pane width of the moment it finished;
// without this, a picture first shown in a narrow pane (a split, a window
// still opening) stays that small after the pane grows.
func (a *App) fitted(key string, img *termImage, maxCols int) *termImage {
if f := a.images().refit(key, maxCols, 40); f != nil && f.err == nil {
return f
}
return img
}

// latexErrorRows is the first line of a failed latex render, wrapped rather
// than truncated because the cause sits at the end of long paths. A render
// that worked, is still running or lacks its tools has none.
Expand Down
136 changes: 111 additions & 25 deletions internal/tui/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,37 @@ type termImage struct {
}

type imageStore struct {
mu sync.Mutex
byKey map[string]*termImage
nextID uint32
cellW int
cellH int
out io.Writer
mu sync.Mutex
byKey map[string]*termImage
// pics keeps each key's PNG and the sizes it was sent at, so a picture
// re-fits when its pane changes width without being loaded again.
pics map[string]*storedPic
picBytes int
picTick int
nextID uint32
cellW int
cellH int
out io.Writer
}

// storedPic is a loaded picture and the sizes sent for it, newest first.
type storedPic struct {
png []byte
w, h int
sent []*termImage
used int
}

// maxSentSizes is how many sizes of one picture stay in the terminal: a
// note open in two panes of different widths shows both without either
// being sent again on every redraw. An older size is freed.
const maxSentSizes = 3

// maxPicBytes caps the PNGs kept for re-fitting. Past it the least recently
// shown picture's bytes go; a vault image then reloads from disk when it is
// next re-fitted, and a rendered one keeps the size it has. Tests lower it.
var maxPicBytes = 64 << 20

func newImageStore() *imageStore {
s := &imageStore{byKey: map[string]*termImage{}, nextID: 1000, out: os.Stdout}
s.cellW, s.cellH = cellPixelSize()
Expand All @@ -94,42 +117,105 @@ func (a *App) images() *imageStore {
return a.imageStore
}

// imageFor returns the transmitted image for a key, loading and sending it
// on first use. maxCols bounds the width; the height follows the picture's
// aspect ratio at the terminal's cell size, capped at maxRows.
// imageFor returns the transmitted image for a key at the size that fits
// maxCols, loading the picture on first use. The width is the picture's
// own, or maxCols when that is narrower; the height follows its aspect
// ratio at the terminal's cell size, capped at maxRows. The size follows
// maxCols both ways, so a picture first shown in a narrow pane grows when
// the pane does instead of staying small.
func (s *imageStore) imageFor(key string, maxCols, maxRows int, load func() ([]byte, error)) *termImage {
s.mu.Lock()
defer s.mu.Unlock()
if img, ok := s.byKey[key]; ok && (img.err != nil || img.cols <= maxCols) {
if img, ok := s.byKey[key]; ok && img.err != nil {
return img
}
data, err := load()
if err != nil {
img := &termImage{err: err}
s.byKey[key] = img
return img
if s.pics == nil {
s.pics = map[string]*storedPic{}
}
pngData, w, h, err := toPNG(data, 1600)
if err != nil {
img := &termImage{err: err}
s.byKey[key] = img
return img
p, ok := s.pics[key]
if !ok {
data, err := load()
var pngData []byte
var w, h int
if err == nil {
pngData, w, h, err = toPNG(data, 1600)
}
if err != nil {
img := &termImage{err: err}
s.byKey[key] = img
return img
}
p = &storedPic{png: pngData, w: w, h: h}
s.pics[key] = p
s.picBytes += len(pngData)
}
cols := max(1, min(maxCols, (w+s.cellW-1)/s.cellW))
rows := max(1, int(float64(h)*float64(cols)*float64(s.cellW)/float64(w)/float64(s.cellH)+0.5))
img := s.fitLocked(key, p, maxCols, maxRows)
s.trimPics()
return img
}

// refit is imageFor for a picture already loaded under key, at the size
// that fits maxCols now, or nil when its bytes are not kept.
func (s *imageStore) refit(key string, maxCols, maxRows int) *termImage {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.pics[key]
if !ok {
return nil
}
return s.fitLocked(key, p, maxCols, maxRows)
}

// fitLocked returns p at the size that fits maxCols, sending that size the
// first time it is asked for. s.mu must be held.
func (s *imageStore) fitLocked(key string, p *storedPic, maxCols, maxRows int) *termImage {
s.picTick++
p.used = s.picTick
cols := max(1, min(maxCols, (p.w+s.cellW-1)/s.cellW))
rows := max(1, int(float64(p.h)*float64(cols)*float64(s.cellW)/float64(p.w)/float64(s.cellH)+0.5))
if maxRows > 0 && rows > maxRows {
rows = maxRows
cols = max(1, int(float64(w)*float64(rows)*float64(s.cellH)/float64(h)/float64(s.cellW)+0.5))
cols = max(1, int(float64(p.w)*float64(rows)*float64(s.cellH)/float64(p.h)/float64(s.cellW)+0.5))
}
for i, img := range p.sent {
if img.cols == cols && img.rows == rows {
copy(p.sent[1:i+1], p.sent[:i])
p.sent[0] = img
s.byKey[key] = img
return img
}
}
s.nextID++
img := &termImage{id: s.nextID, cols: cols, rows: rows, width: w, height: h}
if err := kittyTransmit(s.out, img.id, cols, rows, pngData, insideTmux()); err != nil {
img := &termImage{id: s.nextID, cols: cols, rows: rows, width: p.w, height: p.h}
if err := kittyTransmit(s.out, img.id, cols, rows, p.png, insideTmux()); err != nil {
img.err = err
s.byKey[key] = img
return img
}
p.sent = append([]*termImage{img}, p.sent...)
if len(p.sent) > maxSentSizes {
kittyDelete(s.out, p.sent[maxSentSizes].id, insideTmux())
p.sent = p.sent[:maxSentSizes]
}
s.byKey[key] = img
return img
}

// trimPics drops the least recently shown pictures' bytes past
// maxPicBytes. Their sent sizes stay in the terminal and on screen.
func (s *imageStore) trimPics() {
for s.picBytes > maxPicBytes && len(s.pics) > 1 {
oldest, at := "", 0
for k, p := range s.pics {
if oldest == "" || p.used < at {
oldest, at = k, p.used
}
}
s.picBytes -= len(s.pics[oldest].png)
delete(s.pics, oldest)
}
}

// toPNG decodes any supported picture and re-encodes it as PNG, scaled
// down when wider than maxWidth so a huge photo does not flood the terminal.
func toPNG(data []byte, maxWidth int) ([]byte, int, int, error) {
Expand Down
132 changes: 132 additions & 0 deletions internal/tui/images_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package tui

import (
"bytes"
"image"
"strconv"
"strings"
"testing"
"time"
)

// fullPNG is a w x h picture inked edge to edge, so cropToInk keeps its size.
func fullPNG(t *testing.T, w, h int) []byte {
t.Helper()
return inkPNG(t, w, h, image.Rect(0, 0, w, h))
}

func testStore() (*imageStore, *bytes.Buffer) {
out := &bytes.Buffer{}
return &imageStore{byKey: map[string]*termImage{}, nextID: 1000, cellW: 10, cellH: 20, out: out}, out
}

// sends counts the pictures transmitted to out since the last call.
func sends(out *bytes.Buffer) int {
n := strings.Count(out.String(), "a=T,")
out.Reset()
return n
}

func TestPictureGrowsAndShrinksWithThePane(t *testing.T) {
s, out := testStore()
data := fullPNG(t, 1552, 122) // 156 cells wide at 10 px a cell
load := func() ([]byte, error) { return data, nil }

narrow := s.imageFor("wide", 30, 40, load)
if narrow.cols != 30 || sends(out) != 1 {
t.Fatalf("narrow pane: %d cols, want 30, sent once", narrow.cols)
}
wide := s.imageFor("wide", 120, 40, func() ([]byte, error) { t.Fatal("loaded again"); return nil, nil })
if wide.cols != 120 || wide.id == narrow.id || sends(out) != 1 {
t.Fatalf("wider pane: %d cols (id %d, was %d), want 120 under a new id, sent once", wide.cols, wide.id, narrow.id)
}
if wide.rows < narrow.rows {
t.Fatalf("height shrank as the picture grew: %d rows from %d", wide.rows, narrow.rows)
}
again := s.imageFor("wide", 30, 40, load)
if again.id != narrow.id || sends(out) != 0 {
t.Fatalf("back to the narrow pane sent a new picture (id %d, was %d)", again.id, narrow.id)
}
}

func TestPictureAtItsOwnSizeIsSentOnce(t *testing.T) {
s, out := testStore()
data := fullPNG(t, 120, 40) // 12 cells wide
load := func() ([]byte, error) { return data, nil }
first := s.imageFor("small", 30, 40, load)
for _, cols := range []int{60, 120, 13} {
if img := s.imageFor("small", cols, 40, load); img.id != first.id || img.cols != 12 {
t.Fatalf("at %d cols: id %d cols %d, want id %d at its own 12 cols", cols, img.id, img.cols, first.id)
}
}
if n := sends(out); n != 1 {
t.Fatalf("a picture that fits every pane was sent %d times, want 1", n)
}
}

func TestOldestPictureSizeIsFreed(t *testing.T) {
s, out := testStore()
data := fullPNG(t, 1552, 122)
load := func() ([]byte, error) { return data, nil }
first := s.imageFor("wide", 20, 40, load)
for _, cols := range []int{40, 60, 80} {
s.imageFor("wide", cols, 40, load)
}
if !strings.Contains(out.String(), "a=d,d=I,i="+strconv.FormatUint(uint64(first.id), 10)+",") {
t.Fatalf("the fourth size did not free the first (id %d):\n%q", first.id, out.String())
}
out.Reset()
if img := s.imageFor("wide", 20, 40, load); img.id == first.id || sends(out) != 1 {
t.Fatalf("a freed size came back under its old id %d", first.id)
}
}

func TestKeptPictureBytesAreCapped(t *testing.T) {
prev := maxPicBytes
t.Cleanup(func() { maxPicBytes = prev })
s, _ := testStore()
data := fullPNG(t, 400, 100)
maxPicBytes = 2*len(data) + len(data)/2
for _, k := range []string{"a", "b", "c"} {
s.imageFor(k, 80, 40, func() ([]byte, error) { return data, nil })
}
if _, ok := s.pics["a"]; ok || len(s.pics) != 2 {
t.Fatalf("kept %d pictures, want the 2 most recent: %v", len(s.pics), s.pics)
}
if s.refit("a", 20, 40) != nil {
t.Fatal("refit of a dropped picture did not report it")
}
if img := s.byKey["a"]; img == nil || img.err != nil {
t.Fatalf("dropping the bytes broke the picture on screen: %+v", img)
}
}

// placeholderCols is how many placeholder cells the first picture row of a
// preview has.
func placeholderCols(lines []previewLine) int {
for _, l := range lines {
if n := strings.Count(l.text, "\U0010EEEE"); n > 0 {
return n
}
}
return 0
}

func TestLatexPictureRefitsWhenThePreviewWidens(t *testing.T) {
latexEnv(t)
fakeTeX(t, texFake{png: fullPNG(t, 1552, 122)})
a, done := newMathApp(t, mathPrefs("latex", "kitty"))
body := "\\[ a_1 + a_2 + \\dots + a_{20} \\]"
a.mathLines(body, 0, 32)
if !nextRender(done, 5*time.Second) {
t.Fatal("no render finished")
}
narrow := placeholderCols(a.mathLines(body, 0, 32))
wide := placeholderCols(a.mathLines(body, 0, 122))
if narrow != 30 || wide != 120 {
t.Fatalf("picture is %d cells in a 32-cell preview and %d in a 122-cell one, want 30 then 120", narrow, wide)
}
if back := placeholderCols(a.mathLines(body, 0, 32)); back != 30 {
t.Fatalf("narrowed again, the picture is %d cells, want 30", back)
}
}
10 changes: 8 additions & 2 deletions internal/tui/latex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ type texFake struct {
render func(ctx context.Context, base string) ([]byte, error)
// cairo, when set, replaces a successful pdftocairo run.
cairo func(ctx context.Context) ([]byte, error)
// png, when set, is the picture pdftocairo writes instead of tinyPNG.
png []byte
}

// fakeTeX swaps runTool for fake pdftex and pdftocairo. A format build
Expand Down Expand Up @@ -187,17 +189,21 @@ func fakeTeX(t *testing.T, f texFake) func() []texCall {
if f.cairo != nil {
return f.cairo(ctx)
}
pic := f.png
if pic == nil {
pic = tinyPNG(t)
}
wrote := false
for _, a := range args {
if strings.HasSuffix(a, ".png") {
if err := os.WriteFile(inDir(dir, a), tinyPNG(t), 0o644); err != nil {
if err := os.WriteFile(inDir(dir, a), pic, 0o644); err != nil {
return nil, err
}
wrote = true
}
}
if last := args[len(args)-1]; !wrote && !strings.HasPrefix(last, "-") && !strings.HasSuffix(last, ".pdf") {
if err := os.WriteFile(inDir(dir, last)+".png", tinyPNG(t), 0o644); err != nil {
if err := os.WriteFile(inDir(dir, last)+".png", pic, 0o644); err != nil {
return nil, err
}
}
Expand Down
Loading