From a4734a1314e652a6b356d5043e1f1aa002a0bc1f Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Sat, 25 Apr 2026 14:03:14 +0400 Subject: [PATCH] feat: split panes (#1006) Co-authored-by: Andriy Chernov Co-authored-by: Lea Co-authored-by: Steve Evans --- config/config.go | 5 + docs/docs/Configuration.md | 3 + docs/docs/Features/SPLIT_VIEW.md | 40 +++++ docs/docs/usage.md | 1 + i18n/locales/ar.json | 1 + i18n/locales/de.json | 1 + i18n/locales/en.json | 1 + i18n/locales/es.json | 1 + i18n/locales/fr.json | 1 + i18n/locales/ja.json | 1 + i18n/locales/pl.json | 1 + i18n/locales/pt.json | 1 + i18n/locales/ru.json | 1 + i18n/locales/uk.json | 1 + i18n/locales/zh.json | 1 + main.go | 105 ++++++++++++ tui/email_view.go | 17 +- tui/folder_inbox.go | 275 ++++++++++++++++++++++++++++--- tui/inbox.go | 30 +++- tui/messages.go | 13 ++ tui/settings_general.go | 10 +- tui/theme.go | 3 +- view/html.go | 11 +- 23 files changed, 491 insertions(+), 33 deletions(-) create mode 100644 docs/docs/Features/SPLIT_VIEW.md diff --git a/config/config.go b/config/config.go index a1edb4d6ee7b1e48353f9b2194fc78fe0266e4a8..fa1bcca954e364719371124609bc357f3ce383ad 100644 --- a/config/config.go +++ b/config/config.go @@ -87,6 +87,7 @@ type Config struct { DisableImages bool `json:"disable_images,omitempty"` HideTips bool `json:"hide_tips,omitempty"` DisableNotifications bool `json:"disable_notifications,omitempty"` + EnableSplitPane bool `json:"enable_split_pane,omitempty"` Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` DateFormat string `json:"date_format,omitempty"` @@ -379,6 +380,7 @@ type secureDiskConfig struct { DisableImages bool `json:"disable_images,omitempty"` HideTips bool `json:"hide_tips,omitempty"` DisableNotifications bool `json:"disable_notifications,omitempty"` + EnableSplitPane bool `json:"enable_split_pane,omitempty"` Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` DateFormat string `json:"date_format,omitempty"` @@ -422,6 +424,7 @@ func SaveConfig(config *Config) error { DisableImages: config.DisableImages, HideTips: config.HideTips, DisableNotifications: config.DisableNotifications, + EnableSplitPane: config.EnableSplitPane, Theme: config.Theme, MailingLists: config.MailingLists, DateFormat: config.DateFormat, @@ -514,6 +517,7 @@ func LoadConfig() (*Config, error) { DisableImages bool `json:"disable_images,omitempty"` HideTips bool `json:"hide_tips,omitempty"` DisableNotifications bool `json:"disable_notifications,omitempty"` + EnableSplitPane bool `json:"enable_split_pane,omitempty"` Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` DateFormat string `json:"date_format,omitempty"` @@ -548,6 +552,7 @@ func LoadConfig() (*Config, error) { config.DisableImages = raw.DisableImages config.HideTips = raw.HideTips config.DisableNotifications = raw.DisableNotifications + config.EnableSplitPane = raw.EnableSplitPane config.Theme = raw.Theme config.MailingLists = raw.MailingLists config.DateFormat = raw.DateFormat diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index 0af390856f8da7903656202dddc92642b02cadde..66c7336effdaf4120242bb17d5a8d7aa6c44a2f0 100644 --- a/docs/docs/Configuration.md +++ b/docs/docs/Configuration.md @@ -43,6 +43,7 @@ Configuration is stored in `~/.config/matcha/config.json`. } ], "theme": "Matcha", + "enable_split_pane": true, "disable_images": true, "hide_tips": true } @@ -50,6 +51,8 @@ Configuration is stored in `~/.config/matcha/config.json`. `send_as_email` is optional. When set, Matcha uses it for the outgoing `From` header while continuing to authenticate with the account's login address. +`enable_split_pane` enables a side-by-side view where the email list and the selected email are shown on the same screen. + ## Data Locations Configuration and persistent data are stored in `~/.config/matcha/`: diff --git a/docs/docs/Features/SPLIT_VIEW.md b/docs/docs/Features/SPLIT_VIEW.md new file mode 100644 index 0000000000000000000000000000000000000000..106e472a4a50309e1d4ac0e07f2e9ed9249fb7b9 --- /dev/null +++ b/docs/docs/Features/SPLIT_VIEW.md @@ -0,0 +1,40 @@ +--- +title: Split View +sidebar_position: 12 +--- + +# Split View + +Matcha includes an optional split pane view that allows you to browse your inbox and read emails side-by-side without having to switch to a separate full-screen email view. + +## Enabling Split View + +You can enable split view in two ways: + +1. **Via the Settings Menu:** + - Open the main menu by pressing `Esc` from the inbox. + - Select **Settings**, then **General**. + - Toggle **View inbox and email side-by-side** to ON. + +2. **Via Configuration File:** + - Open `~/.config/matcha/config.json` in your editor. + - Add `"enable_split_pane": true` to the root configuration. + +## Using Split View + +When split pane is enabled, pressing `Enter` on an email in your inbox will open the email in a preview pane on the right side of the screen, instead of switching to a full-screen view. + +### Keybindings + +When the split preview pane is open, the following keybindings are available: + +| Key | Action | +|-----|--------| +| `]` | Switch focus to the preview pane | +| `[` | Switch focus back to the inbox list | +| `j`/`k` | Scroll the content of the currently focused pane (inbox or preview) | +| `Esc` | Close the split preview pane and return focus to the inbox | + +## Visual Indicators + +The currently focused pane will have a highlighted border, making it clear which side of the split view will receive your keyboard input for scrolling or other actions. diff --git a/docs/docs/usage.md b/docs/docs/usage.md index 47a811ec51e210120989e33ee93b03d499d360f2..79c12b86a0805137911679dab5446f2f385b0bff 100644 --- a/docs/docs/usage.md +++ b/docs/docs/usage.md @@ -29,6 +29,7 @@ On first launch, Matcha will prompt you to configure an email account. You'll ne - `Enter` - Open selected email - `/` - Filter/search emails - `r` - Refresh inbox +- `[` / `]` - Switch focus between inbox and split pane (when split pane is enabled) - `d` - Delete selected email - `a` - Archive selected email - `v` - Enter visual mode (multi-select) diff --git a/i18n/locales/ar.json b/i18n/locales/ar.json index 656f138490677e10e136ec397632e8a55946a67e..4d0e0849698681711d7f891904e8be54414588d7 100644 --- a/i18n/locales/ar.json +++ b/i18n/locales/ar.json @@ -148,6 +148,7 @@ "disable_images": "تعطيل عرض الصور", "hide_tips": "إخفاء النصائح السياقية", "disable_notifications": "تعطيل الإشعارات", + "enable_split_pane": "عرض مقسم", "date_format": "تنسيق التاريخ", "language": "اللغة", "signature": "تعديل التوقيع", diff --git a/i18n/locales/de.json b/i18n/locales/de.json index 6333d2b0ffdc80f24fc165e31c765fa3f487da25..bb8ccc905eb9dc46c857472f6502851a057ec34b 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -144,6 +144,7 @@ "disable_images": "Bildanzeige Deaktivieren", "hide_tips": "Kontextuelle Tipps Ausblenden", "disable_notifications": "Benachrichtigungen Deaktivieren", + "enable_split_pane": "Geteilte Ansicht", "date_format": "Datumsformat", "language": "Sprache", "signature": "Signatur Bearbeiten", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index e05984f4d6231585ae5c25332df39dfa375fae02..5c063a77228f5163387102f4d4d0eee9cbb3388d 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -144,6 +144,7 @@ "disable_images": "Disable Image Display", "hide_tips": "Hide Contextual Tips", "disable_notifications": "Disable Notifications", + "enable_split_pane": "Split Pane View", "date_format": "Date Format", "language": "Language", "signature": "Edit Signature", diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 0dbb7962f6093f64495cad1228b2455b093a1388..842b26388aada89fdac370af888f3c0f432fe887 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -144,6 +144,7 @@ "disable_images": "Deshabilitar Visualización de Imágenes", "hide_tips": "Ocultar Consejos Contextuales", "disable_notifications": "Deshabilitar Notificaciones", + "enable_split_pane": "Vista dividida", "date_format": "Formato de Fecha", "language": "Idioma", "signature": "Editar Firma", diff --git a/i18n/locales/fr.json b/i18n/locales/fr.json index 69d55f11f2eaec750d495a6fd9414838d45c51d1..e64a9345b07cb74a5824e7217def2f8f6187b1df 100644 --- a/i18n/locales/fr.json +++ b/i18n/locales/fr.json @@ -144,6 +144,7 @@ "disable_images": "Désactiver l'Affichage des Images", "hide_tips": "Masquer les Conseils Contextuels", "disable_notifications": "Désactiver les Notifications", + "enable_split_pane": "Vue divisée", "date_format": "Format de Date", "language": "Langue", "signature": "Modifier la Signature", diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index 43914e7240a6d29484311f318655dc78b9ae8801..e8c8b7ee93a2937e715f7c468d2e4ccdc3091821 100644 --- a/i18n/locales/ja.json +++ b/i18n/locales/ja.json @@ -142,6 +142,7 @@ "disable_images": "画像表示を無効化", "hide_tips": "コンテキストヒントを非表示", "disable_notifications": "通知を無効化", + "enable_split_pane": "分割ビュー", "date_format": "日付形式", "language": "言語", "signature": "署名を編集", diff --git a/i18n/locales/pl.json b/i18n/locales/pl.json index e480e6ae9f212d1a5b44b9034ee59793657f71f0..43523b7fc1b4468bd614d98436f7e2f768c6d5a3 100644 --- a/i18n/locales/pl.json +++ b/i18n/locales/pl.json @@ -148,6 +148,7 @@ "disable_images": "Wyłącz Wyświetlanie Obrazów", "hide_tips": "Ukryj Wskazówki Kontekstowe", "disable_notifications": "Wyłącz Powiadomienia", + "enable_split_pane": "Widok podzielony", "date_format": "Format Daty", "language": "Język", "signature": "Edytuj Podpis", diff --git a/i18n/locales/pt.json b/i18n/locales/pt.json index 577df47c4eac33c44381045d9fc4ccddfe19290e..f802963c4e9d371ef4926e4c816f98a23e1ccb6c 100644 --- a/i18n/locales/pt.json +++ b/i18n/locales/pt.json @@ -144,6 +144,7 @@ "disable_images": "Desativar Exibição de Imagens", "hide_tips": "Ocultar Dicas Contextuais", "disable_notifications": "Desativar Notificações", + "enable_split_pane": "Vista dividida", "date_format": "Formato de Data", "language": "Idioma", "signature": "Editar Assinatura", diff --git a/i18n/locales/ru.json b/i18n/locales/ru.json index 4d8b3e62fd70c12c9dc4142b0502762038d70580..7a53b31a69aed13759e490a44565b6f10d657d5f 100644 --- a/i18n/locales/ru.json +++ b/i18n/locales/ru.json @@ -148,6 +148,7 @@ "disable_images": "Отключить Отображение Изображений", "hide_tips": "Скрыть Контекстные Подсказки", "disable_notifications": "Отключить Уведомления", + "enable_split_pane": "Разделённый вид", "date_format": "Формат Даты", "language": "Язык", "signature": "Редактировать Подпись", diff --git a/i18n/locales/uk.json b/i18n/locales/uk.json index 9879b9569a9b16df435d92a48883793b1c63a8e7..797ab9863a6d406484205c1dd6defa6dfc5ce6c2 100644 --- a/i18n/locales/uk.json +++ b/i18n/locales/uk.json @@ -146,6 +146,7 @@ "disable_images": "Вимкнути показ зображень", "hide_tips": "Приховати контекстні підказки", "disable_notifications": "Вимкнути сповіщення", + "enable_split_pane": "Розділений вигляд", "date_format": "Формат дати", "language": "Мова", "signature": "Редагувати підпис", diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index 17ac5c6a26e9ccd1aec9536c1ebb881eb8828dad..c2ccbb1ae55652c1c4e98318c16855eff452dafd 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -142,6 +142,7 @@ "disable_images": "禁用图片显示", "hide_tips": "隐藏上下文提示", "disable_notifications": "禁用通知", + "enable_split_pane": "分屏视图", "date_format": "日期格式", "language": "语言", "signature": "编辑签名", diff --git a/main.go b/main.go index 0d4d6189a9c5287d9862d8bbf580602735f5deb4..33d329e1690511abf16131f085b1f315f3f3d2fb 100644 --- a/main.go +++ b/main.go @@ -689,6 +689,73 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = tui.NewStatus("Moving email...") return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder)) + case tui.UpdatePreviewMsg: + // Trigger preview body fetch + if m.folderInbox == nil { + return m, nil + } + folderName := m.folderInbox.GetCurrentFolder() + // Check cache first + if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil { + var attachments []fetcher.Attachment + for _, ca := range cached.Attachments { + att := fetcher.Attachment{ + Filename: ca.Filename, + PartID: ca.PartID, + Encoding: ca.Encoding, + MIMEType: ca.MIMEType, + ContentID: ca.ContentID, + Inline: ca.Inline, + IsSMIMESignature: ca.IsSMIMESignature, + SMIMEVerified: ca.SMIMEVerified, + IsSMIMEEncrypted: ca.IsSMIMEEncrypted, + IsCalendarInvite: ca.IsCalendarInvite, + } + if ca.IsCalendarInvite && len(ca.CalendarData) > 0 { + att.Data = ca.CalendarData + } + attachments = append(attachments, att) + } + return m, func() tea.Msg { + return tui.PreviewBodyFetchedMsg{ + UID: msg.UID, + Body: cached.Body, + Attachments: attachments, + AccountID: msg.AccountID, + } + } + } + return m, fetchPreviewBodyCmd(m.config, msg.UID, msg.AccountID, folderName) + + case tui.PreviewBodyFetchedMsg: + // Cache body and forward to FolderInbox + if msg.Err == nil && m.folderInbox != nil { + folderName := m.folderInbox.GetCurrentFolder() + var cachedAttachments []config.CachedAttachment + for _, a := range msg.Attachments { + cachedAttachments = append(cachedAttachments, config.CachedAttachment{ + Filename: a.Filename, + PartID: a.PartID, + Encoding: a.Encoding, + MIMEType: a.MIMEType, + ContentID: a.ContentID, + Inline: a.Inline, + }) + } + go config.SaveEmailBody(folderName, config.CachedEmailBody{ + UID: msg.UID, + AccountID: msg.AccountID, + Body: msg.Body, + Attachments: cachedAttachments, + }) + } + // Forward to FolderInbox for rendering + if m.folderInbox != nil { + m.current, cmd = m.current.Update(msg) + return m, cmd + } + return m, nil + case tui.EmailMovedMsg: if msg.Err != nil { log.Printf("Move failed: %v", msg.Err) @@ -1114,6 +1181,23 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName) m.plugins.CallHook(plugin.HookEmailViewed, t) } + // Split pane mode: open in split view instead of full screen + if m.config.EnableSplitPane && m.folderInbox != nil { + m.folderInbox.OpenSplitPreview(msg.UID, msg.AccountID) + m.current = m.folderInbox + // Mark as read + if !email.IsRead { + m.markEmailAsReadInStores(msg.UID, msg.AccountID) + account := m.config.GetAccountByID(msg.AccountID) + if account != nil { + cmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName) + } + } + // Fetch body + return m, tea.Batch(cmd, func() tea.Msg { + return tui.UpdatePreviewMsg{UID: msg.UID, AccountID: msg.AccountID} + }) + } // Check body cache first if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil { // Convert cached attachments back to fetcher.Attachment @@ -2543,6 +2627,27 @@ func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, f } } +func fetchPreviewBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string) tea.Cmd { + return func() tea.Msg { + account := cfg.GetAccountByID(accountID) + if account == nil { + return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")} + } + + body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid) + if err != nil { + return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err} + } + + return tui.PreviewBodyFetchedMsg{ + UID: uid, + Body: body, + Attachments: attachments, + AccountID: accountID, + } + } +} + func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd { return func() tea.Msg { err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid) diff --git a/tui/email_view.go b/tui/email_view.go index 584640f9f0130cb5383439db8abfc9d1b827c13d..89e0588f8ab7a0c8ddea63e538670c904b3b505c 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -50,6 +50,8 @@ type EmailView struct { hasCalendarInvite bool calendarEvent *calendar.Event originalICSData []byte + isPreviewMode bool + columnOffset int // horizontal offset for image rendering in split pane } func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView { @@ -152,9 +154,18 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma hasCalendarInvite: calendarEvent != nil, calendarEvent: calendarEvent, originalICSData: originalICSData, + isPreviewMode: false, } } +// NewEmailViewPreview creates EmailView in preview mode with column offset for images +func NewEmailViewPreview(email fetcher.Email, width, height, colOffset int, disableImages bool) *EmailView { + ev := NewEmailView(email, 0, width, height, MailboxInbox, disableImages) + ev.isPreviewMode = true + ev.columnOffset = colOffset + return ev +} + func (m *EmailView) Init() tea.Cmd { return nil } @@ -389,7 +400,11 @@ func (m *EmailView) View() tea.View { // their start line scrolls above the viewport. if p.Line >= yOffset && p.Line < yOffset+vpHeight { screenRow := headerLines + (p.Line - yOffset) - view.RenderImageToStdout(p, screenRow) + if m.columnOffset > 0 { + view.RenderImageToStdout(p, screenRow, m.columnOffset+1) + } else { + view.RenderImageToStdout(p, screenRow) + } } } } diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index f09ccc2b16b07a17f9013a82bfa58bfda64aef68..060c6fd4cf46d5bf6ece14d33ea26c7e6ca93922 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -56,6 +56,26 @@ var ( PaddingLeft(1). Foreground(lipgloss.Color("42")). Bold(true) + + inboxPaneStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderRight(true). + PaddingRight(1) + + previewPaneStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderLeft(true). + PaddingLeft(1) + + focusedBorderColor = lipgloss.Color("42") + unfocusedBorderColor = lipgloss.Color("240") +) + +type PaneType int + +const ( + FocusInbox PaneType = iota + FocusPreview ) // FolderInbox combines a folder sidebar with an email list. @@ -76,6 +96,12 @@ type FolderInbox struct { moveUIDs []uint32 // Batch: multiple UIDs moveAccountID string moveSourceFolder string + + // Split pane state + previewPane *EmailView + previewedUID uint32 + previewedAccountID string + focusedPane PaneType } // sortFolders sorts folder names with INBOX always first, then alphabetically. @@ -113,19 +139,16 @@ func NewFolderInbox(folders []string, accounts []config.Account) *FolderInbox { inbox := NewInbox(nil, accounts) inbox.SetFolderName(currentFolder) - inbox.extraShortHelpKeys = []key.Binding{ - key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")), - key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")), - key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move")), - } - return &FolderInbox{ + fi := &FolderInbox{ folders: folders, activeFolderIdx: 0, currentFolder: currentFolder, inbox: inbox, accounts: accounts, } + fi.updateHelpKeys() + return fi } func (m *FolderInbox) Init() tea.Cmd { @@ -144,7 +167,30 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.inbox.list.FilterState() == list.Filtering { break } + + // Route input to preview pane when focused + if m.previewPane != nil && m.focusedPane == FocusPreview { + s := msg.String() + if s != "[" && s != "]" && s != "esc" && s != "q" { + var cmd tea.Cmd + _, cmd = m.previewPane.Update(msg) + return m, cmd + } + } + switch msg.String() { + case "]": + // Switch focus to preview pane + if m.previewPane != nil && m.focusedPane == FocusInbox { + m.focusedPane = FocusPreview + return m, nil + } + case "[": + // Switch focus to inbox pane + if m.previewPane != nil && m.focusedPane == FocusPreview { + m.focusedPane = FocusInbox + return m, nil + } case "tab": m.activeFolderIdx++ if m.activeFolderIdx >= len(m.folders) { @@ -157,6 +203,13 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.activeFolderIdx = len(m.folders) - 1 } return m, m.switchFolder() + case "esc": + // Close split preview if open + if m.previewPane != nil { + m.closeSplitPreview() + return m, nil + } + // Otherwise let inbox handle (or parent) case "m": // Start move-to-folder flow if m.inbox.visualMode && len(m.inbox.selectedUIDs) > 0 { @@ -190,11 +243,24 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - inboxWidth := msg.Width - sidebarWidth - 3 // 3 for border + padding - if inboxWidth < 20 { - inboxWidth = 20 + if m.previewPane != nil || m.previewedUID != 0 { + // Recalculate pane widths for split mode + inboxWidth := m.calculateInboxWidth() + previewWidth := m.calculatePreviewWidth() + m.inbox.SetSize(inboxWidth-2, msg.Height) + if m.previewPane != nil { + // Forward resize to EmailView with preview pane dimensions + previewMsg := tea.WindowSizeMsg{Width: previewWidth - 2, Height: msg.Height - 2} + m.previewPane.Update(previewMsg) + } + } else { + // Original two-pane resize + inboxWidth := msg.Width - sidebarWidth - 3 + if inboxWidth < 20 { + inboxWidth = 20 + } + m.inbox.SetSize(inboxWidth, msg.Height) } - m.inbox.SetSize(inboxWidth, msg.Height) return m, nil case FolderEmailsFetchedMsg: @@ -236,6 +302,44 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.inbox.RemoveEmail(msg.UID, msg.AccountID) + // Clear preview if moved email was being previewed + if msg.UID == m.previewedUID { + m.closeSplitPreview() + } + return m, nil + + case UpdatePreviewMsg: + // Stale update, ignore + if msg.UID == m.previewedUID && m.previewPane != nil { + return m, nil + } + m.previewedUID = msg.UID + m.previewedAccountID = msg.AccountID + // Will trigger fetch in main.go + return m, nil + + case PreviewBodyFetchedMsg: + // Stale fetch or no preview active + if msg.UID != m.previewedUID { + return m, nil + } + if msg.Err != nil { + // Show error in preview pane + return m, nil + } + // Find email and create preview + email := m.findEmailByUID(msg.UID, msg.AccountID) + if email == nil { + return m, nil + } + // Update email with body + email.Body = msg.Body + email.Attachments = msg.Attachments + // Create preview pane with column offset for image rendering + previewWidth := m.calculatePreviewWidth() + inboxWidth := m.calculateInboxWidth() + colOffset := sidebarWidth + 2 + inboxWidth + 2 // borders + padding + m.previewPane = NewEmailViewPreview(*email, previewWidth, m.height, colOffset, false) return m, nil } @@ -365,11 +469,23 @@ func (m *FolderInbox) View() tea.View { // Render sidebar sidebar := m.renderSidebar() - // Render inbox - inboxView := m.inbox.View().Content - - // Join horizontally - content := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxView) + var content string + + if m.previewPane != nil { + // Three-pane layout: folders | inbox | email preview + inboxPane := m.renderInboxPane() + previewPane := m.renderPreviewPane() + content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, previewPane) + } else if m.previewedUID != 0 { + // Split pane loading state (body being fetched) + inboxPane := m.renderInboxPane() + emptyPreview := m.renderEmptyPreview() + content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, emptyPreview) + } else { + // Two-pane layout (original): folders | inbox + inboxView := m.inbox.View().Content + content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxView) + } // If move overlay is active, render it on top if m.movingEmail { @@ -544,13 +660,20 @@ func (m *FolderInbox) RemoveEmail(uid uint32, accountID string) { m.inbox.RemoveEmail(uid, accountID) } -// AdditionalShortHelpKeys returns help key bindings for the folder inbox. -func (m *FolderInbox) AdditionalShortHelpKeys() []key.Binding { - return []key.Binding{ +// updateHelpKeys refreshes the inbox help keys based on preview state +func (m *FolderInbox) updateHelpKeys() { + bindings := []key.Binding{ key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")), key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")), - key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move to folder")), + key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move")), + } + if m.previewPane != nil || m.previewedUID != 0 { + bindings = append(bindings, + key.NewBinding(key.WithKeys("]"), key.WithHelp("]/[", "switch pane")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close preview")), + ) } + m.inbox.extraShortHelpKeys = bindings } // SetLoadingEmails sets the loading state. @@ -579,3 +702,117 @@ func (m *FolderInbox) GetFolders() []string { func folderInboxTitle(folder string) string { return fmt.Sprintf("Folder: %s", folder) } + +// renderInboxPane renders inbox with border for split pane mode +func (m *FolderInbox) renderInboxPane() string { + inboxWidth := m.calculateInboxWidth() + + borderColor := unfocusedBorderColor + if m.focusedPane == FocusInbox { + borderColor = focusedBorderColor + } + + paneStyle := inboxPaneStyle. + BorderForeground(borderColor). + Width(inboxWidth). + Height(m.height) + + m.inbox.SetSize(inboxWidth-2, m.height) + return paneStyle.Render(m.inbox.View().Content) +} + +// renderPreviewPane renders email preview with border +func (m *FolderInbox) renderPreviewPane() string { + if m.previewPane == nil { + return m.renderEmptyPreview() + } + + previewWidth := m.calculatePreviewWidth() + + borderColor := unfocusedBorderColor + if m.focusedPane == FocusPreview { + borderColor = focusedBorderColor + } + + paneStyle := previewPaneStyle. + BorderForeground(borderColor). + Width(previewWidth). + Height(m.height) + + return paneStyle.Render(m.previewPane.View().Content) +} + +// renderEmptyPreview renders placeholder when no email selected +func (m *FolderInbox) renderEmptyPreview() string { + previewWidth := m.calculatePreviewWidth() + + emptyStyle := lipgloss.NewStyle(). + Width(previewWidth). + Height(m.height). + Align(lipgloss.Center, lipgloss.Center). + Foreground(lipgloss.Color("240")) + + return emptyStyle.Render("Loading...") +} + +// OpenSplitPreview opens the split preview pane for a specific email +func (m *FolderInbox) OpenSplitPreview(uid uint32, accountID string) { + m.previewPane = nil // Will be created when body arrives + m.previewedUID = uid + m.previewedAccountID = accountID + m.focusedPane = FocusPreview + // Recalculate inbox width for split mode + inboxWidth := m.calculateInboxWidth() + m.inbox.SetSize(inboxWidth-2, m.height) + m.updateHelpKeys() +} + +// closeSplitPreview closes the preview pane and returns to inbox-only +func (m *FolderInbox) closeSplitPreview() { + ClearKittyGraphics() + m.previewPane = nil + m.previewedUID = 0 + m.previewedAccountID = "" + m.focusedPane = FocusInbox + // Restore full inbox width + inboxWidth := m.width - sidebarWidth - 3 + if inboxWidth < 20 { + inboxWidth = 20 + } + m.inbox.SetSize(inboxWidth, m.height) + m.updateHelpKeys() +} + +// findEmailByUID finds email in inbox by UID and account ID +func (m *FolderInbox) findEmailByUID(uid uint32, accountID string) *fetcher.Email { + for i := range m.inbox.allEmails { + if m.inbox.allEmails[i].UID == uid && m.inbox.allEmails[i].AccountID == accountID { + return &m.inbox.allEmails[i] + } + } + return nil +} + +// calculatePreviewWidth calculates width for preview pane +func (m *FolderInbox) calculatePreviewWidth() int { + remainingWidth := m.width - sidebarWidth - 4 // 4 for borders + inboxWidth := int(float64(remainingWidth) * 0.4) + if inboxWidth < 30 { + inboxWidth = 30 + } + previewWidth := remainingWidth - inboxWidth + if previewWidth < 40 { + previewWidth = 40 + } + return previewWidth +} + +// calculateInboxWidth calculates width for inbox pane in split mode +func (m *FolderInbox) calculateInboxWidth() int { + remainingWidth := m.width - sidebarWidth - 4 + inboxWidth := int(float64(remainingWidth) * 0.4) + if inboxWidth < 30 { + inboxWidth = 30 + } + return inboxWidth +} diff --git a/tui/inbox.go b/tui/inbox.go index e0c653a862629220cadf81854cd5894ba1da041b..27b42c566f45deaaa1ebc1c5350577142368e126 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -28,6 +28,7 @@ var dateStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) var unreadEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) var readEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) var visualSelectedStyle lipgloss.Style +var selectedDateStyle lipgloss.Style type item struct { title, desc string @@ -79,12 +80,12 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list layout = d.inbox.dateFormat } dateStr := formatRelativeDate(i.date, layout) - listWidth := m.Width() + listWidth := m.Width() - 2 // account for PaddingLeft(2) in itemStyle isSelected := index == m.Index() styledDate := dateStyle.Render(dateStr) if isSelected { - styledDate = selectedItemStyle.Render(dateStr) + styledDate = selectedDateStyle.Render(dateStr) } else { styledDate = statusStyle.Render(dateStr) } @@ -102,8 +103,24 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list prefixWidth := lipgloss.Width(prefix) iconWidth := lipgloss.Width(styledIcon) + 1 - senderWidth := lipgloss.Width(styledSender) sepWidth := len(separator) + + availableForText := maxLeft - prefixWidth - iconWidth - sepWidth + if availableForText < 10 { + availableForText = 10 + } + + maxSenderWidth := availableForText / 2 + if lipgloss.Width(sender) > maxSenderWidth { + runes := []rune(sender) + for lipgloss.Width(string(runes)) > maxSenderWidth-1 && len(runes) > 0 { + runes = runes[:len(runes)-1] + } + sender = string(runes) + "…" + styledSender = statusStyle.Render(sender) + } + + senderWidth := lipgloss.Width(styledSender) subjectBudget := maxLeft - prefixWidth - iconWidth - senderWidth - sepWidth subject := i.title @@ -111,10 +128,11 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list subjectBudget = 4 } if lipgloss.Width(subject) > subjectBudget { - for lipgloss.Width(subject) > subjectBudget-1 && len(subject) > 0 { - subject = subject[:len(subject)-1] + runes := []rune(subject) + for lipgloss.Width(string(runes)) > subjectBudget-1 && len(runes) > 0 { + runes = runes[:len(runes)-1] } - subject += "…" + subject = string(runes) + "…" } styledSubject := statusStyle.Render(subject) diff --git a/tui/messages.go b/tui/messages.go index 8f4436880a9a0a5eb29f4215a068df1e6848ad40..1766e2b58344ca95066b04639604c924bce32ade 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -86,6 +86,19 @@ type EmailsFetchedMsg struct { Mailbox MailboxKind } +type UpdatePreviewMsg struct { + UID uint32 + AccountID string +} + +type PreviewBodyFetchedMsg struct { + UID uint32 + AccountID string + Body string + Attachments []fetcher.Attachment + Err error +} + type FetchErr error type GoToInboxMsg struct{} diff --git a/tui/settings_general.go b/tui/settings_general.go index 716e46bf51075d9035aaf95a0eb0dd99b0d1e8bd..68e29bb300812fd22741b1d566e7da8c7f1a5e7b 100644 --- a/tui/settings_general.go +++ b/tui/settings_general.go @@ -22,6 +22,7 @@ func (m *Settings) buildGeneralOptions() []generalOption { {"settings_general.disable_images", onOff(m.cfg.DisableImages), "Prevent images from loading automatically in emails.", false, ""}, {"settings_general.hide_tips", onOff(m.cfg.HideTips), "Hide helpful hints displayed at the bottom of the screen.", false, ""}, {"settings_general.disable_notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail.", false, ""}, + {"settings_general.enable_split_pane", onOff(m.cfg.EnableSplitPane), "View inbox and email side-by-side.", false, ""}, {"settings_general.date_format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed.", false, ""}, {"settings_general.language", getLanguageLabel(m.cfg.GetLanguage()), "Change the interface language. Changes apply instantly.", false, ""}, {"settings_general.signature", getSignatureStatus(), "Configure the global signature appended to your outgoing emails.", false, ""}, @@ -77,7 +78,10 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case 2: // Desktop Notifications m.cfg.DisableNotifications = !m.cfg.DisableNotifications _ = config.SaveConfig(m.cfg) - case 3: // Date Format + case 3: // Split Pane View + m.cfg.EnableSplitPane = !m.cfg.EnableSplitPane + _ = config.SaveConfig(m.cfg) + case 4: // Date Format switch m.cfg.DateFormat { case config.DateFormatEU: m.cfg.DateFormat = config.DateFormatUS @@ -87,7 +91,7 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.cfg.DateFormat = config.DateFormatEU } _ = config.SaveConfig(m.cfg) - case 4: // Language + case 5: // Language // Cycle through available languages langs := i18n.LanguageCodes() currentLang := m.cfg.GetLanguage() @@ -105,7 +109,7 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { i18n.GetManager().SetLanguage(m.cfg.Language) // Trigger full UI rebuild return m, func() tea.Msg { return LanguageChangedMsg{} } - case 5: // Edit Signature + case 6: // Edit Signature if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" { return m, func() tea.Msg { return GoToSignatureEditorMsg{} } } diff --git a/tui/theme.go b/tui/theme.go index 85274c313fb767b20f980714e09bc5641034fdbc..bbbbc20bb8a0990b169fe28d928b2b945471b644 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -73,7 +73,8 @@ func RebuildStyles() { dateStyle = lipgloss.NewStyle().Foreground(t.MutedText) unreadEmailStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true) readEmailStyle = lipgloss.NewStyle().Foreground(t.Secondary) - visualSelectedStyle = lipgloss.NewStyle().Background(t.AccentDark).Foreground(t.AccentText) + visualSelectedStyle = lipgloss.NewStyle().Background(t.AccentDark).Foreground(t.AccentText).PaddingLeft(2) + selectedDateStyle = lipgloss.NewStyle().Foreground(t.Accent) // folder_inbox.go sidebarStyle = lipgloss.NewStyle(). diff --git a/view/html.go b/view/html.go index 09ac5ea42e3f7265eaf5ef43b23385ba7eba20d3..c3ca1692a1bf9a1918f70d4895871f0a4865fbdc 100644 --- a/view/html.go +++ b/view/html.go @@ -509,11 +509,16 @@ func iterm2ImageEscapeOnly(payload string) string { // // For Kitty-protocol terminals, images are uploaded once and then displayed by // ID on subsequent calls, making scroll rendering nearly instant. -func RenderImageToStdout(placement *ImagePlacement, screenRow int) { +func RenderImageToStdout(placement *ImagePlacement, screenRow int, screenCol ...int) { if placement.Base64 == "" { return } + col := 1 + if len(screenCol) > 0 && screenCol[0] > 0 { + col = screenCol[0] + } + useKitty := kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() useIterm2 := iterm2Supported() || warpSupported() @@ -525,11 +530,11 @@ func RenderImageToStdout(placement *ImagePlacement, screenRow int) { placement.Uploaded = true } seq := kittyDisplayImage(placement.ID) - fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq) + fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq) os.Stdout.Sync() } else if useIterm2 { seq := iterm2ImageEscapeOnly(placement.Base64) - fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq) + fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq) os.Stdout.Sync() } }