Real-image posters: blocks while the window is being resized; ARR_TUI_DEBUG_GFX log

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 00:35:19 +02:00
co-authored by Claude Sonnet 5.5
parent ce0d441a2e
commit 55810bb1e2
6 changed files with 90 additions and 4 deletions
+17 -1
View File
@@ -122,7 +122,7 @@ func New(cfg *config.Config) *App {
blocks, graphics := resolveCovers(cfg.UI.CoverStyle, os.Getenv)
a.coverStyle = blocks
if graphics {
a.gfx = newKittyGraphics(a.writeTerminal)
a.gfx = a.newGraphics()
}
a.cfg = cfg
@@ -192,6 +192,17 @@ func New(cfg *config.Config) *App {
return a
}
// newGraphics creates the real-image drawer. With ARR_TUI_DEBUG_GFX set it also
// writes what it does (window size, cell shape, every upload) to the Log, which
// helps to find out why a poster is drawn wrongly in some terminal.
func (a *App) newGraphics() *kittyGraphics {
k := newKittyGraphics(a.writeTerminal)
if os.Getenv("ARR_TUI_DEBUG_GFX") != "" {
k.debug = func(msg string) { a.logs.add(levelInfo, msg) }
}
return k
}
// noteWindow notices when the terminal's size or cell size changed. The
// real-image posters are then uploaded and placed again from scratch instead of
// relying on the terminal to rescale what is there.
@@ -205,6 +216,11 @@ func (a *App) noteWindow(s tcell.Screen) {
a.lastWindow = win
if !first && a.gfx != nil {
a.gfx.deleteAll()
a.gfx.hold() // blocks while the size is changing, real images when it has settled
time.AfterFunc(settleDelay+30*time.Millisecond, func() { a.post(func() {}) })
}
if a.gfx != nil && a.gfx.debug != nil {
a.gfx.debug(fmt.Sprintf("window: %d×%d cells, cell shape %.3f", w, h, a.cellAspect))
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ func (a *App) restyleCovers() {
a.coverStyle = blocks
switch {
case graphics && a.gfx == nil:
a.gfx = newKittyGraphics(a.writeTerminal)
a.gfx = a.newGraphics()
case !graphics:
a.gfx = nil
}
+1 -1
View File
@@ -398,7 +398,7 @@ func cropToBox(b image.Rectangle, cols, rows int, cellAspect float64) image.Rect
// 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 {
if c.gfx == nil || c.gfx.settling() || screen.Colors() < 1<<24 || cols > maxPlaceholders || rows > maxPlaceholders {
return false
}
id, err := c.gfx.show(c.img, cropToBox(c.img.Bounds(), cols, rows, c.aspectOfCells()), cols, rows)
+20
View File
@@ -7,6 +7,7 @@ import (
"image"
"image/png"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
@@ -39,8 +40,24 @@ type kittyGraphics struct {
nextID uint32
ids map[footprint]uint32
latest map[*image.RGBA]footprint // the footprint of each poster that is currently uploaded
holdUntil time.Time // no real images until then (the window is being resized)
debug func(string) // reports what is uploaded, if set
}
// settleDelay is how long the window size must stay the same before real
// images are shown again. While a window is dragged to another size, tens of
// sizes go by; uploading and placing images for each of them races with the
// terminal's own resizing, and the result can be a placement that does not
// match the cells (a small poster in a big area, or a corner of a big one).
const settleDelay = 300 * time.Millisecond
// hold stops real images being drawn until the window has been left alone for settleDelay.
func (k *kittyGraphics) hold() { k.holdUntil = time.Now().Add(settleDelay) }
// settling reports whether the window size changed so recently that blocks are drawn instead.
func (k *kittyGraphics) settling() bool { return time.Now().Before(k.holdUntil) }
// footprint identifies an uploaded image: a poster, the part of it that was
// sent, and the cells it covers.
type footprint struct {
@@ -112,6 +129,9 @@ func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows i
}
k.ids[key] = id
k.latest[img] = key
if k.debug != nil {
k.debug(fmt.Sprintf("graphics: id %d, poster %dx%d px cropped to %v, placed over %dx%d cells", id, img.Bounds().Dx(), img.Bounds().Dy(), rect, cols, rows))
}
return id, nil
}
+50
View File
@@ -10,6 +10,7 @@ import (
"regexp"
"strings"
"testing"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
@@ -318,3 +319,52 @@ func TestPosterSource(t *testing.T) {
}
}
}
func TestGraphicsAreHeldWhileTheWindowChanges(t *testing.T) {
sim := tcell.NewSimulationScreen("")
if err := sim.Init(); err != nil {
t.Fatal(err)
}
defer sim.Fini()
sim.SetSize(40, 20)
screen := trueColor{sim}
rec := &recorder{}
k := newKittyGraphics(rec.write)
c := newCoverView(quadrantStyle, k, nil)
c.setImage(stripes(40, 60))
c.SetRect(0, 0, 30, 10)
k.hold()
if !k.settling() {
t.Fatal("hold must start the settling period")
}
c.Draw(screen)
if len(rec.cmds) != 0 {
t.Fatalf("graphics commands while the window changes: %q", rec.cmds)
}
if ch, _, _, _ := sim.GetContent(29, 9); ch == placeholderRune {
t.Error("placeholders drawn while settling; blocks are the stand-in")
}
k.holdUntil = time.Now().Add(-time.Millisecond) // the period is over
c.Draw(screen)
if len(rec.cmds) != 2 {
t.Errorf("after settling: %d commands, want upload + placement", len(rec.cmds))
}
if ch, _, _, _ := sim.GetContent(29, 9); ch != placeholderRune {
t.Error("real image not shown after settling")
}
}
func TestGraphicsDebugReportsUploads(t *testing.T) {
rec := &recorder{}
k := newKittyGraphics(rec.write)
var lines []string
k.debug = func(s string) { lines = append(lines, s) }
img := noisy(10, 15)
k.show(img, img.Bounds(), 12, 8)
if len(lines) != 1 || !strings.Contains(lines[0], "id 1") || !strings.Contains(lines[0], "12x8 cells") || !strings.Contains(lines[0], "10x15 px") {
t.Errorf("debug lines = %q", lines)
}
}