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