1
0
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:
Chris Cummer 2019-12-13 11:33:29 -08:00 committed by GitHub
parent e1f1d0a410
commit 58299c2efa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 627 additions and 58 deletions

View File

@ -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
View File

@ -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
View File

@ -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=

View 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)
}

View 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()
}

View 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")
}

View 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
}

View 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()
})
}

View File

@ -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")
}

View File

@ -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
}
// 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)
// Refresh updates the data for this widget and displays it onscreen
func (widget *Widget) Refresh() {
widget.load()
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()) {

View File

@ -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")

View File

@ -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
}

View File

@ -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))
}

View File

@ -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
View 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()
}

View File

@ -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)

View File

@ -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()
})