diff --git a/cmd/ui/slideshow.go b/cmd/ui/slideshow.go new file mode 100644 index 0000000..bf52a25 --- /dev/null +++ b/cmd/ui/slideshow.go @@ -0,0 +1,118 @@ +package ui + +import ( + "context" + "io" + "log" + "os/exec" + "time" + + "github.com/eiannone/keyboard" +) + +const ( + CMD_FIM = "/usr/local/bin/pf-fim.sh" + SLIDESHOW_INTERVAL = 300 * time.Second +) + +func Slideshow() { + // fim placeholder so we can operate on it when a exit slideshow is received + var fim *exec.Cmd = nil + + // Run slideshow + fim = exec.Command(CMD_FIM) + + // Setup stdin for fim to control slideshow + stdin, err := fim.StdinPipe() + if err != nil { + log.Fatalf("Error getting fim stdin : %s", err) + } + + // Advance slideshow every interval as defined in const() + ticker := time.NewTicker(SLIDESHOW_INTERVAL) + stop_ticker := make(chan struct{}) + go func() { + for { + select { + case <-ticker.C: + _, err = io.WriteString(stdin, "n") + if err != nil { + log.Fatalf("Error advancing slides : %s", err) + } + case <-stop_ticker: + ticker.Stop() + stdin.Close() + return + } + } + }() + + // Start watching for key strokes and echo them back to stdout + keysEvents, err := keyboard.GetKeys(10) + if err != nil { + log.Fatalf("Error setting up keyboard listener : %s", err) + } + // NOT deferring keyboard.close as we do that by hand further down when fim exits ahead of showing the config UI + + // Goroutine for tracking which keys are pressed and controlling fim if appropriate + keyboardCtx, keyboardCancel := context.WithCancel(context.Background()) + go func(keyboardCtx context.Context) { + for { + select { + case <-keyboardCtx.Done(): + return + case event := <-keysEvents: + if event.Err != nil { + log.Fatalf("Error listening to key events : %s", err) + } + log.Printf("You pressed: key %X\n", event.Key) + + // Keys for fim event management (previous/next in particular) + fimKey := "" + if event.Key == keyboard.KeyArrowLeft || event.Key == keyboard.KeyArrowDown { + fimKey = "p" + } + if event.Key == keyboard.KeyArrowRight || event.Key == keyboard.KeyArrowUp { + fimKey = "n" + } + + // Exit fim and move to the config UI + if event.Key == keyboard.KeyEsc || event.Key == keyboard.KeyEnter || event.Key == keyboard.KeySpace { + if fim != nil { // Just in case someone lays on exit key or similar during startup + if err := fim.Process.Kill(); err != nil { + log.Fatalf("failed to kill fim : %s", err) + } + } + break + } + + // Control fim if we received a valid key for next/previous slide + if fimKey != "" { + log.Printf("Sending key to fim : %s", fimKey) + _, err = io.WriteString(stdin, fimKey) + if err != nil { + log.Fatalf("Error controlling fim : %s", err) + } + ticker.Reset(SLIDESHOW_INTERVAL) + } + } + } + }(keyboardCtx) + + // Run fim + // if err := fim.Run(); err != nil { + // // Unwrap the error a bit so we can find out if a signal killed fim or something else + // // An exit code of -1 means the program didn't exit in time or was terminated by a signal (per the docs) + // if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() != -1 { + // log.Fatalf("Error running fim : %s", err) + // } + // } + + // Stop fim slideshow advancing go routine + close(stop_ticker) + + // Stop listening to keyboard events + keyboard.Close() + keyboardCancel() + +} diff --git a/cmd/ui/ui.go b/cmd/ui/ui.go index 38c6a0c..03d93fa 100644 --- a/cmd/ui/ui.go +++ b/cmd/ui/ui.go @@ -2,122 +2,37 @@ package main import ( "fmt" - "io" + "io/ioutil" "log" "math" "net" "os/exec" + "strconv" "strings" - "time" - "github.com/eiannone/keyboard" "github.com/gdamore/tcell" "github.com/guillermo/go.procmeminfo" "github.com/rivo/tview" ) const ( - CMD_FINDMNT = "/usr/bin/findmnt" - CMD_FIM = "/usr/local/bin/pf-fim.sh" - SLIDESHOW_INTERVAL = 300 * time.Second + CMD_SYSTEMCTL = "/usr/bin/systemctl" + CMD_FINDMNT = "/usr/bin/findmnt" + CMD_VCGENCMD = "/opt/vc/bin/vcgencmd" + FILE_CPU_TEMP = "/sys/class/thermal/thermal_zone0/temp" +) + +const ( + PAGE_MAIN_UI = "PAGE_MAIN_UI" + PAGE_EXIT = "PAGE_EXIT" + PAGE_REBOOT = "PAGE_REBOOT" + PAGE_POWEROFF = "PAGE_POWEROFF" ) func main() { - // fim placeholder so we can operate on it when a exit slideshow is received - var fim *exec.Cmd = nil - - // Run slideshow - fim = exec.Command(CMD_FIM) - - // Setup stdin for fim to control slideshow - stdin, err := fim.StdinPipe() - if err != nil { - log.Fatalf("Error getting fim stdin : %s", err) - } - - // Advance slideshow every interval as defined in const() - ticker := time.NewTicker(SLIDESHOW_INTERVAL) - stop_ticker := make(chan struct{}) - go func() { - for { - select { - case <-ticker.C: - _, err = io.WriteString(stdin, "n") - if err != nil { - log.Fatalf("Error advancing slides : %s", err) - } - case <-stop_ticker: - ticker.Stop() - stdin.Close() - return - } - } - }() - - // Start watching for key strokes and echo them back to stdout - keysEvents, err := keyboard.GetKeys(10) - if err != nil { - panic(err) - } - defer func() { - _ = keyboard.Close() - }() - - // Goroutine for tracking which keys are pressed and controlling fim if appropriate - go func() { - for { - event := <-keysEvents - if event.Err != nil { - panic(event.Err) - } - log.Printf("You pressed: key %X\n", event.Key) - - // Keys for fim event management (previous/next in particular) - fimKey := "" - if event.Key == keyboard.KeyArrowLeft || event.Key == keyboard.KeyArrowDown { - fimKey = "p" - } - if event.Key == keyboard.KeyArrowRight || event.Key == keyboard.KeyArrowUp { - fimKey = "n" - } - - // Exit fim and move to the config UI - if event.Key == keyboard.KeyEsc || event.Key == keyboard.KeyEnter || event.Key == keyboard.KeySpace { - if fim != nil { // Just in case someone lays on exit key or similar during startup - if err := fim.Process.Kill(); err != nil { - log.Fatalf("failed to kill fim : %s", err) - } - } - break - } - - // Control fim if we received a valid key for next/previous slide - if fimKey != "" { - log.Printf("Sending key to fim : %s", fimKey) - _, err = io.WriteString(stdin, fimKey) - if err != nil { - log.Fatalf("Error controlling fim : %s", err) - } - ticker.Reset(SLIDESHOW_INTERVAL) - } - } - }() - - // Run fim - // if err := fim.Run(); err != nil { - // // Unwrap the error a bit so we can find out if a signal killed fim or something else - // // An exit code of -1 means the program didn't exit in time or was terminated by a signal (per the docs) - // if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() != -1 { - // log.Fatalf("Error running fim : %s", err) - // } - // } - - // Stop fim slideshow advancing go routine - close(stop_ticker) - // Memory info for status panel meminfo := &procmeminfo.MemInfo{} - err = meminfo.Update() + err := meminfo.Update() if err != nil { log.Printf("Error getting memory info : %s", err) } @@ -135,6 +50,19 @@ func main() { "-t", "ext4,exfat,vfat,btrfs,zfs,xfs").Output() filesystems := strings.Split(strings.Trim(string(findmntOut), "\n"), "\n") + // GPU Temp + vcgencmdOut, _ := exec.Command(CMD_VCGENCMD, "measure_temp").Output() + gpuTemp := strings.Split(strings.Trim(string(vcgencmdOut), "\n"), "=")[1] + + // CPU Temp + cpuTempFileContents, err := ioutil.ReadFile(FILE_CPU_TEMP) + if err != nil { + log.Fatalf("Error reading file %s : %s", FILE_CPU_TEMP, err) + } + cpuTempStr := strings.Trim(string(cpuTempFileContents), "\n") + cpuTempInt, _ := strconv.Atoi(cpuTempStr) + cpuTemp := fmt.Sprintf("%.2f'C", float64(cpuTempInt)/1000.0) + // Run config UI when slideshow stops app := tview.NewApplication() @@ -155,9 +83,7 @@ func main() { // Footer fields (Left Column) exitButton := tview.NewButton("Exit"). - SetSelectedFunc(func() { - app.Stop() - }) + SetBackgroundColorActivated(tcell.ColorGray) exitButton.SetLabelColor(tcell.ColorBlack). SetBorder(true). SetBorderColor(tcell.ColorBlack). @@ -165,9 +91,7 @@ func main() { SetRect(0, 0, 22, 3) rebootButton := tview.NewButton("Reboot"). - SetSelectedFunc(func() { - app.Stop() - }) + SetBackgroundColorActivated(tcell.ColorGray) rebootButton.SetLabelColor(tcell.ColorBlack). SetBorder(true). SetBorderColor(tcell.ColorBlack). @@ -175,9 +99,7 @@ func main() { SetRect(0, 0, 22, 3) powerOffButton := tview.NewButton("Power Off"). - SetSelectedFunc(func() { - app.Stop() - }) + SetBackgroundColorActivated(tcell.ColorGray) powerOffButton.SetLabelColor(tcell.ColorBlack). SetBorder(true). SetBorderColor(tcell.ColorBlack). @@ -189,21 +111,70 @@ func main() { footer.AddItem(exitButton, 0, 1, false). AddItem(rebootButton, 0, 1, false). AddItem(powerOffButton, 0, 1, false) - // Setup menu - menu := tview.NewFlex(). + menu := tview.NewList() + menu.SetBorder(true). + SetTitle("Menu"). + SetTitleColor(tcell.ColorAqua) + menu.AddItem("Select Albums", "", '1', nil) + menu.AddItem("Configure WiFi", "", '2', nil) + + // Setup base var for main column so the menu setup is easier to manage + main := tview.NewFlex(). SetDirection(tview.FlexRow) - menu.SetTitle("Menu"). + + // Setup main panel (Center column) + main.SetTitle(""). SetBorder(true). SetTitleColor(tcell.ColorAqua) - // Setup main panel (Center column) - main := tview.NewFlex(). - SetDirection(tview.FlexRow) - main.SetTitle(""). - SetBorder(true) + // WiFi Config Form + wifiConfigForm := tview.NewForm() + wifiConfigForm.AddInputField("Access Point", "", 0, nil, nil) + wifiConfigForm.AddPasswordField("Password", "", 0, '*', nil) + wifiConfigForm.AddButton("Apply", nil) + wifiConfigForm.AddButton("Cancel", func() { + main.Clear() + app.SetFocus(menu) + }) + + // Select Albums Form + selectAlbumsForm := tview.NewForm() + selectAlbumsForm.AddCheckbox("Album 1", false, nil) + selectAlbumsForm.AddCheckbox("Album 2", false, nil) + selectAlbumsForm.AddButton("Apply", nil) + selectAlbumsForm.AddButton("Cancel", func() { + main.Clear() + app.SetFocus(menu) + }) + + // Setup menu selection handler + menu.SetSelectedFunc(func(index int, title string, desc string, shortcut rune) { + if title == "Select Albums" { + main.SetTitle("Select Albums") + main.Clear() + main.AddItem(selectAlbumsForm, 0, 1, true) + app.SetFocus(selectAlbumsForm) + } + if title == "Configure WiFi" { + main.SetTitle("Configure WiFi") + main.Clear() + main.AddItem(wifiConfigForm, 0, 1, true) + app.SetFocus(wifiConfigForm) + } + }) // Side bar fields + sideBarCPUTempTitle := tview.NewTextView(). + SetText("CPU Temperature"). + SetTextColor(tcell.ColorYellow) + sideBarCPUTemp := tview.NewTextView(). + SetText(fmt.Sprintf(" %s", cpuTemp)) + sideBarGPUTempTitle := tview.NewTextView(). + SetText("GPU Temperature"). + SetTextColor(tcell.ColorYellow) + sideBarGPUTemp := tview.NewTextView(). + SetText(fmt.Sprintf(" %s", gpuTemp)) sideBarMemoryTitle := tview.NewTextView(). SetText("Memory Use (Mb)"). SetTextColor(tcell.ColorYellow) @@ -249,6 +220,10 @@ func main() { sideBar.SetTitle("System Info"). SetBorder(true). SetTitleColor(tcell.ColorAqua) + sideBar.AddItem(sideBarCPUTempTitle, 1, 1, false) + sideBar.AddItem(sideBarCPUTemp, 1, 1, false) + sideBar.AddItem(sideBarGPUTempTitle, 1, 1, false) + sideBar.AddItem(sideBarGPUTemp, 1, 1, false) sideBar.AddItem(sideBarMemoryTitle, 1, 1, false) sideBar.AddItem(sideBarMemoryStats, 1, 1, false) sideBar.AddItem(sideBarSwapTitle, 1, 1, false) @@ -262,20 +237,138 @@ func main() { sideBar.AddItem(ipAsTextView, 1, 1, false) } + // Pages + pages := tview.NewPages() + // Main UI - grid := tview.NewGrid(). + mainUI := tview.NewGrid(). SetRows(2, 0, 4). SetColumns(25, 50, 50). SetBorders(true). AddItem(header, 0, 0, 1, 3, 0, 0, false). AddItem(footer, 2, 0, 1, 3, 0, 0, false) - grid.AddItem(menu, 1, 0, 1, 1, 0, 100, true). + mainUI.AddItem(menu, 1, 0, 1, 1, 0, 100, true). AddItem(main, 1, 1, 1, 1, 0, 100, false). AddItem(sideBar, 1, 2, 1, 1, 0, 100, false) + pages.AddPage(PAGE_MAIN_UI, mainUI, true, true) + + // Button modals + exitModal := tview.NewModal(). + SetText("Are you sure you want to [red]EXIT?"). + AddButtons([]string{"Yes", "Cancel"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonLabel == "Yes" { + app.Stop() + } + pages.SwitchToPage(PAGE_MAIN_UI) + }) + pages.AddPage(PAGE_EXIT, exitModal, true, false) + exitButton.SetSelectedFunc(func() { + pages.ShowPage(PAGE_EXIT) + }) + + rebootModal := tview.NewModal(). + SetText("Are you sure you want to [red]REBOOT?"). + AddButtons([]string{"Yes", "Cancel"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonLabel == "Yes" { + err := exec.Command(CMD_SYSTEMCTL, "reboot").Run() + if err != nil { + log.Fatalf("Could not reboot : %s ", err) + } + } + pages.SwitchToPage(PAGE_MAIN_UI) + }) + pages.AddPage(PAGE_REBOOT, rebootModal, true, false) + rebootButton.SetSelectedFunc(func() { + pages.ShowPage(PAGE_REBOOT) + }) + + powerOffModal := tview.NewModal(). + SetText("Are you sure you want to [red]POWER [red]OFF?"). + AddButtons([]string{"Yes", "Cancel"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonLabel == "Yes" { + err := exec.Command(CMD_SYSTEMCTL, "poweroff").Run() + if err != nil { + log.Fatalf("Could not power off : %s ", err) + } + } + pages.SwitchToPage(PAGE_MAIN_UI) + }) + pages.AddPage(PAGE_POWEROFF, powerOffModal, true, false) + powerOffButton.SetSelectedFunc(func() { + pages.ShowPage(PAGE_POWEROFF) + }) + + // Setup tracking of which are of the UI can/has focus + primitivesThatCanFocus := []tview.Primitive{menu, exitButton, rebootButton, powerOffButton} + currentFocus := 0 + + // Setup basic switching between main menu and buttons for the UI + app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + // Don't process input if we aren't on an existing element that can focus + canContinue := false + focusedPrimitive := app.GetFocus() + + // Override some of the default behavior so up/dn move between fields in forms + // Per API GetFocusedItemIndex on a form will be -1 if a form item isn't currently focused + // We use this as a bit of a cheat to figure out if we're inside of a form that needs better nav options for users (ie. tab doesn't exist on a remote) + wifiField, wifiButton := wifiConfigForm.GetFocusedItemIndex() + albumField, albumButton := selectAlbumsForm.GetFocusedItemIndex() + if wifiField != -1 || wifiButton != -1 || albumField != -1 || albumButton != -1 { + switch event.Key() { + case tcell.KeyUp: + return tcell.NewEventKey(tcell.KeyBacktab, 0, event.Modifiers()) + case tcell.KeyDown: + return tcell.NewEventKey(tcell.KeyTab, 0, event.Modifiers()) + } + } + + // Standard override for the screen primitives we are using (ie. don't take over control in modals) + for _, primitive := range primitivesThatCanFocus { + if primitive == focusedPrimitive { + canContinue = true + break + } + } + + // Bail if we shouldn't be affecting user input + if !canContinue { + return event + } + + // Add various forms of nav that make sense for a TUI (common stuff folks are used to) + currentFocusChanged := false + switch event.Key() { + case tcell.KeyRight, tcell.KeyTab: + currentFocus += 1 + if currentFocus >= len(primitivesThatCanFocus) { + currentFocus = 0 + } + currentFocusChanged = true + case tcell.KeyLeft, tcell.KeyBacktab: + currentFocus -= 1 + if currentFocus < 0 { + currentFocus = len(primitivesThatCanFocus) - 1 + } + currentFocusChanged = true + } + + // Update the focus based on navigation + if currentFocusChanged { + app.SetFocus(primitivesThatCanFocus[currentFocus]) + return nil + } + + // Pass through event so the main UI can process stuff properly + return event + }) + // Show UI and panic if there are any errors - if err := app.SetRoot(grid, true).EnableMouse(false).Run(); err != nil { - panic(err) + if err := app.SetRoot(pages, true).SetFocus(primitivesThatCanFocus[currentFocus]).EnableMouse(false).Run(); err != nil { + log.Fatalf("Failed to run UI : ", err) } }