commit eca55a61963c6f7a60742d25a78010e6ca72386e from: ale date: Fri Jul 31 04:18:48 2026 UTC add real image rendering via Kitty Graphics Protocol Renders ![alt](src "caption") as actual terminal graphics using Kitty Unicode Placeholders, with the caption shown below via glamour. Falls back to today's plain-text image rendering on terminals that don't answer the capability probe, so this is a no-op change for anyone not on Kitty. Image decoding/transmission is kept off the render path: View() only ever does a non-blocking cache lookup, while the slow work (decode, PNG re-encode, transmit) runs in a tea.Cmd triggered on page change and window resize. Images are sent as PNG so Kitty does the scaling itself, avoiding a resize dependency. commit - 24a58b81d73f151c7ba7236890a6e13aea9f0c78 commit + eca55a61963c6f7a60742d25a78010e6ca72386e blob - cc609bb9759125ce12bd242c0c2bd2cfd1af771e blob + 2acdd6a7f7c59eac015c4ac41b9f84b6787f8b57 --- go.mod +++ go.mod @@ -10,6 +10,8 @@ require ( 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 @@ -25,8 +27,6 @@ require ( 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 @@ -8,6 +8,10 @@ charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X 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= @@ -24,10 +28,12 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeD 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= @@ -56,27 +62,40 @@ github.com/charmbracelet/x/windows v0.2.2 h1:Iofanmuva 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= @@ -92,6 +111,7 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+6 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= @@ -99,10 +119,15 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6Ztn 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= @@ -113,6 +138,7 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH 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= @@ -124,8 +150,13 @@ golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIV 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 @@ -0,0 +1,125 @@ +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 @@ -0,0 +1,86 @@ +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 @@ -0,0 +1,97 @@ +// Package image renders markdown images (![alt](src "caption")) 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 ![alt](src "caption") 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 @@ -0,0 +1,93 @@ +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: "![a diagram](./diagram.png)", + want: []image.Ref{ + {Raw: "![a diagram](./diagram.png)", Alt: "a diagram", Src: "./diagram.png"}, + }, + }, + { + name: "image with caption", + markdown: `![a diagram](./diagram.png "Figure 1: the diagram")`, + want: []image.Ref{ + { + Raw: `![a diagram](./diagram.png "Figure 1: the diagram")`, + Alt: "a diagram", + Src: "./diagram.png", + Caption: "Figure 1: the diagram", + }, + }, + }, + { + name: "two images on the same slide", + markdown: "![a](a.png)\n\n![b](b.png)", + want: []image.Ref{ + {Raw: "![a](a.png)", Alt: "a", Src: "a.png"}, + {Raw: "![b](b.png)", 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![a chart](chart.png \"Figure 1\")\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 := "![missing](nope.png)" + 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 @@ -0,0 +1,87 @@ +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 @@ -0,0 +1,47 @@ +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 @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "time" @@ -17,8 +18,11 @@ import ( "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" ) @@ -49,20 +53,66 @@ type Model struct { // 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 { @@ -105,6 +155,9 @@ func (m *Model) Load() error { if m.Theme == nil { m.Theme = styles.SelectTheme(metaData.Theme) } + if m.images == nil { + m.images = image.NewCache() + } return nil } @@ -115,7 +168,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd 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() @@ -185,8 +241,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd }, 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() { @@ -207,6 +277,15 @@ func (m Model) View() tea.View { 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 @@ -214,6 +293,9 @@ func (m Model) View() tea.View { 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 {