diff --git a/cmd/root.go b/cmd/root.go index 50cdcc4..cbc24c0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -61,6 +61,7 @@ var ( } state := internal.StateModel{ + Offset: viper.GetInt("offset"), Status: internal.StatusModel{ State: "INITIALIZING", Version: "NA", diff --git a/internal/accounts.go b/internal/accounts.go index b7492b0..417c1d1 100644 --- a/internal/accounts.go +++ b/internal/accounts.go @@ -112,6 +112,17 @@ 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 { values := make(map[string]Account) @@ -135,23 +146,23 @@ 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, + 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/participation.go b/internal/participation.go index 29ef856..28e8f8b 100644 --- a/internal/participation.go +++ b/internal/participation.go @@ -39,7 +39,12 @@ func waitForNewKey( client *api.ClientWithResponses, keys *[]api.ParticipationKey, interval time.Duration, + timeout time.Duration, ) (*[]api.ParticipationKey, error) { + if timeout <= 0*time.Second { + return nil, errors.New("timeout occurred waiting for new key") + } + timeout = timeout - interval // Fetch the latest keys currentKeys, err := GetPartKeys(ctx, client) if err != nil { @@ -49,7 +54,7 @@ func waitForNewKey( if len(*currentKeys) == len(*keys) { // Sleep then try again time.Sleep(interval) - return waitForNewKey(ctx, client, keys, interval) + return waitForNewKey(ctx, client, keys, interval, timeout) } return currentKeys, nil } @@ -97,7 +102,7 @@ func GenerateKeyPair( } // Wait for the api to have a new key - keys, err := waitForNewKey(ctx, client, originalKeys, 2*time.Second) + keys, err := waitForNewKey(ctx, client, originalKeys, 2*time.Second, 20*time.Second) if err != nil { return nil, err } diff --git a/internal/participation_test.go b/internal/participation_test.go index 2588366..f0a401f 100644 --- a/internal/participation_test.go +++ b/internal/participation_test.go @@ -3,10 +3,10 @@ package internal import ( "context" "fmt" - "testing" - "github.com/algorandfoundation/hack-tui/api" "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" + "testing" + "time" ) func Test_ListParticipationKeys(t *testing.T) { @@ -201,3 +201,25 @@ func Test_RemovePartKeyByID(t *testing.T) { } }) } + +func Test_Timeout(t *testing.T) { + ctx := context.Background() + // 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)) + if err != nil { + t.Fatal(err) + } + + keys, err := GetPartKeys(ctx, client) + if err != nil { + t.Fatal(err) + } + _, err = waitForNewKey(ctx, client, keys, 100*time.Millisecond, 1*time.Second) + if err == nil { + t.Fatal("Did not error") + } +} diff --git a/internal/state.go b/internal/state.go index 62a0dc9..922cb67 100644 --- a/internal/state.go +++ b/internal/state.go @@ -9,14 +9,20 @@ import ( ) type StateModel struct { + // Models Status StatusModel Metrics MetricsModel Accounts map[string]Account ParticipationKeys *[]api.ParticipationKey + + // Application State + Admin bool + Offset int + // TODO: handle contexts instead of adding it to state - Admin bool Watching bool + // RPC Client *api.ClientWithResponses Context context.Context } diff --git a/ui/error.go b/ui/error.go deleted file mode 100644 index f074251..0000000 --- a/ui/error.go +++ /dev/null @@ -1,67 +0,0 @@ -package ui - -import ( - "github.com/algorandfoundation/hack-tui/ui/style" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "strings" -) - -type ErrorViewModel struct { - Height int - Width int - 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/modal/cmds.go b/ui/modal/cmds.go new file mode 100644 index 0000000..f7092d1 --- /dev/null +++ b/ui/modal/cmds.go @@ -0,0 +1,24 @@ +package modal + +import ( + "github.com/algorandfoundation/hack-tui/api" + tea "github.com/charmbracelet/bubbletea" +) + +type Event struct { + Key *api.ParticipationKey + Address string + Type string +} + +type ShowModal Event + +func EmitShowModal(evt Event) tea.Cmd { + return func() tea.Msg { + return ShowModal(evt) + } +} + +type DeleteFinished string + +type DeleteKey *api.ParticipationKey diff --git a/ui/modals/modal.go b/ui/modal/controller.go similarity index 51% rename from ui/modals/modal.go rename to ui/modal/controller.go index dba42d1..77c985c 100644 --- a/ui/modals/modal.go +++ b/ui/modal/controller.go @@ -1,80 +1,12 @@ package modal import ( - "context" - "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/internal" "github.com/algorandfoundation/hack-tui/ui/modals/confirm" - "github.com/algorandfoundation/hack-tui/ui/modals/info" - "github.com/algorandfoundation/hack-tui/ui/modals/transaction" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -func DeleteKeyCmd(ctx context.Context, client *api.ClientWithResponses, id string) tea.Cmd { - return func() tea.Msg { - err := internal.DeletePartKey(ctx, client, id) - if err != nil { - return DeleteFinished(err.Error()) - } - return DeleteFinished(id) - } -} - -type DeleteFinished string - -type DeleteKey *api.ParticipationKey - -type Page string - -const ( - InfoModal Page = "accounts" - ConfirmModal Page = "confirm" - TransactionModal Page = "transaction" -) - -type ViewModel struct { - // Width and Height - Width int - Height int - - // State for Context/Client - State *internal.StateModel - - // Views - infoModal *info.ViewModel - transactionModal *transaction.ViewModel - confirmModal *confirm.ViewModel - - // Current Component Data - title string - controls string - borderColor string - Page Page -} - -func New(state *internal.StateModel) *ViewModel { - return &ViewModel{ - Width: 0, - Height: 0, - - State: state, - - infoModal: info.New(state), - transactionModal: transaction.New(state), - confirmModal: confirm.New(state), - - Page: InfoModal, - controls: "", - borderColor: "3", - } -} -func (m ViewModel) SetKey(key *api.ParticipationKey) { - m.infoModal.ActiveKey = key - m.confirmModal.ActiveKey = key - m.transactionModal.ActiveKey = key -} func (m ViewModel) Init() tea.Cmd { return nil } @@ -83,20 +15,41 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { cmd tea.Cmd cmds []tea.Cmd ) - switch msg := msg.(type) { - case DeleteFinished: - m.Page = InfoModal + case error: + m.Open = true + m.Page = ExceptionModal + m.exceptionModal.Message = msg.Error() + // Handle Confirmation Dialog Cancel case confirm.Msg: - if msg != nil { - return &m, DeleteKeyCmd(m.State.Context, m.State.Client, msg.Id) - } else { + if msg == nil { m.Page = InfoModal } + // Handle Confirmation Dialog Delete Finished + case confirm.DeleteFinished: + m.Open = false + m.Page = InfoModal + case tea.KeyMsg: switch msg.String() { case "esc": - m.Page = InfoModal + switch m.Page { + case InfoModal: + m.Open = false + case GenerateModal: + m.Open = false + m.Page = InfoModal + case TransactionModal: + m.Page = InfoModal + case ExceptionModal: + m.Open = false + case ConfirmModal: + m.Page = InfoModal + } + case "g": + if m.Page != GenerateModal { + m.Page = GenerateModal + } case "d": if m.Page == InfoModal { m.Page = ConfirmModal @@ -133,11 +86,18 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { 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) return &m, tea.Batch(cmds...) } // Only trigger modal commands when they are active switch m.Page { + case ExceptionModal: + m.exceptionModal, cmd = m.exceptionModal.HandleMessage(msg) + m.title = m.exceptionModal.Title + m.controls = m.exceptionModal.Controls + m.borderColor = m.exceptionModal.BorderColor case InfoModal: m.infoModal, cmd = m.infoModal.HandleMessage(msg) m.title = m.infoModal.Title @@ -153,6 +113,11 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { m.title = m.confirmModal.Title m.controls = m.confirmModal.Controls m.borderColor = m.confirmModal.BorderColor + case GenerateModal: + m.generateModal, cmd = m.generateModal.HandleMessage(msg) + m.title = m.generateModal.Title + m.controls = m.generateModal.Controls + m.borderColor = m.generateModal.BorderColor } cmds = append(cmds, cmd) return &m, tea.Batch(cmds...) @@ -160,18 +125,3 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.HandleMessage(msg) } - -func (m ViewModel) View() string { - var render = "" - switch m.Page { - case InfoModal: - render = m.infoModal.View() - case TransactionModal: - render = m.transactionModal.View() - case ConfirmModal: - render = m.confirmModal.View() - } - width := lipgloss.Width(render) + 2 - height := lipgloss.Height(render) - return style.WithNavigation(m.controls, style.WithTitle(m.title, style.ApplyBorder(width, height, m.borderColor).PaddingRight(1).PaddingLeft(1).Render(render))) -} diff --git a/ui/modal/model.go b/ui/modal/model.go new file mode 100644 index 0000000..f0e78ba --- /dev/null +++ b/ui/modal/model.go @@ -0,0 +1,82 @@ +package modal + +import ( + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "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 Page string + +const ( + InfoModal Page = "accounts" + ConfirmModal Page = "confirm" + TransactionModal Page = "transaction" + GenerateModal Page = "generate" + ExceptionModal Page = "exception" +) + +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 + Page Page +} + +func (m ViewModel) SetAddress(address string) { + m.Address = address + m.generateModal.SetAddress(address) +} +func (m ViewModel) SetKey(key *api.ParticipationKey) { + m.infoModal.ActiveKey = key + m.confirmModal.ActiveKey = key + m.transactionModal.ActiveKey = key +} +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(""), + + Page: InfoModal, + controls: "", + borderColor: "3", + } +} diff --git a/ui/modal/view.go b/ui/modal/view.go new file mode 100644 index 0000000..03a5160 --- /dev/null +++ b/ui/modal/view.go @@ -0,0 +1,38 @@ +package modal + +import ( + "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.Page { + case InfoModal: + render = m.infoModal.View() + case TransactionModal: + render = m.transactionModal.View() + case ConfirmModal: + render = m.confirmModal.View() + case GenerateModal: + render = m.generateModal.View() + case 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 index 842671e..7dc277b 100644 --- a/ui/modals/confirm/confirm.go +++ b/ui/modals/confirm/confirm.go @@ -1,6 +1,7 @@ package confirm import ( + "context" "fmt" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" @@ -11,12 +12,22 @@ import ( type Msg *api.ParticipationKey -func EmitMsg(key *api.ParticipationKey) tea.Cmd { +func EmitCmd(key *api.ParticipationKey) tea.Cmd { return func() tea.Msg { return Msg(key) } } +func DeleteKeyCmd(ctx context.Context, client *api.ClientWithResponses, id string) tea.Cmd { + return func() tea.Msg { + err := internal.DeletePartKey(ctx, client, id) + if err != nil { + return DeleteFinished(err.Error()) + } + return DeleteFinished(id) + } +} +type DeleteFinished string type ViewModel struct { Width int Height int @@ -50,9 +61,14 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "y": - return &m, EmitMsg(m.ActiveKey) + var ( + cmds []tea.Cmd + ) + cmds = append(cmds, EmitCmd(m.ActiveKey)) + cmds = append(cmds, DeleteKeyCmd(m.Data.Context, m.Data.Client, m.ActiveKey.Id)) + return &m, tea.Batch(cmds...) case "n": - return &m, EmitMsg(nil) + return &m, EmitCmd(nil) } case tea.WindowSizeMsg: m.Width = msg.Width diff --git a/ui/modals/exception/error.go b/ui/modals/exception/error.go new file mode 100644 index 0000000..2d99fea --- /dev/null +++ b/ui/modals/exception/error.go @@ -0,0 +1,57 @@ +package exception + +import ( + "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.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/modals/generate/cmds.go b/ui/modals/generate/cmds.go index 6690034..8d8f514 100644 --- a/ui/modals/generate/cmds.go +++ b/ui/modals/generate/cmds.go @@ -1,6 +1,9 @@ package generate import ( + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/pages/accounts" tea "github.com/charmbracelet/bubbletea" ) @@ -12,3 +15,34 @@ func EmitCancel(cg Cancel) tea.Cmd { return cg } } + +func EmitErr(err error) tea.Cmd { + return func() tea.Msg { + return err + } +} + +func (m ViewModel) GenerateCmd() (*ViewModel, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + params := api.GenerateParticipationKeysParams{ + Dilution: nil, + First: int(m.State.Status.LastRound), + Last: int(m.State.Status.LastRound) + m.State.Offset, + } + + key, err := internal.GenerateKeyPair(m.State.Context, m.State.Client, m.Input.Value(), ¶ms) + if err != nil { + return &m, EmitErr(err) + } + + cmd = accounts.EmitAccountSelected(internal.Account{ + Address: key.Address, + }) + cmds = append(cmds, cmd) + cmd = EmitCancel(Cancel{}) + cmds = append(cmds, cmd) + return &m, tea.Batch(cmds...) +} diff --git a/ui/modals/generate/controller.go b/ui/modals/generate/controller.go index c8adbaa..aabe50f 100644 --- a/ui/modals/generate/controller.go +++ b/ui/modals/generate/controller.go @@ -1,16 +1,8 @@ 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 { @@ -21,83 +13,27 @@ 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 - +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("")) + m.Width = msg.Width + m.Height = msg.Height 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...) + return &m, EmitCancel(Cancel{}) 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, - }) - + return m.GenerateCmd() } } // 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) - } + var val textinput.Model + val, cmd = m.Input.Update(msg) - return tea.Batch(cmds...) + m.Input = &val + return &m, cmd } diff --git a/ui/modals/generate/model.go b/ui/modals/generate/model.go index 0d7f806..caa69c7 100644 --- a/ui/modals/generate/model.go +++ b/ui/modals/generate/model.go @@ -1,53 +1,46 @@ package generate import ( - "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" "github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/textinput" ) type ViewModel struct { - Width int - Height int + Width int + Height int + Address string - Inputs []textinput.Model + Input *textinput.Model - controls string + Title string + Controls string + BorderColor string - client *api.ClientWithResponses - focusIndex int + State *internal.StateModel 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" - } +func (m ViewModel) SetAddress(address string) { + m.Address = address + m.Input.SetValue(address) +} - m.Inputs[i] = t +func New(address string, state *internal.StateModel) *ViewModel { + input := textinput.New() + m := ViewModel{ + Address: address, + State: state, + Input: &input, + Title: "Generate Participation Key", + Controls: "( esc to cancel )", + BorderColor: "2", } - - return m + input.Cursor.Style = cursorStyle + input.CharLimit = 68 + input.Placeholder = "Wallet Address" + input.Focus() + input.PromptStyle = focusedStyle + input.TextStyle = focusedStyle + return &m } diff --git a/ui/modals/generate/view.go b/ui/modals/generate/view.go index 667410e..55fe054 100644 --- a/ui/modals/generate/view.go +++ b/ui/modals/generate/view.go @@ -1,33 +1,15 @@ package generate import ( - "fmt" - "github.com/algorandfoundation/hack-tui/ui/style" - "strings" + "github.com/charmbracelet/lipgloss" ) func (m ViewModel) View() string { - var b strings.Builder + m.Input.Focused() + render := m.Input.View() - for i := range m.Inputs { - b.WriteString(m.Inputs[i].View()) - if i < len(m.Inputs)-1 { - b.WriteRune('\n') - } + if lipgloss.Width(render) < 70 { + return lipgloss.NewStyle().Width(70).Render(render) } - - 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, - ), - ) + return render } diff --git a/ui/overlay/overlay.go b/ui/overlay/overlay.go deleted file mode 100644 index 0c4d983..0000000 --- a/ui/overlay/overlay.go +++ /dev/null @@ -1,62 +0,0 @@ -package overlay - -import ( - "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/ui/modals" - "github.com/algorandfoundation/hack-tui/ui/style" - tea "github.com/charmbracelet/bubbletea" -) - -type ShowModal *api.ParticipationKey - -func EmitShowModal(key *api.ParticipationKey) tea.Cmd { - return func() tea.Msg { - return ShowModal(key) - } -} - -type ViewModel struct { - Parent string - Open bool - modal *modal.ViewModel -} - -func (m ViewModel) SetKey(key *api.ParticipationKey) { - m.modal.SetKey(key) -} -func New(parent string, open bool, modal *modal.ViewModel) ViewModel { - return ViewModel{ - Parent: parent, - Open: open, - modal: modal, - } -} - -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 modal.DeleteFinished: - m.modal.Page = modal.InfoModal - case tea.KeyMsg: - switch msg.String() { - case "esc": - if m.modal.Page == modal.InfoModal { - m.Open = false - } - } - } - m.modal, cmd = m.modal.HandleMessage(msg) - return m, cmd -} -func (m ViewModel) View() string { - if !m.Open { - return m.Parent - } - return style.WithOverlay(m.modal.View(), m.Parent) -} diff --git a/ui/pages/accounts/controller.go b/ui/pages/accounts/controller.go index 4e4cf94..22e80f7 100644 --- a/ui/pages/accounts/controller.go +++ b/ui/pages/accounts/controller.go @@ -48,6 +48,9 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { } m.table, cmd = m.table.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } diff --git a/ui/pages/accounts/model.go b/ui/pages/accounts/model.go index 8e1ef3e..37e16bd 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("accounts") + " | keys |", + Title: "Accounts", + Width: 0, + Height: 0, + BorderColor: "6", + Data: state, + Controls: "( (g)enerate )", + Navigation: "| " + style.Green.Render("accounts") + " | keys |", } m.table = table.New( @@ -43,7 +48,7 @@ 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 diff --git a/ui/pages/accounts/view.go b/ui/pages/accounts/view.go index c59b85d..a3c0461 100644 --- a/ui/pages/accounts/view.go +++ b/ui/pages/accounts/view.go @@ -5,13 +5,13 @@ import ( ) func (m ViewModel) View() string { - table := style.ApplyBorder(m.Width, m.Height, "6").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( - "Accounts", + m.Title, table, ), ), diff --git a/ui/pages/keys/controller.go b/ui/pages/keys/controller.go index 3bb241e..3ba6b44 100644 --- a/ui/pages/keys/controller.go +++ b/ui/pages/keys/controller.go @@ -2,8 +2,8 @@ package keys import ( "github.com/algorandfoundation/hack-tui/internal" - "github.com/algorandfoundation/hack-tui/ui/modals" - "github.com/algorandfoundation/hack-tui/ui/overlay" + "github.com/algorandfoundation/hack-tui/ui/modal" + "github.com/algorandfoundation/hack-tui/ui/modals/confirm" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -18,26 +18,40 @@ 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 + cmds []tea.Cmd + ) switch msg := msg.(type) { + // When the State changes case internal.StateModel: m.Data = msg.ParticipationKeys m.table.SetRows(m.makeRows(m.Data)) + // When the Account is Selected case internal.Account: m.Address = msg.Address m.table.SetRows(m.makeRows(m.Data)) - case modal.DeleteFinished: + // When a confirmation Modal is finished deleting + case confirm.DeleteFinished: internal.RemovePartKeyByID(m.Data, string(msg)) m.table.SetRows(m.makeRows(m.Data)) + // When the user interacts with the render case tea.KeyMsg: switch msg.String() { + // Show the Info Modal case "enter": selKey := m.SelectedKey() if selKey != nil { - return m, overlay.EmitShowModal(selKey) + return m, modal.EmitShowModal(modal.Event{ + Key: selKey, + Address: selKey.Address, + Type: "info", + }) } return m, nil } + // Handle Resize Events case tea.WindowSizeMsg: borderRender := style.Border.Render("") borderWidth := lipgloss.Width(borderRender) @@ -50,9 +64,13 @@ 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 + // Handle Table Update m.table, cmd = m.table.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } cmds = append(cmds, cmd) + + // Batch all commands return m, tea.Batch(cmds...) } diff --git a/ui/pages/keys/model.go b/ui/pages/keys/model.go index e5be7ab..508a96f 100644 --- a/ui/pages/keys/model.go +++ b/ui/pages/keys/model.go @@ -11,31 +11,49 @@ 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 + // Data holds a pointer to a slice of ParticipationKey, representing the set of participation keys managed by the ViewModel. + Data *[]api.ParticipationKey - SelectedKeyToDelete *api.ParticipationKey + // 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 table.Model - controls string - navigation string + // 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 | enter )", - navigation: "| accounts | " + style.Green.Render("keys") + " |", + // 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)), @@ -44,6 +62,7 @@ func New(address string, keys *[]api.ParticipationKey) ViewModel { table.WithWidth(m.Width), ) + // Style Table s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). @@ -52,26 +71,29 @@ 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 } +// 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 { if m.Data == nil { return nil } var partkey *api.ParticipationKey + selected := m.table.SelectedRow() for _, key := range *m.Data { - selected := m.table.SelectedRow() if len(selected) > 0 && key.Id == selected[0] { partkey = &key } } return partkey } + +// 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) / 4 @@ -85,9 +107,11 @@ func (m ViewModel) makeColumns(width int) []table.Column { } } +// 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 { + if keys == nil || m.Address == "" { return rows } for _, key := range *keys { diff --git a/ui/pages/keys/view.go b/ui/pages/keys/view.go index 8eafaa1..68eb440 100644 --- a/ui/pages/keys/view.go +++ b/ui/pages/keys/view.go @@ -5,13 +5,13 @@ import ( ) func (m ViewModel) View() string { - table := style.ApplyBorder(m.Width, m.Height, "4").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, ), ), diff --git a/ui/viewport.go b/ui/viewport.go index de5ce88..39feb87 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -2,11 +2,11 @@ package ui import ( "fmt" - "github.com/algorandfoundation/hack-tui/ui/modals" - "github.com/algorandfoundation/hack-tui/ui/overlay" - "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/modal" + "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/pages/accounts" "github.com/algorandfoundation/hack-tui/ui/pages/keys" @@ -17,11 +17,9 @@ import ( type ViewportPage string const ( - AccountsPage ViewportPage = "accounts" - KeysPage ViewportPage = "keys" - GeneratePage ViewportPage = "generate" - TransactionPage ViewportPage = "transaction" - ErrorPage ViewportPage = "error" + AccountsPage ViewportPage = "accounts" + KeysPage ViewportPage = "keys" + ErrorPage ViewportPage = "error" ) type ViewportViewModel struct { @@ -37,15 +35,14 @@ type ViewportViewModel struct { // Pages accountsPage accounts.ViewModel keysPage keys.ViewModel - generatePage generate.ViewModel - modal overlay.ViewModel + modal *modal.ViewModel page ViewportPage client *api.ClientWithResponses // Error Handler errorMsg *string - errorPage ErrorViewModel + errorPage *exception.ViewModel } // Init is a no-op @@ -66,10 +63,11 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) switch msg := msg.(type) { - case overlay.ShowModal: + case modal.ShowModal: m.modal.Open = true - m.modal.SetKey(msg) - case modal.DeleteFinished: + m.modal.SetKey(msg.Key) + m.modal.SetAddress(msg.Address) + case confirm.DeleteFinished: m.modal.Open = false m.page = AccountsPage m.modal, cmd = m.modal.HandleMessage(msg) @@ -80,9 +78,8 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.page = AccountsPage return m, nil case error: - m.modal.Open = false - strMsg := msg.Error() - m.errorMsg = &strMsg + m.modal.Open = true + m.modal.Page = modal.ExceptionModal // When the state updates case internal.StateModel: if m.errorMsg != nil { @@ -96,9 +93,11 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "g": - if m.page != GeneratePage && !m.modal.Open { - m.page = GeneratePage + if !m.modal.Open { + m.modal.Open = true + m.modal.SetAddress(m.accountsPage.SelectedAccount().Address) } + case "left": // Disable when overlay is active if m.modal.Open { @@ -107,9 +106,6 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.page == AccountsPage { return m, nil } - if m.page == TransactionPage { - return m, accounts.EmitAccountSelected(m.accountsPage.SelectedAccount()) - } if m.page == KeysPage { m.page = AccountsPage return m, nil @@ -129,9 +125,7 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case "ctrl+c": - if m.page != GeneratePage { - return m, tea.Quit - } + return m, tea.Quit } case tea.WindowSizeMsg: @@ -161,29 +155,25 @@ 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.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 ErrorPage: - m.errorPage, cmd = m.errorPage.HandleMessage(msg) - } // Ignore commands while open if m.modal.Open { m.modal, cmd = m.modal.HandleMessage(msg) + } else { + // 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 ErrorPage: + m.errorPage, cmd = m.errorPage.HandleMessage(msg) + } } cmds = append(cmds, cmd) @@ -204,8 +194,6 @@ func (m ViewportViewModel) View() string { switch m.page { case AccountsPage: page = m.accountsPage - case GeneratePage: - page = m.generatePage case KeysPage: page = m.keysPage case ErrorPage: @@ -248,17 +236,16 @@ func MakeViewportViewModel(state *internal.StateModel, client *api.ClientWithRes // Pages accountsPage: accounts.New(state), keysPage: keys.New("", state.ParticipationKeys), - generatePage: generate.New("", client), // Modal - modal: overlay.New("", false, modal.New(state)), + modal: modal.New("", false, state), // Current Page page: AccountsPage, // RPC client client: client, - errorPage: NewErrorViewModel(""), + errorPage: exception.New(""), } return &m, nil