auto cover style: use sextants when fontconfig has a font with the glyphs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
- **Posters as real images** in kitty and Ghostty (Kitty graphics protocol with Unicode
|
- **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
|
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.
|
keep the block-character posters, using sextants when fontconfig finds a font with the glyphs
|
||||||
|
(e.g. GNOME Terminal with Adwaita Mono) and quadrants otherwise. `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
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
|
|||||||
Radarr/Sonarr; search results are downloaded from the metadata provider's image host (TMDb,
|
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
|
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
|
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),
|
sextants on terminals known to have the glyphs or when fontconfig finds an installed font with
|
||||||
|
them (not over SSH), else quadrants), `kitty` (force real images),
|
||||||
`sextant` (2x3 pixels per cell, needs a font with the Symbols for Legacy Computing block) or
|
`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
|
`quadrant` (2x2, every font has them). Inside tmux or screen the block styles are used. If a
|
||||||
poster looks wrong, set `quadrant`.
|
poster looks wrong, set `quadrant`.
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ Notes:
|
|||||||
- **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. Two ways to draw the poster, chosen by `ui.cover_style`:
|
- **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.
|
- **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.
|
- **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 and Ghostty, and on other terminals when fontconfig lists a font with the sextant characters (`fc-list :charset=1fb00`, about 20 ms at startup; terminals draw glyphs their font lacks from fallback fonts; skipped over SSH because the installed fonts are the remote machine's), and quadrants otherwise.
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ type UI struct {
|
|||||||
// block, e.g. kitty, WezTerm, foot, Ghostty), "kitty" (real images through
|
// block, e.g. kitty, WezTerm, foot, Ghostty), "kitty" (real images through
|
||||||
// the Kitty graphics protocol with Unicode placeholders: kitty and
|
// the Kitty graphics protocol with Unicode placeholders: kitty and
|
||||||
// Ghostty) or "auto" (default: the Kitty protocol where the terminal is
|
// Ghostty) or "auto" (default: the Kitty protocol where the terminal is
|
||||||
// known to support it, else sextant on terminals known to draw them, else
|
// known to support it, else sextant on terminals known to draw them or when
|
||||||
// quadrant). The block styles are also the fallback for "kitty".
|
// fontconfig finds a font with the glyphs, else quadrant). The block styles are also the fallback for "kitty".
|
||||||
CoverStyle string `json:"cover_style,omitempty"`
|
CoverStyle string `json:"cover_style,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"image/color"
|
"image/color"
|
||||||
_ "image/jpeg" // poster formats
|
_ "image/jpeg" // poster formats
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -138,9 +139,29 @@ func coverStyleFor(setting string, env func(string) string) coverStyle {
|
|||||||
if env("KITTY_WINDOW_ID") != "" || env("WEZTERM_PANE") != "" {
|
if env("KITTY_WINDOW_ID") != "" || env("WEZTERM_PANE") != "" {
|
||||||
return sextantStyle
|
return sextantStyle
|
||||||
}
|
}
|
||||||
|
// Other terminals (GNOME Terminal, Konsole, Alacritty...) draw glyphs their
|
||||||
|
// 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() {
|
||||||
|
return sextantStyle
|
||||||
|
}
|
||||||
return quadrantStyle
|
return quadrantStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sextantFontAvailable reports whether fontconfig knows a font with the
|
||||||
|
// sextant characters. It is a variable so tests can replace it.
|
||||||
|
var sextantFontAvailable = func() bool {
|
||||||
|
path, err := exec.LookPath("fc-list")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, path, ":charset=1fb00", "family").Output()
|
||||||
|
return err == nil && len(bytes.TrimSpace(out)) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// resolveCovers decides how posters are drawn: as real images through the
|
// resolveCovers decides how posters are drawn: as real images through the
|
||||||
// Kitty graphics protocol (graphics) or with block characters in the given
|
// Kitty graphics protocol (graphics) or with block characters in the given
|
||||||
// style. "kitty" asks for the protocol explicitly; "auto" uses it when the
|
// style. "kitty" asks for the protocol explicitly; "auto" uses it when the
|
||||||
|
|||||||
@@ -223,7 +223,39 @@ func TestSextantCell(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoverStyleForFontCheck(t *testing.T) {
|
||||||
|
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
|
||||||
|
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||||
|
|
||||||
|
sextantFontAvailable = func() bool { return true }
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"unknown terminal with a suitable font", map[string]string{"TERM": "xterm-256color"}, "sextant"},
|
||||||
|
{"over ssh the local fonts are unknown", map[string]string{"TERM": "xterm-256color", "SSH_CONNECTION": "1 2 3 4"}, "quadrant"},
|
||||||
|
{"over ssh with a tty", map[string]string{"SSH_TTY": "/dev/pts/1"}, "quadrant"},
|
||||||
|
} {
|
||||||
|
if got := coverStyleFor("auto", env(tc.env)).name; got != tc.want {
|
||||||
|
t.Errorf("%s: %s, want %s", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An explicit setting never asks fontconfig.
|
||||||
|
sextantFontAvailable = func() bool { t.Error("fontconfig consulted for an explicit style"); return true }
|
||||||
|
if coverStyleFor("quadrant", env(nil)).name != "quadrant" || coverStyleFor("sextant", env(nil)).name != "sextant" {
|
||||||
|
t.Error("explicit styles must win")
|
||||||
|
}
|
||||||
|
// No suitable font: quadrants.
|
||||||
|
sextantFontAvailable = func() bool { return false }
|
||||||
|
if got := coverStyleFor("auto", env(map[string]string{"TERM": "xterm-256color"})).name; got != "quadrant" {
|
||||||
|
t.Errorf("no font: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoverStyleFor(t *testing.T) {
|
func TestCoverStyleFor(t *testing.T) {
|
||||||
|
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
|
||||||
|
sextantFontAvailable = func() bool { return false } // the table below is about the terminal checks
|
||||||
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string
|
setting string
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ func TestPlaceholderCell(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestKittyTerminalDetection(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
|
||||||
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Reference in New Issue
Block a user