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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
This commit is contained in:
2026-10-21 14:26:09 +02:00
co-authored by Claude Opus 5.5
parent 12e5d5aa33
commit 15f7fc7ee8
3 changed files with 128 additions and 2 deletions
+3
View File
@@ -2,6 +2,9 @@
## Unreleased ## 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 - **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. 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 - **Config (F6):** the default quality profile and root folder are chosen from the lists Radarr
+74 -2
View File
@@ -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) }) p.table.SetBlurFunc(func() { p.table.SetBorderColor(tview.Styles.BorderColor) })
v.panes[kind] = p 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). v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false). AddItem(v.header, 1, 0, false).
@@ -401,7 +401,8 @@ func (v *libraryView) renderPane(p *libPane) {
header(p.table, 0, "Title", "Status", "Size") header(p.table, 0, "Title", "Status", "Size")
selRow := 0 selRow := 0
for i, it := range p.shown { 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, 1, tview.NewTableCell(it.status))
p.table.SetCell(i+1, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight)) p.table.SetCell(i+1, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight))
if it.id == selID { if it.id == selID {
@@ -444,6 +445,77 @@ func (v *libraryView) renderPane(p *libPane) {
p.table.SetTitle(title + " ") 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 { func containsItem(items []libItem, id int) bool {
for _, it := range items { for _, it := range items {
if it.id == id { if it.id == id {
+51
View File
@@ -5,6 +5,9 @@ import (
"strings" "strings"
"testing" "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/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr" "git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr" "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) 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)
}
}
}