diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 342e1bd..1711f4d 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -319,7 +319,7 @@ func (m *Model) initAlertHuhForm() tea.Cmd { ).Title("Gotify Settings").WithHideFunc(func() bool { return m.alertFormData.AlertType != "gotify" }), - ).WithTheme(huh.ThemeDracula()) + ).WithTheme(m.theme.HuhTheme()) return m.huhForm.Init() } diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index 0c4d135..688be7c 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -11,7 +11,7 @@ import ( "github.com/charmbracelet/lipgloss" ) -var maintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7")) +var maintStyle lipgloss.Style type maintFormData struct { Title string @@ -187,7 +187,7 @@ func (m *Model) initMaintHuhForm() tea.Cmd { ).Title("Duration").WithHideFunc(func() bool { return m.maintFormData.Type == "incident" }), - ).WithTheme(huh.ThemeDracula()) + ).WithTheme(m.theme.HuhTheme()) return m.huhForm.Init() } diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index be9ae1d..72b162a 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -37,10 +37,7 @@ func typeIcon(siteType string, collapsed bool) string { } } -var siteGroupStyle = lipgloss.NewStyle(). - Padding(0, 1). - Bold(true). - Foreground(lipgloss.Color("#7D56F4")) +var siteGroupStyle lipgloss.Style type siteFormData struct { Name string @@ -340,7 +337,7 @@ func (m Model) viewSitesTab() string { if len(m.sites) == 0 { welcome := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#7D56F4")). + BorderForeground(m.theme.Accent). Padding(1, 3). Render( titleStyle.Render("Go-Upkeep") + "\n\n" + @@ -651,7 +648,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd { ).Title("Advanced").WithHideFunc(func() bool { return m.siteFormData.SiteType == "group" }), - ).WithTheme(huh.ThemeDracula()) + ).WithTheme(m.theme.HuhTheme()) return m.huhForm.Init() } diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 019bb03..4793933 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -94,7 +94,7 @@ func (m *Model) initUserHuhForm() tea.Cmd { huh.NewOption("Admin", "admin"), ).Value(&m.userFormData.Role), ).Title("SSH Access"), - ).WithTheme(huh.ThemeDracula()) + ).WithTheme(m.theme.HuhTheme()) return m.huhForm.Init() } diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index 7c4e654..0f37e72 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -6,25 +6,11 @@ import ( ) var ( - tableHeaderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D56F4")). - Bold(true). - Padding(0, 1) - - tableCellStyle = lipgloss.NewStyle().Padding(0, 1) - - tableSelectedStyle = lipgloss.NewStyle(). - Padding(0, 1). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#3b3b5c")) - - tableBorderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#444")) - - tableZebraStyle = lipgloss.NewStyle(). - Padding(0, 1). - Background(lipgloss.Color("#1a1a2e")) + tableHeaderStyle lipgloss.Style + tableCellStyle lipgloss.Style + tableSelectedStyle lipgloss.Style + tableBorderStyle lipgloss.Style + tableZebraStyle lipgloss.Style ) type StyleOverride func(row, col int) *lipgloss.Style diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..4c0af6b --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,191 @@ +package tui + +import ( + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +type Theme struct { + Name string + + // Base layers + Bg lipgloss.Color + Surface lipgloss.Color + Panel lipgloss.Color + Border lipgloss.Color + + // Text + Fg lipgloss.Color + Muted lipgloss.Color + Subtle lipgloss.Color + + // Semantic + Success lipgloss.Color + Warning lipgloss.Color + Danger lipgloss.Color + Info lipgloss.Color + Accent lipgloss.Color + Purple lipgloss.Color + + // Table + ZebraBg lipgloss.Color + + // Selection + SelectedFg lipgloss.Color + SelectedBg lipgloss.Color +} + +var themes = []Theme{ + themeFlexokiDark, + themeTokyoNight, + themeCatppuccinMocha, + themeNord, + themeGruvbox, +} + +var themeFlexokiDark = Theme{ + Name: "Flexoki Dark", + Bg: "#1C1B1A", + Surface: "#282726", + Panel: "#343331", + Border: "#575653", + Fg: "#CECDC3", + Muted: "#878580", + Subtle: "#6F6E69", + Success: "#879A39", + Warning: "#D0A215", + Danger: "#D14D41", + Info: "#4385BE", + Accent: "#3AA99F", + Purple: "#8B7EC8", + ZebraBg: "#222120", + SelectedFg: "#FFFCF0", + SelectedBg: "#403E3C", +} + +var themeTokyoNight = Theme{ + Name: "Tokyo Night", + Bg: "#1a1b26", + Surface: "#24283b", + Panel: "#292e42", + Border: "#3b4261", + Fg: "#c0caf5", + Muted: "#a9b1d6", + Subtle: "#565f89", + Success: "#9ece6a", + Warning: "#e0af68", + Danger: "#f7768e", + Info: "#7aa2f7", + Accent: "#7dcfff", + Purple: "#bb9af7", + ZebraBg: "#1c1d28", + SelectedFg: "#c0caf5", + SelectedBg: "#292e42", +} + +var themeGruvbox = Theme{ + Name: "Gruvbox", + Bg: "#282828", + Surface: "#3c3836", + Panel: "#504945", + Border: "#665c54", + Fg: "#ebdbb2", + Muted: "#bdae93", + Subtle: "#7c6f64", + Success: "#b8bb26", + Warning: "#fabd2f", + Danger: "#fb4934", + Info: "#83a598", + Accent: "#8ec07c", + Purple: "#d3869b", + ZebraBg: "#2a2a2a", + SelectedFg: "#fbf1c7", + SelectedBg: "#504945", +} + +var themeCatppuccinMocha = Theme{ + Name: "Catppuccin Mocha", + Bg: "#1e1e2e", + Surface: "#313244", + Panel: "#45475a", + Border: "#585b70", + Fg: "#cdd6f4", + Muted: "#a6adc8", + Subtle: "#6c7086", + Success: "#a6e3a1", + Warning: "#f9e2af", + Danger: "#f38ba8", + Info: "#89b4fa", + Accent: "#94e2d5", + Purple: "#cba6f7", + ZebraBg: "#232334", + SelectedFg: "#cdd6f4", + SelectedBg: "#45475a", +} + +var themeNord = Theme{ + Name: "Nord", + Bg: "#2e3440", + Surface: "#3b4252", + Panel: "#434c5e", + Border: "#4c566a", + Fg: "#d8dee9", + Muted: "#d8dee9", + Subtle: "#4c566a", + Success: "#a3be8c", + Warning: "#ebcb8b", + Danger: "#bf616a", + Info: "#81a1c1", + Accent: "#88c0d0", + Purple: "#b48ead", + ZebraBg: "#323845", + SelectedFg: "#eceff4", + SelectedBg: "#434c5e", +} + +func (t Theme) HuhTheme() *huh.Theme { + ht := huh.ThemeBase() + + ht.Focused.Base = ht.Focused.Base.BorderForeground(t.Border) + ht.Focused.Card = ht.Focused.Base + ht.Focused.Title = ht.Focused.Title.Foreground(t.Accent).Bold(true) + ht.Focused.NoteTitle = ht.Focused.NoteTitle.Foreground(t.Accent).Bold(true).MarginBottom(1) + ht.Focused.Description = ht.Focused.Description.Foreground(t.Muted) + ht.Focused.ErrorIndicator = ht.Focused.ErrorIndicator.Foreground(t.Danger) + ht.Focused.ErrorMessage = ht.Focused.ErrorMessage.Foreground(t.Danger) + ht.Focused.SelectSelector = ht.Focused.SelectSelector.Foreground(t.Purple) + ht.Focused.NextIndicator = ht.Focused.NextIndicator.Foreground(t.Purple) + ht.Focused.PrevIndicator = ht.Focused.PrevIndicator.Foreground(t.Purple) + ht.Focused.Option = ht.Focused.Option.Foreground(t.Fg) + ht.Focused.MultiSelectSelector = ht.Focused.MultiSelectSelector.Foreground(t.Purple) + ht.Focused.SelectedOption = ht.Focused.SelectedOption.Foreground(t.Success) + ht.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(t.Success).SetString("✓ ") + ht.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.Subtle).SetString("• ") + ht.Focused.UnselectedOption = ht.Focused.UnselectedOption.Foreground(t.Fg) + ht.Focused.FocusedButton = ht.Focused.FocusedButton.Foreground(t.Bg).Background(t.Accent) + ht.Focused.Next = ht.Focused.FocusedButton + ht.Focused.BlurredButton = ht.Focused.BlurredButton.Foreground(t.Fg).Background(t.Surface) + ht.Focused.TextInput.Cursor = ht.Focused.TextInput.Cursor.Foreground(t.Accent) + ht.Focused.TextInput.Placeholder = ht.Focused.TextInput.Placeholder.Foreground(t.Subtle) + ht.Focused.TextInput.Prompt = ht.Focused.TextInput.Prompt.Foreground(t.Purple) + + ht.Blurred = ht.Focused + ht.Blurred.Base = ht.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) + ht.Blurred.Card = ht.Blurred.Base + ht.Blurred.NextIndicator = lipgloss.NewStyle() + ht.Blurred.PrevIndicator = lipgloss.NewStyle() + + ht.Group.Title = ht.Focused.Title + ht.Group.Description = ht.Focused.Description + + return ht +} + +func themeByName(name string) Theme { + for _, t := range themes { + if t.Name == name { + return t + } + } + return themes[0] +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index eaef5d6..d6a4642 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -20,16 +20,34 @@ import ( ) var ( - subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9ca0b0", Dark: "#565f89"}) - specialStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) - warnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F0E442", Dark: "#F0E442"}) - dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#F25D94"}) - titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Bold(true) - - activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(lipgloss.Color("#7D56F4")).Foreground(lipgloss.Color("#7D56F4")).Bold(true).Padding(0, 1) - inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.AdaptiveColor{Light: "#AAA", Dark: "#555"}) + subtleStyle lipgloss.Style + specialStyle lipgloss.Style + warnStyle lipgloss.Style + dangerStyle lipgloss.Style + titleStyle lipgloss.Style + activeTab lipgloss.Style + inactiveTab lipgloss.Style ) +func applyTheme(t Theme) { + subtleStyle = lipgloss.NewStyle().Foreground(t.Subtle) + specialStyle = lipgloss.NewStyle().Foreground(t.Success) + warnStyle = lipgloss.NewStyle().Foreground(t.Warning) + dangerStyle = lipgloss.NewStyle().Foreground(t.Danger) + titleStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true) + activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(t.Accent).Foreground(t.Accent).Bold(true).Padding(0, 1) + inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted) + + tableHeaderStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1) + tableCellStyle = lipgloss.NewStyle().Padding(0, 1) + tableSelectedStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg) + tableBorderStyle = lipgloss.NewStyle().Foreground(t.Border) + tableZebraStyle = lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg) + + siteGroupStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent) + maintStyle = lipgloss.NewStyle().Foreground(t.Purple) +} + var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} const ( @@ -81,9 +99,11 @@ type Model struct { deleteName string deleteTab int - collapsed map[int]bool - store store.Store - engine *monitor.Engine + collapsed map[int]bool + store store.Store + engine *monitor.Engine + theme Theme + themeIndex int // harmonica animation state pulseSpring harmonica.Spring @@ -107,6 +127,19 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model { z := zone.New() spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4) collapsed := loadCollapsed(s) + + themeName, _ := s.GetPreference("theme") + theme := themeByName(themeName) + themeIdx := 0 + for i, t := range themes { + if t.Name == theme.Name { + themeIdx = i + break + } + } + + applyTheme(theme) + return Model{ state: stateDashboard, logViewport: vpLogs, @@ -117,6 +150,8 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model { zones: z, pulseSpring: spring, collapsed: collapsed, + theme: theme, + themeIndex: themeIdx, } } @@ -458,6 +493,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshData() } } + case "T": + m.themeIndex = (m.themeIndex + 1) % len(themes) + m.theme = themes[m.themeIndex] + applyTheme(m.theme) + _ = m.store.SetPreference("theme", m.theme.Name) case "d", "backspace": if m.currentTab == 0 && len(m.sites) > 0 { m.deleteID = m.sites[m.cursor].ID @@ -723,7 +763,7 @@ func (m Model) View() string { hint := subtleStyle.Render("[y] Confirm [n] Cancel") box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#F25D94")). + BorderForeground(m.theme.Danger). Padding(1, 3). Render(msg + "\n\n" + hint) return lipgloss.NewStyle().Padding(2, 4).Render(box) @@ -875,19 +915,19 @@ func (m Model) viewDashboard() string { var footer string if m.filterMode { - cursor := lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Render("│") + cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│") footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear") } else { var keys string switch m.currentTab { case 0: - keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit" + keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit" case 4: - keys = "[n]New [x]End [d]Del [Tab]Switch [q]Quit" + keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" case 5: - keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit" + keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit" default: - keys = "[Tab]Switch [q]Quit" + keys = "[T]Theme [Tab]Switch [q]Quit" } footer = "\n" + statusLine + " " + subtleStyle.Render(keys) if m.filterText != "" && m.currentTab == 0 {