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) + } + } +}