From 8654ded7a0712cc694b8de9078c686868530e0e1 Mon Sep 17 00:00:00 2001 From: Brett Bender Date: Thu, 18 Jan 2024 00:03:13 -0600 Subject: [PATCH] initial commit --- .gitignore | 117 +++++++++++ cmd/client/client.go | 17 ++ go.mod | 24 +++ go.sum | 66 +++++++ internal/api/api.go | 146 ++++++++++++++ internal/api/utils.go | 11 ++ internal/app/app.go | 112 +++++++++++ internal/app/init.go | 8 + internal/app/menu.go | 44 +++++ internal/app/refresh.go | 19 ++ internal/app/screens.go | 56 ++++++ internal/types/assets.go | 42 ++++ internal/types/buildings.go | 11 ++ internal/types/categories.go | 11 ++ internal/types/shelves.go | 23 +++ internal/ui/assets/assets.go | 216 +++++++++++++++++++++ internal/ui/assets/command.go | 103 ++++++++++ internal/ui/assets/data.go | 48 +++++ internal/ui/assets/draw.go | 24 +++ internal/ui/assets/key.go | 54 ++++++ internal/ui/assets/refresh.go | 97 ++++++++++ internal/ui/dialogs/command.go | 300 +++++++++++++++++++++++++++++ internal/ui/dialogs/confirm.go | 203 +++++++++++++++++++ internal/ui/dialogs/error.go | 106 ++++++++++ internal/ui/dialogs/message.go | 238 +++++++++++++++++++++++ internal/ui/dialogs/progress.go | 128 ++++++++++++ internal/ui/dialogs/utils.go | 42 ++++ internal/ui/help/help.go | 112 +++++++++++ internal/ui/style/style.go | 44 +++++ internal/ui/style/utils.go | 14 ++ internal/ui/style/utils_windows.go | 15 ++ internal/ui/utils/keys.go | 147 ++++++++++++++ internal/ui/utils/utils.go | 29 +++ magefile.go | 23 +++ 34 files changed, 2650 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/client/client.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/api.go create mode 100644 internal/api/utils.go create mode 100644 internal/app/app.go create mode 100644 internal/app/init.go create mode 100644 internal/app/menu.go create mode 100644 internal/app/refresh.go create mode 100644 internal/app/screens.go create mode 100644 internal/types/assets.go create mode 100644 internal/types/buildings.go create mode 100644 internal/types/categories.go create mode 100644 internal/types/shelves.go create mode 100644 internal/ui/assets/assets.go create mode 100644 internal/ui/assets/command.go create mode 100644 internal/ui/assets/data.go create mode 100644 internal/ui/assets/draw.go create mode 100644 internal/ui/assets/key.go create mode 100644 internal/ui/assets/refresh.go create mode 100644 internal/ui/dialogs/command.go create mode 100644 internal/ui/dialogs/confirm.go create mode 100644 internal/ui/dialogs/error.go create mode 100644 internal/ui/dialogs/message.go create mode 100644 internal/ui/dialogs/progress.go create mode 100644 internal/ui/dialogs/utils.go create mode 100644 internal/ui/help/help.go create mode 100644 internal/ui/style/style.go create mode 100644 internal/ui/style/utils.go create mode 100644 internal/ui/style/utils_windows.go create mode 100644 internal/ui/utils/keys.go create mode 100644 internal/ui/utils/utils.go create mode 100644 magefile.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7239fd5 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/cmd/client/client.go b/cmd/client/client.go new file mode 100644 index 0000000..c298bc6 --- /dev/null +++ b/cmd/client/client.go @@ -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)) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b65dfa6 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f1ae770 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..f04fb8c --- /dev/null +++ b/internal/api/api.go @@ -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 +} diff --git a/internal/api/utils.go b/internal/api/utils.go new file mode 100644 index 0000000..7c2aaf7 --- /dev/null +++ b/internal/api/utils.go @@ -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 +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..7a95c0a --- /dev/null +++ b/internal/app/app.go @@ -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 +} diff --git a/internal/app/init.go b/internal/app/init.go new file mode 100644 index 0000000..5ae6293 --- /dev/null +++ b/internal/app/init.go @@ -0,0 +1,8 @@ +package app + +func (app *App) initUI() { + + // Assets page + app.assets.UpdateShelfData() + app.assets.UpdateAssetData() +} diff --git a/internal/app/menu.go b/internal/app/menu.go new file mode 100644 index 0000000..4c0498a --- /dev/null +++ b/internal/app/menu.go @@ -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 +} diff --git a/internal/app/refresh.go b/internal/app/refresh.go new file mode 100644 index 0000000..dd915b1 --- /dev/null +++ b/internal/app/refresh.go @@ -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() + } +} diff --git a/internal/app/screens.go b/internal/app/screens.go new file mode 100644 index 0000000..68d72d9 --- /dev/null +++ b/internal/app/screens.go @@ -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() + } +} diff --git a/internal/types/assets.go b/internal/types/assets.go new file mode 100644 index 0000000..c813dc6 --- /dev/null +++ b/internal/types/assets.go @@ -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"` +} diff --git a/internal/types/buildings.go b/internal/types/buildings.go new file mode 100644 index 0000000..30d8198 --- /dev/null +++ b/internal/types/buildings.go @@ -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"` +} diff --git a/internal/types/categories.go b/internal/types/categories.go new file mode 100644 index 0000000..1e908f0 --- /dev/null +++ b/internal/types/categories.go @@ -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"` +} diff --git a/internal/types/shelves.go b/internal/types/shelves.go new file mode 100644 index 0000000..c8f3df5 --- /dev/null +++ b/internal/types/shelves.go @@ -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"` +} diff --git a/internal/ui/assets/assets.go b/internal/ui/assets/assets.go new file mode 100644 index 0000000..83438e2 --- /dev/null +++ b/internal/ui/assets/assets.go @@ -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 +} diff --git a/internal/ui/assets/command.go b/internal/ui/assets/command.go new file mode 100644 index 0000000..022df68 --- /dev/null +++ b/internal/ui/assets/command.go @@ -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() +} diff --git a/internal/ui/assets/data.go b/internal/ui/assets/data.go new file mode 100644 index 0000000..74fe213 --- /dev/null +++ b/internal/ui/assets/data.go @@ -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 +} diff --git a/internal/ui/assets/draw.go b/internal/ui/assets/draw.go new file mode 100644 index 0000000..caf036e --- /dev/null +++ b/internal/ui/assets/draw.go @@ -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 + } + } +} diff --git a/internal/ui/assets/key.go b/internal/ui/assets/key.go new file mode 100644 index 0000000..ef62e92 --- /dev/null +++ b/internal/ui/assets/key.go @@ -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) + }) +} diff --git a/internal/ui/assets/refresh.go b/internal/ui/assets/refresh.go new file mode 100644 index 0000000..04eb9ba --- /dev/null +++ b/internal/ui/assets/refresh.go @@ -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)) + } +} diff --git a/internal/ui/dialogs/command.go b/internal/ui/dialogs/command.go new file mode 100644 index 0000000..e77eeb2 --- /dev/null +++ b/internal/ui/dialogs/command.go @@ -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) + } +} diff --git a/internal/ui/dialogs/confirm.go b/internal/ui/dialogs/confirm.go new file mode 100644 index 0000000..cc87ff9 --- /dev/null +++ b/internal/ui/dialogs/confirm.go @@ -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 +} diff --git a/internal/ui/dialogs/error.go b/internal/ui/dialogs/error.go new file mode 100644 index 0000000..da105d5 --- /dev/null +++ b/internal/ui/dialogs/error.go @@ -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 +} diff --git a/internal/ui/dialogs/message.go b/internal/ui/dialogs/message.go new file mode 100644 index 0000000..2bc830e --- /dev/null +++ b/internal/ui/dialogs/message.go @@ -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 +} diff --git a/internal/ui/dialogs/progress.go b/internal/ui/dialogs/progress.go new file mode 100644 index 0000000..4174568 --- /dev/null +++ b/internal/ui/dialogs/progress.go @@ -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 +} diff --git a/internal/ui/dialogs/utils.go b/internal/ui/dialogs/utils.go new file mode 100644 index 0000000..a2b8db3 --- /dev/null +++ b/internal/ui/dialogs/utils.go @@ -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 +} diff --git a/internal/ui/help/help.go b/internal/ui/help/help.go new file mode 100644 index 0000000..77d3d65 --- /dev/null +++ b/internal/ui/help/help.go @@ -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) +} diff --git a/internal/ui/style/style.go b/internal/ui/style/style.go new file mode 100644 index 0000000..3636ed0 --- /dev/null +++ b/internal/ui/style/style.go @@ -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 +) diff --git a/internal/ui/style/utils.go b/internal/ui/style/utils.go new file mode 100644 index 0000000..ed416ef --- /dev/null +++ b/internal/ui/style/utils.go @@ -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()) +} diff --git a/internal/ui/style/utils_windows.go b/internal/ui/style/utils_windows.go new file mode 100644 index 0000000..50b2e6b --- /dev/null +++ b/internal/ui/style/utils_windows.go @@ -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 "" +} diff --git a/internal/ui/utils/keys.go b/internal/ui/utils/keys.go new file mode 100644 index 0000000..0066275 --- /dev/null +++ b/internal/ui/utils/keys.go @@ -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 +} diff --git a/internal/ui/utils/utils.go b/internal/ui/utils/utils.go new file mode 100644 index 0000000..eb02411 --- /dev/null +++ b/internal/ui/utils/utils.go @@ -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 +} diff --git a/magefile.go b/magefile.go new file mode 100644 index 0000000..7b61eb0 --- /dev/null +++ b/magefile.go @@ -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") +}