Browse Source

initial commit

master
Brett Bender 2 years ago
commit
8654ded7a0
34 changed files with 2650 additions and 0 deletions
  1. +117
    -0
      .gitignore
  2. +17
    -0
      cmd/client/client.go
  3. +24
    -0
      go.mod
  4. +66
    -0
      go.sum
  5. +146
    -0
      internal/api/api.go
  6. +11
    -0
      internal/api/utils.go
  7. +112
    -0
      internal/app/app.go
  8. +8
    -0
      internal/app/init.go
  9. +44
    -0
      internal/app/menu.go
  10. +19
    -0
      internal/app/refresh.go
  11. +56
    -0
      internal/app/screens.go
  12. +42
    -0
      internal/types/assets.go
  13. +11
    -0
      internal/types/buildings.go
  14. +11
    -0
      internal/types/categories.go
  15. +23
    -0
      internal/types/shelves.go
  16. +216
    -0
      internal/ui/assets/assets.go
  17. +103
    -0
      internal/ui/assets/command.go
  18. +48
    -0
      internal/ui/assets/data.go
  19. +24
    -0
      internal/ui/assets/draw.go
  20. +54
    -0
      internal/ui/assets/key.go
  21. +97
    -0
      internal/ui/assets/refresh.go
  22. +300
    -0
      internal/ui/dialogs/command.go
  23. +203
    -0
      internal/ui/dialogs/confirm.go
  24. +106
    -0
      internal/ui/dialogs/error.go
  25. +238
    -0
      internal/ui/dialogs/message.go
  26. +128
    -0
      internal/ui/dialogs/progress.go
  27. +42
    -0
      internal/ui/dialogs/utils.go
  28. +112
    -0
      internal/ui/help/help.go
  29. +44
    -0
      internal/ui/style/style.go
  30. +14
    -0
      internal/ui/style/utils.go
  31. +15
    -0
      internal/ui/style/utils_windows.go
  32. +147
    -0
      internal/ui/utils/keys.go
  33. +29
    -0
      internal/ui/utils/utils.go
  34. +23
    -0
      magefile.go

+ 117
- 0
.gitignore View File

