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:
@@ -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
|
||||
|
||||
+74
-2
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user