diff --git a/cmd/sonrd/main.go b/cmd/sonrd/main.go index 414f57b03..8b78410a0 100644 --- a/cmd/sonrd/main.go +++ b/cmd/sonrd/main.go @@ -14,6 +14,7 @@ import ( func main() { rootCmd := NewRootCmd() rootCmd.AddCommand(tui.NewBuildProtoMsgCmd()) + // rootCmd.AddCommand(tui.NewExplorerCmd()) if err := svrcmd.Execute(rootCmd, "", app.DefaultNodeHome); err != nil { log.NewLogger(rootCmd.OutOrStderr()).Error("failure when running app", "err", err) diff --git a/internal/tui/explorer.go b/internal/tui/explorer.go new file mode 100644 index 000000000..e19b0faa5 --- /dev/null +++ b/internal/tui/explorer.go @@ -0,0 +1,173 @@ +package tui + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" +) + +var ( + subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} + highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} + special = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"} + + titleStyle = lipgloss.NewStyle(). + MarginLeft(1). + MarginRight(5). + Padding(0, 1). + Italic(true). + Foreground(lipgloss.Color("#FFF7DB")). + SetString("Cosmos Block Explorer") + + infoStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderTop(true). + BorderForeground(subtle) +) + +type model struct { + blocks []string + transactionTable table.Model + stats map[string]string + width int + height int +} + +func initialModel() model { + columns := []table.Column{ + {Title: "Hash", Width: 10}, + {Title: "Type", Width: 15}, + {Title: "Height", Width: 10}, + {Title: "Time", Width: 20}, + } + + rows := []table.Row{ + {"abc123", "Transfer", "1000", time.Now().Format(time.RFC3339)}, + {"def456", "Delegate", "999", time.Now().Add(-1 * time.Minute).Format(time.RFC3339)}, + {"ghi789", "Vote", "998", time.Now().Add(-2 * time.Minute).Format(time.RFC3339)}, + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(7), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + return model{ + blocks: []string{"Block 1", "Block 2", "Block 3"}, + transactionTable: t, + stats: map[string]string{ + "Latest Block": "1000", + "Validators": "100", + "Bonded Tokens": "1,000,000", + }, + } +} + +func (m model) Init() tea.Cmd { + return tick +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "enter": + return m, tea.Batch( + tea.Printf("Selected transaction: %s", m.transactionTable.SelectedRow()[0]), + ) + } + case tea.WindowSizeMsg: + m.height = msg.Height + m.width = msg.Width + case tickMsg: + // Update data here + m.blocks = append([]string{"New Block"}, m.blocks...) + if len(m.blocks) > 5 { + m.blocks = m.blocks[:5] + } + + // Add a new transaction to the table + newRow := table.Row{ + fmt.Sprintf("tx%d", time.Now().Unix()), + "NewTxType", + fmt.Sprintf("%d", 1000+len(m.transactionTable.Rows())), + time.Now().Format(time.RFC3339), + } + m.transactionTable.SetRows(append([]table.Row{newRow}, m.transactionTable.Rows()...)) + if len(m.transactionTable.Rows()) > 10 { + m.transactionTable.SetRows(m.transactionTable.Rows()[:10]) + } + + return m, tick + } + m.transactionTable, cmd = m.transactionTable.Update(msg) + return m, cmd +} + +func (m model) View() string { + s := titleStyle.Render("Cosmos Block Explorer") + s += "\n\n" + + // Blocks + s += lipgloss.NewStyle().Bold(true).Render("Recent Blocks") + "\n" + for _, block := range m.blocks { + s += "• " + block + "\n" + } + s += "\n" + + // Transactions + s += lipgloss.NewStyle().Bold(true).Render("Recent Transactions") + "\n" + s += m.transactionTable.View() + "\n\n" + + // Stats + s += lipgloss.NewStyle().Bold(true).Render("Network Statistics") + "\n" + for key, value := range m.stats { + s += fmt.Sprintf("%s: %s\n", key, value) + } + + return s +} + +type tickMsg time.Time + +func tick() tea.Msg { + time.Sleep(time.Second) + return tickMsg{} +} + +func runExplorer(cmd *cobra.Command, args []string) error { + p := tea.NewProgram(initialModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + return fmt.Errorf("error running explorer: %v", err) + } + return nil +} + +func NewExplorerCmd() *cobra.Command { + return &cobra.Command{ + Use: "cosmos-explorer", + Short: "A terminal-based Cosmos blockchain explorer", + RunE: runExplorer, + } +} diff --git a/internal/tui/forms.go b/internal/tui/forms.go index ab18ff1ed..8e0408b66 100644 --- a/internal/tui/forms.go +++ b/internal/tui/forms.go @@ -6,6 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" "github.com/cosmos/cosmos-sdk/codec" codectypes "github.com/cosmos/cosmos-sdk/codec/types" sdk "github.com/cosmos/cosmos-sdk/types" @@ -14,77 +15,261 @@ import ( "github.com/spf13/cobra" ) +const maxWidth = 100 + +var ( + red = lipgloss.AdaptiveColor{Light: "#FE5F86", Dark: "#FE5F86"} + indigo = lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"} + green = lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"} +) + +type Styles struct { + Base, + HeaderText, + Status, + StatusHeader, + Highlight, + ErrorHeaderText, + Help lipgloss.Style +} + +func NewStyles(lg *lipgloss.Renderer) *Styles { + s := Styles{} + s.Base = lg.NewStyle(). + Padding(1, 2, 0, 1) + s.HeaderText = lg.NewStyle(). + Foreground(indigo). + Bold(true). + Padding(0, 1, 0, 1) + s.Status = lg.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(indigo). + PaddingLeft(1). + MarginTop(1) + s.StatusHeader = lg.NewStyle(). + Foreground(green). + Bold(true) + s.Highlight = lg.NewStyle(). + Foreground(lipgloss.Color("212")) + s.ErrorHeaderText = s.HeaderText. + Foreground(red) + s.Help = lg.NewStyle(). + Foreground(lipgloss.Color("240")) + return &s +} + +type state int + +const ( + statusNormal state = iota + stateDone +) + type Model struct { + state state + lg *lipgloss.Renderer + styles *Styles form *huh.Form + width int message *tx.TxBody } func NewModel() Model { - return Model{ - form: huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Key("from"). - Title("From Address"). - Placeholder("cosmos1..."). - Validate(func(s string) error { - if !strings.HasPrefix(s, "cosmos1") { - return fmt.Errorf("invalid address format") - } - return nil - }), - huh.NewInput(). - Key("to"). - Title("To Address"). - Placeholder("cosmos1..."). - Validate(func(s string) error { - if !strings.HasPrefix(s, "cosmos1") { - return fmt.Errorf("invalid address format") - } - return nil - }), - huh.NewInput(). - Key("amount"). - Title("Amount"). - Placeholder("100"). - Validate(func(s string) error { - if _, err := sdk.ParseCoinNormalized(s + "atom"); err != nil { - return fmt.Errorf("invalid coin amount") - } - return nil - }), - huh.NewSelect[string](). - Key("denom"). - Title("Denom"). - Options(huh.NewOptions("atom", "osmo", "usnr", "snr")...), - huh.NewInput(). - Key("memo"). - Title("Memo"). - Placeholder("Optional"), - ), + m := Model{width: maxWidth} + m.lg = lipgloss.DefaultRenderer() + m.styles = NewStyles(m.lg) + + m.form = huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Key("from"). + Title("From Address"). + Placeholder("cosmos1..."). + Validate(func(s string) error { + if !strings.HasPrefix(s, "cosmos1") { + return fmt.Errorf("invalid address format") + } + return nil + }), + + huh.NewInput(). + Key("to"). + Title("To Address"). + Placeholder("cosmos1..."). + Validate(func(s string) error { + if !strings.HasPrefix(s, "cosmos1") { + return fmt.Errorf("invalid address format") + } + return nil + }), + + huh.NewInput(). + Key("amount"). + Title("Amount"). + Placeholder("100"). + Validate(func(s string) error { + if _, err := sdk.ParseCoinNormalized(s + "atom"); err != nil { + return fmt.Errorf("invalid coin amount") + } + return nil + }), + + huh.NewSelect[string](). + Key("denom"). + Title("Denom"). + Options(huh.NewOptions("atom", "osmo", "usnr", "snr")...), + + huh.NewInput(). + Key("memo"). + Title("Memo"). + Placeholder("Optional"), + + huh.NewConfirm(). + Key("done"). + Title("Ready to convert?"). + Validate(func(v bool) error { + if !v { + return fmt.Errorf("Please confirm when you're ready to convert") + } + return nil + }). + Affirmative("Yes, convert!"). + Negative("Not yet"), ), - } + ). + WithWidth(60). + WithShowHelp(false). + WithShowErrors(false) + + return m } func (m Model) Init() tea.Cmd { return m.form.Init() } +func min(x, y int) int { + if x > y { + return y + } + return x +} + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = min(msg.Width, maxWidth) - m.styles.Base.GetHorizontalFrameSize() + case tea.KeyMsg: + switch msg.String() { + case "esc", "ctrl+c", "q": + return m, tea.Quit + } + } + + var cmds []tea.Cmd + form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f - if m.form.State == huh.StateCompleted { - m.buildMessage() - } + cmds = append(cmds, cmd) } - return m, cmd + + if m.form.State == huh.StateCompleted { + m.buildMessage() + cmds = append(cmds, tea.Quit) + } + + return m, tea.Batch(cmds...) } func (m Model) View() string { - formView := m.form.View() - messageView := m.getMessageView() - return fmt.Sprintf("%s\n\n%s", formView, messageView) + s := m.styles + + switch m.form.State { + case huh.StateCompleted: + pklCode := m.generatePkl() + messageView := m.getMessageView() + var b strings.Builder + fmt.Fprintf(&b, "Final Tx:\n\n%s\n\n%s", pklCode, messageView) + return s.Status.Margin(0, 1).Padding(1, 2).Width(80).Render(b.String()) + "\n\n" + default: + var schemaType string + if m.form.GetString("schemaType") != "" { + schemaType = "Schema Type: " + m.form.GetString("schemaType") + } + + v := strings.TrimSuffix(m.form.View(), "\n\n") + form := m.lg.NewStyle().Margin(1, 0).Render(v) + + var status string + { + preview := "(Preview will appear here)" + if m.form.GetString("schema") != "" { + preview = m.generatePkl() + } + + const statusWidth = 40 + statusMarginLeft := m.width - statusWidth - lipgloss.Width(form) - s.Status.GetMarginRight() + status = s.Status. + Height(lipgloss.Height(form)). + Width(statusWidth). + MarginLeft(statusMarginLeft). + Render(s.StatusHeader.Render("Pkl Preview") + "\n" + + schemaType + "\n\n" + + preview) + } + + errors := m.form.Errors() + header := m.appBoundaryView("Sonr TX Builder") + if len(errors) > 0 { + header = m.appErrorBoundaryView(m.errorView()) + } + body := lipgloss.JoinHorizontal(lipgloss.Top, form, status) + + footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds())) + if len(errors) > 0 { + footer = m.appErrorBoundaryView("") + } + + return s.Base.Render(header + "\n" + body + "\n\n" + footer) + } +} + +func (m Model) errorView() string { + var s string + for _, err := range m.form.Errors() { + s += err.Error() + } + return s +} + +func (m Model) appBoundaryView(text string) string { + return lipgloss.PlaceHorizontal( + m.width, + lipgloss.Left, + m.styles.HeaderText.Render(text), + lipgloss.WithWhitespaceChars("="), + lipgloss.WithWhitespaceForeground(indigo), + ) +} + +func (m Model) appErrorBoundaryView(text string) string { + return lipgloss.PlaceHorizontal( + m.width, + lipgloss.Left, + m.styles.ErrorHeaderText.Render(text), + lipgloss.WithWhitespaceChars("="), + lipgloss.WithWhitespaceForeground(red), + ) +} + +func (m Model) generatePkl() string { + schemaType := m.form.GetString("schemaType") + schema := m.form.GetString("schema") + + // This is a placeholder for the actual conversion logic + // In a real implementation, you would parse the schema and generate Pkl code + return fmt.Sprintf("// Converted from %s\n\nclass ConvertedSchema {\n // TODO: Implement conversion from %s\n // Original schema:\n /*\n%s\n */\n}", schemaType, schemaType, schema) } func (m *Model) buildMessage() { @@ -120,7 +305,7 @@ func (m Model) getMessageView() string { return fmt.Sprintf("Current Message:\n%s", string(jsonBytes)) } -func runTUIForm() (*tx.TxBody, error) { +func RunTUIForm() (*tx.TxBody, error) { m := NewModel() p := tea.NewProgram(m) @@ -139,10 +324,10 @@ func runTUIForm() (*tx.TxBody, error) { func NewBuildProtoMsgCmd() *cobra.Command { return &cobra.Command{ - Use: "build-proto-msg", - Short: "Build a Cosmos SDK protobuf message using a TUI form", + Use: "dash", + Short: "TUI for managing the local Sonr validator node", RunE: func(cmd *cobra.Command, args []string) error { - txBody, err := runTUIForm() + txBody, err := RunTUIForm() if err != nil { return err } diff --git a/pkg/builder/builder.go b/pkg/builder/builder.go new file mode 100644 index 000000000..e576aa4a2 --- /dev/null +++ b/pkg/builder/builder.go @@ -0,0 +1,8 @@ +package builder + +type Builder interface { + Build() error +} + +func New() Builder { +}