ditty/gui.go

536 lines
12 KiB
Go

package main
import (
"bytes"
"fmt"
"math"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"github.com/faiface/beep"
"github.com/faiface/beep/speaker"
"github.com/gdamore/tcell"
"github.com/mattn/go-runewidth"
"gitlab.com/tslocum/cview"
)
var (
app *cview.Application
mainbuf *cview.TextView
queuebuf *cview.TextView
topstatusbuf *cview.TextView
bottomstatusbuf *cview.TextView
mainBufferFiles []*libraryEntry
mainBufferCursor = 1 // Position cursor on first entry
mainBufferDirectory string
mainBufferOrigin int
mainBufHeight int
mainBufferAutoFocus string // Entry path to focus after loading display
mainBuffer bytes.Buffer
mainLock = new(sync.Mutex)
queueFiles []*libraryEntry
queueCursor int
queueDirectory string
queueOrigin int
queueHeight int
queueBuffer bytes.Buffer
queueLock = new(sync.Mutex)
queueFocused bool
seekStart, seekEnd int
volumeStart, volumeEnd int
screenWidth, screenHeight int
statusText string
statusBuffer bytes.Buffer
statusLock = new(sync.Mutex)
)
func initTUI() error {
app = cview.NewApplication()
app.EnableMouse()
app.SetInputCapture(inputConfig.Capture)
app.SetAfterResizeFunc(handleResize)
app.SetMouseCapture(handleMouse)
grid := cview.NewGrid().SetRows(-2, -1, 1, 1).SetColumns(-1)
mainbuf = cview.NewTextView().SetDynamicColors(true).SetWrap(true).SetWordWrap(false)
queuebuf = cview.NewTextView().SetDynamicColors(true).SetWrap(true).SetWordWrap(false)
topstatusbuf = cview.NewTextView().SetWrap(false).SetWordWrap(false)
bottomstatusbuf = cview.NewTextView().SetWrap(false).SetWordWrap(false)
mainbuf.SetBorder(true).SetTitleAlign(cview.AlignLeft)
queuebuf.SetBorder(true).SetTitleAlign(cview.AlignLeft).SetTitle(" Queue ")
setTextViewParameters(mainbuf)
setTextViewParameters(queuebuf)
setTextViewParameters(topstatusbuf)
setTextViewParameters(bottomstatusbuf)
grid.AddItem(mainbuf, 0, 0, 1, 1, 0, 0, false)
grid.AddItem(queuebuf, 1, 0, 1, 1, 0, 0, false)
grid.AddItem(topstatusbuf, 2, 0, 1, 1, 0, 0, false)
grid.AddItem(bottomstatusbuf, 3, 0, 1, 1, 0, 0, false)
mainbuf.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (i int, i2 int, i3 int, i4 int) {
mainBufHeight = height
return mainbuf.GetInnerRect()
})
queuebuf.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (i int, i2 int, i3 int, i4 int) {
queueHeight = height
return queuebuf.GetInnerRect()
})
app.SetRoot(grid, true)
return nil
}
func browseFolder(browse string) {
var err error
browse, err = filepath.Abs(browse)
if err != nil {
return
}
if browse == mainBufferDirectory {
mainBufferAutoFocus = ""
return
}
if !strings.HasPrefix(browse, restrictLibrary) {
statusText = "failed to browse folder: permission denied"
go func() {
time.Sleep(5 * time.Second)
statusText = ""
go app.QueueUpdateDraw(updateMain)
}()
go app.QueueUpdateDraw(updateMain)
return
}
placeCursorAtTop := mainBufferCursor == 0
mainBufferFiles = scanFolder(browse)
if !placeCursorAtTop && len(mainBufferFiles) > 0 {
mainBufferCursor = 1
} else {
mainBufferCursor = 0
}
mainBufferOrigin = 0
mainBufferDirectory = browse
if mainBufferAutoFocus != "" {
autoSelectAbs, err := filepath.Abs(mainBufferAutoFocus)
if err == nil && autoSelectAbs != mainBufferDirectory {
autoSelect := -1
var entryPath string
for i, entry := range mainBufferFiles {
if !entry.File.IsDir() {
continue
}
entryPath, err = filepath.Abs(path.Join(mainBufferDirectory, entry.File.Name()))
if err == nil {
if entryPath == autoSelectAbs {
autoSelect = i
break
}
}
}
if autoSelect >= 0 {
mainBufferCursor = autoSelect
mainBufferNewOrigin := (mainBufferCursor - (mainBufHeight - 4)) + ((mainBufHeight - 2) / 2)
if mainBufferNewOrigin <= 0 {
mainBufferNewOrigin = 0
} else if mainBufferNewOrigin > len(mainBufferFiles)-(mainBufHeight-3) {
mainBufferNewOrigin = len(mainBufferFiles) - (mainBufHeight - 3)
}
mainBufferOrigin = mainBufferNewOrigin
mainBufferAutoFocus = ""
go listNext()
return
}
}
mainBufferAutoFocus = ""
}
go app.QueueUpdateDraw(updateMain)
}
func setTextViewParameters(tv *cview.TextView) {
tv.SetTitleColor(tcell.ColorDefault)
tv.SetBorderColor(tcell.ColorDefault)
tv.SetTextColor(tcell.ColorDefault)
tv.SetBackgroundColor(tcell.ColorDefault)
}
func browseParent() {
mainBufferAutoFocus = mainBufferDirectory
go browseFolder(path.Join(mainBufferDirectory, ".."))
}
func updateMain() {
mainLock.Lock()
defer mainLock.Unlock()
mainBuffer.Reset()
var statusMessage string
if statusText != "" {
statusMessage = statusText
} else {
statusMessage = mainBufferDirectory
}
truncated := false
widthRequirement := 4
for {
if runewidth.StringWidth(statusMessage) <= screenWidth-widthRequirement || !strings.ContainsRune(statusMessage, os.PathSeparator) {
break
}
statusMessage = statusMessage[strings.IndexRune(statusMessage, '/')+1:]
truncated = true
widthRequirement = 8
}
if truncated {
mainBuffer.WriteString(".../")
}
mainBuffer.WriteString(statusMessage)
mainbuf.SetTitle(" " + cview.Escape(runewidth.Truncate(mainBuffer.String(), screenWidth-4, "...")) + " ")
mainBuffer.Reset()
l := len(mainBufferFiles) + 1
var printed int
var line string
if mainBufferOrigin == 0 {
writeListItemPrefix(&mainBuffer, !queueFocused, mainBufferCursor, 0)
if mainBufferDirectory == "/" {
line = "./"
} else {
line = "../"
}
lineWidth := runewidth.StringWidth(line)
line = cview.Escape(line)
mainBuffer.WriteString(line)
writeListItemSuffix(&mainBuffer, !queueFocused, mainBufferCursor, 0, 0, l, lineWidth, mainBufHeight-2)
printed++
}
for i, entry := range mainBufferFiles {
if i < mainBufferOrigin-1 || i-mainBufferOrigin-1 > mainBufHeight-1 {
continue
}
if printed > 0 {
mainBuffer.WriteRune('\n')
}
writeListItemPrefix(&mainBuffer, !queueFocused, mainBufferCursor-1, i)
if entry.File.IsDir() {
line = strings.TrimSpace(entry.File.Name()) + "/"
} else {
line = entry.String()
}
lineWidth := runewidth.StringWidth(line)
line = cview.Escape(line)
mainBuffer.WriteString(line)
writeListItemSuffix(&mainBuffer, !queueFocused, mainBufferCursor, printed, i+1, l, lineWidth, mainBufHeight-2)
printed++
if printed == mainBufHeight-2 {
break
}
}
remaining := (mainBufHeight - 2) - printed
for i := 0; i < remaining; i++ {
if printed > 0 {
mainBuffer.WriteRune('\n')
}
writeListItemSuffix(&mainBuffer, !queueFocused, mainBufferCursor, printed, remaining-printed, l, 0, mainBufHeight-2)
printed++
}
mainbuf.SetText(mainBuffer.String())
}
func updateQueue() {
queueLock.Lock()
defer queueLock.Unlock()
queueBuffer.Reset()
l := len(queueFiles)
var printed int
var line string
for i, entry := range queueFiles {
if i < queueOrigin || i-queueOrigin > queueHeight-1 {
continue
}
if printed > 0 {
queueBuffer.WriteRune('\n')
}
writeListItemPrefix(&queueBuffer, queueFocused, queueCursor, i)
line = entry.String()
lineWidth := runewidth.StringWidth(line)
line = cview.Escape(line)
queueBuffer.WriteString(line)
writeListItemSuffix(&queueBuffer, queueFocused, queueCursor, printed, i, l, lineWidth, queueHeight-2)
printed++
if printed == queueHeight-2 {
break
}
}
remaining := (queueHeight - 2) - printed
for i := 0; i < remaining; i++ {
if printed > 0 {
queueBuffer.WriteRune('\n')
}
writeListItemSuffix(&queueBuffer, queueFocused, queueCursor, printed, remaining-printed, l, 0, queueHeight-2)
printed++
}
queuebuf.SetText(queueBuffer.String())
}
func writeListItemPrefix(buffer *bytes.Buffer, focused bool, cursor int, i int) {
if focused {
if i == cursor {
buffer.WriteString("[::r]")
}
} else {
if i == cursor {
buffer.WriteString("[::bu]")
}
}
}
func writeListItemSuffix(buffer *bytes.Buffer, focused bool, cursor int, printed int, i int, count int, lineWidth int, height int) {
if !focused && i == cursor {
buffer.WriteString("[-:-:-]")
}
for i := lineWidth; i < screenWidth-3; i++ {
buffer.WriteRune(' ')
}
if focused && i == cursor {
buffer.WriteString("[-:-:-]")
}
scrollBlockPos := int(float64(height-1) * (float64(cursor) / float64(count-1)))
if focused {
if printed == scrollBlockPos {
buffer.WriteString("[::r]")
buffer.WriteRune(' ')
buffer.WriteString("[-:-:-]")
} else {
buffer.WriteRune('▒')
}
} else {
if printed == scrollBlockPos {
buffer.WriteRune('▓')
} else {
buffer.WriteRune('░')
}
}
}
func updateLists() {
updateMain()
updateQueue()
}
func updateStatus() {
statusLock.Lock()
defer statusLock.Unlock()
var (
sampleRate beep.SampleRate
p time.Duration
l time.Duration
v float64
paused bool
silent bool
progressFormatted string
durationFormatted string
)
if playingStreamer != nil && volume != nil && ctrl != nil {
audioLock.Lock()
speaker.Lock()
silent = volume.Silent
paused = ctrl.Paused
sampleRate = playingFormat.SampleRate
p = playingFormat.SampleRate.D(playingStreamer.Position()).Truncate(time.Second)
l = playingFormat.SampleRate.D(playingStreamer.Len()).Truncate(time.Second)
v = volume.Volume
speaker.Unlock()
audioLock.Unlock()
progressFormatted = formatDuration(p)
durationFormatted = formatDuration(l)
statusBuffer.Reset()
if paused {
statusBuffer.WriteString("Paused ")
}
statusBuffer.WriteString(fmt.Sprintf(" %dHz %s", sampleRate.N(time.Second), fileFormat(playingFileName)))
topStatusExtra := statusBuffer.String()
statusBuffer.Reset()
topStatusMaxLength := screenWidth - 2
printExtra := topStatusMaxLength >= (len(topStatusExtra)*2)+1
if printExtra {
topStatusMaxLength -= len(topStatusExtra)
}
statusBuffer.WriteRune(' ')
var trackInfo string
if playingFileInfo != "" {
trackInfo = runewidth.Truncate(playingFileInfo, topStatusMaxLength, "...")
} else {
trackInfo = runewidth.Truncate(playingFileName, topStatusMaxLength, "...")
}
statusBuffer.WriteString(trackInfo)
if printExtra {
padding := topStatusMaxLength - runewidth.StringWidth(trackInfo)
for i := 0; i < padding; i++ {
statusBuffer.WriteRune(' ')
}
statusBuffer.WriteString(topStatusExtra)
}
topstatusbuf.SetText(statusBuffer.String())
} else {
v = startingVolumeLevel
silent = startingVolumeSilent
progressFormatted = "--:--"
durationFormatted = "--:--"
statusBuffer.Reset()
trackInfo := fmt.Sprintf("ditty v%s", version)
topStatusMaxLength := screenWidth - 2
padding := (topStatusMaxLength - runewidth.StringWidth(trackInfo)) + 1
for i := 0; i < padding; i++ {
statusBuffer.WriteRune(' ')
}
statusBuffer.WriteString(trackInfo)
topstatusbuf.SetText(statusBuffer.String())
}
statusBuffer.Reset()
if silent {
statusBuffer.WriteString("Mut ")
for i := -7.5; i < 0.0; i += 0.5 {
statusBuffer.WriteRune(' ')
}
} else {
statusBuffer.WriteString("Vol ")
for i := -7.5; i < v-0.5; i += 0.5 {
statusBuffer.WriteRune(tcell.RuneHLine)
}
statusBuffer.WriteRune('▷')
for i := v; i < 0; i += 0.5 {
statusBuffer.WriteRune(' ')
}
}
bottomStatus := fmt.Sprintf("%s %s", durationFormatted, statusBuffer.String())
statusBuffer.Reset()
var progressIndicator string
if paused {
progressIndicator = "||"
} else {
progressIndicator = "▷"
}
padding := screenWidth - runewidth.StringWidth(bottomStatus) - len(formatDuration(p)) - runewidth.StringWidth(progressIndicator) - 3
position := int(float64(padding) * (float64(p) / float64(l)))
if position > padding-1 {
position = padding - 1
}
if paused && position > 0 {
position--
}
for i := 0; i < padding; i++ {
if i == position {
statusBuffer.WriteString(progressIndicator)
} else {
statusBuffer.WriteRune(tcell.RuneHLine)
}
}
seekStart = len(formatDuration(p)) + 2
seekEnd = seekStart + padding - 1
volumeStart = seekEnd + len(formatDuration(l)) + 4
volumeEnd = screenWidth - 2
bottomstatusbuf.SetText(" " + progressFormatted + " " + statusBuffer.String() + " " + bottomStatus)
statusBuffer.Reset()
}
func formatDuration(d time.Duration) string {
minutes := int(math.Floor(float64(d) / float64(time.Minute)))
seconds := int((d % time.Minute) / time.Second)
return fmt.Sprintf("%02d:%02d", minutes, seconds)
}
func handleResize(width int, height int) {
screenWidth, screenHeight = width, height
updateMain()
updateQueue()
updateStatus()
}