gomu/popup.go
2021-04-01 16:09:09 +08:00

919 lines
20 KiB
Go

// Copyright (C) 2020 Raziman
package main
import (
"fmt"
"regexp"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/sahilm/fuzzy"
"github.com/ztrue/tracerr"
"github.com/issadarkthing/gomu/invidious"
"github.com/issadarkthing/gomu/lyric"
"github.com/issadarkthing/gomu/player"
)
// this is used to make the popup unique
// this mitigates the issue of closing all popups when timeout ends
var (
popupCounter = 0
)
// Stack Simple stack data structure
type Stack struct {
popups []tview.Primitive
}
// Push popup to the stack and focus
func (s *Stack) push(p tview.Primitive) {
s.popups = append(s.popups, p)
gomu.app.SetFocus(p)
}
// Show item on the top of the stack
func (s *Stack) peekTop() tview.Primitive {
if len(s.popups)-1 < 0 {
return nil
}
return s.popups[len(s.popups)-1]
}
// Remove popup from the stack and focus previous popup
func (s *Stack) pop() tview.Primitive {
if len(s.popups) == 0 {
return nil
}
last := s.popups[len(s.popups)-1]
s.popups[len(s.popups)-1] = nil // avoid memory leak
res := s.popups[:len(s.popups)-1]
s.popups = res
// focus previous popup
if len(s.popups) > 0 {
gomu.app.SetFocus(s.popups[len(s.popups)-1])
} else {
// focus the panel if no popup left
gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive))
}
return last
}
// Gets popup timeout from config file
func getPopupTimeout() time.Duration {
dur := gomu.anko.GetString("General.popup_timeout")
m, err := time.ParseDuration(dur)
if err != nil {
logError(err)
return time.Second * 5
}
return m
}
// Simple confirmation popup. Accepts callback
func confirmationPopup(
text string,
handler func(buttonIndex int, buttonLabel string),
) {
modal := tview.NewModal().
SetText(text).
SetBackgroundColor(gomu.colors.popup).
AddButtons([]string{"no", "yes"}).
SetButtonBackgroundColor(gomu.colors.popup).
SetButtonTextColor(gomu.colors.accent).
SetDoneFunc(func(indx int, label string) {
gomu.pages.RemovePage("confirmation-popup")
gomu.popups.pop()
handler(indx, label)
})
modal.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
switch e.Rune() {
case 'h':
return tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)
case 'j':
return tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)
case 'k':
return tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)
case 'l':
return tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)
}
return e
})
gomu.pages.
AddPage("confirmation-popup", center(modal, 40, 10), true, true)
gomu.popups.push(modal)
}
// Confirmation popup for delete playlist. Accepts callback
func confirmDeleteAllPopup(selPlaylist *tview.TreeNode) (err error) {
popupID := "confirm-deleteall-input-popup"
input := newInputPopup(popupID, "Are you sure to delete the folder and all files under it?", "Type DELETE to Confirm: ", "")
input.SetDoneFunc(func(key tcell.Key) {
switch key {
case tcell.KeyEnter:
confirmationText := input.GetText()
gomu.pages.RemovePage(popupID)
gomu.popups.pop()
if confirmationText == "DELETE" {
err = gomu.playlist.deletePlaylist(selPlaylist.GetReference().(*AudioFile))
if err != nil {
errorPopup(err)
}
}
case tcell.KeyEscape:
gomu.pages.RemovePage(popupID)
gomu.popups.pop()
}
gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive))
})
return tracerr.Wrap(err)
}
func center(p tview.Primitive, width, height int) tview.Primitive {
return tview.NewFlex().
AddItem(nil, 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(nil, 0, 1, false).
AddItem(p, height, 1, false).
AddItem(nil, 0, 1, false), width, 1, false).
AddItem(nil, 0, 1, false)
}
func topRight(p tview.Primitive, width, height int) tview.Primitive {
return tview.NewFlex().
AddItem(nil, 0, 23, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(nil, 0, 1, false).
AddItem(p, height, 1, false).
AddItem(nil, 0, 15, false), width, 1, false).
AddItem(nil, 0, 1, false)
}
// Width and height parameter are optional, provide 0 for both to use deault values.
// It defaults to 70 and 7 respectively.
func timedPopup(
title string, desc string, timeout time.Duration, width, height int,
) {
if width == 0 && height == 0 {
width = 70
height = 7
}
textView := tview.NewTextView().
SetText(desc).
SetTextColor(gomu.colors.accent)
textView.SetTextAlign(tview.AlignCenter).SetBackgroundColor(gomu.colors.popup)
box := tview.NewFrame(textView).SetBorders(1, 0, 0, 0, 0, 0)
box.SetTitle(title).SetBorder(true).SetBackgroundColor(gomu.colors.popup)
popupID := fmt.Sprintf("%s %d", "timeout-popup", popupCounter)
popupCounter++
gomu.pages.AddPage(popupID, topRight(box, width, height), true, true)
// gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive))
resetFocus := func() {
// timed popup shouldn't get focused
// this here check if another popup exists and focus that instead of panel
// if none continue focus panel
topPopup := gomu.popups.peekTop()
if topPopup == nil {
gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive))
} else {
gomu.app.SetFocus(topPopup)
}
}
resetFocus()
go func() {
time.Sleep(timeout)
gomu.app.QueueUpdateDraw(func() {
gomu.pages.RemovePage(popupID)
resetFocus()
})
}()
}
// Wrapper for timed popup
func defaultTimedPopup(title, description string) {
timedPopup(title, description, getPopupTimeout(), 0, 0)
}
// Shows popup for the current volume
func volumePopup(volume float64) {
currVol := player.VolToHuman(volume)
maxVol := 100
// max progress bar length
maxLength := 50
progressBar := progresStr(currVol, maxVol, maxLength, "█", "-")
progress := fmt.Sprintf("\n%d |%s| %d",
currVol,
progressBar,
maxVol,
)
defaultTimedPopup(" Volume ", progress)
}
// Shows a list of keybind. The upper list is the local keybindings to specific
// panel only. The lower list is the global keybindings
func helpPopup(panel Panel) {
helpText := panel.help()
genHelp := []string{
" ",
"tab change panel",
"space toggle play/pause",
"esc close popup",
"n skip",
"q quit",
"+ volume up",
"- volume down",
"f/F forward 10/60 seconds",
"b/B rewind 10/60 seconds",
"? toggle help",
"m open repl",
"T switch lyrics",
"c show colors",
}
list := tview.NewList().ShowSecondaryText(false)
list.SetBackgroundColor(gomu.colors.popup).SetTitle(" Help ").
SetBorder(true)
list.SetSelectedBackgroundColor(gomu.colors.popup).
SetSelectedTextColor(gomu.colors.accent)
for _, v := range append(helpText, genHelp...) {
list.AddItem(" "+v, "", 0, nil)
}
prev := func() {
currIndex := list.GetCurrentItem()
list.SetCurrentItem(currIndex - 1)
}
next := func() {
currIndex := list.GetCurrentItem()
idx := currIndex + 1
if currIndex == list.GetItemCount()-1 {
idx = 0
}
list.SetCurrentItem(idx)
}
list.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
switch e.Rune() {
case 'j':
next()
case 'k':
prev()
}
switch e.Key() {
case tcell.KeyEsc:
gomu.pages.RemovePage("help-page")
gomu.popups.pop()
case tcell.KeyEnter:
gomu.pages.RemovePage("help-page")
gomu.popups.pop()
}
return nil
})
gomu.pages.AddPage("help-page", center(list, 50, 32), true, true)
gomu.popups.push(list)
}
// Input popup. Takes video url from youtube to be downloaded
func downloadMusicPopup(selPlaylist *tview.TreeNode) {
re := regexp.MustCompile(`^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$`)
popupID := "download-input-popup"
input := newInputPopup(popupID, " Download ", "Url: ", "")
input.SetDoneFunc(func(key tcell.Key) {
switch key {
case tcell.KeyEnter:
url := input.GetText()
// check if valid youtube url was given
if re.MatchString(url) {
go func() {
if err := ytdl(url, selPlaylist); err != nil {
errorPopup(err)
}
}()
} else {
defaultTimedPopup("Invalid url", "Invalid youtube url was given")
}
gomu.pages.RemovePage("download-input-popup")
gomu.popups.pop()
case tcell.KeyEscape:
gomu.pages.RemovePage("download-input-popup")
gomu.popups.pop()
}
gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive))
})
}
// Input popup that takes the name of directory to be created
func createPlaylistPopup() {
popupID := "mkdir-input-popup"
input := newInputPopup(popupID, " New Playlist ", "Enter playlist name: ", "")
input.SetDoneFunc(func(key tcell.Key) {
switch key {
case tcell.KeyEnter:
playListName := input.GetText()
err := gomu.playlist.createPlaylist(playListName)
if err != nil {
logError(err)
}
gomu.pages.RemovePage("mkdir-input-popup")
gomu.popups.pop()
case tcell.KeyEsc:
gomu.pages.RemovePage("mkdir-input-popup")
gomu.popups.pop()
}
})
}
func exitConfirmation(args Args) {
confirmationPopup("Are you sure to exit?", func(_ int, label string) {
if label == "no" || label == "" {
return
}
err := gomu.quit(args)
if err != nil {
logError(err)
}
})
}
func searchPopup(title string, stringsToMatch []string, handler func(selected string)) {
list := tview.NewList().ShowSecondaryText(false)
list.SetSelectedBackgroundColor(gomu.colors.accent)
list.SetHighlightFullLine(true)
list.SetBackgroundColor(gomu.colors.popup)
for _, v := range stringsToMatch {
list.AddItem(v, v, 0, nil)
}
input := tview.NewInputField()
input.SetFieldBackgroundColor(gomu.colors.popup).
SetLabel("[red]>[-] ")
input.SetChangedFunc(func(text string) {
list.Clear()
// list all item if input is empty
if len(text) == 0 {
for _, v := range stringsToMatch {
list.AddItem(v, v, 0, nil)
}
return
}
pattern := input.GetText()
matches := fuzzy.Find(pattern, stringsToMatch)
const highlight = "[red]%c[-]"
for _, match := range matches {
var text strings.Builder
matchrune := []rune(match.Str)
matchruneIndexes := match.MatchedIndexes
for i := 0; i < len(match.MatchedIndexes); i++ {
matchruneIndexes[i] =
utf8.RuneCountInString(match.Str[0:match.MatchedIndexes[i]])
}
for i := 0; i < len(matchrune); i++ {
if contains(i, matchruneIndexes) {
textwithcolor := fmt.Sprintf(highlight, matchrune[i])
for _, j := range textwithcolor {
text.WriteRune(j)
}
} else {
text.WriteRune(matchrune[i])
}
}
list.AddItem(text.String(), match.Str, 0, nil)
}
})
input.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
switch e.Key() {
case tcell.KeyCtrlN, tcell.KeyDown, tcell.KeyCtrlJ:
currIndx := list.GetCurrentItem()
// if last index
if currIndx == list.GetItemCount()-1 {
currIndx = 0
} else {
currIndx++
}
list.SetCurrentItem(currIndx)
case tcell.KeyCtrlP, tcell.KeyUp, tcell.KeyCtrlK:
currIndx := list.GetCurrentItem()
if currIndx == 0 {
currIndx = list.GetItemCount() - 1
} else {
currIndx--
}
list.SetCurrentItem(currIndx)
case tcell.KeyEnter:
if list.GetItemCount() > 0 {
_, selected := list.GetItemText(list.GetCurrentItem())
gomu.pages.RemovePage("search-input-popup")
gomu.popups.pop()
handler(selected)
}
case tcell.KeyEscape:
gomu.pages.RemovePage("search-input-popup")
gomu.popups.pop()
}
return e
})
popup := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(input, 2, 1, true).
AddItem(list, 0, 1, false)
popupBox := tview.NewBox().SetBorder(true).
SetBackgroundColor(gomu.colors.popup).
SetTitle(" "+title+" ").
SetBorderPadding(1, 1, 2, 2)
popup.Box = popupBox
// this is to fix the left border of search popup
popupFrame := tview.NewFrame(popup)
gomu.pages.AddPage("search-input-popup", center(popupFrame, 70, 40), true, true)
gomu.popups.push(popup)
}
// Creates new popup widget with default settings
func newInputPopup(popupID, title, label string, text string) *tview.InputField {
inputField := tview.NewInputField().
SetLabel(label).
SetFieldWidth(0).
SetAcceptanceFunc(tview.InputFieldMaxLength(50)).
SetFieldBackgroundColor(gomu.colors.popup).
SetFieldTextColor(gomu.colors.foreground)
inputField.SetBackgroundColor(gomu.colors.popup).
SetTitle(title).
SetBorder(true).
SetBorderPadding(1, 0, 2, 2)
inputField.SetText(text)
gomu.pages.
AddPage(popupID, center(inputField, 60, 5), true, true)
gomu.popups.push(inputField)
return inputField
}
func renamePopup(node *AudioFile) {
popupID := "rename-input-popup"
input := newInputPopup(popupID, " Rename ", "New name: ", node.name)
input.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
switch e.Key() {
case tcell.KeyEnter:
newName := input.GetText()
if newName == "" {
return e
}
err := gomu.playlist.rename(newName)
if err != nil {
errorPopup(err)
}
gomu.pages.RemovePage(popupID)
gomu.popups.pop()
gomu.playlist.refresh()
gomu.setFocusPanel(gomu.playlist)
gomu.prevPanel = gomu.playlist
err = gomu.playlist.refreshByNode(node, newName)
if err != nil {
errorPopup(err)
}
case tcell.KeyEsc:
gomu.pages.RemovePage(popupID)
gomu.popups.pop()
}
return e
})
}
// Show error popup with error is logged. Prefer this when its related with user
// interaction. Otherwise, use logError.
func errorPopup(message error) {
defaultTimedPopup(" Error ", tracerr.Unwrap(message).Error())
logError(message)
}
// Show debug popup and log debug info. Mainly used when scripting.
func debugPopup(message interface{}) {
m := fmt.Sprintf("%v", message)
defaultTimedPopup(" Debug ", m)
logDebug(m)
}
// Show info popup and does not log anything. Prefer this when making simple
// popup.
func infoPopup(message string) {
defaultTimedPopup(" Info ", message)
}
func inputPopup(prompt, placeholder string, handler func(string)) {
popupID := "general-input-popup"
input := newInputPopup(popupID, "", prompt+": ", "")
input.SetText(placeholder)
input.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
switch e.Key() {
case tcell.KeyEnter:
output := input.GetText()
if output == "" {
return e
}
handler(output)
gomu.pages.RemovePage(popupID)
gomu.popups.pop()
case tcell.KeyEsc:
gomu.pages.RemovePage(popupID)
gomu.popups.pop()
}
return e
})
}
func replPopup() {
popupID := "repl-input-popup"
prompt := "> "
textview := tview.NewTextView()
input := tview.NewInputField().
SetFieldBackgroundColor(gomu.colors.popup).
SetLabel(prompt)
// to store input history
history := []string{}
upCount := 0
gomu.anko.DefineGlobal("println", func(x ...interface{}) {
fmt.Fprintln(textview, x...)
})
gomu.anko.DefineGlobal("print", func(x ...interface{}) {
fmt.Fprint(textview, x...)
})
gomu.anko.DefineGlobal("printf", func(format string, x ...interface{}) {
fmt.Fprintf(textview, format, x...)
})
input.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyUp:
if upCount < len(history) {
input.SetText(history[upCount])
upCount++
}
case tcell.KeyDown:
if upCount > 0 {
if upCount == len(history) {
upCount -= 2
} else {
upCount--
}
input.SetText(history[upCount])
} else if upCount == 0 {
input.SetText("")
}
case tcell.KeyCtrlL:
textview.SetText("")
case tcell.KeyEsc:
gomu.pages.RemovePage(popupID)
gomu.popups.pop()
return nil
case tcell.KeyEnter:
text := input.GetText()
// most recent is placed the most front
history = append([]string{text}, history...)
upCount = 0
input.SetText("")
defer func() {
if err := recover(); err != nil {
fmt.Fprintf(textview, "%s%s\n%v\n\n", prompt, text, err)
}
}()
fmt.Fprintf(textview, "%s%s\n", prompt, text)
res, err := gomu.anko.Execute(text)
if err != nil {
fmt.Fprintf(textview, "%v\n\n", err)
return nil
}
fmt.Fprintf(textview, "%v\n\n", res)
}
return event
})
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(input, 3, 1, true).
AddItem(textview, 0, 1, false)
flexBox := tview.NewBox().
SetBackgroundColor(gomu.colors.popup).
SetBorder(true).
SetBorderPadding(1, 1, 2, 2).
SetTitle(" REPL ")
flex.Box = flexBox
gomu.pages.AddPage(popupID, center(flex, 90, 30), true, true)
gomu.popups.push(flex)
}
func ytSearchPopup() {
popupID := "youtube-search-input-popup"
input := newInputPopup(popupID, " Youtube Search ", "search: ", "")
instance := gomu.anko.GetString("General.invidious_instance")
inv := invidious.Invidious{Domain: instance}
var mutex sync.Mutex
prefixMap := make(map[string][]string)
// quick hack to change the autocomplete text color
tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack
input.SetAutocompleteFunc(func(currentText string) []string {
// Ignore empty text.
prefix := strings.TrimSpace(strings.ToLower(currentText))
if prefix == "" {
return nil
}
mutex.Lock()
defer mutex.Unlock()
entries, ok := prefixMap[prefix]
if ok {
return entries
}
go func() {
suggestions, err := inv.GetSuggestions(currentText)
if err != nil {
logError(err)
return
}
mutex.Lock()
prefixMap[prefix] = suggestions
mutex.Unlock()
input.Autocomplete()
gomu.app.Draw()
}()
return nil
})
input.SetDoneFunc(func(key tcell.Key) {
switch key {
case tcell.KeyEnter:
search := input.GetText()
defaultTimedPopup(" Youtube Search ", "Searching for "+search)
gomu.pages.RemovePage(popupID)
gomu.popups.pop()
go func() {
results, err := inv.GetSearchQuery(search)
if err != nil {
logError(err)
defaultTimedPopup(" Error ", err.Error())
return
}
titles := []string{}
urls := make(map[string]string)
for _, result := range results {
duration, err := time.ParseDuration(fmt.Sprintf("%ds", result.LengthSeconds))
if err != nil {
logError(err)
return
}
durationText := fmt.Sprintf("[ %s ] ", fmtDuration(duration))
title := durationText + result.Title
urls[title] = `https://www.youtube.com/watch?v=` + result.VideoId
titles = append(titles, title)
}
searchPopup("Youtube Videos", titles, func(title string) {
audioFile := gomu.playlist.getCurrentFile()
var dir *tview.TreeNode
if audioFile.isAudioFile {
dir = audioFile.parent
} else {
dir = audioFile.node
}
go func() {
url := urls[title]
if err := ytdl(url, dir); err != nil {
errorPopup(err)
}
gomu.playlist.refresh()
}()
gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive))
})
gomu.app.Draw()
}()
case tcell.KeyEscape:
gomu.pages.RemovePage(popupID)
gomu.popups.pop()
gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive))
}
})
}
func lyricPopup(lang string, audioFile *AudioFile, wg *sync.WaitGroup) error {
var titles []string
// below we chose languages with GetLyrics interfaces
getLyric := lyricLang(lang)
results, err := getLyric.GetLyricOptions(audioFile.name)
if err != nil {
return tracerr.Wrap(err)
}
for _, v := range results {
titles = append(titles, v.TitleForPopup)
}
searchPopup(" Lyrics ", titles, func(selected string) {
if selected == "" {
return
}
go func() {
defer wg.Done()
var selectedIndex int
for i, v := range results {
if v.TitleForPopup == selected {
selectedIndex = i
break
}
}
lyricContent, err := getLyric.GetLyric(results[selectedIndex])
if err != nil {
errorPopup(err)
gomu.app.Draw()
return
}
var lyric lyric.Lyric
err = lyric.NewFromLRC(lyricContent)
if err != nil {
errorPopup(err)
gomu.app.Draw()
return
}
lyric.LangExt = lang
err = embedLyric(audioFile.path, &lyric, false)
if err != nil {
errorPopup(err)
gomu.app.Draw()
return
}
infoPopup(lang + " lyric added successfully")
gomu.app.Draw()
}()
})
gomu.app.Draw()
return nil
}
func lyricLang(lang string) lyric.GetLyrics {
switch lang {
case "en":
return lyric.GetLyricEn{}
case "zh-CN":
return lyric.GetLyricCn{}
default:
return lyric.GetLyricEn{}
}
}