composer.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  7	"github.com/charmbracelet/bubbles/textarea"
  8	"github.com/charmbracelet/bubbles/textinput"
  9	tea "github.com/charmbracelet/bubbletea"
 10	"github.com/charmbracelet/lipgloss"
 11)
 12
 13// Styles for the UI
 14var (
 15	focusedStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
 16	blurredStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 17	cursorStyle         = focusedStyle.Copy()
 18	noStyle             = lipgloss.NewStyle()
 19	helpStyle           = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
 20	focusedButton       = focusedStyle.Copy().Render("[ Send ]")
 21	blurredButton       = blurredStyle.Copy().Render("[ Send ]")
 22	emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
 23	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240")) // This was the missing style
 24)
 25
 26// Composer model holds the state of the email composition UI.
 27type Composer struct {
 28	focusIndex     int
 29	toInput        textinput.Model
 30	subjectInput   textinput.Model
 31	bodyInput      textarea.Model
 32	attachmentPath string
 33	fromAddr       string
 34	width          int
 35	height         int
 36	confirmingExit bool
 37}
 38
 39// NewComposer initializes a new composer model.
 40func NewComposer(from, to, subject, body string) *Composer {
 41	m := &Composer{fromAddr: from}
 42
 43	m.toInput = textinput.New()
 44	m.toInput.Cursor.Style = cursorStyle
 45	m.toInput.Placeholder = "To"
 46	m.toInput.SetValue(to)
 47	m.toInput.Focus()
 48	m.toInput.Prompt = "> "
 49	m.toInput.CharLimit = 256
 50
 51	m.subjectInput = textinput.New()
 52	m.subjectInput.Cursor.Style = cursorStyle
 53	m.subjectInput.Placeholder = "Subject"
 54	m.subjectInput.SetValue(subject)
 55	m.subjectInput.Prompt = "> "
 56	m.subjectInput.CharLimit = 256
 57
 58	m.bodyInput = textarea.New()
 59	m.bodyInput.Cursor.Style = cursorStyle
 60	m.bodyInput.Placeholder = "Body (Markdown supported)..."
 61	m.bodyInput.SetValue(body)
 62	m.bodyInput.Prompt = "> "
 63	m.bodyInput.SetHeight(10)
 64	m.bodyInput.SetCursor(0)
 65
 66	return m
 67}
 68
 69// ResetConfirmation ensures a restored draft isn't stuck in the exit prompt.
 70func (m *Composer) ResetConfirmation() {
 71	m.confirmingExit = false
 72}
 73
 74func (m *Composer) Init() tea.Cmd {
 75	return textinput.Blink
 76}
 77
 78func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 79	var cmds []tea.Cmd
 80	var cmd tea.Cmd
 81
 82	switch msg := msg.(type) {
 83	case tea.WindowSizeMsg:
 84		m.width = msg.Width
 85		m.height = msg.Height
 86		inputWidth := msg.Width - 6
 87		m.toInput.Width = inputWidth
 88		m.subjectInput.Width = inputWidth
 89		m.bodyInput.SetWidth(inputWidth)
 90
 91	case SetComposerCursorToStartMsg:
 92		m.bodyInput.SetCursor(0)
 93		return m, nil
 94
 95	case FileSelectedMsg:
 96		m.attachmentPath = msg.Path
 97		return m, nil
 98
 99	case tea.KeyMsg:
100		if m.confirmingExit {
101			switch msg.String() {
102			case "y", "Y":
103				return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
104			case "n", "N", "esc":
105				m.confirmingExit = false
106				return m, nil
107			default:
108				return m, nil
109			}
110		}
111
112		switch msg.Type {
113		case tea.KeyCtrlC:
114			return m, tea.Quit
115		case tea.KeyEsc:
116			m.confirmingExit = true
117			return m, nil
118
119		case tea.KeyTab, tea.KeyShiftTab:
120			if msg.Type == tea.KeyShiftTab {
121				m.focusIndex--
122			} else {
123				m.focusIndex++
124			}
125
126			if m.focusIndex > 4 {
127				m.focusIndex = 0
128			} else if m.focusIndex < 0 {
129				m.focusIndex = 4
130			}
131
132			m.toInput.Blur()
133			m.subjectInput.Blur()
134			m.bodyInput.Blur()
135
136			switch m.focusIndex {
137			case 0:
138				cmds = append(cmds, m.toInput.Focus())
139			case 1:
140				cmds = append(cmds, m.subjectInput.Focus())
141			case 2:
142				cmds = append(cmds, m.bodyInput.Focus())
143				cmds = append(cmds, func() tea.Msg { return SetComposerCursorToStartMsg{} })
144			}
145			return m, tea.Batch(cmds...)
146
147		case tea.KeyEnter:
148			if m.focusIndex == 3 {
149				return m, func() tea.Msg { return GoToFilePickerMsg{} }
150			}
151			if m.focusIndex == 4 {
152				return m, func() tea.Msg {
153					return SendEmailMsg{
154						To:             m.toInput.Value(),
155						Subject:        m.subjectInput.Value(),
156						Body:           m.bodyInput.Value(),
157						AttachmentPath: m.attachmentPath,
158					}
159				}
160			}
161		}
162	}
163
164	switch m.focusIndex {
165	case 0:
166		m.toInput, cmd = m.toInput.Update(msg)
167		cmds = append(cmds, cmd)
168	case 1:
169		m.subjectInput, cmd = m.subjectInput.Update(msg)
170		cmds = append(cmds, cmd)
171	case 2:
172		m.bodyInput, cmd = m.bodyInput.Update(msg)
173		cmds = append(cmds, cmd)
174	}
175
176	return m, tea.Batch(cmds...)
177}
178
179func (m *Composer) View() string {
180	var composerView strings.Builder
181	var button string
182
183	if m.focusIndex == 4 {
184		button = focusedButton
185	} else {
186		button = blurredButton
187	}
188
189	var attachmentField string
190	attachmentText := "None (Press Enter to select)"
191	if m.attachmentPath != "" {
192		attachmentText = m.attachmentPath
193	}
194
195	if m.focusIndex == 3 {
196		attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText))
197	} else {
198		attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachment: %s", attachmentText))
199	}
200
201	composerView.WriteString(lipgloss.JoinVertical(lipgloss.Left,
202		"Compose New Email",
203		"From: "+emailRecipientStyle.Render(m.fromAddr),
204		m.toInput.View(),
205		m.subjectInput.View(),
206		m.bodyInput.View(),
207		attachmentStyle.Render(attachmentField),
208		button,
209		helpStyle.Render("Markdown/HTML • tab: next field • esc: back to menu"),
210	))
211
212	if m.confirmingExit {
213		dialog := DialogBoxStyle.Render(
214			lipgloss.JoinVertical(lipgloss.Center,
215				"Discard draft?",
216				HelpStyle.Render("\n(y/n)"),
217			),
218		)
219		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
220	}
221
222	return composerView.String()
223}