commit - 24a58b81d73f151c7ba7236890a6e13aea9f0c78
commit + eca55a61963c6f7a60742d25a78010e6ca72386e
blob - cc609bb9759125ce12bd242c0c2bd2cfd1af771e
blob + 2acdd6a7f7c59eac015c4ac41b9f84b6787f8b57
--- go.mod
+++ go.mod
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
+ github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
+ github.com/charmbracelet/x/ansi v0.11.7
github.com/muesli/coral v1.0.0
github.com/muesli/termenv v0.16.0
github.com/stretchr/testify v1.11.1
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/keygen v0.5.4 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
- github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
- github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/conpty v0.1.1 // indirect
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect
blob - 281bfaa5f83ec7b9dc3112c1361f402f61293a8a
blob + ed1ab6707ef089eb569896d23faf307bf298165b
--- go.sum
+++ go.sum
charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
charm.land/wish/v2 v2.0.1 h1:xYOsvQG/bYNRoKUBt0AEyMiCv9rksmCOG/fD5WifAzg=
charm.land/wish/v2 v2.0.1/go.mod h1:uBWdsKTFk9BE0JsdEv8KKF8/abBfRe/hKEGOBm3OKZA=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
+github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
blob - /dev/null
blob + 5e48cfac7a6d71ae8cbc008d80be16e8c108740a (mode 644)
--- /dev/null
+++ internal/image/cache.go
+package image
+
+import (
+ stdimage "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "os"
+ "sync"
+)
+
+// cellWidthOverHeight approximates a terminal cell's pixel aspect ratio
+// (cells are roughly twice as tall as wide in most monospace fonts). There
+// is no cheap way to query the real value without an extra async
+// round-trip to the terminal, so v1 uses this fixed assumption.
+const cellWidthOverHeight = 0.5
+
+// defaultMaxCols caps how wide a single image gets by default, so one
+// picture doesn't consume the whole slide width on a very wide terminal.
+const defaultMaxCols = 60
+
+// maxRows caps how tall a single image gets, so a narrow/tall image
+// doesn't consume the whole slide height.
+const maxRows = 20
+
+// Entry is a resolved, ready-to-place image.
+type Entry struct {
+ ID int
+ Cols int
+ Rows int
+}
+
+type cacheKey struct {
+ path string
+ maxCols int
+}
+
+// Cache decodes and transmits images over the Kitty Graphics Protocol,
+// keyed by resolved path and target width, and assigns each a stable ID
+// for the process's lifetime.
+type Cache struct {
+ mu sync.RWMutex
+ entries map[cacheKey]Entry
+ nextID int
+}
+
+func NewCache() *Cache {
+ return &Cache{entries: make(map[cacheKey]Entry), nextID: 1}
+}
+
+// Get returns the entry for path/maxCols if it has already been loaded.
+// It does no I/O and is safe to call from View() on every frame.
+func (c *Cache) Get(path string, maxCols int) (Entry, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ e, ok := c.entries[cacheKey{path, maxCols}]
+ return e, ok
+}
+
+// Load decodes and encodes the image at path if it isn't cached yet,
+// returning the entry and the Kitty transmit sequence to send for it. It
+// does file I/O and image decoding, so callers must only invoke it from a
+// tea.Cmd, never from View(). If the entry is already cached, transmitSeq
+// is empty and there is nothing new to send.
+func (c *Cache) Load(path string, maxCols int) (entry Entry, transmitSeq string, err error) {
+ if e, ok := c.Get(path, maxCols); ok {
+ return e, "", nil
+ }
+
+ f, err := os.Open(path)
+ if err != nil {
+ return Entry{}, "", err
+ }
+ defer f.Close()
+
+ img, _, err := stdimage.Decode(f)
+ if err != nil {
+ return Entry{}, "", err
+ }
+
+ cols, rows := fitSize(img.Bounds(), maxCols)
+
+ c.mu.Lock()
+ id := c.nextID
+ c.nextID++
+ e := Entry{ID: id, Cols: cols, Rows: rows}
+ c.entries[cacheKey{path, maxCols}] = e
+ c.mu.Unlock()
+
+ seq, err := TransmitSeq(img, id, cols, rows)
+ if err != nil {
+ return Entry{}, "", err
+ }
+ return e, seq, nil
+}
+
+// fitSize picks a cols x rows grid that preserves the image's aspect
+// ratio within the column/row caps.
+func fitSize(bounds stdimage.Rectangle, maxCols int) (cols, rows int) {
+ w, h := bounds.Dx(), bounds.Dy()
+ if w <= 0 || h <= 0 {
+ return 1, 1
+ }
+
+ cols = maxCols
+ if cols > defaultMaxCols {
+ cols = defaultMaxCols
+ }
+ if cols < 1 {
+ cols = 1
+ }
+
+ rows = int(float64(cols) * cellWidthOverHeight * float64(h) / float64(w))
+ if rows > maxRows {
+ rows = maxRows
+ cols = int(float64(rows) / cellWidthOverHeight * float64(w) / float64(h))
+ }
+ if rows < 1 {
+ rows = 1
+ }
+ if cols < 1 {
+ cols = 1
+ }
+ return cols, rows
+}
blob - /dev/null
blob + 7ad42469e8a9b3ce56171b30260b7cea266393bc (mode 644)
--- /dev/null
+++ internal/image/cache_test.go
+package image_test
+
+import (
+ stdimage "image"
+ "image/color"
+ "image/png"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/maaslalani/slides/internal/image"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func writeTestPNG(t *testing.T, dir, name string, w, h int) string {
+ t.Helper()
+ img := stdimage.NewRGBA(stdimage.Rect(0, 0, w, h))
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ img.Set(x, y, color.RGBA{R: 200, G: 100, B: 50, A: 255})
+ }
+ }
+ path := filepath.Join(dir, name)
+ f, err := os.Create(path)
+ require.NoError(t, err)
+ defer f.Close()
+ require.NoError(t, png.Encode(f, img))
+ return path
+}
+
+func TestCache_LoadAndGet(t *testing.T) {
+ dir := t.TempDir()
+ path := writeTestPNG(t, dir, "wide.png", 200, 100)
+
+ c := image.NewCache()
+
+ _, ok := c.Get(path, 40)
+ assert.False(t, ok, "not loaded yet")
+
+ entry, seq, err := c.Load(path, 40)
+ require.NoError(t, err)
+ assert.NotEmpty(t, seq)
+ assert.Equal(t, 1, entry.ID)
+ assert.Equal(t, 40, entry.Cols)
+
+ got, ok := c.Get(path, 40)
+ assert.True(t, ok)
+ assert.Equal(t, entry, got)
+
+ entry2, seq2, err := c.Load(path, 40)
+ require.NoError(t, err)
+ assert.Equal(t, entry, entry2)
+ assert.Empty(t, seq2, "reloading a cached entry should not retransmit")
+}
+
+func TestCache_DifferentMaxColsGetsDifferentID(t *testing.T) {
+ dir := t.TempDir()
+ path := writeTestPNG(t, dir, "img.png", 100, 100)
+
+ c := image.NewCache()
+ e1, _, err := c.Load(path, 40)
+ require.NoError(t, err)
+ e2, _, err := c.Load(path, 20)
+ require.NoError(t, err)
+
+ assert.NotEqual(t, e1.ID, e2.ID)
+}
+
+func TestCache_TallImageCapsRows(t *testing.T) {
+ dir := t.TempDir()
+ path := writeTestPNG(t, dir, "tall.png", 10, 1000)
+
+ c := image.NewCache()
+ entry, _, err := c.Load(path, 60)
+ require.NoError(t, err)
+
+ assert.LessOrEqual(t, entry.Rows, 20)
+ assert.Greater(t, entry.Cols, 0)
+}
+
+func TestCache_MissingFile(t *testing.T) {
+ c := image.NewCache()
+ _, _, err := c.Load("/no/such/file.png", 40)
+ assert.Error(t, err)
+}
blob - /dev/null
blob + 8618c9015f2c4bb7d7fc89fe1d28268b1b17c420 (mode 644)
--- /dev/null
+++ internal/image/image.go
+// Package image renders markdown images () as real
+// terminal graphics via the Kitty Unicode Placeholder technique, with the
+// caption shown below. Sentinel lines used during pre-processing are
+// wrapped in Private Use Area characters so they can't collide with real
+// slide content and glamour's word-wrap never splits them (no spaces).
+package image
+
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// Ref is a single  reference found in a slide.
+type Ref struct {
+ Raw string
+ Alt string
+ Src string
+ Caption string
+}
+
+// Placement pairs a resolved Ref with its cache Entry, in sentinel order.
+type Placement struct {
+ Ref Ref
+ Entry Entry
+}
+
+var refRe = regexp.MustCompile(`!\[([^\]]*)\]\(\s*(\S+?)(?:\s+"([^"]*)")?\s*\)`)
+
+// Parse extracts image references from raw slide markdown, in the order
+// they appear.
+func Parse(markdown string) []Ref {
+ var refs []Ref
+ for _, m := range refRe.FindAllStringSubmatch(markdown, -1) {
+ refs = append(refs, Ref{Raw: m[0], Alt: m[1], Src: m[2], Caption: m[3]})
+ }
+ return refs
+}
+
+const sentinelOpen = "IMG"
+const sentinelClose = ""
+
+var sentinelRe = regexp.MustCompile(sentinelOpen + `(\d+)` + sentinelClose)
+
+func sentinel(index int) string {
+ return fmt.Sprintf("%s%d%s", sentinelOpen, index, sentinelClose)
+}
+
+// Preprocess replaces each image reference whose cache entry is already
+// resolved (lookup returns ok) with a sentinel line, plus a caption
+// paragraph if present. References not yet resolved are left untouched, so
+// glamour falls back to its default "Image: alt -> src" rendering until
+// they become ready. The returned placements are indexed exactly as the
+// sentinels appear, for Expand to consume after markdown rendering.
+func Preprocess(markdown string, lookup func(Ref) (Entry, bool)) (string, []Placement) {
+ var placements []Placement
+ for _, ref := range Parse(markdown) {
+ entry, ok := lookup(ref)
+ if !ok {
+ continue
+ }
+ replacement := sentinel(len(placements))
+ if ref.Caption != "" {
+ replacement += "\n\n*" + ref.Caption + "*"
+ }
+ markdown = strings.Replace(markdown, ref.Raw, replacement, 1)
+ placements = append(placements, Placement{Ref: ref, Entry: entry})
+ }
+ return markdown, placements
+}
+
+// Expand scans already-rendered text (post markdown, post padding) line by
+// line and replaces each sentinel line with the real placeholder block for
+// its placement, expanding it to as many rows as the image needs.
+func Expand(rendered string, placements []Placement) string {
+ if len(placements) == 0 {
+ return rendered
+ }
+ lines := strings.Split(rendered, "\n")
+ out := make([]string, 0, len(lines))
+ for _, line := range lines {
+ m := sentinelRe.FindStringSubmatch(line)
+ if m == nil {
+ out = append(out, line)
+ continue
+ }
+ idx, err := strconv.Atoi(m[1])
+ if err != nil || idx < 0 || idx >= len(placements) {
+ out = append(out, line)
+ continue
+ }
+ e := placements[idx].Entry
+ out = append(out, strings.Split(PlaceholderBlock(e.ID, e.Cols, e.Rows), "\n")...)
+ }
+ return strings.Join(out, "\n")
+}
blob - /dev/null
blob + 04997a8b08fe05f30a6ed2cfe260b976c5e48dcc (mode 644)
--- /dev/null
+++ internal/image/image_test.go
+package image_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi/kitty"
+ "github.com/maaslalani/slides/internal/image"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParse(t *testing.T) {
+ tests := []struct {
+ name string
+ markdown string
+ want []image.Ref
+ }{
+ {
+ name: "no images",
+ markdown: "# Just a title\n\nSome text.",
+ want: nil,
+ },
+ {
+ name: "image without caption",
+ markdown: "",
+ want: []image.Ref{
+ {Raw: "", Alt: "a diagram", Src: "./diagram.png"},
+ },
+ },
+ {
+ name: "image with caption",
+ markdown: ``,
+ want: []image.Ref{
+ {
+ Raw: ``,
+ Alt: "a diagram",
+ Src: "./diagram.png",
+ Caption: "Figure 1: the diagram",
+ },
+ },
+ },
+ {
+ name: "two images on the same slide",
+ markdown: "\n\n",
+ want: []image.Ref{
+ {Raw: "", Alt: "a", Src: "a.png"},
+ {Raw: "", Alt: "b", Src: "b.png"},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, image.Parse(tt.markdown))
+ })
+ }
+}
+
+func TestPreprocessAndExpand(t *testing.T) {
+ md := "# Title\n\n\n\nSome text after."
+
+ lookup := func(ref image.Ref) (image.Entry, bool) {
+ if ref.Src == "chart.png" {
+ return image.Entry{ID: 5, Cols: 10, Rows: 3}, true
+ }
+ return image.Entry{}, false
+ }
+
+ pre, placements := image.Preprocess(md, lookup)
+ require.Len(t, placements, 1)
+ assert.Equal(t, image.Entry{ID: 5, Cols: 10, Rows: 3}, placements[0].Entry)
+ assert.NotContains(t, pre, "![a chart]")
+ assert.Contains(t, pre, "Figure 1")
+
+ expanded := image.Expand(pre, placements)
+ placeholderLines := 0
+ for _, l := range strings.Split(expanded, "\n") {
+ if strings.Contains(l, string(kitty.Placeholder)) {
+ placeholderLines++
+ }
+ }
+ assert.Equal(t, 3, placeholderLines)
+}
+
+func TestPreprocess_SkipsUnresolvedRefs(t *testing.T) {
+ md := ""
+ pre, placements := image.Preprocess(md, func(image.Ref) (image.Entry, bool) {
+ return image.Entry{}, false
+ })
+ assert.Equal(t, md, pre)
+ assert.Empty(t, placements)
+}
blob - /dev/null
blob + 408a82584e7d1dac81abd7bee7bf905b2182a1cd (mode 644)
--- /dev/null
+++ internal/image/kitty.go
+package image
+
+import (
+ "bytes"
+ stdimage "image"
+ "strconv"
+ "strings"
+
+ "github.com/charmbracelet/x/ansi/kitty"
+)
+
+// ProbeID is a reserved image ID used only to check whether the terminal
+// understands the Kitty graphics protocol; never placed on screen.
+const ProbeID = 999999
+
+// ProbeSeq transmits a throwaway 1x1 image with quiet mode off, so the
+// terminal answers with an OK/error response that Update can watch for.
+func ProbeSeq() string {
+ img := stdimage.NewRGBA(stdimage.Rect(0, 0, 1, 1))
+ var buf bytes.Buffer
+ _ = kitty.EncodeGraphics(&buf, img, &kitty.Options{
+ ID: ProbeID,
+ Action: kitty.Transmit,
+ Transmission: kitty.Direct,
+ Format: kitty.RGBA,
+ ImageWidth: 1,
+ ImageHeight: 1,
+ Quite: 0,
+ })
+ return buf.String()
+}
+
+// TransmitSeq encodes img as PNG (letting the terminal decode and scale it
+// itself, so we never need to resize pixels ourselves) and returns the
+// escape sequence that transmits it and registers a virtual placement
+// sized to cols x rows.
+func TransmitSeq(img stdimage.Image, id, cols, rows int) (string, error) {
+ var buf bytes.Buffer
+ err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
+ ID: id,
+ Action: kitty.TransmitAndPut,
+ Transmission: kitty.Direct,
+ Format: kitty.PNG,
+ Columns: cols,
+ Rows: rows,
+ VirtualPlacement: true,
+ Chunk: true,
+ Quite: 2,
+ })
+ return buf.String(), err
+}
+
+// fgSGR encodes id into a foreground color escape, replicating
+// ultraviolet's own reference encoding (id bytes -> indexed color when the
+// two high bytes are zero, else 24-bit truecolor).
+func fgSGR(id int) string {
+ r := (id >> 16) & 0xff
+ g := (id >> 8) & 0xff
+ b := id & 0xff
+ if r == 0 && g == 0 {
+ return "\x1b[38;5;" + strconv.Itoa(b) + "m"
+ }
+ return "\x1b[38;2;" + strconv.Itoa(r) + ";" + strconv.Itoa(g) + ";" + strconv.Itoa(b) + "m"
+}
+
+// PlaceholderBlock returns the Unicode-placeholder text for a placed
+// image: only the first cell of each row carries the row+column
+// diacritics, the terminal infers the rest from position -- validated
+// against Bubble Tea v2's cell-diffing renderer in cmd/spike-images.
+func PlaceholderBlock(id, cols, rows int) string {
+ fg := fgSGR(id)
+ const reset = "\x1b[0m"
+ lines := make([]string, rows)
+ for y := 0; y < rows; y++ {
+ var sb strings.Builder
+ sb.WriteString(fg)
+ sb.WriteRune(kitty.Placeholder)
+ sb.WriteRune(kitty.Diacritic(y))
+ sb.WriteRune(kitty.Diacritic(0))
+ for x := 1; x < cols; x++ {
+ sb.WriteRune(kitty.Placeholder)
+ }
+ sb.WriteString(reset)
+ lines[y] = sb.String()
+ }
+ return strings.Join(lines, "\n")
+}
blob - /dev/null
blob + f49f945cb7d01d45921c1d09ed9ad250e557d30e (mode 644)
--- /dev/null
+++ internal/image/kitty_test.go
+package image_test
+
+import (
+ stdimage "image"
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi/kitty"
+ "github.com/maaslalani/slides/internal/image"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPlaceholderBlock(t *testing.T) {
+ got := image.PlaceholderBlock(1, 2, 2)
+ lines := strings.Split(got, "\n")
+ require.Len(t, lines, 2)
+
+ const fg = "\x1b[38;5;1m"
+ const reset = "\x1b[0m"
+ ph := string(kitty.Placeholder)
+
+ assert.Equal(t, fg+ph+string(kitty.Diacritic(0))+string(kitty.Diacritic(0))+ph+reset, lines[0])
+ assert.Equal(t, fg+ph+string(kitty.Diacritic(1))+string(kitty.Diacritic(0))+ph+reset, lines[1])
+}
+
+func TestPlaceholderBlock_TruecolorID(t *testing.T) {
+ // A non-zero green byte forces the 24-bit branch instead of indexed color.
+ got := image.PlaceholderBlock(0x00010203, 1, 1)
+ assert.Contains(t, got, "\x1b[38;2;1;2;3m")
+}
+
+func TestProbeSeq(t *testing.T) {
+ seq := image.ProbeSeq()
+ assert.True(t, strings.HasPrefix(seq, "\x1b_G"))
+ assert.Contains(t, seq, "i=999999")
+}
+
+func TestTransmitSeq(t *testing.T) {
+ img := stdimage.NewRGBA(stdimage.Rect(0, 0, 4, 4))
+ seq, err := image.TransmitSeq(img, 7, 10, 5)
+ require.NoError(t, err)
+ assert.Contains(t, seq, "i=7")
+ assert.Contains(t, seq, "c=10")
+ assert.Contains(t, seq, "r=5")
+ assert.Contains(t, seq, "f=100")
+}
blob - 52ac1bcee142cba39202d2e856dec744f6ac96e5
blob + 5bebe5d79a354e68c5d4ae787959ea99cafe7a04
--- internal/model/model.go
+++ internal/model/model.go
"fmt"
"io"
"os"
+ "path/filepath"
"strings"
"time"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
+ uv "github.com/charmbracelet/ultraviolet"
+
"github.com/charmbracelet/glamour"
"github.com/maaslalani/slides/internal/code"
+ "github.com/maaslalani/slides/internal/image"
"github.com/maaslalani/slides/internal/meta"
"github.com/maaslalani/slides/styles"
)
// original slides, it will be displayed on a slide and reset on page change
VirtualText string
Search navigation.Search
+
+ images *image.Cache
+ graphicsOK bool
}
type fileWatchMsg struct{}
+// imagesLoadedMsg carries the Kitty transmit sequences for images that
+// finished decoding since the last time it was requested.
+type imagesLoadedMsg struct{ seqs []string }
+
var fileInfo os.FileInfo
-// Init initializes the model and begins watching the slides file for changes
-// if it exists.
+// Init initializes the model, begins watching the slides file for changes
+// if it exists, and probes whether the terminal supports the Kitty
+// graphics protocol.
func (m Model) Init() tea.Cmd {
+ cmds := []tea.Cmd{tea.Raw(image.ProbeSeq())}
+ if m.FileName != "" {
+ fileInfo, _ = os.Stat(m.FileName)
+ cmds = append(cmds, fileWatchCmd())
+ }
+ return tea.Batch(cmds...)
+}
+
+// imageBaseDir returns the directory image sources are resolved relative
+// to: the slides file's directory, or the working directory for stdin.
+func (m *Model) imageBaseDir() string {
if m.FileName == "" {
+ return "."
+ }
+ return filepath.Dir(m.FileName)
+}
+
+// loadImagesCmd decodes and encodes any not-yet-cached images referenced
+// by slide, off the render path. It does file I/O, so it must only run
+// inside a tea.Cmd.
+func loadImagesCmd(images *image.Cache, baseDir, slide string, maxCols int) tea.Cmd {
+ refs := image.Parse(slide)
+ if images == nil || len(refs) == 0 {
return nil
}
- fileInfo, _ = os.Stat(m.FileName)
- return fileWatchCmd()
+ return func() tea.Msg {
+ var seqs []string
+ for _, ref := range refs {
+ path := filepath.Join(baseDir, ref.Src)
+ if _, ok := images.Get(path, maxCols); ok {
+ continue
+ }
+ _, seq, err := images.Load(path, maxCols)
+ if err != nil || seq == "" {
+ continue
+ }
+ seqs = append(seqs, seq)
+ }
+ if len(seqs) == 0 {
+ return nil
+ }
+ return imagesLoadedMsg{seqs: seqs}
+ }
}
func fileWatchCmd() tea.Cmd {
if m.Theme == nil {
m.Theme = styles.SelectTheme(metaData.Theme)
}
+ if m.images == nil {
+ m.images = image.NewCache()
+ }
return nil
}
case tea.WindowSizeMsg:
m.viewport.SetWidth(msg.Width)
m.viewport.SetHeight(msg.Height)
- return m, nil
+ if !m.graphicsOK || len(m.Slides) == 0 {
+ return m, nil
+ }
+ return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width())
case tea.KeyPressMsg:
keyPress := msg.String()
}, keyPress)
m.buffer = newState.Buffer
m.SetPage(newState.Page)
+ if !m.graphicsOK {
+ return m, nil
+ }
+ return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width())
}
+ case uv.KittyGraphicsEvent:
+ if msg.Options.ID == image.ProbeID && string(msg.Payload) == "OK" {
+ m.graphicsOK = true
+ return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width())
+ }
+ return m, nil
+
+ case imagesLoadedMsg:
+ return m, tea.Raw(strings.Join(msg.seqs, ""))
+
case fileWatchMsg:
newFileInfo, err := os.Stat(m.FileName)
if err == nil && newFileInfo.ModTime() != fileInfo.ModTime() {
r, _ := glamour.NewTermRenderer(m.Theme, glamour.WithWordWrap(m.viewport.Width()))
slide := m.Slides[m.Page]
slide = code.HideComments(slide)
+
+ var placements []image.Placement
+ if m.graphicsOK && m.images != nil {
+ baseDir, maxCols := m.imageBaseDir(), m.viewport.Width()
+ slide, placements = image.Preprocess(slide, func(ref image.Ref) (image.Entry, bool) {
+ return m.images.Get(filepath.Join(baseDir, ref.Src), maxCols)
+ })
+ }
+
slide, err := r.Render(slide)
slide = strings.ReplaceAll(slide, "\t", tabSpaces)
slide += m.VirtualText
slide = fmt.Sprintf("Error: Could not render markdown! (%v)", err)
}
slide = styles.Slide.Render(slide)
+ if len(placements) > 0 {
+ slide = image.Expand(slide, placements)
+ }
var left string
if m.Search.Active {