Ask the terminal before using Kitty graphics; no fontconfig-based sextants on macOS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
This commit is contained in:
2026-10-21 11:08:36 +02:00
co-authored by Claude Opus 5.5
parent 705d795e6d
commit 2aa28c7e18
9 changed files with 140 additions and 6 deletions
+9
View File
@@ -117,6 +117,12 @@ func New(cfg *config.Config) *App {
}
blocks, graphics := resolveCovers(cfg.UI.CoverStyle, os.Getenv)
coverNote := "Posters: " + blocks.name + " block characters"
if graphics {
coverNote = "Posters: real images (the terminal confirmed the Kitty graphics protocol)"
} else if cfg.UI.CoverStyle == "kitty" || kittyTerminal(os.Getenv) {
coverNote += " (the terminal did not confirm the Kitty graphics protocol)"
}
a.coverStyle = blocks
if graphics {
a.gfx = a.newGraphics()
@@ -127,6 +133,9 @@ func New(cfg *config.Config) *App {
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
a.radarr, a.sonarr = radarrClient, sonarrClient
a.logs = newLogView(a)
if a.covers {
a.logs.add(levelInfo, coverNote)
}
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
a.sab = sabClient
a.probes = []probe{
+21 -3
View File
@@ -13,7 +13,9 @@ import (
_ "golang.org/x/image/webp"
"net/url"
"os/exec"
"runtime"
"strings"
"sync"
"time"
"github.com/gdamore/tcell/v2"
@@ -147,7 +149,9 @@ func coverStyleFor(setting string, env func(string) string) coverStyle {
// font lacks from a fallback font, so sextants work if some installed font
// has them. Over SSH the installed fonts are the remote machine's, which
// says nothing about the terminal, so keep the safe style there.
if env("SSH_CONNECTION") == "" && env("SSH_TTY") == "" && sextantFontAvailable() {
// macOS terminals do not draw missing glyphs from fontconfig's fonts, so a
// font found there says nothing about them.
if runtime.GOOS != "darwin" && env("SSH_CONNECTION") == "" && env("SSH_TTY") == "" && sextantFontAvailable() {
return sextantStyle
}
return quadrantStyle
@@ -174,13 +178,27 @@ var sextantFontAvailable = func() bool {
func resolveCovers(setting string, env func(string) string) (blocks coverStyle, graphics bool) {
switch setting {
case "kitty":
return coverStyleFor("auto", env), true
// Asked for explicitly, but a terminal without the protocol prints the
// commands as text, so it is still confirmed.
return coverStyleFor("auto", env), kittyGraphicsSupported()
case "auto", "":
return coverStyleFor("auto", env), kittyTerminal(env)
return coverStyleFor("auto", env), kittyTerminal(env) && kittyGraphicsSupported()
}
return coverStyleFor(setting, env), false
}
var (
kittyProbeOnce sync.Once
kittyProbeResult bool
)
// kittyGraphicsSupported asks the terminal once (see probeKittyGraphics) and
// remembers the answer. It is a variable so tests can replace it.
var kittyGraphicsSupported = func() bool {
kittyProbeOnce.Do(func() { kittyProbeResult = probeKittyGraphics(300 * time.Millisecond) })
return kittyProbeResult
}
// 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
+25
View File
@@ -165,6 +165,8 @@ func TestPlaceholderCell(t *testing.T) {
func TestKittyTerminalDetection(t *testing.T) {
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
sextantFontAvailable = func() bool { return false } // not what these cases are about
defer func(old func() bool) { kittyGraphicsSupported = old }(kittyGraphicsSupported)
kittyGraphicsSupported = func() bool { return true } // the terminal confirms
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
for _, tc := range []struct {
name string
@@ -368,3 +370,26 @@ func TestGraphicsDebugReportsUploads(t *testing.T) {
t.Errorf("debug lines = %q", lines)
}
}
func TestGraphicsNeedTheTerminalsConfirmation(t *testing.T) {
defer func(old func() bool) { kittyGraphicsSupported = old }(kittyGraphicsSupported)
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
ghostty := env(map[string]string{"TERM": "xterm-ghostty"})
kittyGraphicsSupported = func() bool { return false } // e.g. a terminal that claims kitty but prints APC as text
if _, gfx := resolveCovers("auto", ghostty); gfx {
t.Error("auto must not use real images when the terminal does not answer the query")
}
if _, gfx := resolveCovers("kitty", ghostty); gfx {
t.Error("even an explicit kitty setting must not send graphics a terminal does not understand")
}
asked := false
kittyGraphicsSupported = func() bool { asked = true; return true }
if _, gfx := resolveCovers("auto", env(map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "Apple_Terminal"})); gfx || asked {
t.Error("a terminal not known for the protocol is not even asked (it might print the query)")
}
if _, gfx := resolveCovers("auto", ghostty); !gfx || !asked {
t.Error("a known terminal that answers gets real images")
}
}
+13
View File
@@ -0,0 +1,13 @@
package ui
import (
"os"
"testing"
)
// TestMain keeps the tests from asking the real terminal about graphics
// support (the probe writes to and reads from /dev/tty).
func TestMain(m *testing.M) {
kittyGraphicsSupported = func() bool { return false }
os.Exit(m.Run())
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !unix
package ui
import "time"
// probeKittyGraphics is not implemented here; the block characters are used.
func probeKittyGraphics(time.Duration) bool { return false }
+57
View File
@@ -0,0 +1,57 @@
//go:build unix
package ui
import (
"bytes"
"os"
"time"
"golang.org/x/term"
)
// probeKittyGraphics asks the terminal whether it understands the Kitty
// graphics protocol: a tiny query image (which a capable terminal answers with
// "OK" and does not show) followed by a Primary Device Attributes request,
// which every terminal answers. If the DA answer arrives without a graphics
// answer before it, the protocol is not supported. Runs before the screen is
// set up, on the controlling terminal.
func probeKittyGraphics(timeout time.Duration) bool {
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
return false
}
defer tty.Close()
fd := int(tty.Fd())
state, err := term.MakeRaw(fd)
if err != nil {
return false
}
defer term.Restore(fd, state)
const query = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b[c"
if _, err := tty.WriteString(query); err != nil {
return false
}
// A terminal that does not know APC sequences may print the query; erase it.
defer tty.WriteString("\r\x1b[2K")
if err := tty.SetReadDeadline(time.Now().Add(timeout)); err != nil {
return false
}
var got []byte
buf := make([]byte, 256)
for {
n, err := tty.Read(buf)
got = append(got, buf[:n]...)
if bytes.Contains(got, []byte("_Gi=31;OK")) {
return true
}
if da := bytes.Index(got, []byte("\x1b[?")); da >= 0 && bytes.IndexByte(got[da:], 'c') >= 0 {
return false // device attributes answered, graphics query not
}
if err != nil {
return false // timeout or error: assume no support
}
}
}