From 108541af912ac894de797d96c2868bf51c74cbf7 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 23:03:29 +0200 Subject: [PATCH] Show posters as real images in kitty and Ghostty (Kitty graphics with Unicode placeholders) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 + README.md | 22 +-- SPECS.md | 5 +- internal/config/config.go | 11 +- internal/config/config_test.go | 4 +- internal/ui/app.go | 50 ++++++- internal/ui/cover.go | 106 +++++++++++--- internal/ui/cover_test.go | 14 +- internal/ui/detail.go | 4 +- internal/ui/kitty.go | 144 +++++++++++++++++++ internal/ui/kitty_diacritics.go | 46 ++++++ internal/ui/kitty_test.go | 241 ++++++++++++++++++++++++++++++++ 12 files changed, 601 insertions(+), 49 deletions(-) create mode 100644 internal/ui/kitty.go create mode 100644 internal/ui/kitty_diacritics.go create mode 100644 internal/ui/kitty_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 15c28e2..266bcda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- **Posters as real images** in kitty and Ghostty (Kitty graphics protocol with Unicode + placeholders), at the terminal's full resolution and with the right aspect ratio. Other terminals + keep the block-character posters. `ui.cover_style` gains `kitty`; `auto` is the default. - **Calendar (F5):** two weeks of movie releases and airing episodes grouped by day, with poster, plot, release search and automatic search. The Log moves to F6. - **Download notifications:** popup and log entry when a SABnzbd download finishes or fails, in the diff --git a/README.md b/README.md index da554fe..f838f78 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,17 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video - **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons), sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first. - **Posters**: the detail panes show the poster at the top right; title, facts, path and plot - flow along its left side and continue at full width below it. It is drawn with colored block characters (works over SSH, needs a terminal - with colors). Library items use the copy stored in Radarr/Sonarr; search results are downloaded - from the metadata provider's image host (TMDb, TheTVDB). Turn it off with - `"ui": {"covers": false}`. `"ui": {"cover_style": ...}` picks the characters: `quadrant` (2x2 - pixels per cell, every font has them), `sextant` (2x3, sharper, needs a font with the Symbols for - Legacy Computing block, e.g. kitty, WezTerm, foot, Ghostty) or `auto` (default: sextant on - terminals known to support it, quadrant elsewhere). If you see boxes or question marks in the - poster, set `quadrant`. -- **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), toggle - monitoring, trigger searches, remove titles (the files stay on disk unless you tick "Delete files"). Series open into seasons and episodes with per-season and - per-episode monitoring, automatic search and release search. + flow along its left side and continue at full width below it. In kitty and Ghostty it is a real + image at the terminal's full resolution (Kitty graphics protocol with Unicode placeholders, so + popups and dialogs cover it like text). Everywhere else it is drawn with colored block characters, + which works over SSH and in any terminal with colors. Library items use the copy stored in + Radarr/Sonarr; search results are downloaded from the metadata provider's image host (TMDb, + TheTVDB). Turn it off with `"ui": {"covers": false}`. `"ui": {"cover_style": ...}` picks the + drawing: `auto` (default: real images where the terminal is known to support them, otherwise + sextants on terminals known to have the glyphs, else quadrants), `kitty` (force real images), + `sextant` (2x3 pixels per cell, needs a font with the Symbols for Legacy Computing block) or + `quadrant` (2x2, every font has them). Inside tmux or screen the block styles are used. If a + poster looks wrong, set `quadrant`. - **Calendar**: the next two weeks of movie releases (cinemas, digital, physical) and airing episodes in one list grouped by day, with poster and plot. Move by weeks, include unmonitored items, search for releases or start an automatic search from there. diff --git a/SPECS.md b/SPECS.md index 48b760e..6203889 100644 --- a/SPECS.md +++ b/SPECS.md @@ -63,7 +63,10 @@ Notes: - Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible. - Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job. - **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference. -- **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. The poster is drawn with block characters in 24-bit colors: every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing). `ui.cover_style` is `auto` (default: sextant on kitty, WezTerm, foot, Ghostty, quadrant elsewhere), `quadrant` or `sextant`. JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed. Library items fetch `/MediaCover//poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory, failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it. +- **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. Two ways to draw the poster, chosen by `ui.cover_style`: + - **Real images** (`kitty`, and `auto` in kitty/Ghostty): the Kitty graphics protocol with Unicode placeholders. The poster (kept up to 640 px tall) is uploaded once as PNG (`a=t,f=100,q=2`, 4096-byte base64 chunks), placed virtually with the cell footprint (`a=p,U=1,c=,r=`; a new size deletes and recreates the placement), and shown by writing U+10EEEE placeholder characters whose combining marks give row and column (the 297-entry table from the protocol) and whose foreground color is the image id. Since it is ordinary text, popups, dialogs and view changes cover it correctly and tcell's diffing keeps it in place. The footprint uses the real cell size in pixels (from the terminal's window size) so the poster is not stretched. Writes to the terminal go through the screen's tty on the UI goroutine, so they cannot interleave with drawing. Requires 24-bit color; otherwise, and inside tmux/screen, the block fallback is used. Terminals are recognised from `TERM`/`TERM_PROGRAM`/`KITTY_WINDOW_ID`/`GHOSTTY_RESOURCES_DIR`. The go-termimg library was looked at and not used: its placeholder support is marked experimental, its diacritic table does not match the protocol's, and it writes images straight to the terminal instead of through the screen. + - **Block characters** (`quadrant`, `sextant`, and the fallback): every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing). `auto` picks sextants on kitty, WezTerm, foot, Ghostty and quadrants elsewhere. + Posters are JPEG/PNG, decoded and downscaled in the process. Library items fetch `/MediaCover//poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it. - **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search. - **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that. - **Notifications and log (F5):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll. diff --git a/internal/config/config.go b/internal/config/config.go index 7a1518d..12b00e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,8 +40,11 @@ type UI struct { // CoverStyle selects the block characters posters are drawn with: // "quadrant" (2x2 pixels per cell, every font has the glyphs), "sextant" // (2x3, sharper, needs a font with the Symbols for Legacy Computing - // block, e.g. kitty, WezTerm, foot, Ghostty) or "auto" (default: sextant - // on terminals known to draw them, quadrant elsewhere). + // block, e.g. kitty, WezTerm, foot, Ghostty), "kitty" (real images through + // the Kitty graphics protocol with Unicode placeholders: kitty and + // Ghostty) or "auto" (default: the Kitty protocol where the terminal is + // known to support it, else sextant on terminals known to draw them, else + // quadrant). The block styles are also the fallback for "kitty". CoverStyle string `json:"cover_style,omitempty"` } @@ -161,9 +164,9 @@ func (c *Config) Validate() error { } } switch c.UI.CoverStyle { - case "", "auto", "quadrant", "sextant": + case "", "auto", "quadrant", "sextant", "kitty": default: - errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "quadrant" or "sextant"`, c.UI.CoverStyle)) + errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "kitty", "sextant" or "quadrant"`, c.UI.CoverStyle)) } return errors.Join(errs...) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5166dbc..243f49f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -100,12 +100,12 @@ func TestShowCovers(t *testing.T) { func TestCoverStyleValidation(t *testing.T) { base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"cover_style":` - for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `""`} { + for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `"kitty"`, `""`} { if _, err := Parse([]byte(base + ok + `}}`)); err != nil { t.Errorf("%s rejected: %v", ok, err) } } - if _, err := Parse([]byte(base + `"braille"}}`)); err == nil { + if _, err := Parse([]byte(base + `"sixel"}}`)); err == nil { t.Error("unknown style accepted") } } diff --git a/internal/ui/app.go b/internal/ui/app.go index 5959ce9..629b2ef 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -52,6 +52,8 @@ type App struct { bell bool covers bool // poster thumbnails enabled coverStyle coverStyle + gfx *kittyGraphics // real-image posters, nil when not used + cellAspect float64 // height of a terminal cell divided by its width coverCache *coverCache } @@ -99,10 +101,16 @@ func New(cfg *config.Config) *App { notifyDone: cfg.UI.ShouldNotifyDownloads(), bell: cfg.UI.Bell, covers: cfg.UI.ShowCovers(), - coverStyle: coverStyleFor(cfg.UI.CoverStyle, os.Getenv), + cellAspect: 2, coverCache: newCoverCache(), } + blocks, graphics := resolveCovers(cfg.UI.CoverStyle, os.Getenv) + a.coverStyle = blocks + if graphics { + a.gfx = newKittyGraphics(a.writeTerminal) + } + radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey) sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey) a.logs = newLogView(a) @@ -149,6 +157,7 @@ func New(cfg *config.Config) *App { a.app.SetBeforeDrawFunc(func(s tcell.Screen) bool { a.screen = s + a.cellAspect = cellAspectOf(s) return false }) a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false) @@ -158,6 +167,40 @@ func New(cfg *config.Config) *App { return a } +// writeTerminal sends raw bytes (graphics commands) to the terminal through the +// screen's own connection. UI goroutine only, so it stays in step with drawing. +func (a *App) writeTerminal(b []byte) error { + if a.screen == nil { + return fmt.Errorf("no screen yet") + } + tty, ok := a.screen.Tty() + if !ok { + return fmt.Errorf("the screen has no terminal") + } + _, err := tty.Write(b) + return err +} + +// cellAspectOf measures the height of a terminal cell divided by its width in +// pixels; 2 when the terminal does not report pixel sizes. +func cellAspectOf(s tcell.Screen) float64 { + if tty, ok := s.Tty(); ok { + if ws, err := tty.WindowSize(); err == nil && ws.Width > 0 && ws.Height > 0 && ws.PixelWidth > 0 && ws.PixelHeight > 0 { + return (float64(ws.PixelHeight) / float64(ws.Height)) / (float64(ws.PixelWidth) / float64(ws.Width)) + } + } + return 2 +} + +// coverMaxHeight is how tall the stored poster copies are: real images keep +// more detail than block characters need. +func (a *App) coverMaxHeight() int { + if a.gfx != nil { + return graphicsCoverHeight + } + return storedCoverHeight +} + // SetService records the outcome of a request to a service and updates the // connection indicators. UI goroutine only. A nil err means reachable. func (a *App) SetService(name, version string, err error) { @@ -246,6 +289,11 @@ func (a *App) post(f func()) { func (a *App) Run() error { stop := make(chan struct{}) defer close(stop) + defer func() { + if a.gfx != nil { + a.gfx.deleteAll() + } + }() go a.watchServices(stop) if a.notifyDone { ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/ui/cover.go b/internal/ui/cover.go index 5697978..00cac70 100644 --- a/internal/ui/cover.go +++ b/internal/ui/cover.go @@ -18,15 +18,17 @@ import ( ) const ( - maxCoverPixels = 25_000_000 // refuse decompression bombs - storedCoverHeight = 160 // a small copy is plenty for a terminal thumbnail - coverFailureTTL = 2 * time.Minute - maxCoverCache = 300 - coverFetchLimit = 4 + maxCoverPixels = 25_000_000 // refuse decompression bombs + storedCoverHeight = 160 // a small copy is plenty for block characters + graphicsCoverHeight = 640 // posters shown as real images keep more detail + coverFailureTTL = 2 * time.Minute + maxCoverCache = 120 + coverFetchLimit = 4 ) -// decodeCover decodes a JPEG or PNG poster and keeps a small copy. -func decodeCover(data []byte) (*image.RGBA, error) { +// decodeCover decodes a JPEG or PNG poster and keeps a copy at most +// maxHeight pixels tall. +func decodeCover(data []byte, maxHeight int) (*image.RGBA, error) { cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("not a supported image: %w", err) @@ -39,10 +41,10 @@ func decodeCover(data []byte) (*image.RGBA, error) { return nil, err } b := img.Bounds() - if b.Dy() <= storedCoverHeight { + if b.Dy() <= maxHeight { return boxScale(img, b.Dx(), b.Dy()), nil } - return boxScale(img, max(1, b.Dx()*storedCoverHeight/b.Dy()), storedCoverHeight), nil + return boxScale(img, max(1, b.Dx()*maxHeight/b.Dy()), maxHeight), nil } // boxScale resizes src to w×h by averaging the source pixels under every @@ -117,8 +119,9 @@ func sextantGlyph(mask int) rune { return rune(0x1FB00 + idx) } -// coverStyleFor resolves the configured style. "auto" picks sextants on -// terminals known to have the glyphs and quadrants everywhere else. +// coverStyleFor resolves the block-character style for the setting. "auto" +// picks sextants on terminals known to have the glyphs and quadrants everywhere +// else. func coverStyleFor(setting string, env func(string) string) coverStyle { switch setting { case "sextant": @@ -138,6 +141,21 @@ func coverStyleFor(setting string, env func(string) string) coverStyle { return quadrantStyle } +// resolveCovers decides how posters are drawn: as real images through the +// Kitty graphics protocol (graphics) or with block characters in the given +// style. "kitty" asks for the protocol explicitly; "auto" uses it when the +// terminal is known to support it. The block style is also the fallback when +// the protocol cannot be used at draw time (e.g. no true color). +func resolveCovers(setting string, env func(string) string) (blocks coverStyle, graphics bool) { + switch setting { + case "kitty": + return coverStyleFor("auto", env), true + case "auto", "": + return coverStyleFor("auto", env), kittyTerminal(env) + } + return coverStyleFor(setting, env), false +} + // flatPixelError is the squared RGB distance per pixel below which a cell is // drawn flat: smooth areas and noise should not turn into patterns. const flatPixelError = 150 @@ -214,9 +232,11 @@ func (st coverStyle) bestCell(p []color.RGBA) (ch rune, fg, bg color.RGBA) { // colors, also over SSH. type coverView struct { *tview.Box - style coverStyle - img *image.RGBA - note string // shown instead of the image: "loading…", "no cover" + style coverStyle + gfx *kittyGraphics // real images; nil for block characters only + cellAspect func() float64 // height of a terminal cell divided by its width + img *image.RGBA + note string // shown instead of the image: "loading…", "no cover" // The cells for the last drawn size. cols, rows int @@ -228,7 +248,18 @@ type styledCell struct { fg, bg tcell.Color } -func newCoverView(style coverStyle) *coverView { return &coverView{Box: tview.NewBox(), style: style} } +func newCoverView(style coverStyle, gfx *kittyGraphics, cellAspect func() float64) *coverView { + return &coverView{Box: tview.NewBox(), style: style, gfx: gfx, cellAspect: cellAspect} +} + +func (c *coverView) aspectOfCells() float64 { + if c.cellAspect != nil { + if a := c.cellAspect(); a > 0.5 && a < 8 { + return a + } + } + return 2 +} func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.cells = img, "", nil } func (c *coverView) setNote(note string) { c.img, c.note, c.cells = nil, note, nil } @@ -243,13 +274,14 @@ func (c *coverView) aspect() (w, h int) { } // coverCells returns how many columns and rows a poster of the given aspect -// needs inside w×h cells. A cell is about twice as tall as it is wide. -func coverCells(imgW, imgH, w, h int) (cols, rows int) { +// needs inside w×h cells. cellAspect is the height of a cell divided by its +// width, about 2 in most terminals. +func coverCells(imgW, imgH, w, h int, cellAspect float64) (cols, rows int) { cols = w - rows = cols * imgH / imgW / 2 + rows = int(float64(cols) * float64(imgH) / float64(imgW) / cellAspect) if rows > h { rows = h - cols = rows * 2 * imgW / imgH + cols = int(float64(rows) * cellAspect * float64(imgW) / float64(imgH)) } return max(cols, 0), max(rows, 0) } @@ -268,10 +300,13 @@ func (c *coverView) Draw(screen tcell.Screen) { } b := c.img.Bounds() - cols, rows := coverCells(b.Dx(), b.Dy(), w, h) + cols, rows := coverCells(b.Dx(), b.Dy(), w, h, c.aspectOfCells()) if cols < 1 || rows < 1 { return } + if c.drawGraphics(screen, x+w-cols, y, cols, rows) { + return + } if c.cells == nil || c.cols != cols || c.rows != rows { c.cols, c.rows, c.cells = cols, rows, c.build(cols, rows) } @@ -285,6 +320,31 @@ func (c *coverView) Draw(screen tcell.Screen) { } } +// drawGraphics shows the poster as a real image through the Kitty graphics +// protocol: the image is uploaded once and the cells get placeholder +// characters. It reports false when that is not possible (no protocol, no true +// color, too many rows or columns, or the terminal could not be written to), so +// the caller can draw block characters instead. +func (c *coverView) drawGraphics(screen tcell.Screen, x, y, cols, rows int) bool { + if c.gfx == nil || screen.Colors() < 1<<24 || cols > maxPlaceholders || rows > maxPlaceholders { + return false + } + id, err := c.gfx.imageID(c.img) + if err == nil { + err = c.gfx.place(id, cols, rows) + } + if err != nil { + return false + } + for row := 0; row < rows; row++ { + for col := 0; col < cols; col++ { + ch, comb, style := placeholderCell(id, row, col) + screen.SetContent(x+col, y+row, ch, comb, style) + } + } + return true +} + // build renders the poster into cols×rows cells. func (c *coverView) build(cols, rows int) []styledCell { st := c.style @@ -358,6 +418,7 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) { return } c.waiting[ref.key] = []func(*image.RGBA, error){done} + maxHeight := a.coverMaxHeight() go func() { c.slots <- struct{}{} @@ -367,7 +428,7 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) { var img *image.RGBA data, err := ref.fetch(ctx) if err == nil { - img, err = decodeCover(data) + img, err = decodeCover(data, maxHeight) } a.post(func() { waiters := c.waiting[ref.key] @@ -377,6 +438,9 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) { } else { if len(c.imgs) >= maxCoverCache { c.imgs = map[string]*image.RGBA{} // simple bound; posters are cheap to fetch again + if a.gfx != nil { + a.gfx.deleteAll() // the terminal's copies belong to the dropped images + } } c.imgs[ref.key] = img } diff --git a/internal/ui/cover_test.go b/internal/ui/cover_test.go index e7acda7..8f69202 100644 --- a/internal/ui/cover_test.go +++ b/internal/ui/cover_test.go @@ -50,7 +50,7 @@ func TestDecodeCover(t *testing.T) { if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil { t.Fatal(err) } - img, err := decodeCover(buf.Bytes()) + img, err := decodeCover(buf.Bytes(), storedCoverHeight) if err != nil { t.Fatal(err) } @@ -62,17 +62,17 @@ func TestDecodeCover(t *testing.T) { if err := png.Encode(&buf, stripes(20, 30)); err != nil { t.Fatal(err) } - if img, err := decodeCover(buf.Bytes()); err != nil || img.Bounds().Dy() != 30 { + if img, err := decodeCover(buf.Bytes(), storedCoverHeight); err != nil || img.Bounds().Dy() != 30 { t.Errorf("small png kept as is: %v %v", img, err) } - if _, err := decodeCover([]byte("not an image")); err == nil { + if _, err := decodeCover([]byte("not an image"), storedCoverHeight); err == nil { t.Error("garbage must be rejected") } // A header that promises a huge image is refused before decoding it. var huge bytes.Buffer huge.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x27\x10\x00\x00\x27\x10\x08\x02\x00\x00\x00")) - if _, err := decodeCover(huge.Bytes()); err == nil { + if _, err := decodeCover(huge.Bytes(), storedCoverHeight); err == nil { t.Error("oversized image must be rejected") } } @@ -110,11 +110,11 @@ func TestQuadCell(t *testing.T) { func TestCoverCells(t *testing.T) { // A 2:3 poster in a roomy box is limited by the height: 10 rows are 20 // width units tall, so 13 columns. - if c, r := coverCells(40, 60, 30, 10); c != 13 || r != 10 { + if c, r := coverCells(40, 60, 30, 10, 2); c != 13 || r != 10 { t.Errorf("tall limit: %dx%d", c, r) } // In a narrow box the width limits it. - if c, r := coverCells(40, 60, 10, 20); c != 10 || r != 7 { + if c, r := coverCells(40, 60, 10, 20, 2); c != 10 || r != 7 { t.Errorf("narrow limit: %dx%d", c, r) } } @@ -127,7 +127,7 @@ func TestCoverViewDraws(t *testing.T) { defer screen.Fini() screen.SetSize(30, 10) - c := newCoverView(quadrantStyle) + c := newCoverView(quadrantStyle, nil, nil) c.setImage(stripes(40, 60)) // red above blue c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows diff --git a/internal/ui/detail.go b/internal/ui/detail.go index c2fa201..5bcf414 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -43,7 +43,7 @@ func newDetailPane(app *App) *detailPane { Flex: tview.NewFlex(), app: app, text: tview.NewTextView().SetDynamicColors(true).SetWrap(false), - cover: newCoverView(app.coverStyle), + cover: newCoverView(app.coverStyle, app.gfx, func() float64 { return app.cellAspect }), } d.AddItem(d.text, 0, 1, false) d.SetBorder(true).SetTitle(" Details ") @@ -85,7 +85,7 @@ func (d *detailPane) Draw(screen tcell.Screen) { if d.hasCover { aw, ah := d.cover.aspect() // At most 40% of the width, and the text keeps some rows below. - d.coverW, d.coverH = coverCells(aw, ah, w*2/5, h-minTextRows) + d.coverW, d.coverH = coverCells(aw, ah, w*2/5, h-minTextRows, d.cover.aspectOfCells()) if d.coverH < minCoverRows || d.coverW < minCoverCols { d.coverW, d.coverH = 0, 0 } diff --git a/internal/ui/kitty.go b/internal/ui/kitty.go new file mode 100644 index 0000000..2f4e7f2 --- /dev/null +++ b/internal/ui/kitty.go @@ -0,0 +1,144 @@ +package ui + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/png" + "strings" + + "github.com/gdamore/tcell/v2" +) + +// The Kitty graphics protocol with Unicode placeholders, as supported by +// kitty and Ghostty: the poster is uploaded to the terminal once, and then +// shown by writing special placeholder characters into ordinary text cells. +// Because the picture is made of text cells it behaves like text: popups and +// dialogs drawn over it cover it, it vanishes when the view changes, and the +// normal screen diffing keeps it in place. The poster is shown at the +// terminal's full resolution. + +// placeholderRune marks a cell that shows a piece of an image. It is followed +// by combining characters that give the row and column in the image; the +// image id is the cell's foreground color. +const placeholderRune = '\U0010EEEE' + +const kittyChunk = 4096 // bytes of base64 per escape sequence, the protocol maximum + +// kittyGraphics uploads images to the terminal and keeps track of them. +type kittyGraphics struct { + write func([]byte) error // writes to the terminal, in step with the screen + nextID uint32 + ids map[*image.RGBA]uint32 + placed map[uint32][2]int // columns and rows each image is currently placed with +} + +func newKittyGraphics(write func([]byte) error) *kittyGraphics { + return &kittyGraphics{write: write, nextID: 1, ids: map[*image.RGBA]uint32{}, placed: map[uint32][2]int{}} +} + +// kittyAPC builds one graphics command: ESC _ G ; ESC \. +func kittyAPC(keys string, payload []byte) []byte { + var b bytes.Buffer + b.WriteString("\x1b_G") + b.WriteString(keys) + if len(payload) > 0 { + b.WriteByte(';') + b.Write(payload) + } + b.WriteString("\x1b\\") + return b.Bytes() +} + +// imageID returns the terminal's id for the image, uploading it on first use. +func (k *kittyGraphics) imageID(img *image.RGBA) (uint32, error) { + if id, ok := k.ids[img]; ok { + return id, nil + } + var data bytes.Buffer + enc := png.Encoder{CompressionLevel: png.BestSpeed} + if err := enc.Encode(&data, img); err != nil { + return 0, err + } + encoded := base64.StdEncoding.EncodeToString(data.Bytes()) + id := k.nextID + for first, rest := true, encoded; len(rest) > 0; first = false { + n := min(len(rest), kittyChunk) + more := 0 + if n < len(rest) { + more = 1 + } + keys := fmt.Sprintf("m=%d", more) + if first { + // Transmit only (a=t), PNG data (f=100) directly in the command (t=d), + // no replies (q=2): a reply would arrive as keyboard input. + keys = fmt.Sprintf("a=t,f=100,t=d,i=%d,q=2,m=%d", id, more) + } + if err := k.write(kittyAPC(keys, []byte(rest[:n]))); err != nil { + return 0, err + } + rest = rest[n:] + } + k.nextID++ + k.ids[img] = id + return id, nil +} + +// place makes the image cover cols×rows cells for the placeholders. A new size +// replaces the previous placement. +func (k *kittyGraphics) place(id uint32, cols, rows int) error { + if p, ok := k.placed[id]; ok { + if p == [2]int{cols, rows} { + return nil + } + // Remove the old placement (the uploaded data stays) before making a new one. + if err := k.write(kittyAPC(fmt.Sprintf("a=d,d=i,i=%d,q=2", id), nil)); err != nil { + return err + } + } + // U=1: a virtual placement, shown wherever placeholder cells refer to it. + if err := k.write(kittyAPC(fmt.Sprintf("a=p,U=1,i=%d,c=%d,r=%d,q=2", id, cols, rows), nil)); err != nil { + return err + } + k.placed[id] = [2]int{cols, rows} + return nil +} + +// deleteAll removes every image uploaded so far from the terminal. +func (k *kittyGraphics) deleteAll() { + for _, id := range k.ids { + _ = k.write(kittyAPC(fmt.Sprintf("a=d,d=I,i=%d,q=2", id), nil)) + } + k.ids = map[*image.RGBA]uint32{} + k.placed = map[uint32][2]int{} +} + +// placeholderCell returns what to draw in the cell at (row, col) of the image +// with the given id: the placeholder character, its combining characters and +// the style that carries the id. +func placeholderCell(id uint32, row, col int) (main rune, comb []rune, style tcell.Style) { + comb = []rune{kittyDiacritics[row], kittyDiacritics[col]} + fg := tcell.NewRGBColor(int32(id>>16&0xFF), int32(id>>8&0xFF), int32(id&0xFF)) + return placeholderRune, comb, tcell.StyleDefault.Foreground(fg) +} + +// maxPlaceholders is how many rows and columns can be numbered. +const maxPlaceholders = len(kittyDiacritics) + +// kittyTerminal reports whether the environment looks like a terminal that +// supports Kitty graphics with Unicode placeholders: kitty and Ghostty, not +// inside tmux or screen (which swallow the graphics commands). +func kittyTerminal(env func(string) string) bool { + term := strings.ToLower(env("TERM")) + if env("TMUX") != "" || env("STY") != "" || strings.HasPrefix(term, "screen") || strings.HasPrefix(term, "tmux") { + return false + } + switch { + case strings.Contains(term, "kitty"), strings.Contains(term, "ghostty"): + return true + case strings.EqualFold(env("TERM_PROGRAM"), "ghostty"), env("GHOSTTY_RESOURCES_DIR") != "", env("KITTY_WINDOW_ID") != "": + return true + } + return false +} diff --git a/internal/ui/kitty_diacritics.go b/internal/ui/kitty_diacritics.go new file mode 100644 index 0000000..a704a9a --- /dev/null +++ b/internal/ui/kitty_diacritics.go @@ -0,0 +1,46 @@ +package ui + +// kittyDiacritics are the 297 combining characters the Kitty graphics +// protocol uses to number the rows and columns of an image drawn with Unicode +// placeholders (rowcolumn-diacritics.txt in the kitty repository, fixed to +// Unicode 6.0). Entry i stands for the number i. +var kittyDiacritics = [...]rune{ + 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, + 0x0346, 0x034A, 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, + 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, + 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484, + 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597, + 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, + 0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, + 0x0612, 0x0613, 0x0614, 0x0615, 0x0616, 0x0617, 0x0657, 0x0658, + 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8, + 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, + 0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, + 0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, + 0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE, + 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817, 0x0818, 0x0819, + 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822, + 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, + 0x082D, 0x0951, 0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, + 0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76, + 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D, + 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1, + 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, + 0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, + 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, + 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1, + 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, + 0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, + 0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, + 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, + 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2, + 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA, + 0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, + 0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, + 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, + 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7, + 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23, + 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, + 0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, + 0x1D244, +} diff --git a/internal/ui/kitty_test.go b/internal/ui/kitty_test.go new file mode 100644 index 0000000..be5e897 --- /dev/null +++ b/internal/ui/kitty_test.go @@ -0,0 +1,241 @@ +package ui + +import ( + "bytes" + "encoding/base64" + "image" + "image/png" + "math/rand" + "regexp" + "strings" + "testing" + + "github.com/gdamore/tcell/v2" +) + +// recorder collects what is written to the terminal. +type recorder struct{ cmds []string } + +func (r *recorder) write(b []byte) error { r.cmds = append(r.cmds, string(b)); return nil } + +func noisy(w, h int) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + rng := rand.New(rand.NewSource(1)) + for i := range img.Pix { + img.Pix[i] = uint8(rng.Intn(256)) + } + for i := 3; i < len(img.Pix); i += 4 { + img.Pix[i] = 255 + } + return img +} + +func TestKittyDiacriticsTable(t *testing.T) { + if len(kittyDiacritics) != 297 { + t.Fatalf("%d diacritics, the protocol defines 297", len(kittyDiacritics)) + } + if kittyDiacritics[0] != 0x0305 || kittyDiacritics[296] != 0x1D244 { + t.Errorf("table ends are %U and %U", kittyDiacritics[0], kittyDiacritics[296]) + } + for i := 1; i < len(kittyDiacritics); i++ { + if kittyDiacritics[i] <= kittyDiacritics[i-1] { + t.Fatalf("table not strictly ascending at %d", i) + } + } +} + +func TestKittyUploadChunksAndEncodes(t *testing.T) { + rec := &recorder{} + k := newKittyGraphics(rec.write) + img := noisy(200, 300) // incompressible: far more than one 4096-byte chunk of base64 + + id, err := k.imageID(img) + if err != nil || id != 1 { + t.Fatalf("id=%d err=%v", id, err) + } + if len(rec.cmds) < 3 { + t.Fatalf("only %d commands for a large image", len(rec.cmds)) + } + re := regexp.MustCompile(`^\x1b_G([^;]*);(.*)\x1b\\$`) + var payload strings.Builder + for i, c := range rec.cmds { + m := re.FindStringSubmatch(c) + if m == nil { + t.Fatalf("command %d is not a graphics command: %q", i, c[:min(40, len(c))]) + } + keys, data := m[1], m[2] + if len(data) > kittyChunk { + t.Errorf("chunk %d has %d bytes", i, len(data)) + } + last := i == len(rec.cmds)-1 + switch { + case i == 0 && !strings.HasPrefix(keys, "a=t,f=100,t=d,i=1,q=2,m=1"): + t.Errorf("first command keys = %q", keys) + case i > 0 && !last && keys != "m=1": + t.Errorf("middle command keys = %q", keys) + case last && !strings.HasSuffix(keys, "m=0"): + t.Errorf("last command keys = %q", keys) + } + payload.WriteString(data) + } + raw, err := base64.StdEncoding.DecodeString(payload.String()) + if err != nil { + t.Fatal(err) + } + decoded, err := png.Decode(bytes.NewReader(raw)) + if err != nil || decoded.Bounds() != img.Bounds() { + t.Fatalf("payload is not the image as PNG: %v %v", decoded, err) + } + r, g, b, _ := decoded.At(17, 23).RGBA() + want := img.RGBAAt(17, 23) + if uint8(r>>8) != want.R || uint8(g>>8) != want.G || uint8(b>>8) != want.B { + t.Error("pixel data changed") + } + + // The same image is not uploaded twice; another one gets the next id. + n := len(rec.cmds) + if again, _ := k.imageID(img); again != id || len(rec.cmds) != n { + t.Error("an uploaded image must be reused") + } + if other, _ := k.imageID(noisy(4, 4)); other != 2 { + t.Errorf("second image id = %d", other) + } +} + +func TestKittyPlacement(t *testing.T) { + rec := &recorder{} + k := newKittyGraphics(rec.write) + if err := k.place(7, 20, 15); err != nil { + t.Fatal(err) + } + if len(rec.cmds) != 1 || rec.cmds[0] != "\x1b_Ga=p,U=1,i=7,c=20,r=15,q=2\x1b\\" { + t.Fatalf("first placement: %q", rec.cmds) + } + k.place(7, 20, 15) // unchanged: nothing to send + if len(rec.cmds) != 1 { + t.Errorf("unchanged placement sent %d commands", len(rec.cmds)) + } + k.place(7, 10, 8) // a new size replaces the placement + if len(rec.cmds) != 3 || rec.cmds[1] != "\x1b_Ga=d,d=i,i=7,q=2\x1b\\" || rec.cmds[2] != "\x1b_Ga=p,U=1,i=7,c=10,r=8,q=2\x1b\\" { + t.Errorf("resize: %q", rec.cmds[1:]) + } + + rec.cmds = nil + id, _ := k.imageID(noisy(2, 2)) + k.deleteAll() + if last := rec.cmds[len(rec.cmds)-1]; last != "\x1b_Ga=d,d=I,i="+itoa(int(id))+",q=2\x1b\\" { + t.Errorf("deleteAll sent %q", last) + } + if len(k.ids) != 0 || len(k.placed) != 0 { + t.Error("deleteAll must forget everything") + } +} + +func TestPlaceholderCell(t *testing.T) { + ch, comb, style := placeholderCell(0x010203, 2, 5) + if ch != '\U0010EEEE' || len(comb) != 2 || comb[0] != kittyDiacritics[2] || comb[1] != kittyDiacritics[5] { + t.Errorf("cell = %U %U", ch, comb) + } + fg, _, _ := style.Decompose() + if r, g, b := fg.RGB(); r != 1 || g != 2 || b != 3 { + t.Errorf("the id must be in the foreground color, got %d,%d,%d", r, g, b) + } +} + +func TestKittyTerminalDetection(t *testing.T) { + env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } + for _, tc := range []struct { + name string + env map[string]string + want bool + }{ + {"ghostty term", map[string]string{"TERM": "xterm-ghostty"}, true}, + {"ghostty program", map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "ghostty"}, true}, + {"ghostty resources", map[string]string{"GHOSTTY_RESOURCES_DIR": "/x"}, true}, + {"kitty", map[string]string{"TERM": "xterm-kitty"}, true}, + {"kitty window", map[string]string{"KITTY_WINDOW_ID": "1"}, true}, + {"plain xterm", map[string]string{"TERM": "xterm-256color"}, false}, + {"wezterm has no placeholders", map[string]string{"TERM_PROGRAM": "WezTerm"}, false}, + {"tmux swallows graphics", map[string]string{"TERM": "xterm-ghostty", "TMUX": "/tmp/x"}, false}, + {"screen swallows graphics", map[string]string{"TERM": "screen-256color", "TERM_PROGRAM": "ghostty"}, false}, + {"nothing", nil, false}, + } { + if got := kittyTerminal(env(tc.env)); got != tc.want { + t.Errorf("%s: %v, want %v", tc.name, got, tc.want) + } + } + for _, tc := range []struct { + setting string + env map[string]string + wantGfx bool + wantName string + }{ + {"auto", map[string]string{"TERM": "xterm-ghostty"}, true, "sextant"}, + {"", map[string]string{"TERM": "xterm-256color"}, false, "quadrant"}, + {"kitty", map[string]string{"TERM": "xterm-256color"}, true, "quadrant"}, + {"sextant", map[string]string{"TERM": "xterm-ghostty"}, false, "sextant"}, + {"quadrant", map[string]string{"TERM": "xterm-ghostty"}, false, "quadrant"}, + } { + blocks, gfx := resolveCovers(tc.setting, env(tc.env)) + if gfx != tc.wantGfx || blocks.name != tc.wantName { + t.Errorf("%q %v = %s/%v, want %s/%v", tc.setting, tc.env, blocks.name, gfx, tc.wantName, tc.wantGfx) + } + } +} + +// trueColor makes a simulation screen report 24-bit color. +type trueColor struct{ tcell.SimulationScreen } + +func (trueColor) Colors() int { return 1 << 24 } + +func TestCoverViewGraphicsDrawsPlaceholders(t *testing.T) { + sim := tcell.NewSimulationScreen("") + if err := sim.Init(); err != nil { + t.Fatal(err) + } + defer sim.Fini() + sim.SetSize(40, 20) + + rec := &recorder{} + c := newCoverView(quadrantStyle, newKittyGraphics(rec.write), nil) + c.setImage(stripes(40, 60)) // 2:3, cells twice as tall as wide: 13 columns × 10 rows in a 30×10 box + c.SetRect(0, 0, 30, 10) + + // Without true color the protocol is not used and blocks are drawn. + c.Draw(sim) + if len(rec.cmds) != 0 { + t.Fatalf("graphics commands sent without true color: %q", rec.cmds) + } + if ch, _, _, _ := sim.GetContent(29, 0); ch == placeholderRune { + t.Error("placeholders drawn without true color") + } + + screen := trueColor{sim} + c.Draw(screen) + if len(rec.cmds) != 2 { // upload (one chunk) + placement + t.Fatalf("%d commands: %q", len(rec.cmds), rec.cmds) + } + if !strings.Contains(rec.cmds[1], "c=13,r=10") { + t.Errorf("placement = %q", rec.cmds[1]) + } + // Right-aligned: columns 17..29, rows 0..9. + ch, comb, _, _ := sim.GetContent(17, 0) + if ch != placeholderRune || len(comb) < 2 || comb[0] != kittyDiacritics[0] || comb[1] != kittyDiacritics[0] { + t.Errorf("top left cell = %U %U", ch, comb) + } + ch, comb, _, _ = sim.GetContent(29, 9) + if ch != placeholderRune || comb[0] != kittyDiacritics[9] || comb[1] != kittyDiacritics[12] { + t.Errorf("bottom right cell = %U %U", ch, comb) + } + c.Draw(screen) // a second frame uploads nothing again + if len(rec.cmds) != 2 { + t.Errorf("redraw sent %d more commands", len(rec.cmds)-2) + } + + // A cell aspect of 2.5 (taller cells) changes the footprint of the same box. + c.cellAspect = func() float64 { return 2.5 } + c.Draw(screen) + if len(rec.cmds) != 4 || !strings.Contains(rec.cmds[3], "c=16,r=10") { + t.Errorf("after the cell aspect changed: %q", rec.cmds[2:]) + } +}