From 15f7fc7ee876db0f9bbe99368eaf1e0ce4c64d52 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Wed, 21 Oct 2026 14:26:09 +0200 Subject: [PATCH] Library: cut long titles to the width of the list Long titles made the movies and series tables wider than their pane, so they scrolled sideways and status and size left the view. The title column is now cut on every draw to what the other columns leave, ending in an ellipsis (done by hand: tview only draws its ellipsis for untagged text, and every title starts with the colored monitored mark). Titles are now escaped, so square brackets in them are not taken as color tags. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW --- CHANGELOG.md | 3 ++ internal/ui/library.go | 76 ++++++++++++++++++++++++++++++++++++- internal/ui/library_test.go | 51 +++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e14b2..8d3d69b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- **Library:** long titles are cut with "…" to the width of the list, so the movies and series lists + never scroll sideways and status and size stay in view; the cut follows the window size. Titles + with square brackets (e.g. "[Rec]") are no longer read as color tags. - **Library:** after removing a title the selection stays at its place (on the title that moved up into it, or the new last one) instead of jumping to the top of the list. - **Config (F6):** the default quality profile and root folder are chosen from the lists Radarr diff --git a/internal/ui/library.go b/internal/ui/library.go index ff6fc22..6ac3402 100644 --- a/internal/ui/library.go +++ b/internal/ui/library.go @@ -249,7 +249,7 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView { }) p.table.SetBlurFunc(func() { p.table.SetBorderColor(tview.Styles.BorderColor) }) v.panes[kind] = p - lists.AddItem(p.table, 0, 1, kind == kindMovies) + lists.AddItem(fitTitles{p.table}, 0, 1, kind == kindMovies) } v.root = tview.NewFlex().SetDirection(tview.FlexRow). AddItem(v.header, 1, 0, false). @@ -401,7 +401,8 @@ func (v *libraryView) renderPane(p *libPane) { header(p.table, 0, "Title", "Status", "Size") selRow := 0 for i, it := range p.shown { - p.table.SetCell(i+1, 0, tview.NewTableCell(monMark(it.monitored)+" "+it.label).SetExpansion(1)) + title := titleCell{prefix: monMark(it.monitored) + " ", title: it.label} + p.table.SetCell(i+1, 0, tview.NewTableCell(title.fit(0)).SetExpansion(1).SetReference(title)) p.table.SetCell(i+1, 1, tview.NewTableCell(it.status)) p.table.SetCell(i+1, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight)) if it.id == selID { @@ -444,6 +445,77 @@ func (v *libraryView) renderPane(p *libPane) { p.table.SetTitle(title + " ") } +// fitTitles draws a table whose first column (the title) is cut to the width +// the other columns leave, with an ellipsis, so long titles never make the +// list scroll sideways. The cut is worked out on every draw, so it follows +// the window size. +type fitTitles struct{ *tview.Table } + +func (f fitTitles) Draw(screen tcell.Screen) { + fitFirstColumn(f.Table) + f.Table.Draw(screen) +} + +// fitFirstColumn cuts the title cells (those with a titleCell reference) to +// the width the table leaves after the other columns and their separators. +// The cut is done here rather than with the cell's maximum width, because tview +// only marks a cut with an ellipsis for text without color tags, and every +// title starts with the colored monitored mark. +func fitFirstColumn(t *tview.Table) { + _, _, width, _ := t.GetInnerRect() + rows, cols := t.GetRowCount(), t.GetColumnCount() + rest := 0 + for c := 1; c < cols; c++ { + w := 0 + for r := 0; r < rows; r++ { + if cell := t.GetCell(r, c); cell != nil { + w = max(w, tview.TaggedStringWidth(cell.Text)) + } + } + rest += w + 1 // the column and the separator before it + } + limit := max(width-rest, 8) + for r := 0; r < rows; r++ { + cell := t.GetCell(r, 0) + if title, ok := cell.GetReference().(titleCell); ok { + cell.SetText(title.fit(limit)) + } + } +} + +// titleCell is the text of a title cell: a tagged prefix (the monitored mark) +// and the plain title, kept whole so it can be cut again for another width. +type titleCell struct{ prefix, title string } + +// fit returns the cell text at most width columns wide, the title cut with an +// ellipsis if needed; width 0 means no limit. +func (c titleCell) fit(width int) string { + title := c.title + if width > 0 { + title = cutToWidth(title, width-tview.TaggedStringWidth(c.prefix)) + } + return c.prefix + tview.Escape(title) +} + +// cutToWidth shortens s to at most w screen columns, ending with "…" when +// anything was cut. +func cutToWidth(s string, w int) string { + width := func(s string) int { return tview.TaggedStringWidth(tview.Escape(s)) } + if width(s) <= w { + return s + } + if w < 1 { + return "" + } + runes := []rune(s) + for n := len(runes); n > 0; n-- { + if cut := string(runes[:n]) + "…"; width(cut) <= w { + return cut + } + } + return "…" +} + func containsItem(items []libItem, id int) bool { for _, it := range items { if it.id == id { diff --git a/internal/ui/library_test.go b/internal/ui/library_test.go index 72cbd0a..af19ce5 100644 --- a/internal/ui/library_test.go +++ b/internal/ui/library_test.go @@ -5,6 +5,9 @@ import ( "strings" "testing" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "git.stinnesbeck.com/nils/arr-tui/internal/arr" "git.stinnesbeck.com/nils/arr-tui/internal/radarr" "git.stinnesbeck.com/nils/arr-tui/internal/sonarr" @@ -257,3 +260,51 @@ func TestRemovingATitleKeepsThePlace(t *testing.T) { t.Errorf("filtered out: row %d, want the top", r) } } + +func TestLongTitlesAreCutToTheList(t *testing.T) { + sim := tcell.NewSimulationScreen("") + if err := sim.Init(); err != nil { + t.Fatal(err) + } + defer sim.Fini() + sim.SetSize(40, 5) + + tbl := tview.NewTable().SetSelectable(true, false).SetFixed(1, 0) + header(tbl, 0, "Title", "Status", "Size") + title := titleCell{prefix: monMark(true) + " ", title: "Neon Genesis Evangelion: The End of Evangelion (1997)"} + tbl.SetCell(1, 0, tview.NewTableCell(title.fit(0)).SetExpansion(1).SetReference(title)) + tbl.SetCell(1, 1, tview.NewTableCell("missing")) + tbl.SetCell(1, 2, tview.NewTableCell("12.3 GB").SetAlign(tview.AlignRight)) + tbl.SetRect(0, 0, 40, 5) + fitTitles{tbl}.Draw(sim) + + row := rowText(sim, 1, 0, 40) + if !strings.HasSuffix(strings.TrimRight(row, " "), "12.3 GB") || !strings.Contains(row, "missing") { + t.Errorf("status and size must stay in view: %q", row) + } + if !strings.Contains(row, "…") { + t.Errorf("the cut title should end in an ellipsis: %q", row) + } + if !strings.HasPrefix(row, "● Neon Genesis") { + t.Errorf("the title should start at the left edge: %q", row) + } +} + +func TestCutToWidth(t *testing.T) { + for _, tc := range []struct { + in string + w int + want string + }{ + {"Dune (2021)", 20, "Dune (2021)"}, + {"Dune (2021)", 11, "Dune (2021)"}, + {"Dune (2021)", 8, "Dune (2…"}, + {"千と千尋の神隠し", 7, "千と千…"}, // wide characters count twice + {"[Rec] (2007)", 6, "[Rec]…"}, // brackets are text, not color tags + {"Dune", 0, ""}, + } { + if got := cutToWidth(tc.in, tc.w); got != tc.want { + t.Errorf("cutToWidth(%q, %d) = %q, want %q", tc.in, tc.w, got, tc.want) + } + } +}