diff --git a/internal/config/model.go b/internal/config/model.go index e1cf9bc..be4da85 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -21,7 +21,7 @@ import ( "sort" ) -// MPD's track attribute identifiers +// MPD's track attribute identifiers. These must precisely match the queueListStore's columns declared in player.glade const ( MTAttrArtist = iota MTAttrArtistSort @@ -46,6 +46,10 @@ const ( MTAttrGrouping MTAttrComment MTAttrLabel + // List store's "artificial" columns used for rendering + QueueColumnFontWeight + QueueColumnBgColor + QueueColumnVisible ) // MpdTrackAttribute describes an MPD's track attribute diff --git a/internal/player/builder.go b/internal/player/builder.go index 3a67dd3..2c4823d 100644 --- a/internal/player/builder.go +++ b/internal/player/builder.go @@ -137,6 +137,15 @@ func (b *Builder) getListBox(name string) *gtk.ListBox { return result } +// getListStore finds and returns a list box by its name +func (b *Builder) getListStore(name string) *gtk.ListStore { + result, ok := b.get(name).(*gtk.ListStore) + if !ok { + log.Fatal(errors.Errorf("%v is not a gtk.ListStore", name)) + } + return result +} + // getMenu finds and returns a menu by its name func (b *Builder) getMenu(name string) *gtk.Menu { result, ok := b.get(name).(*gtk.Menu) @@ -173,6 +182,24 @@ func (b *Builder) getScale(name string) *gtk.Scale { return result } +// getSearchBar finds and returns a search bar by its name +func (b *Builder) getSearchBar(name string) *gtk.SearchBar { + result, ok := b.get(name).(*gtk.SearchBar) + if !ok { + log.Fatal(errors.Errorf("%v is not a gtk.SearchBar", name)) + } + return result +} + +// getSearchEntry finds and returns a search entry by its name +func (b *Builder) getSearchEntry(name string) *gtk.SearchEntry { + result, ok := b.get(name).(*gtk.SearchEntry) + if !ok { + log.Fatal(errors.Errorf("%v is not a gtk.SearchEntry", name)) + } + return result +} + // getShortcutsWindow finds and returns a shortcuts window by its name func (b *Builder) getShortcutsWindow(name string) *gtk.ShortcutsWindow { result, ok := b.get(name).(*gtk.ShortcutsWindow) @@ -227,6 +254,15 @@ func (b *Builder) getToolButton(name string) *gtk.ToolButton { return result } +// getTreeModelFilter finds and returns a tree model filter by its name +func (b *Builder) getTreeModelFilter(name string) *gtk.TreeModelFilter { + result, ok := b.get(name).(*gtk.TreeModelFilter) + if !ok { + log.Fatal(errors.Errorf("%v is not a gtk.TreeModelFilter", name)) + } + return result +} + // getTreeView finds and returns a tree view by its name func (b *Builder) getTreeView(name string) *gtk.TreeView { result, ok := b.get(name).(*gtk.TreeView) diff --git a/internal/player/main-window.go b/internal/player/main-window.go index 8164a96..9bbe585 100644 --- a/internal/player/main-window.go +++ b/internal/player/main-window.go @@ -15,6 +15,7 @@ package player +import "C" import ( "bytes" "fmt" @@ -54,12 +55,18 @@ type MainWindow struct { scPlayPosition *gtk.Scale adjPlayPosition *gtk.Adjustment // Queue widgets - bxQueue *gtk.Box - lblQueueInfo *gtk.Label - trvQueue *gtk.TreeView - pmnQueueSort *gtk.PopoverMenu - pmnQueueSave *gtk.PopoverMenu - mnQueue *gtk.Menu + bxQueue *gtk.Box + lblQueueInfo *gtk.Label + trvQueue *gtk.TreeView + pmnQueueSort *gtk.PopoverMenu + pmnQueueSave *gtk.PopoverMenu + mnQueue *gtk.Menu + btnQueueFilter *gtk.ToggleToolButton + queueSearchBar *gtk.SearchBar + queueSearchEntry *gtk.SearchEntry + lblQueueFilter *gtk.Label + queueListStore *gtk.ListStore + queueListFilter *gtk.TreeModelFilter // Queue sort popup cbxQueueSortBy *gtk.ComboBoxText // Queue save popup @@ -104,31 +111,17 @@ type MainWindow struct { aPlayerRepeat *glib.SimpleAction aPlayerConsume *glib.SimpleAction - // Queue list store - queueListStore *gtk.ListStore - // Number of items in the play queue - currentQueueSize int - // Queue's track index (last) marked as current - currentQueueIndex int + currentQueueSize int // Number of items in the play queue + currentQueueIndex int // Queue's track index (last) marked as current - // Current library path, separated by slashes - currentLibPath string + currentLibPath string // Current library path, separated by slashes - // Compiled template for player's track title - playerTitleTemplate *template.Template + playerTitleTemplate *template.Template // Compiled template for player's track title - // Play position manual update flag - playPosUpdating bool - // Options update flag - optionsUpdating bool + playPosUpdating bool // Play position manual update flag + optionsUpdating bool // Options update flag } -var ( - // Indices of "artificial" queue list store columns used for rendering - queueColNumFontWeight int - queueColNumBgColor int -) - const ( // Rendering properties for the Queue list fontWeightNormal = 400 @@ -158,12 +151,18 @@ func NewMainWindow(application *gtk.Application) (*MainWindow, error) { scPlayPosition: builder.getScale("scPlayPosition"), adjPlayPosition: builder.getAdjustment("adjPlayPosition"), // Queue - bxQueue: builder.getBox("bxQueue"), - lblQueueInfo: builder.getLabel("lblQueueInfo"), - trvQueue: builder.getTreeView("trvQueue"), - pmnQueueSort: builder.getPopoverMenu("pmnQueueSort"), - pmnQueueSave: builder.getPopoverMenu("pmnQueueSave"), - mnQueue: builder.getMenu("mnQueue"), + bxQueue: builder.getBox("bxQueue"), + lblQueueInfo: builder.getLabel("lblQueueInfo"), + trvQueue: builder.getTreeView("trvQueue"), + pmnQueueSort: builder.getPopoverMenu("pmnQueueSort"), + pmnQueueSave: builder.getPopoverMenu("pmnQueueSave"), + mnQueue: builder.getMenu("mnQueue"), + btnQueueFilter: builder.getToggleToolButton("btnQueueFilter"), + queueSearchBar: builder.getSearchBar("queueSearchBar"), + queueSearchEntry: builder.getSearchEntry("queueSearchEntry"), + lblQueueFilter: builder.getLabel("lblQueueFilter"), + queueListStore: builder.getListStore("queueListStore"), + queueListFilter: builder.getTreeModelFilter("queueListFilter"), // Queue sort popup cbxQueueSortBy: builder.getComboBoxText("cbxQueueSortBy"), // Queue save popup @@ -181,33 +180,32 @@ func NewMainWindow(application *gtk.Application) (*MainWindow, error) { bxPlaylists: builder.getBox("bxPlaylists"), lbxPlaylists: builder.getListBox("lbxPlaylists"), lblPlaylistsInfo: builder.getLabel("lblPlaylistsInfo"), - - // Other stuff - queueListStore: createQueueListStore(), } - // Bind the list store to the queue tree view - w.trvQueue.SetModel(w.queueListStore) + // Initialise queue filter model + w.queueListFilter.SetVisibleColumn(config.QueueColumnVisible) // Initialise player title template w.updatePlayerTitleTemplate() // Map the handlers to callback functions builder.ConnectSignals(map[string]interface{}{ - "on_mainWindow_delete": w.onDelete, - "on_mainWindow_map": w.onMap, - "on_trvQueue_buttonPress": w.onQueueTreeViewButtonPress, - "on_trvQueue_keyPress": w.onQueueTreeViewKeyPress, - "on_tselQueue_changed": w.updateQueueActions, - "on_lbxLibrary_buttonPress": w.onLibraryListBoxButtonPress, - "on_lbxLibrary_keyPress": w.onLibraryListBoxKeyPress, - "on_lbxLibrary_selectionChange": w.updateLibraryActions, - "on_lbxPlaylists_buttonPress": w.onPlaylistListBoxButtonPress, - "on_lbxPlaylists_keyPress": w.onPlaylistListBoxKeyPress, - "on_lbxPlaylists_selectionChange": w.updatePlaylistsActions, - "on_pmnQueueSave_validate": w.onQueueSavePopoverValidate, - "on_scPlayPosition_buttonEvent": w.onPlayPositionButtonEvent, - "on_scPlayPosition_valueChanged": w.updatePlayerSeekBar, + "on_mainWindow_delete": w.onDelete, + "on_mainWindow_map": w.onMap, + "on_trvQueue_buttonPress": w.onQueueTreeViewButtonPress, + "on_trvQueue_keyPress": w.onQueueTreeViewKeyPress, + "on_tselQueue_changed": w.updateQueueActions, + "on_queueSearchBar_searchMode": w.queueFilter, + "on_queueSearchEntry_searchChanged": w.queueFilter, + "on_lbxLibrary_buttonPress": w.onLibraryListBoxButtonPress, + "on_lbxLibrary_keyPress": w.onLibraryListBoxKeyPress, + "on_lbxLibrary_selectionChange": w.updateLibraryActions, + "on_lbxPlaylists_buttonPress": w.onPlaylistListBoxButtonPress, + "on_lbxPlaylists_keyPress": w.onPlaylistListBoxKeyPress, + "on_lbxPlaylists_selectionChange": w.updatePlaylistsActions, + "on_pmnQueueSave_validate": w.onQueueSavePopoverValidate, + "on_scPlayPosition_buttonEvent": w.onPlayPositionButtonEvent, + "on_scPlayPosition_valueChanged": w.updatePlayerSeekBar, // For some reason binding actions to menu items keeps them grayed out, so old-school signals are used here "on_miQueueNowPlaying_activate": w.updateQueueNowPlaying, @@ -215,6 +213,13 @@ func NewMainWindow(application *gtk.Application) (*MainWindow, error) { "on_miQueueDelete_activate": w.queueDelete, }) + // Configure search bar + glib.BindProperty(w.queueSearchBar.Object, "search-mode-enabled", w.btnQueueFilter.Object, "active", glib.BINDING_BIDIRECTIONAL) + glib.BindProperty(w.queueSearchBar.Object, "search-mode-enabled", w.lblQueueFilter.Object, "visible", glib.BINDING_DEFAULT) + + // Forcefully disable tree search popup on Ctrl+F + w.trvQueue.SetSearchColumn(-1) + // Register the main window with the app application.AddWindow(w.window) @@ -232,29 +237,6 @@ func NewMainWindow(application *gtk.Application) (*MainWindow, error) { return w, nil } -// createQueueListStore initialises the queue list store object -func createQueueListStore() *gtk.ListStore { - // Collect column types from MPD attributes - countAttrs := len(config.MpdTrackAttributeIds) - types := make([]glib.Type, countAttrs+2) - for i := range config.MpdTrackAttributeIds { - types[i] = glib.TYPE_STRING - } - - // Last 2 columns are font weight and background color - queueColNumFontWeight = countAttrs - queueColNumBgColor = countAttrs + 1 - types[queueColNumFontWeight] = glib.TYPE_INT - types[queueColNumBgColor] = glib.TYPE_STRING - - // Create a list store instance - store, err := gtk.ListStoreNew(types...) - if err != nil { - log.Fatal("Failed to create queue tree view list store", err) - } - return store -} - func (w *MainWindow) onConnectorStatusChange() { util.WhenIdle("onConnectorStatusChange()", w.updateAll) } @@ -433,11 +415,16 @@ func (w *MainWindow) onQueueTreeViewKeyPress(_ *gtk.TreeView, event *gdk.Event) mask := gtk.AcceleratorGetDefaultModMask() state := gdk.ModifierType(evt.State()) switch evt.KeyVal() { - // Enter + // Enter: apply current selection case gdk.KEY_Return: if state&mask == 0 { w.applyQueueSelection() } + // Esc: exit filtering mode if it's active + case gdk.KEY_Escape: + if state&mask == 0 { + w.queueSearchBar.SetSearchMode(false) + } // Delete case gdk.KEY_Delete: switch state & mask { @@ -453,6 +440,11 @@ func (w *MainWindow) onQueueTreeViewKeyPress(_ *gtk.TreeView, event *gdk.Event) if state&mask == 0 { w.playerPlayPause() } + // Ctrl+F: activate search bar + case gdk.KEY_f: + if state&mask == gdk.GDK_CONTROL_MASK { + w.queueSearchBar.SetSearchMode(true) + } } } @@ -632,6 +624,15 @@ func (w *MainWindow) getQueueSaveNewPlaylistName() string { return s } +// getQueueHasSelection returns whether there's any selected rows in the queue +func (w *MainWindow) getQueueHasSelection() bool { + if sel, err := w.trvQueue.GetSelection(); errCheck(err, "getQueueHasSelection(): trvQueue.GetSelection() failed") { + return false + } else { + return sel.CountSelectedRows() > 0 + } +} + // getQueueSelectedIndices returns indices of the currently selected rows in the queue func (w *MainWindow) getQueueSelectedIndices() []int { // Get the tree's selection @@ -642,11 +643,17 @@ func (w *MainWindow) getQueueSelectedIndices() []int { // Get selected nodes' indices var indices []int - sel.GetSelectedRows(nil).Foreach(func(item interface{}) { - if ix := item.(*gtk.TreePath).GetIndices(); len(ix) > 0 { - indices = append(indices, ix[0]) + err = sel.SelectedForEach(func(model *gtk.TreeModel, path *gtk.TreePath, iter *gtk.TreeIter, userData interface{}) { + // Convert the provided tree (filtered) path into unfiltered one + if queuePath := w.queueListFilter.ConvertPathToChildPath(path); queuePath != nil { + if ix := queuePath.GetIndices(); len(ix) > 0 { + indices = append(indices, ix[0]) + } } }) + if errCheck(err, "getQueueSelectedIndices(): SelectedForEach() failed") { + return nil + } return indices } @@ -881,6 +888,65 @@ func (w *MainWindow) queueDelete() { w.errCheckDialog(err, "Failed to delete tracks from the queue") } +// queueFilter applies the currently entered filter substring to the queue +func (w *MainWindow) queueFilter() { + log.Debug("queueFilter()") + substr := "" + + // Only use filter pattern if the search bar is visible + if w.queueSearchBar.GetSearchMode() { + var err error + if substr, err = w.queueSearchEntry.GetText(); errCheck(err, "queueSearchEntry.GetText() failed") { + return + } + } + + // Iterate all rows in the list store + count := 0 + err := w.queueListStore.ForEach(func(model *gtk.TreeModel, path *gtk.TreePath, iter *gtk.TreeIter, userData interface{}) bool { + // Show all rows if no search pattern given + visible := substr == "" + if !visible { + // We're going to compare case-insensitively + substr := strings.ToLower(substr) + + // Scan all known columns in the row + for _, id := range config.MpdTrackAttributeIds { + // Get column's value + v, err := model.GetValue(iter, id) + if errCheck(err, "queueFilter(): queueListStore.GetValue() failed") { + continue + } + + // Convert the value into a string + s, _ := v.GetString() // Ignoring the returned error due to https://github.com/gotk3/gotk3/issues/583 + + // Check for a match and stop checking if match has already been found + visible = s != "" && strings.Contains(strings.ToLower(s), substr) + if visible { + break + } + } + } + + // Modify the row's visibility + if err := w.queueListStore.SetValue(iter, config.QueueColumnVisible, visible); errCheck(err, "queueFilter(): queueListStore.SetValue() failed") { + return true + } + if visible { + count++ + } + + // Proceed to the next row + return false + }) + if errCheck(err, "queueListStore.ForEach() failed") { + return + } + + w.lblQueueFilter.SetText(fmt.Sprintf("%d tracks displayed", count)) +} + // queueOne adds or replaces the content of the queue with one specified URI func (w *MainWindow) queueOne(replace bool, uri string) { w.queue(replace, []string{uri}) @@ -912,7 +978,7 @@ func (w *MainWindow) queuePlaylist(replace bool, playlistName string) { // queueSave shows a dialog for saving the play queue into a playlist and performs the operation if confirmed func (w *MainWindow) queueSave() { // Tweak widgets - selection := len(w.getQueueSelectedIndices()) > 0 + selection := w.getQueueHasSelection() w.cbQueueSaveSelectedOnly.SetVisible(selection) w.cbQueueSaveSelectedOnly.SetActive(selection) w.eQueueSavePlaylistName.SetText("") @@ -1057,7 +1123,7 @@ func (w *MainWindow) shortcutInfo() { // Show displays the window and all its child widgets func (w *MainWindow) Show() { - w.window.ShowAll() + w.window.Show() } // setLibraryPath sets the current library path selector and updates its widget and the current library list @@ -1080,8 +1146,8 @@ func (w *MainWindow) setQueueHighlight(index int, selected bool) { } errCheck( w.queueListStore.SetCols(iter, map[int]interface{}{ - queueColNumFontWeight: weight, - queueColNumBgColor: bgColor, + config.QueueColumnFontWeight: weight, + config.QueueColumnBgColor: bgColor, }), "lstQueue.SetValue() failed") } @@ -1449,8 +1515,9 @@ func (w *MainWindow) updateQueue() { rowData[id] = value // Add the "artificial" column values - rowData[queueColNumFontWeight] = fontWeightNormal - rowData[queueColNumBgColor] = colorBgNormal + rowData[config.QueueColumnFontWeight] = fontWeightNormal + rowData[config.QueueColumnBgColor] = colorBgNormal + rowData[config.QueueColumnVisible] = true } // Add a row to the list store @@ -1465,7 +1532,7 @@ func (w *MainWindow) updateQueue() { // Add number of tracks var status string - switch len(attrs) { + switch w.currentQueueSize { case 0: status = "Queue is empty" case 1: @@ -1527,8 +1594,8 @@ func (w *MainWindow) updateQueueColumns() { col.SetFixedWidth(width) col.SetClickable(true) col.SetResizable(true) - col.AddAttribute(renderer, "background", queueColNumBgColor) - col.AddAttribute(renderer, "weight", queueColNumFontWeight) + col.AddAttribute(renderer, "weight", config.QueueColumnFontWeight) + col.AddAttribute(renderer, "background", config.QueueColumnBgColor) // Bind the clicked signal _, err = col.Connect("clicked", func() { w.onQueueTreeViewColClicked(col, index, &attr) }) @@ -1550,7 +1617,7 @@ func (w *MainWindow) updateQueueColumns() { func (w *MainWindow) updateQueueActions() { connected := w.connector.IsConnected() notEmpty := connected && w.currentQueueSize > 0 - selection := notEmpty && len(w.getQueueSelectedIndices()) > 0 + selection := notEmpty && w.getQueueHasSelection() w.aQueueNowPlaying.SetEnabled(notEmpty) w.aQueueClear.SetEnabled(notEmpty) w.aQueueSort.SetEnabled(notEmpty) @@ -1572,7 +1639,14 @@ func (w *MainWindow) updateQueueNowPlaying() { // Scroll to the currently playing if w.currentQueueIndex >= 0 { - if treePath, err := gtk.TreePathNewFromString(strconv.Itoa(w.currentQueueIndex)); err == nil { + // Obtain a path in the unfiltered list + treePath, err := gtk.TreePathNewFromIndicesv([]int{w.currentQueueIndex}) + if errCheck(err, "updateQueueNowPlaying(): TreePathNewFromString() failed") { + return + } + + // Convert the path into one in the filtered list + if treePath = w.queueListFilter.ConvertChildPathToPath(treePath); treePath != nil { w.trvQueue.ScrollToCell(treePath, nil, true, 0.5, 0) } } diff --git a/resources/player.glade b/resources/player.glade index 66c7f31..db0c0f1 100644 --- a/resources/player.glade +++ b/resources/player.glade @@ -45,6 +45,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + queueListStore + 600 300 @@ -122,6 +181,7 @@ app.queue.now-playing Now playing True + multimedia-volume-control False @@ -136,6 +196,7 @@ app.queue.clear Clear True + dialog-close False @@ -151,6 +212,7 @@ app.queue.sort Sort ▾ True + view-sort-descending False @@ -165,6 +227,7 @@ app.queue.delete Delete True + edit-delete False @@ -180,6 +243,21 @@ app.queue.save Save ▾ True + document-save + + + False + True + + + + + True + False + Filter the play queue + Search + True + edit-find False @@ -193,19 +271,46 @@ 0 + + + True + False + True + + + + True + True + 50 + edit-find-symbolic + False + False + Filter… + + + + + + False + True + 1 + + True True True True - in + etched-out True True True True + queueListFilter + False True True @@ -222,24 +327,52 @@ False True - 1 + 2 - + True False - True - False - ... - end - False + 6 + + + True + False + 3 + 3 + + end + False + + + True + True + 0 + + + + + False + 6 + 3 + 3 + + + + False + True + 1 + + + False True - 6 - 2 + 3 @@ -341,19 +474,33 @@ - + True False - True - False - ... - end - False + vertical + + + True + False + 3 + 3 + ... + end + False + + + True + True + 0 + + + False True - 6 2 @@ -440,19 +587,33 @@ - + True False - True - False - ... - end - False + vertical + + + True + False + 3 + 3 + ... + end + False + + + True + True + 0 + + + False True - 6 2 @@ -470,17 +631,6 @@ 0 - - - True - False - - - False - True - 1 - - True @@ -494,7 +644,7 @@ False True 6 - 2 + 1 @@ -676,7 +826,7 @@ False True - 3 + 2