diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e908f9..5adf4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7e80a8b..ef162fc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SPECS.md b/SPECS.md index 5c0b356..fe250e8 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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//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//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) diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 0140ad7..4c475a4 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -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 } diff --git a/internal/ui/detail_test.go b/internal/ui/detail_test.go index e7cfcf2..ef2de77 100644 --- a/internal/ui/detail_test.go +++ b/internal/ui/detail_test.go @@ -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") } } diff --git a/internal/ui/library.go b/internal/ui/library.go index ee802a1..9df6b0a 100644 --- a/internal/ui/library.go +++ b/internal/ui/library.go @@ -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 }