diff --git a/cmd/root.go b/cmd/root.go index d1432fc4..d7eee569 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -133,7 +133,7 @@ func runTUI(cmd *cobra.Command, dataDir string, incentivesFlag bool) error { cobra.CheckErr(err) // Construct the TUI Model from the State - m, err := ui.NewViewportViewModel(state, client) + m, err := ui.NewViewportViewModel(state) cobra.CheckErr(err) // Construct the TUI Application diff --git a/ui/app/accounts.go b/ui/app/accounts.go index 7b219a4c..d04cae9a 100644 --- a/ui/app/accounts.go +++ b/ui/app/accounts.go @@ -6,10 +6,14 @@ import ( ) // AccountSelected is a type alias for algod.Account, representing a selected account during application runtime. -type AccountSelected algod.Account +type AccountSelected *algod.Account // EmitAccountSelected waits for and retrieves a new set of table rows from a given channel. -func EmitAccountSelected(account algod.Account) tea.Cmd { +func EmitAccountSelected(account *algod.Account) tea.Cmd { + // Do nothing when there is no account + if account == nil { + return nil + } return func() tea.Msg { return AccountSelected(account) } diff --git a/ui/app/app_test.go b/ui/app/app_test.go index f546b43a..956ea087 100644 --- a/ui/app/app_test.go +++ b/ui/app/app_test.go @@ -13,23 +13,17 @@ func Test_GenerateCmd(t *testing.T) { client := test.GetClient(false) fn := GenerateCmd("ABC", participation.TimeRange, int(time.Second*60), uitest.GetState(client)) res := fn() - evt, ok := res.(ModalEvent) + _, ok := res.(KeySelectedEvent) if !ok { t.Error("Expected ModalEvent") } - if evt.Type != InfoModal { - t.Error("Expected InfoModal") - } client = test.GetClient(true) fn = GenerateCmd("ABC", participation.TimeRange, int(time.Second*60), uitest.GetState(client)) res = fn() - evt, ok = res.(ModalEvent) + _, ok = res.(error) if !ok { - t.Error("Expected ModalEvent") - } - if evt.Type != ExceptionModal { - t.Error("Expected ExceptionModal") + t.Error("Expected error") } } diff --git a/ui/app/keys.go b/ui/app/keys.go index 7dc134bc..278df74d 100644 --- a/ui/app/keys.go +++ b/ui/app/keys.go @@ -56,18 +56,11 @@ func GenerateCmd(account string, rangeType participation.RangeType, duration int key, err := participation.GenerateKeys(state.Context, state.Client, account, ¶ms) if err != nil { - return ModalEvent{ - Key: nil, - Address: "", - Active: false, - Err: err, - Type: ExceptionModal, - } + return err } - return ModalEvent{ - Key: key, - Address: key.Address, + return KeySelectedEvent{ + Key: key, Prefix: lipgloss.JoinVertical( lipgloss.Left, "Participation keys generated.", @@ -77,9 +70,27 @@ func GenerateCmd(account string, rangeType participation.RangeType, duration int "", ), Active: false, - Err: nil, - Type: InfoModal, } } } + +// KeySelectedEvent represents an event triggered in the modal system. +type KeySelectedEvent struct { + + // Key represents a participation key associated with the modal event. + Key *api.ParticipationKey + + // Active indicates whether key is Online or not. + Active bool + + // Prefix adds prefix message to info modal + Prefix string +} + +// EmitKeySelectedEvent creates a command that emits a ModalEvent as a message in the Tea framework. +func EmitKeySelectedEvent(event KeySelectedEvent) tea.Cmd { + return func() tea.Msg { + return event + } +} diff --git a/ui/app/modal.go b/ui/app/modal.go index e84b0edf..d9616177 100644 --- a/ui/app/modal.go +++ b/ui/app/modal.go @@ -1,7 +1,6 @@ package app import ( - "github.com/algorandfoundation/nodekit/api" tea "github.com/charmbracelet/bubbletea" ) @@ -10,12 +9,6 @@ type ModalType string const ( - // CloseModal represents an event or type used to close the currently active modal in the application. - CloseModal ModalType = "" - - // CancelModal is a constant representing the type for modals used to indicate cancellation events in the application. - CancelModal ModalType = "cancel" - // InfoModal indicates a modal type used for displaying informational messages or content in the application. InfoModal ModalType = "info" @@ -38,32 +31,3 @@ func EmitShowModal(modal ModalType) tea.Cmd { return modal } } - -// ModalEvent represents an event triggered in the modal system. -type ModalEvent struct { - - // Key represents a participation key associated with the modal event. - Key *api.ParticipationKey - - // Active indicates whether key is Online or not. - Active bool - - // Address represents the address associated with the modal event. It is used to identify the relevant account or key. - Address string - - // Prefix adds prefix message to info modal - Prefix string - - // Err is an error that represents an exceptional condition or failure state for the modal event. - Err error - - // Type represents the specific category or variant of the modal event. - Type ModalType -} - -// EmitModalEvent creates a command that emits a ModalEvent as a message in the Tea framework. -func EmitModalEvent(event ModalEvent) tea.Cmd { - return func() tea.Msg { - return event - } -} diff --git a/ui/app/overlay.go b/ui/app/overlay.go new file mode 100644 index 00000000..44e6df97 --- /dev/null +++ b/ui/app/overlay.go @@ -0,0 +1,22 @@ +package app + +import tea "github.com/charmbracelet/bubbletea" + +type OverlayEventType string + +const ( + OverlayEventClose OverlayEventType = "close" + OverlayEventCancel OverlayEventType = "cancel" +) + +func EmitCloseOverlay() tea.Cmd { + return func() tea.Msg { + return OverlayEventClose + } +} + +func EmitCancelOverlay() tea.Cmd { + return func() tea.Msg { + return OverlayEventCancel + } +} diff --git a/ui/modals/confirm/confirm.go b/ui/modals/confirm/confirm.go index db6f3614..880d71ec 100644 --- a/ui/modals/confirm/confirm.go +++ b/ui/modals/confirm/confirm.go @@ -44,18 +44,16 @@ func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // HandleMessage processes incoming messages, updates ViewModel state, and returns the updated model alongside a command. func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { switch msg := msg.(type) { + // Handle Confirmation Dialog Delete Finished + case app.DeleteFinished: + return m, app.EmitCloseOverlay() case tea.KeyMsg: switch msg.String() { case "esc", "n": - return m, app.EmitModalEvent(app.ModalEvent{ - Type: app.CancelModal, - }) + return m, app.EmitCancelOverlay() case "y": - var ( - cmds []tea.Cmd - ) - cmds = append(cmds, app.EmitDeleteKey(m.State.Context, m.State.Client, m.Participation.Id)) - return m, tea.Batch(cmds...) + // Emit the delete request + return m, app.EmitDeleteKey(m.State.Context, m.State.Client, m.Participation.Id) } case tea.WindowSizeMsg: m.Width = msg.Width diff --git a/ui/modals/exception/error.go b/ui/modals/exception/error.go index 2fd45797..a39bf947 100644 --- a/ui/modals/exception/error.go +++ b/ui/modals/exception/error.go @@ -12,22 +12,13 @@ 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 New(message string) ViewModel { + return ViewModel{ + Height: 0, + Width: 0, + Message: message, } } @@ -39,28 +30,54 @@ 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) { +func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { + // Handle errors make ensure the modal is visible case error: m.Message = msg.Error() + return m, app.EmitShowModal(app.ExceptionModal) case tea.KeyMsg: switch msg.String() { case "esc": - return &m, app.EmitModalEvent(app.ModalEvent{ - Type: app.CancelModal, - }) + return m, app.EmitCloseOverlay() } 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)) + m.Width = msg.Width + m.Height = msg.Height } - return &m, cmd + return m, cmd } -func (m ViewModel) View() string { +func (m ViewModel) Title() string { + return "Error" +} +func (m ViewModel) BorderColor() string { + return "1" +} +func (m ViewModel) Controls() string { + return "( esc )" +} +func (m ViewModel) Body() string { return ansi.Hardwrap(style.Red.Render(m.Message), m.Width, false) } + +// View renders the ViewModel as a styled string, incorporating title, controls, and body content with dynamic borders. +func (m ViewModel) View() string { + body := m.Body() + width := lipgloss.Width(body) + height := lipgloss.Height(body) + return style.WithNavigation( + m.Controls(), + style.WithTitle( + m.Title(), + // Apply the Borders with the Padding + style.ApplyBorder(width+2, height+2, m.BorderColor()). + Padding(1). + Render(m.Body()), + ), + ) + +} diff --git a/ui/modals/exception/testdata/Test_Snapshot/Visible.golden b/ui/modals/exception/testdata/Test_Snapshot/Visible.golden index 72f8f900..d95600dc 100644 --- a/ui/modals/exception/testdata/Test_Snapshot/Visible.golden +++ b/ui/modals/exception/testdata/Test_Snapshot/Visible.golden @@ -1 +1,5 @@ -Something went wrong \ No newline at end of file +╭──Error───────────────╮ +│ │ +│ Something went wrong │ +│ │ +╰───────────( esc )────╯ \ No newline at end of file diff --git a/ui/modals/generate/controller.go b/ui/modals/generate/controller.go index 19ad987d..6dc773b8 100644 --- a/ui/modals/generate/controller.go +++ b/ui/modals/generate/controller.go @@ -35,11 +35,6 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { if msg.Address != m.Address { m.Reset(msg.Address) } - // Event triggered the Generate Modal - case app.ModalEvent: - if msg.Type == app.GenerateModal { - m.Reset(msg.Address) - } case tea.WindowSizeMsg: m.Width = msg.Width m.Height = msg.Height @@ -47,9 +42,7 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { switch msg.String() { case "esc": if m.Step != WaitingStep { - return m, app.EmitModalEvent(app.ModalEvent{ - Type: app.CancelModal, - }) + return m, app.EmitCancelOverlay() } case "s": if m.Step == DurationStep { diff --git a/ui/modals/info/info.go b/ui/modals/info/info.go index 4d6c04d9..901ba650 100644 --- a/ui/modals/info/info.go +++ b/ui/modals/info/info.go @@ -39,18 +39,10 @@ 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) { - case app.ModalEvent: - if msg.Type == app.InfoModal { - m.Prefix = msg.Prefix - m.Participation = msg.Key - m.OfflineControls = msg.Active - } case tea.KeyMsg: switch msg.String() { case "esc": - return m, app.EmitModalEvent(app.ModalEvent{ - Type: app.CancelModal, - }) + return m, app.EmitCloseOverlay() case "d": if !m.OfflineControls { return m, app.EmitShowModal(app.ConfirmModal) diff --git a/ui/modals/transaction/controller.go b/ui/modals/transaction/controller.go index 54c3f599..0d166b74 100644 --- a/ui/modals/transaction/controller.go +++ b/ui/modals/transaction/controller.go @@ -2,6 +2,7 @@ package transaction import ( "encoding/base64" + "github.com/algorandfoundation/nodekit/internal/algod/participation" "github.com/algorand/go-algorand-sdk/v2/types" "github.com/algorandfoundation/algourl/encoder" @@ -10,10 +11,12 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +// Init initializes the ViewModel and returns a command for further processing or side effects. func (m ViewModel) Init() tea.Cmd { return nil } +// Update processes a given message and returns an updated model along with any command to be executed. func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.HandleMessage(msg) } @@ -22,12 +25,16 @@ 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 switch msg := msg.(type) { + // When the link response comes back, display this modal with the updated state + case participation.ShortLinkResponse: + m.Link = &msg + // Ensure the transaction modal is showing + return &m, app.EmitShowModal(app.TransactionModal) + // Handle keystroke interactions like cancel case tea.KeyMsg: switch msg.String() { case "esc": - return &m, app.EmitModalEvent(app.ModalEvent{ - Type: app.CancelModal, - }) + return &m, app.EmitCancelOverlay() case "s": if m.IsQREnabled() { m.ShowLink = !m.ShowLink @@ -45,7 +52,7 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { return &m, cmd } -func (m *ViewModel) Account() *algod.Account { +func (m ViewModel) Account() *algod.Account { if m.Participation == nil || m.State == nil || m.State.Accounts == nil { return nil } @@ -57,12 +64,12 @@ func (m *ViewModel) Account() *algod.Account { return nil } -func (m *ViewModel) IsIncentiveProtocol() bool { +func (m ViewModel) IsIncentiveProtocol() bool { return m.State.Status.LastProtocolVersion == "https://github.com/algorandfoundation/specs/tree/236dcc18c9c507d794813ab768e467ea42d1b4d9" } // Whether the 2A incentive fee should be added -func (m *ViewModel) ShouldAddIncentivesFee() bool { +func (m ViewModel) ShouldAddIncentivesFee() bool { // conditions for 2A fee: // 1) incentives allowed by user: command line flag to disable incentives has not been passed // 2) online keyreg diff --git a/ui/modals/transaction/view.go b/ui/modals/transaction/view.go index 052e19eb..8fd8910d 100644 --- a/ui/modals/transaction/view.go +++ b/ui/modals/transaction/view.go @@ -22,7 +22,10 @@ func (m ViewModel) Title() string { } } func (m ViewModel) BorderColor() string { - return "9" + if m.OfflineControls { + return "9" + } + return "2" } func (m ViewModel) Controls() string { escLegend := style.Red.Render("(esc) go back") diff --git a/ui/modal/controller.go b/ui/overlay/controller.go similarity index 69% rename from ui/modal/controller.go rename to ui/overlay/controller.go index b22bcd66..33efcb9f 100644 --- a/ui/modal/controller.go +++ b/ui/overlay/controller.go @@ -1,11 +1,10 @@ -package modal +package overlay import ( "fmt" "github.com/algorandfoundation/nodekit/internal/algod" "github.com/algorandfoundation/nodekit/internal/algod/participation" "github.com/algorandfoundation/nodekit/ui/app" - "github.com/algorandfoundation/nodekit/ui/modals/generate" "github.com/algorandfoundation/nodekit/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -23,28 +22,17 @@ func (m ViewModel) Init() tea.Cmd { ) } -func boolToInt(input bool) int { - if input { - return 1 - } - return 0 -} - // HandleMessage processes the given message, updates the ViewModel state, and returns any commands to execute. -func (m *ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { +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 participation.ShortLinkResponse: - m.Open = true - m.SetShortLink(msg) - m.SetType(app.TransactionModal) + case app.AccountSelected: + m.Address = msg.Address + // When the state updates + // TODO: refactor to split this up a bit case *algod.StateModel: // Clear the catchup modal if msg.Status.State != algod.FastCatchupState && m.Type == app.ExceptionModal && m.title == "Fast Catchup" { @@ -116,7 +104,7 @@ func (m *ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { // This is the closest thing we have to state, between this and the transaction modal state it works // Set active ensures the offline modal is changed when a corruption happens m.SetActive(false) - if m.infoModal.Prefix == "" && diff.VoteKeyDilution { + if (m.infoModal.Prefix == "" && diff.VoteKeyDilution) || (m.infoModal.Prefix == "" && count <= 4) { m.infoModal.Prefix = "***WARNING***\nRegistered online but keys do not fully match\nCheck your registered keys carefully against the node keys\n\n" if diff.VoteFirstValid { m.infoModal.Prefix = m.infoModal.Prefix + "Mismatched: Vote First Valid\n" @@ -150,103 +138,69 @@ func (m *ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { } } - case app.ModalEvent: - if msg.Type == app.ExceptionModal { - m.Open = true - m.exceptionModal.Message = msg.Err.Error() - m.generateModal.SetStep(generate.AddressStep) - m.SetType(app.ExceptionModal) - } - - if msg.Type == app.InfoModal { - m.generateModal.SetStep(generate.AddressStep) - } - // On closing events - if msg.Type == app.CloseModal { + case app.KeySelectedEvent: + m.Open = true + m.SetKey(msg.Key) + m.SetActive(msg.Active) + m.SetType(app.InfoModal) + // Update or clear the prefix + m.infoModal.Prefix = msg.Prefix + + case app.OverlayEventType: + switch msg { + case app.OverlayEventClose: m.Open = false - m.generateModal.AddressInput.Focus() - } else { - m.Open = true - } - // When something has triggered a cancel - if msg.Type == app.CancelModal { + m.SetType(app.InfoModal) + m.generateModal.Reset("") + case app.OverlayEventCancel: switch m.Type { case app.InfoModal: - m.Open = false + return m, app.EmitCloseOverlay() case app.GenerateModal: - m.Open = false - m.SetType(app.InfoModal) - m.generateModal.SetStep(generate.AddressStep) - m.generateModal.AddressInput.Focus() + return m, app.EmitCloseOverlay() 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.Open = true 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" + case tea.KeyMsg: + if msg.String() == "q" && m.Type != app.GenerateModal && m.Open { + return m, tea.Quit } - // 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), + // 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) } - - // 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) + // Exit early and don't apply twice 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) - } + // Handle all other messages + m.confirmModal, cmd = m.confirmModal.HandleMessage(msg) + cmds = append(cmds, cmd) + m.infoModal, cmd = m.infoModal.HandleMessage(msg) + cmds = append(cmds, cmd) + m.transactionModal, cmd = m.transactionModal.HandleMessage(msg) + cmds = append(cmds, cmd) + m.generateModal, cmd = m.generateModal.HandleMessage(msg) + cmds = append(cmds, cmd) + m.exceptionModal, cmd = m.exceptionModal.HandleMessage(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) diff --git a/ui/modal/interfaces.go b/ui/overlay/interfaces.go similarity index 85% rename from ui/modal/interfaces.go rename to ui/overlay/interfaces.go index ef1bae69..e84dbfeb 100644 --- a/ui/modal/interfaces.go +++ b/ui/overlay/interfaces.go @@ -1,4 +1,4 @@ -package modal +package overlay type Modal interface { Title() string diff --git a/ui/modal/modal_test.go b/ui/overlay/modal_test.go similarity index 57% rename from ui/modal/modal_test.go rename to ui/overlay/modal_test.go index b34078af..a2a28131 100644 --- a/ui/modal/modal_test.go +++ b/ui/overlay/modal_test.go @@ -1,8 +1,9 @@ -package modal +package overlay import ( "bytes" "errors" + "github.com/algorandfoundation/nodekit/internal/algod" "github.com/algorandfoundation/nodekit/internal/test/mock" "github.com/algorandfoundation/nodekit/ui/app" "github.com/algorandfoundation/nodekit/ui/internal/test" @@ -20,7 +21,7 @@ func Test_Snapshot(t *testing.T) { 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.SetAddress("ABC") model.SetType(app.InfoModal) tm := teatest.NewTestModel( t, model, @@ -72,69 +73,16 @@ func Test_Messages(t *testing.T) { 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.KeySelectedEvent{ + Key: nil, + Active: false, }) - - tm.Send(app.ModalEvent{ - Key: nil, - Active: false, + tm.Send(app.AccountSelected(&algod.Account{ Address: "ABC", - Err: nil, - Type: app.CloseModal, + })) + tm.Send(app.KeySelectedEvent{ + Key: nil, + Active: false, }) tm.Send(tea.QuitMsg{}) diff --git a/ui/modal/model.go b/ui/overlay/model.go similarity index 79% rename from ui/modal/model.go rename to ui/overlay/model.go index 809820d8..a5ec21b2 100644 --- a/ui/modal/model.go +++ b/ui/overlay/model.go @@ -1,4 +1,4 @@ -package modal +package overlay import ( "github.com/algorandfoundation/nodekit/api" @@ -39,7 +39,7 @@ type ViewModel struct { transactionModal *transaction.ViewModel confirmModal confirm.ViewModel generateModal generate.ViewModel - exceptionModal *exception.ViewModel + exceptionModal exception.ViewModel // Current Component Data title string @@ -48,12 +48,6 @@ type ViewModel struct { Type app.ModalType } -// SetAddress updates the ViewModel's Address property and synchronizes it with the associated generateModal. -func (m *ViewModel) SetAddress(address string) { - m.Address = address - //m.generateModal.SetAddress(address) -} - // SetKey updates the participation key across infoModal, confirmModal, and transactionModal in the ViewModel. func (m *ViewModel) SetKey(key *api.ParticipationKey) { m.infoModal.Participation = key @@ -65,35 +59,22 @@ func (m *ViewModel) SetKey(key *api.ParticipationKey) { func (m *ViewModel) SetActive(active bool) { m.infoModal.OfflineControls = active m.transactionModal.OfflineControls = active - m.transactionModal.UpdateState() } // SetSuspended sets the suspended state func (m *ViewModel) SetSuspended(sus bool) { m.infoModal.Suspended = sus m.transactionModal.Suspended = sus - m.transactionModal.UpdateState() -} - -func (m *ViewModel) SetShortLink(res participation.ShortLinkResponse) { - m.Link = &res - m.transactionModal.Link = &res } // SetType updates the modal type of the ViewModel and configures its title, controls, and border color accordingly. func (m *ViewModel) SetType(modal app.ModalType) { m.Type = modal - switch modal { - case app.ExceptionModal: - m.title = m.exceptionModal.Title - m.controls = m.exceptionModal.Controls - m.borderColor = m.exceptionModal.BorderColor - } } // New initializes and returns a new ViewModel with the specified parent, open state, and application StateModel. -func New(parent string, open bool, state *algod.StateModel) *ViewModel { - return &ViewModel{ +func New(parent string, open bool, state *algod.StateModel) ViewModel { + return ViewModel{ Parent: parent, Open: open, diff --git a/ui/modal/view.go b/ui/overlay/view.go similarity index 97% rename from ui/modal/view.go rename to ui/overlay/view.go index 63221edd..86872e7b 100644 --- a/ui/modal/view.go +++ b/ui/overlay/view.go @@ -1,4 +1,4 @@ -package modal +package overlay import ( "github.com/algorandfoundation/nodekit/ui/app" diff --git a/ui/pages/accounts/controller.go b/ui/pages/accounts/controller.go index 27f91f5b..8b0267cb 100644 --- a/ui/pages/accounts/controller.go +++ b/ui/pages/accounts/controller.go @@ -26,10 +26,10 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { case "enter": selAcc := m.SelectedAccount() 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, tea.Sequence( + app.EmitAccountSelected(selAcc), + app.EmitShowPage(app.KeysPage), + ) } return m, nil } diff --git a/ui/pages/keys/controller.go b/ui/pages/keys/controller.go index 7dab673b..977ca149 100644 --- a/ui/pages/keys/controller.go +++ b/ui/pages/keys/controller.go @@ -43,11 +43,9 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { selKey, active := m.SelectedKey() if selKey != nil { // 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, app.EmitKeySelectedEvent(app.KeySelectedEvent{ + Key: selKey, + Active: active, }) } return m, nil diff --git a/ui/pages/keys/keys_test.go b/ui/pages/keys/keys_test.go index e0eca968..0b9b365e 100644 --- a/ui/pages/keys/keys_test.go +++ b/ui/pages/keys/keys_test.go @@ -2,7 +2,6 @@ package keys import ( "bytes" - "github.com/algorandfoundation/nodekit/api" "github.com/algorandfoundation/nodekit/internal/test/mock" "github.com/algorandfoundation/nodekit/ui/app" "github.com/algorandfoundation/nodekit/ui/internal/test" @@ -19,7 +18,7 @@ func Test_New(t *testing.T) { if m.Address != "ABC" { t.Errorf("Expected Address to be ABC, got %s", m.Address) } - d, active := m.SelectedKey() + _, active := m.SelectedKey() if active { t.Errorf("Expected to not find a selected key") } @@ -30,26 +29,6 @@ func Test_New(t *testing.T) { 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) { diff --git a/ui/viewport.go b/ui/viewport.go index 70dd2f17..f8ddd4d8 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -4,10 +4,9 @@ import ( "errors" "fmt" - "github.com/algorandfoundation/nodekit/api" "github.com/algorandfoundation/nodekit/internal/algod" "github.com/algorandfoundation/nodekit/ui/app" - "github.com/algorandfoundation/nodekit/ui/modal" + "github.com/algorandfoundation/nodekit/ui/overlay" "github.com/algorandfoundation/nodekit/ui/pages/accounts" "github.com/algorandfoundation/nodekit/ui/pages/keys" tea "github.com/charmbracelet/bubbletea" @@ -29,15 +28,17 @@ type ViewportViewModel struct { accountsPage accounts.ViewModel keysPage keys.ViewModel - outside app.Outside - modal *modal.ViewModel - page app.Page - client api.ClientWithResponsesInterface + modal overlay.ViewModel + page app.Page } -// Init is a no-op +// Init hooks for components func (m ViewportViewModel) Init() tea.Cmd { - return m.modal.Init() + return tea.Batch( + m.modal.Init(), + m.accountsPage.Init(), + m.keysPage.Init(), + ) } // Update Handle the viewport lifecycle @@ -46,50 +47,43 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd tea.Cmd cmds []tea.Cmd ) - // Handle Header Updates + // Handle Header and Modal Updates m.protocol, cmd = m.protocol.HandleMessage(msg) cmds = append(cmds, cmd) m.status, cmd = m.status.HandleMessage(msg) cmds = append(cmds, cmd) switch msg := msg.(type) { - case app.Page: - if msg == app.KeysPage { - m.keysPage.Address = m.accountsPage.SelectedAccount().Address - } - m.page = msg - // When the state updates case *algod.StateModel: m.Data = 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) - m.protocol, cmd = m.protocol.HandleMessage(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) + // When a page message comes, set the current page + case app.Page: + m.page = msg + return m, nil + // When the Participation Key endpoint responds, check for keys remaining + // and navigate back to accounts when te participation key list is empty. case app.DeleteFinished: if len(m.keysPage.Rows()) <= 1 { - cmd = app.EmitShowPage(app.AccountsPage) - cmds = append(cmds, cmd) + cmds = append(cmds, app.EmitShowPage(app.AccountsPage)) } + // Handle navigations between the different pages and modals case tea.KeyMsg: + // When the modal is open, handle controls via the overlay component + if m.modal.Open { + m.modal, cmd = m.modal.HandleMessage(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + + // Otherwise let the viewport have focus on the inputs for the following global controls switch msg.String() { case "g": // Only open modal when it is closed and not syncing - if !m.modal.Open && m.Data.Status.State == algod.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, - }) + if m.Data.Status.State == algod.StableState && m.Data.Metrics.RoundTime > 0 { + return m, tea.Sequence( + app.EmitAccountSelected(m.accountsPage.SelectedAccount()), + app.EmitShowModal(app.GenerateModal), + ) } else if m.Data.Status.State != algod.StableState || m.Data.Metrics.RoundTime == 0 { genErr := errors.New("Please wait until your node is fully synced") m.modal, cmd = m.modal.HandleMessage(genErr) @@ -97,8 +91,8 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } case "left": - // Disable when overlay is active or on Accounts - if m.modal.Open || m.page == app.AccountsPage { + // No more pages to the left + if m.page == app.AccountsPage { return m, nil } // Navigate to the Keys Page @@ -106,40 +100,49 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, app.EmitShowPage(app.AccountsPage) } case "right": - // Disable when overlay is active - if m.modal.Open { + // No more pages to the right + if m.page != app.AccountsPage { return m, nil } - if m.page == app.AccountsPage { - selAcc := m.accountsPage.SelectedAccount() - if selAcc != nil { - m.page = app.KeysPage - return m, app.EmitAccountSelected(*selAcc) - } - return m, nil + + // Navigate to the keys page + selAcc := m.accountsPage.SelectedAccount() + if selAcc != nil { + return m, tea.Sequence(app.EmitAccountSelected(selAcc), app.EmitShowPage(app.KeysPage)) } + + // Nothing to do if there are no accounts return m, nil + + // Exit the application case "q", "ctrl+c": - // Close the app when anything other than generate modal is visible - if !m.modal.Open || (m.modal.Open && m.modal.Type != app.GenerateModal) { - return m, tea.Quit - } + return m, tea.Quit + } + // Pass commands to the pages, depending on which is active + if m.page == app.AccountsPage { + m.accountsPage, cmd = m.accountsPage.HandleMessage(msg) + cmds = append(cmds, cmd) + } + if m.page == app.KeysPage { + m.keysPage, cmd = m.keysPage.HandleMessage(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) + + // Override the page height for the page renders case tea.WindowSizeMsg: + // Handle modal height + m.modal, cmd = m.modal.HandleMessage(msg) + cmds = append(cmds, cmd) + m.TerminalWidth = msg.Width m.TerminalHeight = msg.Height m.PageWidth = msg.Width m.PageHeight = max(0, msg.Height-lipgloss.Height(m.headerView())) - modalMsg := tea.WindowSizeMsg{ - Width: msg.Width, - Height: msg.Height, - } - - m.modal, cmd = m.modal.HandleMessage(modalMsg) - cmds = append(cmds, cmd) - // Custom size message pageMsg := tea.WindowSizeMsg{ Height: m.PageHeight, @@ -157,22 +160,14 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } - // 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 + // Handle all other events + 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...) } @@ -213,7 +208,7 @@ func (m ViewportViewModel) headerView() string { } // NewViewportViewModel handles the construction of the TUI viewport -func NewViewportViewModel(state *algod.StateModel, client api.ClientWithResponsesInterface) (*ViewportViewModel, error) { +func NewViewportViewModel(state *algod.StateModel) (*ViewportViewModel, error) { m := ViewportViewModel{ Data: state, @@ -226,12 +221,9 @@ func NewViewportViewModel(state *algod.StateModel, client api.ClientWithResponse keysPage: keys.New("", state.ParticipationKeys), // Modal - modal: modal.New("", false, state), - outside: app.NewOutside(), + modal: overlay.New("", false, state), // Current Page page: app.AccountsPage, - // RPC client - client: client, } return &m, nil diff --git a/ui/viewport_test.go b/ui/viewport_test.go index 79f738b4..78763f20 100644 --- a/ui/viewport_test.go +++ b/ui/viewport_test.go @@ -16,7 +16,7 @@ func Test_ViewportViewRender(t *testing.T) { client := test.GetClient(false) state := uitest.GetState(client) // Create the Model - m, err := NewViewportViewModel(state, client) + m, err := NewViewportViewModel(state) if err != nil { t.Fatal(err) } @@ -35,8 +35,9 @@ func Test_ViewportViewRender(t *testing.T) { teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3), ) + acc := state.Accounts["ABC"] tm.Send(app.AccountSelected( - state.Accounts["ABC"])) + &acc)) tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("left"),