diff --git a/config/config.go b/config/config.go index 7b266db0ce518975fe1f4611a77098107fadbd9f..bc94efbf90f2e9142f16443b53454cb55979a3f7 100644 --- a/config/config.go +++ b/config/config.go @@ -28,7 +28,8 @@ type Account struct { // Config stores the user's email configuration with multiple accounts. type Config struct { - Accounts []Account `json:"accounts"` + Accounts []Account `json:"accounts"` + DisableImages bool `json:"disable_images,omitempty"` } // GetIMAPServer returns the IMAP server address for the account. diff --git a/main b/main new file mode 100755 index 0000000000000000000000000000000000000000..62745bf2fd27c89a149b394efbc959f36ef0e287 Binary files /dev/null and b/main differ diff --git a/main.go b/main.go index 384b2731512fedf609ab783e2aa911c8847258d9..501dc72a645581ce60a4a02b6c300c088ecc3e0d 100644 --- a/main.go +++ b/main.go @@ -490,11 +490,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case tui.GoToSettingsMsg: - if m.config != nil { - m.current = tui.NewSettings(m.config.Accounts) - } else { - m.current = tui.NewSettings(nil) - } + m.current = tui.NewSettings(m.config) m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() @@ -529,7 +525,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.emails = allEmails // Go back to settings - m.current = tui.NewSettings(m.config.Accounts) + m.current = tui.NewSettings(m.config) m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) } return m, m.current.Init() @@ -568,7 +564,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Find the index for the email view (used for display purposes) emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox) - emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox) + emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages) m.current = emailView return m, m.current.Init() diff --git a/tui/email_view.go b/tui/email_view.go index 5da1c3a2100a5136abf79284a290f89865ba1bf5..2da090ef021f76ecb665c1e5a3f2583f16e537f6 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -33,12 +33,18 @@ type EmailView struct { focusOnAttachments bool accountID string mailbox MailboxKind + disableImages bool + showImages bool } -func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind) *EmailView { +func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView { // Pass the styles from the tui package to the view package inlineImages := inlineImagesFromAttachments(email.Attachments) - body, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle) + + // Initial state for showImages matches config unless overridden later + showImages := !disableImages + + body, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages) if err != nil { body = fmt.Sprintf("Error rendering body: %v", err) } @@ -58,11 +64,13 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma vp.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n") return &EmailView{ - viewport: vp, - email: email, - emailIndex: emailIndex, - accountID: email.AccountID, - mailbox: mailbox, + viewport: vp, + email: email, + emailIndex: emailIndex, + accountID: email.AccountID, + mailbox: mailbox, + disableImages: disableImages, + showImages: showImages, } } @@ -118,6 +126,20 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } else { switch msg.String() { + case "i": + if view.ImageProtocolSupported() { + m.showImages = !m.showImages + clearKittyGraphics() + + inlineImages := inlineImagesFromAttachments(m.email.Attachments) + body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages) + if err != nil { + body = fmt.Sprintf("Error rendering body: %v", err) + } + wrapped := wrapBodyToWidth(body, m.viewport.Width) + m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n") + return m, nil + } case "r": // Clear Kitty graphics before opening composer clearKittyGraphics() @@ -161,7 +183,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // When the window size changes, wrap and clear kitty images to keep placement stable inlineImages := inlineImagesFromAttachments(m.email.Attachments) - body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle) + body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages) if err != nil { body = fmt.Sprintf("Error rendering body: %v", err) } @@ -188,7 +210,11 @@ func (m *EmailView) View() string { if m.focusOnAttachments { help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body") } else { - help = helpStyle.Render("\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox") + shortcuts := "\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox" + if view.ImageProtocolSupported() { + shortcuts = shortcuts + "• \uf03e i: toggle images" + } + help = helpStyle.Render(shortcuts) } var attachmentView string diff --git a/tui/email_view_test.go b/tui/email_view_test.go index c96b3c39087e69a41c18c83f98635e59199fbde0..3af1a26107e67e34588e1d394239b6f1901efd8a 100644 --- a/tui/email_view_test.go +++ b/tui/email_view_test.go @@ -28,7 +28,7 @@ func TestEmailViewUpdate(t *testing.T) { } t.Run("Focus on attachments", func(t *testing.T) { - emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox) + emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false) if emailView.focusOnAttachments { t.Error("focusOnAttachments should be initially false") } @@ -50,7 +50,7 @@ func TestEmailViewUpdate(t *testing.T) { }) t.Run("No focus on attachments when there are none", func(t *testing.T) { - emailView := NewEmailView(emailWithoutAttachments, 0, 80, 24, MailboxInbox) + emailView := NewEmailView(emailWithoutAttachments, 0, 80, 24, MailboxInbox, false) if emailView.focusOnAttachments { t.Error("focusOnAttachments should be initially false") } @@ -63,7 +63,7 @@ func TestEmailViewUpdate(t *testing.T) { }) t.Run("Navigate attachments", func(t *testing.T) { - emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox) + emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false) // Focus on attachments model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab}) emailView = model.(*EmailView) @@ -95,7 +95,7 @@ func TestEmailViewUpdate(t *testing.T) { }) t.Run("Download attachment", func(t *testing.T) { - emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox) + emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false) // Focus on attachments model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab}) emailView = model.(*EmailView) @@ -124,7 +124,7 @@ func TestEmailViewUpdate(t *testing.T) { }) t.Run("Reply to email", func(t *testing.T) { - emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox) + emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox, false) _, cmd := emailView.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) if cmd == nil { diff --git a/tui/settings.go b/tui/settings.go index 7f7cbd6040558c1430c32f2d91cf43ed0753eabf..7df506adc1629c2eb53b00295a092fd5ae0c73f0 100644 --- a/tui/settings.go +++ b/tui/settings.go @@ -16,9 +16,17 @@ var ( dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) ) -// Settings displays the account management screen. +type SettingsState int + +const ( + SettingsMain SettingsState = iota + SettingsAccounts +) + +// Settings displays the settings screen. type Settings struct { - accounts []config.Account + cfg *config.Config + state SettingsState cursor int confirmingDelete bool width int @@ -26,10 +34,14 @@ type Settings struct { } // NewSettings creates a new settings model. -func NewSettings(accounts []config.Account) *Settings { +func NewSettings(cfg *config.Config) *Settings { + if cfg == nil { + cfg = &config.Config{} + } return &Settings{ - accounts: accounts, - cursor: 0, + cfg: cfg, + state: SettingsMain, + cursor: 0, } } @@ -47,68 +59,155 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: - if m.confirmingDelete { - switch msg.String() { - case "y", "Y": - if m.cursor < len(m.accounts) { - accountID := m.accounts[m.cursor].ID - m.confirmingDelete = false - return m, func() tea.Msg { - return DeleteAccountMsg{AccountID: accountID} - } - } - case "n", "N", "esc": - m.confirmingDelete = false - return m, nil - } + if m.state == SettingsMain { + return m.updateMain(msg) + } else { + return m.updateAccounts(msg) + } + } + return m, nil +} + +func (m *Settings) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + // Options: 0: Email Accounts, 1: Image Display, 2: Edit Signature + if m.cursor < 2 { + m.cursor++ + } + case "enter": + switch m.cursor { + case 0: // Email Accounts + m.state = SettingsAccounts + m.cursor = 0 return m, nil + case 1: // Image Display + m.cfg.DisableImages = !m.cfg.DisableImages + // Save config immediately + _ = config.SaveConfig(m.cfg) + return m, nil + case 2: // Edit Signature + return m, func() tea.Msg { return GoToSignatureEditorMsg{} } } + case "esc": + return m, func() tea.Msg { return GoToChoiceMenuMsg{} } + } + return m, nil +} +func (m *Settings) updateAccounts(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.confirmingDelete { switch msg.String() { - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - // +2 for "Add Account" and "Signature" options - if m.cursor < len(m.accounts)+1 { - m.cursor++ - } - case "d": - // Delete selected account (not the "Add Account" option) - if m.cursor < len(m.accounts) && len(m.accounts) > 0 { - m.confirmingDelete = true - } - case "enter": - // If cursor is on "Add Account" - if m.cursor == len(m.accounts) { - return m, func() tea.Msg { return GoToAddAccountMsg{} } - } - // If cursor is on "Signature" - if m.cursor == len(m.accounts)+1 { - return m, func() tea.Msg { return GoToSignatureEditorMsg{} } + case "y", "Y": + if m.cursor < len(m.cfg.Accounts) { + accountID := m.cfg.Accounts[m.cursor].ID + m.confirmingDelete = false + return m, func() tea.Msg { + return DeleteAccountMsg{AccountID: accountID} + } } - case "esc": - return m, func() tea.Msg { return GoToChoiceMenuMsg{} } + case "n", "N", "esc": + m.confirmingDelete = false + return m, nil + } + return m, nil + } + + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + // +1 for "Add Account" option + if m.cursor < len(m.cfg.Accounts) { + m.cursor++ + } + case "d": + // Delete selected account (not the "Add Account" option) + if m.cursor < len(m.cfg.Accounts) && len(m.cfg.Accounts) > 0 { + m.confirmingDelete = true } + case "enter": + // If cursor is on "Add Account" + if m.cursor == len(m.cfg.Accounts) { + return m, func() tea.Msg { return GoToAddAccountMsg{} } + } + case "esc": + m.state = SettingsMain + m.cursor = 0 + return m, nil } return m, nil } // View renders the settings screen. func (m *Settings) View() string { + if m.state == SettingsMain { + return m.viewMain() + } + return m.viewAccounts() +} + +func (m *Settings) viewMain() string { var b strings.Builder - b.WriteString(titleStyle.Render("Account Settings") + "\n\n") - b.WriteString(listHeader.Render("Your email accounts:")) + b.WriteString(titleStyle.Render("Settings") + "\n\n") + + // Option 0: Email Accounts + if m.cursor == 0 { + b.WriteString(selectedAccountItemStyle.Render("> Email Accounts")) + } else { + b.WriteString(accountItemStyle.Render(" Email Accounts")) + } + b.WriteString("\n") + + // Option 1: Image Display + status := "ON" + if m.cfg.DisableImages { + status = "OFF" + } + text := fmt.Sprintf("Image Display: %s", status) + if m.cursor == 1 { + b.WriteString(selectedAccountItemStyle.Render("> " + text)) + } else { + b.WriteString(accountItemStyle.Render(" " + text)) + } + b.WriteString("\n") + + // Option 2: Edit Signature + sigText := "Edit Signature" + if config.HasSignature() { + sigText = "Edit Signature (configured)" + } + if m.cursor == 2 { + b.WriteString(selectedAccountItemStyle.Render("> " + sigText)) + } else { + b.WriteString(accountItemStyle.Render(" " + sigText)) + } + b.WriteString("\n\n") + + b.WriteString(helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back")) + + return docStyle.Render(b.String()) +} + +func (m *Settings) viewAccounts() string { + var b strings.Builder + + b.WriteString(titleStyle.Render("Account Settings")) b.WriteString("\n\n") - if len(m.accounts) == 0 { + if len(m.cfg.Accounts) == 0 { b.WriteString(accountEmailStyle.Render(" No accounts configured.\n")) b.WriteString("\n") } - for i, account := range m.accounts { + for i, account := range m.cfg.Accounts { displayName := account.Email if account.Name != "" { displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail) @@ -131,29 +230,17 @@ func (m *Settings) View() string { // Add Account option addAccountText := "Add New Account" - if m.cursor == len(m.accounts) { + if m.cursor == len(m.cfg.Accounts) { b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addAccountText))) } else { b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addAccountText))) } - b.WriteString("\n") - - // Signature option - signatureText := "Edit Signature" - if config.HasSignature() { - signatureText = "Edit Signature (configured)" - } - if m.cursor == len(m.accounts)+1 { - b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", signatureText))) - } else { - b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", signatureText))) - } b.WriteString("\n\n") b.WriteString(helpStyle.Render("↑/↓: navigate • enter: select • d: delete account • esc: back")) if m.confirmingDelete { - accountName := m.accounts[m.cursor].Email + accountName := m.cfg.Accounts[m.cursor].Email dialog := DialogBoxStyle.Render( lipgloss.JoinVertical(lipgloss.Center, dangerStyle.Render("Delete account?"), @@ -167,10 +254,10 @@ func (m *Settings) View() string { return docStyle.Render(b.String()) } -// UpdateAccounts updates the list of accounts. -func (m *Settings) UpdateAccounts(accounts []config.Account) { - m.accounts = accounts - if m.cursor >= len(accounts) { - m.cursor = len(accounts) +// UpdateConfig updates the configuration (used when accounts are deleted). +func (m *Settings) UpdateConfig(cfg *config.Config) { + m.cfg = cfg + if m.state == SettingsAccounts && m.cursor >= len(cfg.Accounts) { + m.cursor = len(cfg.Accounts) } } diff --git a/view/html.go b/view/html.go index 3f0050f6c6dd27b4ee25fbece635f54d88368d26..89f972e2e78c8e7e363b8985771d6f5ae6209a52 100644 --- a/view/html.go +++ b/view/html.go @@ -273,6 +273,11 @@ func konsoleSupported() bool { return false } +// ImageProtocolSupported checks if any supported image protocol terminal is detected. +func ImageProtocolSupported() bool { + return imageProtocolSupported() +} + // imageProtocolSupported checks if any supported image protocol terminal is detected. func imageProtocolSupported() bool { return kittySupported() || ghosttySupported() || iterm2Supported() || @@ -470,7 +475,7 @@ type InlineImage struct { } // ProcessBodyWithInline renders the body and resolves CID inline images when provided. -func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) { +func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, error) { inlineMap := make(map[string]string, len(inline)) for _, img := range inline { cid := strings.TrimSpace(img.CID) @@ -482,16 +487,16 @@ func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Styl } inlineMap[cid] = img.Base64 } - return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle) + return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle, disableImages) } // ProcessBody takes a raw email body, decodes it, and formats it as plain // text with terminal hyperlinks. -func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) { - return processBody(rawBody, nil, h1Style, h2Style, bodyStyle) +func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, error) { + return processBody(rawBody, nil, h1Style, h2Style, bodyStyle, disableImages) } -func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) { +func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, error) { decodedBody, err := decodeQuotedPrintable(rawBody) if err != nil { decodedBody = rawBody @@ -583,7 +588,7 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod alt = "Does not contain alt text" } - if imageProtocolSupported() { + if !disableImages && imageProtocolSupported() { var payload string if strings.HasPrefix(src, "data:image/") { payload = dataURIBase64(src) diff --git a/view/html_test.go b/view/html_test.go index b8b10900859c2535149a7d2a4ba2f29a718af888..6f785864daaaca98446c9f24641b204f1fb0a010 100644 --- a/view/html_test.go +++ b/view/html_test.go @@ -513,7 +513,7 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) { t.Run(tc.name, func(t *testing.T) { tc.setupHyperlinks() - processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle) + processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false) if err != nil { t.Fatalf("ProcessBody() failed: %v", err) } @@ -635,7 +635,7 @@ func TestProcessBodyWithImageProtocol(t *testing.T) { tc.clearAllImageEnv() tc.setupImageProtocol() - processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle) + processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false) if err != nil { t.Fatalf("ProcessBody() failed: %v", err) } @@ -685,7 +685,7 @@ func TestProcessBody(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle) + processed, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false) if err != nil { t.Fatalf("ProcessBody() failed: %v", err) }