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("42"))
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("42")).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}