Detailed changes
@@ -46,6 +46,7 @@ type Config struct {
Accounts []Account `json:"accounts"`
DisableImages bool `json:"disable_images,omitempty"`
HideTips bool `json:"hide_tips,omitempty"`
+ Theme string `json:"theme,omitempty"`
MailingLists []MailingList `json:"mailing_lists,omitempty"`
}
@@ -190,6 +191,7 @@ func LoadConfig() (*Config, error) {
Accounts []rawAccount `json:"accounts"`
DisableImages bool `json:"disable_images,omitempty"`
HideTips bool `json:"hide_tips,omitempty"`
+ Theme string `json:"theme,omitempty"`
MailingLists []MailingList `json:"mailing_lists,omitempty"`
}
@@ -220,6 +222,7 @@ func LoadConfig() (*Config, error) {
config.DisableImages = raw.DisableImages
config.HideTips = raw.HideTips
+ config.Theme = raw.Theme
config.MailingLists = raw.MailingLists
for _, rawAcc := range raw.Accounts {
acc := Account{
@@ -36,6 +36,7 @@ Configuration is stored in `~/.config/matcha/config.json`.
"addresses": ["alice@example.com", "bob@example.com"]
}
],
+ "theme": "Matcha",
"disable_images": true,
"hide_tips": true
}
@@ -46,3 +47,4 @@ Configuration is stored in `~/.config/matcha/config.json`.
- **Drafts**: `~/.config/matcha/drafts/`
- **Email Cache**: `~/.config/matcha/cache.json`
- **Contacts**: `~/.config/matcha/contacts.json`
+- **Custom Themes**: `~/.config/matcha/themes/*.json`
@@ -0,0 +1,69 @@
+# Themes
+
+Matcha supports color themes to personalize the look of your terminal email client. Choose from built-in themes or create your own.
+
+## Changing the Theme
+
+Go to **Settings > Theme** to browse available themes. A live preview panel on the right shows how each theme looks as you navigate the list. Press `enter` to apply the selected theme.
+
+Your selection is saved to `config.json` and persists across sessions.
+
+
+
+## Built-in Themes
+
+| Theme | Description |
+|-------|-------------|
+| **Matcha** | The default green theme |
+| **Rose** | Soft pink and rose accents |
+| **Lavender** | Purple and violet tones |
+| **Ocean** | Cool blue palette |
+| **Peach** | Warm orange and peach tones |
+| **Catppuccin Mocha** | Based on the popular [Catppuccin](https://catppuccin.com/) color scheme |
+
+## Custom Themes
+
+You can create your own themes by adding JSON files to `~/.config/matcha/themes/`. Each `.json` file in that directory will appear in the theme picker.
+
+### Example Custom Theme
+
+Create a file like `~/.config/matcha/themes/dracula.json`:
+
+```json
+{
+ "name": "Dracula",
+ "accent": "#BD93F9",
+ "accent_dark": "#6272A4",
+ "accent_text": "#F8F8F2",
+ "secondary": "#6272A4",
+ "subtle_text": "#6272A4",
+ "muted_text": "#6272A4",
+ "dim_text": "#F8F8F2",
+ "danger": "#FF5555",
+ "warning": "#FFB86C",
+ "tip": "#F1FA8C",
+ "link": "#8BE9FD",
+ "directory": "#BD93F9",
+ "contrast": "#282A36"
+}
+```
+
+### Color Properties
+
+| Property | Used For |
+|----------|----------|
+| `accent` | Selected items, focused elements, primary highlights |
+| `accent_dark` | Borders, title backgrounds |
+| `accent_text` | Text on accent-colored backgrounds |
+| `secondary` | Help text, blurred/unfocused elements |
+| `subtle_text` | List headers, hints |
+| `muted_text` | Dates, timestamps |
+| `dim_text` | Sender names, secondary info |
+| `danger` | Delete confirmations, errors |
+| `warning` | Update notifications |
+| `tip` | Contextual tips |
+| `link` | Hyperlinks in email content |
+| `directory` | Directory names in the file picker |
+| `contrast` | Text on accent-colored backgrounds (e.g. active folder) |
+
+Colors can be specified as hex values (`#BD93F9`) or ANSI 256-color codes (`42`).
@@ -4,7 +4,7 @@ Matcha features a modern terminal interface built for efficiency and aesthetics.
## Key Features
-- **🎨 Beautiful TUI**: Clean, modern terminal interface built with Bubble Tea.
+- **🎨 Beautiful TUI**: Clean, modern, customizable terminal interface built with Bubble Tea.
- **⌨️ Vim-like Keybindings**: Efficient keyboard navigation (`j/k`, `h/l`, etc.).
- **📱 Responsive Design**: Adapts to your terminal window size.
- **🎯 Focus Management**: Clear visual indication of focused elements.
@@ -23,6 +23,7 @@ import (
"github.com/floatpane/matcha/config"
"github.com/floatpane/matcha/fetcher"
"github.com/floatpane/matcha/sender"
+ "github.com/floatpane/matcha/theme"
"github.com/floatpane/matcha/tui"
"github.com/google/uuid"
"github.com/yuin/goldmark"
@@ -1975,6 +1976,11 @@ func main() {
}
cfg, err := config.LoadConfig()
+ if err == nil && cfg.Theme != "" {
+ theme.SetTheme(cfg.Theme)
+ }
+ tui.RebuildStyles()
+
var initialModel *mainModel
if err != nil {
initialModel = newInitialModel(nil)
@@ -0,0 +1,48 @@
+# Screenshot: Theme Settings
+# Shows the theme picker with Rose selected and a live preview
+
+Output screenshots/theme_settings.gif
+
+Set FontSize 14
+Set FontFamily "JetBrainsMono Nerd Font"
+Set Width 1400
+Set Height 800
+Set Theme "Catppuccin Mocha"
+Set Padding 20
+Set WindowBar Colorful
+Set WindowBarSize 40
+Set BorderRadius 10
+
+Hide
+Type "go run ."
+Enter
+Show
+
+Sleep 2s
+
+# Navigate to Settings (last item in menu)
+# Inbox -> Compose -> [Drafts if present] -> Settings
+Down
+Sleep 300ms
+Down
+Sleep 300ms
+Down
+Sleep 300ms
+Down
+Sleep 300ms
+Enter
+
+Sleep 1s
+
+# Navigate to Theme (2nd option in settings)
+Down
+Sleep 300ms
+Enter
+
+Sleep 1s
+
+# Navigate to Rose (2nd theme in list)
+Down
+Sleep 500ms
+
+Screenshot screenshots/theme_settings.png
@@ -0,0 +1,248 @@
+package theme
+
+import (
+ "encoding/json"
+ "image/color"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+)
+
+// Theme defines the color palette for the application.
+type Theme struct {
+ Name string `json:"name"`
+ Accent color.Color `json:"-"`
+ AccentDark color.Color `json:"-"`
+ AccentText color.Color `json:"-"`
+ Secondary color.Color `json:"-"`
+ SubtleText color.Color `json:"-"`
+ MutedText color.Color `json:"-"`
+ DimText color.Color `json:"-"`
+ Danger color.Color `json:"-"`
+ Warning color.Color `json:"-"`
+ Tip color.Color `json:"-"`
+ Link color.Color `json:"-"`
+ Directory color.Color `json:"-"`
+ Contrast color.Color `json:"-"`
+}
+
+// themeJSON is the JSON-serializable form of Theme using string color values.
+type themeJSON struct {
+ Name string `json:"name"`
+ Accent string `json:"accent"`
+ AccentDark string `json:"accent_dark"`
+ AccentText string `json:"accent_text"`
+ Secondary string `json:"secondary"`
+ SubtleText string `json:"subtle_text"`
+ MutedText string `json:"muted_text"`
+ DimText string `json:"dim_text"`
+ Danger string `json:"danger"`
+ Warning string `json:"warning"`
+ Tip string `json:"tip"`
+ Link string `json:"link"`
+ Directory string `json:"directory"`
+ Contrast string `json:"contrast"`
+}
+
+func themeFromJSON(j themeJSON) Theme {
+ return Theme{
+ Name: j.Name,
+ Accent: lipgloss.Color(j.Accent),
+ AccentDark: lipgloss.Color(j.AccentDark),
+ AccentText: lipgloss.Color(j.AccentText),
+ Secondary: lipgloss.Color(j.Secondary),
+ SubtleText: lipgloss.Color(j.SubtleText),
+ MutedText: lipgloss.Color(j.MutedText),
+ DimText: lipgloss.Color(j.DimText),
+ Danger: lipgloss.Color(j.Danger),
+ Warning: lipgloss.Color(j.Warning),
+ Tip: lipgloss.Color(j.Tip),
+ Link: lipgloss.Color(j.Link),
+ Directory: lipgloss.Color(j.Directory),
+ Contrast: lipgloss.Color(j.Contrast),
+ }
+}
+
+// Built-in themes
+
+var Matcha = Theme{
+ Name: "Matcha",
+ Accent: lipgloss.Color("42"),
+ AccentDark: lipgloss.Color("#25A065"),
+ AccentText: lipgloss.Color("#FFFDF5"),
+ Secondary: lipgloss.Color("240"),
+ SubtleText: lipgloss.Color("241"),
+ MutedText: lipgloss.Color("243"),
+ DimText: lipgloss.Color("250"),
+ Danger: lipgloss.Color("196"),
+ Warning: lipgloss.Color("208"),
+ Tip: lipgloss.Color("214"),
+ Link: lipgloss.Color("#9BC4FF"),
+ Directory: lipgloss.Color("34"),
+ Contrast: lipgloss.Color("#000000"),
+}
+
+var Rose = Theme{
+ Name: "Rose",
+ Accent: lipgloss.Color("#E8729B"),
+ AccentDark: lipgloss.Color("#B5547A"),
+ AccentText: lipgloss.Color("#FFFDF5"),
+ Secondary: lipgloss.Color("240"),
+ SubtleText: lipgloss.Color("241"),
+ MutedText: lipgloss.Color("243"),
+ DimText: lipgloss.Color("250"),
+ Danger: lipgloss.Color("196"),
+ Warning: lipgloss.Color("208"),
+ Tip: lipgloss.Color("214"),
+ Link: lipgloss.Color("#9BC4FF"),
+ Directory: lipgloss.Color("#E8729B"),
+ Contrast: lipgloss.Color("#000000"),
+}
+
+var Lavender = Theme{
+ Name: "Lavender",
+ Accent: lipgloss.Color("#B4A7D6"),
+ AccentDark: lipgloss.Color("#8E7CC3"),
+ AccentText: lipgloss.Color("#FFFDF5"),
+ Secondary: lipgloss.Color("240"),
+ SubtleText: lipgloss.Color("241"),
+ MutedText: lipgloss.Color("243"),
+ DimText: lipgloss.Color("250"),
+ Danger: lipgloss.Color("196"),
+ Warning: lipgloss.Color("208"),
+ Tip: lipgloss.Color("214"),
+ Link: lipgloss.Color("#9BC4FF"),
+ Directory: lipgloss.Color("#B4A7D6"),
+ Contrast: lipgloss.Color("#000000"),
+}
+
+var Ocean = Theme{
+ Name: "Ocean",
+ Accent: lipgloss.Color("#5B9BD5"),
+ AccentDark: lipgloss.Color("#3A7BBF"),
+ AccentText: lipgloss.Color("#FFFDF5"),
+ Secondary: lipgloss.Color("240"),
+ SubtleText: lipgloss.Color("241"),
+ MutedText: lipgloss.Color("243"),
+ DimText: lipgloss.Color("250"),
+ Danger: lipgloss.Color("196"),
+ Warning: lipgloss.Color("208"),
+ Tip: lipgloss.Color("214"),
+ Link: lipgloss.Color("#9BC4FF"),
+ Directory: lipgloss.Color("#5B9BD5"),
+ Contrast: lipgloss.Color("#000000"),
+}
+
+var Peach = Theme{
+ Name: "Peach",
+ Accent: lipgloss.Color("#FAB387"),
+ AccentDark: lipgloss.Color("#E0956E"),
+ AccentText: lipgloss.Color("#1E1E2E"),
+ Secondary: lipgloss.Color("240"),
+ SubtleText: lipgloss.Color("241"),
+ MutedText: lipgloss.Color("243"),
+ DimText: lipgloss.Color("250"),
+ Danger: lipgloss.Color("#F38BA8"),
+ Warning: lipgloss.Color("#F9E2AF"),
+ Tip: lipgloss.Color("#F9E2AF"),
+ Link: lipgloss.Color("#89B4FA"),
+ Directory: lipgloss.Color("#FAB387"),
+ Contrast: lipgloss.Color("#1E1E2E"),
+}
+
+var CatppuccinMocha = Theme{
+ Name: "Catppuccin Mocha",
+ Accent: lipgloss.Color("#89B4FA"),
+ AccentDark: lipgloss.Color("#74C7EC"),
+ AccentText: lipgloss.Color("#1E1E2E"),
+ Secondary: lipgloss.Color("#6C7086"),
+ SubtleText: lipgloss.Color("#7F849C"),
+ MutedText: lipgloss.Color("#9399B2"),
+ DimText: lipgloss.Color("#BAC2DE"),
+ Danger: lipgloss.Color("#F38BA8"),
+ Warning: lipgloss.Color("#FAB387"),
+ Tip: lipgloss.Color("#F9E2AF"),
+ Link: lipgloss.Color("#89DCEB"),
+ Directory: lipgloss.Color("#89B4FA"),
+ Contrast: lipgloss.Color("#1E1E2E"),
+}
+
+// BuiltinThemes lists all built-in themes in display order.
+var BuiltinThemes = []Theme{
+ Matcha,
+ Rose,
+ Lavender,
+ Ocean,
+ Peach,
+ CatppuccinMocha,
+}
+
+// ActiveTheme is the currently active theme used for styling.
+var ActiveTheme = Matcha
+
+// SetTheme sets the active theme by name. Returns true if found.
+// It searches built-in themes first, then custom themes.
+func SetTheme(name string) bool {
+ if name == "" {
+ ActiveTheme = Matcha
+ return true
+ }
+ for _, t := range BuiltinThemes {
+ if strings.EqualFold(t.Name, name) {
+ ActiveTheme = t
+ return true
+ }
+ }
+ // Try custom themes
+ custom := LoadCustomThemes()
+ for _, t := range custom {
+ if strings.EqualFold(t.Name, name) {
+ ActiveTheme = t
+ return true
+ }
+ }
+ return false
+}
+
+// AllThemes returns all available themes (built-in + custom).
+func AllThemes() []Theme {
+ all := make([]Theme, len(BuiltinThemes))
+ copy(all, BuiltinThemes)
+ all = append(all, LoadCustomThemes()...)
+ return all
+}
+
+// LoadCustomThemes loads custom themes from ~/.config/matcha/themes/*.json.
+func LoadCustomThemes() []Theme {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil
+ }
+ themesDir := filepath.Join(home, ".config", "matcha", "themes")
+ entries, err := os.ReadDir(themesDir)
+ if err != nil {
+ return nil
+ }
+
+ var themes []Theme
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
+ continue
+ }
+ data, err := os.ReadFile(filepath.Join(themesDir, entry.Name()))
+ if err != nil {
+ continue
+ }
+ var j themeJSON
+ if err := json.Unmarshal(data, &j); err != nil {
+ continue
+ }
+ if j.Name == "" {
+ j.Name = strings.TrimSuffix(entry.Name(), ".json")
+ }
+ themes = append(themes, themeFromJSON(j))
+ }
+ return themes
+}
@@ -8,6 +8,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/floatpane/matcha/config"
+ "github.com/floatpane/matcha/theme"
)
// Styles defined locally to avoid import issues.
@@ -127,7 +128,7 @@ func (m Choice) View() tea.View {
// If we detected an update, show a short message under the header.
if m.UpdateAvailable {
- updateStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Padding(0, 1)
+ updateStyle := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Warning).Padding(0, 1)
cur := m.CurrentVersion
if cur == "" {
cur = "unknown"
@@ -10,6 +10,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/floatpane/matcha/config"
+ "github.com/floatpane/matcha/theme"
)
// draftItem represents a draft in the list
@@ -85,7 +86,7 @@ func NewDrafts(drafts []config.Draft) *Drafts {
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "Drafts"
- l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
+ l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
l.SetShowStatusBar(true)
l.SetFilteringEnabled(true)
l.SetStatusBarItemName("draft", "drafts")
@@ -198,7 +199,7 @@ func (m *Drafts) View() tea.View {
if len(m.drafts) == 0 {
emptyMsg := lipgloss.NewStyle().
- Foreground(lipgloss.Color("240")).
+ Foreground(theme.ActiveTheme.Secondary).
Render("No drafts saved.\n\nPress esc to go back.")
return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, emptyMsg))
}
@@ -10,6 +10,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/floatpane/matcha/fetcher"
+ "github.com/floatpane/matcha/theme"
"github.com/floatpane/matcha/view"
)
@@ -243,12 +244,12 @@ func (m *EmailView) View() tea.View {
smimeStatus := ""
if m.isEncrypted {
- smimeStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(" [S/MIME: 🔒 Encrypted]")
+ smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]")
} else if m.isSMIME {
if m.smimeTrusted {
- smimeStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(" [S/MIME: ✅ Trusted]")
+ smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]")
} else {
- smimeStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(" [S/MIME: ❌ Untrusted]")
+ smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]")
}
}
@@ -12,6 +12,7 @@ import (
"charm.land/lipgloss/v2"
"github.com/floatpane/matcha/config"
"github.com/floatpane/matcha/fetcher"
+ "github.com/floatpane/matcha/theme"
)
var (
@@ -314,7 +315,7 @@ func (m *Inbox) updateList() {
l.Title = m.getTitle()
l.SetShowStatusBar(true)
l.SetFilteringEnabled(true)
- l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
+ l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
l.Styles.PaginationStyle = paginationStyle
l.Styles.HelpStyle = inboxHelpStyle
l.SetStatusBarItemName("email", "emails")
@@ -8,6 +8,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/floatpane/matcha/config"
+ "github.com/floatpane/matcha/theme"
)
var (
@@ -27,6 +28,7 @@ const (
SettingsAccounts
SettingsMailingLists
SettingsSMIMEConfig
+ SettingsTheme
)
// Settings displays the settings screen.
@@ -91,6 +93,8 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
if m.state == SettingsMain {
return m.updateMain(msg)
+ } else if m.state == SettingsTheme {
+ return m.updateTheme(msg)
} else if m.state == SettingsMailingLists {
return m.updateMailingLists(msg)
} else if m.state == SettingsSMIMEConfig {
@@ -120,8 +124,8 @@ func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.cursor--
}
case "down", "j":
- // Options: 0: Email Accounts, 1: Image Display, 2: Edit Signature, 3: Contextual Tips, 4: Mailing Lists
- if m.cursor < 4 {
+ // Options: 0: Email Accounts, 1: Theme, 2: Image Display, 3: Edit Signature, 4: Contextual Tips, 5: Mailing Lists
+ if m.cursor < 5 {
m.cursor++
}
case "enter":
@@ -130,19 +134,29 @@ func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.state = SettingsAccounts
m.cursor = 0
return m, nil
- case 1: // Image Display
+ case 1: // Theme
+ m.state = SettingsTheme
+ // Position cursor on the currently active theme
+ themes := theme.AllThemes()
+ m.cursor = 0
+ for i, t := range themes {
+ if t.Name == theme.ActiveTheme.Name {
+ m.cursor = i
+ break
+ }
+ }
+ return m, nil
+ case 2: // Image Display
m.cfg.DisableImages = !m.cfg.DisableImages
- // Save config immediately
_ = config.SaveConfig(m.cfg)
return m, nil
- case 2: // Edit Signature
+ case 3: // Edit Signature
return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
- case 3: // Contextual Tips
+ case 4: // Contextual Tips
m.cfg.HideTips = !m.cfg.HideTips
- // Save config immediately
_ = config.SaveConfig(m.cfg)
return m, nil
- case 4: // Mailing Lists
+ case 5: // Mailing Lists
m.state = SettingsMailingLists
m.cursor = 0
return m, nil
@@ -324,6 +338,8 @@ func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
func (m *Settings) View() tea.View {
if m.state == SettingsMain {
return tea.NewView(m.viewMain())
+ } else if m.state == SettingsTheme {
+ return tea.NewView(m.viewTheme())
} else if m.state == SettingsMailingLists {
return tea.NewView(m.viewMailingLists())
} else if m.state == SettingsSMIMEConfig {
@@ -345,47 +361,56 @@ func (m *Settings) viewMain() string {
}
b.WriteString("\n")
- // Option 1: Image Display
+ // Option 1: Theme
+ themeText := fmt.Sprintf("Theme: %s", theme.ActiveTheme.Name)
+ if m.cursor == 1 {
+ b.WriteString(selectedAccountItemStyle.Render("> " + themeText))
+ } else {
+ b.WriteString(accountItemStyle.Render(" " + themeText))
+ }
+ b.WriteString("\n")
+
+ // Option 2: Image Display
status := "ON"
if m.cfg.DisableImages {
status = "OFF"
}
text := fmt.Sprintf("Image Display: %s", status)
- if m.cursor == 1 {
+ if m.cursor == 2 {
b.WriteString(selectedAccountItemStyle.Render("> " + text))
} else {
b.WriteString(accountItemStyle.Render(" " + text))
}
b.WriteString("\n")
- // Option 2: Edit Signature
+ // Option 3: Edit Signature
sigText := "Edit Signature"
if config.HasSignature() {
sigText = "Edit Signature (configured)"
}
- if m.cursor == 2 {
+ if m.cursor == 3 {
b.WriteString(selectedAccountItemStyle.Render("> " + sigText))
} else {
b.WriteString(accountItemStyle.Render(" " + sigText))
}
b.WriteString("\n")
- // Option 3: Contextual Tips
+ // Option 4: Contextual Tips
tipsStatus := "ON"
if m.cfg.HideTips {
tipsStatus = "OFF"
}
tipsText := fmt.Sprintf("Contextual Tips: %s", tipsStatus)
- if m.cursor == 3 {
+ if m.cursor == 4 {
b.WriteString(selectedAccountItemStyle.Render("> " + tipsText))
} else {
b.WriteString(accountItemStyle.Render(" " + tipsText))
}
b.WriteString("\n")
- // Option 4: Mailing Lists
+ // Option 5: Mailing Lists
mailingListsText := "Mailing Lists"
- if m.cursor == 4 {
+ if m.cursor == 5 {
b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText))
} else {
b.WriteString(accountItemStyle.Render(" " + mailingListsText))
@@ -398,12 +423,14 @@ func (m *Settings) viewMain() string {
case 0:
tip = "Manage your connected email accounts."
case 1:
- tip = "Toggle displaying images in emails."
+ tip = "Choose a color theme for the application."
case 2:
- tip = "Configure the signature appended to your outgoing emails."
+ tip = "Toggle displaying images in emails."
case 3:
- tip = "Toggle displaying helpful contextual tips like this one."
+ tip = "Configure the signature appended to your outgoing emails."
case 4:
+ tip = "Toggle displaying helpful contextual tips like this one."
+ case 5:
tip = "Manage groups of email addresses to quickly send to multiple people."
}
if tip != "" {
@@ -630,6 +657,150 @@ func (m *Settings) viewMailingLists() string {
return docStyle.Render(mainContent + helpView)
}
+func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ themes := theme.AllThemes()
+
+ switch msg.String() {
+ case "up", "k":
+ if m.cursor > 0 {
+ m.cursor--
+ }
+ case "down", "j":
+ if m.cursor < len(themes)-1 {
+ m.cursor++
+ }
+ case "enter":
+ if m.cursor < len(themes) {
+ selected := themes[m.cursor]
+ theme.SetTheme(selected.Name)
+ RebuildStyles()
+ m.cfg.Theme = selected.Name
+ _ = config.SaveConfig(m.cfg)
+ }
+ m.state = SettingsMain
+ m.cursor = 1 // Return to Theme option
+ return m, nil
+ case "esc":
+ m.state = SettingsMain
+ m.cursor = 1 // Return to Theme option
+ return m, nil
+ }
+ return m, nil
+}
+
+func (m *Settings) viewTheme() string {
+ themes := theme.AllThemes()
+
+ // Build left panel: theme list
+ var left strings.Builder
+ left.WriteString(titleStyle.Render("Theme") + "\n\n")
+
+ for i, t := range themes {
+ isActive := t.Name == theme.ActiveTheme.Name
+
+ label := t.Name
+ if isActive {
+ label += " (active)"
+ }
+
+ if m.cursor == i {
+ left.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", label)))
+ } else {
+ left.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", label)))
+ }
+ left.WriteString("\n")
+ }
+
+ left.WriteString("\n")
+ if !m.cfg.HideTips {
+ left.WriteString(TipStyle.Render("Tip: Custom themes can be added as\nJSON files in ~/.config/matcha/themes/") + "\n")
+ }
+
+ // Build right panel: theme preview
+ var previewTheme theme.Theme
+ if m.cursor < len(themes) {
+ previewTheme = themes[m.cursor]
+ } else {
+ previewTheme = theme.ActiveTheme
+ }
+ preview := renderThemePreview(previewTheme, m.width)
+
+ // Join panels side by side
+ leftPanel := lipgloss.NewStyle().Width(30).Render(left.String())
+ content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", preview)
+
+ helpView := helpStyle.Render("↑/↓: navigate • enter: select • esc: back")
+
+ if m.height > 0 {
+ currentHeight := lipgloss.Height(docStyle.Render(content + "\n" + helpView))
+ gap := m.height - currentHeight
+ if gap > 0 {
+ content += strings.Repeat("\n", gap)
+ }
+ } else {
+ content += "\n\n"
+ }
+
+ return docStyle.Render(content + "\n" + helpView)
+}
+
+// renderThemePreview renders a small mockup showing how a theme looks.
+func renderThemePreview(t theme.Theme, maxWidth int) string {
+ previewWidth := maxWidth - 38 // 30 for left panel + padding/margins
+ if previewWidth < 30 {
+ previewWidth = 30
+ }
+ if previewWidth > 60 {
+ previewWidth = 60
+ }
+
+ accent := lipgloss.NewStyle().Foreground(t.Accent)
+ accentBold := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+ secondary := lipgloss.NewStyle().Foreground(t.Secondary)
+ muted := lipgloss.NewStyle().Foreground(t.MutedText)
+ dim := lipgloss.NewStyle().Foreground(t.DimText)
+ danger := lipgloss.NewStyle().Foreground(t.Danger)
+ warn := lipgloss.NewStyle().Foreground(t.Warning)
+ tip := lipgloss.NewStyle().Foreground(t.Tip).Italic(true)
+ link := lipgloss.NewStyle().Foreground(t.Link)
+ title := lipgloss.NewStyle().Foreground(t.AccentText).Background(t.AccentDark).Padding(0, 1)
+ activeTab := lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Underline(true)
+ activeFolder := lipgloss.NewStyle().Background(t.Accent).Foreground(t.Contrast).Bold(true).Padding(0, 1)
+
+ var b strings.Builder
+
+ b.WriteString(title.Render("Preview: "+t.Name) + "\n\n")
+
+ // Fake inbox tabs
+ b.WriteString(activeTab.Render("Inbox") + " " + secondary.Render("Sent") + " " + secondary.Render("Drafts") + "\n")
+ b.WriteString(secondary.Render(strings.Repeat("─", previewWidth)) + "\n")
+
+ // Fake email list
+ b.WriteString(accentBold.Render("> ") + dim.Render("Alice ") + accent.Render("Meeting tomorrow") + " " + muted.Render("2m ago") + "\n")
+ b.WriteString(" " + dim.Render("Bob ") + secondary.Render("Re: Project update") + " " + muted.Render("1h ago") + "\n")
+ b.WriteString(" " + dim.Render("Carol ") + secondary.Render("Quick question") + " " + muted.Render("3h ago") + "\n\n")
+
+ // Folder sidebar sample
+ b.WriteString(accentBold.Render("Folders") + "\n")
+ b.WriteString(activeFolder.Render(" INBOX ") + " " + secondary.Render("Sent") + " " + secondary.Render("Trash") + "\n\n")
+
+ // Status indicators
+ b.WriteString(accentBold.Render("Success: ") + accent.Render("Email sent!") + "\n")
+ b.WriteString(danger.Render("Error: ") + danger.Render("Connection failed") + "\n")
+ b.WriteString(warn.Render("Update available: v2.0") + "\n")
+ b.WriteString(tip.Render("Tip: Press ? for help") + "\n")
+ b.WriteString(link.Render("https://example.com") + "\n")
+
+ box := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(t.AccentDark).
+ Padding(1, 2).
+ Width(previewWidth).
+ Render(b.String())
+
+ return box
+}
+
// UpdateConfig updates the configuration (used when accounts are deleted).
func (m *Settings) UpdateConfig(cfg *config.Config) {
m.cfg = cfg
@@ -6,6 +6,7 @@ import (
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
+ "github.com/floatpane/matcha/theme"
)
// ASCII logo for Matcha displayed during loading screens
@@ -57,7 +58,7 @@ type Status struct {
func NewStatus(msg string) Status {
s := spinner.New()
s.Spinner = spinner.Dot
- s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+ s.Style = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent)
return Status{spinner: s, message: msg}
}
@@ -70,7 +71,7 @@ func (m Status) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m Status) View() tea.View {
- logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+ logoStyle := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent)
styledLogo := logoStyle.Render(asciiLogo)
spinnerLine := fmt.Sprintf(" %s %s", m.spinner.View(), m.message)
@@ -0,0 +1,119 @@
+package tui
+
+import (
+ "charm.land/lipgloss/v2"
+ "github.com/floatpane/matcha/theme"
+)
+
+// RebuildStyles updates all package-level style variables to match the active theme.
+// This must be called after theme.SetTheme() and at startup.
+func RebuildStyles() {
+ t := theme.ActiveTheme
+
+ // styles.go
+ DialogBoxStyle = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(t.AccentDark).
+ Padding(1, 0).
+ BorderTop(true).
+ BorderLeft(true).
+ BorderRight(true).
+ BorderBottom(true)
+
+ HelpStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+ TipStyle = lipgloss.NewStyle().Foreground(t.Tip).Italic(true)
+ SuccessStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+ InfoStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+
+ H1Style = lipgloss.NewStyle().
+ Foreground(t.Accent).
+ Bold(true).
+ Align(lipgloss.Center)
+
+ H2Style = lipgloss.NewStyle().
+ Foreground(t.Accent).
+ Bold(false).
+ Align(lipgloss.Center)
+
+ // choice.go
+ titleStyle = lipgloss.NewStyle().Foreground(t.AccentText).Background(t.AccentDark).Padding(0, 1)
+ logoStyle = lipgloss.NewStyle().Foreground(t.Accent)
+ listHeader = lipgloss.NewStyle().Foreground(t.SubtleText).PaddingBottom(1)
+ itemStyle = lipgloss.NewStyle().PaddingLeft(2)
+ selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(t.Accent)
+
+ // settings.go
+ accountItemStyle = lipgloss.NewStyle().PaddingLeft(2)
+ selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(t.Accent)
+ accountEmailStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+ dangerStyle = lipgloss.NewStyle().Foreground(t.Danger)
+ settingsFocusedStyle = lipgloss.NewStyle().Foreground(t.Accent)
+ settingsBlurredStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+
+ // composer.go
+ suggestionStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+ selectedSuggestionStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+ suggestionBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Secondary).Padding(0, 1)
+ focusedStyle = lipgloss.NewStyle().Foreground(t.Accent)
+ blurredStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+ noStyle = lipgloss.NewStyle()
+ helpStyle = lipgloss.NewStyle().Foreground(t.SubtleText)
+ focusedButton = focusedStyle.Render("[ Send ]")
+ blurredButton = blurredStyle.Render("[ Send ]")
+ emailRecipientStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+ attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(t.Secondary)
+ fromSelectorStyle = lipgloss.NewStyle().Foreground(t.Accent)
+ smimeToggleStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(t.Secondary)
+
+ // inbox.go
+ tabStyle = lipgloss.NewStyle().Padding(0, 2)
+ activeTabStyle = lipgloss.NewStyle().Padding(0, 2).Foreground(t.Accent).Bold(true).Underline(true)
+ tabBarStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
+ dateStyle = lipgloss.NewStyle().Foreground(t.MutedText)
+ senderStyle = lipgloss.NewStyle().Foreground(t.DimText).Bold(true)
+
+ // folder_inbox.go
+ sidebarStyle = lipgloss.NewStyle().
+ Width(sidebarWidth).
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderRight(true).
+ PaddingRight(1).
+ PaddingLeft(1)
+ sidebarTitleStyle = lipgloss.NewStyle().
+ Foreground(t.Accent).
+ Bold(true).
+ PaddingBottom(1)
+ folderStyle = lipgloss.NewStyle().
+ PaddingLeft(1).
+ PaddingRight(1)
+ activeFolderStyle = lipgloss.NewStyle().
+ PaddingLeft(1).
+ PaddingRight(1).
+ Background(t.Accent).
+ Foreground(t.Contrast).
+ Bold(true)
+ moveOverlayStyle = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(t.AccentDark).
+ Padding(1, 2)
+ moveOverlayTitleStyle = lipgloss.NewStyle().
+ Foreground(t.Accent).
+ Bold(true).
+ PaddingBottom(1)
+ moveItemStyle = lipgloss.NewStyle().
+ PaddingLeft(1)
+ moveSelectedItemStyle = lipgloss.NewStyle().
+ PaddingLeft(1).
+ Foreground(t.Accent).
+ Bold(true)
+
+ // filepicker.go
+ filePickerItemStyle = lipgloss.NewStyle().PaddingLeft(2)
+ filePickerSelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(t.Accent)
+ directoryStyle = lipgloss.NewStyle().Foreground(t.Directory)
+ fileSizeStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+
+ // trash_archive.go
+ mailboxTabStyle = lipgloss.NewStyle().Padding(0, 3)
+ activeMailboxTabStyle = lipgloss.NewStyle().Padding(0, 3).Foreground(t.Accent).Bold(true).Underline(true)
+}
@@ -20,12 +20,15 @@ import (
"charm.land/lipgloss/v2"
"github.com/PuerkitoBio/goquery"
+ "github.com/floatpane/matcha/theme"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/renderer/html"
"golang.org/x/sys/unix"
)
-var linkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9BC4FF")) // Cyan
+func linkStyle() lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Link)
+}
// getTerminalCellSize returns the height of a terminal cell in pixels.
// It queries the terminal using TIOCGWINSZ to get both character and pixel dimensions.
@@ -143,14 +146,14 @@ func hyperlink(url, text string) string {
if supported {
// Use OSC 8 hyperlink sequence for supported terminals
- return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, linkStyle.Render(text))
+ return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, linkStyle().Render(text))
} else {
// Fallback to plain text format for unsupported terminals
// Use HTML-encoded angle brackets to prevent HTML parser from treating them as tags
if text == url {
- return fmt.Sprintf("<%s>", linkStyle.Render(url))
+ return fmt.Sprintf("<%s>", linkStyle().Render(url))
}
- return fmt.Sprintf("%s <%s>", linkStyle.Render(text), linkStyle.Render(url))
+ return fmt.Sprintf("%s <%s>", linkStyle().Render(text), linkStyle().Render(url))
}
}
@@ -771,7 +774,7 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
if hyperlinkSupported() {
s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
} else {
- s.ReplaceWithHtml(fmt.Sprintf("\n %s \n", linkStyle.Render(fmt.Sprintf("[Image: %s, %s]", alt, src))))
+ s.ReplaceWithHtml(fmt.Sprintf("\n %s \n", linkStyle().Render(fmt.Sprintf("[Image: %s, %s]", alt, src))))
}
})
@@ -829,16 +832,18 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
return bodyStyle.Render(text), placements, nil
}
-// quoteBoxStyle is the style for the quoted reply box border
-var quoteBoxStyle = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("240")).
- Padding(0, 1).
- Foreground(lipgloss.Color("240"))
+func quoteBoxStyle() lipgloss.Style {
+ return lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(theme.ActiveTheme.Secondary).
+ Padding(0, 1).
+ Foreground(theme.ActiveTheme.Secondary)
+}
-// quoteHeaderStyle is the style for the header line in the quote box
-var quoteHeaderStyle = lipgloss.NewStyle().
- Foreground(lipgloss.Color("240"))
+func quoteHeaderStyle() lipgloss.Style {
+ return lipgloss.NewStyle().
+ Foreground(theme.ActiveTheme.Secondary)
+}
// styleQuotedReplies detects quoted reply sections and styles them in a box
func styleQuotedReplies(text string) string {
@@ -950,11 +955,11 @@ func renderQuoteBox(from, date string, lines []string) string {
var header string
if from != "" || date != "" {
if from != "" && date != "" {
- header = quoteHeaderStyle.Render(from + " " + date)
+ header = quoteHeaderStyle().Render(from + " " + date)
} else if from != "" {
- header = quoteHeaderStyle.Render(from)
+ header = quoteHeaderStyle().Render(from)
} else {
- header = quoteHeaderStyle.Render(date)
+ header = quoteHeaderStyle().Render(date)
}
}
@@ -969,5 +974,5 @@ func renderQuoteBox(from, date string, lines []string) string {
boxContent = content
}
- return quoteBoxStyle.Render(boxContent)
+ return quoteBoxStyle().Render(boxContent)
}