@ -0,0 +1,117 @@
# Created by https://www.toptal.com/developers/gitignore/api/goland+all,go
# Edit at https://www.toptal.com/developers/gitignore?templates=goland+all,go
### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
### GoLand+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### GoLand+all Patch ###
# Ignore everything but code style settings and run configurations
# that are supposed to be shared within teams.
.idea/*
!.idea/codeStyles
!.idea/runConfigurations
# End of https://www.toptal.com/developers/gitignore/api/goland+all,go
build

+ 17
- 0
cmd/client/client.go View File

@ -0,0 +1,17 @@
package main
import (
"git.brettb.xyz/goinv/client/internal/app"
"go.uber.org/zap"
)
func main() {
logger, _ := zap.NewProduction()
defer logger.Sync()
a := app.NewApp("goinv", "0.1.0", "http://localhost:3001", logger) // TODO: CONFIGURATION
if err := a.Run(); err != nil {
logger.Panic("application errored", zap.Error(err))
}
}

+ 24
- 0
go.mod View File

@ -0,0 +1,24 @@
module git.brettb.xyz/goinv/client
go 1.21.6
require (
github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73
github.com/magefile/mage v1.15.0
go.uber.org/zap v1.26.0
)
require (
github.com/gdamore/encoding v1.0.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/rivo/uniseg v0.4.3 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/term v0.9.0 // indirect
golang.org/x/text v0.12.0 // indirect
)
require (
github.com/rivo/tview v0.0.0-20240116070845-bf8f1c43e46c
go.uber.org/multierr v1.10.0 // indirect
)

+ 66
- 0
go.sum View File

@ -0,0 +1,66 @@
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/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73 h1:SeDV6ZUSVlTAUUPdMzPXgMyj96z+whQJRRUff8dIeic=
github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73/go.mod h1:pwzJMyH4Hd0AZMJkWQ+/g01dDvYWEvmJuaiRU71Xl8k=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/tview v0.0.0-20240116070845-bf8f1c43e46c h1:l7KIWhypqjpOJzJKynao/m8Ku4Y6PcZDlVcOVirYkKs=
github.com/rivo/tview v0.0.0-20240116070845-bf8f1c43e46c/go.mod h1:c0SPlNPXkM+/Zgjn/0vD3W0Ds1yxstN7lpquqLDpWCg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28=
golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 146
- 0
internal/api/api.go View File

@ -0,0 +1,146 @@
package api
import (
"bytes"
"fmt"
"io"
"net/http"
"git.brettb.xyz/goinv/client/internal/types"
)
const userAgent = "goinv-cli"
type APIClient struct {
Host string
Token string
}
func NewAPIClient(host string) *APIClient {
return &APIClient{
Host: host,
}
}
func (c *APIClient) retrieveSingleAsset(r *http.Request) (*types.AssetResponse, error) {
r.Header.Set("User-Agent", userAgent)
//r.Header.Set("Authorization", "Bearer "+c.Token)
r.Header.Set("Accept", "application/json")
r.Header.Set("Content-Type", "application/json")
httpCli := http.Client{}
httpResp, err := httpCli.Do(r)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
body, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}
resp, err := unmarshal[types.AssetResponse](body)
if err != nil {
return nil, fmt.Errorf("%s", "An unexpected response was received")
}
return resp, nil
}
func (c *APIClient) retrieveMultipleAssets(r *http.Request) (*types.MultipleAssetsResponse, error) {
r.Header.Set("User-Agent", userAgent)
//r.Header.Set("Authorization", "Bearer "+c.Token)
r.Header.Set("Accept", "application/json")
r.Header.Set("Content-Type", "application/json")
httpCli := http.Client{}
httpResp, err := httpCli.Do(r)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
body, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}
resp, err := unmarshal[types.MultipleAssetsResponse](body)
if err != nil {
return nil, fmt.Errorf("%s", "An unexpected response was received")
}
return resp, nil
}
func (c *APIClient) retrieveMultipleShelves(r *http.Request) (*types.MultipleShelfResponse, error) {
r.Header.Set("User-Agent", userAgent)
//r.Header.Set("Authorization", "Bearer "+c.Token)
r.Header.Set("Accept", "application/json")
r.Header.Set("Content-Type", "application/json")
httpCli := http.Client{}
httpResp, err := httpCli.Do(r)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
body, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}
resp, err := unmarshal[types.MultipleShelfResponse](body)
if err != nil {
return nil, fmt.Errorf("%s", "An unexpected response was received")
}
return resp, nil
}
func (c *APIClient) RetrieveAssetByID(idNumber string) (*types.Asset, error) {
url := fmt.Sprintf("%s/assets/%s", c.Host, idNumber)
req, _ := http.NewRequest("GET", url, bytes.NewBuffer([]byte{}))
resp, err := c.retrieveSingleAsset(req)
if err != nil {
return nil, err
}
return resp.Asset, nil
}
func (c *APIClient) RetrieveAllAssets() ([]*types.Asset, error) {
url := fmt.Sprintf("%s/assets", c.Host)
req, _ := http.NewRequest("GET", url, bytes.NewBuffer([]byte{}))
resp, err := c.retrieveMultipleAssets(req)
if err != nil {
return nil, err
}
return resp.Assets, nil
}
func (c *APIClient) DeleteAssetByID(idNumber string) (*types.Asset, error) {
url := fmt.Sprintf("%s/assets/%s", c.Host, idNumber)
req, _ := http.NewRequest("DELETE", url, bytes.NewBuffer([]byte{}))
resp, err := c.retrieveSingleAsset(req)
if err != nil {
return nil, err
}
return resp.Asset, nil
}
func (c *APIClient) RetrieveAllShelves() ([]*types.ShelfLocation, error) {
url := fmt.Sprintf("%s/shelves", c.Host)
req, _ := http.NewRequest("GET", url, bytes.NewBuffer([]byte{}))
resp, err := c.retrieveMultipleShelves(req)
if err != nil {
return nil, err
}
return resp.ShelfLocations, nil
}

+ 11
- 0
internal/api/utils.go View File

@ -0,0 +1,11 @@
package api
import (
"encoding/json"
)
func unmarshal[T any](data []byte) (*T, error) {
var r T
err := json.Unmarshal(data, &r)
return &r, err
}

+ 112
- 0
internal/app/app.go View File

@ -0,0 +1,112 @@
package app
import (
"os"
"git.brettb.xyz/goinv/client/internal/api"
"git.brettb.xyz/goinv/client/internal/ui/assets"
"git.brettb.xyz/goinv/client/internal/ui/help"
"git.brettb.xyz/goinv/client/internal/ui/utils"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"go.uber.org/zap"
)
type App struct {
*tview.Application
APIClient *api.APIClient
pages *tview.Pages
logger *zap.Logger
help *help.Help
assets *assets.Assets
menu *tview.TextView
currentPage string
needInitUI bool
}
func NewApp(name, version, host string, logger *zap.Logger) *App {
sugar := logger.Sugar()
sugar.Debug("creating app")
app := App{
Application: tview.NewApplication(),
APIClient: api.NewAPIClient(host),
pages: tview.NewPages(),
logger: logger,
needInitUI: false,
}
app.assets = assets.NewAssets(logger, app.APIClient)
app.help = help.NewHelp(name, version)
menuItems := [][]string{
{utils.HelpScreenKey.Label(), app.help.GetTitle()},
{utils.AssetsScreenKey.Label(), app.assets.GetTitle()},
}
app.menu = makeMenu(menuItems)
app.pages.AddPage(app.help.GetTitle(), app.help, true, false)
app.pages.AddPage(app.assets.GetTitle(), app.assets, true, false)
return &app
}
func (app *App) Run() error {
app.logger.Info("starting app")
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(app.pages, 0, 1, false).
AddItem(app.menu, 1, 1, false)
app.initUI()
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == utils.AppExitKey.Key {
app.logger.Info("stopping app")
app.Stop()
os.Exit(0)
}
if !app.frontScreenHasActiveDialog() {
event = utils.ParseKeyEventKey(app.logger, event)
// Next & Previous Screen
switch event.Rune() {
case utils.NextScreenKey.Rune():
app.switchToNextScreen()
return nil
case utils.PreviousScreenKey.Rune():
app.switchToPreviousScreen()
}
// Normal page key switch
switch event.Key() {
case utils.HelpScreenKey.EventKey():
app.switchToScreen(app.help.GetTitle())
return nil
case utils.AssetsScreenKey.EventKey():
app.switchToScreen(app.assets.GetTitle())
return nil
}
}
return event
})
app.currentPage = app.assets.GetTitle()
app.pages.SwitchToPage(app.currentPage)
// start the refresh loop
go app.refresh()
if err := app.SetRoot(flex, true).SetFocus(app.assets).EnableMouse(false).Run(); err != nil {
return err
}
return nil
}

+ 8
- 0
internal/app/init.go View File

@ -0,0 +1,8 @@
package app
func (app *App) initUI() {
// Assets page
app.assets.UpdateShelfData()
app.assets.UpdateAssetData()
}

+ 44
- 0
internal/app/menu.go View File

@ -0,0 +1,44 @@
package app
import (
"fmt"
"strings"
"git.brettb.xyz/goinv/client/internal/ui/style"
"github.com/rivo/tview"
)
func makeMenu(menuItems [][]string) *tview.TextView {
menu := tview.NewTextView().
SetDynamicColors(true).
SetWrap(true).
SetTextAlign(tview.AlignCenter)
menu.SetBackgroundColor(style.BgColor)
var menuList []string
for i, v := range menuItems {
key, item := genMenuItem(v)
if i == len(menuItems)-1 {
item += " "
}
menuList = append(menuList, key+item)
}
fmt.Fprintf(menu, "%s", strings.Join(menuList, " "))
return menu
}
func genMenuItem(items []string) (string, string) {
key := fmt.Sprintf("[%s::b] <%s>[-:-:-]", style.GetColorHex(style.MenuBgColor), items[0])
desc := fmt.Sprintf("[%s:%s:b] %s [-:-:-]",
style.GetColorHex(style.MenuFgColor),
style.GetColorHex(style.MenuBgColor),
strings.ToUpper(items[1]))
return key, desc
}

+ 19
- 0
internal/app/refresh.go View File

@ -0,0 +1,19 @@
package app
import (
"time"
"git.brettb.xyz/goinv/client/internal/ui/utils"
)
func (app *App) refresh() {
app.logger.Sugar().Debugf("starting app refresh loop (interval=%v)", utils.RefreshInterval)
tick := time.NewTicker(utils.RefreshInterval)
for {
<-tick.C
app.Application.Draw()
}
}

+ 56
- 0
internal/app/screens.go View File

@ -0,0 +1,56 @@
package app
func (app *App) switchToScreen(name string) {
app.logger.Sugar().Debugf("switching to %s screen", name)
app.pages.SwitchToPage(name)
app.setPageFocus(name)
app.updatePageData(name)
app.currentPage = name
}
func (app *App) frontScreenHasActiveDialog() bool {
switch app.currentPage {
case app.assets.GetTitle():
return app.assets.SubDialogHasFocus()
}
return false
}
func (app *App) switchToPreviousScreen() {
var previousScreen string
switch app.currentPage {
case app.help.GetTitle():
previousScreen = app.assets.GetTitle()
case app.assets.GetTitle():
previousScreen = app.help.GetTitle()
}
app.switchToScreen(previousScreen)
}
func (app *App) switchToNextScreen() {
var nextScreen string
switch app.currentPage {
case app.help.GetTitle():
nextScreen = app.assets.GetTitle()
case app.assets.GetTitle():
nextScreen = app.help.GetTitle()
}
app.switchToScreen(nextScreen)
}
func (app *App) setPageFocus(page string) {
switch page {
case app.help.GetTitle():
app.Application.SetFocus(app.help)
case app.assets.GetTitle():
app.Application.SetFocus(app.assets)
}
}
func (app *App) updatePageData(page string) {
switch page {
case app.assets.GetTitle():
app.assets.UpdateData()
}
}

+ 42
- 0
internal/types/assets.go View File

@ -0,0 +1,42 @@
package types
import (
"time"
)
type AssetResponse struct {
Asset *Asset `json:"asset"`
}
type MultipleAssetsResponse struct {
Assets []*Asset `json:"assets"`
Total int `json:"total"`
}
type CreateAssetRequest struct {
Name string `json:"name"`
Quantity int `json:"quantity"`
Length string `json:"length,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
ModelName string `json:"model_name,omitempty"`
Price float64 `json:"price,omitempty"`
Comments string `json:"comments,omitempty"`
ShelfLocationID int `json:"shelf_location_id,omitempty"`
CategoryID int `json:"category_id,omitempty"`
}
type Asset struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Quantity int `json:"quantity"`
Length string `json:"length,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
ModelName string `json:"model_name,omitempty"`
Price float64 `json:"price,omitempty"`
Comments string `json:"comments,omitempty"`
ShelfLocation *ShelfLocation `json:"shelf_location,omitempty"`
Category *Category `json:"category,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}

+ 11
- 0
internal/types/buildings.go View File

@ -0,0 +1,11 @@
package types
import "time"
type Building struct {
ID uint64 `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}

+ 11
- 0
internal/types/categories.go View File

@ -0,0 +1,11 @@
package types
import "time"
type Category struct {
ID uint64 `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}

+ 23
- 0
internal/types/shelves.go View File

@ -0,0 +1,23 @@
package types
import "time"
type ShelfResponse struct {
ShelfLocation *ShelfLocation `json:"shelf"`
}
type MultipleShelfResponse struct {
ShelfLocations []*ShelfLocation `json:"shelves"`
Total int `json:"total"`
}
type ShelfLocation struct {
ID uint64 `json:"id"`
Name string `json:"name"`
RoomNumber string `json:"room_number,omitempty"`
Description string `json:"description,omitempty"`
BuildingID *uint64 `json:"building_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}

+ 216
- 0
internal/ui/assets/assets.go View File

@ -0,0 +1,216 @@
package assets
import (
"sync"
"git.brettb.xyz/goinv/client/internal/api"
"git.brettb.xyz/goinv/client/internal/types"
"git.brettb.xyz/goinv/client/internal/ui/dialogs"
"git.brettb.xyz/goinv/client/internal/ui/style"
"git.brettb.xyz/goinv/client/internal/ui/utils"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"go.uber.org/zap"
)
const (
status_CONFIRM_DELETE_ASSET = "delete_asset"
)
type Assets struct {
*tview.Box
client *api.APIClient
title string
logger *zap.Logger
assetTable *tview.Table
assetList assetListReport
shelfLocationCache shelfListReport
assetTableHeaders []string
assetTableExpansions []int
cmdDialog *dialogs.CommandDialog
confirmDialog *dialogs.ConfirmDialog
errorDialog *dialogs.ErrorDialog
progressDialog *dialogs.ProgressDialog
messageDialog *dialogs.MessageDialog
allDialogs []dialogs.Dialog
confirmData string
assetListFunc func() ([]types.Asset, error)
shelfListFunc func() (map[uint64]types.ShelfLocation, error)
}
type assetSelectedItem struct {
id string
item string
quantity string
shelfLocation string
manufacturer string
model string
category string
}
type assetListReport struct {
mu sync.Mutex
report []types.Asset
dirty bool
}
type shelfListReport struct {
mu sync.Mutex
report map[uint64]types.ShelfLocation
}
func NewAssets(logger *zap.Logger, client *api.APIClient) *Assets {
assets := &Assets{
Box: tview.NewBox(),
client: client,
title: "assets",
logger: logger,
assetTable: tview.NewTable(),
assetTableHeaders: []string{"id", "item", "quantity", "shelf location", "manufacturer", "model", "category"},
assetTableExpansions: []int{1, 4, 1, 2, 2, 2, 2},
confirmDialog: dialogs.NewConfirmDialog(logger),
errorDialog: dialogs.NewErrorDialog(logger),
progressDialog: dialogs.NewProgressDialog(logger),
messageDialog: dialogs.NewMessageDialog(logger, ""),
}
assets.assetTable.SetBackgroundColor(style.BgColor)
assets.assetTable.SetBorder(true)
assets.updateAssetTableTitle(0)
assets.assetTable.SetTitleColor(style.FgColor)
assets.assetTable.SetBorderColor(style.BorderColor)
assets.assetTable.SetFixed(1, 1)
assets.assetTable.SetSelectable(true, false)
assets.writeTableHeaders()
assets.assetTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if assets.assetTable.GetRowCount() <= 1 {
return nil
}
return event
})
assets.cmdDialog = dialogs.NewCommandDialog(logger, [][]string{
{"create asset", "create a new asset"},
{"view asset", "view the selected asset"},
{"delete asset", "delete the selected asset"},
{"refresh", "refresh the page"},
})
assets.cmdDialog.SetSelectedFunc(func() {
assets.cmdDialog.Hide()
assets.runCommand(assets.cmdDialog.GetSelectedItem())
}).SetCancelFunc(func() {
assets.cmdDialog.Hide()
})
assets.confirmDialog.SetSelectedFunc(func() {
assets.confirmDialog.Hide()
switch assets.confirmData {
case status_CONFIRM_DELETE_ASSET:
assets.delete()
}
}).SetCancelFunc(func() {
assets.confirmDialog.Hide()
})
assets.messageDialog.SetCancelFunc(func() {
assets.messageDialog.Hide()
})
assets.SetAssetListFunc(func() ([]types.Asset, error) {
if asp, err := assets.client.RetrieveAllAssets(); err != nil {
return nil, err
} else {
var aso []types.Asset
for _, a := range asp {
aso = append(aso, *a)
}
return aso, nil
}
})
assets.SetShelfListFunc(func() (map[uint64]types.ShelfLocation, error) {
if resp, err := assets.client.RetrieveAllShelves(); err != nil {
return nil, err
} else {
shelves := map[uint64]types.ShelfLocation{}
for _, a := range resp {
shelves[a.ID] = *a
}
return shelves, nil
}
})
assets.allDialogs = []dialogs.Dialog{
assets.errorDialog,
assets.messageDialog,
assets.progressDialog,
assets.confirmDialog,
assets.cmdDialog,
}
return assets
}
func (a *Assets) GetTitle() string {
return a.title
}
func (a *Assets) HasFocus() bool {
return dialogs.CheckDialogFocus(a.allDialogs...) || utils.CheckFocus(a.assetTable, a.Box)
}
func (a *Assets) SubDialogHasFocus() bool {
return dialogs.CheckDialogFocus(a.allDialogs...)
}
func (a *Assets) Focus(delegate func(tview.Primitive)) {
for _, dialog := range a.allDialogs {
if dialog.IsDisplay() {
delegate(dialog)
return
}
}
delegate(a.assetTable)
}
func (a *Assets) SetAssetListFunc(list func() ([]types.Asset, error)) {
a.assetListFunc = list
}
func (a *Assets) SetShelfListFunc(list func() (map[uint64]types.ShelfLocation, error)) {
a.shelfListFunc = list
}
func (a *Assets) hideAllDialogs() {
for _, dialog := range a.allDialogs {
dialog.Hide()
}
}
func (a *Assets) getSelectedItem() *assetSelectedItem {
selectedItem := assetSelectedItem{}
if a.assetTable.GetRowCount() <= 1 {
return nil
}
row, _ := a.assetTable.GetSelection()
selectedItem.id = a.assetTable.GetCell(row, 0).Text
selectedItem.item = a.assetTable.GetCell(row, 1).Text
selectedItem.quantity = a.assetTable.GetCell(row, 2).Text
selectedItem.shelfLocation = a.assetTable.GetCell(row, 3).Text
selectedItem.manufacturer = a.assetTable.GetCell(row, 4).Text
selectedItem.model = a.assetTable.GetCell(row, 5).Text
selectedItem.category = a.assetTable.GetCell(row, 6).Text
return &selectedItem
}

+ 103
- 0
internal/ui/assets/command.go View File

@ -0,0 +1,103 @@
package assets
import (
"fmt"
"git.brettb.xyz/goinv/client/internal/ui/dialogs"
"git.brettb.xyz/goinv/client/internal/ui/style"
)
func (a *Assets) runCommand(cmd string) {
switch cmd {
case "create asset", "view asset":
a.cNotImplemented()
return
case "delete asset":
a.cdelete()
return
case "refresh":
a.crefresh()
}
}
func (a *Assets) cNotImplemented() {
a.displayError("not implemented", fmt.Errorf("this command has not been implemented"))
}
// Confirm deletion
func (a *Assets) cdelete() {
selectedItem := a.getSelectedItem()
// Empty table
if selectedItem == nil {
a.displayError("DELETE ASSET ERROR", fmt.Errorf("no assets to delete"))
return
}
title := "delete asset"
a.confirmDialog.SetTitle(title)
a.confirmData = status_CONFIRM_DELETE_ASSET
bgColor := style.GetColorHex(style.DialogBorderColor)
fgColor := style.GetColorHex(style.DialogFgColor)
assetName := fmt.Sprintf("[%s:%s:b]ASSET NAME:[:-:-] %s", fgColor, bgColor, selectedItem.item)
assetQuantity := fmt.Sprintf(" [%s:%s:b]QUANTITY:[:-:-] %s", fgColor, bgColor, selectedItem.quantity)
confirmMsg := fmt.Sprintf("%s\n%s\nAre you sure you want to delete the selected asset ?", assetName, assetQuantity)
a.confirmDialog.SetText(confirmMsg)
a.confirmDialog.Display()
}
func (a *Assets) delete() {
selectedItem := a.getSelectedItem()
a.progressDialog.SetTitle(fmt.Sprintf("deleting asset %s", selectedItem.id))
a.progressDialog.Display()
del := func() {
_, err := a.client.DeleteAssetByID(selectedItem.id)
a.progressDialog.Hide()
if err != nil {
a.displayError("DELETE ASSET ERROR", err)
return
}
// display success message
a.messageDialog.SetTitle(fmt.Sprintf("deleting asset %s", selectedItem.id))
a.messageDialog.SetText(dialogs.MessageGeneric, "Success!", fmt.Sprintf("Asset %s successfully deleted.", selectedItem.id))
a.messageDialog.Display()
a.UpdateAssetData()
}
del()
}
func (a *Assets) crefresh() {
a.progressDialog.SetTitle("refreshing assets")
a.progressDialog.Display()
ref := func() {
a.UpdateShelfData()
a.UpdateAssetData()
a.progressDialog.Hide()
if !a.errorDialog.IsDisplay() {
a.messageDialog.SetTitle(fmt.Sprintf("asset refresh"))
a.messageDialog.SetText(dialogs.MessageGeneric, "Refreshed!", "Successfully refreshed page.")
a.messageDialog.Display()
}
}
ref()
}
func (a *Assets) displayError(title string, err error) {
a.errorDialog.SetTitle(title)
a.errorDialog.SetText(fmt.Sprintf("%v", err))
a.errorDialog.Display()
}

+ 48
- 0
internal/ui/assets/data.go View File

@ -0,0 +1,48 @@
package assets
import "git.brettb.xyz/goinv/client/internal/types"
func (a *Assets) UpdateData() {
a.UpdateShelfData()
a.UpdateAssetData()
}
func (a *Assets) UpdateAssetData() {
assets, err := a.assetListFunc()
a.assetList.mu.Lock()
defer a.assetList.mu.Unlock()
if err != nil {
a.displayError("could not retrieve assets", err)
a.assetList.dirty = true
return
}
a.assetList.dirty = false
a.assetList.report = assets
}
func (a *Assets) UpdateShelfData() {
shelves, err := a.shelfListFunc()
if err != nil {
a.displayError("could not retrieve shelves", err)
return
}
a.shelfLocationCache.mu.Lock()
a.shelfLocationCache.report = shelves
a.shelfLocationCache.mu.Unlock()
}
func (a *Assets) getAssetData() []types.Asset {
a.assetList.mu.Lock()
assetReport := a.assetList.report
defer a.assetList.mu.Unlock()
return assetReport
}
func (a *Assets) getShelfData() map[uint64]types.ShelfLocation {
a.shelfLocationCache.mu.Lock()
shelfReport := a.shelfLocationCache.report
defer a.shelfLocationCache.mu.Unlock()
return shelfReport
}

+ 24
- 0
internal/ui/assets/draw.go View File

@ -0,0 +1,24 @@
package assets
import "github.com/gdamore/tcell/v2"
func (a *Assets) Draw(screen tcell.Screen) {
a.refresh()
a.Box.DrawForSubclass(screen, a)
x, y, width, height := a.GetInnerRect()
a.assetTable.SetRect(x, y, width, height)
a.assetTable.Draw(screen)
x, y, width, height = a.assetTable.GetInnerRect()
for _, diag := range a.allDialogs {
if diag.IsDisplay() {
diag.SetRect(x, y, width, height)
diag.Draw(screen)
return
}
}
}

+ 54
- 0
internal/ui/assets/key.go View File

@ -0,0 +1,54 @@
package assets
import (
"git.brettb.xyz/goinv/client/internal/ui/utils"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func (a *Assets) InputHandler() func(*tcell.EventKey, func(tview.Primitive)) {
return a.WrapInputHandler(func(event *tcell.EventKey, setFocus func(tview.Primitive)) {
a.logger.Sugar().Debugf("assets event %v received", event)
if a.progressDialog.IsDisplay() {
setFocus(a.progressDialog)
return
}
if a.errorDialog.HasFocus() {
if errorHandler := a.errorDialog.InputHandler(); errorHandler != nil {
errorHandler(event, setFocus)
}
}
if a.messageDialog.HasFocus() {
if messageHandler := a.messageDialog.InputHandler(); messageHandler != nil {
messageHandler(event, setFocus)
}
}
if a.confirmDialog.HasFocus() {
if confirmHandler := a.confirmDialog.InputHandler(); confirmHandler != nil {
confirmHandler(event, setFocus)
}
}
if a.cmdDialog.HasFocus() {
if cmdHandler := a.cmdDialog.InputHandler(); cmdHandler != nil {
cmdHandler(event, setFocus)
}
}
if a.assetTable.HasFocus() {
if event.Rune() == utils.CommandMenuKey.Rune() {
a.cmdDialog.Display()
} else {
if tableHandler := a.assetTable.InputHandler(); tableHandler != nil {
tableHandler(event, setFocus)
}
}
}
setFocus(a)
})
}

+ 97
- 0
internal/ui/assets/refresh.go View File

@ -0,0 +1,97 @@
package assets
import (
"fmt"
"strings"
"git.brettb.xyz/goinv/client/internal/ui/style"
"github.com/rivo/tview"
)
const tableHeaderOffset = 1
func (a *Assets) refresh() {
assets := a.getAssetData()
a.assetTable.Clear()
a.updateAssetTableTitle(len(assets))
a.writeTableHeaders()
for i, asset := range assets {
row := i + tableHeaderOffset
a.assetTable.SetCell(row, 0,
tview.NewTableCell(fmt.Sprintf("%d", asset.ID)).
SetExpansion(a.assetTableExpansions[0]).
SetAlign(tview.AlignLeft))
a.assetTable.SetCell(row, 1,
tview.NewTableCell(asset.Name).
SetExpansion(a.assetTableExpansions[1]).
SetAlign(tview.AlignLeft))
quantity := ""
if asset.Quantity < 0 {
quantity = "DNI"
} else {
quantity = fmt.Sprintf("%d", asset.Quantity)
}
a.assetTable.SetCell(row, 2,
tview.NewTableCell(quantity).
SetExpansion(a.assetTableExpansions[2]).
SetAlign(tview.AlignLeft))
shelfLocation := ""
if asset.ShelfLocation != nil {
shelfLocation = asset.ShelfLocation.Name
}
a.assetTable.SetCell(row, 3,
tview.NewTableCell(shelfLocation).
SetExpansion(a.assetTableExpansions[3]).
SetAlign(tview.AlignLeft))
a.assetTable.SetCell(row, 4,
tview.NewTableCell(asset.Manufacturer).
SetExpansion(a.assetTableExpansions[4]).
SetAlign(tview.AlignLeft))
a.assetTable.SetCell(row, 5,
tview.NewTableCell(asset.ModelName).
SetExpansion(a.assetTableExpansions[5]).
SetAlign(tview.AlignLeft))
category := ""
if asset.Category != nil {
category = asset.Category.Name
}
a.assetTable.SetCell(row, 6,
tview.NewTableCell(category).
SetExpansion(a.assetTableExpansions[6]).
SetAlign(tview.AlignLeft))
}
}
func (a *Assets) updateAssetTableTitle(count int) {
dirtyFlag := ""
if a.assetList.dirty {
dirtyFlag = "*"
}
title := fmt.Sprintf("[::b]ASSETS [%s%d]", dirtyFlag, count)
a.assetTable.SetTitle(title)
}
func (a *Assets) writeTableHeaders() {
for i, headerText := range a.assetTableHeaders {
header := fmt.Sprintf("[::b]%s", strings.ToUpper(headerText))
a.assetTable.SetCell(0, i,
tview.NewTableCell(header).
SetExpansion(a.assetTableExpansions[i]).
SetBackgroundColor(style.TableHeaderBgColor).
SetTextColor(style.TableHeaderFgColor).
SetAlign(tview.AlignLeft).
SetSelectable(false))
}
}

+ 300
- 0
internal/ui/dialogs/command.go View File

@ -0,0 +1,300 @@
package dialogs
import (
"fmt"
"git.brettb.xyz/goinv/client/internal/ui/style"
"git.brettb.xyz/goinv/client/internal/ui/utils"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"go.uber.org/zap"
)
const (
cmdWidthOffset = 6
)
const (
cmdTableFocus = 0 + iota
cmdFormFocus
)
type CommandDialog struct {
*tview.Box
layout *tview.Flex
table *tview.Table
form *tview.Form
logger *zap.Logger
display bool
options [][]string
width int
height int
focusElement int
selectedStyle tcell.Style
cancelHandler func()
selectHandler func()
}
func NewCommandDialog(logger *zap.Logger, options [][]string) *CommandDialog {
form := tview.NewForm().
AddButton("Cancel", nil).
SetButtonsAlign(tview.AlignRight)
form.SetBackgroundColor(style.DialogBgColor)
form.SetButtonBackgroundColor(style.ButtonBgColor)
form.SetButtonTextColor(style.ButtonFgColor)
activatedStyle := tcell.StyleDefault.
Background(style.ButtonSelectedBgColor).
Foreground(style.ButtonSelectedFgColor)
form.SetButtonActivatedStyle(activatedStyle)
cmdsTable := tview.NewTable()
cmdsTable.SetBackgroundColor(style.DialogBgColor)
cmdWidth := 0
cmdsTable.SetCell(0, 0,
tview.NewTableCell(fmt.Sprintf("[%s::b]COMMAND", style.GetColorHex(style.TableHeaderFgColor))).
SetExpansion(1).
SetBackgroundColor(style.TableHeaderBgColor).
SetTextColor(style.TableHeaderFgColor).
SetAlign(tview.AlignLeft).
SetSelectable(false))
cmdsTable.SetCell(0, 1,
tview.NewTableCell(fmt.Sprintf("[%s::b]DESCRIPTION", style.GetColorHex(style.TableHeaderFgColor))).
SetExpansion(1).
SetBackgroundColor(style.TableHeaderBgColor).
SetTextColor(style.TableHeaderFgColor).
SetAlign(tview.AlignCenter).
SetSelectable(false))
col1Width := 0
col2Width := 0
for i, option := range options {
cmdsTable.SetCell(i+1, 0,
tview.NewTableCell(option[0]).
SetAlign(tview.AlignLeft).
SetSelectable(true).SetTextColor(style.DialogFgColor))
cmdsTable.SetCell(i+1, 1,
tview.NewTableCell(option[1]).
SetAlign(tview.AlignLeft).
SetSelectable(true).SetTextColor(style.DialogFgColor))
if len(option[0]) > col1Width {
col1Width = len(option[0])
}
if len(option[1]) > col2Width {
col2Width = len(option[1])
}
}
cmdWidth = col1Width + col2Width + 2
cmdsTable.SetFixed(1, 1)
cmdsTable.SetSelectable(true, false)
cmdsTable.SetBackgroundColor(style.DialogBgColor)
cmdLayout := tview.NewFlex().SetDirection(tview.FlexColumn)
cmdLayout.AddItem(utils.EmptyBoxSpace(style.DialogBgColor), 1, 0, false)
cmdLayout.AddItem(cmdsTable, 0, 1, true)
cmdLayout.AddItem(utils.EmptyBoxSpace(style.DialogBgColor), 1, 0, false)
layout := tview.NewFlex().SetDirection(tview.FlexRow)
layout.AddItem(cmdLayout, 0, 1, true)
layout.AddItem(form, DialogFormHeight, 0, true)
layout.SetBorder(true)
layout.SetBorderColor(style.DialogBorderColor)
layout.SetBackgroundColor(style.DialogBgColor)
selectedStyle := tcell.StyleDefault.
Background(style.TableSelectedBgColor).
Foreground(style.TableSelectedFgColor)
cmdsTable.SetSelectedStyle(selectedStyle)
return &CommandDialog{
Box: tview.NewBox().SetBorder(false),
layout: layout,
table: cmdsTable,
form: form,
display: false,
options: options,
width: cmdWidth + cmdWidthOffset,
height: len(options) + TableHeightOffset + DialogFormHeight,
focusElement: cmdTableFocus,
selectedStyle: selectedStyle,
logger: logger,
}
}
// GetSelectedItem returns selected row item.
func (cmd *CommandDialog) GetSelectedItem() string {
row, _ := cmd.table.GetSelection()
if row >= 0 {
return cmd.options[row-1][0]
}
return ""
}
// GetCommandCount returns number of commands
func (cmd *CommandDialog) GetCommandCount() int {
return cmd.table.GetRowCount()
}
// Display this primitive.
func (cmd *CommandDialog) Display() {
cmd.table.Select(1, 0)
cmd.form.SetFocus(1)
cmd.display = true
}
// Hide this primitive
func (cmd *CommandDialog) Hide() {
cmd.display = false
cmd.focusElement = cmdTableFocus
cmd.table.SetSelectedStyle(cmd.selectedStyle)
}
// HasFocus returns whether this primitive has focus
func (cmd *CommandDialog) HasFocus() bool {
return utils.CheckFocus(cmd.table, cmd.form)
}
func (cmd *CommandDialog) IsDisplay() bool {
return cmd.display
}
func (cmd *CommandDialog) Focus(delegate func(tview.Primitive)) {
if cmd.focusElement == cmdTableFocus {
delegate(cmd.table)
return
}
button := cmd.form.GetButton(cmd.form.GetButtonCount() - 1)
button.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == utils.SwitchFocusKey.Key {
cmd.focusElement = cmdTableFocus
cmd.Focus(delegate)
cmd.form.SetFocus(0)
return nil
}
return event
})
delegate(cmd.form)
}
func (cmd *CommandDialog) InputHandler() func(event *tcell.EventKey, setFocus func(primitive tview.Primitive)) {
return cmd.WrapInputHandler(func(event *tcell.EventKey, setFocus func(primitive tview.Primitive)) {
cmd.logger.Sugar().Debugf("command dialog event %v received", event)
if event.Key() == utils.CloseDialogKey.Key {
cmd.cancelHandler()
return
}
if event.Key() == utils.SwitchFocusKey.Key {
cmd.setFocusElement()
}
if cmd.form.HasFocus() {
if formHandler := cmd.form.InputHandler(); formHandler != nil {
formHandler(event, setFocus)
return
}
}
if cmd.table.HasFocus() {
if event.Key() == tcell.KeyEnter {
cmd.selectHandler()
return
}
if tableHandler := cmd.table.InputHandler(); tableHandler != nil {
tableHandler(event, setFocus)
return
}
}
})
}
// SetSelectedFunc sets the form enter button selected function
func (cmd *CommandDialog) SetSelectedFunc(handler func()) *CommandDialog {
cmd.selectHandler = handler
return cmd
}
// SetCancelFunc sets form cancel button selected function.
func (cmd *CommandDialog) SetCancelFunc(handler func()) *CommandDialog {
cmd.cancelHandler = handler
cancelButton := cmd.form.GetButton(cmd.form.GetButtonCount() - 1)
cancelButton.SetSelectedFunc(handler)
return cmd
}
// SetRect set rects for this primitive
func (cmd *CommandDialog) SetRect(x, y, width, height int) {
ws := (width - cmd.width) / 2
hs := (height - cmd.height) / 2
dy := y + hs
bWidth := cmd.width
if cmd.width > width {
ws = 0
bWidth = width - 1
}
bHeight := cmd.height
if cmd.height > height {
dy = y + 1
bHeight = height - 1
}
cmd.Box.SetRect(x+ws, dy, bWidth, bHeight)
x, y, width, height = cmd.Box.GetInnerRect()
cmd.layout.SetRect(x, y, width, height)
}
func (cmd *CommandDialog) Draw(screen tcell.Screen) {
if !cmd.display {
return
}
cmd.Box.DrawForSubclass(screen, cmd)
cmd.layout.Draw(screen)
}
func (cmd *CommandDialog) setFocusElement() {
if cmd.focusElement == cmdTableFocus {
cmd.focusElement = cmdFormFocus
cmd.table.SetSelectedStyle(tcell.StyleDefault.
Background(style.DialogBgColor).
Foreground(style.DialogFgColor))
} else {
cmd.focusElement = cmdTableFocus
cmd.table.SetSelectedStyle(cmd.selectedStyle)
}
}

+ 203
- 0
internal/ui/dialogs/confirm.go View File

@ -0,0 +1,203 @@
package dialogs
import (
"strings"
"git.brettb.xyz/goinv/client/internal/ui/style"
"git.brettb.xyz/goinv/client/internal/ui/utils"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"go.uber.org/zap"
)
type ConfirmDialog struct {
*tview.Box
logger *zap.Logger
layout *tview.Flex
textview *tview.TextView
form *tview.Form
x int
y int
width int
height int
message string
display bool
cancelHandler func()
selectHandler func()
}
func NewConfirmDialog(logger *zap.Logger) *ConfirmDialog {
dialog := &ConfirmDialog{
Box: tview.NewBox(),
logger: logger,
display: false,
}
dialog.textview = tview.NewTextView().
SetDynamicColors(true).
SetWrap(true).
SetTextAlign(tview.AlignLeft)
dialog.textview.SetBackgroundColor(style.DialogBgColor)
dialog.textview.SetTextColor(style.DialogFgColor)
dialog.form = tview.NewForm().
AddButton("Cancel", nil).
AddButton(" OK ", nil).
SetButtonsAlign(tview.AlignRight)
dialog.form.SetBackgroundColor(style.DialogBgColor)
dialog.form.SetButtonBackgroundColor(style.ButtonBgColor)
dialog.form.SetButtonTextColor(style.ButtonFgColor)
activatedStyle := tcell.StyleDefault.
Background(style.ButtonSelectedBgColor).
Foreground(style.ButtonSelectedFgColor)
dialog.form.SetButtonActivatedStyle(activatedStyle)
dialog.layout = tview.NewFlex().SetDirection(tview.FlexRow)
dialog.layout.SetBorder(true)
dialog.layout.SetBorderColor(style.DialogBorderColor)
dialog.layout.SetBackgroundColor(style.DialogBgColor)
return dialog
}
func (d *ConfirmDialog) Display() {
d.display = true
d.form.SetFocus(1)
}
func (d *ConfirmDialog) IsDisplay() bool {
return d.display
}
func (d *ConfirmDialog) Hide() {
d.textview.SetText("")
d.message = ""
d.display = false
}
func (d *ConfirmDialog) SetTitle(title string) {
d.layout.SetTitle(strings.ToUpper(title))
d.layout.SetTitleColor(style.DialogFgColor)
}
func (d *ConfirmDialog) SetText(message string) {
d.message = message
d.textview.Clear()
msg := "\n" + message
d.textview.SetText(msg)
d.textview.ScrollToBeginning()
d.setRect()
}
func (d *ConfirmDialog) Focus(delegate func(tview.Primitive)) {
delegate(d.form)
}
func (d *ConfirmDialog) HasFocus() bool {
return d.form.HasFocus()
}
func (d *ConfirmDialog) SetRect(x, y, width, height int) {
d.x = x + DialogPadding
d.y = y + DialogPadding
d.width = width - (2 * DialogPadding) //nolint:gomnd
d.height = height - (2 * DialogPadding) //nolint:gomnd
d.setRect()
}
func (d *ConfirmDialog) setRect() {
maxHeight := d.height
maxWidth := d.width
messageHeight := len(strings.Split(d.message, "\n"))
messageWidth := getMessageWidth(d.message)
layoutHeight := messageHeight + 2 //nolint:gomnd
if maxHeight > layoutHeight+DialogFormHeight {
d.height = layoutHeight + DialogFormHeight + 2 //nolint:gomnd
} else {
d.height = maxHeight
layoutHeight = d.height - DialogFormHeight - 2 //nolint:gomnd
}
if maxHeight > d.height {
emptyHeight := (maxHeight - d.height) / 2 //nolint:gomnd
d.y += emptyHeight
}
if d.width > DialogMinWidth {
if messageWidth < DialogMinWidth {
d.width = DialogMinWidth + 2 //nolint:gomnd
} else if messageWidth < d.width {
d.width = messageWidth + 2 //nolint:gomnd
}
}
if maxWidth > d.width {
emptyWidth := (maxWidth - d.width) / 2 //nolint:gomnd
d.x += emptyWidth
}
msgLayout := tview.NewFlex().SetDirection(tview.FlexColumn)
msgLayout.AddItem(utils.EmptyBoxSpace(style.DialogBgColor), 1, 0, false)
msgLayout.AddItem(d.textview, 0, 1, true)
msgLayout.AddItem(utils.EmptyBoxSpace(style.DialogBgColor), 1, 0, false)
d.layout.Clear()
d.layout.AddItem(msgLayout, layoutHeight, 0, true)
d.layout.AddItem(d.form, DialogFormHeight, 0, true)
d.Box.SetRect(d.x, d.y, d.width, d.height)
}
// Draw draws this primitive onto the screen.
func (d *ConfirmDialog) Draw(screen tcell.Screen) {
if !d.display {
return
}
d.Box.DrawForSubclass(screen, d)
x, y, width, height := d.Box.GetInnerRect()
d.layout.SetRect(x, y, width, height)
d.layout.Draw(screen)
}
func (d *ConfirmDialog) InputHandler() func(*tcell.EventKey, func(tview.Primitive)) {
return d.WrapInputHandler(func(event *tcell.EventKey, setFocus func(tview.Primitive)) {
d.logger.Sugar().Debugf("confirm dialog event %v received", event)
if event.Key() == utils.CloseDialogKey.EventKey() {
d.cancelHandler()
return
}
if formHandler := d.form.InputHandler(); formHandler != nil {
formHandler(event, setFocus)
return
}
})
}
func (d *ConfirmDialog) SetCancelFunc(handler func()) *ConfirmDialog {
d.cancelHandler = handler
cancelButton := d.form.GetButton(d.form.GetButtonCount() - 2)
cancelButton.SetSelectedFunc(handler)
return d
}
func (d *ConfirmDialog) SetSelectedFunc(handler func()) *ConfirmDialog {
d.selectHandler = handler
enterButton := d.form.GetButton(d.form.GetButtonCount() - 1)
enterButton.SetSelectedFunc(handler)
return d
}

+ 106
- 0
internal/ui/dialogs/error.go View File

@ -0,0 +1,106 @@
package dialogs
import (
"fmt"
"git.brettb.xyz/goinv/client/internal/ui/style"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"go.uber.org/zap"
)
type ErrorDialog struct {
*tview.Box
logger *zap.Logger
modal *tview.Modal
title string
message string
display bool
}
func NewErrorDialog(logger *zap.Logger) *ErrorDialog {
bgColor := style.ErrorDialogBgColor
dialog := ErrorDialog{
Box: tview.NewBox(),
logger: logger,
modal: tview.NewModal().SetBackgroundColor(bgColor).AddButtons([]string{"OK"}),
display: false,
}
dialog.modal.SetButtonBackgroundColor(style.ErrorDialogButtonBgColor)
dialog.modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
dialog.Hide()
})
return &dialog
}
func (e *ErrorDialog) Display() {
e.display = true
}
func (e *ErrorDialog) Hide() {
e.SetText("")
e.title = ""
e.message = ""
e.display = false
}
func (e *ErrorDialog) IsDisplay() bool {
return e.display
}
func (e *ErrorDialog) SetText(message string) {
e.message = message
}
func (e *ErrorDialog) SetTitle(title string) {
e.title = title
}
func (e *ErrorDialog) HasFocus() bool {
return e.modal.HasFocus()
}
func (e *ErrorDialog) Focus(delegate func(tview.Primitive)) {
delegate(e.modal)
}
func (e *ErrorDialog) InputHandler() func(*tcell.EventKey, func(tview.Primitive)) {
return e.WrapInputHandler(func(event *tcell.EventKey, setFocus func(tview.Primitive)) {
e.logger.Sugar().Debugf("error dialog event %v received", event)
if modalHandler := e.modal.InputHandler(); modalHandler != nil {
modalHandler(event, setFocus)
return
}
})
}
func (e *ErrorDialog) SetRect(x, y, width, height int) {
e.Box.SetRect(x, y, width, height)
}
func (e *ErrorDialog) Draw(screen tcell.Screen) {
hFgColor := style.FgColor
headerColor := style.GetColorHex(hFgColor)
var errorMessage string
if e.title != "" {
errorMessage = fmt.Sprintf("[%s::b]%s[-::-]\n", headerColor, e.title)
}
errorMessage += e.message
e.modal.SetText(errorMessage)
e.modal.Draw(screen)
}
func (e *ErrorDialog) SetDoneFunc(handler func()) *ErrorDialog {
e.modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
handler()
})
return e
}

+ 238
- 0
internal/ui/dialogs/message.go View File

@ -0,0 +1,238 @@
package dialogs
import (
"strings"
"git.brettb.xyz/goinv/client/internal/ui/style"
"git.brettb.xyz/goinv/client/internal/ui/utils"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"go.uber.org/zap"
)
type MessageDialog struct {
*tview.Box
logger *zap.Logger
layout *tview.Flex
infoType *tview.InputField
textView *tview.TextView
form *tview.Form
display bool
message string
cancelHandler func()
}
type messageInfo int
const (
MessageGeneric messageInfo = iota
)
func NewMessageDialog(logger *zap.Logger, text string) *MessageDialog {
dialog := MessageDialog{
Box: tview.NewBox(),
logger: logger,
infoType: tview.NewInputField(),
display: false,
message: text,
}
dialog.infoType.SetBackgroundColor(style.ButtonBgColor)
dialog.infoType.SetFieldStyle(tcell.StyleDefault.
Background(style.ButtonBgColor).
Foreground(style.ButtonFgColor))
dialog.infoType.SetLabelStyle(tcell.StyleDefault.
Background(style.ButtonBgColor).
Foreground(style.ButtonFgColor))
dialog.textView = tview.NewTextView().
SetDynamicColors(true).
SetWrap(true).
SetTextAlign(tview.AlignLeft)
dialog.textView.SetBackgroundColor(style.DialogSubBoxBgColor)
dialog.textView.SetBorderColor(style.DialogSubBoxBorderColor)
dialog.textView.SetBorder(true)
dialog.textView.SetTextStyle(tcell.StyleDefault.
Background(style.DialogSubBoxBgColor).
Foreground(style.DialogSubBoxFgColor))
tlayout := tview.NewFlex().SetDirection(tview.FlexColumn)
tlayout.AddItem(utils.EmptyBoxSpace(style.DialogBgColor), 1, 0, false)
tlayout.AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(dialog.infoType, 1, 0, false).
AddItem(utils.EmptyBoxSpace(style.DialogBgColor), 1, 0, false).
AddItem(dialog.textView, 0, 1, true),
0, 1, true)
tlayout.AddItem(utils.EmptyBoxSpace(style.DialogBgColor), 1, 0, false)
dialog.form = tview.NewForm().
AddButton("Cancel", nil).
SetButtonsAlign(tview.AlignRight)
dialog.form.SetFocus(0)
dialog.form.SetBackgroundColor(style.DialogBgColor)
dialog.form.SetButtonBackgroundColor(style.ButtonBgColor)
dialog.form.SetButtonTextColor(style.ButtonFgColor)
dialog.form.SetButtonActivatedStyle(tcell.StyleDefault.
Background(style.ButtonSelectedBgColor).
Foreground(style.ButtonSelectedFgColor))
dialog.layout = tview.NewFlex().SetDirection(tview.FlexRow)
dialog.layout.AddItem(utils.EmptyBoxSpace(style.DialogBgColor), 1, 0, false)
dialog.layout.AddItem(tlayout, 0, 1, true)
dialog.layout.AddItem(dialog.form, DialogFormHeight, 0, true)
dialog.layout.SetBorder(true)
dialog.layout.SetBorderColor(style.DialogBorderColor)
dialog.layout.SetBackgroundColor(style.DialogBgColor)
dialog.layout.SetTitleColor(style.DialogFgColor)
return &dialog
}
func (d *MessageDialog) Display() {
d.display = true
}
func (d *MessageDialog) IsDisplay() bool {
return d.display
}
func (d *MessageDialog) Hide() {
d.message = ""
d.textView.SetText("")
d.display = false
}
func (d *MessageDialog) SetTitle(title string) {
d.layout.SetTitle(strings.ToUpper(title))
}
func (d *MessageDialog) SetText(headerType messageInfo, headerMessage string, message string) {
msgTypeLabel := ""
switch headerType {
case MessageGeneric:
msgTypeLabel = "SYSTEM:"
}
if msgTypeLabel != "" {
d.infoType.SetLabel("[::b]" + msgTypeLabel)
d.infoType.SetLabelWidth(len(msgTypeLabel) + 1)
d.infoType.SetText(headerMessage)
}
d.message = message
d.textView.Clear()
if d.message == "" {
d.textView.SetBorder(false)
d.textView.SetText("")
} else {
//d.textView.SetTextColor(style.DialogFgColor)
//d.textView.SetBackgroundColor(style.DialogSubBoxBorderColor)
//d.textView.SetBorder(true)
//d.textView.SetBorderColor(style.DialogBorderColor)
//d.textView.SetTextStyle(tcell.StyleDefault.
// Background(style.DialogSubBoxBorderColor).
// Foreground(style.ButtonFgColor))
d.textView.SetBorder(true)
d.textView.SetText(message)
}
d.textView.ScrollToBeginning()
}
func (d *MessageDialog) TextScrollToEnd() {
d.textView.ScrollToEnd()
}
func (d *MessageDialog) Focus(delegate func(tview.Primitive)) {
delegate(d.form)
}
func (d *MessageDialog) HasFocus() bool {
return utils.CheckFocus(d.form, d.textView, d.Box)
}
func (d *MessageDialog) SetRect(x, y, width, height int) {
messageHeight := 0
if d.message != "" {
messageHeight = len(strings.Split(d.message, "\n"))
}
messageWidth := getMessageWidth(d.message)
headerWidth := len(d.infoType.GetText()) + len(d.infoType.GetLabel()) + 4
if messageWidth < headerWidth {
messageWidth = headerWidth
}
dWidth := width - (2 * DialogPadding)
if messageWidth+4 < dWidth {
dWidth = messageWidth + 4
}
if DialogMinWidth < width && dWidth < DialogMinWidth {
dWidth = DialogMinWidth
}
emptySpace := (width - dWidth) / 2
dX := x + emptySpace
dHeight := messageHeight + DialogFormHeight + DialogPadding + 4
if dHeight > height {
dHeight = height - DialogPadding - 1
}
textviewHeight := dHeight - DialogFormHeight - 2
hs := (height - dHeight) / 2
dY := y + hs
d.Box.SetRect(dX, dY, dWidth, dHeight)
d.layout.ResizeItem(d.textView, textviewHeight, 0)
}
func (d *MessageDialog) Draw(screen tcell.Screen) {
if !d.display {
return
}
d.Box.DrawForSubclass(screen, d)
x, y, width, height := d.Box.GetInnerRect()
d.layout.SetRect(x, y, width, height)
d.layout.Draw(screen)
}
func (d *MessageDialog) InputHandler() func(*tcell.EventKey, func(tview.Primitive)) {
return d.WrapInputHandler(func(event *tcell.EventKey, setFocus func(tview.Primitive)) {
d.logger.Sugar().Debugf("message dialog event %v received", event)
if event.Key() == utils.CloseDialogKey.EventKey() || event.Key() == tcell.KeyEnter {
d.cancelHandler()
return
}
if event.Key() == utils.SwitchFocusKey.EventKey() {
if formHandler := d.form.InputHandler(); formHandler != nil {
formHandler(event, setFocus)
return
}
}
if textHandler := d.textView.InputHandler(); textHandler != nil {
textHandler(event, setFocus)
return
}
})
}
func (d *MessageDialog) SetCancelFunc(handler func()) *MessageDialog {
d.cancelHandler = handler
return d
}

+ 128
- 0
internal/ui/dialogs/progress.go View File

@ -0,0 +1,128 @@
package dialogs
import (
"fmt"
"strings"
"git.brettb.xyz/goinv/client/internal/ui/style"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"go.uber.org/zap"
)
const (
prgCell = "▉"
prgMinWidth = 40
)
type ProgressDialog struct {
*tview.Box
logger *zap.Logger
x int
y int
width int
height int
counterValue int
display bool
}
func NewProgressDialog(logger *zap.Logger) *ProgressDialog {
return &ProgressDialog{
logger: logger,
Box: tview.NewBox().
SetBorder(true).
SetBorderColor(style.DialogBorderColor),
display: false,
}
}
func (d *ProgressDialog) SetTitle(title string) {
d.Box.SetTitle(title)
}
func (d *ProgressDialog) Draw(screen tcell.Screen) {
if !d.display || d.height < 3 {
return
}
d.Box.DrawForSubclass(screen, d)
x, y, width, _ := d.Box.GetInnerRect()
tickStr := d.tickStr(width)
tview.Print(screen, tickStr, x, y, width, tview.AlignLeft, style.PrgBarBgColor)
}
func (d *ProgressDialog) SetRect(x, y, width, height int) {
d.x = x
d.y = y
d.width = width
if d.width > prgMinWidth {
d.width = prgMinWidth
spaceWidth := (width - d.width) / 2
d.x = x + spaceWidth
}
if height > 3 {
d.height = 3
spaceHeight := (height - d.height) / 2
d.y = y + spaceHeight
}
d.Box.SetRect(d.x, d.y, d.width, d.height)
}
func (d *ProgressDialog) Hide() {
d.display = false
}
func (d *ProgressDialog) Display() {
d.counterValue = 0
d.display = true
}
func (d *ProgressDialog) IsDisplay() bool {
return d.display
}
func (d *ProgressDialog) Focus(delegate func(tview.Primitive)) {}
func (d *ProgressDialog) HasFocus() bool {
return d.Box.HasFocus()
}
func (d *ProgressDialog) InputHandler() func(*tcell.EventKey, func(tview.Primitive)) {
return d.WrapInputHandler(func(e *tcell.EventKey, setFocus func(tview.Primitive)) {
d.logger.Sugar().Debugf("progress dialog event %v received", e)
})
}
func (d *ProgressDialog) tickStr(max int) string {
barColor := style.GetColorHex(style.PrgBarColor)
counter := d.counterValue
if counter < max-4 {
d.counterValue++
} else {
d.counterValue = 0
}
prgHeadStr := ""
hWidth := 0
prgEndStr := ""
prgStr := ""
for i := 0; i < d.counterValue; i++ {
prgHeadStr += fmt.Sprintf("[black::]%s", prgCell)
hWidth++
}
prgStr = strings.Repeat(prgCell, 4)
for i := 0; i < max+hWidth; i++ {
prgEndStr += fmt.Sprintf("[black::]%s", prgCell)
}
progress := fmt.Sprintf("%s[%s::]%s%s", prgHeadStr, barColor, prgStr, prgEndStr)
return progress
}

+ 42
- 0
internal/ui/dialogs/utils.go View File

@ -0,0 +1,42 @@
package dialogs
import (
"strings"
"github.com/rivo/tview"
)
const (
DialogFormHeight = 3
DialogMinWidth = 40
DialogPadding = 3
TableHeightOffset = 3
)
type Dialog interface {
tview.Primitive
Display()
Hide()
IsDisplay() bool
}
func getMessageWidth(message string) int {
var messageWidth int
for _, msg := range strings.Split(message, "\n") {
if len(msg) > messageWidth {
messageWidth = len(msg)
}
}
return messageWidth
}
func CheckDialogFocus(dialogs ...Dialog) bool {
for _, dia := range dialogs {
if dia.HasFocus() {
return true
}
}
return false
}

+ 112
- 0
internal/ui/help/help.go View File

@ -0,0 +1,112 @@
package help
import (
"fmt"
"git.brettb.xyz/goinv/client/internal/ui/style"
"git.brettb.xyz/goinv/client/internal/ui/utils"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type Help struct {
*tview.Box
title string
layout *tview.Flex
}
func NewHelp(appName string, appVersion string) *Help {
help := &Help{
Box: tview.NewBox(),
title: "help",
}
headerColor := style.HelpHeaderFgColor
fgColor := style.FgColor
bgColor := style.BgColor
borderColor := style.BorderColor
keyinfo := tview.NewTable()
keyinfo.SetBackgroundColor(bgColor)
keyinfo.SetFixed(1, 1)
keyinfo.SetSelectable(false, false)
appinfo := tview.NewTextView().
SetDynamicColors(true).
SetWrap(true).
SetTextAlign(tview.AlignLeft)
appinfo.SetBackgroundColor(bgColor)
appInfoText := fmt.Sprintf("%s %s - (C) 2024 Brett Bender", appName, appVersion)
appinfo.SetText(appInfoText)
appinfo.SetTextColor(headerColor)
rowIndex := 0
colIndex := 0
needInit := true
maxRowIndex := len(utils.UIKeyBindings) / 2
for i := 0; i < len(utils.UIKeyBindings); i++ {
if i >= maxRowIndex {
if needInit {
colIndex = 2
rowIndex = 0
needInit = false
}
}
keyinfo.SetCell(rowIndex, colIndex,
tview.NewTableCell(fmt.Sprintf("%s:", utils.UIKeyBindings[i].Label())).
SetAlign(tview.AlignRight).
SetBackgroundColor(bgColor).
SetSelectable(true).SetTextColor(headerColor))
keyinfo.SetCell(rowIndex, colIndex+1,
tview.NewTableCell(utils.UIKeyBindings[i].Description()).
SetAlign(tview.AlignLeft).
SetBackgroundColor(bgColor).
SetSelectable(true).
SetTextColor(fgColor))
rowIndex++
}
mlayout := tview.NewFlex().SetDirection(tview.FlexRow)
mlayout.AddItem(appinfo, 1, 0, false)
mlayout.AddItem(utils.EmptyBoxSpace(bgColor), 1, 0, false)
mlayout.AddItem(keyinfo, 0, 1, false)
mlayout.AddItem(utils.EmptyBoxSpace(bgColor), 1, 0, false)
help.layout = tview.NewFlex().SetDirection(tview.FlexColumn)
help.layout.AddItem(utils.EmptyBoxSpace(bgColor), 1, 0, false)
help.layout.AddItem(mlayout, 0, 1, false)
help.layout.AddItem(utils.EmptyBoxSpace(bgColor), 1, 0, false)
help.layout.SetBorder(true)
help.layout.SetBackgroundColor(bgColor)
help.layout.SetBorderColor(borderColor)
return help
}
func (help *Help) GetTitle() string {
return help.title
}
func (help *Help) HasFocus() bool {
return utils.CheckFocus(help.Box, help.layout)
}
func (help *Help) Focus(delegate func(tview.Primitive)) {
delegate(help.layout)
}
func (help *Help) Draw(screen tcell.Screen) {
x, y, width, height := help.Box.GetInnerRect()
if height <= 3 {
return
}
help.Box.DrawForSubclass(screen, help)
help.layout.SetRect(x, y, width, height)
help.layout.Draw(screen)
}

+ 44
- 0
internal/ui/style/style.go View File

@ -0,0 +1,44 @@
package style
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
var (
// main views
FgColor = tview.Styles.PrimaryTextColor
BgColor = tview.Styles.PrimitiveBackgroundColor
BorderColor = tcell.ColorLightBlue
MenuFgColor = tcell.ColorBlack
MenuBgColor = tcell.ColorLightBlue
HelpHeaderFgColor = tcell.ColorLightBlue
PageHeaderBgColor = tcell.ColorPink
PageHeaderFgColor = tview.Styles.PrimaryTextColor
// dialogs
DialogBgColor = tcell.ColorLightBlue
DialogFgColor = tcell.ColorBlack
DialogBorderColor = tcell.ColorLightBlue
DialogSubBoxBgColor = tcell.ColorWhite
DialogSubBoxFgColor = tcell.ColorBlack
DialogSubBoxBorderColor = tcell.ColorLightBlue
ErrorDialogBgColor = tcell.ColorRed
ErrorDialogButtonBgColor = tcell.ColorPink
// tables
TableHeaderBgColor = tcell.ColorLightBlue
TableHeaderFgColor = tcell.ColorBlack
TableSelectedFgColor = tcell.ColorBlack
TableSelectedBgColor = tcell.ColorWhite
// progressbar
PrgBarColor = tcell.ColorGreen
PrgBarBgColor = tcell.ColorDarkGreen
// other
ButtonBgColor = tcell.ColorBlack
ButtonFgColor = tcell.ColorLightBlue
ButtonSelectedFgColor = tcell.ColorBlack
ButtonSelectedBgColor = tcell.ColorWhite
)

+ 14
- 0
internal/ui/style/utils.go View File

@ -0,0 +1,14 @@
//go:build !windows
// +build !windows
package style
import (
"fmt"
"github.com/gdamore/tcell/v2"
)
func GetColorHex(color tcell.Color) string {
return fmt.Sprintf("#%06x", color.Hex())
}

+ 15
- 0
internal/ui/style/utils_windows.go View File

@ -0,0 +1,15 @@
//go:build windows
// +build windows
package style
import "github.com/gdamore/tcell/v2"
func GetColorHex(color tcell.Color) string {
for name, c := range tcell.ColorNames {
if c == color {
return name
}
}
return ""
}

+ 147
- 0
internal/ui/utils/keys.go View File

@ -0,0 +1,147 @@
package utils
import (
"github.com/gdamore/tcell/v2"
"go.uber.org/zap"
)
var (
CommandMenuKey = uiKeyInfo{
Key: tcell.Key(256),
KeyRune: 'm',
KeyLabel: "m",
KeyDesc: "display command menu",
}
NextScreenKey = uiKeyInfo{
Key: tcell.Key(256),
KeyRune: 'l',
KeyLabel: "l",
KeyDesc: "switch to next screen",
}
PreviousScreenKey = uiKeyInfo{
Key: tcell.Key(256),
KeyRune: 'h',
KeyLabel: "h",
KeyDesc: "switch to previous screen",
}
MoveUpKey = uiKeyInfo{
Key: tcell.KeyUp,
KeyRune: 'k',
KeyLabel: "k",
KeyDesc: "move up",
}
MoveDownKey = uiKeyInfo{
Key: tcell.KeyDown,
KeyRune: 'j',
KeyLabel: "j",
KeyDesc: "move down",
}
CloseDialogKey = uiKeyInfo{
Key: tcell.KeyEsc,
KeyLabel: "Esc",
KeyDesc: "close the active dialog",
}
SwitchFocusKey = uiKeyInfo{
Key: tcell.KeyTab,
KeyLabel: "Tab",
KeyDesc: "switch between widgets",
}
ArrowUpKey = uiKeyInfo{
Key: tcell.KeyUp,
KeyLabel: "arrow up",
KeyDesc: "move up",
}
ArrowDownKey = uiKeyInfo{
Key: tcell.KeyDown,
KeyLabel: "arrow down",
KeyDesc: "move down",
}
ArrowLeftKey = uiKeyInfo{
Key: tcell.KeyLeft,
KeyLabel: "Arrow Left",
KeyDesc: "previous screen",
}
ArrowRightKey = uiKeyInfo{
Key: tcell.KeyRight,
KeyLabel: "Arrow Right",
KeyDesc: "next screen",
}
AppExitKey = uiKeyInfo{
Key: tcell.KeyCtrlC,
KeyLabel: "Ctrl+c",
KeyDesc: "exit application",
}
HelpScreenKey = uiKeyInfo{
Key: tcell.KeyF1,
KeyLabel: "F1",
KeyDesc: "display help screen",
}
AssetsScreenKey = uiKeyInfo{
Key: tcell.KeyF2,
KeyLabel: "F2",
KeyDesc: "display assets screen",
}
)
var UIKeyBindings = []uiKeyInfo{
CommandMenuKey,
NextScreenKey,
PreviousScreenKey,
MoveUpKey,
MoveDownKey,
CloseDialogKey,
SwitchFocusKey,
ArrowUpKey,
ArrowDownKey,
ArrowLeftKey,
ArrowRightKey,
AppExitKey,
HelpScreenKey,
AssetsScreenKey,
}
type uiKeyInfo struct {
Key tcell.Key
KeyRune rune
KeyLabel string
KeyDesc string
}
func (key *uiKeyInfo) Label() string {
return key.KeyLabel
}
func (key *uiKeyInfo) Rune() rune {
return key.KeyRune
}
func (key *uiKeyInfo) EventKey() tcell.Key {
return key.Key
}
func (key *uiKeyInfo) Description() string {
return key.KeyDesc
}
func ParseKeyEventKey(logger *zap.Logger, event *tcell.EventKey) *tcell.EventKey {
logger.Sugar().Debugw("parse key event",
"event", event,
"key", event.Key(),
"name", event.Name())
switch event.Rune() {
case MoveUpKey.KeyRune:
return tcell.NewEventKey(MoveUpKey.Key, MoveUpKey.KeyRune, tcell.ModNone)
case MoveDownKey.KeyRune:
return tcell.NewEventKey(MoveDownKey.Key, MoveDownKey.KeyRune, tcell.ModNone)
}
switch event.Key() {
case ArrowLeftKey.Key:
return tcell.NewEventKey(PreviousScreenKey.Key, PreviousScreenKey.KeyRune, tcell.ModNone)
case ArrowRightKey.Key:
return tcell.NewEventKey(NextScreenKey.Key, NextScreenKey.KeyRune, tcell.ModNone)
}
return event
}

+ 29
- 0
internal/ui/utils/utils.go View File

@ -0,0 +1,29 @@
package utils
import (
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
const (
RefreshInterval = 250 * time.Millisecond
)
func EmptyBoxSpace(bgColor tcell.Color) *tview.Box {
box := tview.NewBox()
box.SetBackgroundColor(bgColor)
box.SetBorder(false)
return box
}
func CheckFocus(prims ...tview.Primitive) bool {
for _, prim := range prims {
if prim.HasFocus() {
return true
}
}
return false
}

+ 23
- 0
magefile.go View File

@ -0,0 +1,23 @@
//go:build mage
// +build mage
package main
import (
"github.com/magefile/mage/mg"
"github.com/magefile/mage/sh"
)
var Default = Build
func Build() error {
if err := sh.Run("go", "mod", "download"); err != nil {
return err
}
return sh.Run("go", "build", "-o", "./build/goinv-client", "./cmd/client")
}
func Run() error {
mg.Deps(Build)
return sh.RunV("./build/goinv-client")
}

Loading…
Cancel
Save