Filter dialog: only offer options that yield results

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:48:57 +02:00
co-authored by Claude Sonnet 5.5
parent ac1c2ebce2
commit 8a357f7418
7 changed files with 215 additions and 81 deletions
+112 -57
View File
@@ -82,18 +82,28 @@ func (f attrFilter) match(files []arr.FileAttrs) bool {
return slices.ContainsFunc(files, f.matchFile)
}
// languagesOf collects the languages of all known files, sorted.
func languagesOf(items ...[]libItem) []string {
// filterPool is what the filter dialog works on: the files of every item that
// passes the other filters. It decides which dropdown entries are offered.
type filterPool struct {
files [][]arr.FileAttrs // one entry per item (a series has several files)
unknown bool // some files are not read yet: nothing can be ruled out
}
// has reports whether at least one item matches f.
func (p filterPool) has(f attrFilter) bool {
return p.unknown || slices.ContainsFunc(p.files, f.match)
}
// languages lists the audio languages in the pool, sorted.
func (p filterPool) languages() []string {
seen := map[string]bool{}
var out []string
for _, list := range items {
for _, it := range list {
for _, a := range it.attrs {
for _, l := range a.Languages {
if !seen[l] {
seen[l] = true
out = append(out, l)
}
for _, files := range p.files {
for _, a := range files {
for _, l := range a.Languages {
if !seen[l] {
seen[l] = true
out = append(out, l)
}
}
}
@@ -102,65 +112,105 @@ func languagesOf(items ...[]libItem) []string {
return out
}
func indexOf(options []string, v string) int {
if i := slices.Index(options, v); i >= 0 {
return i + 1 // option 0 is "any"
// facet is one dropdown of the filter dialog.
type facet struct {
label string
candidates func(filterPool) []string
get func(attrFilter) string
with func(attrFilter, string) attrFilter
dd *tview.DropDown
values []string // the listed options; values[0] is "" for "any"
}
func staticOptions(o []string) func(filterPool) []string {
return func(filterPool) []string { return o }
}
var facetDefs = []facet{
{"Quality", staticOptions(resOptions), func(f attrFilter) string { return f.res }, func(f attrFilter, v string) attrFilter { f.res = v; return f }, nil, nil},
{"Codec", staticOptions(codecOptions), func(f attrFilter) string { return f.codec }, func(f attrFilter, v string) attrFilter { f.codec = v; return f }, nil, nil},
{"HDR", staticOptions(hdrOptions), func(f attrFilter) string { return f.hdr }, func(f attrFilter, v string) attrFilter { f.hdr = v; return f }, nil, nil},
{"Audio", staticOptions(audioOptions), func(f attrFilter) string { return f.audio }, func(f attrFilter, v string) attrFilter { f.audio = v; return f }, nil, nil},
{"Language", filterPool.languages, func(f attrFilter) string { return f.lang }, func(f attrFilter, v string) attrFilter { f.lang = v; return f }, nil, nil},
}
// listedValues returns the options a facet offers: those that, together with
// the other chosen criteria, still leave at least one result, plus the
// current choice. The first entry is always "" (any).
func listedValues(f facet, state attrFilter, pool filterPool) []string {
cur := f.get(state)
vals := []string{""}
cands := f.candidates(pool)
if cur != "" && !slices.Contains(cands, cur) {
cands = append([]string{cur}, cands...)
}
return 0
for _, o := range cands {
if o == cur || pool.has(f.with(state, o)) {
vals = append(vals, o)
}
}
return vals
}
// openAttrFilter shows the dialog to pick quality, codec, HDR, audio and
// language. Every change is passed to set immediately so the list behind the
// dialog follows along; Cancel and Esc restore cur.
func (a *App) openAttrFilter(cur attrFilter, languages []string, set func(attrFilter)) {
if cur.lang != "" && !slices.Contains(languages, cur.lang) {
languages = append([]string{cur.lang}, languages...)
// dialog follows along, also while moving through an open list; Cancel and Esc
// restore cur. Entries that would leave no results are not offered. The
// returned function re-evaluates them when the pool has changed; onClose runs
// when the dialog goes away.
func (a *App) openAttrFilter(cur attrFilter, pool func() filterPool, set func(attrFilter), onClose func()) (refresh func()) {
orig, state := cur, cur
facets := make([]*facet, len(facetDefs))
form := tview.NewForm()
byDropDown := map[*tview.DropDown]*facet{}
for i := range facetDefs {
f := facetDefs[i]
facets[i] = &f
form.AddDropDown(f.label, []string{anyOption}, 0, nil)
f.dd = form.GetFormItem(i).(*tview.DropDown)
f.values = []string{""}
byDropDown[f.dd] = facets[i]
facets[i].dd = f.dd
}
withAny := func(o []string) []string { return append([]string{anyOption}, o...) }
const (
lRes = "Quality"
lCodec = "Codec"
lHDR = "HDR"
lAudio = "Audio"
lLang = "Language"
)
form := tview.NewForm().
AddDropDown(lRes, withAny(resOptions), indexOf(resOptions, cur.res), nil).
AddDropDown(lCodec, withAny(codecOptions), indexOf(codecOptions, cur.codec), nil).
AddDropDown(lHDR, withAny(hdrOptions), indexOf(hdrOptions, cur.hdr), nil).
AddDropDown(lAudio, withAny(audioOptions), indexOf(audioOptions, cur.audio), nil).
AddDropDown(lLang, withAny(languages), indexOf(languages, cur.lang), nil)
// filterWith reads the dialog; the entry at index in dropdown over (if
// any) stands in for that dropdown's committed choice.
filterWith := func(over *tview.DropDown, index int) attrFilter {
pick := func(label string, options []string) string {
dd := form.GetFormItemByLabel(label).(*tview.DropDown)
i, _ := dd.GetCurrentOption()
if dd == over {
i = index
rebuilding := false
var onSelect func(f *facet) func(string, int)
refresh = func() {
p := pool()
rebuilding = true
defer func() { rebuilding = false }()
for _, f := range facets {
if f.dd.IsOpen() {
continue // do not pull the list from under the user
}
if i <= 0 {
return ""
vals := listedValues(*f, state, p)
if slices.Equal(vals, f.values) {
continue
}
return options[i-1]
}
return attrFilter{
res: pick(lRes, resOptions),
codec: pick(lCodec, codecOptions),
hdr: pick(lHDR, hdrOptions),
audio: pick(lAudio, audioOptions),
lang: pick(lLang, languages),
f.values = vals
f.dd.SetOptions(append([]string{anyOption}, vals[1:]...), onSelect(f))
f.dd.SetCurrentOption(slices.Index(vals, f.get(state)))
}
}
current := func() attrFilter { return filterWith(nil, 0) }
for _, label := range []string{lRes, lCodec, lHDR, lAudio, lLang} {
form.GetFormItemByLabel(label).(*tview.DropDown).SetSelectedFunc(func(string, int) { set(current()) })
onSelect = func(f *facet) func(string, int) {
return func(_ string, index int) {
if rebuilding || index < 0 || index >= len(f.values) {
return
}
state = f.with(state, f.values[index])
set(state)
refresh()
}
}
for _, f := range facets {
f.dd.SetSelectedFunc(onSelect(f))
}
refresh()
cancel := func() {
a.closeModal()
set(cur)
set(orig)
}
form.AddButton("Apply", a.closeModal)
form.AddButton("Reset", func() {
@@ -169,9 +219,14 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, set func(attrFi
})
form.AddButton("Cancel", cancel)
arrowNavigation(form, cancel, &browseHooks{
browse: func(dd *tview.DropDown, index int) { set(filterWith(dd, index)) },
abort: func() { set(current()) }, // list closed without choosing: back to the committed value
browse: func(dd *tview.DropDown, index int) {
if f := byDropDown[dd]; f != nil && index >= 0 && index < len(f.values) {
set(f.with(state, f.values[index]))
}
},
abort: func() { set(state) }, // list closed without choosing: back to the committed value
})
form.SetBorder(true).SetTitle(" Filter by properties ")
a.openSidePanel(form, 40, 17, "↑↓ move · ←→ change (applies live) · Enter apply · Esc cancel")
a.openSidePanel(form, 40, 17, "↑↓ move · ←→ change (applies live) · Enter apply · Esc cancel", onClose)
return refresh
}