Show posters as real images in kitty and Ghostty (Kitty graphics with Unicode placeholders)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 23:03:29 +02:00
co-authored by Claude Sonnet 5.5
parent dc82b0b95d
commit 108541af91
12 changed files with 601 additions and 49 deletions
+3
View File
@@ -2,6 +2,9 @@
## Unreleased ## 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, - **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. 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 - **Download notifications:** popup and log entry when a SABnzbd download finishes or fails, in the
+11 -11
View File
@@ -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), - **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. 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 - **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 flow along its left side and continue at full width below it. In kitty and Ghostty it is a real
with colors). Library items use the copy stored in Radarr/Sonarr; search results are downloaded image at the terminal's full resolution (Kitty graphics protocol with Unicode placeholders, so
from the metadata provider's image host (TMDb, TheTVDB). Turn it off with popups and dialogs cover it like text). Everywhere else it is drawn with colored block characters,
`"ui": {"covers": false}`. `"ui": {"cover_style": ...}` picks the characters: `quadrant` (2x2 which works over SSH and in any terminal with colors. Library items use the copy stored in
pixels per cell, every font has them), `sextant` (2x3, sharper, needs a font with the Symbols for Radarr/Sonarr; search results are downloaded from the metadata provider's image host (TMDb,
Legacy Computing block, e.g. kitty, WezTerm, foot, Ghostty) or `auto` (default: sextant on TheTVDB). Turn it off with `"ui": {"covers": false}`. `"ui": {"cover_style": ...}` picks the
terminals known to support it, quadrant elsewhere). If you see boxes or question marks in the drawing: `auto` (default: real images where the terminal is known to support them, otherwise
poster, set `quadrant`. sextants on terminals known to have the glyphs, else quadrants), `kitty` (force real images),
- **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 `sextant` (2x3 pixels per cell, needs a font with the Symbols for Legacy Computing block) or
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 `quadrant` (2x2, every font has them). Inside tmux or screen the block styles are used. If a
per-episode monitoring, automatic search and release search. poster looks wrong, set `quadrant`.
- **Calendar**: the next two weeks of movie releases (cinemas, digital, physical) and airing episodes - **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 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. for releases or start an automatic search from there.
+4 -1
View File
@@ -63,7 +63,10 @@ Notes:
- Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible. - 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. - 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. - **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/<id>/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/<id>/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. - **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. - **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. - **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.
+7 -4
View File
@@ -40,8 +40,11 @@ type UI struct {
// CoverStyle selects the block characters posters are drawn with: // CoverStyle selects the block characters posters are drawn with:
// "quadrant" (2x2 pixels per cell, every font has the glyphs), "sextant" // "quadrant" (2x2 pixels per cell, every font has the glyphs), "sextant"
// (2x3, sharper, needs a font with the Symbols for Legacy Computing // (2x3, sharper, needs a font with the Symbols for Legacy Computing
// block, e.g. kitty, WezTerm, foot, Ghostty) or "auto" (default: sextant // block, e.g. kitty, WezTerm, foot, Ghostty), "kitty" (real images through
// on terminals known to draw them, quadrant elsewhere). // 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"` CoverStyle string `json:"cover_style,omitempty"`
} }
@@ -161,9 +164,9 @@ func (c *Config) Validate() error {
} }
} }
switch c.UI.CoverStyle { switch c.UI.CoverStyle {
case "", "auto", "quadrant", "sextant": case "", "auto", "quadrant", "sextant", "kitty":
default: 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...) return errors.Join(errs...)
} }
+2 -2
View File
@@ -100,12 +100,12 @@ func TestShowCovers(t *testing.T) {
func TestCoverStyleValidation(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":` 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 { if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
t.Errorf("%s rejected: %v", ok, err) 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") t.Error("unknown style accepted")
} }
} }
+49 -1
View File
@@ -52,6 +52,8 @@ type App struct {
bell bool bell bool
covers bool // poster thumbnails enabled covers bool // poster thumbnails enabled
coverStyle coverStyle coverStyle coverStyle
gfx *kittyGraphics // real-image posters, nil when not used
cellAspect float64 // height of a terminal cell divided by its width
coverCache *coverCache coverCache *coverCache
} }
@@ -99,10 +101,16 @@ func New(cfg *config.Config) *App {
notifyDone: cfg.UI.ShouldNotifyDownloads(), notifyDone: cfg.UI.ShouldNotifyDownloads(),
bell: cfg.UI.Bell, bell: cfg.UI.Bell,
covers: cfg.UI.ShowCovers(), covers: cfg.UI.ShowCovers(),
coverStyle: coverStyleFor(cfg.UI.CoverStyle, os.Getenv), cellAspect: 2,
coverCache: newCoverCache(), 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) radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey) sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
a.logs = newLogView(a) a.logs = newLogView(a)
@@ -149,6 +157,7 @@ func New(cfg *config.Config) *App {
a.app.SetBeforeDrawFunc(func(s tcell.Screen) bool { a.app.SetBeforeDrawFunc(func(s tcell.Screen) bool {
a.screen = s a.screen = s
a.cellAspect = cellAspectOf(s)
return false return false
}) })
a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false) a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false)
@@ -158,6 +167,40 @@ func New(cfg *config.Config) *App {
return a 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 // SetService records the outcome of a request to a service and updates the
// connection indicators. UI goroutine only. A nil err means reachable. // connection indicators. UI goroutine only. A nil err means reachable.
func (a *App) SetService(name, version string, err error) { func (a *App) SetService(name, version string, err error) {
@@ -246,6 +289,11 @@ func (a *App) post(f func()) {
func (a *App) Run() error { func (a *App) Run() error {
stop := make(chan struct{}) stop := make(chan struct{})
defer close(stop) defer close(stop)
defer func() {
if a.gfx != nil {
a.gfx.deleteAll()
}
}()
go a.watchServices(stop) go a.watchServices(stop)
if a.notifyDone { if a.notifyDone {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
+85 -21
View File
@@ -18,15 +18,17 @@ import (
) )
const ( const (
maxCoverPixels = 25_000_000 // refuse decompression bombs maxCoverPixels = 25_000_000 // refuse decompression bombs
storedCoverHeight = 160 // a small copy is plenty for a terminal thumbnail storedCoverHeight = 160 // a small copy is plenty for block characters
coverFailureTTL = 2 * time.Minute graphicsCoverHeight = 640 // posters shown as real images keep more detail
maxCoverCache = 300 coverFailureTTL = 2 * time.Minute
coverFetchLimit = 4 maxCoverCache = 120
coverFetchLimit = 4
) )
// decodeCover decodes a JPEG or PNG poster and keeps a small copy. // decodeCover decodes a JPEG or PNG poster and keeps a copy at most
func decodeCover(data []byte) (*image.RGBA, error) { // maxHeight pixels tall.
func decodeCover(data []byte, maxHeight int) (*image.RGBA, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil { if err != nil {
return nil, fmt.Errorf("not a supported image: %w", err) return nil, fmt.Errorf("not a supported image: %w", err)
@@ -39,10 +41,10 @@ func decodeCover(data []byte) (*image.RGBA, error) {
return nil, err return nil, err
} }
b := img.Bounds() b := img.Bounds()
if b.Dy() <= storedCoverHeight { if b.Dy() <= maxHeight {
return boxScale(img, b.Dx(), b.Dy()), nil 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 // 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) return rune(0x1FB00 + idx)
} }
// coverStyleFor resolves the configured style. "auto" picks sextants on // coverStyleFor resolves the block-character style for the setting. "auto"
// terminals known to have the glyphs and quadrants everywhere else. // picks sextants on terminals known to have the glyphs and quadrants everywhere
// else.
func coverStyleFor(setting string, env func(string) string) coverStyle { func coverStyleFor(setting string, env func(string) string) coverStyle {
switch setting { switch setting {
case "sextant": case "sextant":
@@ -138,6 +141,21 @@ func coverStyleFor(setting string, env func(string) string) coverStyle {
return quadrantStyle 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 // flatPixelError is the squared RGB distance per pixel below which a cell is
// drawn flat: smooth areas and noise should not turn into patterns. // drawn flat: smooth areas and noise should not turn into patterns.
const flatPixelError = 150 const flatPixelError = 150
@@ -214,9 +232,11 @@ func (st coverStyle) bestCell(p []color.RGBA) (ch rune, fg, bg color.RGBA) {
// colors, also over SSH. // colors, also over SSH.
type coverView struct { type coverView struct {
*tview.Box *tview.Box
style coverStyle style coverStyle
img *image.RGBA gfx *kittyGraphics // real images; nil for block characters only
note string // shown instead of the image: "loading…", "no cover" 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. // The cells for the last drawn size.
cols, rows int cols, rows int
@@ -228,7 +248,18 @@ type styledCell struct {
fg, bg tcell.Color 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) 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 } 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 // 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. // needs inside w×h cells. cellAspect is the height of a cell divided by its
func coverCells(imgW, imgH, w, h int) (cols, rows int) { // width, about 2 in most terminals.
func coverCells(imgW, imgH, w, h int, cellAspect float64) (cols, rows int) {
cols = w cols = w
rows = cols * imgH / imgW / 2 rows = int(float64(cols) * float64(imgH) / float64(imgW) / cellAspect)
if rows > h { if rows > h {
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) return max(cols, 0), max(rows, 0)
} }
@@ -268,10 +300,13 @@ func (c *coverView) Draw(screen tcell.Screen) {
} }
b := c.img.Bounds() 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 { if cols < 1 || rows < 1 {
return return
} }
if c.drawGraphics(screen, x+w-cols, y, cols, rows) {
return
}
if c.cells == nil || c.cols != cols || c.rows != rows { if c.cells == nil || c.cols != cols || c.rows != rows {
c.cols, c.rows, c.cells = cols, rows, c.build(cols, 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. // build renders the poster into cols×rows cells.
func (c *coverView) build(cols, rows int) []styledCell { func (c *coverView) build(cols, rows int) []styledCell {
st := c.style st := c.style
@@ -358,6 +418,7 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
return return
} }
c.waiting[ref.key] = []func(*image.RGBA, error){done} c.waiting[ref.key] = []func(*image.RGBA, error){done}
maxHeight := a.coverMaxHeight()
go func() { go func() {
c.slots <- struct{}{} c.slots <- struct{}{}
@@ -367,7 +428,7 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
var img *image.RGBA var img *image.RGBA
data, err := ref.fetch(ctx) data, err := ref.fetch(ctx)
if err == nil { if err == nil {
img, err = decodeCover(data) img, err = decodeCover(data, maxHeight)
} }
a.post(func() { a.post(func() {
waiters := c.waiting[ref.key] waiters := c.waiting[ref.key]
@@ -377,6 +438,9 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
} else { } else {
if len(c.imgs) >= maxCoverCache { if len(c.imgs) >= maxCoverCache {
c.imgs = map[string]*image.RGBA{} // simple bound; posters are cheap to fetch again 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 c.imgs[ref.key] = img
} }
+7 -7
View File
@@ -50,7 +50,7 @@ func TestDecodeCover(t *testing.T) {
if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil { if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
img, err := decodeCover(buf.Bytes()) img, err := decodeCover(buf.Bytes(), storedCoverHeight)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -62,17 +62,17 @@ func TestDecodeCover(t *testing.T) {
if err := png.Encode(&buf, stripes(20, 30)); err != nil { if err := png.Encode(&buf, stripes(20, 30)); err != nil {
t.Fatal(err) 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) t.Errorf("small png kept as is: %v %v", img, err)
} }
if _, err := decodeCover([]byte("<html>not an image</html>")); err == nil { if _, err := decodeCover([]byte("<html>not an image</html>"), storedCoverHeight); err == nil {
t.Error("garbage must be rejected") t.Error("garbage must be rejected")
} }
// A header that promises a huge image is refused before decoding it. // A header that promises a huge image is refused before decoding it.
var huge bytes.Buffer 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")) 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") t.Error("oversized image must be rejected")
} }
} }
@@ -110,11 +110,11 @@ func TestQuadCell(t *testing.T) {
func TestCoverCells(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 // A 2:3 poster in a roomy box is limited by the height: 10 rows are 20
// width units tall, so 13 columns. // 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) t.Errorf("tall limit: %dx%d", c, r)
} }
// In a narrow box the width limits it. // 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) t.Errorf("narrow limit: %dx%d", c, r)
} }
} }
@@ -127,7 +127,7 @@ func TestCoverViewDraws(t *testing.T) {
defer screen.Fini() defer screen.Fini()
screen.SetSize(30, 10) screen.SetSize(30, 10)
c := newCoverView(quadrantStyle) c := newCoverView(quadrantStyle, nil, nil)
c.setImage(stripes(40, 60)) // red above blue c.setImage(stripes(40, 60)) // red above blue
c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows
+2 -2
View File
@@ -43,7 +43,7 @@ func newDetailPane(app *App) *detailPane {
Flex: tview.NewFlex(), Flex: tview.NewFlex(),
app: app, app: app,
text: tview.NewTextView().SetDynamicColors(true).SetWrap(false), 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.AddItem(d.text, 0, 1, false)
d.SetBorder(true).SetTitle(" Details ") d.SetBorder(true).SetTitle(" Details ")
@@ -85,7 +85,7 @@ func (d *detailPane) Draw(screen tcell.Screen) {
if d.hasCover { if d.hasCover {
aw, ah := d.cover.aspect() aw, ah := d.cover.aspect()
// At most 40% of the width, and the text keeps some rows below. // 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 { if d.coverH < minCoverRows || d.coverW < minCoverCols {
d.coverW, d.coverH = 0, 0 d.coverW, d.coverH = 0, 0
} }
+144
View File
@@ -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 <keys> ; <payload> 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
}
+46
View File
@@ -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,
}
+241
View File
@@ -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:])
}
}