Detailed changes
@@ -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.
@@ -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()
@@ -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
@@ -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 {
@@ -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)
}
}
@@ -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)
@@ -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)
}