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