From 58299c2efa49e2a700da854dc74718091aac1be2 Mon Sep 17 00:00:00 2001 From: Chris Cummer Date: Fri, 13 Dec 2019 11:33:29 -0800 Subject: [PATCH] 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 --- app/widget_maker.go | 10 +- go.mod | 2 + go.sum | 4 + modules/digitalocean/display.go | 40 +++ .../digitalocean/droplet_properties_table.go | 79 ++++++ modules/digitalocean/keyboard.go | 21 ++ modules/digitalocean/settings.go | 35 +++ modules/digitalocean/widget.go | 256 ++++++++++++++++++ modules/newrelic/keyboard.go | 1 - modules/todo/widget.go | 93 +++---- modules/transmission/keyboard.go | 2 +- utils/text.go | 32 ++- utils/text_test.go | 19 +- {wtf => view}/billboard_modal.go | 6 +- view/info_table.go | 76 ++++++ view/keyboard_widget.go | 8 +- view/scrollable_widget.go | 1 + 17 files changed, 627 insertions(+), 58 deletions(-) create mode 100644 modules/digitalocean/display.go create mode 100644 modules/digitalocean/droplet_properties_table.go create mode 100644 modules/digitalocean/keyboard.go create mode 100644 modules/digitalocean/settings.go create mode 100644 modules/digitalocean/widget.go rename {wtf => view}/billboard_modal.go (81%) create mode 100644 view/info_table.go diff --git a/app/widget_maker.go b/app/widget_maker.go index e51a1b6e..41e3c78a 100644 --- a/app/widget_maker.go +++ b/app/widget_maker.go @@ -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) diff --git a/go.mod b/go.mod index cde86e8c..578509bd 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 5c11d8ff..a6b30b1c 100644 --- a/go.sum +++ b/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= diff --git a/modules/digitalocean/display.go b/modules/digitalocean/display.go new file mode 100644 index 00000000..e3cd0d20 --- /dev/null +++ b/modules/digitalocean/display.go @@ -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) +} diff --git a/modules/digitalocean/droplet_properties_table.go b/modules/digitalocean/droplet_properties_table.go new file mode 100644 index 00000000..131c73ba --- /dev/null +++ b/modules/digitalocean/droplet_properties_table.go @@ -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() +} diff --git a/modules/digitalocean/keyboard.go b/modules/digitalocean/keyboard.go new file mode 100644 index 00000000..f33bd506 --- /dev/null +++ b/modules/digitalocean/keyboard.go @@ -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") +} diff --git a/modules/digitalocean/settings.go b/modules/digitalocean/settings.go new file mode 100644 index 00000000..6d2e1c69 --- /dev/null +++ b/modules/digitalocean/settings.go @@ -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 +} diff --git a/modules/digitalocean/widget.go b/modules/digitalocean/widget.go new file mode 100644 index 00000000..73419ae8 --- /dev/null +++ b/modules/digitalocean/widget.go @@ -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() + }) +} diff --git a/modules/newrelic/keyboard.go b/modules/newrelic/keyboard.go index d572a4e1..798bcd11 100644 --- a/modules/newrelic/keyboard.go +++ b/modules/newrelic/keyboard.go @@ -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") } diff --git a/modules/todo/widget.go b/modules/todo/widget.go index bc9c6cd3..e2fd00b2 100644 --- a/modules/todo/widget.go +++ b/modules/todo/widget.go @@ -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()) { diff --git a/modules/transmission/keyboard.go b/modules/transmission/keyboard.go index 1f9dc56f..36039650 100644 --- a/modules/transmission/keyboard.go +++ b/modules/transmission/keyboard.go @@ -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") diff --git a/utils/text.go b/utils/text.go index acf882a7..431bcd08 100644 --- a/utils/text.go +++ b/utils/text.go @@ -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 +} diff --git a/utils/text_test.go b/utils/text_test.go index 23d878b3..73901d37 100644 --- a/utils/text_test.go +++ b/utils/text_test.go @@ -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)) +} diff --git a/wtf/billboard_modal.go b/view/billboard_modal.go similarity index 81% rename from wtf/billboard_modal.go rename to view/billboard_modal.go index e10906c4..0f5624c2 100644 --- a/wtf/billboard_modal.go +++ b/view/billboard_modal.go @@ -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()) == "/" { diff --git a/view/info_table.go b/view/info_table.go new file mode 100644 index 00000000..01e0ddf4 --- /dev/null +++ b/view/info_table.go @@ -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() +} diff --git a/view/keyboard_widget.go b/view/keyboard_widget.go index ba9ffbf0..270c7706 100644 --- a/view/keyboard_widget.go +++ b/view/keyboard_widget.go @@ -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) diff --git a/view/scrollable_widget.go b/view/scrollable_widget.go index 994a6936..9468a750 100644 --- a/view/scrollable_widget.go +++ b/view/scrollable_widget.go @@ -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() })