From bad34af0ee6fb6de035f26a3e1e8ea64be518168 Mon Sep 17 00:00:00 2001 From: drew Date: Mon, 28 Jul 2025 12:43:16 +0400 Subject: [PATCH] fix: better UI for composing emails --- tui/composer.go | 171 +++++++++++++++++++++++++++++------------------- 1 file changed, 104 insertions(+), 67 deletions(-) diff --git a/tui/composer.go b/tui/composer.go index fdad8f82dd8dbc7225a7275443eb235ed81a23be..83cdb56291f7cdc25f5f932f53c297fafb01122f 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -1,43 +1,57 @@ package tui import ( + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) +// Styles for the UI +var ( + focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + cursorStyle = focusedStyle.Copy() + noStyle = lipgloss.NewStyle() + helpStyle = blurredStyle.Copy() + focusedButton = focusedStyle.Copy().Render("[ Send ]") + blurredButton = blurredStyle.Copy().Render("[ Send ]") + emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) +) + +// Composer model holds the state of the email composition UI. type Composer struct { focusIndex int - inputs []textinput.Model + toInput textinput.Model + subjectInput textinput.Model + bodyInput textarea.Model fromAddr string } +// NewComposer initializes a new composer model. func NewComposer(from string) Composer { - m := Composer{ - inputs: make([]textinput.Model, 3), - fromAddr: from, - } + m := Composer{fromAddr: from} + + m.toInput = textinput.New() + m.toInput.Cursor.Style = cursorStyle + m.toInput.Placeholder = "To" + m.toInput.Focus() + m.toInput.Prompt = "> " + m.toInput.CharLimit = 256 + + m.subjectInput = textinput.New() + m.subjectInput.Cursor.Style = cursorStyle + m.subjectInput.Placeholder = "Subject" + m.subjectInput.Prompt = "> " + m.subjectInput.CharLimit = 256 + + m.bodyInput = textarea.New() + m.bodyInput.Cursor.Style = cursorStyle + m.bodyInput.Placeholder = "Body..." + m.bodyInput.Prompt = "> " + m.bodyInput.SetHeight(10) + m.bodyInput.SetWidth(60) - var t textinput.Model - for i := range m.inputs { - t = textinput.New() - t.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - t.CharLimit = 0 // no limit - - switch i { - case 0: - t.Placeholder = "To" - t.Focus() - t.Prompt = "> " - case 1: - t.Placeholder = "Subject" - t.Prompt = "> " - case 2: - t.Placeholder = "Body..." - t.Prompt = "> " - } - m.inputs[i] = t - } return m } @@ -46,68 +60,91 @@ func (m Composer) Init() tea.Cmd { } func (m Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + switch msg := msg.(type) { case tea.KeyMsg: - s := msg.String() - switch s { - case "tab", "shift+tab", "enter", "up", "down": - if s == "enter" { - // If we're on the last input, send the email - if m.focusIndex == len(m.inputs)-1 { - return m, func() tea.Msg { - return SendEmailMsg{ - To: m.inputs[0].Value(), - Subject: m.inputs[1].Value(), - Body: m.inputs[2].Value(), - } - } - } - } + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit - // Cycle focus - if s == "up" || s == "shift+tab" { + // Handle Tab and Shift+Tab to cycle focus between inputs. + case tea.KeyTab, tea.KeyShiftTab: + if msg.Type == tea.KeyShiftTab { m.focusIndex-- } else { m.focusIndex++ } - if m.focusIndex > len(m.inputs)-1 { + // Wrap around + if m.focusIndex > 3 { // 3 is the Send button m.focusIndex = 0 } else if m.focusIndex < 0 { - m.focusIndex = len(m.inputs) - 1 + m.focusIndex = 3 } + + // Blur all inputs + m.toInput.Blur() + m.subjectInput.Blur() + m.bodyInput.Blur() - cmds := make([]tea.Cmd, len(m.inputs)) - for i := 0; i < len(m.inputs); i++ { - if i == m.focusIndex { - cmds[i] = m.inputs[i].Focus() - } else { - m.inputs[i].Blur() - } + // Focus the correct input + switch m.focusIndex { + case 0: + m.toInput.Focus() + case 1: + m.subjectInput.Focus() + case 2: + m.bodyInput.Focus() } return m, tea.Batch(cmds...) + + // Handle Enter key. + case tea.KeyEnter: + // If on the Send button, send the email. + if m.focusIndex == 3 { + return m, func() tea.Msg { + return SendEmailMsg{ + To: m.toInput.Value(), + Subject: m.subjectInput.Value(), + Body: m.bodyInput.Value(), + } + } + } } } - // Update the focused input - cmd := m.updateInputs(msg) - return m, cmd -} - -func (m *Composer) updateInputs(msg tea.Msg) tea.Cmd { - var cmds = make([]tea.Cmd, len(m.inputs)) - for i := range m.inputs { - m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + // Update the focused input. + switch m.focusIndex { + case 0: + m.toInput, cmd = m.toInput.Update(msg) + cmds = append(cmds, cmd) + case 1: + m.subjectInput, cmd = m.subjectInput.Update(msg) + cmds = append(cmds, cmd) + case 2: + m.bodyInput, cmd = m.bodyInput.Update(msg) + cmds = append(cmds, cmd) } - return tea.Batch(cmds...) + + return m, tea.Batch(cmds...) } +// View renders the UI. func (m Composer) View() string { - return lipgloss.JoinVertical( - lipgloss.Left, - "Compose Email (Press Enter on Body to Send)", - m.inputs[0].View(), - m.inputs[1].View(), - m.inputs[2].View(), + button := &blurredButton + if m.focusIndex == 3 { + button = &focusedButton + } + + return lipgloss.JoinVertical(lipgloss.Left, + "Compose New Email", + "From: "+emailRecipientStyle.Render(m.fromAddr), + m.toInput.View(), + m.subjectInput.View(), + m.bodyInput.View(), + *button, + helpStyle.Render("tab: next field • esc: quit"), ) } \ No newline at end of file