mirror of
https://github.com/taigrr/wtf
synced 2025-01-18 04:03:14 -08:00
WTF-484 DigitalOcean module (#782)
* WTF-484 DigitalOcean module stubbed out * WTF-484 Delete droplets via Ctrl-d * WTF-484 Rebasing off master after a long time away * WTF-484 Improve DigitalOcean display * WTF-484 Can shutdown and restart the selected droplet * WTF-484 Display info about the selected droplet using the ? key * WTF-484 Display info about the selected droplet using the Return key * WTF-484 Greatly improve the utils.Truncate function * WTF-484 Display a droplet's features in the info modal * WTF-484 Change reboot key from r to b to not conflict with refresh * WTF-484 Panic if a keyboard control is mapped to the same character more than once * WTF-484 Colorize droplet status indicator * WTF-484 Extract view.InfoTable out into a reusable component
This commit is contained in:
parent
e1f1d0a410
commit
58299c2efa
@ -16,6 +16,7 @@ import (
|
||||
"github.com/wtfutil/wtf/modules/datadog"
|
||||
"github.com/wtfutil/wtf/modules/devto"
|
||||
"github.com/wtfutil/wtf/modules/digitalclock"
|
||||
"github.com/wtfutil/wtf/modules/digitalocean"
|
||||
"github.com/wtfutil/wtf/modules/docker"
|
||||
"github.com/wtfutil/wtf/modules/exchangerates"
|
||||
"github.com/wtfutil/wtf/modules/feedreader"
|
||||
@ -111,9 +112,6 @@ func MakeWidget(
|
||||
case "clocks":
|
||||
settings := clocks.NewSettingsFromYAML(moduleName, moduleConfig, config)
|
||||
widget = clocks.NewWidget(app, settings)
|
||||
case "digitalclock":
|
||||
settings := digitalclock.NewSettingsFromYAML(moduleName, moduleConfig, config)
|
||||
widget = digitalclock.NewWidget(app, settings)
|
||||
case "cmdrunner":
|
||||
settings := cmdrunner.NewSettingsFromYAML(moduleName, moduleConfig, config)
|
||||
widget = cmdrunner.NewWidget(app, settings)
|
||||
@ -126,6 +124,12 @@ func MakeWidget(
|
||||
case "devto":
|
||||
settings := devto.NewSettingsFromYAML(moduleName, moduleConfig, config)
|
||||
widget = devto.NewWidget(app, pages, settings)
|
||||
case "digitalclock":
|
||||
settings := digitalclock.NewSettingsFromYAML(moduleName, moduleConfig, config)
|
||||
widget = digitalclock.NewWidget(app, settings)
|
||||
case "digitalocean":
|
||||
settings := digitalocean.NewSettingsFromYAML(moduleName, moduleConfig, config)
|
||||
widget = digitalocean.NewWidget(app, pages, settings)
|
||||
case "docker":
|
||||
settings := docker.NewSettingsFromYAML(moduleName, moduleConfig, config)
|
||||
widget = docker.NewWidget(app, pages, settings)
|
||||
|
2
go.mod
2
go.mod
@ -14,6 +14,8 @@ require (
|
||||
github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8
|
||||
github.com/briandowns/openweathermap v0.0.0-20180804155945-5f41b7c9d92d
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
|
||||
github.com/digitalocean/godo v1.22.0
|
||||
github.com/docker/distribution v2.7.1+incompatible // indirect
|
||||
github.com/docker/docker v1.13.1
|
||||
github.com/docker/go-connections v0.4.0 // indirect
|
||||
|
4
go.sum
4
go.sum
@ -57,6 +57,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgrijalva/jwt-go v0.0.0-20160705203006-01aeca54ebda h1:NyywMz59neOoVRFDz+ccfKWxn784fiHMDnZSy6T+JXY=
|
||||
github.com/dgrijalva/jwt-go v0.0.0-20160705203006-01aeca54ebda/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/digitalocean/godo v1.22.0 h1:bVFBKXW2TlynZ9SqmlM6ZSW6UPEzFckltSIUT5NC8L4=
|
||||
github.com/digitalocean/godo v1.22.0/go.mod h1:iJnN9rVu6K5LioLxLimlq0uRI+y/eAQjROUmeU/r0hY=
|
||||
github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg=
|
||||
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
|
||||
|
40
modules/digitalocean/display.go
Normal file
40
modules/digitalocean/display.go
Normal file
@ -0,0 +1,40 @@
|
||||
package digitalocean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/wtfutil/wtf/utils"
|
||||
)
|
||||
|
||||
func (widget *Widget) content() (string, string, bool) {
|
||||
title := widget.CommonSettings().Title
|
||||
if widget.err != nil {
|
||||
return title, widget.err.Error(), true
|
||||
}
|
||||
|
||||
str := fmt.Sprintf(
|
||||
" [%s]Droplets\n\n",
|
||||
widget.settings.common.Colors.Subheading,
|
||||
)
|
||||
|
||||
for idx, droplet := range widget.droplets {
|
||||
dropletName := droplet.Name
|
||||
|
||||
row := fmt.Sprintf(
|
||||
"[%s] %-8s %-24s %s",
|
||||
widget.RowColor(idx),
|
||||
droplet.Status,
|
||||
dropletName,
|
||||
utils.Truncate(strings.Join(droplet.Tags, ","), 24, true),
|
||||
)
|
||||
|
||||
str += utils.HighlightableHelper(widget.View, row, idx, 33)
|
||||
}
|
||||
|
||||
return title, str, false
|
||||
}
|
||||
|
||||
func (widget *Widget) display() {
|
||||
widget.ScrollableWidget.Redraw(widget.content)
|
||||
}
|
79
modules/digitalocean/droplet_properties_table.go
Normal file
79
modules/digitalocean/droplet_properties_table.go
Normal file
@ -0,0 +1,79 @@
|
||||
package digitalocean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/digitalocean/godo"
|
||||
"github.com/wtfutil/wtf/utils"
|
||||
"github.com/wtfutil/wtf/view"
|
||||
)
|
||||
|
||||
type dropletPropertiesTable struct {
|
||||
droplet *godo.Droplet
|
||||
propertyMap map[string]string
|
||||
|
||||
colWidth0 int
|
||||
colWidth1 int
|
||||
tableHeight int
|
||||
}
|
||||
|
||||
// newDropletPropertiesTable creates and returns an instance of DropletPropertiesTable
|
||||
func newDropletPropertiesTable(droplet *godo.Droplet) *dropletPropertiesTable {
|
||||
propTable := &dropletPropertiesTable{
|
||||
droplet: droplet,
|
||||
|
||||
colWidth0: 24,
|
||||
colWidth1: 47,
|
||||
tableHeight: 16,
|
||||
}
|
||||
|
||||
propTable.propertyMap = propTable.buildPropertyMap()
|
||||
|
||||
return propTable
|
||||
}
|
||||
|
||||
/* -------------------- Unexported Functions -------------------- */
|
||||
|
||||
// buildPropertyMap creates a mapping of droplet property names to droplet property values
|
||||
func (propTable *dropletPropertiesTable) buildPropertyMap() map[string]string {
|
||||
propMap := map[string]string{}
|
||||
|
||||
if propTable.droplet == nil {
|
||||
return propMap
|
||||
}
|
||||
|
||||
publicV4, _ := propTable.droplet.PublicIPv4()
|
||||
publicV6, _ := propTable.droplet.PublicIPv6()
|
||||
|
||||
propMap["CPUs"] = strconv.Itoa(propTable.droplet.Vcpus)
|
||||
propMap["Created"] = propTable.droplet.Created
|
||||
propMap["Disk"] = strconv.Itoa(propTable.droplet.Disk)
|
||||
propMap["Features"] = utils.Truncate(strings.Join(propTable.droplet.Features, ","), propTable.colWidth1, true)
|
||||
propMap["Image"] = fmt.Sprintf("%s (%s)", propTable.droplet.Image.Name, propTable.droplet.Image.Distribution)
|
||||
propMap["Memory"] = strconv.Itoa(propTable.droplet.Memory)
|
||||
propMap["Public IP v4"] = publicV4
|
||||
propMap["Public IP v6"] = publicV6
|
||||
propMap["Region"] = fmt.Sprintf("%s (%s)", propTable.droplet.Region.Name, propTable.droplet.Region.Slug)
|
||||
propMap["Size"] = propTable.droplet.SizeSlug
|
||||
propMap["Status"] = propTable.droplet.Status
|
||||
propMap["Tags"] = utils.Truncate(strings.Join(propTable.droplet.Tags, ","), propTable.colWidth1, true)
|
||||
propMap["URN"] = utils.Truncate(propTable.droplet.URN(), propTable.colWidth1, true)
|
||||
propMap["VPC"] = propTable.droplet.VPCUUID
|
||||
|
||||
return propMap
|
||||
}
|
||||
|
||||
// render creates a new Table and returns it as a displayable string
|
||||
func (propTable *dropletPropertiesTable) render() string {
|
||||
tbl := view.NewInfoTable(
|
||||
[]string{"Property", "Value"},
|
||||
propTable.propertyMap,
|
||||
propTable.colWidth0,
|
||||
propTable.colWidth1,
|
||||
propTable.tableHeight,
|
||||
)
|
||||
|
||||
return tbl.Render()
|
||||
}
|
21
modules/digitalocean/keyboard.go
Normal file
21
modules/digitalocean/keyboard.go
Normal file
@ -0,0 +1,21 @@
|
||||
package digitalocean
|
||||
|
||||
import "github.com/gdamore/tcell"
|
||||
|
||||
func (widget *Widget) initializeKeyboardControls() {
|
||||
widget.InitializeCommonControls(widget.Refresh)
|
||||
|
||||
widget.SetKeyboardChar("?", widget.showInfo, "Show info about the selected droplet")
|
||||
|
||||
widget.SetKeyboardChar("b", widget.dropletRestart, "Reboot the selected droplet")
|
||||
widget.SetKeyboardChar("j", widget.Prev, "Select previous item")
|
||||
widget.SetKeyboardChar("k", widget.Next, "Select next item")
|
||||
widget.SetKeyboardChar("p", widget.dropletEnabledPrivateNetworking, "Enable private networking for the selected drople")
|
||||
widget.SetKeyboardChar("s", widget.dropletShutDown, "Shut down the selected droplet")
|
||||
widget.SetKeyboardChar("u", widget.Unselect, "Clear selection")
|
||||
|
||||
widget.SetKeyboardKey(tcell.KeyCtrlD, widget.dropletDestroy, "Destroy the selected droplet")
|
||||
widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item")
|
||||
widget.SetKeyboardKey(tcell.KeyEnter, widget.showInfo, "Show info about the selected droplet")
|
||||
widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item")
|
||||
}
|
35
modules/digitalocean/settings.go
Normal file
35
modules/digitalocean/settings.go
Normal file
@ -0,0 +1,35 @@
|
||||
package digitalocean
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/olebedev/config"
|
||||
"github.com/wtfutil/wtf/cfg"
|
||||
"github.com/wtfutil/wtf/wtf"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultFocusable = true
|
||||
defaultTitle = "DigitalOcean"
|
||||
)
|
||||
|
||||
// Settings defines the configuration properties for this module
|
||||
type Settings struct {
|
||||
common *cfg.Common
|
||||
|
||||
apiKey string `help:"Your DigitalOcean API key."`
|
||||
dateFormat string `help:"The format to display dates and times in."`
|
||||
}
|
||||
|
||||
// NewSettingsFromYAML creates a new settings instance from a YAML config block
|
||||
func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings {
|
||||
|
||||
settings := Settings{
|
||||
common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig),
|
||||
|
||||
apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_DIGITALOCEAN_API_KEY"))),
|
||||
dateFormat: ymlConfig.UString("dateFormat", wtf.DateFormat),
|
||||
}
|
||||
|
||||
return &settings
|
||||
}
|
256
modules/digitalocean/widget.go
Normal file
256
modules/digitalocean/widget.go
Normal file
@ -0,0 +1,256 @@
|
||||
package digitalocean
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/digitalocean/godo"
|
||||
"github.com/rivo/tview"
|
||||
"github.com/wtfutil/wtf/utils"
|
||||
"github.com/wtfutil/wtf/view"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
/* -------------------- Oauth2 Token -------------------- */
|
||||
|
||||
type tokenSource struct {
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
// Token creates and returns an Oauth2 token
|
||||
func (t *tokenSource) Token() (*oauth2.Token, error) {
|
||||
token := &oauth2.Token{
|
||||
AccessToken: t.AccessToken,
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
/* -------------------- Widget -------------------- */
|
||||
|
||||
// Widget is the container for transmission data
|
||||
type Widget struct {
|
||||
view.KeyboardWidget
|
||||
view.ScrollableWidget
|
||||
|
||||
app *tview.Application
|
||||
client *godo.Client
|
||||
droplets []godo.Droplet
|
||||
pages *tview.Pages
|
||||
settings *Settings
|
||||
err error
|
||||
}
|
||||
|
||||
// NewWidget creates a new instance of a widget
|
||||
func NewWidget(app *tview.Application, pages *tview.Pages, settings *Settings) *Widget {
|
||||
widget := Widget{
|
||||
KeyboardWidget: view.NewKeyboardWidget(app, pages, settings.common),
|
||||
ScrollableWidget: view.NewScrollableWidget(app, settings.common),
|
||||
|
||||
app: app,
|
||||
pages: pages,
|
||||
settings: settings,
|
||||
}
|
||||
|
||||
widget.initializeKeyboardControls()
|
||||
widget.View.SetInputCapture(widget.InputCapture)
|
||||
|
||||
widget.View.SetScrollable(true)
|
||||
|
||||
widget.KeyboardWidget.SetView(widget.View)
|
||||
widget.SetRenderFunction(widget.display)
|
||||
|
||||
widget.createClient()
|
||||
|
||||
return &widget
|
||||
}
|
||||
|
||||
/* -------------------- Exported Functions -------------------- */
|
||||
|
||||
// Fetch retrieves droplet data
|
||||
func (widget *Widget) Fetch() error {
|
||||
if widget.client == nil {
|
||||
return errors.New("client could not be initialized")
|
||||
}
|
||||
|
||||
var err error
|
||||
widget.droplets, err = widget.dropletsFetch()
|
||||
return err
|
||||
}
|
||||
|
||||
// HelpText returns the help text for this widget
|
||||
func (widget *Widget) HelpText() string {
|
||||
return widget.KeyboardWidget.HelpText()
|
||||
}
|
||||
|
||||
// Next selects the next item in the list
|
||||
func (widget *Widget) Next() {
|
||||
widget.ScrollableWidget.Next()
|
||||
}
|
||||
|
||||
// Prev selects the previous item in the list
|
||||
func (widget *Widget) Prev() {
|
||||
widget.ScrollableWidget.Prev()
|
||||
}
|
||||
|
||||
// Refresh updates the data for this widget and displays it onscreen
|
||||
func (widget *Widget) Refresh() {
|
||||
err := widget.Fetch()
|
||||
if err != nil {
|
||||
widget.err = err
|
||||
widget.SetItemCount(0)
|
||||
} else {
|
||||
widget.err = nil
|
||||
widget.SetItemCount(len(widget.droplets))
|
||||
}
|
||||
|
||||
widget.display()
|
||||
}
|
||||
|
||||
// Unselect clears the selection of list items
|
||||
func (widget *Widget) Unselect() {
|
||||
widget.ScrollableWidget.Unselect()
|
||||
widget.RenderFunction()
|
||||
}
|
||||
|
||||
/* -------------------- Unexported Functions -------------------- */
|
||||
|
||||
// createClient create a persisten DigitalOcean client for use in the calls below
|
||||
func (widget *Widget) createClient() {
|
||||
tokenSource := &tokenSource{
|
||||
AccessToken: widget.settings.apiKey,
|
||||
}
|
||||
|
||||
oauthClient := oauth2.NewClient(context.Background(), tokenSource)
|
||||
widget.client = godo.NewClient(oauthClient)
|
||||
}
|
||||
|
||||
// currentDroplet returns the currently-selected droplet, if there is one
|
||||
// Returns nil if no droplet is selected
|
||||
func (widget *Widget) currentDroplet() *godo.Droplet {
|
||||
if len(widget.droplets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(widget.droplets) <= widget.Selected {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &widget.droplets[widget.Selected]
|
||||
}
|
||||
|
||||
// dropletsFetch uses the DigitalOcean API to fetch information about all the available droplets
|
||||
func (widget *Widget) dropletsFetch() ([]godo.Droplet, error) {
|
||||
dropletList := []godo.Droplet{}
|
||||
opts := &godo.ListOptions{}
|
||||
|
||||
for {
|
||||
droplets, resp, err := widget.client.Droplets.List(context.Background(), opts)
|
||||
if err != nil {
|
||||
return dropletList, err
|
||||
}
|
||||
|
||||
for _, d := range droplets {
|
||||
dropletList = append(dropletList, d)
|
||||
}
|
||||
|
||||
if resp.Links == nil || resp.Links.IsLastPage() {
|
||||
break
|
||||
}
|
||||
|
||||
page, err := resp.Links.CurrentPage()
|
||||
if err != nil {
|
||||
return dropletList, err
|
||||
}
|
||||
|
||||
// Set the page we want for the next request
|
||||
opts.Page = page + 1
|
||||
}
|
||||
|
||||
return dropletList, nil
|
||||
}
|
||||
|
||||
/* -------------------- Droplet Actions -------------------- */
|
||||
|
||||
// dropletDestroy destroys the selected droplet
|
||||
func (widget *Widget) dropletDestroy() {
|
||||
currDroplet := widget.currentDroplet()
|
||||
if currDroplet == nil {
|
||||
return
|
||||
}
|
||||
|
||||
widget.client.Droplets.Delete(context.Background(), currDroplet.ID)
|
||||
|
||||
widget.dropletRemoveSelected()
|
||||
widget.Refresh()
|
||||
}
|
||||
|
||||
// dropletEnabledPrivateNetworking enabled private networking on the selected droplet
|
||||
func (widget *Widget) dropletEnabledPrivateNetworking() {
|
||||
currDroplet := widget.currentDroplet()
|
||||
if currDroplet == nil {
|
||||
return
|
||||
}
|
||||
|
||||
widget.client.DropletActions.EnablePrivateNetworking(context.Background(), currDroplet.ID)
|
||||
widget.Refresh()
|
||||
}
|
||||
|
||||
// dropletRemoveSelected removes the currently-selected droplet from the internal list of droplets
|
||||
func (widget *Widget) dropletRemoveSelected() {
|
||||
currDroplet := widget.currentDroplet()
|
||||
if currDroplet != nil {
|
||||
widget.droplets[len(widget.droplets)-1], widget.droplets[widget.Selected] = widget.droplets[widget.Selected], widget.droplets[len(widget.droplets)-1]
|
||||
widget.droplets = widget.droplets[:len(widget.droplets)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// dropletRestart restarts the selected droplet
|
||||
func (widget *Widget) dropletRestart() {
|
||||
currDroplet := widget.currentDroplet()
|
||||
if currDroplet == nil {
|
||||
return
|
||||
}
|
||||
|
||||
widget.client.DropletActions.Reboot(context.Background(), currDroplet.ID)
|
||||
widget.Refresh()
|
||||
}
|
||||
|
||||
// dropletShutDown powers down the selected droplet
|
||||
func (widget *Widget) dropletShutDown() {
|
||||
currDroplet := widget.currentDroplet()
|
||||
if currDroplet == nil {
|
||||
return
|
||||
}
|
||||
|
||||
widget.client.DropletActions.Shutdown(context.Background(), currDroplet.ID)
|
||||
widget.Refresh()
|
||||
}
|
||||
|
||||
/* -------------------- Common Actions -------------------- */
|
||||
|
||||
// showInfo shows a modal window with information about the selected droplet
|
||||
func (widget *Widget) showInfo() {
|
||||
droplet := widget.currentDroplet()
|
||||
if droplet == nil {
|
||||
return
|
||||
}
|
||||
|
||||
closeFunc := func() {
|
||||
widget.pages.RemovePage("info")
|
||||
widget.app.SetFocus(widget.View)
|
||||
}
|
||||
|
||||
propTable := newDropletPropertiesTable(droplet).render()
|
||||
propTable += utils.CenterText("Esc to close", 80)
|
||||
|
||||
modal := view.NewBillboardModal(propTable, closeFunc)
|
||||
modal.SetTitle(fmt.Sprintf(" %s ", droplet.Name))
|
||||
|
||||
widget.pages.AddPage("info", modal, false, true)
|
||||
widget.app.SetFocus(modal)
|
||||
|
||||
widget.app.QueueUpdateDraw(func() {
|
||||
widget.app.Draw()
|
||||
})
|
||||
}
|
@ -3,7 +3,6 @@ package newrelic
|
||||
import "github.com/gdamore/tcell"
|
||||
|
||||
func (widget *Widget) initializeKeyboardControls() {
|
||||
widget.SetKeyboardChar("/", widget.ShowHelp, "Show/hide this help window")
|
||||
widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous application")
|
||||
widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next application")
|
||||
}
|
||||
|
@ -26,10 +26,10 @@ type Widget struct {
|
||||
view.ScrollableWidget
|
||||
|
||||
app *tview.Application
|
||||
settings *Settings
|
||||
filePath string
|
||||
list checklist.Checklist
|
||||
pages *tview.Pages
|
||||
settings *Settings
|
||||
}
|
||||
|
||||
// NewWidget creates a new instance of a widget
|
||||
@ -61,27 +61,11 @@ func NewWidget(app *tview.Application, pages *tview.Pages, settings *Settings) *
|
||||
|
||||
/* -------------------- Exported Functions -------------------- */
|
||||
|
||||
func (widget *Widget) Refresh() {
|
||||
widget.load()
|
||||
widget.display()
|
||||
}
|
||||
|
||||
func (widget *Widget) SetList(list checklist.Checklist) {
|
||||
widget.list = list
|
||||
}
|
||||
|
||||
// HelpText returns the help text for this widget
|
||||
func (widget *Widget) HelpText() string {
|
||||
return widget.KeyboardWidget.HelpText()
|
||||
}
|
||||
|
||||
/* -------------------- Unexported Functions -------------------- */
|
||||
|
||||
// isItemSelected returns weather any item of the todo is selected or not
|
||||
func (widget *Widget) isItemSelected() bool {
|
||||
|
||||
return widget.Selected >= 0 && widget.Selected < len(widget.list.Items)
|
||||
}
|
||||
|
||||
// SelectedItem returns the currently-selected checklist item or nil if no item is selected
|
||||
func (widget *Widget) SelectedItem() *checklist.ChecklistItem {
|
||||
var selectedItem *checklist.ChecklistItem
|
||||
@ -92,38 +76,18 @@ func (widget *Widget) SelectedItem() *checklist.ChecklistItem {
|
||||
return selectedItem
|
||||
}
|
||||
|
||||
// updateSelectedItem update the text of the selected item.
|
||||
func (widget *Widget) updateSelectedItem(text string) {
|
||||
selectedItem := widget.SelectedItem()
|
||||
if selectedItem == nil {
|
||||
return
|
||||
}
|
||||
|
||||
selectedItem.Text = text
|
||||
// Refresh updates the data for this widget and displays it onscreen
|
||||
func (widget *Widget) Refresh() {
|
||||
widget.load()
|
||||
widget.display()
|
||||
}
|
||||
|
||||
// updateSelected sets the text of the currently-selected item to the provided text
|
||||
func (widget *Widget) updateSelected() {
|
||||
if !widget.isItemSelected() {
|
||||
return
|
||||
}
|
||||
|
||||
form := widget.modalForm("Edit:", widget.SelectedItem().Text)
|
||||
|
||||
saveFctn := func() {
|
||||
text := form.GetFormItem(0).(*tview.InputField).GetText()
|
||||
|
||||
widget.updateSelectedItem(text)
|
||||
widget.persist()
|
||||
widget.pages.RemovePage("modal")
|
||||
widget.app.SetFocus(widget.View)
|
||||
widget.display()
|
||||
}
|
||||
|
||||
widget.addButtons(form, saveFctn)
|
||||
widget.modalFocus(form)
|
||||
func (widget *Widget) SetList(list checklist.Checklist) {
|
||||
widget.list = list
|
||||
}
|
||||
|
||||
/* -------------------- Unexported Functions -------------------- */
|
||||
|
||||
func (widget *Widget) init() {
|
||||
_, err := cfg.CreateFile(widget.filePath)
|
||||
if err != nil {
|
||||
@ -131,6 +95,11 @@ func (widget *Widget) init() {
|
||||
}
|
||||
}
|
||||
|
||||
// isItemSelected returns weather any item of the todo is selected or not
|
||||
func (widget *Widget) isItemSelected() bool {
|
||||
return widget.Selected >= 0 && widget.Selected < len(widget.list.Items)
|
||||
}
|
||||
|
||||
// Loads the todo list from3 Yaml file
|
||||
func (widget *Widget) load() {
|
||||
confDir, _ := cfg.WtfConfigDir()
|
||||
@ -189,6 +158,38 @@ func (widget *Widget) setItemChecks() {
|
||||
}
|
||||
}
|
||||
|
||||
// updateSelected sets the text of the currently-selected item to the provided text
|
||||
func (widget *Widget) updateSelected() {
|
||||
if !widget.isItemSelected() {
|
||||
return
|
||||
}
|
||||
|
||||
form := widget.modalForm("Edit:", widget.SelectedItem().Text)
|
||||
|
||||
saveFctn := func() {
|
||||
text := form.GetFormItem(0).(*tview.InputField).GetText()
|
||||
|
||||
widget.updateSelectedItem(text)
|
||||
widget.persist()
|
||||
widget.pages.RemovePage("modal")
|
||||
widget.app.SetFocus(widget.View)
|
||||
widget.display()
|
||||
}
|
||||
|
||||
widget.addButtons(form, saveFctn)
|
||||
widget.modalFocus(form)
|
||||
}
|
||||
|
||||
// updateSelectedItem update the text of the selected item.
|
||||
func (widget *Widget) updateSelectedItem(text string) {
|
||||
selectedItem := widget.SelectedItem()
|
||||
if selectedItem == nil {
|
||||
return
|
||||
}
|
||||
|
||||
selectedItem.Text = text
|
||||
}
|
||||
|
||||
/* -------------------- Modal Form -------------------- */
|
||||
|
||||
func (widget *Widget) addButtons(form *tview.Form, saveFctn func()) {
|
||||
|
@ -9,7 +9,7 @@ func (widget *Widget) initializeKeyboardControls() {
|
||||
widget.SetKeyboardChar("k", widget.Next, "Select next item")
|
||||
widget.SetKeyboardChar("u", widget.Unselect, "Clear selection")
|
||||
|
||||
widget.SetKeyboardKey(tcell.KeyCtrlD, widget.deleteSelectedTorrent, "Delete selected torrent")
|
||||
widget.SetKeyboardKey(tcell.KeyCtrlD, widget.deleteSelectedTorrent, "Delete the selected torrent")
|
||||
widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item")
|
||||
widget.SetKeyboardKey(tcell.KeyEnter, widget.pauseUnpauseTorrent, "Pause/unpause torrent")
|
||||
widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection")
|
||||
|
@ -27,16 +27,18 @@ func CenterText(str string, width int) string {
|
||||
// containing it. This is helpful for extending row highlighting across the entire width
|
||||
// of the view
|
||||
func HighlightableHelper(view *tview.TextView, input string, idx, offset int) string {
|
||||
fmtStr := fmt.Sprintf(`["%d"][""]`, idx)
|
||||
_, _, w, _ := view.GetInnerRect()
|
||||
|
||||
fmtStr := fmt.Sprintf(`["%d"][""]`, idx)
|
||||
fmtStr += input
|
||||
fmtStr += RowPadding(offset, w+1)
|
||||
fmtStr += RowPadding(offset, w)
|
||||
fmtStr += `[""]` + "\n"
|
||||
|
||||
return fmtStr
|
||||
}
|
||||
|
||||
// RowPadding returns a padding for a row to make it the full width of the containing widget.
|
||||
// Useful for ensurig row highlighting spans the full width (I suspect tcell has a better
|
||||
// Useful for ensuring row highlighting spans the full width (I suspect tcell has a better
|
||||
// way to do this, but I haven't yet found it)
|
||||
func RowPadding(offset int, max int) string {
|
||||
padSize := max - offset
|
||||
@ -46,3 +48,27 @@ func RowPadding(offset int, max int) string {
|
||||
|
||||
return strings.Repeat(" ", padSize)
|
||||
}
|
||||
|
||||
// Truncate chops a given string at len length. Appends an ellipse character if warranted
|
||||
func Truncate(src string, maxLen int, withEllipse bool) string {
|
||||
if len(src) < 1 || maxLen < 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if maxLen == 1 {
|
||||
return src[:1]
|
||||
}
|
||||
|
||||
var runeCount = 0
|
||||
for idx := range src {
|
||||
runeCount++
|
||||
if runeCount > maxLen {
|
||||
if withEllipse == true {
|
||||
return src[:idx-1] + "…"
|
||||
}
|
||||
|
||||
return src[:idx]
|
||||
}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ func Test_HighlightableHelper(t *testing.T) {
|
||||
view := tview.NewTextView()
|
||||
actual := HighlightableHelper(view, "cats", 0, 5)
|
||||
|
||||
assert.Equal(t, "[\"0\"][\"\"]cats [\"\"]\n", actual)
|
||||
assert.Equal(t, "[\"0\"][\"\"]cats [\"\"]\n", actual)
|
||||
}
|
||||
|
||||
func Test_RowPadding(t *testing.T) {
|
||||
@ -26,3 +26,20 @@ func Test_RowPadding(t *testing.T) {
|
||||
assert.Equal(t, " ", RowPadding(1, 2))
|
||||
assert.Equal(t, " ", RowPadding(0, 5))
|
||||
}
|
||||
|
||||
func Test_Truncate(t *testing.T) {
|
||||
assert.Equal(t, "", Truncate("cat", 0, false))
|
||||
assert.Equal(t, "c", Truncate("cat", 1, false))
|
||||
assert.Equal(t, "ca", Truncate("cat", 2, false))
|
||||
assert.Equal(t, "cat", Truncate("cat", 3, false))
|
||||
assert.Equal(t, "cat", Truncate("cat", 4, false))
|
||||
|
||||
assert.Equal(t, "", Truncate("cat", 0, true))
|
||||
assert.Equal(t, "c", Truncate("cat", 1, true))
|
||||
assert.Equal(t, "c…", Truncate("cat", 2, true))
|
||||
assert.Equal(t, "cat", Truncate("cat", 3, true))
|
||||
assert.Equal(t, "cat", Truncate("cat", 4, true))
|
||||
|
||||
// Only supports non-ellipsed emoji
|
||||
assert.Equal(t, "🌮🚙", Truncate("🌮🚙💥👾", 2, false))
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
package wtf
|
||||
package view
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
@ -9,6 +9,10 @@ const offscreen = -1000
|
||||
const modalWidth = 80
|
||||
const modalHeight = 22
|
||||
|
||||
// NewBillboardModal creates and returns a modal dialog suitable for displaying
|
||||
// a wall of text
|
||||
// An example of this is the keyboard help modal that shows up for all widgets
|
||||
// that support keyboard control when '/' is pressed
|
||||
func NewBillboardModal(text string, closeFunc func()) *tview.Frame {
|
||||
keyboardIntercept := func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if string(event.Rune()) == "/" {
|
76
view/info_table.go
Normal file
76
view/info_table.go
Normal file
@ -0,0 +1,76 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sort"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
)
|
||||
|
||||
/*
|
||||
An InfoTable is a two-column table of properties/values:
|
||||
|
||||
-------------------------- -------------------------------------------------
|
||||
PROPERTY VALUE
|
||||
-------------------------- -------------------------------------------------
|
||||
CPUs 1
|
||||
Created 2019-12-12T18:39:09Z
|
||||
Disk 25
|
||||
Features ipv6
|
||||
Image 18.04.3 (LTS) x64 (Ubuntu)
|
||||
Memory 1024
|
||||
Region Toronto 1 (tor1)
|
||||
-------------------------- -------------------------------------------------
|
||||
*/
|
||||
|
||||
// InfoTable contains the internal guts of an InfoTable
|
||||
type InfoTable struct {
|
||||
buf *bytes.Buffer
|
||||
tblWriter *tablewriter.Table
|
||||
}
|
||||
|
||||
// NewInfoTable creates and returns the stringified contents of a two-column table
|
||||
func NewInfoTable(headers []string, dataMap map[string]string, colWidth0, colWidth1, tableHeight int) *InfoTable {
|
||||
tbl := &InfoTable{
|
||||
buf: new(bytes.Buffer),
|
||||
}
|
||||
|
||||
tbl.tblWriter = tablewriter.NewWriter(tbl.buf)
|
||||
|
||||
tbl.tblWriter.SetHeader(headers)
|
||||
tbl.tblWriter.SetBorder(true)
|
||||
tbl.tblWriter.SetCenterSeparator(" ")
|
||||
tbl.tblWriter.SetColumnSeparator(" ")
|
||||
tbl.tblWriter.SetRowSeparator("-")
|
||||
tbl.tblWriter.SetAlignment(tablewriter.ALIGN_LEFT)
|
||||
tbl.tblWriter.SetColMinWidth(0, colWidth0)
|
||||
tbl.tblWriter.SetColMinWidth(1, colWidth1)
|
||||
|
||||
keys := []string{}
|
||||
for key := range dataMap {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
// Enumerate over the alphabetically-sorted keys to render the property values
|
||||
for _, key := range keys {
|
||||
tbl.tblWriter.Append([]string{key, dataMap[key]})
|
||||
}
|
||||
|
||||
// Pad the table with extra rows to push it to the bottom
|
||||
paddingAmt := tableHeight - len(dataMap) - 1
|
||||
if paddingAmt > 0 {
|
||||
for i := 0; i < paddingAmt; i++ {
|
||||
tbl.tblWriter.Append([]string{"", ""})
|
||||
}
|
||||
}
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// Render returns the stringified version of the table
|
||||
func (tbl *InfoTable) Render() string {
|
||||
tbl.tblWriter.Render()
|
||||
return tbl.buf.String()
|
||||
}
|
@ -7,7 +7,6 @@ import (
|
||||
"github.com/gdamore/tcell"
|
||||
"github.com/rivo/tview"
|
||||
"github.com/wtfutil/wtf/cfg"
|
||||
"github.com/wtfutil/wtf/wtf"
|
||||
)
|
||||
|
||||
type helpItem struct {
|
||||
@ -54,6 +53,11 @@ func (widget *KeyboardWidget) SetKeyboardChar(char string, fn func(), helpText s
|
||||
return
|
||||
}
|
||||
|
||||
// Check to ensure that the key trying to be used isn't already being used for something
|
||||
if _, ok := widget.charMap[char]; ok {
|
||||
panic(fmt.Sprintf("Key is already mapped to a keyboard command: %s\n", char))
|
||||
}
|
||||
|
||||
widget.charMap[char] = fn
|
||||
widget.charHelp = append(widget.charHelp, helpItem{char, helpText})
|
||||
}
|
||||
@ -133,7 +137,7 @@ func (widget *KeyboardWidget) ShowHelp() {
|
||||
widget.app.SetFocus(widget.view)
|
||||
}
|
||||
|
||||
modal := wtf.NewBillboardModal(widget.HelpText(), closeFunc)
|
||||
modal := NewBillboardModal(widget.HelpText(), closeFunc)
|
||||
|
||||
widget.pages.AddPage("help", modal, false, true)
|
||||
widget.app.SetFocus(modal)
|
||||
|
@ -83,6 +83,7 @@ func (widget *ScrollableWidget) Unselect() {
|
||||
|
||||
func (widget *ScrollableWidget) Redraw(data func() (string, string, bool)) {
|
||||
widget.TextWidget.Redraw(data)
|
||||
|
||||
widget.app.QueueUpdateDraw(func() {
|
||||
widget.View.Highlight(strconv.Itoa(widget.Selected)).ScrollToHighlight()
|
||||
})
|
||||
|
Loading…
x
Reference in New Issue
Block a user