diff --git a/cmd/root.go b/cmd/root.go index 1f04e8e9..b8ddffac 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -42,7 +42,9 @@ var ( client, err := getClient() cobra.CheckErr(err) - partkeys, err := internal.GetPartKeys(context.Background(), client) + ctx := context.Background() + + partkeys, err := internal.GetPartKeys(ctx, client) cobra.CheckErr(err) state := internal.StateModel{ @@ -61,11 +63,14 @@ 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) cobra.CheckErr(err) m, err := ui.MakeViewportViewModel(&state, client) @@ -85,12 +90,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/internal/state.go b/internal/state.go index c20508c0..0bb853cb 100644 --- a/internal/state.go +++ b/internal/state.go @@ -16,6 +16,9 @@ type StateModel struct { // TODO: handle contexts instead of adding it to state Admin bool Watching bool + + Client *api.ClientWithResponses + Context context.Context } func (s *StateModel) waitAfterError(err error, cb func(model *StateModel, err error)) { @@ -60,7 +63,7 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co s.Status.Update(status.JSON200.LastRound, status.JSON200.CatchupTime, status.JSON200.UpgradeNodeVote) // Fetch Keys - s.UpdateKeys(ctx, client) + s.UpdateKeys() // Run Round Averages and RX/TX every 5 rounds if s.Status.LastRound%5 == 0 { @@ -95,18 +98,18 @@ func (s *StateModel) UpdateMetricsFromRPC(ctx context.Context, client *api.Clien s.Metrics.RX = 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..c5a7e666 100644 --- a/internal/state_test.go +++ b/internal/state_test.go @@ -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/ui/modal/confirm/confirm.go b/ui/modal/confirm/confirm.go new file mode 100644 index 00000000..842671e9 --- /dev/null +++ b/ui/modal/confirm/confirm.go @@ -0,0 +1,81 @@ +package confirm + +import ( + "fmt" + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/style" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Msg *api.ParticipationKey + +func EmitMsg(key *api.ParticipationKey) tea.Cmd { + return func() tea.Msg { + return Msg(key) + } +} + +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 "y": + return &m, EmitMsg(m.ActiveKey) + case "n": + return &m, EmitMsg(nil) + } + 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 { + modalStyle := lipgloss.NewStyle(). + Width(60). + Height(7). + Align(lipgloss.Center). + Padding(1, 2) + + modalContent := fmt.Sprintf("Participation Key: %v\nAccount Address: %v", 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/modal/info/info.go b/ui/modal/info/info.go new file mode 100644 index 00000000..e1a15e7b --- /dev/null +++ b/ui/modal/info/info.go @@ -0,0 +1,86 @@ +package info + +import ( + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "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 + ActiveKey *api.ParticipationKey + Data *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") + " )", + 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.WindowSizeMsg: + m.Width = msg.Width + m.Height = msg.Height + } + m.UpdateState() + return &m, nil +} +func (m *ViewModel) UpdateState() { + if m.ActiveKey == nil { + return + } + accountStatus := m.Data.Accounts[m.ActiveKey.Address].Status + + if accountStatus == "Online" { + m.Controls = "( " + style.Red.Render("(d)elete") + " | " + style.Yellow.Render("(o)ffline") + " )" + } else { + m.Controls = "( " + style.Red.Render("(d)elete") + " | " + style.Green.Render("(o)nline") + " )" + } +} +func (m ViewModel) View() string { + if m.ActiveKey == nil { + return "No key selected" + } + + id := style.Cyan.Render("Participation ID: ") + m.ActiveKey.Id + selection := style.Yellow.Render("Selection Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.ActiveKey.Key.SelectionParticipationKey[:]) + vote := style.Yellow.Render("Vote Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.ActiveKey.Key.VoteParticipationKey[:]) + stateProof := style.Yellow.Render("State Proof Key: ") + *utils.UrlEncodeBytesPtrOrNil(*m.ActiveKey.Key.StateProofKey) + voteFirstValid := style.Purple("Vote First Valid: ") + utils.IntToStr(m.ActiveKey.Key.VoteFirstValid) + voteLastValid := style.Purple("Vote Last Valid: ") + utils.IntToStr(m.ActiveKey.Key.VoteLastValid) + voteKeyDilution := style.Purple("Vote Key Dilution: ") + utils.IntToStr(m.ActiveKey.Key.VoteKeyDilution) + + return ansi.Hardwrap(lipgloss.JoinVertical(lipgloss.Left, + id, + selection, + vote, + stateProof, + voteFirstValid, + voteLastValid, + voteKeyDilution, + ), m.Width, true) + +} diff --git a/ui/modal/modal.go b/ui/modal/modal.go new file mode 100644 index 00000000..e4bd99c6 --- /dev/null +++ b/ui/modal/modal.go @@ -0,0 +1,177 @@ +package modal + +import ( + "context" + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/modal/confirm" + "github.com/algorandfoundation/hack-tui/ui/modal/info" + "github.com/algorandfoundation/hack-tui/ui/modal/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 +} +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + switch msg := msg.(type) { + case DeleteFinished: + m.Page = InfoModal + case confirm.Msg: + if msg != nil { + return &m, DeleteKeyCmd(m.State.Context, m.State.Client, msg.Id) + } else { + m.Page = InfoModal + } + case tea.KeyMsg: + switch msg.String() { + case "esc": + m.Page = InfoModal + case "d": + if m.Page == InfoModal { + m.Page = ConfirmModal + } + case "o": + if m.Page == InfoModal { + m.Page = TransactionModal + m.transactionModal.UpdateState() + } + case "enter": + if m.Page == InfoModal { + m.Page = TransactionModal + } + if m.Page == TransactionModal { + m.Page = InfoModal + } + } + // 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) + return &m, tea.Batch(cmds...) + } + + // Only trigger modal commands when they are active + switch m.Page { + case InfoModal: + m.infoModal, cmd = m.infoModal.HandleMessage(msg) + m.title = m.infoModal.Title + m.controls = m.infoModal.Controls + m.borderColor = m.infoModal.BorderColor + case TransactionModal: + m.transactionModal, cmd = m.transactionModal.HandleMessage(msg) + m.title = m.transactionModal.Title + m.controls = m.transactionModal.Controls + m.borderColor = m.transactionModal.BorderColor + case ConfirmModal: + m.confirmModal, cmd = m.confirmModal.HandleMessage(msg) + m.title = m.confirmModal.Title + m.controls = m.confirmModal.Controls + m.borderColor = m.confirmModal.BorderColor + } + cmds = append(cmds, cmd) + return &m, tea.Batch(cmds...) +} +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/transaction/controller.go b/ui/modal/transaction/controller.go new file mode 100644 index 00000000..7ab69e77 --- /dev/null +++ b/ui/modal/transaction/controller.go @@ -0,0 +1,78 @@ +package transaction + +import ( + "encoding/base64" + "github.com/algorand/go-algorand-sdk/v2/types" + "github.com/algorandfoundation/algourl/encoder" + tea "github.com/charmbracelet/bubbletea" +) + +type Title string + +const ( + OnlineTitle Title = "Online Transaction" + OfflineTitle Title = "Offline Transaction" +) + +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) { + // 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.ActiveKey == nil { + return + } + accountStatus := m.State.Accounts[m.ActiveKey.Address].Status + + if m.ATxn == nil { + m.ATxn = &encoder.AUrlTxn{} + } + fee := uint64(1000) + m.ATxn.AUrlTxnKeyCommon.Sender = m.ActiveKey.Address + m.ATxn.AUrlTxnKeyCommon.Type = string(types.KeyRegistrationTx) + m.ATxn.AUrlTxnKeyCommon.Fee = &fee + + if accountStatus != "Online" { + m.Title = string(OnlineTitle) + m.BorderColor = "2" + votePartKey := base64.RawURLEncoding.EncodeToString(m.ActiveKey.Key.VoteParticipationKey) + selPartKey := base64.RawURLEncoding.EncodeToString(m.ActiveKey.Key.SelectionParticipationKey) + spKey := base64.RawURLEncoding.EncodeToString(*m.ActiveKey.Key.StateProofKey) + firstValid := uint64(m.ActiveKey.Key.VoteFirstValid) + lastValid := uint64(m.ActiveKey.Key.VoteLastValid) + vkDilution := uint64(m.ActiveKey.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/modal/transaction/model.go b/ui/modal/transaction/model.go new file mode 100644 index 00000000..cd1cbe6c --- /dev/null +++ b/ui/modal/transaction/model.go @@ -0,0 +1,50 @@ +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 + ActiveKey *api.ParticipationKey + + // 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.ActiveKey.Address[0:4], m.ActiveKey.Address[len(m.ActiveKey.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/modal/transaction/style.go similarity index 69% rename from ui/pages/transaction/style.go rename to ui/modal/transaction/style.go index 4e4151d1..08f91ff6 100644 --- a/ui/pages/transaction/style.go +++ b/ui/modal/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/modal/transaction/view.go b/ui/modal/transaction/view.go new file mode 100644 index 00000000..f7dbc0d6 --- /dev/null +++ b/ui/modal/transaction/view.go @@ -0,0 +1,32 @@ +package transaction + +import ( + "github.com/algorandfoundation/hack-tui/ui/style" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +func (m ViewModel) View() string { + if m.ActiveKey == nil { + return "No key selected" + } + if m.ATxn == nil { + return "Loading..." + } + txn, err := m.ATxn.ProduceQRCode() + if err != nil { + return "Something went wrong" + } + + render := qrStyle.Render(txn) + + width := lipgloss.Width(render) + height := lipgloss.Height(render) + + if width > m.Width || height > m.Height { + return style.Red.Render(ansi.Wordwrap("QR Code too large to display... Please adjust terminal dimensions or font.", m.Width, " ")) + + } + + return render +} diff --git a/ui/overlay/overlay.go b/ui/overlay/overlay.go new file mode 100644 index 00000000..c648bc2e --- /dev/null +++ b/ui/overlay/overlay.go @@ -0,0 +1,62 @@ +package overlay + +import ( + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/ui/modal" + "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/model.go b/ui/pages/accounts/model.go index 2784f9ac..0b3c7730 100644 --- a/ui/pages/accounts/model.go +++ b/ui/pages/accounts/model.go @@ -25,8 +25,8 @@ func New(state *internal.StateModel) ViewModel { Width: 0, Height: 0, Data: state.Accounts, - controls: "( (g)enerate )", - navigation: "| " + style.Green.Render("accounts") + " | keys | txn |", + controls: "( (g)enerate | enter )", + navigation: "| " + style.Green.Render("accounts") + " | keys |", } m.table = table.New( diff --git a/ui/pages/accounts/view.go b/ui/pages/accounts/view.go index 56913350..c59b85df 100644 --- a/ui/pages/accounts/view.go +++ b/ui/pages/accounts/view.go @@ -5,7 +5,7 @@ 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, "6").Render(m.table.View()) return style.WithNavigation( m.navigation, style.WithControls( 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..7074ae19 100644 --- a/ui/pages/keys/controller.go +++ b/ui/pages/keys/controller.go @@ -2,6 +2,8 @@ package keys import ( "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/modal" + "github.com/algorandfoundation/hack-tui/ui/overlay" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -23,43 +25,17 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { case internal.Account: 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 + case modal.DeleteFinished: + internal.RemovePartKeyByID(m.Data, string(msg)) m.table.SetRows(m.makeRows(m.Data)) - case tea.KeyMsg: switch msg.String() { case "enter": selKey := m.SelectedKey() if selKey != nil { - return m, EmitKeySelected(selKey) - } - 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, overlay.EmitShowModal(selKey) } return m, nil - case "ctrl+c": - return m, tea.Quit } case tea.WindowSizeMsg: diff --git a/ui/pages/keys/model.go b/ui/pages/keys/model.go index d974c991..e5be7ab8 100644 --- a/ui/pages/keys/model.go +++ b/ui/pages/keys/model.go @@ -31,8 +31,8 @@ func New(address string, keys *[]api.ParticipationKey) ViewModel { Width: 80, Height: 24, - controls: "( (g)enerate )", - navigation: "| accounts | " + style.Green.Render("keys") + " | txn |", + controls: "( (g)enerate | enter )", + navigation: "| accounts | " + style.Green.Render("keys") + " |", table: table.New(), } @@ -74,23 +74,14 @@ func (m ViewModel) SelectedKey() *api.ParticipationKey { } 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("")) - 14) / 4 //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: "Last Vote", Width: avgWidth}, + {Title: "Last Block Proposal", Width: avgWidth}, } } @@ -104,19 +95,8 @@ func (m ViewModel) makeRows(keys *[]api.ParticipationKey) []table.Row { 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), utils.StrOrNA(key.LastVote), utils.StrOrNA(key.LastBlockProposal), - utils.StrOrNA(key.LastStateProof), }) } } diff --git a/ui/pages/keys/view.go b/ui/pages/keys/view.go index 8107e710..8eafaa1d 100644 --- a/ui/pages/keys/view.go +++ b/ui/pages/keys/view.go @@ -1,20 +1,11 @@ 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, "4").Render(m.table.View()) return style.WithNavigation( m.navigation, style.WithControls( @@ -26,16 +17,3 @@ func (m ViewModel) View() string { ), ) } - -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 6f155707..00000000 --- a/ui/pages/transaction/controller.go +++ /dev/null @@ -1,119 +0,0 @@ -package transaction - -import ( - "encoding/base64" - "fmt" - "github.com/algorandfoundation/hack-tui/ui/style" - "github.com/charmbracelet/lipgloss" - - "github.com/algorand/go-algorand-sdk/v2/types" - "github.com/algorandfoundation/algourl/encoder" - "github.com/algorandfoundation/hack-tui/api" - tea "github.com/charmbracelet/bubbletea" -) - -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.FormatedAddress()) - return nil - } - m.IsOnline = isOnline - 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.FormatedAddress()) - - } 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.FormatedAddress()) - } - - 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: - 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 -} diff --git a/ui/pages/transaction/model.go b/ui/pages/transaction/model.go deleted file mode 100644 index ac4b2b57..00000000 --- a/ui/pages/transaction/model.go +++ /dev/null @@ -1,45 +0,0 @@ -package transaction - -import ( - "fmt" - "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 - - // Participation Key - Data api.ParticipationKey - - // Pointer to the State - State *internal.StateModel - IsOnline bool - - // Components - controls string - navigation string - - // QR Code, URL and hint text - asciiQR string - urlTxn string - hint string -} - -func (m ViewModel) FormatedAddress() string { - return fmt.Sprintf("%s...%s", m.Data.Address[0:4], m.Data.Address[len(m.Data.Address)-4:]) -} - -// New creates and instance of the ViewModel with a default controls.Model -func New(state *internal.StateModel) ViewModel { - return ViewModel{ - State: state, - IsOnline: false, - navigation: "| accounts | keys | " + style.Green.Render("txn") + " |", - controls: "( <- back )", - } -} diff --git a/ui/pages/transaction/view.go b/ui/pages/transaction/view.go deleted file mode 100644 index 4d972c2c..00000000 --- a/ui/pages/transaction/view.go +++ /dev/null @@ -1,62 +0,0 @@ -package transaction - -import ( - "github.com/algorandfoundation/hack-tui/ui/style" - "github.com/charmbracelet/lipgloss" - "strings" -) - -func (m ViewModel) View() string { - qrCode := qrStyle.Render(m.asciiQR) - qrWidth := lipgloss.Width(qrCode) + 1 - qrHeight := lipgloss.Height(qrCode) - title := "" - if m.IsOnline { - title = "Offline Transaction" - } else { - title = "Online Transaction" - } - - url := "" - if lipgloss.Width(m.urlTxn) > qrWidth { - url = m.urlTxn[:(qrWidth-3)] + "..." - } else { - url = m.urlTxn - } - - var render string - if qrWidth > m.Width || qrHeight+2 > m.Height { - text := style.Red.Render("QR Code too large to display... Please adjust terminal dimensions or font.") - padHeight := max(0, m.Height-lipgloss.Height(text)) - padHString := strings.Repeat("\n", padHeight/2) - padWidth := max(0, m.Width-lipgloss.Width(text)) - padWString := strings.Repeat(" ", padWidth/2) - paddedStr := 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.")), - ) - render = style.ApplyBorder(m.Width, m.Height, "8").Render(paddedStr) - } else { - qRemainingWidth := max(0, (m.Width-lipgloss.Width(qrCode))/2) - qrCode = lipgloss.JoinHorizontal(lipgloss.Left, strings.Repeat(" ", qRemainingWidth), qrCode, strings.Repeat(" ", qRemainingWidth)) - qRemainingHeight := max(0, (m.Height-2-lipgloss.Height(qrCode))/2) - if qrHeight+2 == m.Height { - qrCode = lipgloss.JoinVertical(lipgloss.Center, style.Yellow.Render(m.hint), qrCode, urlStyle.Render(url)) - } else { - qrCode = lipgloss.JoinVertical(lipgloss.Center, strings.Repeat("\n", qRemainingHeight), style.Yellow.Render(m.hint), qrCode, urlStyle.Render(url)) - - } - render = style.ApplyBorder(m.Width, m.Height, "8").Render(qrCode) - } - return style.WithNavigation( - m.navigation, - style.WithControls( - m.controls, - style.WithTitle( - title, - render, - ), - ), - ) -} diff --git a/ui/style/style.go b/ui/style/style.go index 1647f3f7..930bdcb2 100644 --- a/ui/style/style.go +++ b/ui/style/style.go @@ -2,6 +2,8 @@ package style import ( "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "regexp" "strings" ) @@ -52,24 +54,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/viewport.go b/ui/viewport.go index f602d67a..9fb53e0a 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -1,15 +1,15 @@ package ui import ( - "context" "fmt" + "github.com/algorandfoundation/hack-tui/ui/modal" + "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/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" ) @@ -35,11 +35,11 @@ type ViewportViewModel struct { protocol ProtocolViewModel // Pages - accountsPage accounts.ViewModel - keysPage keys.ViewModel - generatePage generate.ViewModel - transactionPage transaction.ViewModel + accountsPage accounts.ViewModel + keysPage keys.ViewModel + generatePage generate.ViewModel + modal overlay.ViewModel page ViewportPage client *api.ClientWithResponses @@ -48,16 +48,6 @@ type ViewportViewModel struct { errorPage ErrorViewModel } -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) - } -} - // Init is a no-op func (m ViewportViewModel) Init() tea.Cmd { return nil @@ -76,10 +66,21 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) switch msg := msg.(type) { + case overlay.ShowModal: + m.modal.Open = true + m.modal.SetKey(msg) + case modal.DeleteFinished: + m.modal.Open = false + m.page = AccountsPage + m.modal, cmd = m.modal.HandleMessage(msg) + cmds = append(cmds, cmd) + m.keysPage, cmd = m.keysPage.HandleMessage(msg) + cmds = append(cmds, cmd) case generate.Cancel: m.page = AccountsPage return m, nil case error: + m.modal.Open = false strMsg := msg.Error() m.errorMsg = &strMsg // When the state updates @@ -89,18 +90,20 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) case tea.KeyMsg: switch msg.String() { - // Tab Backwards + case "g": + if m.page != GeneratePage && !m.modal.Open { + m.page = GeneratePage + } case "left": + // Disable when overlay is active + if m.modal.Open { + return m, nil + } if m.page == AccountsPage { return m, nil } @@ -111,8 +114,11 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.page = AccountsPage return m, nil } - // Tab Forwards case "right": + // Disable when overlay is active + if m.modal.Open { + return m, nil + } if m.page == AccountsPage { selAcc := m.accountsPage.SelectedAccount() if selAcc != (internal.Account{}) { @@ -121,13 +127,6 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } - 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 { @@ -141,6 +140,14 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.PageWidth = msg.Width m.PageHeight = max(0, msg.Height-lipgloss.Height(m.headerView())-1) + modalMsg := tea.WindowSizeMsg{ + Width: m.PageWidth - 2, + Height: m.PageHeight, + } + + m.modal, cmd = m.modal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) + // Custom size message pageMsg := tea.WindowSizeMsg{ Height: m.PageHeight, @@ -157,9 +164,6 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.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 @@ -174,12 +178,15 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { + m.modal, cmd = m.modal.HandleMessage(msg) + } cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) } @@ -201,8 +208,6 @@ func (m ViewportViewModel) View() string { page = m.generatePage case KeysPage: page = m.keysPage - case TransactionPage: - page = m.transactionPage case ErrorPage: page = m.errorPage } @@ -211,7 +216,8 @@ func (m ViewportViewModel) View() string { 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 @@ -240,10 +246,12 @@ 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), + generatePage: generate.New("", client), + + // Modal + modal: overlay.New("", false, modal.New(state)), // Current Page page: AccountsPage,