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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user