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