1package tui
2
3import (
4 "fmt"
5 "sort"
6 "strings"
7
8 "charm.land/bubbles/v2/key"
9 "charm.land/bubbles/v2/list"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "github.com/floatpane/matcha/config"
13 "github.com/floatpane/matcha/fetcher"
14)
15
16const sidebarWidth = 25
17
18var (
19 sidebarStyle = lipgloss.NewStyle().
20 Width(sidebarWidth).
21 BorderStyle(lipgloss.NormalBorder()).
22 BorderRight(true).
23 PaddingRight(1).
24 PaddingLeft(1)
25
26 sidebarTitleStyle = lipgloss.NewStyle().
27 Foreground(lipgloss.Color("42")).
28 Bold(true).
29 PaddingBottom(1)
30
31 folderStyle = lipgloss.NewStyle().
32 PaddingLeft(1).
33 PaddingRight(1)
34
35 activeFolderStyle = lipgloss.NewStyle().
36 PaddingLeft(1).
37 PaddingRight(1).
38 Background(lipgloss.Color("42")).
39 Foreground(lipgloss.Color("#000000")).
40 Bold(true)
41
42 moveOverlayStyle = lipgloss.NewStyle().
43 Border(lipgloss.RoundedBorder()).
44 BorderForeground(lipgloss.Color("#25A065")).
45 Padding(1, 2)
46
47 moveOverlayTitleStyle = lipgloss.NewStyle().
48 Foreground(lipgloss.Color("42")).
49 Bold(true).
50 PaddingBottom(1)
51
52 moveItemStyle = lipgloss.NewStyle().
53 PaddingLeft(1)
54
55 moveSelectedItemStyle = lipgloss.NewStyle().
56 PaddingLeft(1).
57 Foreground(lipgloss.Color("42")).
58 Bold(true)
59
60 inboxPaneStyle = lipgloss.NewStyle().
61 BorderStyle(lipgloss.NormalBorder()).
62 BorderRight(true).
63 PaddingRight(1)
64
65 previewPaneStyle = lipgloss.NewStyle().
66 BorderStyle(lipgloss.NormalBorder()).
67 BorderLeft(true).
68 PaddingLeft(1)
69
70 focusedBorderColor = lipgloss.Color("42")
71 unfocusedBorderColor = lipgloss.Color("240")
72)
73
74type PaneType int
75
76const (
77 FocusInbox PaneType = iota
78 FocusPreview
79)
80
81// FolderInbox combines a folder sidebar with an email list.
82type FolderInbox struct {
83 folders []string
84 activeFolderIdx int
85 currentFolder string
86 inbox *Inbox
87 accounts []config.Account
88 width int
89 height int
90 isLoadingEmails bool
91
92 // Move-to-folder overlay state
93 movingEmail bool
94 moveTargetIdx int
95 moveUID uint32 // Legacy: single UID
96 moveUIDs []uint32 // Batch: multiple UIDs
97 moveAccountID string
98 moveSourceFolder string
99
100 // Split pane state
101 previewPane *EmailView
102 previewedUID uint32
103 previewedAccountID string
104 // previewSearchEmail holds an Email handed in by OpenSplitPreview for hits
105 // that do not live in m.inbox.allEmails (search results across folders).
106 // findEmailByUID falls back to it when allEmails has no match.
107 previewSearchEmail *fetcher.Email
108 focusedPane PaneType
109}
110
111// sortFolders sorts folder names with INBOX always first, then alphabetically.
112func sortFolders(folders []string) []string {
113 sorted := make([]string, len(folders))
114 copy(sorted, folders)
115 sort.SliceStable(sorted, func(i, j int) bool {
116 iUpper := strings.ToUpper(sorted[i])
117 jUpper := strings.ToUpper(sorted[j])
118 if iUpper == "INBOX" {
119 return true
120 }
121 if jUpper == "INBOX" {
122 return false
123 }
124 return sorted[i] < sorted[j]
125 })
126 return sorted
127}
128
129// SetDateFormat propagates the configured date layout to the inner inbox.
130func (m *FolderInbox) SetDateFormat(layout string) {
131 if m.inbox != nil {
132 m.inbox.SetDateFormat(layout)
133 }
134}
135
136// SetDetailedDates propagates the detailed date display toggle.
137func (m *FolderInbox) SetDetailedDates(enabled bool) {
138 if m.inbox != nil {
139 m.inbox.SetDetailedDates(enabled)
140 }
141}
142
143// SetDefaultThreaded propagates the global default threading toggle.
144func (m *FolderInbox) SetDefaultThreaded(v bool) {
145 if m.inbox != nil {
146 m.inbox.SetDefaultThreaded(v)
147 }
148}
149
150// NewFolderInbox creates a new FolderInbox with the given folders and accounts.
151func NewFolderInbox(folders []string, accounts []config.Account) *FolderInbox {
152 folders = sortFolders(folders)
153 currentFolder := "INBOX"
154 if len(folders) > 0 {
155 currentFolder = folders[0]
156 }
157
158 inbox := NewInbox(nil, accounts)
159 inbox.SetFolderName(currentFolder)
160
161 fi := &FolderInbox{
162 folders: folders,
163 activeFolderIdx: 0,
164 currentFolder: currentFolder,
165 inbox: inbox,
166 accounts: accounts,
167 }
168 fi.updateHelpKeys()
169 return fi
170}
171
172func (m *FolderInbox) Init() tea.Cmd {
173 return nil
174}
175
176func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
177 // If move overlay is active, handle its input
178 if m.movingEmail {
179 return m.updateMoveOverlay(msg)
180 }
181
182 switch msg := msg.(type) {
183 case tea.KeyPressMsg:
184 // Don't intercept keys while filtering
185 if m.inbox.list.FilterState() == list.Filtering {
186 break
187 }
188
189 // Don't intercept keys while the inbox search overlay is active.
190 // Otherwise folder-level bindings like "m" (move) would shadow text input.
191 if m.inbox.searchOverlay != nil {
192 break
193 }
194
195 kb := config.Keybinds
196
197 // Route input to preview pane when focused
198 if m.previewPane != nil && m.focusedPane == FocusPreview {
199 s := msg.String()
200 if s != kb.Folder.FocusInbox && s != kb.Folder.FocusPreview && s != kb.Global.Cancel && s != "q" {
201 var cmd tea.Cmd
202 _, cmd = m.previewPane.Update(msg)
203 return m, cmd
204 }
205 }
206
207 switch msg.String() {
208 case kb.Folder.FocusPreview:
209 // Switch focus to preview pane
210 if m.previewPane != nil && m.focusedPane == FocusInbox {
211 m.focusedPane = FocusPreview
212 return m, nil
213 }
214 case kb.Folder.FocusInbox:
215 // Switch focus to inbox pane
216 if m.previewPane != nil && m.focusedPane == FocusPreview {
217 m.focusedPane = FocusInbox
218 return m, nil
219 }
220 case kb.Folder.NextFolder:
221 m.activeFolderIdx++
222 if m.activeFolderIdx >= len(m.folders) {
223 m.activeFolderIdx = 0
224 }
225 return m, m.switchFolder()
226 case kb.Folder.PrevFolder:
227 m.activeFolderIdx--
228 if m.activeFolderIdx < 0 {
229 m.activeFolderIdx = len(m.folders) - 1
230 }
231 return m, m.switchFolder()
232 case kb.Global.Cancel:
233 // Close split preview if open
234 if m.previewPane != nil {
235 m.closeSplitPreview()
236 return m, nil
237 }
238 // Otherwise let inbox handle (or parent)
239 case kb.Folder.Move:
240 // Start move-to-folder flow
241 if m.inbox.visualMode && len(m.inbox.selectedUIDs) > 0 {
242 // Batch move
243 m.movingEmail = true
244 m.moveTargetIdx = 0
245 m.moveUIDs = make([]uint32, len(m.inbox.selectionOrder))
246 copy(m.moveUIDs, m.inbox.selectionOrder)
247 m.moveAccountID = ""
248 for _, acctID := range m.inbox.selectedUIDs {
249 m.moveAccountID = acctID
250 break
251 }
252 m.moveSourceFolder = m.currentFolder
253 return m, nil
254 } else {
255 // Single move
256 selectedItem, ok := m.inbox.list.SelectedItem().(item)
257 if ok {
258 m.movingEmail = true
259 m.moveTargetIdx = 0
260 m.moveUID = selectedItem.uid
261 m.moveUIDs = []uint32{selectedItem.uid}
262 m.moveAccountID = selectedItem.accountID
263 m.moveSourceFolder = m.currentFolder
264 return m, nil
265 }
266 }
267 }
268
269 case tea.WindowSizeMsg:
270 m.width = msg.Width
271 m.height = msg.Height
272 if m.previewPane != nil || m.previewedUID != 0 {
273 // Recalculate pane widths for split mode
274 inboxWidth := m.calculateInboxWidth()
275 previewWidth := m.calculatePreviewWidth()
276 m.inbox.SetSize(inboxWidth-2, msg.Height)
277 if m.previewPane != nil {
278 // Forward resize to EmailView with preview pane dimensions
279 previewMsg := tea.WindowSizeMsg{Width: previewWidth - 2, Height: msg.Height - 2}
280 m.previewPane.Update(previewMsg)
281 }
282 } else {
283 // Original two-pane resize
284 inboxWidth := msg.Width - sidebarWidth - 3
285 if inboxWidth < 20 {
286 inboxWidth = 20
287 }
288 m.inbox.SetSize(inboxWidth, msg.Height)
289 }
290 return m, nil
291
292 case FolderEmailsFetchedMsg:
293 // Ignore stale responses for folders the user has navigated away from
294 if msg.FolderName != m.currentFolder {
295 return m, nil
296 }
297 m.isLoadingEmails = false
298 m.inbox.isFetching = false
299 m.inbox.isRefreshing = false
300 m.inbox.SetEmails(msg.Emails, m.accounts)
301 m.inbox.SetFolderName(msg.FolderName)
302 return m, nil
303
304 case FolderEmailsAppendedMsg:
305 if msg.FolderName != m.currentFolder {
306 return m, nil
307 }
308 m.inbox.isFetching = false
309 m.inbox.list.Title = m.inbox.getTitle()
310 if len(msg.Emails) == 0 {
311 if m.inbox.noMoreByAccount == nil {
312 m.inbox.noMoreByAccount = make(map[string]bool)
313 }
314 m.inbox.noMoreByAccount[msg.AccountID] = true
315 return m, nil
316 }
317 for _, email := range msg.Emails {
318 m.inbox.emailsByAccount[email.AccountID] = append(m.inbox.emailsByAccount[email.AccountID], email)
319 m.inbox.allEmails = append(m.inbox.allEmails, email)
320 }
321 m.inbox.emailCountByAcct[msg.AccountID] = len(m.inbox.emailsByAccount[msg.AccountID])
322 m.inbox.updateList()
323 return m, nil
324
325 case EmailMovedMsg:
326 if msg.Err != nil {
327 // Error handled by main model
328 return m, nil
329 }
330 m.inbox.RemoveEmail(msg.UID, msg.AccountID)
331 // Clear preview if moved email was being previewed
332 if msg.UID == m.previewedUID {
333 m.closeSplitPreview()
334 }
335 return m, nil
336
337 case UpdatePreviewMsg:
338 // Stale update, ignore
339 if msg.UID == m.previewedUID && m.previewPane != nil {
340 return m, nil
341 }
342 m.previewedUID = msg.UID
343 m.previewedAccountID = msg.AccountID
344 // Will trigger fetch in main.go
345 return m, nil
346
347 case PreviewBodyFetchedMsg:
348 // Stale fetch or no preview active
349 if msg.UID != m.previewedUID {
350 return m, nil
351 }
352 if msg.Err != nil {
353 // Show error in preview pane
354 return m, nil
355 }
356 // Find email and create preview
357 email := m.findEmailByUID(msg.UID, msg.AccountID)
358 if email == nil {
359 return m, nil
360 }
361 // Update email with body
362 email.Body = msg.Body
363 email.BodyMIMEType = msg.BodyMIMEType
364 email.Attachments = msg.Attachments
365 // Create preview pane with column offset for image rendering
366 previewWidth := m.calculatePreviewWidth()
367 inboxWidth := m.calculateInboxWidth()
368 colOffset := sidebarWidth + 2 + inboxWidth + 2 // borders + padding
369 m.previewPane = NewEmailViewPreview(*email, previewWidth, m.height, colOffset, false)
370 return m, nil
371 }
372
373 // Forward to inbox
374 var cmd tea.Cmd
375 _, cmd = m.inbox.Update(msg)
376
377 // Intercept FetchMoreEmailsMsg from inbox and convert to folder-aware version
378 if cmd != nil {
379 wrappedCmd := m.wrapInboxCmd(cmd)
380 return m, wrappedCmd
381 }
382
383 return m, cmd
384}
385
386// wrapInboxCmd intercepts messages from the inbox and adds folder context.
387func (m *FolderInbox) wrapInboxCmd(cmd tea.Cmd) tea.Cmd {
388 return func() tea.Msg {
389 msg := cmd()
390 switch inner := msg.(type) {
391 case FetchMoreEmailsMsg:
392 return FetchFolderMoreEmailsMsg{
393 Offset: inner.Offset,
394 AccountID: inner.AccountID,
395 FolderName: m.currentFolder,
396 Limit: inner.Limit,
397 }
398 case RequestRefreshMsg:
399 inner.FolderName = m.currentFolder
400 return inner
401 case SearchRequestedMsg:
402 inner.FolderName = m.currentFolder
403 return inner
404 }
405 return msg
406 }
407}
408
409func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) {
410 kb := config.Keybinds
411 switch msg := msg.(type) {
412 case tea.KeyPressMsg:
413 switch msg.String() {
414 case kb.Global.Cancel:
415 m.movingEmail = false
416 return m, nil
417 case "up", kb.Global.NavUp:
418 m.moveTargetIdx--
419 if m.moveTargetIdx < 0 {
420 m.moveTargetIdx = len(m.moveFolderChoices()) - 1
421 }
422 return m, nil
423 case "down", kb.Global.NavDown:
424 m.moveTargetIdx++
425 choices := m.moveFolderChoices()
426 if m.moveTargetIdx >= len(choices) {
427 m.moveTargetIdx = 0
428 }
429 return m, nil
430 case "enter":
431 choices := m.moveFolderChoices()
432 if len(choices) > 0 && m.moveTargetIdx < len(choices) {
433 destFolder := choices[m.moveTargetIdx]
434 m.movingEmail = false
435
436 if len(m.moveUIDs) > 1 {
437 // Batch move
438 uids := m.moveUIDs
439 m.moveUIDs = nil
440
441 // Exit visual mode in inbox
442 m.inbox.visualMode = false
443 m.inbox.selectedUIDs = make(map[uint32]string)
444 m.inbox.selectionOrder = []uint32{}
445 m.inbox.updateListTitle()
446
447 return m, func() tea.Msg {
448 return BatchMoveEmailsMsg{
449 UIDs: uids,
450 AccountID: m.moveAccountID,
451 SourceFolder: m.moveSourceFolder,
452 DestFolder: destFolder,
453 }
454 }
455 } else {
456 // Single move
457 return m, func() tea.Msg {
458 return MoveEmailToFolderMsg{
459 UID: m.moveUID,
460 AccountID: m.moveAccountID,
461 SourceFolder: m.moveSourceFolder,
462 DestFolder: destFolder,
463 }
464 }
465 }
466 }
467 }
468 }
469 return m, nil
470}
471
472// moveFolderChoices returns all folders except the current one.
473func (m *FolderInbox) moveFolderChoices() []string {
474 var choices []string
475 for _, f := range m.folders {
476 if f != m.currentFolder {
477 choices = append(choices, f)
478 }
479 }
480 return choices
481}
482
483func (m *FolderInbox) switchFolder() tea.Cmd {
484 if m.activeFolderIdx >= 0 && m.activeFolderIdx < len(m.folders) {
485 prevFolder := m.currentFolder
486 m.currentFolder = m.folders[m.activeFolderIdx]
487 m.isLoadingEmails = true
488 m.inbox.SetFolderName(m.currentFolder)
489 // Clear current emails while loading
490 m.inbox.SetEmails(nil, m.accounts)
491 folder := m.currentFolder
492 return func() tea.Msg {
493 return SwitchFolderMsg{FolderName: folder, PreviousFolder: prevFolder}
494 }
495 }
496 return nil
497}
498
499func (m *FolderInbox) View() tea.View {
500 // Render sidebar
501 sidebar := m.renderSidebar()
502
503 var content string
504
505 if m.previewPane != nil {
506 // Three-pane layout: folders | inbox | email preview
507 inboxPane := m.renderInboxPane()
508 previewPane := m.renderPreviewPane()
509 content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, previewPane)
510 } else if m.previewedUID != 0 {
511 // Split pane loading state (body being fetched)
512 inboxPane := m.renderInboxPane()
513 emptyPreview := m.renderEmptyPreview()
514 content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, emptyPreview)
515 } else {
516 // Two-pane layout (original): folders | inbox
517 inboxView := m.inbox.View().Content
518 content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxView)
519 }
520
521 // If move overlay is active, render it on top
522 if m.movingEmail {
523 content = m.renderWithMoveOverlay(content)
524 }
525
526 return tea.NewView(content)
527}
528
529func (m *FolderInbox) renderSidebar() string {
530 var b strings.Builder
531
532 // Account name as title
533 title := t("folder_inbox.folders_title")
534 if len(m.accounts) > 0 {
535 acc := m.accounts[0]
536 if acc.Name != "" {
537 title = acc.Name
538 } else if acc.FetchEmail != "" {
539 title = acc.FetchEmail
540 }
541 }
542 b.WriteString(sidebarTitleStyle.Render(title))
543 b.WriteString("\n")
544
545 for i, folder := range m.folders {
546 displayName := m.formatFolderName(folder)
547 if i == m.activeFolderIdx {
548 b.WriteString(activeFolderStyle.Width(sidebarWidth - 4).Render(displayName))
549 } else {
550 b.WriteString(folderStyle.Render(displayName))
551 }
552 if i < len(m.folders)-1 {
553 b.WriteString("\n")
554 }
555 }
556
557 sidebarHeight := m.height
558 if sidebarHeight < 1 {
559 sidebarHeight = 20
560 }
561
562 return sidebarStyle.Height(sidebarHeight - 2).Render(b.String())
563}
564
565// formatFolderName makes IMAP folder names more readable.
566func (m *FolderInbox) formatFolderName(name string) string {
567 // Strip common IMAP prefixes for cleaner display
568 name = strings.TrimPrefix(name, "[Gmail]/")
569 name = strings.TrimPrefix(name, "[Google Mail]/")
570 // Truncate to fit sidebar
571 maxLen := sidebarWidth - 5
572 if len(name) > maxLen {
573 name = name[:maxLen-1] + "\u2026"
574 }
575 return name
576}
577
578func (m *FolderInbox) renderWithMoveOverlay(content string) string {
579 choices := m.moveFolderChoices()
580 if len(choices) == 0 {
581 return content
582 }
583
584 var b strings.Builder
585 title := t("folder_inbox.move_to_folder")
586 if len(m.moveUIDs) > 1 {
587 title = tn("folder_inbox.move_multiple", len(m.moveUIDs), map[string]interface{}{
588 "count": len(m.moveUIDs),
589 })
590 }
591 b.WriteString(moveOverlayTitleStyle.Render(title))
592 b.WriteString("\n")
593
594 for i, folder := range choices {
595 displayName := m.formatFolderName(folder)
596 if i == m.moveTargetIdx {
597 b.WriteString(moveSelectedItemStyle.Render("> " + displayName))
598 } else {
599 b.WriteString(moveItemStyle.Render(" " + displayName))
600 }
601 if i < len(choices)-1 {
602 b.WriteString("\n")
603 }
604 }
605
606 b.WriteString("\n\n")
607 b.WriteString(helpStyle.Render(t("folder_inbox.help")))
608
609 overlay := moveOverlayStyle.Render(b.String())
610
611 // Place overlay in the center of content
612 contentLines := strings.Split(content, "\n")
613 overlayLines := strings.Split(overlay, "\n")
614 contentHeight := len(contentLines)
615 overlayHeight := len(overlayLines)
616 overlayWidth := lipgloss.Width(overlay)
617
618 startRow := (contentHeight - overlayHeight) / 2
619 if startRow < 0 {
620 startRow = 0
621 }
622 startCol := (m.width - overlayWidth) / 2
623 if startCol < 0 {
624 startCol = 0
625 }
626
627 // Overlay the box on top of the content
628 for i, overlayLine := range overlayLines {
629 row := startRow + i
630 if row >= len(contentLines) {
631 break
632 }
633 line := contentLines[row]
634 lineWidth := lipgloss.Width(line)
635
636 // Build the new line: prefix + overlay + suffix
637 if startCol >= lineWidth {
638 contentLines[row] = line + strings.Repeat(" ", startCol-lineWidth) + overlayLine
639 } else {
640 // We need to place the overlay at startCol
641 // Due to ANSI escape codes, we can't simply slice the string
642 // Instead, place the overlay line padded to the left
643 contentLines[row] = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, overlayLine)
644 }
645 }
646
647 return strings.Join(contentLines, "\n")
648}
649
650// SetFolders updates the folder list.
651func (m *FolderInbox) SetFolders(folders []string) {
652 m.folders = sortFolders(folders)
653 // Keep current folder if it still exists (search sorted list)
654 found := false
655 for i, f := range m.folders {
656 if f == m.currentFolder {
657 m.activeFolderIdx = i
658 found = true
659 break
660 }
661 }
662 if !found && len(m.folders) > 0 {
663 m.activeFolderIdx = 0
664 m.currentFolder = m.folders[0]
665 }
666}
667
668// SetEmails updates the inbox emails.
669func (m *FolderInbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
670 m.accounts = accounts
671 m.inbox.SetEmails(emails, accounts)
672}
673
674// GetCurrentFolder returns the currently selected folder name.
675func (m *FolderInbox) GetCurrentFolder() string {
676 return m.currentFolder
677}
678
679// GetInbox returns the embedded inbox.
680func (m *FolderInbox) GetInbox() *Inbox {
681 return m.inbox
682}
683
684// GetAccounts returns the accounts.
685func (m *FolderInbox) GetAccounts() []config.Account {
686 return m.accounts
687}
688
689// RemoveEmail removes an email from the embedded inbox.
690func (m *FolderInbox) RemoveEmail(uid uint32, accountID string) {
691 m.inbox.RemoveEmail(uid, accountID)
692}
693
694// updateHelpKeys refreshes the inbox help keys based on preview state
695func (m *FolderInbox) updateHelpKeys() {
696 bindings := []key.Binding{
697 key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")),
698 key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")),
699 key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move")),
700 }
701 if m.previewPane != nil || m.previewedUID != 0 {
702 bindings = append(bindings,
703 key.NewBinding(key.WithKeys("]"), key.WithHelp("]/[", "switch pane")),
704 key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close preview")),
705 )
706 }
707 m.inbox.extraShortHelpKeys = bindings
708}
709
710// SetLoadingEmails sets the loading state.
711func (m *FolderInbox) SetLoadingEmails(loading bool) {
712 m.isLoadingEmails = loading
713 if loading {
714 m.inbox.isFetching = true
715 } else {
716 m.inbox.isFetching = false
717 }
718 m.inbox.list.Title = m.inbox.getTitle()
719}
720
721// SetRefreshing sets the refreshing state (used when user presses "r").
722func (m *FolderInbox) SetRefreshing(refreshing bool) {
723 m.inbox.isRefreshing = refreshing
724 m.inbox.list.Title = m.inbox.getTitle()
725}
726
727// GetFolders returns the current folder list.
728func (m *FolderInbox) GetFolders() []string {
729 return m.folders
730}
731
732// Helper to get the formatted inbox title
733func folderInboxTitle(folder string) string {
734 return fmt.Sprintf("Folder: %s", folder)
735}
736
737// renderInboxPane renders inbox with border for split pane mode
738func (m *FolderInbox) renderInboxPane() string {
739 inboxWidth := m.calculateInboxWidth()
740
741 borderColor := unfocusedBorderColor
742 if m.focusedPane == FocusInbox {
743 borderColor = focusedBorderColor
744 }
745
746 paneStyle := inboxPaneStyle.
747 BorderForeground(borderColor).
748 Width(inboxWidth).
749 Height(m.height)
750
751 m.inbox.SetSize(inboxWidth-2, m.height)
752 return paneStyle.Render(m.inbox.View().Content)
753}
754
755// renderPreviewPane renders email preview with border
756func (m *FolderInbox) renderPreviewPane() string {
757 if m.previewPane == nil {
758 return m.renderEmptyPreview()
759 }
760
761 previewWidth := m.calculatePreviewWidth()
762
763 borderColor := unfocusedBorderColor
764 if m.focusedPane == FocusPreview {
765 borderColor = focusedBorderColor
766 }
767
768 paneStyle := previewPaneStyle.
769 BorderForeground(borderColor).
770 Width(previewWidth).
771 Height(m.height)
772
773 return paneStyle.Render(m.previewPane.View().Content)
774}
775
776// renderEmptyPreview renders placeholder when no email selected
777func (m *FolderInbox) renderEmptyPreview() string {
778 previewWidth := m.calculatePreviewWidth()
779
780 emptyStyle := lipgloss.NewStyle().
781 Width(previewWidth).
782 Height(m.height).
783 Align(lipgloss.Center, lipgloss.Center).
784 Foreground(lipgloss.Color("240"))
785
786 return emptyStyle.Render("Loading...")
787}
788
789// OpenSplitPreview opens the split preview pane for a specific email.
790// email may be non-nil for hits coming from search results (which are not in
791// m.inbox.allEmails); when set, it is used as a fallback by findEmailByUID
792// so the preview can render without a follow-up lookup.
793func (m *FolderInbox) OpenSplitPreview(uid uint32, accountID string, email *fetcher.Email) {
794 m.previewPane = nil // Will be created when body arrives
795 m.previewedUID = uid
796 m.previewedAccountID = accountID
797 m.previewSearchEmail = email
798 m.focusedPane = FocusPreview
799 // Recalculate inbox width for split mode
800 inboxWidth := m.calculateInboxWidth()
801 m.inbox.SetSize(inboxWidth-2, m.height)
802 m.updateHelpKeys()
803}
804
805// closeSplitPreview closes the preview pane and returns to inbox-only
806func (m *FolderInbox) closeSplitPreview() {
807 ClearKittyGraphics()
808 m.previewPane = nil
809 m.previewedUID = 0
810 m.previewedAccountID = ""
811 m.previewSearchEmail = nil
812 m.focusedPane = FocusInbox
813 // Restore full inbox width
814 inboxWidth := m.width - sidebarWidth - 3
815 if inboxWidth < 20 {
816 inboxWidth = 20
817 }
818 m.inbox.SetSize(inboxWidth, m.height)
819 m.updateHelpKeys()
820}
821
822// findEmailByUID finds email in inbox by UID and account ID. Falls back to
823// the email handed in by OpenSplitPreview so search hits that are not in
824// allEmails (cross-folder or uncached) still render in the preview pane.
825func (m *FolderInbox) findEmailByUID(uid uint32, accountID string) *fetcher.Email {
826 for i := range m.inbox.allEmails {
827 if m.inbox.allEmails[i].UID == uid && m.inbox.allEmails[i].AccountID == accountID {
828 return &m.inbox.allEmails[i]
829 }
830 }
831 if m.previewSearchEmail != nil &&
832 m.previewSearchEmail.UID == uid &&
833 m.previewSearchEmail.AccountID == accountID {
834 return m.previewSearchEmail
835 }
836 return nil
837}
838
839// calculatePreviewWidth calculates width for preview pane
840func (m *FolderInbox) calculatePreviewWidth() int {
841 remainingWidth := m.width - sidebarWidth - 4 // 4 for borders
842 inboxWidth := int(float64(remainingWidth) * 0.4)
843 if inboxWidth < 30 {
844 inboxWidth = 30
845 }
846 previewWidth := remainingWidth - inboxWidth
847 if previewWidth < 40 {
848 previewWidth = 40
849 }
850 return previewWidth
851}
852
853// calculateInboxWidth calculates width for inbox pane in split mode
854func (m *FolderInbox) calculateInboxWidth() int {
855 remainingWidth := m.width - sidebarWidth - 4
856 inboxWidth := int(float64(remainingWidth) * 0.4)
857 if inboxWidth < 30 {
858 inboxWidth = 30
859 }
860 return inboxWidth
861}