Preview filter while browsing an open dropdown; Esc closes only the list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,7 +67,7 @@ Notes:
|
|||||||
|
|
||||||
### 4.2 v0.2 (done, M8)
|
### 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.
|
- **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search.
|
||||||
|
|
||||||
### 4.3 Later / ideas
|
### 4.3 Later / ideas
|
||||||
|
|||||||
+71
-1
@@ -11,16 +11,39 @@ import (
|
|||||||
// (and move between the buttons),
|
// (and move between the buttons),
|
||||||
//
|
//
|
||||||
// Enter or Space still opens a dropdown list. While a list is open it gets all keys.
|
// 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 {
|
f.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
|
||||||
|
closingList = false
|
||||||
item, button := f.GetFocusedItemIndex()
|
item, button := f.GetFocusedItemIndex()
|
||||||
if item < 0 && button < 0 {
|
if item < 0 && button < 0 {
|
||||||
return ev
|
return ev
|
||||||
}
|
}
|
||||||
dd, _ := focusedDropDown(f, item)
|
dd, _ := focusedDropDown(f, item)
|
||||||
if dd != nil && dd.IsOpen() {
|
if dd != nil && dd.IsOpen() {
|
||||||
|
closingList = ev.Key() == tcell.KeyEscape
|
||||||
|
if hooks != nil {
|
||||||
|
b.track(dd, ev, hooks)
|
||||||
|
}
|
||||||
return ev
|
return ev
|
||||||
}
|
}
|
||||||
|
b.dd = nil
|
||||||
total := f.GetFormItemCount() + f.GetButtonCount()
|
total := f.GetFormItemCount() + f.GetButtonCount()
|
||||||
pos := item
|
pos := item
|
||||||
if button >= 0 {
|
if button >= 0 {
|
||||||
@@ -90,3 +113,50 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
|
|||||||
box.SetChecked(false)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
@@ -12,7 +13,7 @@ func TestArrowNavigation(t *testing.T) {
|
|||||||
AddDropDown("A", []string{"x", "y", "z"}, 0, nil).
|
AddDropDown("A", []string{"x", "y", "z"}, 0, nil).
|
||||||
AddCheckbox("B", true, nil).
|
AddCheckbox("B", true, nil).
|
||||||
AddButton("Add", nil).AddButton("Cancel", nil)
|
AddButton("Add", nil).AddButton("Cancel", nil)
|
||||||
arrowNavigation(f)
|
arrowNavigation(f, nil, nil)
|
||||||
var focus func(p tview.Primitive)
|
var focus func(p tview.Primitive)
|
||||||
focus = func(p tview.Primitive) { p.Focus(focus) }
|
focus = func(p tview.Primitive) { p.Focus(focus) }
|
||||||
f.Focus(focus)
|
f.Focus(focus)
|
||||||
@@ -56,7 +57,7 @@ func TestSearchNowFollowsMonitored(t *testing.T) {
|
|||||||
AddCheckbox("M", true, nil).
|
AddCheckbox("M", true, nil).
|
||||||
AddCheckbox("S", true, nil).
|
AddCheckbox("S", true, nil).
|
||||||
AddButton("Add", nil)
|
AddButton("Add", nil)
|
||||||
arrowNavigation(f)
|
arrowNavigation(f, nil, nil)
|
||||||
var focus func(p tview.Primitive)
|
var focus func(p tview.Primitive)
|
||||||
focus = func(p tview.Primitive) { p.Focus(focus) }
|
focus = func(p tview.Primitive) { p.Focus(focus) }
|
||||||
f.Focus(focus)
|
f.Focus(focus)
|
||||||
@@ -108,3 +109,53 @@ func TestSearchNowFollowsMonitored(t *testing.T) {
|
|||||||
t.Error("Search now should return to its previous choice")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -24,7 +24,8 @@ const helpText = `[yellow]Global[-]
|
|||||||
filters apply to both lists
|
filters apply to both lists
|
||||||
F filter by file properties: quality, codec (x265, x264, AV1),
|
F filter by file properties: quality, codec (x265, x264, AV1),
|
||||||
HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language.
|
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.
|
Series are read on demand.
|
||||||
/ text filter (Enter or Esc returns to the list)
|
/ text filter (Enter or Esc returns to the list)
|
||||||
t toggle monitored
|
t toggle monitored
|
||||||
|
|||||||
@@ -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(lAudio, withAny(audioOptions), indexOf(audioOptions, cur.audio), nil).
|
||||||
AddDropDown(lLang, withAny(languages), indexOf(languages, cur.lang), nil)
|
AddDropDown(lLang, withAny(languages), indexOf(languages, cur.lang), nil)
|
||||||
|
|
||||||
pick := func(label string, options []string) string {
|
// filterWith reads the dialog; the entry at index in dropdown over (if
|
||||||
i := selected(form, label)
|
// any) stands in for that dropdown's committed choice.
|
||||||
if i <= 0 {
|
filterWith := func(over *tview.DropDown, index int) attrFilter {
|
||||||
return ""
|
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{
|
return attrFilter{
|
||||||
res: pick(lRes, resOptions),
|
res: pick(lRes, resOptions),
|
||||||
codec: pick(lCodec, codecOptions),
|
codec: pick(lCodec, codecOptions),
|
||||||
@@ -147,6 +153,7 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, set func(attrFi
|
|||||||
lang: pick(lLang, languages),
|
lang: pick(lLang, languages),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
current := func() attrFilter { return filterWith(nil, 0) }
|
||||||
for _, label := range []string{lRes, lCodec, lHDR, lAudio, lLang} {
|
for _, label := range []string{lRes, lCodec, lHDR, lAudio, lLang} {
|
||||||
form.GetFormItemByLabel(label).(*tview.DropDown).SetSelectedFunc(func(string, int) { set(current()) })
|
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{})
|
set(attrFilter{})
|
||||||
})
|
})
|
||||||
form.AddButton("Cancel", cancel)
|
form.AddButton("Cancel", cancel)
|
||||||
form.SetCancelFunc(cancel)
|
arrowNavigation(form, cancel, &browseHooks{
|
||||||
arrowNavigation(form)
|
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 ")
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,8 +343,7 @@ func (v *searchView) add(i int) {
|
|||||||
v.doAdd(r, choice)
|
v.doAdd(r, choice)
|
||||||
})
|
})
|
||||||
form.AddButton("Cancel", v.app.closeModal)
|
form.AddButton("Cancel", v.app.closeModal)
|
||||||
form.SetCancelFunc(v.app.closeModal)
|
arrowNavigation(form, v.app.closeModal, nil)
|
||||||
arrowNavigation(form)
|
|
||||||
form.SetBorder(true).SetTitle(" Add " + tview.Escape(r.title()) + " ")
|
form.SetBorder(true).SetTitle(" Add " + tview.Escape(r.title()) + " ")
|
||||||
|
|
||||||
v.app.openModal(form, 60, height)
|
v.app.openModal(form, 60, height)
|
||||||
|
|||||||
Reference in New Issue
Block a user