Detail panes: path in the head, text flows around the poster

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 22:05:16 +02:00
co-authored by Claude Sonnet 5.5
parent b4fe931fa9
commit b1e368418b
6 changed files with 144 additions and 78 deletions
+1 -2
View File
@@ -16,8 +16,7 @@ First release.
- **Series drill-down:** seasons and episodes with monitoring, automatic search and release search.
- **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete,
retry, remove and blocklist.
- **Posters:** a thumbnail at the top right of the detail panes, title and facts to its left, plot
below (quadrant or, on capable terminals, sextant block rendering; `ui.cover_style`).
- **Posters:** a thumbnail at the top right of the detail panes with the text flowing around it (quadrant or, on capable terminals, sextant block rendering; `ui.cover_style`).
- **Log (F5) and popups:** feedback appears as fading popups; the log keeps the history.
- Connection indicators for all three services, context-sensitive key hints, `?` help.
- JSON config with environment overrides (`ARR_TUI_*`), `--version`, cross-platform builds
+2 -2
View File
@@ -8,8 +8,8 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
optionally start the automatic search.
- **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.
- **Posters**: the detail panes show the poster at the top right, with title and facts to its left
and the plot below. It is drawn with colored block characters (works over SSH, needs a terminal
- **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
with colors). Library items use the copy stored in 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 characters: `quadrant` (2x2
+1 -1
View File
@@ -63,7 +63,7 @@ Notes:
- 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.
- **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 title and facts to its left and the plot below both; on panes too small for that, everything is one text. 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. 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.
- **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.
### 4.2 v0.2 (done, M8)
+83 -44
View File
@@ -2,55 +2,56 @@ package ui
import (
"image"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
const (
minCoverRows = 6 // a smaller poster is not worth showing
minCoverCols = 8 //
minTextRows = 10 // the poster never takes these rows from the text
minCoverRows = 6 // a smaller poster is not worth showing
minCoverCols = 8
minTextRows = 6 // the poster never takes these rows from the text
)
// detailPane shows the details of one title: the head (title and facts) with
// the poster to its right on top, and the plot below both. On panes too small
// for the poster everything is one text.
// detailPane shows the details of one title as one flowing text with the
// poster floating at the top right: the head (title and facts) and the plot
// run along the poster on the left and continue at full width below it. On
// panes too small for the poster the text simply uses the full width.
type detailPane struct {
*tview.Flex // column: top row, plot
*tview.Flex // holds the text; the poster is drawn on top of its corner
app *App
top *tview.Flex // row: head, cover
head *tview.TextView
text *tview.TextView
cover *coverView
plot *tview.TextView
text detailText
detail detailText
want string // key of the poster that should be shown
hasCover bool // a poster (or its loading note) is part of the content
combined bool // head and plot are shown together in the plot view
// What the text was last wrapped for.
laidOut struct {
width, narrow, rows int
valid bool
}
coverW, coverH int // size of the poster on screen, 0 = hidden
}
func newDetailPane(app *App) *detailPane {
d := &detailPane{
Flex: tview.NewFlex().SetDirection(tview.FlexRow),
Flex: tview.NewFlex(),
app: app,
top: tview.NewFlex(),
head: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
text: tview.NewTextView().SetDynamicColors(true).SetWrap(false),
cover: newCoverView(app.coverStyle),
plot: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
}
d.cover.SetBorderPadding(0, 0, 1, 0)
d.top.AddItem(d.head, 0, 1, false).AddItem(d.cover, 0, 0, false)
d.AddItem(d.top, 0, 0, false).AddItem(d.plot, 0, 1, false)
d.AddItem(d.text, 0, 1, false)
d.SetBorder(true).SetTitle(" Details ")
d.combined = true
return d
}
// Show displays the text and, if there is one, the poster.
func (d *detailPane) Show(text detailText, ref coverRef) {
d.text = text
d.applyText()
d.detail = text
d.laidOut.valid = false
if !d.app.covers || ref.fetch == nil {
d.want, d.hasCover = "", false
d.cover.setImage(nil)
@@ -75,33 +76,71 @@ func (d *detailPane) Show(text detailText, ref coverRef) {
// Clear empties the pane.
func (d *detailPane) Clear() { d.Show(detailText{}, coverRef{}) }
// applyText puts the texts into their views for the current layout.
func (d *detailPane) applyText() {
if d.combined {
d.head.SetText("")
d.plot.SetText(d.text.String()).ScrollToBeginning()
return
}
d.head.SetText(d.text.head).ScrollToBeginning()
d.plot.SetText(d.text.plot).ScrollToBeginning()
}
func (d *detailPane) Draw(screen tcell.Screen) {
_, _, w, h := d.GetInnerRect()
coverW, coverH := 0, 0
x, y, w, h := d.GetInnerRect()
d.coverW, d.coverH = 0, 0
if d.hasCover {
aw, ah := d.cover.aspect()
// At most 40% of the width, and the plot keeps its rows.
coverW, coverH = coverCells(aw, ah, w*2/5, h-minTextRows)
if coverH < minCoverRows || coverW < minCoverCols {
coverW, coverH = 0, 0
// 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)
if d.coverH < minCoverRows || d.coverW < minCoverCols {
d.coverW, d.coverH = 0, 0
}
}
if combined := coverW == 0; combined != d.combined {
d.combined = combined
d.applyText()
narrow, rows := w, 0
if d.coverW > 0 {
narrow, rows = w-d.coverW-1, d.coverH // one column of space left of the poster
}
d.ResizeItem(d.top, coverH, 0)
d.top.ResizeItem(d.cover, coverW+1, 0) // +1: the padding left of the poster
if !d.laidOut.valid || d.laidOut.width != w || d.laidOut.narrow != narrow || d.laidOut.rows != rows {
d.laidOut.width, d.laidOut.narrow, d.laidOut.rows, d.laidOut.valid = w, narrow, rows, true
d.text.SetText(strings.Join(flowWrap(d.detail.String(), narrow, w, rows), "\n"))
}
d.Flex.Draw(screen)
if d.coverW > 0 {
d.cover.SetRect(x+w-d.coverW, y, d.coverW, d.coverH)
d.cover.Draw(screen)
}
}
// flowWrap word-wraps text for a column that is narrow for its first
// narrowRows lines and wide afterwards, like text flowing around a picture
// floating at the top right. Newlines in the text are kept. Color tags have
// no width and carry over from line to line.
func flowWrap(text string, narrow, wide, narrowRows int) []string {
var lines []string
var cur strings.Builder
curW := 0
limit := func() int {
if len(lines) < narrowRows {
return max(narrow, 1)
}
return max(wide, 1)
}
flush := func() {
lines = append(lines, cur.String())
cur.Reset()
curW = 0
}
for _, para := range strings.Split(text, "\n") {
for _, word := range strings.Fields(para) {
ww := tview.TaggedStringWidth(word)
if curW > 0 && curW+1+ww > limit() {
flush()
}
if curW > 0 {
cur.WriteByte(' ')
curW++
}
cur.WriteString(word)
curW += ww
}
flush() // end of paragraph
}
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
return lines
}
+56 -25
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func rowText(screen tcell.SimulationScreen, y, x0, x1 int) string {
@@ -16,7 +17,38 @@ func rowText(screen tcell.SimulationScreen, y, x0, x1 int) string {
return b.String()
}
func TestDetailPaneLayout(t *testing.T) {
func TestFlowWrap(t *testing.T) {
text := "one two three four five six seven eight nine ten"
// Narrow (10) for the first two lines, wide (25) afterwards.
got := flowWrap(text, 10, 25, 2)
want := []string{"one two", "three four", "five six seven eight nine", "ten"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Errorf("got %q, want %q", got, want)
}
// Without a poster everything is wide.
if got := flowWrap(text, 10, 25, 0); got[0] != "one two three four five" {
t.Errorf("no poster: %q", got)
}
// Newlines are kept, blank lines too; trailing blank lines are dropped.
got = flowWrap("head\nfacts\n\nplot words\n", 20, 20, 0)
if strings.Join(got, "|") != "head|facts||plot words" {
t.Errorf("newlines: %q", got)
}
// Color tags have no width and escaped brackets count as one character.
got = flowWrap("[red]ab[-] cd ef", 5, 5, 0)
if strings.Join(got, "|") != "[red]ab[-] cd|ef" {
t.Errorf("tags: %q", got)
}
if got := flowWrap(tview.Escape("[1080p]")+" x", 9, 9, 0); len(got) != 1 {
t.Errorf("escaped brackets: %q", got)
}
// A word wider than the column is not split or lost.
if got := flowWrap("a verylongwordhere b", 5, 5, 0); strings.Join(got, "|") != "a|verylongwordhere|b" {
t.Errorf("long word: %q", got)
}
}
func TestDetailPaneFlowsAroundPoster(t *testing.T) {
a := newTestApp(t)
screen := tcell.NewSimulationScreen("")
if err := screen.Init(); err != nil {
@@ -25,52 +57,51 @@ func TestDetailPaneLayout(t *testing.T) {
defer screen.Fini()
screen.SetSize(60, 40)
plot := strings.Repeat("plot ", 220) // long enough to run past the poster
d := newDetailPane(a)
d.SetRect(0, 0, 60, 40)
d.Show(detailText{head: "[::b]The Matrix (1999)[::-]\nStatus: released", plot: "Plot text goes here"}, coverRef{})
d.Show(detailText{head: "[::b]The Matrix (1999)[::-]\nStatus: released\n", plot: plot}, coverRef{})
// Without a poster everything is one text.
// Without a poster the text uses the full width from the top.
d.Draw(screen)
if got := rowText(screen, 1, 1, 30); !strings.HasPrefix(got, "The Matrix (1999)") {
t.Errorf("first line = %q", got)
}
if got := rowText(screen, 3, 1, 30); !strings.HasPrefix(got, "Plot text goes here") {
t.Errorf("without a poster the plot follows the head: %q", got)
if got := strings.TrimSpace(rowText(screen, 5, 1, 59)); len(got) < 50 {
t.Errorf("without a poster the text should use the full width: %q", got)
}
// With a poster: head on the left, poster on the right, plot below both.
// With a poster (23×17 cells at the top right) the text runs beside it.
d.hasCover = true
d.cover.setImage(stripes(40, 60))
d.Draw(screen)
if d.coverW != 23 || d.coverH != 17 {
t.Fatalf("poster size %dx%d, want 23x17", d.coverW, d.coverH)
}
if got := rowText(screen, 1, 1, 30); !strings.HasPrefix(got, "The Matrix (1999)") {
t.Errorf("head moved: %q", got)
}
coverX := -1
for x := 59; x > 30; x-- {
if ch, _, st, _ := screen.GetContent(x, 1); ch != ' ' || st != tcell.StyleDefault {
coverX = x
break
if got := rowText(screen, 4, 1, 30); !strings.HasPrefix(got, "plot plot") {
t.Errorf("the plot starts beside the poster, right after the head: %q", got)
}
// Beside the poster the text stays left of it (34 columns), below it it uses the full width.
for y := 1; y <= d.coverH; y++ {
if got := strings.TrimRight(rowText(screen, y, 1, 35), " "); len([]rune(got)) > 34 {
t.Errorf("row %d runs into the poster: %q", y, got)
}
}
if coverX < 55 {
t.Errorf("no poster against the right edge (last drawn column %d)", coverX)
if got := strings.TrimSpace(rowText(screen, d.coverH+3, 1, 59)); len(got) < 50 {
t.Errorf("below the poster the text should use the full width: %q", got)
}
// The plot starts below the poster: 40*2/5 = 24 columns -> 24*3/2/2 = 18 rows.
plotRow := -1
for y := 1; y < 39; y++ {
if strings.HasPrefix(rowText(screen, y, 1, 30), "Plot text goes here") {
plotRow = y
break
}
}
if plotRow < 18 {
t.Errorf("plot at row %d, want below the poster", plotRow)
// The poster sits against the right edge of the pane.
if ch, _, st, _ := screen.GetContent(58, 1); ch == ' ' && st == tcell.StyleDefault {
t.Error("nothing drawn at the poster's right edge")
}
// A pane too short for the poster falls back to plain text.
d.SetRect(0, 0, 60, 14)
d.SetRect(0, 0, 60, 10)
d.Draw(screen)
if !d.combined {
if d.coverW != 0 {
t.Error("a short pane must drop the poster")
}
}
+1 -4
View File
@@ -106,10 +106,7 @@ func newMovieItem(m radarr.Movie) libItem {
it.status = "[yellow]missing"
}
it.details = movieDetails(m)
it.details.head += monitoredLine(m.Monitored) + sizeLine(m.SizeOnDisk)
if path := pathLine(m.Path); path != "" {
it.details.plot += "\n\n" + path
}
it.details.head += monitoredLine(m.Monitored) + sizeLine(m.SizeOnDisk) + pathLine(m.Path)
return it
}