feat: TUI added

drew created

Change summary

go.mod  |  11 +-
main.go | 234 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 240 insertions(+), 5 deletions(-)

Detailed changes

go.mod 🔗

@@ -3,13 +3,14 @@ module github.com/andrinoff/email-cli
 go 1.24.5
 
 require (
+	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/charmbracelet/bubbles v0.21.0 // indirect
 	github.com/charmbracelet/bubbletea v1.3.6 // indirect
-	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+	github.com/charmbracelet/colorprofile v0.3.1 // indirect
 	github.com/charmbracelet/lipgloss v1.1.0 // indirect
 	github.com/charmbracelet/x/ansi v0.9.3 // indirect
-	github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
 	github.com/charmbracelet/x/term v0.2.1 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -24,7 +25,7 @@ require (
 	github.com/spf13/cobra v1.9.1 // indirect
 	github.com/spf13/pflag v1.0.7 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
-	golang.org/x/sync v0.15.0 // indirect
-	golang.org/x/sys v0.33.0 // indirect
-	golang.org/x/text v0.3.8 // indirect
+	golang.org/x/sync v0.16.0 // indirect
+	golang.org/x/sys v0.34.0 // indirect
+	golang.org/x/text v0.27.0 // indirect
 )

main.go 🔗

@@ -0,0 +1,234 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"os"
+
+	"github.com/charmbracelet/bubbles/textarea"
+	"github.com/charmbracelet/bubbles/textinput"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+// --- STYLES ---
+// Define styles for different parts of the UI using lipgloss.
+var (
+	// Style for the main container/dialog box.
+	dialogBoxStyle = lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(lipgloss.Color("#874BFD")).
+		Padding(1, 0).
+		BorderTop(true).
+		BorderLeft(true).
+		BorderRight(true).
+		BorderBottom(true)
+
+	// Style for the text labels (e.g., "To:", "Subject:").
+	labelStyle = lipgloss.NewStyle().Padding(0, 1)
+
+	// Style for the help text at the bottom.
+	helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
+
+	// Style for success messages.
+	successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
+
+	// Style for error/sending messages.
+	infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
+)
+
+// --- MODEL ---
+// The model represents the state of our TUI application.
+type model struct {
+	// Inputs for the email fields.
+	recipientInput textinput.Model
+	subjectInput   textinput.Model
+	bodyArea       textarea.Model
+
+	// index tracks which input is currently focused.
+	// 0: recipient, 1: subject, 2: body
+	index int
+
+	// State flags
+	sending bool // Is the email currently being "sent"?
+	sent    bool // Has the email been "sent"?
+	err     error // Any error that occurred.
+}
+
+// initialModel creates the starting state of the application.
+func initialModel() model {
+	// --- Recipient Input ---
+	ti := textinput.New()
+	ti.Placeholder = "recipient@example.com"
+	ti.Focus() // Start with the recipient field focused.
+	ti.CharLimit = 156
+	ti.Width = 50
+	ti.Prompt = "To:      "
+
+	// --- Subject Input ---
+	si := textinput.New()
+	si.Placeholder = "Hello there!"
+	si.CharLimit = 156
+	si.Width = 50
+	si.Prompt = "Subject: "
+
+	// --- Body Text Area ---
+	ta := textarea.New()
+	ta.Placeholder = "Dear friend, ..."
+	ta.SetWidth(50)
+	ta.SetHeight(10)
+
+	return model{
+		recipientInput: ti,
+		subjectInput:   si,
+		bodyArea:       ta,
+		index:          0,
+		err:            nil,
+	}
+}
+
+// --- BUBBLETEA METHODS ---
+
+// Init is the first function that will be called. It returns a command.
+// We'll use it to make the cursor blink.
+func (m model) Init() tea.Cmd {
+	return textinput.Blink
+}
+
+// Update handles all user input and updates the model accordingly.
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
+	var cmds []tea.Cmd
+
+	switch msg := msg.(type) {
+	// Handle key presses
+	case tea.KeyMsg:
+		switch msg.Type {
+		// Quit the application
+		case tea.KeyCtrlC, tea.KeyEsc:
+			return m, tea.Quit
+
+		// Trigger the "send" action
+		case tea.KeyCtrlS:
+			// If we are not already sending, start the sending process.
+			if !m.sending {
+				m.sending = true
+				// In a real app, this is where you'd send the email.
+				// We'll return a command that simulates a network operation.
+				// For now, we'll just pretend it was successful after a short delay.
+				return m, func() tea.Msg {
+					// time.Sleep(time.Second * 2) // Simulate network delay
+					return "sent" // Send a message back on success
+				}
+			}
+
+		// Handle Tab and Shift+Tab to navigate between fields
+		case tea.KeyTab, tea.KeyShiftTab:
+			if msg.Type == tea.KeyTab {
+				m.index = (m.index + 1) % 3
+			} else {
+				m.index--
+				if m.index < 0 {
+					m.index = 2
+				}
+			}
+
+			// Blur all inputs
+			m.recipientInput.Blur()
+			m.subjectInput.Blur()
+			m.bodyArea.Blur()
+
+			// Focus the correct input based on the index
+			switch m.index {
+			case 0:
+				m.recipientInput.Focus()
+			case 1:
+				m.subjectInput.Focus()
+			case 2:
+				m.bodyArea.Focus()
+			}
+			return m, nil
+		}
+
+	// Handle the "sent" message from our simulated send command
+	case string:
+		if msg == "sent" {
+			m.sending = false
+			m.sent = true
+			// Quit after a short delay to show the success message.
+			return m, tea.Sequence(
+				func() tea.Msg {
+					// time.Sleep(time.Second)
+					return nil
+				},
+				tea.Quit,
+			)
+		}
+	}
+
+	// Pass the message to the focused input field for handling.
+	switch m.index {
+	case 0:
+		m.recipientInput, cmd = m.recipientInput.Update(msg)
+	case 1:
+		m.subjectInput, cmd = m.subjectInput.Update(msg)
+	case 2:
+		m.bodyArea, cmd = m.bodyArea.Update(msg)
+	}
+	cmds = append(cmds, cmd)
+
+	return m, tea.Batch(cmds...)
+}
+
+// View renders the UI based on the current model state.
+func (m model) View() string {
+	// If the email was sent, just show a success message.
+	if m.sent {
+		return successStyle.Render("\n   ✓ Email sent successfully! \n\n")
+	}
+
+	// If we are "sending", show an info message.
+	if m.sending {
+		return infoStyle.Render("\n   Sending email... \n\n")
+	}
+
+	// Otherwise, render the form.
+	var s string
+
+	s += "\n"
+	s += m.recipientInput.View() + "\n"
+	s += m.subjectInput.View() + "\n"
+	s += m.bodyArea.View()
+
+	help := fmt.Sprintf("\n\n %s | %s | %s | %s \n",
+		"Tab: Next Field", "Esc: Quit", "Ctrl+S: Send", helpStyle.Render("Focus: "+m.focusString()))
+	s += helpStyle.Render(help)
+
+	return dialogBoxStyle.Render(s)
+}
+
+// Helper function to show which field is focused.
+func (m model) focusString() string {
+	switch m.index {
+	case 0:
+		return "Recipient"
+	case 1:
+		return "Subject"
+	case 2:
+		return "Body"
+	default:
+		return ""
+	}
+}
+
+// --- MAIN FUNCTION ---
+func main() {
+	// Initialize the Bubble Tea program.
+	p := tea.NewProgram(initialModel())
+
+	// Run the program and handle any errors.
+	if _, err := p.Run(); err != nil {
+		log.Fatalf("Alas, there's been an error: %v", err)
+		os.Exit(1)
+	}
+}