diff --git a/README.md b/README.md index 885c009..707e908 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Dev Branch: - Browse by folder - Queue songs and albums - Create and play playlists +- Search music library - Mark favorites - Volume control - Server-side scrobbling (e.g., on Navidrome, gonic) @@ -81,7 +82,8 @@ spinner = '▁▂▃▄▅▆▇█▇▆▅▄▃▂▁' - `1`: Folder view - `2`: Queue view - `3`: Playlist view -- `4`: Log (errors, etc.) view +- `4`: Search view +- `5`: Log (errors, etc.) view - `Escape`/`Return`: Close modal if open ### Playback Controls @@ -133,6 +135,25 @@ spinner = '⣾⣽⣻⢿⡿⣟⣯⣷' The default is `▉▊▋▌▍▎▏▎▍▌▋▊▉`. Set only one of these at a time, and the glyphs must exist in the font that the terminal running stmps is using. +### Search Controls + +The search tab performs a server-side search for text in metadata name fields. +The search results are filtered into three columns: artist, album, and song. 20 +results (in each column) are fetched at a time; use `n` to load more results. + +In any of the columns: + +- `/`: Focus search field. +- `Enter` / `a`: Adds the selected item recursively to the queue. +- `n`: Load more search results. +- Left/right arrow keys (`←`, `→`) navigate between the columns +- Up/down arrow keys (`↓`, `↑`) navigate the selected column list + +In the search field: + +- `Enter`: Perform the query. +- `Escape`: Escapes into the columns, where the global key bindings work. + ## Advanced Configuration and Features ### MPRIS2 Integration diff --git a/event_loop.go b/event_loop.go index d17e6e2..ded9789 100644 --- a/event_loop.go +++ b/event_loop.go @@ -45,7 +45,7 @@ func (ui *Ui) guiEventLoop() { select { case <-fpsTimer.C: fpsTimer.Reset(10 * time.Second) - ui.logger.Printf("guiEventLoop: %f events per second", events/10.0) + // ui.logger.Printf("guiEventLoop: %f events per second", events/10.0) events = 0 case msg := <-ui.logger.Prints: diff --git a/gui.go b/gui.go index 0c00275..934fd47 100644 --- a/gui.go +++ b/gui.go @@ -35,6 +35,9 @@ type Ui struct { // playlist page playlistPage *PlaylistPage + // search page + searchPage *SearchPage + // log page logPage *LogPage @@ -61,6 +64,7 @@ const ( PageBrowser = "browser" PageQueue = "queue" PagePlaylists = "playlists" + PageSearch = "search" PageLog = "log" PageDeletePlaylist = "deletePlaylist" @@ -149,12 +153,16 @@ func InitGui(indexes *[]subsonic.SubsonicIndex, // playlist page ui.playlistPage = ui.createPlaylistPage() + // search page + ui.searchPage = ui.createSearchPage() + // log page ui.logPage = ui.createLogPage() ui.pages.AddPage(PageBrowser, ui.browserPage.Root, true, true). AddPage(PageQueue, ui.queuePage.Root, true, false). AddPage(PagePlaylists, ui.playlistPage.Root, true, false). + AddPage(PageSearch, ui.searchPage.Root, true, false). AddPage(PageDeletePlaylist, ui.playlistPage.DeletePlaylistModal, true, false). AddPage(PageNewPlaylist, ui.playlistPage.NewPlaylistModal, true, false). AddPage(PageAddToPlaylist, ui.browserPage.AddToPlaylistModal, true, false). diff --git a/gui_handlers.go b/gui_handlers.go index 87aff90..0690c9d 100644 --- a/gui_handlers.go +++ b/gui_handlers.go @@ -12,7 +12,7 @@ import ( func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { // we don't want any of these firing if we're trying to add a new playlist focused := ui.app.GetFocus() - if ui.playlistPage.IsNewPlaylistInputFocused(focused) || ui.browserPage.IsSearchFocused(focused) { + if ui.playlistPage.IsNewPlaylistInputFocused(focused) || ui.browserPage.IsSearchFocused(focused) || focused == ui.searchPage.searchField { return event } @@ -27,6 +27,9 @@ func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { ui.ShowPage(PagePlaylists) case '4': + ui.ShowPage(PageSearch) + + case '5': ui.ShowPage(PageLog) case '?': @@ -50,7 +53,6 @@ func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { if err != nil { ui.logger.PrintError("handlePageInput: Pause", err) } - return nil case 'P': // stop playing without changes to queue @@ -59,42 +61,36 @@ func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { if err != nil { ui.logger.PrintError("handlePageInput: Stop", err) } - return nil case 'X': // debug stuff ui.logger.Print("test") //ui.player.Test() ui.showMessageBox("foo bar") - return nil case '-': // volume- if err := ui.player.AdjustVolume(-5); err != nil { ui.logger.PrintError("handlePageInput: AdjustVolume-", err) } - return nil case '+', '=': // volume+ if err := ui.player.AdjustVolume(5); err != nil { ui.logger.PrintError("handlePageInput: AdjustVolume+", err) } - return nil case '.': // << if err := ui.player.Seek(10); err != nil { ui.logger.PrintError("handlePageInput: Seek+", err) } - return nil case ',': // >> if err := ui.player.Seek(-10); err != nil { ui.logger.PrintError("handlePageInput: Seek-", err) } - return nil case '>': // skip to next track @@ -102,9 +98,12 @@ func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { ui.logger.PrintError("handlePageInput: Next", err) } ui.queuePage.UpdateQueue() + + default: + return event } - return event + return nil } func (ui *Ui) ShowPage(name string) { diff --git a/help_text.go b/help_text.go index 412674f..f453502 100644 --- a/help_text.go +++ b/help_text.go @@ -39,3 +39,16 @@ n new playlist d delete playlist a add playlist or song to queue ` + +const helpSearchPage = ` +artist, album, or song tab + Down focus search field + Left previous column + Right next column + Enter recursively add item to quue + a recursively add item to quue + / start search + n load more results +search field + Enter search for text +` diff --git a/page_browser.go b/page_browser.go index 93d1877..6c40bd5 100644 --- a/page_browser.go +++ b/page_browser.go @@ -82,7 +82,6 @@ func (ui *Ui) createBrowserPage(indexes *[]subsonic.SubsonicIndex) *BrowserPage AddItem(browserPage.artistList, 0, 1, true). AddItem(browserPage.entityList, 0, 1, false) - // TODO (A) add search-for-song, if feasible. Might be able to do server-side then drill-down, but we might also have all entities cached on the client already. To investigate. browserPage.Root = tview.NewFlex().SetDirection(tview.FlexRow) browserPage.showSearchField(false) // add artist/search items @@ -254,8 +253,7 @@ func (b *BrowserPage) UpdateStars() { } func (b *BrowserPage) handleAddArtistToQueue() { - currentIndex := b.artistList.GetCurrentItem() - if currentIndex < 0 { + if b.artistList.GetCurrentItem() < 0 { return } diff --git a/page_playlist.go b/page_playlist.go index bbde492..95cb187 100644 --- a/page_playlist.go +++ b/page_playlist.go @@ -203,7 +203,7 @@ func (p *PlaylistPage) UpdatePlaylists() { spinnerText = []rune("▉▊▋▌▍▎▏▎▍▌▋▊▉") } spinnerMax := len(spinnerText) - 1 - playlistsButton := buttonOrder[2] + playlistsButton := buttonOrder[PAGE_PLAYLISTS] stop := make(chan bool) go func() { var idx int @@ -219,7 +219,7 @@ func (p *PlaylistPage) UpdatePlaylists() { } else { format = "%d: [red]%c[white]%s" } - label := fmt.Sprintf(format, 3, spinnerText[idx], playlistsButton) + label := fmt.Sprintf(format, PAGE_PLAYLISTS+1, spinnerText[idx], playlistsButton) p.ui.menuWidget.buttons[playlistsButton].SetLabel(label) idx++ if idx > spinnerMax { @@ -234,7 +234,7 @@ func (p *PlaylistPage) UpdatePlaylists() { } else { format = "%d: %s" } - label := fmt.Sprintf(format, 3, playlistsButton) + label := fmt.Sprintf(format, PAGE_PLAYLISTS+1, playlistsButton) p.ui.menuWidget.buttons[playlistsButton].SetLabel(label) }) close(stop) diff --git a/page_search.go b/page_search.go new file mode 100644 index 0000000..e6e7908 --- /dev/null +++ b/page_search.go @@ -0,0 +1,279 @@ +// Copyright 2023 The STMPS Authors +// SPDX-License-Identifier: GPL-3.0-only + +package main + +import ( + "sort" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/spezifisch/stmps/logger" + "github.com/spezifisch/stmps/subsonic" +) + +type SearchPage struct { + Root *tview.Flex + AddToPlaylistModal tview.Primitive + + columnsFlex *tview.Flex + + artistList *tview.List + albumList *tview.List + songList *tview.List + searchField *tview.InputField + + artists []*subsonic.Artist + albums []*subsonic.Album + songs []*subsonic.SubsonicEntity + + artistOffset int + albumOffset int + songOffset int + + // external refs + ui *Ui + logger logger.LoggerInterface +} + +func (ui *Ui) createSearchPage() *SearchPage { + searchPage := SearchPage{ + ui: ui, + logger: ui.logger, + } + + // artist list + searchPage.artistList = tview.NewList(). + ShowSecondaryText(false) + searchPage.artistList.Box. + SetTitle(" artist "). + SetTitleAlign(tview.AlignLeft). + SetBorder(true) + + // album list + searchPage.albumList = tview.NewList(). + ShowSecondaryText(false) + searchPage.albumList.Box. + SetTitle(" album "). + SetTitleAlign(tview.AlignLeft). + SetBorder(true) + + // song list + searchPage.songList = tview.NewList(). + ShowSecondaryText(false) + searchPage.songList.Box. + SetTitle(" song "). + SetTitleAlign(tview.AlignLeft). + SetBorder(true) + + // search bar + searchPage.searchField = tview.NewInputField(). + SetLabel("search:"). + SetFieldBackgroundColor(tcell.ColorBlack) + + searchPage.columnsFlex = tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(searchPage.artistList, 0, 1, false). + AddItem(searchPage.albumList, 0, 1, false). + AddItem(searchPage.songList, 0, 1, false) + + searchPage.Root = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(searchPage.columnsFlex, 0, 1, false). + AddItem(searchPage.searchField, 1, 1, true) + + searchPage.artistList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyLeft: + ui.app.SetFocus(searchPage.songList) + return nil + case tcell.KeyRight: + ui.app.SetFocus(searchPage.albumList) + return nil + case tcell.KeyEnter: + idx := searchPage.artistList.GetCurrentItem() + searchPage.addArtistToQueue(searchPage.artists[idx]) + return nil + } + + switch event.Rune() { + case 'a': + idx := searchPage.artistList.GetCurrentItem() + searchPage.logger.Printf("artistList adding (%d) %s", idx, searchPage.artists[idx].Name) + searchPage.addArtistToQueue(searchPage.artists[idx]) + return nil + case '/': + searchPage.ui.app.SetFocus(searchPage.searchField) + return nil + case 'n': + searchPage.search() + return nil + } + + return event + }) + searchPage.albumList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyLeft: + ui.app.SetFocus(searchPage.artistList) + return nil + case tcell.KeyRight: + ui.app.SetFocus(searchPage.songList) + return nil + case tcell.KeyEnter: + idx := searchPage.albumList.GetCurrentItem() + searchPage.addAlbumToQueue(searchPage.albums[idx]) + return nil + } + + switch event.Rune() { + case 'a': + idx := searchPage.albumList.GetCurrentItem() + searchPage.logger.Printf("albumList adding (%d) %s", idx, searchPage.albums[idx].Name) + searchPage.addAlbumToQueue(searchPage.albums[idx]) + return nil + case '/': + searchPage.ui.app.SetFocus(searchPage.searchField) + return nil + case 'n': + searchPage.search() + return nil + } + + return event + }) + searchPage.songList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyLeft: + ui.app.SetFocus(searchPage.albumList) + return nil + case tcell.KeyRight: + ui.app.SetFocus(searchPage.artistList) + return nil + case tcell.KeyEnter: + idx := searchPage.songList.GetCurrentItem() + ui.addSongToQueue(searchPage.songs[idx]) + ui.queuePage.UpdateQueue() + return nil + } + + switch event.Rune() { + case 'a': + idx := searchPage.songList.GetCurrentItem() + ui.addSongToQueue(searchPage.songs[idx]) + ui.queuePage.updateQueue() + return nil + case '/': + searchPage.ui.app.SetFocus(searchPage.searchField) + return nil + case 'n': + searchPage.search() + return nil + } + + return event + }) + searchPage.searchField.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyUp, tcell.KeyESC: + if len(searchPage.artists) != 0 { + ui.app.SetFocus(searchPage.artistList) + } else if len(searchPage.albums) != 0 { + ui.app.SetFocus(searchPage.albumList) + } else if len(searchPage.songs) != 0 { + ui.app.SetFocus(searchPage.songList) + } else { + ui.app.SetFocus(searchPage.artistList) + } + case tcell.KeyEnter: + searchPage.artistList.Clear() + searchPage.artists = make([]*subsonic.Artist, 0) + searchPage.albumList.Clear() + searchPage.albums = make([]*subsonic.Album, 0) + searchPage.songList.Clear() + searchPage.songs = make([]*subsonic.SubsonicEntity, 0) + + searchPage.artistOffset = 0 + searchPage.albumOffset = 0 + searchPage.songOffset = 0 + searchPage.search() + if len(searchPage.artists) > 0 { + ui.app.SetFocus(searchPage.artistList) + } else if len(searchPage.albums) > 0 { + ui.app.SetFocus(searchPage.albumList) + } else if len(searchPage.songs) > 0 { + ui.app.SetFocus(searchPage.songList) + } + default: + return event + } + return nil + }) + + return &searchPage +} + +func (s *SearchPage) search() { + if len(s.searchField.GetText()) == 0 { + return + } + query := s.searchField.GetText() + + res, err := s.ui.connection.Search(query, s.artistOffset, s.albumOffset, s.songOffset) + if err != nil { + s.logger.PrintError("SearchPage.search", err) + return + } + + for _, artist := range res.SearchResults.Artist { + s.artistList.AddItem(tview.Escape(artist.Name), "", 0, nil) + s.artists = append(s.artists, &artist) + } + for _, album := range res.SearchResults.Album { + s.albumList.AddItem(tview.Escape(album.Album), "", 0, nil) + s.albums = append(s.albums, &album) + } + for _, song := range res.SearchResults.Song { + s.songList.AddItem(tview.Escape(song.Title), "", 0, nil) + s.songs = append(s.songs, &song) + } + + s.artistOffset += len(res.SearchResults.Artist) + s.albumOffset += len(res.SearchResults.Album) + s.songOffset += len(res.SearchResults.Song) +} + +func (s *SearchPage) addArtistToQueue(entity subsonic.Ider) { + response, err := s.ui.connection.GetArtist(entity.ID()) + if err != nil { + s.logger.Printf("addArtistToQueue: GetArtist %s -- %s", entity.ID(), err.Error()) + return + } + artistName := response.Artist.Name + + for _, album := range response.Artist.Album { + response, err = s.ui.connection.GetAlbum(album.Id) + sort.Sort(response.Album.Song) + for _, e := range response.Album.Song { + for _, art := range e.Artists { + if art.Name == artistName { + s.ui.addSongToQueue(&e) + break + } + } + } + } + + s.ui.queuePage.UpdateQueue() +} + +func (s *SearchPage) addAlbumToQueue(entity subsonic.Ider) { + response, err := s.ui.connection.GetAlbum(entity.ID()) + if err != nil { + s.logger.Printf("addToQueue: GetMusicDirectory %s -- %s", entity.ID(), err.Error()) + return + } + sort.Sort(response.Album.Song) + for _, e := range response.Album.Song { + s.ui.addSongToQueue(&e) + } + s.ui.queuePage.UpdateQueue() +} diff --git a/subsonic/api.go b/subsonic/api.go index 585d091..db8691d 100644 --- a/subsonic/api.go +++ b/subsonic/api.go @@ -70,6 +70,10 @@ func defaultQuery(connection *SubsonicConnection) url.Values { return query } +type Ider interface { + ID() string +} + // response structs type SubsonicError struct { Code int `json:"code"` @@ -77,9 +81,13 @@ type SubsonicError struct { } type SubsonicArtist struct { - Id string - Name string - AlbumCount int + Id string `json:"id"` + Name string `json:"name"` + AlbumCount int `json:"albumCount"` +} + +func (s SubsonicArtist) ID() string { + return s.Id } type SubsonicDirectory struct { @@ -89,26 +97,72 @@ type SubsonicDirectory struct { Entities SubsonicEntities `json:"child"` } +func (s SubsonicDirectory) ID() string { + return s.Id +} + type SubsonicSongs struct { Song SubsonicEntities `json:"song"` } -type SubsonicStarred struct { - Artist SubsonicEntities `json:"artist"` - Album SubsonicEntities `json:"album"` +type SubsonicResults struct { + Artist []Artist `json:"artist"` + Album []Album `json:"album"` Song SubsonicEntities `json:"song"` } +type Artist struct { + Id string `json:"id"` + Name string `json:"name"` + AlbumCount int `json:"albumCount"` + Album []Album `json:"album"` +} + +func (s Artist) ID() string { + return s.Id +} + +type Album struct { + Id string `json:"id"` + Created string `json:"created"` + Artist string `json:"artist"` + Artists []Artist `json:"artists"` + DisplayArtist string `json:"displayArtist"` + Title string `json:"title"` + Album string `json:"album"` + Name string `json:"name"` + SongCount int `json:"songCount"` + Duration int `json:"duration"` + PlayCount int `json:"playCount"` + Genre string `json:"genre"` + Genres []Genre `json:"genres"` + Year int `json:"year"` + Song SubsonicEntities `json:"song"` +} + +func (s Album) ID() string { + return s.Id +} + +type Genre struct { + Name string `json:"name"` +} + type SubsonicEntity struct { - Id string `json:"id"` - IsDirectory bool `json:"isDir"` - Parent string `json:"parent"` - Title string `json:"title"` - Artist string `json:"artist"` - Duration int `json:"duration"` - Track int `json:"track"` - DiskNumber int `json:"diskNumber"` - Path string `json:"path"` + Id string `json:"id"` + IsDirectory bool `json:"isDir"` + Parent string `json:"parent"` + Title string `json:"title"` + Artist string `json:"artist"` + Artists []Artist `json:"artists"` + Duration int `json:"duration"` + Track int `json:"track"` + DiskNumber int `json:"diskNumber"` + Path string `json:"path"` +} + +func (s SubsonicEntity) ID() string { + return s.Id } // Return the title if present, otherwise fallback to the file path @@ -177,16 +231,19 @@ type SubsonicPlaylist struct { } type SubsonicResponse struct { - Status string `json:"status"` - Version string `json:"version"` - Indexes SubsonicIndexes `json:"indexes"` - Directory SubsonicDirectory `json:"directory"` - RandomSongs SubsonicSongs `json:"randomSongs"` - SimilarSongs SubsonicSongs `json:"similarSongs"` - Starred SubsonicStarred `json:"starred"` - Playlists SubsonicPlaylists `json:"playlists"` - Playlist SubsonicPlaylist `json:"playlist"` - Error SubsonicError `json:"error"` + Status string `json:"status"` + Version string `json:"version"` + Indexes SubsonicIndexes `json:"indexes"` + Directory SubsonicDirectory `json:"directory"` + RandomSongs SubsonicSongs `json:"randomSongs"` + SimilarSongs SubsonicSongs `json:"similarSongs"` + Starred SubsonicResults `json:"starred"` + Playlists SubsonicPlaylists `json:"playlists"` + Playlist SubsonicPlaylist `json:"playlist"` + Error SubsonicError `json:"error"` + Artist Artist `json:"artist"` + Album Album `json:"album"` + SearchResults SubsonicResults `json:"searchResult3"` } type responseWrapper struct { @@ -221,6 +278,48 @@ func (connection *SubsonicConnection) GetIndexes() (*SubsonicResponse, error) { return connection.getResponse("GetIndexes", requestUrl) } +func (connection *SubsonicConnection) GetArtist(id string) (*SubsonicResponse, error) { + if cachedResponse, present := connection.directoryCache[id]; present { + return &cachedResponse, nil + } + + query := defaultQuery(connection) + query.Set("id", id) + requestUrl := connection.Host + "/rest/getArtist" + "?" + query.Encode() + resp, err := connection.getResponse("GetMusicDirectory", requestUrl) + if err != nil { + return resp, err + } + + // on a sucessful request, cache the response + if resp.Status == "ok" { + connection.directoryCache[id] = *resp + } + + return resp, nil +} + +func (connection *SubsonicConnection) GetAlbum(id string) (*SubsonicResponse, error) { + if cachedResponse, present := connection.directoryCache[id]; present { + return &cachedResponse, nil + } + + query := defaultQuery(connection) + query.Set("id", id) + requestUrl := connection.Host + "/rest/getAlbum" + "?" + query.Encode() + resp, err := connection.getResponse("GetAlbum", requestUrl) + if err != nil { + return resp, err + } + + // on a sucessful request, cache the response + if resp.Status == "ok" { + connection.directoryCache[id] = *resp + } + + return resp, nil +} + func (connection *SubsonicConnection) GetMusicDirectory(id string) (*SubsonicResponse, error) { if cachedResponse, present := connection.directoryCache[id]; present { return &cachedResponse, nil @@ -426,3 +525,18 @@ func (connection *SubsonicConnection) GetPlayUrl(entity *SubsonicEntity) string query.Set("id", entity.Id) return connection.Host + "/rest/stream" + "?" + query.Encode() } + +// Search uses the Subsonic search3 API to query a server for all songs that have +// ID3 tags that match the query. The query is global, in that it matches in any +// ID3 field. +// https://www.subsonic.org/pages/api.jsp#search3 +func (connection *SubsonicConnection) Search(searchTerm string, artistOffset, albumOffset, songOffset int) (*SubsonicResponse, error) { + query := defaultQuery(connection) + query.Set("query", searchTerm) + query.Set("artistOffset", strconv.Itoa(artistOffset)) + query.Set("albumOffset", strconv.Itoa(albumOffset)) + query.Set("songOffset", strconv.Itoa(songOffset)) + requestUrl := connection.Host + "/rest/search3" + "?" + query.Encode() + res, err := connection.getResponse("Search", requestUrl) + return res, err +} diff --git a/widget_help.go b/widget_help.go index a3d00ae..caccc17 100644 --- a/widget_help.go +++ b/widget_help.go @@ -61,6 +61,9 @@ func (h *HelpWidget) RenderHelp(context string) { case PagePlaylists: rightText = "[::b]Playlists[::-]\n" + tview.Escape(strings.TrimSpace(helpPagePlaylists)) + case PageSearch: + rightText = "[::b]Search[::-]\n" + tview.Escape(strings.TrimSpace(helpSearchPage)) + case PageLog: fallthrough default: diff --git a/widget_menu.go b/widget_menu.go index 1b87deb..17f64cb 100644 --- a/widget_menu.go +++ b/widget_menu.go @@ -26,11 +26,19 @@ type MenuWidget struct { ui *Ui } -var buttonOrder = []string{PageBrowser, PageQueue, PagePlaylists, PageLog} +const ( + PAGE_BROWSER = iota + PAGE_QUEUE + PAGE_PLAYLISTS + PAGE_SEARCH + PAGE_LOG +) + +var buttonOrder = []string{PageBrowser, PageQueue, PagePlaylists, PageSearch, PageLog} func (ui *Ui) createMenuWidget() (m *MenuWidget) { m = &MenuWidget{ - activeButton: buttonOrder[0], + activeButton: buttonOrder[PAGE_BROWSER], buttons: make(map[string]*tview.Button), buttonStyle: tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite),