package ui import ( "fmt" "io/ioutil" "log" "math" "net" "os" "os/exec" "path/filepath" "strconv" "strings" "github.com/gdamore/tcell" "github.com/guillermo/go.procmeminfo" "github.com/knadh/koanf" "github.com/rivo/tview" "git.kemonine.info/PiFrame/wifi" ) const ( 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" SYNCTHING_FOLDER_SKIP = ".stfolder" ) const ( PAGE_MAIN_UI = "PAGE_MAIN_UI" PAGE_EXIT = "PAGE_EXIT" PAGE_REBOOT = "PAGE_REBOOT" PAGE_POWEROFF = "PAGE_POWEROFF" ) func ConfigGui(config *koanf.Koanf) { // Memory info for status panel meminfo := &procmeminfo.MemInfo{} err := meminfo.Update() if err != nil { log.Printf("Error getting memory info : %s", err) } // Network interfaces for status panel ifaces, err := net.Interfaces() if err != nil { log.Fatalf("Error getting netork interfaces : %s", err) return } // Disk use findmntOut, err := exec.Command(CMD_FINDMNT, "-n", "-l", "-o", "TARGET,USE%", "-t", "ext4,exfat,vfat,btrfs,zfs,xfs").Output() if err != nil { log.Fatalf("Error getting disk use : %s", err) } filesystems := strings.Split(strings.Trim(string(findmntOut), "\n"), "\n") // GPU Temp vcgencmdOut, err := exec.Command(CMD_VCGENCMD, "measure_temp").Output() if err != nil { log.Fatalf("Error getting GPU temp : %s", err) } gpuTemp := strings.Split(strings.Trim(string(vcgencmdOut), "\n"), "=")[1] // CPU Temp cpuTempFileContents, err := ioutil.ReadFile(FILE_CPU_TEMP) if err != nil { log.Fatalf("Error getting CPU temp : %s", err) } cpuTempStr := strings.Trim(string(cpuTempFileContents), "\n") cpuTempInt, err := strconv.Atoi(cpuTempStr) if err != nil { log.Fatalf("Error processing CPU temp : %S", err) } cpuTemp := fmt.Sprintf("%.2f'C", float64(cpuTempInt)/1000.0) // Get list of all folders that can be used as albums var albums []string err = filepath.Walk(config.String(CONFIG_KEY_ALBUMS_ROOT), func(path string, fi os.FileInfo, err error) error { if err != nil { return err } if fi.IsDir() { if strings.Contains(path, SYNCTHING_FOLDER_SKIP) { return nil } albumName := strings.TrimPrefix(path, config.String(CONFIG_KEY_ALBUMS_ROOT)) if albumName == "" { albumName = "Main Folder" } albums = append(albums, albumName) } return nil }) if err != nil { log.Fatalf("Error getting list of albums : %s", err) } // Run config UI when slideshow stops app := tview.NewApplication() // Header headerTitle := tview.NewTextView(). SetText("PiFrame"). SetTextAlign(tview.AlignCenter). SetTextColor(tcell.ColorAqua) headerSubTitle := tview.NewTextView(). SetText("Management Utility"). SetTextAlign(tview.AlignCenter). SetTextColor(tcell.ColorSilver) header := tview.NewFlex(). SetDirection(tview.FlexRow) header.AddItem(headerTitle, 0, 1, false). AddItem(headerSubTitle, 0, 1, false) // Footer fields (Left Column) exitButton := tview.NewButton("Exit"). SetBackgroundColorActivated(tcell.ColorGray) exitButton.SetLabelColor(tcell.ColorBlack). SetBorder(true). SetBorderColor(tcell.ColorBlack). SetBackgroundColor(tcell.ColorGreen). SetRect(0, 0, 22, 3) rebootButton := tview.NewButton("Reboot"). SetBackgroundColorActivated(tcell.ColorGray) rebootButton.SetLabelColor(tcell.ColorBlack). SetBorder(true). SetBorderColor(tcell.ColorBlack). SetBackgroundColor(tcell.ColorYellow). SetRect(0, 0, 22, 3) powerOffButton := tview.NewButton("Power Off"). SetBackgroundColorActivated(tcell.ColorGray) powerOffButton.SetLabelColor(tcell.ColorBlack). SetBorder(true). SetBorderColor(tcell.ColorBlack). SetBackgroundColor(tcell.ColorRed). SetRect(0, 0, 22, 3) // Footer footer := tview.NewFlex() footer.AddItem(exitButton, 0, 1, false). AddItem(rebootButton, 0, 1, false). AddItem(powerOffButton, 0, 1, false) // Setup menu menu := tview.NewList() menu.SetBorder(true). SetTitle("Menu"). SetTitleColor(tcell.ColorAqua) menu.AddItem("Select Albums", "", '1', nil) menu.AddItem("Intervals", "", '2', nil) menu.AddItem("WiFi", "", '3', nil) menu.AddItem("HDMI On/Off", "", '5', nil) // Setup base var for main column so the menu setup is easier to manage main := tview.NewFlex(). SetDirection(tview.FlexRow) // Setup main panel (Center column) main.SetTitle(""). SetBorder(true). SetTitleColor(tcell.ColorAqua) // Select Albums Form selectAlbumsForm := tview.NewForm() configSelectedAlbums := config.Strings(CONFIG_KEY_ALBUMS_SELECTED) for _, album := range albums { albumSelected := false for _, configSelectedAlbum := range configSelectedAlbums { if configSelectedAlbum == "/" { configSelectedAlbum = "Main Folder" } if album == configSelectedAlbum { albumSelected = true } } selectAlbumsForm.AddCheckbox(album, albumSelected, nil) } selectAlbumsForm.AddButton("Apply", nil) selectAlbumsForm.AddButton("Cancel", func() { main.Clear() app.SetFocus(menu) }) // Slide Interval Form intervalsForm := tview.NewForm() configSlideInterval := config.String(CONFIG_KEY_SLIDESHOW_INTERVAL) configRestartInterval := config.String(CONFIG_KEY_SLIDESHOW_RESTART_INTERVAL) intervalsForm.AddInputField("Slide", configSlideInterval, 0, nil, nil) intervalsForm.AddInputField("Restart/Reshuffle", configRestartInterval, 0, nil, nil) intervalsForm.AddButton("Apply", nil) intervalsForm.AddButton("Cancel", func() { main.Clear() app.SetFocus(menu) }) // WiFi Config Form wifiConfigForm := tview.NewForm() wifiConfigAccessPoint := "" wifiConfigPassword := "" wifiConfigForm.AddInputField("Access Point", "", 0, nil, func(value string) { wifiConfigAccessPoint = value }) wifiConfigForm.AddPasswordField("Password", "", 0, '*', func(value string) { wifiConfigPassword = value }) wifiConfigForm.AddButton("Apply", func() { // Cleanup old wifi configs and apply new one nmWifi := wifi.New(wifiConfigAccessPoint, wifiConfigPassword) nmWifi.ApplyConfig() }) wifiConfigForm.AddButton("Cancel", func() { main.Clear() app.SetFocus(menu) }) // HDMI On/Off Form hdmiForm := tview.NewForm() configHDMIOff := config.String(CONFIG_KEY_HDMI_OFF) configHDMIOn := config.String(CONFIG_KEY_HDMI_ON) hdmiForm.AddInputField("HDMI Off Schedule", configHDMIOff, 0, nil, nil) hdmiForm.AddInputField("HDMI On Schedule", configHDMIOn, 0, nil, nil) hdmiForm.AddButton("Apply", nil) hdmiForm.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 == "WiFi" { main.SetTitle("Configure WiFi") main.Clear() main.AddItem(wifiConfigForm, 0, 1, true) app.SetFocus(wifiConfigForm) } if title == "Intervals" { main.SetTitle("Configure Intervals") main.Clear() main.AddItem(intervalsForm, 0, 1, true) app.SetFocus(intervalsForm) } if title == "HDMI On/Off" { main.SetTitle("Configure HDMI On/Off") main.Clear() main.AddItem(hdmiForm, 0, 1, true) app.SetFocus(hdmiForm) } }) // 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) divisor := math.Pow(1024.0, 2.0) sideBarMemoryStats := tview.NewTextView(). SetText(fmt.Sprintf(" %.1f / %.1f", float64(meminfo.Used())/divisor, float64(meminfo.Total())/divisor)) sideBarSwapTitle := tview.NewTextView(). SetText("Swap Use"). SetTextColor(tcell.ColorYellow) sideBarSwapStats := tview.NewTextView(). SetText(fmt.Sprintf(" %d%%", meminfo.Swap())) sideBarFilesystemTitle := tview.NewTextView(). SetText("Disk Use"). SetTextColor(tcell.ColorYellow) var sideBarFilesystems []*tview.TextView for _, i := range filesystems { filesystemAsTextView := tview.NewTextView(). SetText(fmt.Sprintf(" %s", i)) sideBarFilesystems = append(sideBarFilesystems, filesystemAsTextView) } sideBarIPAddressesTitle := tview.NewTextView(). SetText("IP Addresses"). SetTextColor(tcell.ColorYellow) var sideBarIPAddresses []*tview.TextView for _, i := range ifaces { addrs, err := i.Addrs() if err != nil { log.Printf("Error getting interface addresses : %s", err) continue } for _, a := range addrs { ipAsTextView := tview.NewTextView(). SetText(fmt.Sprintf(" %v : %s", i.Name, a.String())) sideBarIPAddresses = append(sideBarIPAddresses, ipAsTextView) } } // Setup side bar (Right column) sideBar := tview.NewFlex(). SetDirection(tview.FlexRow) 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) sideBar.AddItem(sideBarSwapStats, 1, 1, false) sideBar.AddItem(sideBarFilesystemTitle, 1, 1, false) for _, filesystemAsTextView := range sideBarFilesystems { sideBar.AddItem(filesystemAsTextView, 1, 1, false) } sideBar.AddItem(sideBarIPAddressesTitle, 1, 1, false) for _, ipAsTextView := range sideBarIPAddresses { sideBar.AddItem(ipAsTextView, 1, 1, false) } // Pages pages := tview.NewPages() // Main UI 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) 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) albumField, albumButton := selectAlbumsForm.GetFocusedItemIndex() intervalField, intervalButton := intervalsForm.GetFocusedItemIndex() wifiField, wifiButton := wifiConfigForm.GetFocusedItemIndex() hdmiField, hdmiButton := hdmiForm.GetFocusedItemIndex() if (wifiField != -1 || wifiButton != -1 || albumField != -1 || albumButton != -1 || intervalField != -1 || intervalButton != -1 || hdmiField != -1 || hdmiButton != -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(pages, true).SetFocus(primitivesThatCanFocus[currentFocus]).EnableMouse(false).Run(); err != nil { log.Fatalf("Failed to run UI : ", err) } }