diff --git a/Dockerfile b/Dockerfile index 6104377d..e6cda073 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ -FROM golang:1.23-bookworm as BUILDER +FROM golang:1.23-bookworm AS builder WORKDIR /app ADD . . -RUN go build -o ./bin/algorun *.go +RUN CGO_ENABLED=0 go build -o ./bin/algorun *.go FROM algorand/algod:latest @@ -19,11 +19,13 @@ ADD .docker/start_dev.sh /node/run/start_dev.sh ADD .docker/start_empty.sh /node/run/start_empty.sh ADD .docker/start_fast_catchup.sh /node/run/start_fast_catchup.sh -COPY --from=BUILDER /app/bin/algorun /bin/algorun +COPY --from=builder /app/bin/algorun /bin/algorun + +RUN apt-get update && apt-get install jq -y ENTRYPOINT /node/run/start_dev.sh CMD [] EXPOSE 8080 EXPOSE 8081 -EXPOSE 8082 \ No newline at end of file +EXPOSE 8082 diff --git a/Makefile b/Makefile index 40bbaa98..fc3c4d38 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ build: - go build -o bin/algorun *.go + CGO_ENABLED=0 go build -o bin/algorun *.go test: go test -coverpkg=./... -covermode=atomic ./... generate: diff --git a/cmd/root.go b/cmd/root.go index f50bc7a0..439b380b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -52,18 +52,18 @@ var ( client, err := getClient() cobra.CheckErr(err) - partkeys, err := internal.GetPartKeys(context.Background(), client) + ctx := context.Background() + partkeys, err := internal.GetPartKeys(ctx, client) if err != nil { return fmt.Errorf( style.Red.Render("failed to get participation keys: %s"), err) } - state := internal.StateModel{ Status: internal.StatusModel{ State: "INITIALIZING", - Version: "NA", - Network: "NA", + Version: "N/A", + Network: "N/A", Voting: false, NeedsUpdate: true, LastRound: 0, @@ -75,14 +75,17 @@ var ( TX: 0, }, ParticipationKeys: partkeys, + + Client: client, + Context: ctx, } state.Accounts = internal.AccountsFromState(&state, new(internal.Clock), client) // Fetch current state - err = state.Status.Fetch(context.Background(), client) + err = state.Status.Fetch(ctx, client, new(internal.HttpPkg)) cobra.CheckErr(err) - m, err := ui.MakeViewportViewModel(&state, client) + m, err := ui.NewViewportViewModel(&state, client) cobra.CheckErr(err) p := tea.NewProgram( @@ -99,12 +102,9 @@ var ( p.Send(state) p.Send(err) } - }, context.Background(), client) + }, ctx, client) }() _, err = p.Run() - //for { - // time.Sleep(10 * time.Second) - //} return err }, } diff --git a/cmd/status.go b/cmd/status.go index 5af7f38b..7f4caf46 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -30,8 +30,8 @@ var statusCmd = &cobra.Command{ state := internal.StateModel{ Status: internal.StatusModel{ State: "SYNCING", - Version: "NA", - Network: "NA", + Version: "N/A", + Network: "N/A", Voting: false, NeedsUpdate: true, LastRound: 0, @@ -44,7 +44,7 @@ var statusCmd = &cobra.Command{ }, ParticipationKeys: nil, } - err = state.Status.Fetch(context.Background(), client) + err = state.Status.Fetch(context.Background(), client, new(internal.HttpPkg)) cobra.CheckErr(err) // Create the TUI view := ui.MakeStatusViewModel(&state) diff --git a/internal/accounts.go b/internal/accounts.go index b7492b03..07e00bd9 100644 --- a/internal/accounts.go +++ b/internal/accounts.go @@ -2,10 +2,8 @@ package internal import ( "context" - "encoding/json" "errors" "fmt" - "io" "time" "github.com/algorandfoundation/hack-tui/api" @@ -13,6 +11,7 @@ import ( // Account represents a user's account, including address, status, balance, and number of keys. type Account struct { + Participation *api.AccountParticipation // Account Address is the algorand encoded address Address string // Status is the Online/Offline/"NotParticipating" status of the account @@ -27,71 +26,8 @@ type Account struct { Expires time.Time } -// Gets the list of addresses created at genesis from the genesis file -func getAddressesFromGenesis(client *api.ClientWithResponses) ([]string, string, string, error) { - resp, err := client.GetGenesis(context.Background()) - if err != nil { - return []string{}, "", "", err - } - - if resp.StatusCode != 200 { - return []string{}, "", "", errors.New(fmt.Sprintf("Failed to get genesis file. Received error code: %d", resp.StatusCode)) - } - - defer resp.Body.Close() - - // Read the response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return []string{}, "", "", err - } - - // Unmarshal the JSON response into a map - var jsonResponse map[string]interface{} - err = json.Unmarshal(body, &jsonResponse) - if err != nil { - return []string{}, "", "", err - } - - // Two special addresses - rewardsPool := "7777777777777777777777777777777777777777777777777774MSJUVU" - feeSink := "A7NMWS3NT3IUDMLVO26ULGXGIIOUQ3ND2TXSER6EBGRZNOBOUIQXHIBGDE" - rewardsPoolIncluded := false - feeSinkIncluded := false - - // Loop over each entry in the "alloc" list and collect the "addr" values - var addresses []string - if allocList, ok := jsonResponse["alloc"].([]interface{}); ok { - for _, entry := range allocList { - if entryMap, ok := entry.(map[string]interface{}); ok { - if addr, ok := entryMap["addr"].(string); ok { - if addr == rewardsPool { - rewardsPoolIncluded = true - } else if addr == feeSink { - feeSinkIncluded = true - } else { - addresses = append(addresses, addr) - } - } else { - return []string{}, "", "", fmt.Errorf("In genesis.json no addr string found in list element entry: %+v", entry) - } - } else { - return []string{}, "", "", fmt.Errorf("In genesis.json list element of alloc-field is not a map: %+v", entry) - } - } - } else { - return []string{}, "", "", errors.New("alloc is not a list") - } - - if !rewardsPoolIncluded || !feeSinkIncluded { - return []string{}, "", "", errors.New("Expected RewardsPool and/or FeeSink addresses NOT found in genesis file") - } - - return addresses, rewardsPool, feeSink, nil -} - // Get Online Status of Account -func GetAccount(client *api.ClientWithResponses, address string) (api.Account, error) { +func GetAccount(client api.ClientWithResponsesInterface, address string) (api.Account, error) { var format api.AccountInformationParamsFormat = "json" r, err := client.AccountInformationWithResponse( context.Background(), @@ -112,8 +48,19 @@ func GetAccount(client *api.ClientWithResponses, address string) (api.Account, e return *r.JSON200, nil } +func GetExpiresTime(t Time, key api.ParticipationKey, state *StateModel) time.Time { + now := t.Now() + var expires = now.Add(-(time.Hour * 24 * 365 * 100)) + if key.LastBlockProposal != nil && state.Status.LastRound != 0 && state.Metrics.RoundTime != 0 { + roundDiff := max(0, *key.EffectiveLastValid-int(state.Status.LastRound)) + distance := int(state.Metrics.RoundTime) * roundDiff + expires = now.Add(time.Duration(distance)) + } + return expires +} + // AccountsFromParticipationKeys maps an array of api.ParticipationKey to a keyed map of Account -func AccountsFromState(state *StateModel, t Time, client *api.ClientWithResponses) map[string]Account { +func AccountsFromState(state *StateModel, t Time, client api.ClientWithResponsesInterface) map[string]Account { values := make(map[string]Account) if state == nil || state.ParticipationKeys == nil { return values @@ -126,7 +73,7 @@ func AccountsFromState(state *StateModel, t Time, client *api.ClientWithResponse Status: "Unknown", Amount: 0, } - if state.Status.State != "SYNCING" { + if state.Status.State != SyncingState { var err error account, err = GetAccount(client, key.Address) // TODO: handle error @@ -135,23 +82,24 @@ func AccountsFromState(state *StateModel, t Time, client *api.ClientWithResponse panic(err) } } - now := t.Now() - var expires = now.Add(-(time.Hour * 24 * 365 * 100)) - if key.EffectiveLastValid != nil { - roundDiff := max(0, *key.EffectiveLastValid-int(state.Status.LastRound)) - distance := int(state.Metrics.RoundTime) * roundDiff - expires = now.Add(time.Duration(distance)) - } values[key.Address] = Account{ - Address: key.Address, - Status: account.Status, - Balance: account.Amount / 1000000, - Expires: expires, - Keys: 1, + Participation: account.Participation, + Address: key.Address, + Status: account.Status, + Balance: account.Amount / 1000000, + Expires: GetExpiresTime(t, key, state), + Keys: 1, } } else { val.Keys++ + if val.Expires.Before(t.Now()) { + now := t.Now() + var expires = GetExpiresTime(t, key, state) + if !expires.Before(now) { + val.Expires = expires + } + } values[key.Address] = val } } diff --git a/internal/accounts_test.go b/internal/accounts_test.go index 234bf7f8..188ce8eb 100644 --- a/internal/accounts_test.go +++ b/internal/accounts_test.go @@ -1,17 +1,16 @@ package internal import ( + "context" "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal/test" + "github.com/algorandfoundation/hack-tui/internal/test/mock" "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" "github.com/stretchr/testify/assert" "testing" "time" ) -type TestClock struct{} - -func (TestClock) Now() time.Time { return time.Time{} } - func Test_AccountsFromState(t *testing.T) { // Setup elevated client @@ -21,7 +20,7 @@ func Test_AccountsFromState(t *testing.T) { } client, err := api.NewClientWithResponses("http://localhost:8080", api.WithRequestEditorFn(apiToken.Intercept)) - addresses, rewardsPool, feeSink, err := getAddressesFromGenesis(client) + addresses, rewardsPool, feeSink, err := test.GetAddressesFromGenesis(context.Background(), client) if err != nil { t.Fatal(err) @@ -67,7 +66,7 @@ func Test_AccountsFromState(t *testing.T) { effectiveFirstValid := 0 effectiveLastValid := 10000 - + lastProposedRound := 1336 // Create mockedPart Keys var mockedPartKeys = []api.ParticipationKey{ { @@ -83,7 +82,7 @@ func Test_AccountsFromState(t *testing.T) { VoteLastValid: 9999999, VoteKeyDilution: 0, }, - LastBlockProposal: nil, + LastBlockProposal: &lastProposedRound, LastStateProof: nil, LastVote: nil, }, @@ -117,7 +116,7 @@ func Test_AccountsFromState(t *testing.T) { VoteLastValid: 9999999, VoteKeyDilution: 0, }, - LastBlockProposal: nil, + LastBlockProposal: &lastProposedRound, LastStateProof: nil, LastVote: nil, }, @@ -145,7 +144,7 @@ func Test_AccountsFromState(t *testing.T) { } // Calculate expiration - clock := new(TestClock) + clock := new(mock.Clock) now := clock.Now() roundDiff := max(0, effectiveLastValid-int(state.Status.LastRound)) distance := int(state.Metrics.RoundTime) * roundDiff @@ -154,18 +153,20 @@ func Test_AccountsFromState(t *testing.T) { // Construct expected accounts expectedAccounts := map[string]Account{ onlineAccounts[0].Address: { - Address: onlineAccounts[0].Address, - Status: onlineAccounts[0].Status, - Balance: onlineAccounts[0].Amount / 1_000_000, - Keys: 2, - Expires: expires, + Participation: onlineAccounts[0].Participation, + Address: onlineAccounts[0].Address, + Status: onlineAccounts[0].Status, + Balance: onlineAccounts[0].Amount / 1_000_000, + Keys: 2, + Expires: expires, }, onlineAccounts[1].Address: { - Address: onlineAccounts[1].Address, - Status: onlineAccounts[1].Status, - Balance: onlineAccounts[1].Amount / 1_000_000, - Keys: 1, - Expires: expires, + Participation: onlineAccounts[1].Participation, + Address: onlineAccounts[1].Address, + Status: onlineAccounts[1].Status, + Balance: onlineAccounts[1].Amount / 1_000_000, + Keys: 1, + Expires: expires, }, } diff --git a/internal/block.go b/internal/block.go index 5af2fbae..e1365279 100644 --- a/internal/block.go +++ b/internal/block.go @@ -12,7 +12,7 @@ type BlockMetrics struct { TPS float64 } -func GetBlockMetrics(ctx context.Context, client *api.ClientWithResponses, round uint64, window int) (*BlockMetrics, error) { +func GetBlockMetrics(ctx context.Context, client api.ClientWithResponsesInterface, round uint64, window int) (*BlockMetrics, error) { var avgs = BlockMetrics{ AvgTime: 0, TPS: 0, diff --git a/internal/github.go b/internal/github.go index b92f27b3..87abc699 100644 --- a/internal/github.go +++ b/internal/github.go @@ -2,12 +2,10 @@ package internal import ( "encoding/json" - "log" - "net/http" "strings" ) -func GetGoAlgorandRelease(channel string) (*string, error) { +func GetGoAlgorandRelease(channel string, http HttpPkgInterface) (*string, error) { resp, err := http.Get("https://api.github.com/repos/algorand/go-algorand/releases") if err != nil { return nil, err @@ -16,7 +14,7 @@ func GetGoAlgorandRelease(channel string) (*string, error) { defer resp.Body.Close() var versions []map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil { - log.Fatal("ooopsss! an error occurred, please try again") + return nil, err } var versionResponse *string for i := range versions { diff --git a/internal/github_test.go b/internal/github_test.go new file mode 100644 index 00000000..8b0b1d40 --- /dev/null +++ b/internal/github_test.go @@ -0,0 +1,82 @@ +package internal + +import ( + "bytes" + "errors" + "io" + "net/http" + "testing" +) + +type testDecoder struct { + HttpPkgInterface +} + +func (testDecoder) Get(url string) (resp *http.Response, err error) { + return &http.Response{ + Status: "", + StatusCode: 0, + Proto: "", + ProtoMajor: 0, + ProtoMinor: 0, + Header: nil, + Body: http.NoBody, + ContentLength: 0, + TransferEncoding: nil, + Close: false, + Uncompressed: false, + Trailer: nil, + Request: nil, + TLS: nil, + }, nil +} + +type testResponse struct { + HttpPkgInterface +} + +var jsonStr = `[{ + "tag_name": "v3.26.0-beta" + }]` + +func (testResponse) Get(url string) (resp *http.Response, err error) { + + responseBody := io.NopCloser(bytes.NewReader([]byte(jsonStr))) + return &http.Response{ + StatusCode: 200, + Body: responseBody, + }, nil +} + +type testError struct { + HttpPkgInterface +} + +func (testError) Get(url string) (resp *http.Response, err error) { + return &http.Response{ + StatusCode: 404, + }, errors.New("not found") +} + +func Test_Github(t *testing.T) { + _, err := GetGoAlgorandRelease("beta", new(testDecoder)) + if err == nil { + t.Error("should fail to decode") + } + + r, err := GetGoAlgorandRelease("beta", new(testResponse)) + if err != nil { + t.Error(err) + } + if r == nil { + t.Error("should not be nil") + } + if *r != "v3.26.0-beta" { + t.Error("should return v3.26.0-beta") + } + + _, err = GetGoAlgorandRelease("beta", new(testError)) + if err == nil { + t.Error("should fail to get") + } +} diff --git a/internal/http.go b/internal/http.go new file mode 100644 index 00000000..9f2fc57f --- /dev/null +++ b/internal/http.go @@ -0,0 +1,17 @@ +package internal + +import "net/http" + +type HttpPkg struct { + HttpPkgInterface +} + +func (HttpPkg) Get(url string) (resp *http.Response, err error) { + return http.Get(url) +} + +var Http HttpPkg + +type HttpPkgInterface interface { + Get(url string) (resp *http.Response, err error) +} diff --git a/internal/metrics.go b/internal/metrics.go index b10a9f40..f3283232 100644 --- a/internal/metrics.go +++ b/internal/metrics.go @@ -59,7 +59,7 @@ func parseMetricsContent(content string) (MetricsResponse, error) { } // GetMetrics parses the /metrics endpoint from algod into a map -func GetMetrics(ctx context.Context, client *api.ClientWithResponses) (MetricsResponse, error) { +func GetMetrics(ctx context.Context, client api.ClientWithResponsesInterface) (MetricsResponse, error) { res, err := client.MetricsWithResponse(ctx) if err != nil { return nil, err diff --git a/internal/metrics_test.go b/internal/metrics_test.go index 2d5ff5ad..ecdd2ba4 100644 --- a/internal/metrics_test.go +++ b/internal/metrics_test.go @@ -2,27 +2,20 @@ package internal import ( "context" + "github.com/algorandfoundation/hack-tui/internal/test" "strconv" "testing" - - "github.com/algorandfoundation/hack-tui/api" - "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" ) func Test_GetMetrics(t *testing.T) { - // Setup elevated client - apiToken, err := securityprovider.NewSecurityProviderApiKey("header", "X-Algo-API-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - if err != nil { - t.Fatal(err) - } - client, err := api.NewClientWithResponses("http://localhost:8080", api.WithRequestEditorFn(apiToken.Intercept)) + client := test.GetClient(true) metrics, err := GetMetrics(context.Background(), client) - if err != nil { - t.Fatal(err) + if err == nil { + t.Error("error expected") } - // TODO: ensure localnet is running before tests + client = test.GetClient(false) metrics, err = GetMetrics(context.Background(), client) if err != nil { t.Fatal(err) @@ -31,6 +24,12 @@ func Test_GetMetrics(t *testing.T) { if metrics["algod_agreement_dropped"] != 0 { t.Fatal(strconv.Itoa(metrics["algod_agreement_dropped"]) + " is not zero") } + + client = test.NewClient(false, true) + metrics, err = GetMetrics(context.Background(), client) + if err == nil { + t.Error("expected error") + } } func Test_parseMetrics(t *testing.T) { diff --git a/internal/participation.go b/internal/participation.go index 29ef856a..8b718519 100644 --- a/internal/participation.go +++ b/internal/participation.go @@ -2,14 +2,18 @@ package internal import ( "context" + "encoding/base64" "errors" + "fmt" + "net/url" + "strings" "time" "github.com/algorandfoundation/hack-tui/api" ) // GetPartKeys get the participation keys from the node -func GetPartKeys(ctx context.Context, client *api.ClientWithResponses) (*[]api.ParticipationKey, error) { +func GetPartKeys(ctx context.Context, client api.ClientWithResponsesInterface) (*[]api.ParticipationKey, error) { parts, err := client.GetParticipationKeysWithResponse(ctx) if err != nil { return nil, err @@ -21,7 +25,7 @@ func GetPartKeys(ctx context.Context, client *api.ClientWithResponses) (*[]api.P } // ReadPartKey get a specific participation key by id -func ReadPartKey(ctx context.Context, client *api.ClientWithResponses, participationId string) (*api.ParticipationKey, error) { +func ReadPartKey(ctx context.Context, client api.ClientWithResponsesInterface, participationId string) (*api.ParticipationKey, error) { key, err := client.GetParticipationKeyByIDWithResponse(ctx, participationId) if err != nil { return nil, err @@ -32,82 +36,49 @@ func ReadPartKey(ctx context.Context, client *api.ClientWithResponses, participa return key.JSON200, err } -// waitForNewKey await the new key based on known existing keys -// We should try to update the API endpoint -func waitForNewKey( - ctx context.Context, - client *api.ClientWithResponses, - keys *[]api.ParticipationKey, - interval time.Duration, -) (*[]api.ParticipationKey, error) { - // Fetch the latest keys - currentKeys, err := GetPartKeys(ctx, client) - if err != nil { - return nil, err - } - // Check the length against known keys - if len(*currentKeys) == len(*keys) { - // Sleep then try again - time.Sleep(interval) - return waitForNewKey(ctx, client, keys, interval) - } - return currentKeys, nil -} - -// findKeyPair look for a new key based on address between two key lists -// this is not robust, and we should try to update the API endpoint to wait for -// the key creation and return its metadata to the caller -func findKeyPair( - originalKeys *[]api.ParticipationKey, - currentKeys *[]api.ParticipationKey, - address string, -) (*api.ParticipationKey, error) { - var participationKey api.ParticipationKey - for _, key := range *currentKeys { - if key.Address == address { - for _, oKey := range *originalKeys { - if oKey.Id != key.Id { - participationKey = key - } - } - } - } - return &participationKey, nil -} - // GenerateKeyPair creates a keypair and finds the result func GenerateKeyPair( ctx context.Context, - client *api.ClientWithResponses, + client api.ClientWithResponsesInterface, address string, params *api.GenerateParticipationKeysParams, ) (*api.ParticipationKey, error) { - // The api response is an empty body, we need to fetch known keys first - originalKeys, err := GetPartKeys(ctx, client) - if err != nil { - return nil, err - } // Generate a new keypair key, err := client.GenerateParticipationKeysWithResponse(ctx, address, params) if err != nil { return nil, err } if key.StatusCode() != 200 { - return nil, errors.New(key.Status()) + status := key.Status() + if status != "" { + return nil, errors.New(status) + } + return nil, errors.New("something went wrong") } - - // Wait for the api to have a new key - keys, err := waitForNewKey(ctx, client, originalKeys, 2*time.Second) - if err != nil { - return nil, err + for { + select { + case <-ctx.Done(): + return nil, context.Canceled + case <-time.After(2 * time.Second): + partKeys, err := GetPartKeys(ctx, client) + if partKeys == nil || err != nil { + return nil, errors.New("failed to get participation keys") + } + for _, k := range *partKeys { + if k.Address == address && + k.Key.VoteFirstValid == params.First && + k.Key.VoteLastValid == params.Last { + return &k, nil + } + } + case <-time.After(20 * time.Minute): + return nil, errors.New("timeout waiting for key to be created") + } } - - // Find the new keypair in the results - return findKeyPair(originalKeys, keys, address) } // DeletePartKey remove a key from the node -func DeletePartKey(ctx context.Context, client *api.ClientWithResponses, participationId string) error { +func DeletePartKey(ctx context.Context, client api.ClientWithResponsesInterface, participationId string) error { deletion, err := client.DeleteParticipationKeyByIDWithResponse(ctx, participationId) if err != nil { return err @@ -127,3 +98,42 @@ func RemovePartKeyByID(slice *[]api.ParticipationKey, id string) { } } } + +func FindParticipationIdForVoteKey(slice *[]api.ParticipationKey, votekey []byte) *string { + for _, item := range *slice { + if string(item.Key.VoteParticipationKey) == string(votekey) { + return &item.Id + } + } + return nil +} + +func ToLoraDeepLink(network string, offline bool, part api.ParticipationKey) (string, error) { + fee := 2000000 + var loraNetwork = strings.Replace(strings.Replace(network, "-v1.0", "", 1), "-v1", "", 1) + if loraNetwork == "dockernet" || loraNetwork == "tuinet" { + loraNetwork = "localnet" + } + + var query = "" + encodedIndex := url.QueryEscape("[0]") + if offline { + query = fmt.Sprintf( + "type[0]=keyreg&sender[0]=%s", + part.Address, + ) + } else { + query = fmt.Sprintf( + "type[0]=keyreg&fee[0]=%d&sender[0]=%s&selkey[0]=%s&sprfkey[0]=%s&votekey[0]=%s&votefst[0]=%d&votelst[0]=%d&votekd[0]=%d", + fee, + part.Address, + base64.RawURLEncoding.EncodeToString(part.Key.SelectionParticipationKey), + base64.RawURLEncoding.EncodeToString(*part.Key.StateProofKey), + base64.RawURLEncoding.EncodeToString(part.Key.VoteParticipationKey), + part.Key.VoteFirstValid, + part.Key.VoteLastValid, + part.Key.VoteKeyDilution, + ) + } + return fmt.Sprintf("https://lora.algokit.io/%s/transaction-wizard?%s", loraNetwork, strings.Replace(query, "[0]", encodedIndex, -1)), nil +} diff --git a/internal/participation_test.go b/internal/participation_test.go index 25883663..f8e870a1 100644 --- a/internal/participation_test.go +++ b/internal/participation_test.go @@ -3,10 +3,9 @@ package internal import ( "context" "fmt" - "testing" - "github.com/algorandfoundation/hack-tui/api" "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" + "testing" ) func Test_ListParticipationKeys(t *testing.T) { diff --git a/internal/state.go b/internal/state.go index 7b3ccd8b..1eae0c33 100644 --- a/internal/state.go +++ b/internal/state.go @@ -9,13 +9,21 @@ import ( ) type StateModel struct { + // Models Status StatusModel Metrics MetricsModel Accounts map[string]Account ParticipationKeys *[]api.ParticipationKey + + // Application State + Admin bool + // TODO: handle contexts instead of adding it to state - Admin bool Watching bool + + // RPC + Client api.ClientWithResponsesInterface + Context context.Context } func (s *StateModel) waitAfterError(err error, cb func(model *StateModel, err error)) { @@ -27,13 +35,13 @@ func (s *StateModel) waitAfterError(err error, cb func(model *StateModel, err er } // TODO: allow context to handle loop -func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Context, client *api.ClientWithResponses) { +func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Context, client api.ClientWithResponsesInterface) { s.Watching = true if s.Metrics.Window == 0 { s.Metrics.Window = 100 } - err := s.Status.Fetch(ctx, client) + err := s.Status.Fetch(ctx, client, new(HttpPkg)) if err != nil { cb(nil, err) } @@ -44,6 +52,16 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co if !s.Watching { break } + + if s.Status.State == FastCatchupState { + time.Sleep(time.Second * 10) + err := s.Status.Fetch(ctx, client, new(HttpPkg)) + if err != nil { + cb(nil, err) + } + continue + } + status, err := client.WaitForBlockWithResponse(ctx, int(lastRound)) s.waitAfterError(err, cb) if err != nil { @@ -57,18 +75,18 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co s.Status.State = "Unknown" // Update Status - s.Status.Update(status.JSON200.LastRound, status.JSON200.CatchupTime, status.JSON200.UpgradeNodeVote) + s.Status.Update(status.JSON200.LastRound, status.JSON200.CatchupTime, status.JSON200.CatchpointAcquiredBlocks, status.JSON200.UpgradeNodeVote) // Fetch Keys - s.UpdateKeys(ctx, client) + s.UpdateKeys() - if s.Status.State == "SYNCING" { + if s.Status.State == SyncingState { lastRound = s.Status.LastRound cb(s, nil) continue } // Run Round Averages and RX/TX every 5 rounds - if s.Status.LastRound%5 == 0 { + if s.Status.LastRound%5 == 0 || (s.Status.LastRound > 100 && s.Metrics.RoundTime.Seconds() == 0) { bm, err := GetBlockMetrics(ctx, client, s.Status.LastRound, s.Metrics.Window) s.waitAfterError(err, cb) if err != nil { @@ -88,7 +106,7 @@ func (s *StateModel) Stop() { s.Watching = false } -func (s *StateModel) UpdateMetricsFromRPC(ctx context.Context, client *api.ClientWithResponses) { +func (s *StateModel) UpdateMetricsFromRPC(ctx context.Context, client api.ClientWithResponsesInterface) { // Fetch RX/TX res, err := GetMetrics(ctx, client) if err != nil { @@ -107,18 +125,18 @@ func (s *StateModel) UpdateMetricsFromRPC(ctx context.Context, client *api.Clien s.Metrics.LastRX = res["algod_network_received_bytes_total"] } } -func (s *StateModel) UpdateAccounts(client *api.ClientWithResponses) { - s.Accounts = AccountsFromState(s, new(Clock), client) +func (s *StateModel) UpdateAccounts() { + s.Accounts = AccountsFromState(s, new(Clock), s.Client) } -func (s *StateModel) UpdateKeys(ctx context.Context, client *api.ClientWithResponses) { +func (s *StateModel) UpdateKeys() { var err error - s.ParticipationKeys, err = GetPartKeys(ctx, client) + s.ParticipationKeys, err = GetPartKeys(s.Context, s.Client) if err != nil { s.Admin = false } if err == nil { s.Admin = true - s.UpdateAccounts(client) + s.UpdateAccounts() } } diff --git a/internal/state_test.go b/internal/state_test.go index 769c6639..898e0135 100644 --- a/internal/state_test.go +++ b/internal/state_test.go @@ -21,7 +21,7 @@ func Test_StateModel(t *testing.T) { Status: StatusModel{ LastRound: 1337, NeedsUpdate: true, - State: "SYNCING", + State: SyncingState, }, Metrics: MetricsModel{ RoundTime: 0, @@ -29,6 +29,8 @@ func Test_StateModel(t *testing.T) { RX: 0, TPS: 0, }, + Client: client, + Context: context.Background(), } count := 0 go state.Watch(func(model *StateModel, err error) { diff --git a/internal/status.go b/internal/status.go index 0c417880..7ed0ba4e 100644 --- a/internal/status.go +++ b/internal/status.go @@ -7,9 +7,17 @@ import ( "github.com/algorandfoundation/hack-tui/api" ) +type State string + +const ( + FastCatchupState State = "FAST-CATCHUP" + SyncingState State = "SYNCING" + StableState State = "RUNNING" +) + // StatusModel represents a status response from algod.Status type StatusModel struct { - State string + State State Version string Network string Voting bool @@ -21,12 +29,16 @@ type StatusModel struct { func (m *StatusModel) String() string { return fmt.Sprintf("\nLastRound: %d\n", m.LastRound) } -func (m *StatusModel) Update(lastRound int, catchupTime int, upgradeNodeVote *bool) { +func (m *StatusModel) Update(lastRound int, catchupTime int, aquiredBlocks *int, upgradeNodeVote *bool) { m.LastRound = uint64(lastRound) if catchupTime > 0 { - m.State = "SYNCING" + if aquiredBlocks != nil { + m.State = FastCatchupState + } else { + m.State = SyncingState + } } else { - m.State = "WATCHING" + m.State = StableState } if upgradeNodeVote != nil { m.Voting = *upgradeNodeVote @@ -34,8 +46,8 @@ func (m *StatusModel) Update(lastRound int, catchupTime int, upgradeNodeVote *bo } // Fetch handles algod.Status -func (m *StatusModel) Fetch(ctx context.Context, client *api.ClientWithResponses) error { - if m.Version == "" || m.Version == "NA" { +func (m *StatusModel) Fetch(ctx context.Context, client api.ClientWithResponsesInterface, httpPkg HttpPkgInterface) error { + if m.Version == "" || m.Version == "N/A" { v, err := client.GetVersionWithResponse(ctx) if err != nil { return err @@ -45,7 +57,7 @@ func (m *StatusModel) Fetch(ctx context.Context, client *api.ClientWithResponses } m.Network = v.JSON200.GenesisId m.Version = fmt.Sprintf("v%d.%d.%d-%s", v.JSON200.Build.Major, v.JSON200.Build.Minor, v.JSON200.Build.BuildNumber, v.JSON200.Build.Channel) - currentRelease, err := GetGoAlgorandRelease(v.JSON200.Build.Channel) + currentRelease, err := GetGoAlgorandRelease(v.JSON200.Build.Channel, httpPkg) if err != nil { return err } @@ -65,6 +77,6 @@ func (m *StatusModel) Fetch(ctx context.Context, client *api.ClientWithResponses return fmt.Errorf("Status code %d: %s", s.StatusCode(), s.Status()) } - m.Update(s.JSON200.LastRound, s.JSON200.CatchupTime, s.JSON200.UpgradeNodeVote) + m.Update(s.JSON200.LastRound, s.JSON200.CatchupTime, s.JSON200.CatchpointAcquiredBlocks, s.JSON200.UpgradeNodeVote) return nil } diff --git a/internal/status_test.go b/internal/status_test.go index cd165e9e..04e984d3 100644 --- a/internal/status_test.go +++ b/internal/status_test.go @@ -1,6 +1,8 @@ package internal import ( + "context" + "github.com/algorandfoundation/hack-tui/internal/test" "strings" "testing" ) @@ -10,4 +12,49 @@ func Test_StatusModel(t *testing.T) { if !strings.Contains(m.String(), "LastRound: 0") { t.Fatal("expected \"LastRound: 0\", got ", m.String()) } + + stale := true + m.Update(5, 10, nil, &stale) + + if m.LastRound != 5 { + t.Errorf("expected LastRound: 5, got %d", m.LastRound) + } + if m.State != SyncingState { + t.Errorf("expected State: %s, got %s", SyncingState, m.State) + } + + m.Update(10, 0, nil, &stale) + if m.LastRound != 10 { + t.Errorf("expected LastRound: 10, got %d", m.LastRound) + } + if m.State != StableState { + t.Errorf("expected State: %s, got %s", StableState, m.State) + } + +} + +func Test_StatusFetch(t *testing.T) { + client := test.GetClient(true) + m := StatusModel{LastRound: 0} + pkg := new(HttpPkg) + err := m.Fetch(context.Background(), client, pkg) + if err == nil { + t.Error("expected error, got nil") + } + + client = test.NewClient(false, true) + err = m.Fetch(context.Background(), client, pkg) + if err == nil { + t.Error("expected error, got nil") + } + + client = test.GetClient(false) + err = m.Fetch(context.Background(), client, pkg) + if err != nil { + t.Error(err) + } + if m.LastRound == 0 { + t.Error("expected LastRound to be non-zero") + } + } diff --git a/internal/test/client.go b/internal/test/client.go new file mode 100644 index 00000000..cb23591a --- /dev/null +++ b/internal/test/client.go @@ -0,0 +1,236 @@ +package test + +import ( + "context" + "errors" + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal/test/mock" + "net/http" +) + +func GetClient(throws bool) api.ClientWithResponsesInterface { + return NewClient(throws, false) +} + +type Client struct { + api.ClientWithResponsesInterface + Errors bool + Invalid bool +} + +func NewClient(throws bool, invalid bool) api.ClientWithResponsesInterface { + client := new(Client) + if throws { + client.Errors = true + } + if invalid { + client.Invalid = true + } + return client +} + +func (c *Client) MetricsWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.MetricsResponse, error) { + var res api.MetricsResponse + body := `# HELP algod_telemetry_drops_total telemetry messages dropped due to full queues +# TYPE algod_telemetry_drops_total counter +algod_telemetry_drops_total 0 +# HELP algod_telemetry_errs_total telemetry messages dropped due to server error +# TYPE algod_telemetry_errs_total counter +algod_telemetry_errs_total 0 +# HELP algod_ram_usage number of bytes runtime.ReadMemStats().HeapInuse +# TYPE algod_ram_usage gauge +algod_ram_usage 0 +# HELP algod_crypto_vrf_generate_total Total number of calls to GenerateVRFSecrets +# TYPE algod_crypto_vrf_generate_total counter +algod_crypto_vrf_generate_total 0 +# HELP algod_crypto_vrf_prove_total Total number of calls to VRFSecrets.Prove +# TYPE algod_crypto_vrf_prove_total counter +algod_crypto_vrf_prove_total 0 +# HELP algod_crypto_vrf_hash_total Total number of calls to VRFProof.Hash +# TYPE algod_crypto_vrf_hash_total counter +algod_crypto_vrf_hash_total 0` + if !c.Invalid { + httpResponse := http.Response{StatusCode: 200} + res = api.MetricsResponse{ + Body: []byte(body), + HTTPResponse: &httpResponse, + } + } else { + httpResponse := http.Response{StatusCode: 404} + res = api.MetricsResponse{ + Body: []byte(body), + HTTPResponse: &httpResponse, + } + } + if c.Errors { + return &res, errors.New("test error") + } + return &res, nil +} +func (c *Client) GetParticipationKeysWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.GetParticipationKeysResponse, error) { + var res api.GetParticipationKeysResponse + clone := mock.Keys + if !c.Invalid { + httpResponse := http.Response{StatusCode: 200} + res = api.GetParticipationKeysResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: &clone, + JSON400: nil, + JSON401: nil, + JSON404: nil, + JSON500: nil, + } + } else { + httpResponse := http.Response{StatusCode: 404} + res = api.GetParticipationKeysResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: &clone, + JSON400: nil, + JSON401: nil, + JSON404: nil, + JSON500: nil, + } + } + + if c.Errors { + return nil, errors.New("test error") + } + return &res, nil +} + +func (c *Client) DeleteParticipationKeyByIDWithResponse(ctx context.Context, participationId string, reqEditors ...api.RequestEditorFn) (*api.DeleteParticipationKeyByIDResponse, error) { + var res api.DeleteParticipationKeyByIDResponse + if !c.Invalid { + httpResponse := http.Response{StatusCode: 200} + res = api.DeleteParticipationKeyByIDResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON400: nil, + JSON401: nil, + JSON404: nil, + JSON500: nil, + } + } else { + httpResponse := http.Response{StatusCode: 404} + res = api.DeleteParticipationKeyByIDResponse{ + Body: nil, + HTTPResponse: &httpResponse, + } + } + + if c.Errors { + return &res, errors.New("test error") + } + return &res, nil +} + +func (c *Client) GenerateParticipationKeysWithResponse(ctx context.Context, address string, params *api.GenerateParticipationKeysParams, reqEditors ...api.RequestEditorFn) (*api.GenerateParticipationKeysResponse, error) { + mock.Keys = append(mock.Keys, api.ParticipationKey{ + Address: "ABC", + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "", + Key: api.AccountParticipation{ + SelectionParticipationKey: nil, + StateProofKey: nil, + VoteFirstValid: 0, + VoteKeyDilution: 0, + VoteLastValid: 30, + VoteParticipationKey: nil, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }) + httpResponse := http.Response{StatusCode: 200} + res := api.GenerateParticipationKeysResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: nil, + JSON400: nil, + JSON401: nil, + JSON500: nil, + } + + return &res, nil +} + +func (c *Client) GetVersionWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.GetVersionResponse, error) { + var res api.GetVersionResponse + version := api.Version{ + Build: api.BuildVersion{ + Branch: "test", + BuildNumber: 1, + Channel: "beta", + CommitHash: "abc", + Major: 0, + Minor: 0, + }, + GenesisHashB64: nil, + GenesisId: "tui-net", + Versions: nil, + } + if !c.Invalid { + httpResponse := http.Response{StatusCode: 200} + + res = api.GetVersionResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: &version, + } + } else { + httpResponse := http.Response{StatusCode: 404} + res = api.GetVersionResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: nil, + } + } + if c.Errors { + return &res, errors.New("test error") + } + return &res, nil +} +func (c *Client) GetStatusWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.GetStatusResponse, error) { + httpResponse := http.Response{StatusCode: 200} + data := new(struct { + Catchpoint *string `json:"catchpoint,omitempty"` + CatchpointAcquiredBlocks *int `json:"catchpoint-acquired-blocks,omitempty"` + CatchpointProcessedAccounts *int `json:"catchpoint-processed-accounts,omitempty"` + CatchpointProcessedKvs *int `json:"catchpoint-processed-kvs,omitempty"` + CatchpointTotalAccounts *int `json:"catchpoint-total-accounts,omitempty"` + CatchpointTotalBlocks *int `json:"catchpoint-total-blocks,omitempty"` + CatchpointTotalKvs *int `json:"catchpoint-total-kvs,omitempty"` + CatchpointVerifiedAccounts *int `json:"catchpoint-verified-accounts,omitempty"` + CatchpointVerifiedKvs *int `json:"catchpoint-verified-kvs,omitempty"` + CatchupTime int `json:"catchup-time"` + LastCatchpoint *string `json:"last-catchpoint,omitempty"` + LastRound int `json:"last-round"` + LastVersion string `json:"last-version"` + NextVersion string `json:"next-version"` + NextVersionRound int `json:"next-version-round"` + NextVersionSupported bool `json:"next-version-supported"` + StoppedAtUnsupportedRound bool `json:"stopped-at-unsupported-round"` + TimeSinceLastRound int `json:"time-since-last-round"` + UpgradeDelay *int `json:"upgrade-delay,omitempty"` + UpgradeNextProtocolVoteBefore *int `json:"upgrade-next-protocol-vote-before,omitempty"` + UpgradeNoVotes *int `json:"upgrade-no-votes,omitempty"` + UpgradeNodeVote *bool `json:"upgrade-node-vote,omitempty"` + UpgradeVoteRounds *int `json:"upgrade-vote-rounds,omitempty"` + UpgradeVotes *int `json:"upgrade-votes,omitempty"` + UpgradeVotesRequired *int `json:"upgrade-votes-required,omitempty"` + UpgradeYesVotes *int `json:"upgrade-yes-votes,omitempty"` + }) + data.LastRound = 10 + res := api.GetStatusResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: data, + JSON401: nil, + JSON500: nil, + } + + return &res, nil +} diff --git a/internal/test/mock/clock.go b/internal/test/mock/clock.go new file mode 100644 index 00000000..66039202 --- /dev/null +++ b/internal/test/mock/clock.go @@ -0,0 +1,7 @@ +package mock + +import "time" + +type Clock struct{} + +func (Clock) Now() time.Time { return time.Time{} } diff --git a/internal/test/mock/fixtures.go b/internal/test/mock/fixtures.go new file mode 100644 index 00000000..41bd53ae --- /dev/null +++ b/internal/test/mock/fixtures.go @@ -0,0 +1,45 @@ +package mock + +import ( + "github.com/algorandfoundation/hack-tui/api" +) + +var VoteKey = []byte("TESTKEY") +var SelectionKey = []byte("TESTKEY") +var StateProofKey = []byte("TESTKEY") +var Keys = []api.ParticipationKey{ + { + Address: "ABC", + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "123", + Key: api.AccountParticipation{ + SelectionParticipationKey: SelectionKey, + StateProofKey: &StateProofKey, + VoteFirstValid: 0, + VoteKeyDilution: 100, + VoteLastValid: 30000, + VoteParticipationKey: VoteKey, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }, + { + Address: "ABC", + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "1234", + Key: api.AccountParticipation{ + SelectionParticipationKey: nil, + StateProofKey: nil, + VoteFirstValid: 0, + VoteKeyDilution: 100, + VoteLastValid: 30000, + VoteParticipationKey: nil, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }, +} diff --git a/internal/test/utils.go b/internal/test/utils.go new file mode 100644 index 00000000..25ea5ad9 --- /dev/null +++ b/internal/test/utils.go @@ -0,0 +1,73 @@ +package test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/algorandfoundation/hack-tui/api" + "io" +) + +// GetAddressesFromGenesis gets the list of addresses created at genesis from the genesis file +func GetAddressesFromGenesis(ctx context.Context, client api.ClientInterface) ([]string, string, string, error) { + resp, err := client.GetGenesis(ctx) + if err != nil { + return []string{}, "", "", err + } + + if resp.StatusCode != 200 { + return []string{}, "", "", errors.New(fmt.Sprintf("Failed to get genesis file. Received error code: %d", resp.StatusCode)) + } + + defer resp.Body.Close() + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return []string{}, "", "", err + } + + // Unmarshal the JSON response into a map + var jsonResponse map[string]interface{} + err = json.Unmarshal(body, &jsonResponse) + if err != nil { + return []string{}, "", "", err + } + + // Two special addresses + rewardsPool := "7777777777777777777777777777777777777777777777777774MSJUVU" + feeSink := "A7NMWS3NT3IUDMLVO26ULGXGIIOUQ3ND2TXSER6EBGRZNOBOUIQXHIBGDE" + rewardsPoolIncluded := false + feeSinkIncluded := false + + // Loop over each entry in the "alloc" list and collect the "addr" values + var addresses []string + if allocList, ok := jsonResponse["alloc"].([]interface{}); ok { + for _, entry := range allocList { + if entryMap, ok := entry.(map[string]interface{}); ok { + if addr, ok := entryMap["addr"].(string); ok { + if addr == rewardsPool { + rewardsPoolIncluded = true + } else if addr == feeSink { + feeSinkIncluded = true + } else { + addresses = append(addresses, addr) + } + } else { + return []string{}, "", "", fmt.Errorf("In genesis.json no addr string found in list element entry: %+v", entry) + } + } else { + return []string{}, "", "", fmt.Errorf("In genesis.json list element of alloc-field is not a map: %+v", entry) + } + } + } else { + return []string{}, "", "", errors.New("alloc is not a list") + } + + if !rewardsPoolIncluded || !feeSinkIncluded { + return []string{}, "", "", errors.New("Expected RewardsPool and/or FeeSink addresses NOT found in genesis file") + } + + return addresses, rewardsPool, feeSink, nil +} diff --git a/ui/pages/accounts/cmds.go b/ui/app/accounts.go similarity index 77% rename from ui/pages/accounts/cmds.go rename to ui/app/accounts.go index a4d1a455..9c2971e9 100644 --- a/ui/pages/accounts/cmds.go +++ b/ui/app/accounts.go @@ -1,13 +1,15 @@ -package accounts +package app import ( "github.com/algorandfoundation/hack-tui/internal" tea "github.com/charmbracelet/bubbletea" ) +type AccountSelected internal.Account + // EmitAccountSelected waits for and retrieves a new set of table rows from a given channel. func EmitAccountSelected(account internal.Account) tea.Cmd { return func() tea.Msg { - return account + return AccountSelected(account) } } diff --git a/ui/app/app_test.go b/ui/app/app_test.go new file mode 100644 index 00000000..da9e6d0e --- /dev/null +++ b/ui/app/app_test.go @@ -0,0 +1,65 @@ +package app + +import ( + "context" + "github.com/algorandfoundation/hack-tui/internal/test" + uitest "github.com/algorandfoundation/hack-tui/ui/internal/test" + "testing" + "time" +) + +func Test_GenerateCmd(t *testing.T) { + client := test.GetClient(false) + fn := GenerateCmd("ABC", time.Second*60, uitest.GetState(client)) + res := fn() + evt, ok := res.(ModalEvent) + if !ok { + t.Error("Expected ModalEvent") + } + if evt.Type != InfoModal { + t.Error("Expected InfoModal") + } + + client = test.GetClient(true) + fn = GenerateCmd("ABC", time.Second*60, uitest.GetState(client)) + res = fn() + evt, ok = res.(ModalEvent) + if !ok { + t.Error("Expected ModalEvent") + } + if evt.Type != ExceptionModal { + t.Error("Expected ExceptionModal") + } + +} + +func Test_EmitDeleteKey(t *testing.T) { + client := test.GetClient(false) + fn := EmitDeleteKey(context.Background(), client, "ABC") + res := fn() + evt, ok := res.(DeleteFinished) + if !ok { + t.Error("Expected DeleteFinished") + } + if evt.Id != "ABC" { + t.Error("Expected ABC") + } + if evt.Err != nil { + t.Error("Expected no errors") + } + + client = test.GetClient(true) + fn = EmitDeleteKey(context.Background(), client, "ABC") + res = fn() + evt, ok = res.(DeleteFinished) + if !ok { + t.Error("Expected DeleteFinished") + } + if evt.Id != "" { + t.Error("Expected no response") + } + if evt.Err == nil { + t.Error("Expected errors") + } + +} diff --git a/ui/app/keys.go b/ui/app/keys.go new file mode 100644 index 00000000..34db1a3e --- /dev/null +++ b/ui/app/keys.go @@ -0,0 +1,62 @@ +package app + +import ( + "context" + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + tea "github.com/charmbracelet/bubbletea" + "time" +) + +type DeleteFinished struct { + Err *error + Id string +} + +type DeleteKey *api.ParticipationKey + +func EmitDeleteKey(ctx context.Context, client api.ClientWithResponsesInterface, id string) tea.Cmd { + return func() tea.Msg { + err := internal.DeletePartKey(ctx, client, id) + if err != nil { + return DeleteFinished{ + Err: &err, + Id: "", + } + } + return DeleteFinished{ + Err: nil, + Id: id, + } + } +} + +func GenerateCmd(account string, duration time.Duration, state *internal.StateModel) tea.Cmd { + return func() tea.Msg { + params := api.GenerateParticipationKeysParams{ + Dilution: nil, + First: int(state.Status.LastRound), + Last: int(state.Status.LastRound) + int((duration / state.Metrics.RoundTime)), + } + + key, err := internal.GenerateKeyPair(state.Context, state.Client, account, ¶ms) + if err != nil { + return ModalEvent{ + Key: nil, + Address: "", + Active: false, + Err: &err, + Type: ExceptionModal, + } + } + + return ModalEvent{ + Key: key, + Address: key.Address, + Active: false, + Err: nil, + Type: InfoModal, + } + } + +} diff --git a/ui/app/modal.go b/ui/app/modal.go new file mode 100644 index 00000000..ab9318db --- /dev/null +++ b/ui/app/modal.go @@ -0,0 +1,38 @@ +package app + +import ( + "github.com/algorandfoundation/hack-tui/api" + tea "github.com/charmbracelet/bubbletea" +) + +type ModalType string + +const ( + CloseModal ModalType = "" + CancelModal ModalType = "cancel" + InfoModal ModalType = "info" + ConfirmModal ModalType = "confirm" + TransactionModal ModalType = "transaction" + GenerateModal ModalType = "generate" + ExceptionModal ModalType = "exception" +) + +func EmitShowModal(modal ModalType) tea.Cmd { + return func() tea.Msg { + return modal + } +} + +type ModalEvent struct { + Key *api.ParticipationKey + Active bool + Address string + Err *error + Type ModalType +} + +func EmitModalEvent(event ModalEvent) tea.Cmd { + return func() tea.Msg { + return event + } +} diff --git a/ui/app/viewport.go b/ui/app/viewport.go new file mode 100644 index 00000000..a7ed03f2 --- /dev/null +++ b/ui/app/viewport.go @@ -0,0 +1,19 @@ +package app + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// Page represents different pages that can be displayed in the application's viewport. +type Page string + +const ( + AccountsPage Page = "accounts" + KeysPage Page = "keys" +) + +func EmitShowPage(page Page) tea.Cmd { + return func() tea.Msg { + return page + } +} diff --git a/ui/controls/controller.go b/ui/controls/controller.go deleted file mode 100644 index 006f9eb7..00000000 --- a/ui/controls/controller.go +++ /dev/null @@ -1,29 +0,0 @@ -package controls - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -// Init has no I/O right now -func (m Model) Init() tea.Cmd { - return nil -} - -// Update processes incoming messages, modifies the model state, and returns the updated model and command to execute. -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m.HandleMessage(msg) -} - -// HandleMessage processes incoming messages, updates the model's state, and returns the updated model and a command to execute. -func (m Model) HandleMessage(msg tea.Msg) (Model, tea.Cmd) { - switch msg := msg.(type) { - - case tea.WindowSizeMsg: - if msg.Width != 0 && msg.Height != 0 { - m.Width = msg.Width - m.Height = lipgloss.Height(m.View()) - } - } - return m, nil -} diff --git a/ui/controls/model.go b/ui/controls/model.go deleted file mode 100644 index 92baa819..00000000 --- a/ui/controls/model.go +++ /dev/null @@ -1,19 +0,0 @@ -package controls - -// Model represents the data structure used for defining visibility, dimensions, and content. -type Model struct { - Width int - Height int - IsVisible bool - Content string -} - -// New creates a instance of a Model -func New(body string) Model { - return Model{ - IsVisible: true, - Width: 80, - Height: 24, - Content: body, - } -} diff --git a/ui/controls/style.go b/ui/controls/style.go deleted file mode 100644 index 3ad10eb2..00000000 --- a/ui/controls/style.go +++ /dev/null @@ -1,12 +0,0 @@ -package controls - -import "github.com/charmbracelet/lipgloss" - -var controlStyle = func() lipgloss.Style { - b := lipgloss.RoundedBorder() - b.Left = "┤" - b.Right = "├" - return lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderStyle(b) -}() diff --git a/ui/controls/testdata/Test_Snapshot/Visible.golden b/ui/controls/testdata/Test_Snapshot/Visible.golden deleted file mode 100644 index 8f20fb4b..00000000 --- a/ui/controls/testdata/Test_Snapshot/Visible.golden +++ /dev/null @@ -1,3 +0,0 @@ - ╭──────╮ -────────────────────────────────────┤ test ├──────────────────────────────────── - ╰──────╯ \ No newline at end of file diff --git a/ui/controls/view.go b/ui/controls/view.go deleted file mode 100644 index 3754f42c..00000000 --- a/ui/controls/view.go +++ /dev/null @@ -1,17 +0,0 @@ -package controls - -import ( - "github.com/charmbracelet/lipgloss" - "strings" -) - -// View renders the model's content if it is visible, aligning it horizontally and ensuring it fits within the specified width. -func (m Model) View() string { - if !m.IsVisible { - return "" - } - render := controlStyle.Render(m.Content) - difference := m.Width - lipgloss.Width(render) - line := strings.Repeat("─", max(0, difference/2)) - return lipgloss.JoinHorizontal(lipgloss.Center, line, render, line) -} diff --git a/ui/error.go b/ui/error.go deleted file mode 100644 index c0678d7d..00000000 --- a/ui/error.go +++ /dev/null @@ -1,69 +0,0 @@ -package ui - -import ( - "github.com/algorandfoundation/hack-tui/ui/controls" - "github.com/algorandfoundation/hack-tui/ui/style" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "strings" -) - -type ErrorViewModel struct { - Height int - Width int - controls controls.Model - Message string -} - -func NewErrorViewModel(message string) ErrorViewModel { - return ErrorViewModel{ - Height: 0, - Width: 0, - Message: message, - } -} - -func (m ErrorViewModel) Init() tea.Cmd { - return nil -} - -func (m ErrorViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m.HandleMessage(msg) -} - -func (m ErrorViewModel) HandleMessage(msg tea.Msg) (ErrorViewModel, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - - case tea.WindowSizeMsg: - borderRender := style.Border.Render("") - m.Width = max(0, msg.Width-lipgloss.Width(borderRender)) - m.Height = max(0, msg.Height-lipgloss.Height(borderRender)) - } - - return m, cmd -} - -func (m ErrorViewModel) View() string { - msgHeight := lipgloss.Height(m.Message) - msgWidth := lipgloss.Width(m.Message) - - if msgWidth > m.Width/2 { - m.Message = m.Message[0:m.Width/2] + "..." - msgWidth = m.Width/2 + 3 - } - - msg := style.Red.Render(m.Message) - padT := strings.Repeat("\n", max(0, (m.Height/2)-msgHeight)) - padL := strings.Repeat(" ", max(0, (m.Width-msgWidth)/2)) - - text := lipgloss.JoinHorizontal(lipgloss.Left, padL, msg) - render := style.ApplyBorder(m.Width, m.Height, "8").Render(lipgloss.JoinVertical(lipgloss.Center, padT, text)) - return style.WithNavigation( - "( Waiting for recovery... )", - style.WithTitle( - "System Error", - render, - ), - ) -} diff --git a/ui/error_test.go b/ui/error_test.go deleted file mode 100644 index 10020eac..00000000 --- a/ui/error_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package ui - -import ( - "bytes" - "github.com/algorandfoundation/hack-tui/ui/controls" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/exp/golden" - "github.com/charmbracelet/x/exp/teatest" - "testing" - "time" -) - -func Test_ErrorSnapshot(t *testing.T) { - t.Run("Visible", func(t *testing.T) { - model := ErrorViewModel{ - Height: 20, - Width: 40, - controls: controls.New(" Error "), - Message: "a test error", - } - got := ansi.Strip(model.View()) - golden.RequireEqual(t, []byte(got)) - }) -} - -func Test_ErrorMessages(t *testing.T) { - tm := teatest.NewTestModel( - t, ErrorViewModel{Message: "a test error"}, - teatest.WithInitialTermSize(120, 80), - ) - - // Wait for prompt to exit - teatest.WaitFor( - t, tm.Output(), - func(bts []byte) bool { - return bytes.Contains(bts, []byte("a test error")) - }, - teatest.WithCheckInterval(time.Millisecond*100), - teatest.WithDuration(time.Second*3), - ) - // Resize Message - tm.Send(tea.WindowSizeMsg{ - Width: 50, - Height: 20, - }) - - // Send quit key - tm.Send(tea.QuitMsg{}) - - tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) -} diff --git a/ui/internal/test/state.go b/ui/internal/test/state.go new file mode 100644 index 00000000..8d9904ec --- /dev/null +++ b/ui/internal/test/state.go @@ -0,0 +1,59 @@ +package test + +import ( + "context" + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + mock2 "github.com/algorandfoundation/hack-tui/internal/test/mock" + "time" +) + +func GetState(client api.ClientWithResponsesInterface) *internal.StateModel { + sm := &internal.StateModel{ + Status: internal.StatusModel{ + State: internal.StableState, + Version: "v-test", + Network: "v-test-network", + Voting: false, + NeedsUpdate: false, + LastRound: 0, + }, + Metrics: internal.MetricsModel{ + Enabled: true, + Window: 100, + RoundTime: time.Second * 2, + TPS: 2.5, + RX: 0, + TX: 0, + LastTS: time.Time{}, + LastRX: 0, + LastTX: 0, + }, + Accounts: nil, + ParticipationKeys: &mock2.Keys, + Admin: false, + Watching: false, + Client: client, + Context: context.Background(), + } + values := make(map[string]internal.Account) + clock := new(mock2.Clock) + for _, key := range *sm.ParticipationKeys { + val, ok := values[key.Address] + if !ok { + values[key.Address] = internal.Account{ + Address: key.Address, + Status: "Offline", + Balance: 0, + Expires: internal.GetExpiresTime(clock, key, sm), + Keys: 1, + } + } else { + val.Keys++ + values[key.Address] = val + } + } + sm.Accounts = values + + return sm +} diff --git a/ui/modal/controller.go b/ui/modal/controller.go new file mode 100644 index 00000000..6e54278f --- /dev/null +++ b/ui/modal/controller.go @@ -0,0 +1,156 @@ +package modal + +import ( + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/modals/generate" + "github.com/algorandfoundation/hack-tui/ui/style" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func (m ViewModel) Init() tea.Cmd { + return tea.Batch( + m.infoModal.Init(), + m.exceptionModal.Init(), + m.transactionModal.Init(), + m.confirmModal.Init(), + m.generateModal.Init(), + ) +} +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + switch msg := msg.(type) { + case error: + m.Open = true + m.exceptionModal.Message = msg.Error() + m.SetType(app.ExceptionModal) + case internal.StateModel: + m.State = &msg + m.transactionModal.State = &msg + m.infoModal.State = &msg + + // When the state changes, and we are displaying a valid QR Code/Transaction Modal + if m.Type == app.TransactionModal && m.transactionModal.Participation != nil { + acct, ok := msg.Accounts[m.Address] + // If the previous state is not active + if ok { + if !m.transactionModal.Active { + if acct.Participation != nil && + acct.Participation.VoteFirstValid == m.transactionModal.Participation.Key.VoteFirstValid { + m.SetActive(true) + m.infoModal.Active = true + m.SetType(app.InfoModal) + } + } else { + if acct.Participation == nil { + m.SetActive(false) + m.infoModal.Active = false + m.transactionModal.Active = false + m.SetType(app.InfoModal) + } + } + } + + } + + case app.ModalEvent: + if msg.Type == app.InfoModal { + m.generateModal.SetStep(generate.AddressStep) + } + // On closing events + if msg.Type == app.CloseModal { + m.Open = false + m.generateModal.Input.Focus() + } else { + m.Open = true + } + // When something has triggered a cancel + if msg.Type == app.CancelModal { + switch m.Type { + case app.InfoModal: + m.Open = false + case app.GenerateModal: + m.Open = false + m.SetType(app.InfoModal) + m.generateModal.SetStep(generate.AddressStep) + m.generateModal.Input.Focus() + case app.TransactionModal: + m.SetType(app.InfoModal) + case app.ExceptionModal: + m.Open = false + case app.ConfirmModal: + m.SetType(app.InfoModal) + } + } + + if msg.Type != app.CloseModal && msg.Type != app.CancelModal { + m.SetKey(msg.Key) + m.SetAddress(msg.Address) + m.SetActive(msg.Active) + m.SetType(msg.Type) + } + + // Handle Modal Type + case app.ModalType: + m.SetType(msg) + + // Handle Confirmation Dialog Delete Finished + case app.DeleteFinished: + m.Open = false + m.Type = app.InfoModal + if msg.Err != nil { + m.Open = true + m.Type = app.ExceptionModal + m.exceptionModal.Message = "Delete failed" + } + // Handle View Size changes + case tea.WindowSizeMsg: + m.Width = msg.Width + m.Height = msg.Height + + b := style.Border.Render("") + // Custom size message + modalMsg := tea.WindowSizeMsg{ + Width: m.Width - lipgloss.Width(b), + Height: m.Height - lipgloss.Height(b), + } + + // Handle the page resize event + m.infoModal, cmd = m.infoModal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) + m.transactionModal, cmd = m.transactionModal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) + m.confirmModal, cmd = m.confirmModal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) + m.generateModal, cmd = m.generateModal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) + m.exceptionModal, cmd = m.exceptionModal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) + return &m, tea.Batch(cmds...) + } + + // Only trigger modal commands when they are active + switch m.Type { + case app.ExceptionModal: + m.exceptionModal, cmd = m.exceptionModal.HandleMessage(msg) + case app.InfoModal: + m.infoModal, cmd = m.infoModal.HandleMessage(msg) + case app.TransactionModal: + m.transactionModal, cmd = m.transactionModal.HandleMessage(msg) + + case app.ConfirmModal: + m.confirmModal, cmd = m.confirmModal.HandleMessage(msg) + case app.GenerateModal: + m.generateModal, cmd = m.generateModal.HandleMessage(msg) + } + cmds = append(cmds, cmd) + + return &m, tea.Batch(cmds...) +} +func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m.HandleMessage(msg) +} diff --git a/ui/modal/modal_test.go b/ui/modal/modal_test.go new file mode 100644 index 00000000..f7af976b --- /dev/null +++ b/ui/modal/modal_test.go @@ -0,0 +1,194 @@ +package modal + +import ( + "bytes" + "errors" + "github.com/algorandfoundation/hack-tui/internal/test/mock" + "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/internal/test" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +func Test_Snapshot(t *testing.T) { + t.Run("NoKey", func(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) + + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("InfoModal", func(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) + model.SetKey(&mock.Keys[0]) + model.SetType(app.InfoModal) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("ConfirmModal", func(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) + model.SetKey(&mock.Keys[0]) + model.SetType(app.ConfirmModal) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("ExceptionModal", func(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) + model.SetKey(&mock.Keys[0]) + model.SetType(app.ExceptionModal) + model, _ = model.HandleMessage(errors.New("test error")) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("GenerateModal", func(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) + model.SetKey(&mock.Keys[0]) + model.SetAddress("ABC") + model.SetType(app.GenerateModal) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + + t.Run("TransactionModal", func(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) + model.State.Status.Network = "testnet-v1.0" + model.SetKey(&mock.Keys[0]) + model.SetActive(true) + model.SetType(app.TransactionModal) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) + model.SetKey(&mock.Keys[0]) + model.SetAddress("ABC") + model.SetType(app.InfoModal) + tm := teatest.NewTestModel( + t, model, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("State Proof Key: VEVTVEtFWQ")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(errors.New("Something else went wrong")) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("d"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("o"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + + tm.Send(app.InfoModal) + + tm.Send(app.DeleteFinished{ + Err: nil, + Id: mock.Keys[0].Id, + }) + + delError := errors.New("Something went wrong") + tm.Send(app.DeleteFinished{ + Err: &delError, + Id: "", + }) + + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.InfoModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.CancelModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.GenerateModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.CancelModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.ConfirmModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.CancelModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.TransactionModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.CancelModal, + }) + + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.CloseModal, + }) + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/modal/model.go b/ui/modal/model.go new file mode 100644 index 00000000..b22d6be7 --- /dev/null +++ b/ui/modal/model.go @@ -0,0 +1,106 @@ +package modal + +import ( + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/modals/confirm" + "github.com/algorandfoundation/hack-tui/ui/modals/exception" + "github.com/algorandfoundation/hack-tui/ui/modals/generate" + "github.com/algorandfoundation/hack-tui/ui/modals/info" + "github.com/algorandfoundation/hack-tui/ui/modals/transaction" +) + +type ViewModel struct { + // Parent render which the modal will be displayed on + Parent string + // Open indicates whether the modal is open or closed. + Open bool + // Width specifies the width in units. + Width int + // Height specifies the height in units. + Height int + + // State for Context/Client + State *internal.StateModel + // Address defines the string format address of the entity + Address string + + // Views + infoModal *info.ViewModel + transactionModal *transaction.ViewModel + confirmModal *confirm.ViewModel + generateModal *generate.ViewModel + exceptionModal *exception.ViewModel + + // Current Component Data + title string + controls string + borderColor string + Type app.ModalType +} + +func (m *ViewModel) SetAddress(address string) { + m.Address = address + m.generateModal.SetAddress(address) +} +func (m *ViewModel) SetKey(key *api.ParticipationKey) { + m.infoModal.Participation = key + m.confirmModal.ActiveKey = key + m.transactionModal.Participation = key +} +func (m *ViewModel) SetActive(active bool) { + m.infoModal.Active = active + m.infoModal.UpdateState() + m.transactionModal.Active = active + m.transactionModal.UpdateState() +} + +func (m *ViewModel) SetType(modal app.ModalType) { + m.Type = modal + switch modal { + case app.InfoModal: + m.title = m.infoModal.Title + m.controls = m.infoModal.Controls + m.borderColor = m.infoModal.BorderColor + case app.ConfirmModal: + m.title = m.confirmModal.Title + m.controls = m.confirmModal.Controls + m.borderColor = m.confirmModal.BorderColor + case app.GenerateModal: + m.title = m.generateModal.Title + m.controls = m.generateModal.Controls + m.borderColor = m.generateModal.BorderColor + case app.TransactionModal: + m.title = m.transactionModal.Title + m.controls = m.transactionModal.Controls + m.borderColor = m.transactionModal.BorderColor + case app.ExceptionModal: + m.title = m.exceptionModal.Title + m.controls = m.exceptionModal.Controls + m.borderColor = m.exceptionModal.BorderColor + } +} + +func New(parent string, open bool, state *internal.StateModel) *ViewModel { + return &ViewModel{ + Parent: parent, + Open: open, + + Width: 0, + Height: 0, + + Address: "", + State: state, + + infoModal: info.New(state), + transactionModal: transaction.New(state), + confirmModal: confirm.New(state), + generateModal: generate.New("", state), + exceptionModal: exception.New(""), + + Type: app.InfoModal, + controls: "", + borderColor: "3", + } +} diff --git a/ui/modal/testdata/Test_Snapshot/ConfirmModal.golden b/ui/modal/testdata/Test_Snapshot/ConfirmModal.golden new file mode 100644 index 00000000..e78113bb --- /dev/null +++ b/ui/modal/testdata/Test_Snapshot/ConfirmModal.golden @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──Delete Key────────────────────────────────────────────────╮ + │ │ + │ Are you sure you want to delete this key from your node? │ + │ │ + │ Account Address: │ + │ ABC │ + │ │ + │ Participation Key: │ + │ 123 │ + │ │ + ╰────────────────────────────────────────( (y)es | (n)o )────╯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/modal/testdata/Test_Snapshot/ExceptionModal.golden b/ui/modal/testdata/Test_Snapshot/ExceptionModal.golden new file mode 100644 index 00000000..5068e929 --- /dev/null +++ b/ui/modal/testdata/Test_Snapshot/ExceptionModal.golden @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──Error─────╮ + │ test error │ + ╰─( esc )────╯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/modal/testdata/Test_Snapshot/GenerateModal.golden b/ui/modal/testdata/Test_Snapshot/GenerateModal.golden new file mode 100644 index 00000000..11c74911 --- /dev/null +++ b/ui/modal/testdata/Test_Snapshot/GenerateModal.golden @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──Generate Consensus Participation Keys─────────────────────────────────╮ + │ │ + │ Create keys required to participate in Algorand consensus. │ + │ │ + │ Account address: │ + │ > ABC │ + │ │ + ╰───────────────────────────────────────────────────( esc to cancel )────╯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/modal/testdata/Test_Snapshot/InfoModal.golden b/ui/modal/testdata/Test_Snapshot/InfoModal.golden new file mode 100644 index 00000000..d35f7740 --- /dev/null +++ b/ui/modal/testdata/Test_Snapshot/InfoModal.golden @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──Key Information────────────╮ + │ │ + │ Account: ABC │ + │ Participation ID: 123 │ + │ │ + │ Selection Key: VEVTVEtFWQ │ + │ Vote Key: VEVTVEtFWQ │ + │ State Proof Key: VEVTVEtFWQ │ + │ │ + │ Vote First Valid: 0 │ + │ Vote Last Valid: 30000 │ + │ Vote Key Dilution: 100 │ + │ │ + ╰──( (d)elete | (o)nline )────╯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/modal/testdata/Test_Snapshot/NoKey.golden b/ui/modal/testdata/Test_Snapshot/NoKey.golden new file mode 100644 index 00000000..6fc9abbd --- /dev/null +++ b/ui/modal/testdata/Test_Snapshot/NoKey.golden @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭─────────────────╮ + │ No key selected │ + ╰─────────────────╯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/modal/testdata/Test_Snapshot/TransactionModal.golden b/ui/modal/testdata/Test_Snapshot/TransactionModal.golden new file mode 100644 index 00000000..164440e1 --- /dev/null +++ b/ui/modal/testdata/Test_Snapshot/TransactionModal.golden @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──Register Offline─────────────────────────────────────────╮ + │ Sign this transaction to deregister your account keys: │ + │ │ + │ Scan the QR code with Pera or Defly │ + │ (make sure you use the testnet-v1.0 network) │ + │ │ + │ █████████████████████████████████ │ + │ ██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ │ + │ ██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ │ + │ ██ █▄▄▄█ █▄ ▄▀▄▄▀▀ █ ▄█ █▄▄▄█ ██ │ + │ ██▄▄▄▄▄▄▄█▄▀▄▀ █▄█ ▀▄▀▄█▄▄▄▄▄▄▄██ │ + │ ██ ▀ █ ▀▄██▀█ ▀▀██ ▀██▀██▄█▀ ▀██ │ + │ ██▄ ▀▄██▄ ▀▄ ▄▀▀ ▀▀▀█▀█▀ ▄▄█▄ ██ │ + │ ██ ▄▀▄ █▄ ██▀▄█ █ ▄▀██ ▄█▄ █ ██ │ + │ ██▀▄▀▀█▀▄▀▀▀ ██▄██▄▀ ▀▀█ ▄▀██ ▄██ │ + │ ███▀██▀▄▄ ▄▀▀▀▄▀██▀ ▀▄██ ▀█▀█ ▀██ │ + │ ██▄▀ ▀▄▄▄ ▄ █▀▀ ▀█▀▄▀▀▄▀▄▀▄▄▄▄██ │ + │ ██▄█▄▄██▄█▀█▀███ ▄▀ █▀ ▄▄▄ █▀▀ ██ │ + │ ██ ▄▄▄▄▄ ██▄█▄█▄█▀▀▀▀▄ █▄█ ██▄ ██ │ + │ ██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ │ + │ ██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ │ + │ ██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ │ + │ │ + │ -or- │ + │ │ + │ Click here to sign via Lora. │ + │ │ + │ Note: this will take effect after 320 rounds (15 mins.) │ + │ Please keep your node online during this cooldown period. │ + ╰────────────────────────────────────────────────( esc )────╯ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/modal/view.go b/ui/modal/view.go new file mode 100644 index 00000000..f02aad8f --- /dev/null +++ b/ui/modal/view.go @@ -0,0 +1,39 @@ +package modal + +import ( + "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/style" + "github.com/charmbracelet/lipgloss" +) + +func (m ViewModel) View() string { + if !m.Open { + return m.Parent + } + var render = "" + switch m.Type { + case app.InfoModal: + render = m.infoModal.View() + case app.TransactionModal: + render = m.transactionModal.View() + case app.ConfirmModal: + render = m.confirmModal.View() + case app.GenerateModal: + render = m.generateModal.View() + case app.ExceptionModal: + render = m.exceptionModal.View() + } + width := lipgloss.Width(render) + 2 + height := lipgloss.Height(render) + + return style.WithOverlay(style.WithNavigation( + m.controls, + style.WithTitle( + m.title, + style.ApplyBorder(width, height, m.borderColor). + PaddingRight(1). + PaddingLeft(1). + Render(render), + ), + ), m.Parent) +} diff --git a/ui/modals/confirm/confirm.go b/ui/modals/confirm/confirm.go new file mode 100644 index 00000000..5d852058 --- /dev/null +++ b/ui/modals/confirm/confirm.go @@ -0,0 +1,77 @@ +package confirm + +import ( + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/style" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type ViewModel struct { + Width int + Height int + Title string + Controls string + BorderColor string + ActiveKey *api.ParticipationKey + Data *internal.StateModel +} + +func New(state *internal.StateModel) *ViewModel { + return &ViewModel{ + Width: 0, + Height: 0, + Title: "Delete Key", + BorderColor: "9", + Controls: "( " + style.Green.Render("(y)es") + " | " + style.Red.Render("(n)o") + " )", + Data: state, + } +} + +func (m ViewModel) Init() tea.Cmd { + return nil +} + +func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m.HandleMessage(msg) +} +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc", "n": + return &m, app.EmitModalEvent(app.ModalEvent{ + Type: app.CancelModal, + }) + case "y": + var ( + cmds []tea.Cmd + ) + cmds = append(cmds, app.EmitDeleteKey(m.Data.Context, m.Data.Client, m.ActiveKey.Id)) + return &m, tea.Batch(cmds...) + } + case tea.WindowSizeMsg: + m.Width = msg.Width + m.Height = msg.Height + } + return &m, nil +} +func (m ViewModel) View() string { + if m.ActiveKey == nil { + return "No key selected" + } + return renderDeleteConfirmationModal(m.ActiveKey) + +} + +func renderDeleteConfirmationModal(partKey *api.ParticipationKey) string { + return lipgloss.NewStyle().Padding(1).Render(lipgloss.JoinVertical(lipgloss.Center, + "Are you sure you want to delete this key from your node?\n", + style.Cyan.Render("Account Address:"), + partKey.Address+"\n", + style.Cyan.Render("Participation Key:"), + partKey.Id, + )) +} diff --git a/ui/modals/confirm/confirm_test.go b/ui/modals/confirm/confirm_test.go new file mode 100644 index 00000000..a045ab30 --- /dev/null +++ b/ui/modals/confirm/confirm_test.go @@ -0,0 +1,79 @@ +package confirm + +import ( + "bytes" + "github.com/algorandfoundation/hack-tui/internal/test/mock" + "github.com/algorandfoundation/hack-tui/ui/internal/test" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +func Test_New(t *testing.T) { + m := New(test.GetState(nil)) + if m.ActiveKey != nil { + t.Errorf("expected ActiveKey to be nil") + } + m.ActiveKey = &mock.Keys[0] + // Handle Delete + m, cmd := m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("y"), + }) + + if cmd == nil { + t.Errorf("expected cmd to be non-nil") + } +} +func Test_Snapshot(t *testing.T) { + t.Run("NoKey", func(t *testing.T) { + model := New(test.GetState(nil)) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Visible", func(t *testing.T) { + model := New(test.GetState(nil)) + model.ActiveKey = &mock.Keys[0] + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + // Create the Model + m := New(test.GetState(nil)) + m.ActiveKey = &mock.Keys[0] + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("Are you sure you want to delete this key from your node?")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(*test.GetState(nil)) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("n"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/modals/confirm/testdata/Test_Snapshot/NoKey.golden b/ui/modals/confirm/testdata/Test_Snapshot/NoKey.golden new file mode 100644 index 00000000..eacdfa5a --- /dev/null +++ b/ui/modals/confirm/testdata/Test_Snapshot/NoKey.golden @@ -0,0 +1 @@ +No key selected \ No newline at end of file diff --git a/ui/modals/confirm/testdata/Test_Snapshot/Visible.golden b/ui/modals/confirm/testdata/Test_Snapshot/Visible.golden new file mode 100644 index 00000000..290e2e59 --- /dev/null +++ b/ui/modals/confirm/testdata/Test_Snapshot/Visible.golden @@ -0,0 +1,9 @@ + + Are you sure you want to delete this key from your node? + + Account Address: + ABC + + Participation Key: + 123 + \ No newline at end of file diff --git a/ui/modals/exception/error.go b/ui/modals/exception/error.go new file mode 100644 index 00000000..23fcb642 --- /dev/null +++ b/ui/modals/exception/error.go @@ -0,0 +1,66 @@ +package exception + +import ( + "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/style" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +type ViewModel struct { + Height int + Width int + Message string + + Title string + BorderColor string + Controls string + Navigation string +} + +func New(message string) *ViewModel { + return &ViewModel{ + Height: 0, + Width: 0, + Message: message, + Title: "Error", + BorderColor: "1", + Controls: "( esc )", + Navigation: "", + } +} + +func (m ViewModel) Init() tea.Cmd { + return nil +} + +func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m.HandleMessage(msg) +} + +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case error: + m.Message = msg.Error() + case tea.KeyMsg: + switch msg.String() { + case "esc": + return &m, app.EmitModalEvent(app.ModalEvent{ + Type: app.CancelModal, + }) + + } + case tea.WindowSizeMsg: + borderRender := style.Border.Render("") + m.Width = max(0, msg.Width-lipgloss.Width(borderRender)) + m.Height = max(0, msg.Height-lipgloss.Height(borderRender)) + } + + return &m, cmd +} + +func (m ViewModel) View() string { + return ansi.Hardwrap(style.Red.Render(m.Message), m.Width, false) +} diff --git a/ui/controls/controls_test.go b/ui/modals/exception/error_test.go similarity index 72% rename from ui/controls/controls_test.go rename to ui/modals/exception/error_test.go index 2693afa3..99bb9c2b 100644 --- a/ui/controls/controls_test.go +++ b/ui/modals/exception/error_test.go @@ -1,7 +1,8 @@ -package controls +package exception import ( "bytes" + "errors" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -12,17 +13,15 @@ import ( func Test_Snapshot(t *testing.T) { t.Run("Visible", func(t *testing.T) { - model := New(" test ") + model := New("Something went wrong") got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) } func Test_Messages(t *testing.T) { - expected := "(q)uit | (d)elete | (g)enerate | (t)xn | (h)ide" // Create the Model - m := New(expected) - + m := New("Something went wrong") tm := teatest.NewTestModel( t, m, teatest.WithInitialTermSize(80, 40), @@ -32,13 +31,18 @@ func Test_Messages(t *testing.T) { teatest.WaitFor( t, tm.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte(expected)) + return bytes.Contains(bts, []byte("Something went wrong")) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3), ) - // Send quit msg + tm.Send(errors.New("Something else went wrong")) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + tm.Send(tea.QuitMsg{}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) diff --git a/ui/modals/exception/testdata/Test_Snapshot/Visible.golden b/ui/modals/exception/testdata/Test_Snapshot/Visible.golden new file mode 100644 index 00000000..72f8f900 --- /dev/null +++ b/ui/modals/exception/testdata/Test_Snapshot/Visible.golden @@ -0,0 +1 @@ +Something went wrong \ No newline at end of file diff --git a/ui/modals/generate/controller.go b/ui/modals/generate/controller.go new file mode 100644 index 00000000..1ee5aa11 --- /dev/null +++ b/ui/modals/generate/controller.go @@ -0,0 +1,114 @@ +package generate + +import ( + "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "strconv" + "time" +) + +func (m ViewModel) Init() tea.Cmd { + return tea.Batch(textinput.Blink, spinner.Tick) +} + +func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m.HandleMessage(msg) +} + +func (m *ViewModel) SetStep(step Step) { + m.Step = step + switch m.Step { + case AddressStep: + m.Controls = "( esc to cancel )" + m.Title = DefaultTitle + m.BorderColor = DefaultBorderColor + case DurationStep: + m.Controls = "( (s)witch range )" + m.Title = "Validity Range" + m.InputTwo.Focus() + m.InputTwo.PromptStyle = focusedStyle + m.InputTwo.TextStyle = focusedStyle + m.Input.Blur() + case WaitingStep: + m.Controls = "" + m.Title = "Generating Keys" + m.BorderColor = "9" + } +} + +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.Width = msg.Width + m.Height = msg.Height + case tea.KeyMsg: + switch msg.String() { + case "esc": + if m.Step != WaitingStep { + return &m, app.EmitModalEvent(app.ModalEvent{ + Type: app.CancelModal, + }) + } + case "s": + if m.Step == DurationStep { + switch m.Range { + case Day: + m.Range = Week + case Week: + m.Range = Month + case Month: + m.Range = Year + case Year: + m.Range = Day + } + return &m, nil + } + case "enter": + switch m.Step { + case AddressStep: + m.SetStep(DurationStep) + return &m, app.EmitShowModal(app.GenerateModal) + case DurationStep: + m.SetStep(WaitingStep) + val, _ := strconv.Atoi(m.InputTwo.Value()) + var dur time.Duration + switch m.Range { + case Day: + dur = time.Duration(int(time.Hour*24) * val) + case Week: + dur = time.Duration(int(time.Hour*24*7) * val) + case Month: + dur = time.Duration(int(time.Hour*24*30) * val) + case Year: + dur = time.Duration(int(time.Hour*24*365) * val) + } + return &m, tea.Sequence(app.EmitShowModal(app.GenerateModal), app.GenerateCmd(m.Input.Value(), dur, m.State)) + + } + + } + + } + + switch m.Step { + case AddressStep: + // Handle character input and blinking + var val textinput.Model + val, cmd = m.Input.Update(msg) + m.Input = &val + cmds = append(cmds, cmd) + case DurationStep: + var val textinput.Model + val, cmd = m.InputTwo.Update(msg) + m.InputTwo = &val + cmds = append(cmds, cmd) + } + + return &m, tea.Batch(cmds...) +} diff --git a/ui/modals/generate/generate_test.go b/ui/modals/generate/generate_test.go new file mode 100644 index 00000000..42d0bae5 --- /dev/null +++ b/ui/modals/generate/generate_test.go @@ -0,0 +1,150 @@ +package generate + +import ( + "bytes" + "github.com/algorandfoundation/hack-tui/ui/internal/test" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +func Test_New(t *testing.T) { + m := New("ABC", test.GetState(nil)) + + m.SetAddress("ACB") + + if m.Address != "ABC" { + t.Error("Did not set address") + } + + m.SetStep(AddressStep) + if m.Step != AddressStep { + t.Error("Did not advance to address step") + } + if m.Controls != "( esc to cancel )" { + t.Error("Did not set controls") + } + + m.SetStep(DurationStep) + m.InputTwo.SetValue("1") + + m, cmd := m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + if cmd == nil { + t.Error("Did not return the generate command") + } + if m.Step != WaitingStep { + t.Error("Did not advance to waiting step") + } + + m.SetStep(DurationStep) + m.Range = Week + m, cmd = m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + if cmd == nil { + t.Error("Did not return the generate command") + } + + m.SetStep(DurationStep) + m.Range = Month + m, cmd = m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + if cmd == nil { + t.Error("Did not return the generate command") + } + + m.SetStep(DurationStep) + m.Range = Year + m, cmd = m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + if cmd == nil { + t.Error("Did not return the generate command") + } +} + +func Test_Snapshot(t *testing.T) { + t.Run("Visible", func(t *testing.T) { + model := New("ABC", test.GetState(nil)) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Duration", func(t *testing.T) { + model := New("ABC", test.GetState(nil)) + model.SetStep(DurationStep) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Waiting", func(t *testing.T) { + model := New("ABC", test.GetState(nil)) + model.SetStep(WaitingStep) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + // Create the Model + m := New("ABC", test.GetState(nil)) + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("Create keys required to participate in Algorand consensus.")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + // Enter into duration mode + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + // Rotate the durations + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("s"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("s"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("s"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("s"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("1"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/modals/generate/model.go b/ui/modals/generate/model.go new file mode 100644 index 00000000..da1f3927 --- /dev/null +++ b/ui/modals/generate/model.go @@ -0,0 +1,84 @@ +package generate + +import ( + "github.com/algorandfoundation/hack-tui/internal" + "github.com/charmbracelet/bubbles/cursor" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" +) + +type Step string + +const ( + AddressStep Step = "address" + DurationStep Step = "duration" + WaitingStep Step = "waiting" +) + +type Range string + +const ( + Day Range = "day" + Week Range = "week" + Month Range = "month" + Year Range = "year" +) + +type ViewModel struct { + Width int + Height int + + Address string + Input *textinput.Model + InputTwo *textinput.Model + Spinner *spinner.Model + Step Step + Range Range + + Title string + Controls string + BorderColor string + + State *internal.StateModel + cursorMode cursor.Mode +} + +func (m ViewModel) SetAddress(address string) { + m.Address = address + m.Input.SetValue(address) +} + +var DefaultControls = "( esc to cancel )" +var DefaultTitle = "Generate Consensus Participation Keys" +var DefaultBorderColor = "2" + +func New(address string, state *internal.StateModel) *ViewModel { + input := textinput.New() + input2 := textinput.New() + + m := ViewModel{ + Address: address, + State: state, + Input: &input, + InputTwo: &input2, + Step: AddressStep, + Range: Day, + Title: DefaultTitle, + Controls: DefaultControls, + BorderColor: DefaultBorderColor, + } + input.Cursor.Style = cursorStyle + input.CharLimit = 68 + input.Placeholder = "Wallet Address" + input.Focus() + input.PromptStyle = focusedStyle + input.TextStyle = focusedStyle + + input2.Cursor.Style = cursorStyle + input2.CharLimit = 68 + input2.Placeholder = "Length of time" + + input2.PromptStyle = noStyle + input2.TextStyle = noStyle + return &m +} diff --git a/ui/pages/generate/style.go b/ui/modals/generate/style.go similarity index 51% rename from ui/pages/generate/style.go rename to ui/modals/generate/style.go index 8783d051..25ff905e 100644 --- a/ui/pages/generate/style.go +++ b/ui/modals/generate/style.go @@ -1,16 +1,11 @@ package generate import ( - "fmt" "github.com/charmbracelet/lipgloss" ) var ( focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) cursorStyle = focusedStyle noStyle = lipgloss.NewStyle() - - focusedButton = focusedStyle.Render("[ Submit ]") - blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Submit")) ) diff --git a/ui/modals/generate/testdata/Test_Snapshot/Duration.golden b/ui/modals/generate/testdata/Test_Snapshot/Duration.golden new file mode 100644 index 00000000..cc1ad542 --- /dev/null +++ b/ui/modals/generate/testdata/Test_Snapshot/Duration.golden @@ -0,0 +1,6 @@ + +How long should the keys be valid for? + +Duration in days: +> Length of time + \ No newline at end of file diff --git a/ui/modals/generate/testdata/Test_Snapshot/Visible.golden b/ui/modals/generate/testdata/Test_Snapshot/Visible.golden new file mode 100644 index 00000000..a6014f05 --- /dev/null +++ b/ui/modals/generate/testdata/Test_Snapshot/Visible.golden @@ -0,0 +1,6 @@ + +Create keys required to participate in Algorand consensus. + +Account address: +> Wallet Address + \ No newline at end of file diff --git a/ui/modals/generate/testdata/Test_Snapshot/Waiting.golden b/ui/modals/generate/testdata/Test_Snapshot/Waiting.golden new file mode 100644 index 00000000..96b7e302 --- /dev/null +++ b/ui/modals/generate/testdata/Test_Snapshot/Waiting.golden @@ -0,0 +1,3 @@ +Generating Participation Keys... + +Please wait. This operation can take a few minutes. \ No newline at end of file diff --git a/ui/modals/generate/view.go b/ui/modals/generate/view.go new file mode 100644 index 00000000..f53ad905 --- /dev/null +++ b/ui/modals/generate/view.go @@ -0,0 +1,37 @@ +package generate + +import ( + "fmt" + "github.com/charmbracelet/lipgloss" +) + +func (m ViewModel) View() string { + render := "" + switch m.Step { + case AddressStep: + render = lipgloss.JoinVertical(lipgloss.Left, + "", + "Create keys required to participate in Algorand consensus.", + "", + "Account address:", + m.Input.View(), + "", + ) + case DurationStep: + render = lipgloss.JoinVertical(lipgloss.Left, + "", + "How long should the keys be valid for?", + "", + fmt.Sprintf("Duration in %ss:", m.Range), + m.InputTwo.View(), + "", + ) + case WaitingStep: + render = lipgloss.JoinVertical(lipgloss.Left, + "Generating Participation Keys...", + "", + "Please wait. This operation can take a few minutes.") + } + + return lipgloss.NewStyle().Width(70).Render(render) +} diff --git a/ui/modals/info/info.go b/ui/modals/info/info.go new file mode 100644 index 00000000..16321930 --- /dev/null +++ b/ui/modals/info/info.go @@ -0,0 +1,110 @@ +package info + +import ( + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/style" + "github.com/algorandfoundation/hack-tui/ui/utils" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +type ViewModel struct { + Width int + Height int + Title string + Controls string + BorderColor string + Active bool + Participation *api.ParticipationKey + State *internal.StateModel +} + +func New(state *internal.StateModel) *ViewModel { + return &ViewModel{ + Width: 0, + Height: 0, + Title: "Key Information", + BorderColor: "3", + Controls: "( " + style.Red.Render("(d)elete") + " | " + style.Green.Render("(o)nline") + " )", + State: state, + } +} + +func (m ViewModel) Init() tea.Cmd { + return nil +} + +func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m.HandleMessage(msg) +} +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + return &m, app.EmitModalEvent(app.ModalEvent{ + Type: app.CancelModal, + }) + case "d": + if !m.Active { + return &m, app.EmitShowModal(app.ConfirmModal) + } + case "o": + return &m, app.EmitShowModal(app.TransactionModal) + } + case tea.WindowSizeMsg: + m.Width = msg.Width + m.Height = msg.Height + } + m.UpdateState() + return &m, nil +} +func (m *ViewModel) UpdateState() { + if m.Participation == nil { + return + } + accountStatus := m.State.Accounts[m.Participation.Address].Status + + if accountStatus == "Online" && m.Active { + m.BorderColor = "1" + m.Controls = "( take " + style.Red.Render(style.Red.Render("(o)ffline")) + " )" + } + + if !m.Active { + m.BorderColor = "3" + m.Controls = "( " + style.Red.Render("(d)elete") + " | take " + style.Green.Render("(o)nline") + " )" + } +} +func (m ViewModel) View() string { + if m.Participation == nil { + return "No key selected" + } + account := style.Cyan.Render("Account: ") + m.Participation.Address + id := style.Cyan.Render("Participation ID: ") + m.Participation.Id + selection := style.Yellow.Render("Selection Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.Participation.Key.SelectionParticipationKey[:]) + vote := style.Yellow.Render("Vote Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.Participation.Key.VoteParticipationKey[:]) + stateProof := style.Yellow.Render("State Proof Key: ") + *utils.UrlEncodeBytesPtrOrNil(*m.Participation.Key.StateProofKey) + voteFirstValid := style.Purple("Vote First Valid: ") + utils.IntToStr(m.Participation.Key.VoteFirstValid) + voteLastValid := style.Purple("Vote Last Valid: ") + utils.IntToStr(m.Participation.Key.VoteLastValid) + voteKeyDilution := style.Purple("Vote Key Dilution: ") + utils.IntToStr(m.Participation.Key.VoteKeyDilution) + + return ansi.Hardwrap(lipgloss.JoinVertical(lipgloss.Left, + "", + account, + id, + "", + selection, + vote, + stateProof, + "", + voteFirstValid, + voteLastValid, + voteKeyDilution, + "", + ), m.Width, true) + +} diff --git a/ui/modals/info/info_test.go b/ui/modals/info/info_test.go new file mode 100644 index 00000000..fa6a63e8 --- /dev/null +++ b/ui/modals/info/info_test.go @@ -0,0 +1,83 @@ +package info + +import ( + "bytes" + "github.com/algorandfoundation/hack-tui/internal/test/mock" + "github.com/algorandfoundation/hack-tui/ui/internal/test" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +func Test_New(t *testing.T) { + m := New(test.GetState(nil)) + if m == nil { + t.Fatal("New returned nil") + } + m.Participation = &mock.Keys[0] + account := m.State.Accounts[mock.Keys[0].Address] + account.Status = "Online" + m.State.Accounts[mock.Keys[0].Address] = account + m.Active = true + m.UpdateState() + if m.BorderColor != "1" { + t.Error("State is not correct, border should be 1") + } + if m.Controls != "( take (o)ffline )" { + t.Error("Controls are not correct") + } +} +func Test_Snapshot(t *testing.T) { + t.Run("Visible", func(t *testing.T) { + model := New(test.GetState(nil)) + model.Participation = &mock.Keys[0] + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("NoKey", func(t *testing.T) { + model := New(test.GetState(nil)) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + // Create the Model + m := New(test.GetState(nil)) + m.Participation = &mock.Keys[0] + + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("Account: ABC")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("o"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("d"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/modals/info/testdata/Test_Snapshot/NoKey.golden b/ui/modals/info/testdata/Test_Snapshot/NoKey.golden new file mode 100644 index 00000000..eacdfa5a --- /dev/null +++ b/ui/modals/info/testdata/Test_Snapshot/NoKey.golden @@ -0,0 +1 @@ +No key selected \ No newline at end of file diff --git a/ui/modals/info/testdata/Test_Snapshot/Visible.golden b/ui/modals/info/testdata/Test_Snapshot/Visible.golden new file mode 100644 index 00000000..d258004b --- /dev/null +++ b/ui/modals/info/testdata/Test_Snapshot/Visible.golden @@ -0,0 +1,12 @@ + +Account: ABC +Participation ID: 123 + +Selection Key: VEVTVEtFWQ +Vote Key: VEVTVEtFWQ +State Proof Key: VEVTVEtFWQ + +Vote First Valid: 0 +Vote Last Valid: 30000 +Vote Key Dilution: 100 + \ No newline at end of file diff --git a/ui/modals/transaction/controller.go b/ui/modals/transaction/controller.go new file mode 100644 index 00000000..eb19fd55 --- /dev/null +++ b/ui/modals/transaction/controller.go @@ -0,0 +1,85 @@ +package transaction + +import ( + "encoding/base64" + "github.com/algorand/go-algorand-sdk/v2/types" + "github.com/algorandfoundation/algourl/encoder" + "github.com/algorandfoundation/hack-tui/ui/app" + tea "github.com/charmbracelet/bubbletea" +) + +type Title string + +const ( + OnlineTitle Title = "Register Online" + OfflineTitle Title = "Register Offline" +) + +func (m ViewModel) Init() tea.Cmd { + return nil +} + +func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m.HandleMessage(msg) +} + +// HandleMessage is called by the viewport to update its Model +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + return &m, app.EmitModalEvent(app.ModalEvent{ + Type: app.CancelModal, + }) + } + // Handle View Size changes + case tea.WindowSizeMsg: + m.Width = msg.Width + m.Height = msg.Height + } + m.UpdateState() + return &m, cmd +} + +func (m *ViewModel) UpdateState() { + if m.Participation == nil { + return + } + + if m.ATxn == nil { + m.ATxn = &encoder.AUrlTxn{} + } + fee := uint64(1000) + m.ATxn.AUrlTxnKeyCommon.Sender = m.Participation.Address + m.ATxn.AUrlTxnKeyCommon.Type = string(types.KeyRegistrationTx) + m.ATxn.AUrlTxnKeyCommon.Fee = &fee + + if !m.Active { + m.Title = string(OnlineTitle) + m.BorderColor = "2" + votePartKey := base64.RawURLEncoding.EncodeToString(m.Participation.Key.VoteParticipationKey) + selPartKey := base64.RawURLEncoding.EncodeToString(m.Participation.Key.SelectionParticipationKey) + spKey := base64.RawURLEncoding.EncodeToString(*m.Participation.Key.StateProofKey) + firstValid := uint64(m.Participation.Key.VoteFirstValid) + lastValid := uint64(m.Participation.Key.VoteLastValid) + vkDilution := uint64(m.Participation.Key.VoteKeyDilution) + + m.ATxn.AUrlTxnKeyreg.VotePK = &votePartKey + m.ATxn.AUrlTxnKeyreg.SelectionPK = &selPartKey + m.ATxn.AUrlTxnKeyreg.StateProofPK = &spKey + m.ATxn.AUrlTxnKeyreg.VoteFirst = &firstValid + m.ATxn.AUrlTxnKeyreg.VoteLast = &lastValid + m.ATxn.AUrlTxnKeyreg.VoteKeyDilution = &vkDilution + } else { + m.Title = string(OfflineTitle) + m.BorderColor = "9" + m.ATxn.AUrlTxnKeyreg.VotePK = nil + m.ATxn.AUrlTxnKeyreg.SelectionPK = nil + m.ATxn.AUrlTxnKeyreg.StateProofPK = nil + m.ATxn.AUrlTxnKeyreg.VoteFirst = nil + m.ATxn.AUrlTxnKeyreg.VoteLast = nil + m.ATxn.AUrlTxnKeyreg.VoteKeyDilution = nil + } +} diff --git a/ui/modals/transaction/model.go b/ui/modals/transaction/model.go new file mode 100644 index 00000000..c394e133 --- /dev/null +++ b/ui/modals/transaction/model.go @@ -0,0 +1,51 @@ +package transaction + +import ( + "fmt" + "github.com/algorandfoundation/algourl/encoder" + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/style" +) + +type ViewModel struct { + // Width is the last known horizontal lines + Width int + // Height is the last known vertical lines + Height int + + Title string + + // Active Participation Key + Participation *api.ParticipationKey + Active bool + + // Pointer to the State + State *internal.StateModel + IsOnline bool + + // Components + BorderColor string + Controls string + navigation string + + // QR Code + ATxn *encoder.AUrlTxn +} + +func (m ViewModel) FormatedAddress() string { + return fmt.Sprintf("%s...%s", m.Participation.Address[0:4], m.Participation.Address[len(m.Participation.Address)-4:]) +} + +// New creates and instance of the ViewModel with a default controls.Model +func New(state *internal.StateModel) *ViewModel { + return &ViewModel{ + State: state, + Title: "Offline Transaction", + IsOnline: false, + BorderColor: "9", + navigation: "| accounts | keys | " + style.Green.Render("txn") + " |", + Controls: "( " + style.Red.Render("esc") + " )", + ATxn: nil, + } +} diff --git a/ui/pages/transaction/style.go b/ui/modals/transaction/style.go similarity index 69% rename from ui/pages/transaction/style.go rename to ui/modals/transaction/style.go index 4e4151d1..08f91ff6 100644 --- a/ui/pages/transaction/style.go +++ b/ui/modals/transaction/style.go @@ -5,5 +5,3 @@ import "github.com/charmbracelet/lipgloss" var qrStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("15")). Background(lipgloss.Color("0")) - -var urlStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#2596be")) diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Loading.golden b/ui/modals/transaction/testdata/Test_Snapshot/Loading.golden new file mode 100644 index 00000000..37d70925 --- /dev/null +++ b/ui/modals/transaction/testdata/Test_Snapshot/Loading.golden @@ -0,0 +1 @@ +Loading... \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/NoKey.golden b/ui/modals/transaction/testdata/Test_Snapshot/NoKey.golden new file mode 100644 index 00000000..eacdfa5a --- /dev/null +++ b/ui/modals/transaction/testdata/Test_Snapshot/NoKey.golden @@ -0,0 +1 @@ +No key selected \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden b/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden new file mode 100644 index 00000000..46dac261 --- /dev/null +++ b/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden @@ -0,0 +1,5 @@ + Sign this transaction to register your account keys: + +QR Code too large to display. Please adjust terminal dimensions or font size. + -or- + Click here to sign via Lora. \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden b/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden new file mode 100644 index 00000000..b13027ae --- /dev/null +++ b/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden @@ -0,0 +1,28 @@ + Sign this transaction to deregister your account keys: + + Scan the QR code with Pera or Defly + (make sure you use the testnet-v1.0 network) + + █████████████████████████████████ + ██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ + ██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ + ██ █▄▄▄█ █▄ ▄▀▄▄▀▀ █ ▄█ █▄▄▄█ ██ + ██▄▄▄▄▄▄▄█▄▀▄▀ █▄█ ▀▄▀▄█▄▄▄▄▄▄▄██ + ██ ▀ █ ▀▄██▀█ ▀▀██ ▀██▀██▄█▀ ▀██ + ██▄ ▀▄██▄ ▀▄ ▄▀▀ ▀▀▀█▀█▀ ▄▄█▄ ██ + ██ ▄▀▄ █▄ ██▀▄█ █ ▄▀██ ▄█▄ █ ██ + ██▀▄▀▀█▀▄▀▀▀ ██▄██▄▀ ▀▀█ ▄▀██ ▄██ + ███▀██▀▄▄ ▄▀▀▀▄▀██▀ ▀▄██ ▀█▀█ ▀██ + ██▄▀ ▀▄▄▄ ▄ █▀▀ ▀█▀▄▀▀▄▀▄▀▄▄▄▄██ + ██▄█▄▄██▄█▀█▀███ ▄▀ █▀ ▄▄▄ █▀▀ ██ + ██ ▄▄▄▄▄ ██▄█▄█▄█▀▀▀▀▄ █▄█ ██▄ ██ + ██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ + ██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ + ██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ + + -or- + + Click here to sign via Lora. + + Note: this will take effect after 320 rounds (15 mins.) +Please keep your node online during this cooldown period. \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Online.golden b/ui/modals/transaction/testdata/Test_Snapshot/Online.golden new file mode 100644 index 00000000..3ea2e625 --- /dev/null +++ b/ui/modals/transaction/testdata/Test_Snapshot/Online.golden @@ -0,0 +1,35 @@ + Sign this transaction to register your account keys: + + Scan the QR code with Pera or Defly + (make sure you use the testnet-v1.0 network) + +█████████████████████████████████████████████████████ +██ ▄▄▄▄▄ █▀ █▄▀█▀ ▀▄ ▄ █▄ ▄█▄ ▄█▄ ▄▄▀▀ █ ▄▄▄▄▄ ██ +██ █ █ ███▀ ▀▄█▄▀█▀▄▄ ▄▄▄█▄▀▄██ █▀█ ▄█ █ █ ██ +██ █▄▄▄█ █▄ ▀ ██▀█▄ ██▄█ ▄▄▄ ███ █▄ ▄ ▀███ █▄▄▄█ ██ +██▄▄▄▄▄▄▄█▄█ ▀▄█ █▄█ ▀▄▀ █▄█ █▄█▄▀ █▄▀ █▄█ █▄▄▄▄▄▄▄██ +██▄▀▄█▄█▄██▄▀ ▀█▀ ▀██▄█▄▄▄▄ ▄ ▄▄▄ ███▄██ █▀█▀▄██ ▀██ +██▀▀ ███▄▀▀ ▀▄██ ▄▀ ██▄▄▀▄▄█ ▄██▀ █▀ █▄▀▄ █▄ ▄▄ ▄██ +██▄ █▄█▄▄█▀▄▀▄▄█ ▀▀ ██ ▄▀ █▄ ▄▄▄▀▄▀▄▄ █▄▄█▄▄█▄██ ▀██ +██▄ ▀ █▀▄▀▀██▄█▄▄ ▀▄█▀█▀ ▄▄▄▀▄█▀▀▄▄▀ █▀█▄▀██▄██▄▄▄██ +██▀▄▄▀▀ ▄██▀▄▄ ▀█▀█▀▄▀█▀▄ ▄▄▄ ▀█▀ ▀█▀ ▀▀▄▄▄██▄▄█▀█ ██ +██▀█ ▀█▀▄▀█ █ ▄ ▄█▄▀▀ ▄██▄ ▄ ▄█▀▄▄▀█▄▄▀▄▀█▄▀▄ ▀█▄▄ ██ +██▀▄ █ ▄▀▄▀▀▀▀▄▀▀ ▀▄████▄▄█▀▄▄▀▄▄ ▄▄█▄▄█▀█▀▄ ▀▀ ▀██ +██ ▀▄▀ ▄▄▄ ▀▀██ ▀ ▀ ██▄ ▄▄▄ ▄█▀▀ ▀▀█▄██▄█ ▄▄▄ ▄█ ▄██ +██ █ █▄█ █▄█ ▄██ ▀▀▄██ █▄█ ▄▄ ▄ ▄█▄ ▄▄█ █▄█ ██ ██ +███ ▄ ▄ ▄▄▄ ▀▀▀ ▄▀▀█▄██▀ ▄▄ ▄▄ ▀▄ █▄ ▄█▄▄ ▄██▄▄██ +██▀█▀ █ ▄▀▀ ▀▀█ █ ▀██ ▄▀██ ▄█▄▄▄██▄▀▄█ ▄▄▀▄▄█▄█▄█ ██ +██▄ ██▄▄▄█▄▀█ ▀█▄▀▀ ▄▀██▀ ██▀██▄▀██▄▀██ █ █▄▄ ██ +█████▄ ▄ ▄█ ▀▀ ▀ ▄▄▄▄▀ ▄▄ █▄ ▄█▄ ▄█▄▀▄█ ▀ ███ ▀██ +██▄ ▄ ▄ ▄▄ ▄██▄▄▄ ▄█▀ █▄ ▄█ ▄▄▀ ▄▄█ ▀▄▀▀ █▄▄█ ██ +██▀██▄▀▀▄█▀█▄▄▀▀ ▀█▄▀▄█▀▀▄█ █▄██ ▄▀▄▄▄█ ▄▄ ███▄ ██ +███ ▀▀█▄▄█▄█ █ █▀█▀ ▄██ █▄▄▄▄▀█▄▀ █▄▄█▄██▄▄ ▄▄▄ ██ +██▄▄▄███▄█▀ ▀█▀▄ ▀▄▀█▀ ▄▄▄ ▄▄ █▄ █▄▀ ▄▄▄ █ ▀██ +██ ▄▄▄▄▄ ██▄ ▄ ▄▄ ▀▀▄█ █▄█ ▄ ▄▄█ ▄█ ▄▀ █ █▄█ ▄█▄ ██ +██ █ █ █▄▀▀ ██ █▀█ ▀ ▄ ▄▄▄█▄██ ▄██ ▄▄▄ ▄▀█ ██ +██ █▄▄▄█ █▄▀▀▄ ▄▀▀█ █▀ ▄ ▄ ▄▄▀ █▄▀▀▄█▀█ ██▄ █▄ ▄██ +██▄▄▄▄▄▄▄█▄▄█▄██▄▄███▄▄▄▄██▄▄▄███▄███▄████▄▄█▄█▄█▄▄██ + + -or- + + Click here to sign via Lora. \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Unsupported.golden b/ui/modals/transaction/testdata/Test_Snapshot/Unsupported.golden new file mode 100644 index 00000000..dad0a92b --- /dev/null +++ b/ui/modals/transaction/testdata/Test_Snapshot/Unsupported.golden @@ -0,0 +1,5 @@ + +Sign this transaction to register your account keys: + + Click here to sign via Lora. + \ No newline at end of file diff --git a/ui/modals/transaction/transaction_test.go b/ui/modals/transaction/transaction_test.go new file mode 100644 index 00000000..95c82621 --- /dev/null +++ b/ui/modals/transaction/transaction_test.go @@ -0,0 +1,110 @@ +package transaction + +import ( + "bytes" + "github.com/algorandfoundation/hack-tui/internal/test/mock" + "github.com/algorandfoundation/hack-tui/ui/internal/test" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +func Test_New(t *testing.T) { + model := New(test.GetState(nil)) + model.Participation = &mock.Keys[0] + model.Participation.Address = "ALGO123456789" + addr := model.FormatedAddress() + if addr != "ALGO...6789" { + t.Errorf("Expected ALGO123456789, got %s", addr) + } + model.Participation.Address = "ABC" +} +func Test_Snapshot(t *testing.T) { + t.Run("NotVisible", func(t *testing.T) { + model := New(test.GetState(nil)) + model.Participation = &mock.Keys[0] + model.UpdateState() + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Offline", func(t *testing.T) { + model := New(test.GetState(nil)) + model.Participation = &mock.Keys[0] + model.State.Status.Network = "testnet-v1.0" + model, _ = model.HandleMessage(tea.WindowSizeMsg{ + Height: 40, + Width: 80, + }) + model.Active = true + model.UpdateState() + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Online", func(t *testing.T) { + model := New(test.GetState(nil)) + model.Participation = &mock.Keys[0] + model.State.Status.Network = "testnet-v1.0" + model, _ = model.HandleMessage(tea.WindowSizeMsg{ + Height: 40, + Width: 80, + }) + model.UpdateState() + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Unsupported", func(t *testing.T) { + model := New(test.GetState(nil)) + model.Participation = &mock.Keys[0] + model, _ = model.HandleMessage(tea.WindowSizeMsg{ + Height: 40, + Width: 80, + }) + model.UpdateState() + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Loading", func(t *testing.T) { + model := New(test.GetState(nil)) + model.Participation = &mock.Keys[0] + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("NoKey", func(t *testing.T) { + model := New(test.GetState(nil)) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + // Create the Model + m := New(test.GetState(nil)) + m.Participation = &mock.Keys[0] + m.State.Status.Network = "testnet-v1.0" + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("████████")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/modals/transaction/view.go b/ui/modals/transaction/view.go new file mode 100644 index 00000000..ee00c40f --- /dev/null +++ b/ui/modals/transaction/view.go @@ -0,0 +1,88 @@ +package transaction + +import ( + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/style" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +func (m ViewModel) View() string { + if m.Participation == nil { + return "No key selected" + } + if m.ATxn == nil { + return "Loading..." + } + // TODO: Refactor ATxn to Interface + txn, err := m.ATxn.ProduceQRCode() + if err != nil { + return "Something went wrong" + } + + var verb string + isOffline := m.ATxn.AUrlTxnKeyreg.VotePK == nil + if isOffline { + verb = "deregister" + } else { + verb = "register" + } + intro := "Sign this transaction to " + verb + " your account keys:" + + link, _ := internal.ToLoraDeepLink(m.State.Status.Network, m.Active, *m.Participation) + loraText := lipgloss.JoinHorizontal( + lipgloss.Bottom, + style.WithHyperlink("Click here", link), + " to sign via Lora.", + ) + if isOffline { + loraText = lipgloss.JoinVertical( + lipgloss.Center, + loraText, + "", + "Note: this will take effect after 320 rounds (15 mins.)", + "Please keep your node online during this cooldown period.", + ) + } + + var render string + if m.State.Status.Network == "testnet-v1.0" || m.State.Status.Network == "mainnet-v1.0" { + render = lipgloss.JoinVertical( + lipgloss.Center, + intro, + "", + "Scan the QR code with Pera or Defly", + style.Yellow.Render("(make sure you use the "+m.State.Status.Network+" network)"), + "", + qrStyle.Render(txn), + "-or-", + "", + loraText, + ) + } else { + render = lipgloss.JoinVertical( + lipgloss.Center, + "", + intro, + "", + loraText, + "", + ) + } + + width := lipgloss.Width(render) + height := lipgloss.Height(render) + + if width > m.Width || height > m.Height { + return lipgloss.JoinVertical( + lipgloss.Center, + intro, + "", + style.Red.Render(ansi.Wordwrap("QR Code too large to display. Please adjust terminal dimensions or font size.", m.Width, " ")), + "-or-", + loraText, + ) + } + + return render +} diff --git a/ui/pages/accounts/accounts_test.go b/ui/pages/accounts/accounts_test.go index fe18211e..23849e41 100644 --- a/ui/pages/accounts/accounts_test.go +++ b/ui/pages/accounts/accounts_test.go @@ -2,8 +2,8 @@ package accounts import ( "bytes" - "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/internal/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -12,16 +12,47 @@ import ( "time" ) +func Test_New(t *testing.T) { + m := New(&internal.StateModel{}) + acc := m.SelectedAccount() + + if acc != nil { + t.Errorf("Expected no accounts to exist, got %s", acc.Address) + } + m, cmd := m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + if cmd != nil { + t.Errorf("Expected no comand") + } + + m = New(test.GetState(nil)) + m, _ = m.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + + if m.Data.Admin { + t.Errorf("Admin flag should be false, got true") + } + + // Fetch state after message handling + acc = m.SelectedAccount() + if acc == nil { + t.Errorf("expected true, got false") + } + + // Update syncing state + m.Data.Status.State = internal.SyncingState + m.makeRows() + if m.Data.Status.State != internal.SyncingState { + + } +} + func Test_Snapshot(t *testing.T) { t.Run("Visible", func(t *testing.T) { - model := New(&internal.StateModel{ - Status: internal.StatusModel{}, - Metrics: internal.MetricsModel{}, - Accounts: nil, - ParticipationKeys: nil, - Admin: false, - Watching: false, - }) + model := New(test.GetState(nil)) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) @@ -29,52 +60,8 @@ func Test_Snapshot(t *testing.T) { } func Test_Messages(t *testing.T) { - var testKeys = []api.ParticipationKey{ - { - Address: "ABC", - EffectiveFirstValid: nil, - EffectiveLastValid: nil, - Id: "", - Key: api.AccountParticipation{ - SelectionParticipationKey: nil, - StateProofKey: nil, - VoteFirstValid: 0, - VoteKeyDilution: 0, - VoteLastValid: 0, - VoteParticipationKey: nil, - }, - LastBlockProposal: nil, - LastStateProof: nil, - LastVote: nil, - }, - } - sm := &internal.StateModel{ - Status: internal.StatusModel{}, - Metrics: internal.MetricsModel{}, - Accounts: nil, - ParticipationKeys: &testKeys, - Admin: false, - Watching: false, - } - values := make(map[string]internal.Account) - for _, key := range *sm.ParticipationKeys { - val, ok := values[key.Address] - if !ok { - values[key.Address] = internal.Account{ - Address: key.Address, - Status: "Offline", - Balance: 0, - Expires: time.Unix(0, 0), - Keys: 1, - } - } else { - val.Keys++ - values[key.Address] = val - } - } - sm.Accounts = values // Create the Model - m := New(sm) + m := New(test.GetState(nil)) tm := teatest.NewTestModel( t, m, @@ -85,13 +72,13 @@ func Test_Messages(t *testing.T) { teatest.WaitFor( t, tm.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("(k)eys")) + return bytes.Contains(bts, []byte("accounts | keys")) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3), ) - tm.Send(*sm) + tm.Send(*test.GetState(nil)) tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, @@ -103,5 +90,7 @@ func Test_Messages(t *testing.T) { Runes: []rune("ctrl+c"), }) + tm.Send(tea.QuitMsg{}) + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) } diff --git a/ui/pages/accounts/controller.go b/ui/pages/accounts/controller.go index 4e4cf942..8bbc39cb 100644 --- a/ui/pages/accounts/controller.go +++ b/ui/pages/accounts/controller.go @@ -2,6 +2,7 @@ package accounts import ( "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -16,9 +17,6 @@ func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd - switch msg := msg.(type) { case internal.StateModel: m.Data = &msg @@ -27,12 +25,13 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { switch msg.String() { case "enter": selAcc := m.SelectedAccount() - if selAcc != (internal.Account{}) { - return m, EmitAccountSelected(selAcc) + if selAcc != nil { + var cmds []tea.Cmd + cmds = append(cmds, app.EmitAccountSelected(*selAcc)) + cmds = append(cmds, app.EmitShowPage(app.KeysPage)) + return m, tea.Batch(cmds...) } return m, nil - case "ctrl+c": - return m, tea.Quit } case tea.WindowSizeMsg: borderRender := style.Border.Render("") @@ -47,7 +46,8 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { m.table.SetColumns(m.makeColumns(m.Width)) } - m.table, cmd = m.table.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) + // Handle Table Update + m.table, _ = m.table.Update(msg) + + return m, nil } diff --git a/ui/pages/accounts/model.go b/ui/pages/accounts/model.go index 91b550cb..ce594d37 100644 --- a/ui/pages/accounts/model.go +++ b/ui/pages/accounts/model.go @@ -12,22 +12,27 @@ import ( ) type ViewModel struct { - Width int - Height int - Data *internal.StateModel + Data *internal.StateModel - table table.Model - navigation string - controls string + Title string + Navigation string + Controls string + BorderColor string + Width int + Height int + + table table.Model } func New(state *internal.StateModel) ViewModel { m := ViewModel{ - Width: 0, - Height: 0, - Data: state, - controls: "( (g)enerate )", - navigation: "| " + style.Green.Render("(a)ccounts") + " | (k)eys | (t)xn |", + Title: "Accounts", + Width: 0, + Height: 0, + BorderColor: "6", + Data: state, + Controls: "( (g)enerate )", + Navigation: "| " + style.Green.Render("accounts") + " | keys |", } m.table = table.New( @@ -43,22 +48,23 @@ func New(state *internal.StateModel) ViewModel { Bold(false) s.Selected = s.Selected. Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). + Background(lipgloss.Color(m.BorderColor)). Bold(false) m.table.SetStyles(s) return m } -func (m ViewModel) SelectedAccount() internal.Account { - var account internal.Account +func (m ViewModel) SelectedAccount() *internal.Account { + var account *internal.Account var selectedRow = m.table.SelectedRow() if selectedRow != nil { - account = m.Data.Accounts[selectedRow[0]] + selectedAccount := m.Data.Accounts[selectedRow[0]] + account = &selectedAccount } return account } func (m ViewModel) makeColumns(width int) []table.Column { - avgWidth := (width - lipgloss.Width(style.Border.Render("")) - 14) / 5 + avgWidth := (width - lipgloss.Width(style.Border.Render("")) - 9) / 5 return []table.Column{ {Title: "Account", Width: avgWidth}, {Title: "Keys", Width: avgWidth}, @@ -72,12 +78,12 @@ func (m ViewModel) makeRows() *[]table.Row { rows := make([]table.Row, 0) for key := range m.Data.Accounts { - expires := m.Data.Accounts[key].Expires.String() - if m.Data.Status.State == "SYNCING" { + expires := m.Data.Accounts[key].Expires.Format(time.RFC822) + if m.Data.Status.State != internal.StableState { expires = "SYNCING" } if !m.Data.Accounts[key].Expires.After(time.Now().Add(-(time.Hour * 24 * 365 * 50))) { - expires = "NA" + expires = "N/A" } rows = append(rows, table.Row{ m.Data.Accounts[key].Address, diff --git a/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden b/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden index 03e6474b..3e338d4d 100644 --- a/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden +++ b/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden @@ -1,6 +1,7 @@ ╭──Accounts────────────────────────────────────────────────────────────────────╮ -│ Account Keys Status Expires Balance │ -│────────────────────────────────────────────────────────────────────── │ +│ Account Keys Status Expires Balance │ +│─────────────────────────────────────────────────────────────────────────── │ +│ ABC 2 Offline N/A 0 │ │ │ │ │ │ │ @@ -36,5 +37,4 @@ │ │ │ │ │ │ -│ │ -╰────( (g)enerate )─────────────────────────| (a)ccounts | (k)eys | (t)xn |────╯ \ No newline at end of file +╰────( Insufficient Data )──────────────────────────────| accounts | keys |────╯ \ No newline at end of file diff --git a/ui/pages/accounts/view.go b/ui/pages/accounts/view.go index 56913350..52ba4ba4 100644 --- a/ui/pages/accounts/view.go +++ b/ui/pages/accounts/view.go @@ -5,13 +5,17 @@ import ( ) func (m ViewModel) View() string { - table := style.ApplyBorder(m.Width, m.Height, "8").Render(m.table.View()) + table := style.ApplyBorder(m.Width, m.Height, m.BorderColor).Render(m.table.View()) + ctls := m.Controls + if m.Data.Status.LastRound < uint64(m.Data.Metrics.Window) { + ctls = "( Insufficient Data )" + } return style.WithNavigation( - m.navigation, + m.Navigation, style.WithControls( - m.controls, + ctls, style.WithTitle( - "Accounts", + m.Title, table, ), ), diff --git a/ui/pages/generate/cmds.go b/ui/pages/generate/cmds.go deleted file mode 100644 index 66900347..00000000 --- a/ui/pages/generate/cmds.go +++ /dev/null @@ -1,14 +0,0 @@ -package generate - -import ( - tea "github.com/charmbracelet/bubbletea" -) - -type Cancel struct{} - -// EmitCancelGenerate cancel generation -func EmitCancel(cg Cancel) tea.Cmd { - return func() tea.Msg { - return cg - } -} diff --git a/ui/pages/generate/controller.go b/ui/pages/generate/controller.go deleted file mode 100644 index c8adbaa6..00000000 --- a/ui/pages/generate/controller.go +++ /dev/null @@ -1,103 +0,0 @@ -package generate - -import ( - "context" - "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/internal" - "github.com/algorandfoundation/hack-tui/ui/pages/accounts" - "github.com/algorandfoundation/hack-tui/ui/style" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/log" - "strconv" -) - -func (m ViewModel) Init() tea.Cmd { - return textinput.Blink -} - -func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m.HandleMessage(msg) -} - -func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { - - //var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.Width = msg.Width - lipgloss.Width(style.Border.Render("")) - m.Height = msg.Height - lipgloss.Height(style.Border.Render("")) - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "esc": - return m, EmitCancel(Cancel{}) - case "tab", "shift+tab", "up", "down": - s := msg.String() - - // Cycle indexes - if s == "up" || s == "shift+tab" { - m.focusIndex-- - } else { - m.focusIndex++ - } - - if m.focusIndex > len(m.Inputs) { - m.focusIndex = 0 - } else if m.focusIndex < 0 { - m.focusIndex = len(m.Inputs) - } - - cmds := make([]tea.Cmd, len(m.Inputs)) - for i := 0; i <= len(m.Inputs)-1; i++ { - if i == m.focusIndex { - // Set focused state - cmds[i] = m.Inputs[i].Focus() - m.Inputs[i].PromptStyle = focusedStyle - m.Inputs[i].TextStyle = focusedStyle - continue - } - // Remove focused state - m.Inputs[i].Blur() - m.Inputs[i].PromptStyle = noStyle - m.Inputs[i].TextStyle = noStyle - } - - return m, tea.Batch(cmds...) - case "enter": - first, err := strconv.Atoi(m.Inputs[1].Value()) - last, err := strconv.Atoi(m.Inputs[2].Value()) - params := api.GenerateParticipationKeysParams{ - Dilution: nil, - First: first, - Last: last, - } - val := m.Inputs[0].Value() - key, err := internal.GenerateKeyPair(context.Background(), m.client, val, ¶ms) - if err != nil { - log.Fatal(err) - } - return m, accounts.EmitAccountSelected(internal.Account{ - Address: key.Address, - }) - - } - - } - // Handle character input and blinking - cmd := m.updateInputs(msg) - return m, cmd -} - -func (m ViewModel) updateInputs(msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, len(m.Inputs)) - - // Only text inputs with Focus() set will respond, so it's safe to simply - // update all of them here without any further logic. - for i := range m.Inputs { - m.Inputs[i], cmds[i] = m.Inputs[i].Update(msg) - } - - return tea.Batch(cmds...) -} diff --git a/ui/pages/generate/model.go b/ui/pages/generate/model.go deleted file mode 100644 index 0d7f8062..00000000 --- a/ui/pages/generate/model.go +++ /dev/null @@ -1,53 +0,0 @@ -package generate - -import ( - "github.com/algorandfoundation/hack-tui/api" - "github.com/charmbracelet/bubbles/cursor" - "github.com/charmbracelet/bubbles/textinput" -) - -type ViewModel struct { - Width int - Height int - Address string - Inputs []textinput.Model - - controls string - - client *api.ClientWithResponses - focusIndex int - cursorMode cursor.Mode -} - -func New(address string, client *api.ClientWithResponses) ViewModel { - m := ViewModel{ - Address: address, - Inputs: make([]textinput.Model, 3), - controls: "( ctrl+c to cancel )", - client: client, - } - - var t textinput.Model - for i := range m.Inputs { - t = textinput.New() - t.Cursor.Style = cursorStyle - t.CharLimit = 68 - - switch i { - case 0: - t.Placeholder = "Wallet Address or NFD" - t.Focus() - t.PromptStyle = focusedStyle - t.TextStyle = focusedStyle - t.CharLimit = 68 - case 1: - t.Placeholder = "First Valid Round" - case 2: - t.Placeholder = "Last" - } - - m.Inputs[i] = t - } - - return m -} diff --git a/ui/pages/generate/view.go b/ui/pages/generate/view.go deleted file mode 100644 index 667410ea..00000000 --- a/ui/pages/generate/view.go +++ /dev/null @@ -1,33 +0,0 @@ -package generate - -import ( - "fmt" - "github.com/algorandfoundation/hack-tui/ui/style" - "strings" -) - -func (m ViewModel) View() string { - var b strings.Builder - - for i := range m.Inputs { - b.WriteString(m.Inputs[i].View()) - if i < len(m.Inputs)-1 { - b.WriteRune('\n') - } - } - - button := &blurredButton - if m.focusIndex == len(m.Inputs) { - button = &focusedButton - } - fmt.Fprintf(&b, "\n\n%s\n\n", *button) - - render := style.ApplyBorder(m.Width, m.Height, "8").Render(b.String()) - return style.WithControls( - m.controls, - style.WithTitle( - "Generate", - render, - ), - ) -} diff --git a/ui/pages/keys/cmds.go b/ui/pages/keys/cmds.go deleted file mode 100644 index a8f89ec4..00000000 --- a/ui/pages/keys/cmds.go +++ /dev/null @@ -1,23 +0,0 @@ -package keys - -import ( - "github.com/algorandfoundation/hack-tui/api" - tea "github.com/charmbracelet/bubbletea" -) - -type DeleteKey *api.ParticipationKey - -type DeleteFinished string - -func EmitDeleteKey(key *api.ParticipationKey) tea.Cmd { - return func() tea.Msg { - return DeleteKey(key) - } -} - -// EmitKeySelected waits for and retrieves a new set of table rows from a given channel. -func EmitKeySelected(key *api.ParticipationKey) tea.Cmd { - return func() tea.Msg { - return key - } -} diff --git a/ui/pages/keys/controller.go b/ui/pages/keys/controller.go index 22e242f6..dd86e424 100644 --- a/ui/pages/keys/controller.go +++ b/ui/pages/keys/controller.go @@ -2,6 +2,7 @@ package keys import ( "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -17,51 +18,41 @@ func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { switch msg := msg.(type) { + // When the State changes case internal.StateModel: m.Data = msg.ParticipationKeys - m.table.SetRows(m.makeRows(m.Data)) - case internal.Account: + m.table.SetRows(*m.makeRows(m.Data)) + m.Participation = msg.Accounts[m.Address].Participation + // When the Account is Selected + case app.AccountSelected: m.Address = msg.Address - m.table.SetRows(m.makeRows(m.Data)) - case DeleteFinished: - if m.SelectedKeyToDelete == nil { - panic("SelectedKeyToDelete is unexpectedly nil") - } - internal.RemovePartKeyByID(m.Data, m.SelectedKeyToDelete.Id) - m.SelectedKeyToDelete = nil - m.table.SetRows(m.makeRows(m.Data)) - + m.Participation = msg.Participation + m.table.SetRows(*m.makeRows(m.Data)) + // When a confirmation Modal is finished deleting + case app.DeleteFinished: + internal.RemovePartKeyByID(m.Data, msg.Id) + m.table.SetRows(*m.makeRows(m.Data)) + // When the user interacts with the render case tea.KeyMsg: switch msg.String() { + case "esc": + return m, app.EmitShowPage(app.AccountsPage) + // Show the Info Modal case "enter": - selKey := m.SelectedKey() + selKey, active := m.SelectedKey() if selKey != nil { - return m, EmitKeySelected(selKey) + // Show the Info Modal with the selected Key + return m, app.EmitModalEvent(app.ModalEvent{ + Key: selKey, + Active: active, + Address: selKey.Address, + Type: app.InfoModal, + }) } return m, nil - case "g": - // TODO: navigation - case "d": - if m.SelectedKeyToDelete == nil { - m.SelectedKeyToDelete = m.SelectedKey() - } else { - m.SelectedKeyToDelete = nil - } - return m, nil - case "y": // "Yes do delete" option in the delete confirmation modal - if m.SelectedKeyToDelete != nil { - return m, EmitDeleteKey(m.SelectedKeyToDelete) - } - return m, nil - case "n": // "do NOT delete" option in the delete confirmation modal - if m.SelectedKeyToDelete != nil { - m.SelectedKeyToDelete = nil - } - return m, nil - case "ctrl+c": - return m, tea.Quit } + // Handle Resize Events case tea.WindowSizeMsg: borderRender := style.Border.Render("") borderWidth := lipgloss.Width(borderRender) @@ -74,9 +65,8 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { m.table.SetColumns(m.makeColumns(m.Width)) } - var cmds []tea.Cmd - var cmd tea.Cmd - m.table, cmd = m.table.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) + // Handle Table Update + m.table, _ = m.table.Update(msg) + + return m, nil } diff --git a/ui/pages/keys/keys_test.go b/ui/pages/keys/keys_test.go new file mode 100644 index 00000000..eb961bec --- /dev/null +++ b/ui/pages/keys/keys_test.go @@ -0,0 +1,108 @@ +package keys + +import ( + "bytes" + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal/test/mock" + "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/internal/test" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +func Test_New(t *testing.T) { + m := New("ABC", nil) + if m.Address != "ABC" { + t.Errorf("Expected Address to be ABC, got %s", m.Address) + } + d, active := m.SelectedKey() + if active { + t.Errorf("Expected to not find a selected key") + } + m, cmd := m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + if cmd != nil { + t.Errorf("Expected no commands") + } + m.Data = &mock.Keys + m, _ = m.HandleMessage(app.AccountSelected{Address: "ABC", Participation: &api.AccountParticipation{ + SelectionParticipationKey: nil, + StateProofKey: nil, + VoteFirstValid: 0, + VoteKeyDilution: 0, + VoteLastValid: 0, + VoteParticipationKey: mock.VoteKey, + }}) + d, active = m.SelectedKey() + if !active { + t.Errorf("Expected to find a selected key") + } + if d.Address != "ABC" { + t.Errorf("Expected Address to be ABC, got %s", d.Address) + } + + if m.Address != "ABC" { + t.Errorf("Expected Address to be ABC, got %s", m.Address) + } +} + +func Test_Snapshot(t *testing.T) { + t.Run("Visible", func(t *testing.T) { + model := New("ABC", &mock.Keys) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + + // Create the Model + m := New("ABC", &mock.Keys) + //m, _ = m.Address = "ABC" + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("ABC")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + // Emit a state message + tm.Send(*test.GetState(nil)) + + // Send delete finished + tm.Send(app.DeleteFinished{ + Id: "1234", + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("ctrl+c"), + }) + + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/pages/keys/model.go b/ui/pages/keys/model.go index e3879de5..10b8167c 100644 --- a/ui/pages/keys/model.go +++ b/ui/pages/keys/model.go @@ -1,6 +1,7 @@ package keys import ( + "github.com/algorandfoundation/hack-tui/internal" "sort" "github.com/algorandfoundation/hack-tui/ui/style" @@ -11,39 +12,61 @@ import ( "github.com/charmbracelet/lipgloss" ) +// ViewModel represents the view state and logic for managing participation keys. type ViewModel struct { + // Address for or the filter condition in ViewModel. Address string - Data *[]api.ParticipationKey - Width int - Height int + // Participation represents the consensus protocol parameters used by this account. + Participation *api.AccountParticipation - SelectedKeyToDelete *api.ParticipationKey + // Data holds a pointer to a slice of ParticipationKey, representing the set of participation keys managed by the ViewModel. + Data *[]api.ParticipationKey - table table.Model - controls string - navigation string + // Title represents the title displayed at the top of the ViewModel's UI. + Title string + // Controls describe the set of actions or commands available for the user to interact with the ViewModel. + Controls string + // Navigation represents the navigation bar or breadcrumbs in the ViewModel's UI, indicating the current page or section. + Navigation string + // BorderColor represents the color of the border in the ViewModel's UI. + BorderColor string + // Width represents the width of the ViewModel's UI in terms of display units. + Width int + // Height represents the height of the ViewModel's UI in terms of display units. + Height int + + // table manages the tabular representation of participation keys in the ViewModel. + table table.Model } +// New initializes and returns a new ViewModel for managing participation keys. func New(address string, keys *[]api.ParticipationKey) ViewModel { m := ViewModel{ + // State Address: address, Data: keys, - Width: 80, - Height: 24, - controls: "( (g)enerate | (d)elete )", - navigation: "| (a)ccounts | " + style.Green.Render("(k)eys") + " | (t)xn |", + // Sizing + Width: 0, + Height: 0, - table: table.New(), + // Page Wrapper + Title: "Keys", + Controls: "( (g)enerate )", + Navigation: "| accounts | " + style.Green.Render("keys") + " |", + BorderColor: "4", } + + // Create Table m.table = table.New( table.WithColumns(m.makeColumns(80)), - table.WithRows(m.makeRows(keys)), + table.WithRows(*m.makeRows(keys)), table.WithFocused(true), table.WithHeight(m.Height), table.WithWidth(m.Width), ) + // Style Table s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). @@ -52,76 +75,77 @@ func New(address string, keys *[]api.ParticipationKey) ViewModel { Bold(false) s.Selected = s.Selected. Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). + Background(lipgloss.Color(m.BorderColor)). Bold(false) m.table.SetStyles(s) return m } +func (m *ViewModel) Rows() []table.Row { + return m.table.Rows() +} -func (m ViewModel) SelectedKey() *api.ParticipationKey { +// SelectedKey returns the currently selected participation key from the ViewModel's data set, or nil if no key is selected. +func (m ViewModel) SelectedKey() (*api.ParticipationKey, bool) { if m.Data == nil { - return nil + return nil, false } var partkey *api.ParticipationKey + var active bool + selected := m.table.SelectedRow() for _, key := range *m.Data { - selected := m.table.SelectedRow() if len(selected) > 0 && key.Id == selected[0] { partkey = &key + active = selected[2] == "YES" } } - return partkey + return partkey, active } + +// makeColumns generates a set of table columns suitable for displaying participation key data, based on the given `width`. func (m ViewModel) makeColumns(width int) []table.Column { // TODO: refine responsiveness - avgWidth := (width - lipgloss.Width(style.Border.Render("")) - 14) / 7 + avgWidth := (width - lipgloss.Width(style.Border.Render("")) - 9) / 5 //avgWidth := 1 return []table.Column{ {Title: "ID", Width: avgWidth}, {Title: "Address", Width: avgWidth}, - {Title: "SelectionParticipationKey", Width: 0}, - {Title: "VoteParticipationKey", Width: 0}, - {Title: "StateProofKey", Width: 0}, - {Title: "VoteFirstValid", Width: avgWidth}, - {Title: "VoteLastValid", Width: avgWidth}, - {Title: "VoteKeyDilution", Width: avgWidth}, - {Title: "EffectiveLastValid", Width: 0}, - {Title: "EffectiveFirstValid", Width: 0}, - {Title: "LastVote", Width: avgWidth}, - {Title: "LastBlockProposal", Width: avgWidth}, - {Title: "LastStateProof", Width: 0}, + {Title: "Active", Width: avgWidth}, + {Title: "Last Vote", Width: avgWidth}, + {Title: "Last Block Proposal", Width: avgWidth}, } } -func (m ViewModel) makeRows(keys *[]api.ParticipationKey) []table.Row { +// makeRows processes a slice of ParticipationKeys and returns a sorted slice of table rows +// filtered by the ViewModel's address. +func (m ViewModel) makeRows(keys *[]api.ParticipationKey) *[]table.Row { rows := make([]table.Row, 0) - if keys == nil { - return rows + if keys == nil || m.Address == "" { + return &rows + } + + var activeId *string + if m.Participation != nil { + activeId = internal.FindParticipationIdForVoteKey(keys, m.Participation.VoteParticipationKey) } for _, key := range *keys { if key.Address == m.Address { + isActive := "N/A" + if activeId != nil && *activeId == key.Id { + isActive = "YES" + } rows = append(rows, table.Row{ key.Id, key.Address, - *utils.UrlEncodeBytesPtrOrNil(key.Key.SelectionParticipationKey[:]), - *utils.UrlEncodeBytesPtrOrNil(key.Key.VoteParticipationKey[:]), - *utils.UrlEncodeBytesPtrOrNil(*key.Key.StateProofKey), - utils.IntToStr(key.Key.VoteFirstValid), - utils.IntToStr(key.Key.VoteLastValid), - utils.IntToStr(key.Key.VoteKeyDilution), - //utils.StrOrNA(key.Key.VoteKeyDilution), - //utils.StrOrNA(key.Key.StateProofKey), - utils.StrOrNA(key.EffectiveLastValid), - utils.StrOrNA(key.EffectiveFirstValid), + isActive, utils.StrOrNA(key.LastVote), utils.StrOrNA(key.LastBlockProposal), - utils.StrOrNA(key.LastStateProof), }) } } sort.SliceStable(rows, func(i, j int) bool { return rows[i][0] < rows[j][0] }) - return rows + return &rows } diff --git a/ui/pages/keys/testdata/Test_Snapshot/Visible.golden b/ui/pages/keys/testdata/Test_Snapshot/Visible.golden new file mode 100644 index 00000000..3072c80d --- /dev/null +++ b/ui/pages/keys/testdata/Test_Snapshot/Visible.golden @@ -0,0 +1,39 @@ +╭──Keys────────────────────────────────────────────────────────────────────────╮ +│ ID Address Active Last Vote Last Block P… │ +│─────────────────────────────────────────────────────────────────────────── │ +│ 123 ABC N/A N/A N/A │ +│ 1234 ABC N/A N/A N/A │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰────( (g)enerate )─────────────────────────────────────| accounts | keys |────╯ \ No newline at end of file diff --git a/ui/pages/keys/view.go b/ui/pages/keys/view.go index 8107e710..68eb4409 100644 --- a/ui/pages/keys/view.go +++ b/ui/pages/keys/view.go @@ -1,41 +1,19 @@ package keys import ( - "fmt" - - "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/ui/style" - "github.com/charmbracelet/lipgloss" ) func (m ViewModel) View() string { - if m.SelectedKeyToDelete != nil { - modal := renderDeleteConfirmationModal(m.SelectedKeyToDelete) - overlay := lipgloss.Place(m.Width, m.Height, lipgloss.Center, lipgloss.Center, modal) - return overlay - } - table := style.ApplyBorder(m.Width, m.Height, "8").Render(m.table.View()) + table := style.ApplyBorder(m.Width, m.Height, m.BorderColor).Render(m.table.View()) return style.WithNavigation( - m.navigation, + m.Navigation, style.WithControls( - m.controls, + m.Controls, style.WithTitle( - "Keys", + m.Title, table, ), ), ) } - -func renderDeleteConfirmationModal(partKey *api.ParticipationKey) string { - modalStyle := lipgloss.NewStyle(). - Width(60). - Height(7). - Align(lipgloss.Center). - Border(lipgloss.RoundedBorder()). - Padding(1, 2) - - modalContent := fmt.Sprintf("Participation Key: %v\nAccount Address: %v\nPress either y (yes) or n (no).", partKey.Id, partKey.Address) - - return modalStyle.Render("Are you sure you want to delete this key from your node?\n" + modalContent) -} diff --git a/ui/pages/transaction/controller.go b/ui/pages/transaction/controller.go deleted file mode 100644 index 5b5eaa92..00000000 --- a/ui/pages/transaction/controller.go +++ /dev/null @@ -1,123 +0,0 @@ -package transaction - -import ( - "encoding/base64" - "fmt" - - "github.com/algorand/go-algorand-sdk/v2/types" - "github.com/algorandfoundation/algourl/encoder" - "github.com/algorandfoundation/hack-tui/api" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -func (m ViewModel) Init() tea.Cmd { - return nil -} - -func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m.HandleMessage(msg) -} - -func (m *ViewModel) UpdateTxnURLAndQRCode() error { - - accountStatus := m.State.Accounts[m.Data.Address].Status - - m.hint = "" - - var isOnline bool - switch accountStatus { - case "Online": - isOnline = true - case "Offline": - isOnline = false - case "Not Participating": // This status means the account can never participate in consensus - m.urlTxn = "" - m.asciiQR = "" - m.hint = fmt.Sprintf("%s is NotParticipating. Cannot register key.", m.Data.Address) - return nil - } - - fee := uint64(1000) - - kr := &encoder.AUrlTxn{} - - if !isOnline { - - // TX take account online - - votePartKey := base64.RawURLEncoding.EncodeToString(m.Data.Key.VoteParticipationKey) - selPartKey := base64.RawURLEncoding.EncodeToString(m.Data.Key.SelectionParticipationKey) - spKey := base64.RawURLEncoding.EncodeToString(*m.Data.Key.StateProofKey) - firstValid := uint64(m.Data.Key.VoteFirstValid) - lastValid := uint64(m.Data.Key.VoteLastValid) - vkDilution := uint64(m.Data.Key.VoteKeyDilution) - - kr = &encoder.AUrlTxn{ - AUrlTxnKeyCommon: encoder.AUrlTxnKeyCommon{ - Sender: m.Data.Address, - Type: string(types.KeyRegistrationTx), - Fee: &fee, - }, - AUrlTxnKeyreg: encoder.AUrlTxnKeyreg{ - VotePK: &votePartKey, - SelectionPK: &selPartKey, - StateProofPK: &spKey, - VoteFirst: &firstValid, - VoteLast: &lastValid, - VoteKeyDilution: &vkDilution, - }, - } - - m.hint = fmt.Sprintf("Scan this QR code to take %s Online.", m.Data.Address) - - } else { - - // TX to take account offline - kr = &encoder.AUrlTxn{ - AUrlTxnKeyCommon: encoder.AUrlTxnKeyCommon{ - Sender: m.Data.Address, - Type: string(types.KeyRegistrationTx), - Fee: &fee, - }} - - m.hint = fmt.Sprintf("Scan this QR code to take %s Offline.", m.Data.Address) - } - - qrCode, err := kr.ProduceQRCode() - - if err != nil { - return err - } - - m.urlTxn = kr.String() - m.asciiQR = qrCode - return nil -} - -// HandleMessage is called by the viewport to update its Model -func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - // When the participation key updates, set the models data - - case *api.ParticipationKey: - m.Data = *msg - - err := m.UpdateTxnURLAndQRCode() - if err != nil { - panic(err) - } - - // Handle View Size changes - case tea.WindowSizeMsg: - if msg.Width != 0 && msg.Height != 0 { - m.Width = msg.Width - m.Height = max(0, msg.Height-lipgloss.Height(m.controls.View())-3) - } - } - - // Pass messages to controls - m.controls, cmd = m.controls.HandleMessage(msg) - return m, cmd -} diff --git a/ui/pages/transaction/model.go b/ui/pages/transaction/model.go deleted file mode 100644 index aed10168..00000000 --- a/ui/pages/transaction/model.go +++ /dev/null @@ -1,39 +0,0 @@ -package transaction - -import ( - "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/internal" - "github.com/algorandfoundation/hack-tui/ui/controls" - "github.com/charmbracelet/lipgloss" -) - -var green = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) - -type ViewModel struct { - // Width is the last known horizontal lines - Width int - // Height is the last known vertical lines - Height int - - // Participation Key - Data api.ParticipationKey - - // Pointer to the State - State *internal.StateModel - - // Components - controls controls.Model - - // QR Code, URL and hint text - asciiQR string - urlTxn string - hint string -} - -// New creates and instance of the ViewModel with a default controls.Model -func New(state *internal.StateModel) ViewModel { - return ViewModel{ - State: state, - controls: controls.New(" (a)ccounts | (k)eys | " + green.Render("(t)xn") + " | shift+tab: back "), - } -} diff --git a/ui/pages/transaction/view.go b/ui/pages/transaction/view.go deleted file mode 100644 index 795841c1..00000000 --- a/ui/pages/transaction/view.go +++ /dev/null @@ -1,49 +0,0 @@ -package transaction - -import ( - "github.com/algorandfoundation/hack-tui/ui/style" - "github.com/charmbracelet/lipgloss" - "strings" -) - -func (m ViewModel) View() string { - qrRender := lipgloss.JoinVertical( - lipgloss.Center, - style.Yellow.Render(m.hint), - "", - qrStyle.Render(m.asciiQR), - urlStyle.Render(m.urlTxn), - ) - - if m.asciiQR == "" || m.urlTxn == "" { - return lipgloss.JoinVertical( - lipgloss.Center, - "No QR Code or TxnURL available", - "\n", - m.controls.View()) - } - - if lipgloss.Height(qrRender) > m.Height { - padHeight := max(0, m.Height-lipgloss.Height(m.controls.View())-1) - padHString := strings.Repeat("\n", padHeight/2) - text := style.Red.Render("QR Code too large to display... Please adjust terminal dimensions or font.") - padWidth := max(0, m.Width-lipgloss.Width(text)) - padWString := strings.Repeat(" ", padWidth/2) - return lipgloss.JoinVertical( - lipgloss.Left, - padHString, - lipgloss.JoinHorizontal(lipgloss.Left, padWString, style.Red.Render("QR Code too large to display... Please adjust terminal dimensions or font.")), - padHString, - m.controls.View()) - } - - qrRenderPadHeight := max(0, m.Height-(lipgloss.Height(qrRender)-lipgloss.Height(m.controls.View()))-1) - qrPad := strings.Repeat("\n", qrRenderPadHeight/2) - return lipgloss.JoinVertical( - lipgloss.Center, - qrPad, - qrRender, - qrPad, - m.controls.View(), - ) -} diff --git a/ui/protocol_test.go b/ui/protocol_test.go index 7c10b38a..2ae59208 100644 --- a/ui/protocol_test.go +++ b/ui/protocol_test.go @@ -107,7 +107,7 @@ func Test_ProtocolMessages(t *testing.T) { Status: internal.StatusModel{ LastRound: 1337, NeedsUpdate: true, - State: "SYNCING", + State: internal.SyncingState, }, Metrics: internal.MetricsModel{ RoundTime: 0, diff --git a/ui/status.go b/ui/status.go index 6a178eb4..e092a0f0 100644 --- a/ui/status.go +++ b/ui/status.go @@ -45,6 +45,7 @@ func (m StatusViewModel) HandleMessage(msg tea.Msg) (StatusViewModel, tea.Cmd) { return m, nil } +// getBitRate converts a given byte rate to a human-readable string format. The output may vary from B/s to GB/s. func getBitRate(bytes int) string { txString := fmt.Sprintf("%d B/s ", bytes) if bytes >= 1024 { @@ -79,14 +80,21 @@ func (m StatusViewModel) View() string { size = m.TerminalWidth / 2 } beginning := style.Blue.Render(" Latest Round: ") + strconv.Itoa(int(m.Data.Status.LastRound)) - end := style.Yellow.Render(strings.ToUpper(m.Data.Status.State)) + " " + + var end string + switch m.Data.Status.State { + case internal.StableState: + end = style.Green.Render(strings.ToUpper(string(m.Data.Status.State))) + " " + default: + end = style.Yellow.Render(strings.ToUpper(string(m.Data.Status.State))) + " " + } middle := strings.Repeat(" ", max(0, size-(lipgloss.Width(beginning)+lipgloss.Width(end)+2))) // Last Round row1 := lipgloss.JoinHorizontal(lipgloss.Left, beginning, middle, end) roundTime := fmt.Sprintf("%.2fs", float64(m.Data.Metrics.RoundTime)/float64(time.Second)) - if m.Data.Status.State == "SYNCING" { + if m.Data.Status.State != internal.StableState { roundTime = "--" } beginning = style.Blue.Render(" Round time: ") + roundTime @@ -96,7 +104,7 @@ func (m StatusViewModel) View() string { row2 := lipgloss.JoinHorizontal(lipgloss.Left, beginning, middle, end) tps := fmt.Sprintf("%.2f", m.Data.Metrics.TPS) - if m.Data.Status.State == "SYNCING" { + if m.Data.Status.State != internal.StableState { tps = "--" } beginning = style.Blue.Render(" TPS: ") + tps diff --git a/ui/status_test.go b/ui/status_test.go index abc2dac8..8f54a738 100644 --- a/ui/status_test.go +++ b/ui/status_test.go @@ -77,7 +77,7 @@ func Test_StatusMessages(t *testing.T) { Status: internal.StatusModel{ LastRound: 1337, NeedsUpdate: true, - State: "SYNCING", + State: internal.SyncingState, }, Metrics: internal.MetricsModel{ RoundTime: 0, diff --git a/ui/style/style.go b/ui/style/style.go index 1647f3f7..bce9a3e4 100644 --- a/ui/style/style.go +++ b/ui/style/style.go @@ -1,7 +1,10 @@ package style import ( + "fmt" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "regexp" "strings" ) @@ -13,8 +16,6 @@ var ( ApplyBorder = func(width int, height int, color string) lipgloss.Style { return Border. Width(width). - Padding(0). - Margin(0). Height(height). BorderForeground(lipgloss.Color(color)) } @@ -37,6 +38,9 @@ var ( Render ) +func WithHyperlink(text string, url string) string { + return fmt.Sprintf("\033]8;;%s\a%s\033]8;;\a", url, text) +} func WithTitle(title string, view string) string { r := []rune(view) if lipgloss.Width(view) >= len(title)+4 { @@ -52,24 +56,98 @@ func WithControls(nav string, view string) string { return view } controlWidth := lipgloss.Width(nav) + + lines := strings.Split(view, "\n") + padLeft := 5 + if lipgloss.Width(view) >= controlWidth+4 { - b, _, _, _, _ := Border.GetBorder() - find := b.BottomLeft + strings.Repeat(b.Bottom, controlWidth+4) - // TODO: allow other border colors, possibly just grab the last escape char - return strings.Replace(view, find, b.BottomLeft+strings.Repeat(b.Bottom, 4)+"\u001B[0m"+nav+"\u001B[90m", 1) + line := lines[len(lines)-1] + leftEdge := padLeft + lineLeft := ansi.Truncate(line, leftEdge, "") + lineRight := TruncateLeft(line, leftEdge+lipgloss.Width(nav)) + lines[len(lines)-1] = lineLeft + nav + lineRight } - return view + return strings.Join(lines, "\n") } func WithNavigation(controls string, view string) string { if controls == "" { return view } + + padRight := 5 controlWidth := lipgloss.Width(controls) + + lines := strings.Split(view, "\n") + if lipgloss.Width(view) >= controlWidth+4 { - b, _, _, _, _ := Border.GetBorder() - find := strings.Repeat(b.Bottom, controlWidth+4) + b.BottomRight - // TODO: allow other border colors, possibly just grab the last escape char - return strings.Replace(view, find, "\u001B[0m"+controls+"\u001B[90m"+strings.Repeat(b.Bottom, 4)+b.BottomRight, 1) + line := lines[len(lines)-1] + lineWidth := lipgloss.Width(line) + leftEdge := lineWidth - (controlWidth + padRight) + lineLeft := ansi.Truncate(line, leftEdge, "") + lineRight := TruncateLeft(line, leftEdge+lipgloss.Width(controls)) + lines[len(lines)-1] = lineLeft + controls + lineRight } - return view + return strings.Join(lines, "\n") +} + +// WithOverlay is the merging of two views +// Based on https://gist.github.com/Broderick-Westrope/b89b14770c09dda928c4a108f437b927 +func WithOverlay(overlay string, view string) string { + if overlay == "" { + return view + } + + bgLines := strings.Split(view, "\n") + overlayLines := strings.Split(overlay, "\n") + + row := lipgloss.Height(view) / 2 + row -= lipgloss.Height(overlay) / 2 + col := lipgloss.Width(view) / 2 + col -= lipgloss.Width(overlay) / 2 + if col < 0 || row < 0 { + return view + } + + for i, overlayLine := range overlayLines { + targetRow := i + row + + // Ensure the target row exists in the background lines + for len(bgLines) <= targetRow { + bgLines = append(bgLines, "") + } + + bgLine := bgLines[targetRow] + bgLineWidth := ansi.StringWidth(bgLine) + + if bgLineWidth < col { + bgLine += strings.Repeat(" ", col-bgLineWidth) // Add padding + } + + bgLeft := ansi.Truncate(bgLine, col, "") + bgRight := TruncateLeft(bgLine, col+ansi.StringWidth(overlayLine)) + + bgLines[targetRow] = bgLeft + overlayLine + bgRight + } + + return strings.Join(bgLines, "\n") +} + +// TruncateLeft removes characters from the beginning of a line, considering ANSI escape codes. +func TruncateLeft(line string, padding int) string { + + // This is genius, thank you https://gist.github.com/Broderick-Westrope/b89b14770c09dda928c4a108f437b927 + wrapped := strings.Split(ansi.Hardwrap(line, padding, true), "\n") + if len(wrapped) == 1 { + return "" + } + + var ansiStyle string + // Regular expression to match ANSI escape codes. + ansiStyles := regexp.MustCompile(`\x1b[[\d;]*m`).FindAllString(wrapped[0], -1) + if len(ansiStyles) > 0 { + // Pick the last style found + ansiStyle = ansiStyles[len(ansiStyles)-1] + } + + return ansiStyle + strings.Join(wrapped[1:], "") } diff --git a/ui/style/style_test.go b/ui/style/style_test.go new file mode 100644 index 00000000..8d3d7dad --- /dev/null +++ b/ui/style/style_test.go @@ -0,0 +1,20 @@ +package style + +import "testing" + +func Test_WithStyles(t *testing.T) { + testStr := Red.Render("Amazing") + Green.Render("World") + render := WithControls("", testStr) + if render != testStr { + t.Error("Should be empty") + } + render = WithOverlay("", testStr) + if render != testStr { + t.Error("Should be empty") + } + + render = TruncateLeft(testStr, 7) + if render != "World" { + t.Error("Should be World") + } +} diff --git a/ui/utils/utils.go b/ui/utils/utils.go index 6415e64b..af9d89fa 100644 --- a/ui/utils/utils.go +++ b/ui/utils/utils.go @@ -5,23 +5,8 @@ import ( "fmt" ) -func IntPtrToZero(num *int) int { - if num == nil { - return 0 - } - return *num -} - func toPtr[T any](constVar T) *T { return &constVar } -func toPtrOrNil[T comparable](comparable T) *T { - var zero T - if comparable == zero { - return nil - } - return &comparable -} - func UrlEncodeBytesPtrOrNil(b []byte) *string { if b == nil || len(b) == 0 || isZeros(b) { return nil diff --git a/ui/utils/utils_test.go b/ui/utils/utils_test.go new file mode 100644 index 00000000..4c7e3e70 --- /dev/null +++ b/ui/utils/utils_test.go @@ -0,0 +1,21 @@ +package utils + +import "testing" + +func Test_Utils(t *testing.T) { + res := UrlEncodeBytesPtrOrNil(nil) + if res != nil { + t.Error("UrlEncodeBytesPtrOrNil was not nil") + } + + zeros := isZeros([]byte("")) + if !zeros { + t.Error("isZeros was not true") + } + val := 5 + str := StrOrNA(&val) + if str != "5" { + t.Error("StrOrNA was not 5") + } + +} diff --git a/ui/viewport.go b/ui/viewport.go index 9f87aa8b..4df61370 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -1,29 +1,19 @@ package ui import ( - "context" + "errors" "fmt" - "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/modal" "github.com/algorandfoundation/hack-tui/ui/pages/accounts" - "github.com/algorandfoundation/hack-tui/ui/pages/generate" "github.com/algorandfoundation/hack-tui/ui/pages/keys" - "github.com/algorandfoundation/hack-tui/ui/pages/transaction" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -type ViewportPage string - -const ( - AccountsPage ViewportPage = "accounts" - KeysPage ViewportPage = "keys" - GeneratePage ViewportPage = "generate" - TransactionPage ViewportPage = "transaction" - ErrorPage ViewportPage = "error" -) - +// ViewportViewModel represents the state and view model for a viewport in the application. type ViewportViewModel struct { PageWidth, PageHeight int TerminalWidth, TerminalHeight int @@ -35,32 +25,17 @@ type ViewportViewModel struct { protocol ProtocolViewModel // Pages - accountsPage accounts.ViewModel - keysPage keys.ViewModel - generatePage generate.ViewModel - transactionPage transaction.ViewModel - - page ViewportPage - client *api.ClientWithResponses - - // Error Handler - errorMsg *string - errorPage ErrorViewModel -} + accountsPage accounts.ViewModel + keysPage keys.ViewModel -func DeleteKey(client *api.ClientWithResponses, key keys.DeleteKey) tea.Cmd { - return func() tea.Msg { - err := internal.DeletePartKey(context.Background(), client, key.Id) - if err != nil { - return keys.DeleteFinished(err.Error()) - } - return keys.DeleteFinished(key.Id) - } + modal *modal.ViewModel + page app.Page + client api.ClientWithResponsesInterface } // Init is a no-op func (m ViewportViewModel) Init() tea.Cmd { - return nil + return m.modal.Init() } // Update Handle the viewport lifecycle @@ -76,105 +51,88 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) switch msg := msg.(type) { - case generate.Cancel: - m.page = AccountsPage - return m, nil - case error: - strMsg := msg.Error() - m.errorMsg = &strMsg + case app.Page: + if msg == app.KeysPage { + m.keysPage.Address = m.accountsPage.SelectedAccount().Address + } + m.page = msg // When the state updates case internal.StateModel: - if m.errorMsg != nil { - m.errorMsg = nil - m.page = AccountsPage - } m.Data = &msg - // Navigate to the transaction page when a partkey is selected - case *api.ParticipationKey: - m.page = TransactionPage - // Navigate to the keys page when an account is selected - case internal.Account: - m.page = KeysPage - case keys.DeleteKey: - return m, DeleteKey(m.client, msg) + m.accountsPage, cmd = m.accountsPage.HandleMessage(msg) + cmds = append(cmds, cmd) + m.keysPage, cmd = m.keysPage.HandleMessage(msg) + cmds = append(cmds, cmd) + m.modal, cmd = m.modal.HandleMessage(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + case app.DeleteFinished: + if len(m.keysPage.Rows()) <= 1 { + cmd = app.EmitShowPage(app.AccountsPage) + cmds = append(cmds, cmd) + } case tea.KeyMsg: switch msg.String() { - // Tab Backwards - case "shift+tab": - if m.page == AccountsPage { + case "g": + // Only open modal when it is closed and not syncing + if !m.modal.Open && m.Data.Status.State == internal.StableState && m.Data.Metrics.RoundTime > 0 { + address := "" + selected := m.accountsPage.SelectedAccount() + if selected != nil { + address = selected.Address + } + return m, app.EmitModalEvent(app.ModalEvent{ + Key: nil, + Address: address, + Type: app.GenerateModal, + }) + } else if m.Data.Status.State != internal.StableState || m.Data.Metrics.RoundTime == 0 { + genErr := errors.New("Please wait for more data to sync before generating a key") + m.modal, cmd = m.modal.HandleMessage(genErr) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + + case "left": + // Disable when overlay is active or on Accounts + if m.modal.Open || m.page == app.AccountsPage { return m, nil } - if m.page == TransactionPage { - return m, accounts.EmitAccountSelected(m.accountsPage.SelectedAccount()) + // Navigate to the Keys Page + if m.page == app.KeysPage { + return m, app.EmitShowPage(app.AccountsPage) } - if m.page == KeysPage { - m.page = AccountsPage + case "right": + // Disable when overlay is active + if m.modal.Open { return m, nil } - // Tab Forwards - case "tab": - if m.page == AccountsPage { + if m.page == app.AccountsPage { selAcc := m.accountsPage.SelectedAccount() - if selAcc != (internal.Account{}) { - m.page = KeysPage - return m, accounts.EmitAccountSelected(selAcc) + if selAcc != nil { + m.page = app.KeysPage + return m, app.EmitAccountSelected(*selAcc) } return m, nil } - if m.page == KeysPage { - selKey := m.keysPage.SelectedKey() - if selKey != nil && m.Data.Status.State != "SYNCING" { - m.page = TransactionPage - return m, keys.EmitKeySelected(selKey) - } - } - return m, nil - case "a": - m.page = AccountsPage - case "g": - m.generatePage.Inputs[0].SetValue(m.accountsPage.SelectedAccount().Address) - m.page = GeneratePage - return m, nil - case "k": - selAcc := m.accountsPage.SelectedAccount() - if selAcc != (internal.Account{}) { - m.page = KeysPage - return m, accounts.EmitAccountSelected(selAcc) - } - return m, nil - case "t": - if m.Data.Status.State != "SYNCING" { - - if m.page == AccountsPage { - acct := m.accountsPage.SelectedAccount() - data := *m.Data.ParticipationKeys - for i, key := range data { - if key.Address == acct.Address { - m.page = TransactionPage - return m, keys.EmitKeySelected(&data[i]) - } - } - } - if m.page == KeysPage { - selKey := m.keysPage.SelectedKey() - if selKey != nil { - m.page = TransactionPage - return m, keys.EmitKeySelected(selKey) - } - } - } return m, nil case "ctrl+c": - if m.page != GeneratePage { - return m, tea.Quit - } + return m, tea.Quit } case tea.WindowSizeMsg: m.TerminalWidth = msg.Width m.TerminalHeight = msg.Height m.PageWidth = msg.Width - m.PageHeight = max(0, msg.Height-lipgloss.Height(m.headerView())-1) + m.PageHeight = max(0, msg.Height-lipgloss.Height(m.headerView())) + + modalMsg := tea.WindowSizeMsg{ + Width: m.PageWidth, + Height: m.PageHeight, + } + + m.modal, cmd = m.modal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) // Custom size message pageMsg := tea.WindowSizeMsg{ @@ -189,64 +147,47 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keysPage, cmd = m.keysPage.HandleMessage(pageMsg) cmds = append(cmds, cmd) - m.generatePage, cmd = m.generatePage.HandleMessage(pageMsg) - cmds = append(cmds, cmd) - - m.transactionPage, cmd = m.transactionPage.HandleMessage(pageMsg) - cmds = append(cmds, cmd) - - m.errorPage, cmd = m.errorPage.HandleMessage(pageMsg) - cmds = append(cmds, cmd) // Avoid triggering commands again return m, tea.Batch(cmds...) - } - // Get Page Updates - switch m.page { - case AccountsPage: - m.accountsPage, cmd = m.accountsPage.HandleMessage(msg) - case KeysPage: - m.keysPage, cmd = m.keysPage.HandleMessage(msg) - case GeneratePage: - m.generatePage, cmd = m.generatePage.HandleMessage(msg) - case TransactionPage: - m.transactionPage, cmd = m.transactionPage.HandleMessage(msg) - case ErrorPage: - m.errorPage, cmd = m.errorPage.HandleMessage(msg) + + // Ignore commands while open + if !m.modal.Open { + // Get Page Updates + switch m.page { + case app.AccountsPage: + m.accountsPage, cmd = m.accountsPage.HandleMessage(msg) + case app.KeysPage: + m.keysPage, cmd = m.keysPage.HandleMessage(msg) + } + cmds = append(cmds, cmd) } + + // Run Modal Updates Last, + // This ensures Page Behavior is checked before mutating modal state + m.modal, cmd = m.modal.HandleMessage(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } // View renders the viewport.Model func (m ViewportViewModel) View() string { - errMsg := m.errorMsg - - if errMsg != nil { - m.errorPage.Message = *errMsg - m.page = ErrorPage - } // Handle Page render var page tea.Model switch m.page { - case AccountsPage: + case app.AccountsPage: page = m.accountsPage - case GeneratePage: - page = m.generatePage - case KeysPage: + case app.KeysPage: page = m.keysPage - case TransactionPage: - page = m.transactionPage - case ErrorPage: - page = m.errorPage } if page == nil { return "Error loading page..." } - return fmt.Sprintf("%s\n%s", m.headerView(), page.View()) + m.modal.Parent = fmt.Sprintf("%s\n%s", m.headerView(), page.View()) + return m.modal.View() } // headerView generates the top elements @@ -265,8 +206,8 @@ func (m ViewportViewModel) headerView() string { return lipgloss.JoinHorizontal(lipgloss.Center, m.status.View(), m.protocol.View()) } -// MakeViewportViewModel handles the construction of the TUI viewport -func MakeViewportViewModel(state *internal.StateModel, client *api.ClientWithResponses) (*ViewportViewModel, error) { +// NewViewportViewModel handles the construction of the TUI viewport +func NewViewportViewModel(state *internal.StateModel, client api.ClientWithResponsesInterface) (*ViewportViewModel, error) { m := ViewportViewModel{ Data: state, @@ -275,17 +216,16 @@ func MakeViewportViewModel(state *internal.StateModel, client *api.ClientWithRes protocol: MakeProtocolViewModel(state), // Pages - accountsPage: accounts.New(state), - keysPage: keys.New("", state.ParticipationKeys), - generatePage: generate.New("", client), - transactionPage: transaction.New(state), + accountsPage: accounts.New(state), + keysPage: keys.New("", state.ParticipationKeys), + + // Modal + modal: modal.New("", false, state), // Current Page - page: AccountsPage, + page: app.AccountsPage, // RPC client client: client, - - errorPage: NewErrorViewModel(""), } return &m, nil diff --git a/ui/viewport_test.go b/ui/viewport_test.go index 513808e7..fcd4a5ba 100644 --- a/ui/viewport_test.go +++ b/ui/viewport_test.go @@ -2,37 +2,21 @@ package ui import ( "bytes" + "github.com/algorandfoundation/hack-tui/internal/test" + "github.com/algorandfoundation/hack-tui/ui/app" + uitest "github.com/algorandfoundation/hack-tui/ui/internal/test" "testing" "time" - "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/internal" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/exp/teatest" - "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" ) func Test_ViewportViewRender(t *testing.T) { - apiToken, err := securityprovider.NewSecurityProviderApiKey("header", "X-Algo-API-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - client, err := api.NewClientWithResponses("http://localhost:8080", api.WithRequestEditorFn(apiToken.Intercept)) - if err != nil { - t.Fatal(err) - } - state := internal.StateModel{ - Status: internal.StatusModel{ - LastRound: 1337, - NeedsUpdate: true, - State: "SYNCING", - }, - Metrics: internal.MetricsModel{ - RoundTime: 0, - TX: 0, - RX: 0, - TPS: 0, - }, - } + client := test.GetClient(false) + state := uitest.GetState(client) // Create the Model - m, err := MakeViewportViewModel(&state, client) + m, err := NewViewportViewModel(state, client) if err != nil { t.Fatal(err) } @@ -51,7 +35,32 @@ func Test_ViewportViewRender(t *testing.T) { teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3), ) + tm.Send(app.AccountSelected( + state.Accounts["ABC"])) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("left"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("right"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("right"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("left"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("left"), + }) // Send quit key tm.Send(tea.KeyMsg{ Type: tea.KeyRunes,