diff --git a/internal/theme/theme.go b/internal/theme/theme.go index 436ed02..0ab1e14 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -1,38 +1,174 @@ +// SPDX-FileCopyrightText: 2025 Daniel Morris +// SPDX-License-Identifier: MIT + package theme import ( + "fmt" "image/color" + "strconv" + "strings" "github.com/charmbracelet/lipgloss" ) -// Theme defines the color scheme for the application. -type Theme struct { - // Primary highlight color (teal) - used for Fang CLI rendering - Highlight color.RGBA +// Config represents the raw theme configuration values. +type Config struct { + Text string + Muted string + Highlight string + Success string + Worry string +} - // Lipgloss color values for TUI - HighlightLipgloss lipgloss.Color - ActiveText lipgloss.Color - MutedText lipgloss.Color - SuccessText lipgloss.Color +// Theme defines the theme for the TUI and CLI help docs. +type Theme struct { + Text PaletteColor + Muted PaletteColor + Highlight PaletteColor + Success PaletteColor + Worry PaletteColor // UI characters CursorChar string // Character shown next to selected todo item } -// Default returns the default teal theme. +// PaletteColor keeps a hex colour string for LipGloss and an RGBA version for Fang. +type PaletteColor struct { + raw string + rgba color.RGBA +} + +// LipGloss converts the color to a lipgloss.Color. +func (c PaletteColor) LipGloss() lipgloss.Color { + return lipgloss.Color(c.raw) +} + +// RGBA exposes an image/color compliant representation of the color. +func (c PaletteColor) RGBA() color.RGBA { + return c.rgba +} + +// Default returns the default theme. func Default() Theme { - teal := color.RGBA{R: 0x58, G: 0xC5, B: 0xC7, A: 0xFF} + return MustFromConfig(Config{ + Text: "#FFFFFF", + Muted: "#696969", + Highlight: "#58C5C7", + Success: "#99CC00", + Worry: "#FF7676", + }) +} + +// MustFromConfig creates a Theme from a Config and panics if parsing fails. +func MustFromConfig(cfg Config) Theme { + t, err := FromConfig(cfg) + if err != nil { + panic(fmt.Sprintf("invalid theme configuration: %v", err)) + } + return t +} + +// FromConfig converts a Config into a ready-to-use Theme. +func FromConfig(cfg Config) (Theme, error) { + text, err := parsePaletteColor(cfg.Text) + if err != nil { + return Theme{}, fmt.Errorf("parse text colour: %w", err) + } + + muted, err := parsePaletteColor(cfg.Muted) + if err != nil { + return Theme{}, fmt.Errorf("parse muted colour: %w", err) + } + + highlight, err := parsePaletteColor(cfg.Highlight) + if err != nil { + return Theme{}, fmt.Errorf("parse highlight colour: %w", err) + } + + success, err := parsePaletteColor(cfg.Success) + if err != nil { + return Theme{}, fmt.Errorf("parse success colour: %w", err) + } + + worry, err := parsePaletteColor(cfg.Worry) + if err != nil { + return Theme{}, fmt.Errorf("parse worry colour: %w", err) + } return Theme{ - Highlight: teal, - HighlightLipgloss: "#58C5C7", - ActiveText: "15", - MutedText: "240", - SuccessText: "42", - CursorChar: "❯", + Text: text, + Muted: muted, + Highlight: highlight, + Success: success, + Worry: worry, + CursorChar: "❯", + }, nil +} + +func parsePaletteColor(input string) (PaletteColor, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return PaletteColor{}, fmt.Errorf("colour cannot be blank") + } + + hexValue, err := normalizeHex(trimmed) + if err != nil { + return PaletteColor{}, err + } + + rgba, err := hexToRGBA(hexValue) + if err != nil { + return PaletteColor{}, err + } + + return PaletteColor{raw: hexValue, rgba: rgba}, nil +} + +func normalizeHex(input string) (string, error) { + s := strings.TrimPrefix(input, "#") + s = strings.ToLower(s) + + switch len(s) { + case 3: + s = fmt.Sprintf("%c%c%c%c%c%c", s[0], s[0], s[1], s[1], s[2], s[2]) + case 6: + // Sound. + default: + return "", fmt.Errorf("hex colour must be 3 or 6 characters, got %d", len(s)) + } + + for _, r := range s { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + return "", fmt.Errorf("invalid hex digit %q", r) + } + } + + return "#" + strings.ToUpper(s), nil +} + +func hexToRGBA(hex string) (color.RGBA, error) { + s := strings.TrimPrefix(hex, "#") + if len(s) != 6 { + return color.RGBA{}, fmt.Errorf("hex colour must be 6 characters, got %d", len(s)) + } + + r, err := strconv.ParseUint(s[0:2], 16, 8) + if err != nil { + return color.RGBA{}, fmt.Errorf("parse red component: %w", err) + } + + g, err := strconv.ParseUint(s[2:4], 16, 8) + if err != nil { + return color.RGBA{}, fmt.Errorf("parse green component: %w", err) + } + + b, err := strconv.ParseUint(s[4:6], 16, 8) + if err != nil { + return color.RGBA{}, fmt.Errorf("parse blue component: %w", err) } + + return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 0xFF}, nil } // ContainerStyle returns the style for the main container. @@ -44,15 +180,15 @@ func (t *Theme) ContainerStyle() lipgloss.Style { func (t *Theme) TabStyle() lipgloss.Style { return lipgloss.NewStyle(). Padding(0, 2). - Foreground(t.MutedText) + Foreground(t.Muted.LipGloss()) } // ActiveTabStyle returns the style for the active tab. func (t *Theme) ActiveTabStyle() lipgloss.Style { return lipgloss.NewStyle(). Padding(0, 2). - Foreground(t.ActiveText). - Background(t.HighlightLipgloss). + Foreground(t.Text.LipGloss()). + Background(t.Highlight.LipGloss()). Bold(true) } @@ -63,27 +199,32 @@ func (t *Theme) ItemStyle() lipgloss.Style { // HighlightedItemStyle returns the style for the currently selected todo item. func (t *Theme) HighlightedItemStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(t.HighlightLipgloss) + return lipgloss.NewStyle().Foreground(t.Highlight.LipGloss()) } // CompletedTitleStyle returns the style for completed todo titles. func (t *Theme) CompletedTitleStyle() lipgloss.Style { return lipgloss.NewStyle(). - Foreground(t.MutedText). + Foreground(t.Muted.LipGloss()). Strikethrough(true) } // DescriptionStyle returns the style for todo descriptions. func (t *Theme) DescriptionStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(t.MutedText) + return lipgloss.NewStyle().Foreground(t.Muted.LipGloss()) } // HelpStyle returns the style for help text. func (t *Theme) HelpStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(t.MutedText) + return lipgloss.NewStyle().Foreground(t.Muted.LipGloss()) } // SuccessStyle returns the style for success indicators. func (t *Theme) SuccessStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(t.SuccessText) + return lipgloss.NewStyle().Foreground(t.Success.LipGloss()) +} + +// WorryStyle returns the style for warning/error indicators. +func (t *Theme) WorryStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(t.Worry.LipGloss()) } diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go new file mode 100644 index 0000000..8bb9504 --- /dev/null +++ b/internal/theme/theme_test.go @@ -0,0 +1,74 @@ +package theme + +import ( + "image/color" + "testing" +) + +func TestParsePaletteColorHex(t *testing.T) { + c, err := parsePaletteColor("#abcdef") + if err != nil { + t.Fatalf("parsePaletteColor returned error: %v", err) + } + + want := color.RGBA{R: 0xAB, G: 0xCD, B: 0xEF, A: 0xFF} + if c.RGBA() != want { + t.Fatalf("expected RGBA %v, got %v", want, c.RGBA()) + } + + if c.LipGloss() != "#ABCDEF" { + t.Fatalf("expected lipgloss colour #ABCDEF, got %s", c.LipGloss()) + } +} + +func TestFromConfigInvalid(t *testing.T) { + cfg := Config{ + Text: "bogus", + Muted: "#333", + Highlight: "#58C5C7", + Success: "#99CC00", + Worry: "#ff7676", + } + + if _, err := FromConfig(cfg); err == nil { + t.Fatal("expected FromConfig to error for invalid colour") + } +} + +func TestDefaultTheme(t *testing.T) { + th := Default() + + if th.Highlight.LipGloss() != "#58C5C7" { + t.Fatalf("expected default highlight #58C5C7, got %s", th.Highlight.LipGloss()) + } + + if th.Success.RGBA() == (color.RGBA{}) { + t.Fatal("expected success colour to be initialised") + } + + if th.Worry.RGBA() == (color.RGBA{}) { + t.Fatal("expected worry colour to be initialised") + } +} + +func TestParsePaletteColorShorthand(t *testing.T) { + c, err := parsePaletteColor("#abc") + if err != nil { + t.Fatalf("parsePaletteColor returned error: %v", err) + } + + want := color.RGBA{R: 0xAA, G: 0xBB, B: 0xCC, A: 0xFF} + if c.RGBA() != want { + t.Fatalf("expected RGBA %v, got %v", want, c.RGBA()) + } + + if c.LipGloss() != "#AABBCC" { + t.Fatalf("expected lipgloss colour #AABBCC, got %s", c.LipGloss()) + } +} + +func TestParsePaletteColorRejectsANSI(t *testing.T) { + if _, err := parsePaletteColor("15"); err == nil { + t.Fatal("expected ANSI value to be rejected") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 58c8846..f07f1e4 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -17,14 +17,14 @@ import ( "github.com/unfunco/t/internal/theme" ) -// Tab represents a tab in the UI. +// Tab represents a tab in the UI, which corresponds to a todo list. type Tab int const ( TabToday Tab = iota TabTomorrow TabTodo - tabCount + TabCount ) // String implements the fmt.Stringer interface and returns the title of a Tab. @@ -150,7 +150,7 @@ type Model struct { titleInput textinput.Model descriptionInput textarea.Model formTargetList Tab - editingIndex int // Index of todo being edited + editingIndex int } // New creates a new TUI model with the provided todo lists. @@ -296,7 +296,6 @@ func (m *Model) View() string { var b strings.Builder - // Only show tabs if there are any todos in any list if m.hasAnyTodos() { b.WriteString(m.renderTabs()) b.WriteString("\n\n") @@ -313,7 +312,7 @@ func (m *Model) View() string { func (m *Model) renderTabs() string { var tabs []string - for i := TabToday; i < tabCount; i++ { + for i := TabToday; i < TabCount; i++ { var style lipgloss.Style if i == m.activeTab { style = m.theme.ActiveTabStyle() @@ -356,7 +355,7 @@ func (m *Model) renderList() string { var titleStyle lipgloss.Style if i == m.cursor { if todo.Completed { - titleStyle = m.theme.HighlightedItemStyle().Foreground(m.theme.MutedText).Strikethrough(true) + titleStyle = m.theme.HighlightedItemStyle().Foreground(m.theme.Muted.LipGloss()).Strikethrough(true) } else { titleStyle = m.theme.HighlightedItemStyle() } @@ -370,7 +369,7 @@ func (m *Model) renderList() string { var descStyle lipgloss.Style if i == m.cursor { - descStyle = m.theme.HighlightedItemStyle().Foreground(m.theme.MutedText) + descStyle = m.theme.HighlightedItemStyle().Foreground(m.theme.Muted.LipGloss()) } else { descStyle = m.theme.DescriptionStyle() } @@ -451,12 +450,12 @@ func (m *Model) cursorDown() { // nextTab moves to the next tab. func (m *Model) nextTab() { - m.activeTab = (m.activeTab + 1) % tabCount + m.activeTab = (m.activeTab + 1) % TabCount } // previousTab moves to the previous tab. func (m *Model) previousTab() { - m.activeTab = (m.activeTab + tabCount - 1) % tabCount + m.activeTab = (m.activeTab + TabCount - 1) % TabCount } // toggleCurrent toggles the completion status of the current todo. @@ -627,12 +626,12 @@ func (m *Model) updateFormFocus() tea.Cmd { // nextFormList cycles to the next list option. func (m *Model) nextFormList() { - m.formTargetList = (m.formTargetList + 1) % tabCount + m.formTargetList = (m.formTargetList + 1) % TabCount } // previousFormList cycles to the previous list option. func (m *Model) previousFormList() { - m.formTargetList = (m.formTargetList + tabCount - 1) % tabCount + m.formTargetList = (m.formTargetList + TabCount - 1) % TabCount } // getListByTab returns the todo list for the given tab. @@ -693,7 +692,7 @@ func (m *Model) renderForm() string { b.WriteString(listLabel + "\n") - for i := TabToday; i < tabCount; i++ { + for i := TabToday; i < TabCount; i++ { var listStyle lipgloss.Style if i == m.formTargetList { if m.formField == FormFieldList { diff --git a/main.go b/main.go index 2afff75..27ad496 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "image/color" "os" "github.com/charmbracelet/fang" @@ -10,14 +11,6 @@ import ( "github.com/unfunco/t/internal/theme" ) -// customColorScheme returns a custom color scheme with teal headings. -func customColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { - scheme := fang.AnsiColorScheme(c) - // Override the title color to match the teal used in the TUI - scheme.Title = theme.Default().Highlight - return scheme -} - func main() { if err := fang.Execute( context.Background(), @@ -28,3 +21,26 @@ func main() { os.Exit(1) } } + +func customColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { + scheme := fang.AnsiColorScheme(c) + th := theme.Default() + + scheme.Base = th.Text.RGBA() + scheme.Description = th.Muted.RGBA() + scheme.Comment = th.Muted.RGBA() + scheme.Flag = th.Highlight.RGBA() + scheme.FlagDefault = th.Muted.RGBA() + scheme.Command = th.Highlight.RGBA() + scheme.Program = th.Highlight.RGBA() + scheme.QuotedString = th.Success.RGBA() + scheme.Argument = th.Text.RGBA() + scheme.DimmedArgument = th.Muted.RGBA() + scheme.Help = th.Muted.RGBA() + scheme.Dash = th.Text.RGBA() + scheme.Title = th.Highlight.RGBA() + scheme.ErrorHeader = [2]color.Color{th.Text.RGBA(), th.Worry.RGBA()} + scheme.ErrorDetails = th.Worry.RGBA() + + return scheme +}