diff --git a/SPECS.md b/SPECS.md index 61aca34..5ba9c8b 100644 --- a/SPECS.md +++ b/SPECS.md @@ -67,7 +67,7 @@ Notes: ### 4.2 v0.2 (done, M8) -- **Library view:** movies (top) and series (below) with the details of the selected item on the right; Tab switches between the two lists. Filters (all, missing, downloaded, monitored, unmonitored), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio format Atmos/TrueHD/DTS:X/DTS-HD/DTS/DD+/DD/AAC/FLAC/Opus, audio language; changes apply live to the list behind the dialog and Esc restores the previous filter) and a text filter apply to both lists. Movie properties come from the movie list; series properties are read per series from `GET /episodefile?seriesId=` only while a property filter is active, cached while a series' file count and size stay the same, and a series matches when any of its files does. `t` toggles monitoring, `s` starts an automatic search, `i`/Enter opens the release search (movies) or the drill-down (series). +- **Library view:** movies (top) and series (below) with the details of the selected item on the right; Tab switches between the two lists. Filters (all, missing, downloaded, monitored, unmonitored), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio format Atmos/TrueHD/DTS:X/DTS-HD/DTS/DD+/DD/AAC/FLAC/Opus, audio language; changes apply live to the list behind the dialog, including while moving through an open dropdown, and Esc restores the previous filter) and a text filter apply to both lists. Movie properties come from the movie list; series properties are read per series from `GET /episodefile?seriesId=` only while a property filter is active, cached while a series' file count and size stay the same, and a series matches when any of its files does. `t` toggles monitoring, `s` starts an automatic search, `i`/Enter opens the release search (movies) or the drill-down (series). - **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search. ### 4.3 Later / ideas diff --git a/internal/ui/form.go b/internal/ui/form.go index 4c8194a..b30b8fd 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -11,16 +11,39 @@ import ( // (and move between the buttons), // // Enter or Space still opens a dropdown list. While a list is open it gets all keys. -func arrowNavigation(f *tview.Form) { +// +// With hooks, the highlighted entry of an open list is reported while the user +// moves through it, before anything is selected. +// +// cancel is called on Esc, except when Esc only closes an open dropdown list +// (tview would otherwise treat that as cancelling the whole form). +func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) { + var b browser + var closingList bool + f.SetCancelFunc(func() { + if closingList { + closingList = false + return + } + if cancel != nil { + cancel() + } + }) f.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { + closingList = false item, button := f.GetFocusedItemIndex() if item < 0 && button < 0 { return ev } dd, _ := focusedDropDown(f, item) if dd != nil && dd.IsOpen() { + closingList = ev.Key() == tcell.KeyEscape + if hooks != nil { + b.track(dd, ev, hooks) + } return ev } + b.dd = nil total := f.GetFormItemCount() + f.GetButtonCount() pos := item if button >= 0 { @@ -90,3 +113,50 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func()) box.SetChecked(false) } } + +// browseHooks lets a form follow the highlight of an open dropdown list. +type browseHooks struct { + browse func(dd *tview.DropDown, index int) // the highlight moved to entry index + abort func() // the list was closed without selecting +} + +// browser mirrors the highlight of an open dropdown list, which tview does +// not expose: it starts at the current option and follows the navigation keys. +type browser struct { + dd *tview.DropDown + idx int + known bool // false after keys that move the highlight in ways we do not track +} + +func (b *browser) track(dd *tview.DropDown, ev *tcell.EventKey, h *browseHooks) { + n := dd.GetOptionCount() + if b.dd != dd { + cur, _ := dd.GetCurrentOption() + b.dd, b.idx, b.known = dd, max(cur, 0), true + } + move := func(to int) { + b.idx = to + if b.known && h.browse != nil { + h.browse(dd, b.idx) + } + } + switch ev.Key() { + case tcell.KeyDown: + move((b.idx + 1) % n) + case tcell.KeyUp: + move((b.idx - 1 + n) % n) + case tcell.KeyHome: + move(0) + case tcell.KeyEnd: + move(n - 1) + case tcell.KeyEscape: + b.dd = nil + if h.abort != nil { + h.abort() + } + case tcell.KeyEnter: + b.dd = nil // the selection is reported by the dropdown itself + default: + b.known = false // paging, typed prefixes + } +} diff --git a/internal/ui/form_test.go b/internal/ui/form_test.go index 324e941..3459710 100644 --- a/internal/ui/form_test.go +++ b/internal/ui/form_test.go @@ -1,6 +1,7 @@ package ui import ( + "reflect" "testing" "github.com/gdamore/tcell/v2" @@ -12,7 +13,7 @@ func TestArrowNavigation(t *testing.T) { AddDropDown("A", []string{"x", "y", "z"}, 0, nil). AddCheckbox("B", true, nil). AddButton("Add", nil).AddButton("Cancel", nil) - arrowNavigation(f) + arrowNavigation(f, nil, nil) var focus func(p tview.Primitive) focus = func(p tview.Primitive) { p.Focus(focus) } f.Focus(focus) @@ -56,7 +57,7 @@ func TestSearchNowFollowsMonitored(t *testing.T) { AddCheckbox("M", true, nil). AddCheckbox("S", true, nil). AddButton("Add", nil) - arrowNavigation(f) + arrowNavigation(f, nil, nil) var focus func(p tview.Primitive) focus = func(p tview.Primitive) { p.Focus(focus) } f.Focus(focus) @@ -108,3 +109,53 @@ func TestSearchNowFollowsMonitored(t *testing.T) { t.Error("Search now should return to its previous choice") } } + +func TestBrowseOpenDropDown(t *testing.T) { + f := tview.NewForm().AddDropDown("D", []string{"any", "a", "b", "c"}, 0, nil) + var seen []int + aborted, cancelled := 0, 0 + arrowNavigation(f, func() { cancelled++ }, &browseHooks{ + browse: func(_ *tview.DropDown, i int) { seen = append(seen, i) }, + abort: func() { aborted++ }, + }) + var focus func(p tview.Primitive) + focus = func(p tview.Primitive) { p.Focus(focus) } + f.Focus(focus) + send := func(k tcell.Key) { f.InputHandler()(tcell.NewEventKey(k, 0, 0), focus) } + dd := f.GetFormItem(0).(*tview.DropDown) + + send(tcell.KeyEnter) // open + if !dd.IsOpen() { + t.Fatal("dropdown did not open") + } + send(tcell.KeyDown) + send(tcell.KeyDown) + send(tcell.KeyUp) + send(tcell.KeyUp) + send(tcell.KeyUp) // wraps to the last entry + if want := []int{1, 2, 1, 0, 3}; !reflect.DeepEqual(seen, want) { + t.Fatalf("browse saw %v, want %v", seen, want) + } + send(tcell.KeyEnter) // select what the highlight is on + if i, _ := dd.GetCurrentOption(); i != 3 { + t.Errorf("selected option %d, want 3: the mirrored highlight drifted from the real list", i) + } + + seen = nil + send(tcell.KeyEnter) + send(tcell.KeyDown) // wraps to 0 + send(tcell.KeyEscape) + if cancelled != 0 { + t.Error("Esc on an open list must only close the list") + } + send(tcell.KeyEscape) // list closed: now Esc cancels the form + if cancelled != 1 { + t.Errorf("cancelled=%d, want 1", cancelled) + } + if aborted != 1 || !reflect.DeepEqual(seen, []int{0}) { + t.Errorf("aborted=%d seen=%v", aborted, seen) + } + if i, _ := dd.GetCurrentOption(); i != 3 { + t.Errorf("Esc must keep the committed option, got %d", i) + } +} diff --git a/internal/ui/help.go b/internal/ui/help.go index a40231e..c0f08a5 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -24,7 +24,8 @@ const helpText = `[yellow]Global[-] filters apply to both lists F filter by file properties: quality, codec (x265, x264, AV1), HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language. - Changes apply live behind the dialog; Esc undoes them. + Changes apply live behind the dialog, also while you move through an + open list; Esc undoes them. Series are read on demand. / text filter (Enter or Esc returns to the list) t toggle monitored diff --git a/internal/ui/libfilter.go b/internal/ui/libfilter.go index 246f03c..2045ea6 100644 --- a/internal/ui/libfilter.go +++ b/internal/ui/libfilter.go @@ -131,14 +131,20 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, set func(attrFi AddDropDown(lAudio, withAny(audioOptions), indexOf(audioOptions, cur.audio), nil). AddDropDown(lLang, withAny(languages), indexOf(languages, cur.lang), nil) - pick := func(label string, options []string) string { - i := selected(form, label) - if i <= 0 { - return "" + // 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 + } + if i <= 0 { + return "" + } + return options[i-1] } - return options[i-1] - } - current := func() attrFilter { return attrFilter{ res: pick(lRes, resOptions), codec: pick(lCodec, codecOptions), @@ -147,6 +153,7 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, set func(attrFi lang: pick(lLang, languages), } } + 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()) }) } @@ -161,8 +168,10 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, set func(attrFi set(attrFilter{}) }) form.AddButton("Cancel", cancel) - form.SetCancelFunc(cancel) - arrowNavigation(form) + 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 + }) form.SetBorder(true).SetTitle(" Filter by properties ") a.openSidePanel(form, 40, 17, "↑↓ move · ←→ change (applies live) · Enter apply · Esc cancel") } diff --git a/internal/ui/search.go b/internal/ui/search.go index b0811e3..472ee91 100644 --- a/internal/ui/search.go +++ b/internal/ui/search.go @@ -343,8 +343,7 @@ func (v *searchView) add(i int) { v.doAdd(r, choice) }) form.AddButton("Cancel", v.app.closeModal) - form.SetCancelFunc(v.app.closeModal) - arrowNavigation(form) + arrowNavigation(form, v.app.closeModal, nil) form.SetBorder(true).SetTitle(" Add " + tview.Escape(r.title()) + " ") v.app.openModal(form, 60, height)