Detailed changes
@@ -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
@@ -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/`:
@@ -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.
@@ -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)
@@ -148,6 +148,7 @@
"disable_images": "تعطيل عرض الصور",
"hide_tips": "إخفاء النصائح السياقية",
"disable_notifications": "تعطيل الإشعارات",
+ "enable_split_pane": "عرض مقسم",
"date_format": "تنسيق التاريخ",
"language": "اللغة",
"signature": "تعديل التوقيع",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -142,6 +142,7 @@
"disable_images": "画像表示を無効化",
"hide_tips": "コンテキストヒントを非表示",
"disable_notifications": "通知を無効化",
+ "enable_split_pane": "分割ビュー",
"date_format": "日付形式",
"language": "言語",
"signature": "署名を編集",
@@ -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",
@@ -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",
@@ -148,6 +148,7 @@
"disable_images": "Отключить Отображение Изображений",
"hide_tips": "Скрыть Контекстные Подсказки",
"disable_notifications": "Отключить Уведомления",
+ "enable_split_pane": "Разделённый вид",
"date_format": "Формат Даты",
"language": "Язык",
"signature": "Редактировать Подпись",
@@ -146,6 +146,7 @@
"disable_images": "Вимкнути показ зображень",
"hide_tips": "Приховати контекстні підказки",
"disable_notifications": "Вимкнути сповіщення",
+ "enable_split_pane": "Розділений вигляд",
"date_format": "Формат дати",
"language": "Мова",
"signature": "Редагувати підпис",
@@ -142,6 +142,7 @@
"disable_images": "禁用图片显示",
"hide_tips": "隐藏上下文提示",
"disable_notifications": "禁用通知",
+ "enable_split_pane": "分屏视图",
"date_format": "日期格式",
"language": "语言",
"signature": "编辑签名",
@@ -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)
@@ -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)
+ }
}
}
}
@@ -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
+}
@@ -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)
@@ -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{}
@@ -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{} }
}
@@ -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().
@@ -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()
}
}