Skip to main content

flow_core/
theme.rs

1use serde::{Deserialize, Serialize};
2
3/// Available themes.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
5#[serde(rename_all = "kebab-case")]
6pub enum Theme {
7    #[default]
8    Default,
9    Claude,
10    Twitter,
11    NeoBrutalism,
12    RetroArcade,
13    Aurora,
14    Business,
15}
16
17impl Theme {
18    pub const ALL: &[Self] = &[
19        Self::Default,
20        Self::Claude,
21        Self::Twitter,
22        Self::NeoBrutalism,
23        Self::RetroArcade,
24        Self::Aurora,
25        Self::Business,
26    ];
27
28    #[must_use]
29    pub const fn name(&self) -> &'static str {
30        match self {
31            Self::Default => "Default",
32            Self::Claude => "Claude",
33            Self::Twitter => "Twitter",
34            Self::NeoBrutalism => "Neo Brutalism",
35            Self::RetroArcade => "Retro Arcade",
36            Self::Aurora => "Aurora",
37            Self::Business => "Business",
38        }
39    }
40
41    #[must_use]
42    pub const fn css_class(&self) -> &'static str {
43        match self {
44            Self::Default => "",
45            Self::Claude => "theme-claude",
46            Self::Twitter => "theme-twitter",
47            Self::NeoBrutalism => "theme-neo-brutalism",
48            Self::RetroArcade => "theme-retro-arcade",
49            Self::Aurora => "theme-aurora",
50            Self::Business => "theme-business",
51        }
52    }
53
54    #[must_use]
55    #[allow(clippy::too_many_lines)]
56    pub const fn colors(&self) -> ThemeColors {
57        match self {
58            Self::Default => ThemeColors {
59                primary: AnsiColor(36),
60                secondary: AnsiColor(244),
61                background: AnsiColor(0),
62                foreground: AnsiColor(15),
63                accent: AnsiColor(33),
64                muted: AnsiColor(240),
65                border: AnsiColor(238),
66                pending: AnsiColor(226),
67                in_progress: AnsiColor(51),
68                done: AnsiColor(46),
69                blocked: AnsiColor(196),
70                error: AnsiColor(196),
71                warning: AnsiColor(214),
72                css_primary: "#06b6d4",
73                css_secondary: "#64748b",
74                css_background: "#0f172a",
75                css_foreground: "#f8fafc",
76            },
77            Self::Claude => ThemeColors {
78                primary: AnsiColor(208),
79                secondary: AnsiColor(180),
80                background: AnsiColor(230),
81                foreground: AnsiColor(236),
82                accent: AnsiColor(215),
83                muted: AnsiColor(249),
84                border: AnsiColor(223),
85                pending: AnsiColor(226),
86                in_progress: AnsiColor(51),
87                done: AnsiColor(46),
88                blocked: AnsiColor(196),
89                error: AnsiColor(196),
90                warning: AnsiColor(214),
91                css_primary: "#d97706",
92                css_secondary: "#92400e",
93                css_background: "#fffbeb",
94                css_foreground: "#1c1917",
95            },
96            Self::Twitter => ThemeColors {
97                primary: AnsiColor(33),
98                secondary: AnsiColor(244),
99                background: AnsiColor(16),
100                foreground: AnsiColor(15),
101                accent: AnsiColor(39),
102                muted: AnsiColor(240),
103                border: AnsiColor(236),
104                pending: AnsiColor(226),
105                in_progress: AnsiColor(51),
106                done: AnsiColor(46),
107                blocked: AnsiColor(196),
108                error: AnsiColor(196),
109                warning: AnsiColor(214),
110                css_primary: "#1d9bf0",
111                css_secondary: "#71767b",
112                css_background: "#000000",
113                css_foreground: "#e7e9ea",
114            },
115            Self::NeoBrutalism => ThemeColors {
116                primary: AnsiColor(226),
117                secondary: AnsiColor(201),
118                background: AnsiColor(15),
119                foreground: AnsiColor(16),
120                accent: AnsiColor(196),
121                muted: AnsiColor(250),
122                border: AnsiColor(16),
123                pending: AnsiColor(226),
124                in_progress: AnsiColor(51),
125                done: AnsiColor(46),
126                blocked: AnsiColor(196),
127                error: AnsiColor(196),
128                warning: AnsiColor(214),
129                css_primary: "#facc15",
130                css_secondary: "#ec4899",
131                css_background: "#ffffff",
132                css_foreground: "#000000",
133            },
134            Self::RetroArcade => ThemeColors {
135                primary: AnsiColor(46),
136                secondary: AnsiColor(51),
137                background: AnsiColor(16),
138                foreground: AnsiColor(46),
139                accent: AnsiColor(201),
140                muted: AnsiColor(22),
141                border: AnsiColor(28),
142                pending: AnsiColor(226),
143                in_progress: AnsiColor(51),
144                done: AnsiColor(46),
145                blocked: AnsiColor(196),
146                error: AnsiColor(196),
147                warning: AnsiColor(214),
148                css_primary: "#22c55e",
149                css_secondary: "#06b6d4",
150                css_background: "#000000",
151                css_foreground: "#22c55e",
152            },
153            Self::Aurora => ThemeColors {
154                primary: AnsiColor(141),
155                secondary: AnsiColor(80),
156                background: AnsiColor(17),
157                foreground: AnsiColor(189),
158                accent: AnsiColor(213),
159                muted: AnsiColor(60),
160                border: AnsiColor(61),
161                pending: AnsiColor(226),
162                in_progress: AnsiColor(51),
163                done: AnsiColor(46),
164                blocked: AnsiColor(196),
165                error: AnsiColor(196),
166                warning: AnsiColor(214),
167                css_primary: "#8b5cf6",
168                css_secondary: "#14b8a6",
169                css_background: "#0f0a2a",
170                css_foreground: "#c4b5fd",
171            },
172            Self::Business => ThemeColors {
173                primary: AnsiColor(24),
174                secondary: AnsiColor(244),
175                background: AnsiColor(255),
176                foreground: AnsiColor(235),
177                accent: AnsiColor(32),
178                muted: AnsiColor(250),
179                border: AnsiColor(252),
180                pending: AnsiColor(226),
181                in_progress: AnsiColor(51),
182                done: AnsiColor(46),
183                blocked: AnsiColor(196),
184                error: AnsiColor(196),
185                warning: AnsiColor(214),
186                css_primary: "#1e3a5f",
187                css_secondary: "#64748b",
188                css_background: "#ffffff",
189                css_foreground: "#1e293b",
190            },
191        }
192    }
193
194    /// Cycle to the next theme.
195    #[must_use]
196    pub fn next(&self) -> Self {
197        let all = Self::ALL;
198        let idx = all.iter().position(|t| t == self).unwrap_or(0);
199        all[(idx + 1) % all.len()]
200    }
201}
202
203/// ANSI 256-color code.
204#[derive(Debug, Clone, Copy)]
205pub struct AnsiColor(pub u8);
206
207impl AnsiColor {
208    /// ANSI escape for foreground color.
209    #[must_use]
210    pub fn fg(&self) -> String {
211        format!("\x1b[38;5;{}m", self.0)
212    }
213
214    /// ANSI escape for background color.
215    #[must_use]
216    pub fn bg(&self) -> String {
217        format!("\x1b[48;5;{}m", self.0)
218    }
219}
220
221/// Complete color palette for a theme, with both ANSI (TUI) and CSS (web) representations.
222#[derive(Debug, Clone)]
223pub struct ThemeColors {
224    // ANSI colors for TUI
225    pub primary: AnsiColor,
226    pub secondary: AnsiColor,
227    pub background: AnsiColor,
228    pub foreground: AnsiColor,
229    pub accent: AnsiColor,
230    pub muted: AnsiColor,
231    pub border: AnsiColor,
232
233    // Status colors (ANSI)
234    pub pending: AnsiColor,
235    pub in_progress: AnsiColor,
236    pub done: AnsiColor,
237    pub blocked: AnsiColor,
238    pub error: AnsiColor,
239    pub warning: AnsiColor,
240
241    // CSS values for web
242    pub css_primary: &'static str,
243    pub css_secondary: &'static str,
244    pub css_background: &'static str,
245    pub css_foreground: &'static str,
246}
247
248impl ThemeColors {
249    /// Generate CSS custom properties for web injection.
250    #[must_use]
251    pub fn to_css_variables(&self) -> String {
252        format!(
253            ":root {{\n  --primary: {};\n  --secondary: {};\n  --background: {};\n  --foreground: {};\n}}",
254            self.css_primary, self.css_secondary, self.css_background, self.css_foreground
255        )
256    }
257}
258
259impl std::fmt::Display for Theme {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        write!(f, "{}", self.name())
262    }
263}
264
265impl std::str::FromStr for Theme {
266    type Err = String;
267
268    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
269        match s.to_lowercase().as_str() {
270            "default" => Ok(Self::Default),
271            "claude" => Ok(Self::Claude),
272            "twitter" => Ok(Self::Twitter),
273            "neo-brutalism" | "neobrutalism" | "neo_brutalism" => Ok(Self::NeoBrutalism),
274            "retro-arcade" | "retroarcade" | "retro_arcade" => Ok(Self::RetroArcade),
275            "aurora" => Ok(Self::Aurora),
276            "business" => Ok(Self::Business),
277            _ => Err(format!("unknown theme: {s}")),
278        }
279    }
280}