diff --git a/config/config.go b/config/config.go index 5326850..ca5a8b8 100644 --- a/config/config.go +++ b/config/config.go @@ -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{ diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index 6610397..2ef6f10 100644 --- a/docs/docs/Configuration.md +++ b/docs/docs/Configuration.md @@ -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` diff --git a/docs/docs/Features/Themes.md b/docs/docs/Features/Themes.md new file mode 100644 index 0000000..df89fc2 --- /dev/null +++ b/docs/docs/Features/Themes.md @@ -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. + +![Theme Settings](../assets/theme_settings.png) + +## 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`). diff --git a/docs/docs/Features/UI.md b/docs/docs/Features/UI.md index c2ad613..ccff8ca 100644 --- a/docs/docs/Features/UI.md +++ b/docs/docs/Features/UI.md @@ -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. diff --git a/main.go b/main.go index b97b420..f053734 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/screenshots/theme_settings.tape b/screenshots/theme_settings.tape new file mode 100644 index 0000000..cdc834b --- /dev/null +++ b/screenshots/theme_settings.tape @@ -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 diff --git a/theme/theme.go b/theme/theme.go new file mode 100644 index 0000000..ccdf6b4 --- /dev/null +++ b/theme/theme.go @@ -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 +} diff --git a/tui/choice.go b/tui/choice.go index f122116..5f27157 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -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" diff --git a/tui/drafts.go b/tui/drafts.go index afdd282..3f9ef47 100644 --- a/tui/drafts.go +++ b/tui/drafts.go @@ -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)) } diff --git a/tui/email_view.go b/tui/email_view.go index f90d8ef..898605e 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -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]") } } diff --git a/tui/inbox.go b/tui/inbox.go index 7e1ef7e..957dd47 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -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") diff --git a/tui/settings.go b/tui/settings.go index 6e7d968..0f9aa7e 100644 --- a/tui/settings.go +++ b/tui/settings.go @@ -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 diff --git a/tui/styles.go b/tui/styles.go index 9bd3ce1..0c876cb 100644 --- a/tui/styles.go +++ b/tui/styles.go @@ -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) diff --git a/tui/theme.go b/tui/theme.go new file mode 100644 index 0000000..a93190f --- /dev/null +++ b/tui/theme.go @@ -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) +} diff --git a/view/html.go b/view/html.go index 54c4ecb..74f87b7 100644 --- a/view/html.go +++ b/view/html.go @@ -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) }