1use std::fmt::Write as _;
8
9use zenith_core::color::{parse_rgb, rgb_to_hex};
10use zenith_core::theme::{PaletteSpec, Rgb, Scheme, synth_palette};
11
12#[derive(Debug, Clone, Copy)]
14pub struct Shape {
15 pub radius_box: f64,
17 pub radius_field: f64,
19 pub radius_selector: f64,
21 pub border: f64,
23 pub depth: bool,
25 pub noise: bool,
27}
28
29impl Default for Shape {
30 fn default() -> Self {
31 Self {
32 radius_box: 16.0,
33 radius_field: 8.0,
34 radius_selector: 8.0,
35 border: 1.0,
36 depth: false,
37 noise: false,
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
45pub struct ThemeInput<'a> {
46 pub name: &'a str,
48 pub scheme: Scheme,
50 pub primary: &'a str,
52 pub secondary: Option<&'a str>,
54 pub accent: Option<&'a str>,
56 pub neutral: Option<&'a str>,
58 pub info: Option<&'a str>,
60 pub success: Option<&'a str>,
62 pub warning: Option<&'a str>,
64 pub error: Option<&'a str>,
66 pub shape: Shape,
68}
69
70#[derive(Debug)]
72pub struct ThemeErr {
73 pub message: String,
75 pub exit_code: u8,
77}
78
79fn hex(label: &str, s: &str) -> Result<Rgb, ThemeErr> {
80 parse_rgb(s).ok_or_else(|| ThemeErr {
81 message: format!("invalid {label} colour '{s}': expected '#rrggbb' (lowercase hex)"),
82 exit_code: 2,
83 })
84}
85
86fn opt(label: &str, s: Option<&str>) -> Result<Option<Rgb>, ThemeErr> {
87 match s {
88 Some(v) => Ok(Some(hex(label, v)?)),
89 None => Ok(None),
90 }
91}
92
93fn px(v: f64) -> String {
95 if v.fract() == 0.0 {
96 format!("{}", v as i64)
97 } else {
98 format!("{v}")
99 }
100}
101
102pub fn new(input: &ThemeInput) -> Result<String, ThemeErr> {
104 let spec = PaletteSpec {
105 scheme: input.scheme,
106 primary: hex("primary", input.primary)?,
107 secondary: opt("secondary", input.secondary)?,
108 accent: opt("accent", input.accent)?,
109 neutral: opt("neutral", input.neutral)?,
110 info: opt("info", input.info)?,
111 success: opt("success", input.success)?,
112 warning: opt("warning", input.warning)?,
113 error: opt("error", input.error)?,
114 };
115 let palette = synth_palette(&spec);
116 let raw = emit(input, &palette);
117
118 crate::commands::fmt::run(&raw)
121 .map(|r| String::from_utf8_lossy(&r.formatted).into_owned())
122 .map_err(|e| ThemeErr {
123 message: format!(
124 "internal: synthesized theme failed to format: {}",
125 e.message
126 ),
127 exit_code: 2,
128 })
129}
130
131fn emit(input: &ThemeInput, palette: &[(&'static str, Rgb)]) -> String {
132 let name = input.name;
133 let scheme = match input.scheme {
134 Scheme::Light => "light",
135 Scheme::Dark => "dark",
136 };
137 let mut s = String::new();
138 let _ = writeln!(
139 s,
140 "// @zenith/theme.{name} — generated by `zenith theme new`"
141 );
142 let _ = writeln!(
143 s,
144 "// scheme: {scheme} | depth: {} | noise: {} (1 = apply grain-overlay recipe)",
145 input.shape.depth as u8, input.shape.noise as u8
146 );
147 let _ = writeln!(s, "zenith version=1 {{");
148 let _ = writeln!(
149 s,
150 " project id=\"@zenith/theme.{name}\" name=\"Theme: {name}\""
151 );
152 let _ = writeln!(s, " tokens format=\"zenith-token-v1\" {{");
153 for (id, rgb) in palette {
154 let _ = writeln!(
155 s,
156 " token id=\"color.{id}\" type=\"color\" value=\"{}\"",
157 rgb_to_hex(*rgb)
158 );
159 }
160 let _ = writeln!(
161 s,
162 " token id=\"radius.box\" type=\"dimension\" value=(px){}",
163 px(input.shape.radius_box)
164 );
165 let _ = writeln!(
166 s,
167 " token id=\"radius.field\" type=\"dimension\" value=(px){}",
168 px(input.shape.radius_field)
169 );
170 let _ = writeln!(
171 s,
172 " token id=\"radius.selector\" type=\"dimension\" value=(px){}",
173 px(input.shape.radius_selector)
174 );
175 let _ = writeln!(
176 s,
177 " token id=\"border.width\" type=\"dimension\" value=(px){}",
178 px(input.shape.border)
179 );
180 let _ = writeln!(
181 s,
182 " token id=\"space.unit\" type=\"dimension\" value=(px)4"
183 );
184 let _ = writeln!(
185 s,
186 " token id=\"font.heading\" type=\"fontFamily\" value=\"Noto Sans\""
187 );
188 let _ = writeln!(
189 s,
190 " token id=\"font.body\" type=\"fontFamily\" value=\"Noto Sans\""
191 );
192 let _ = writeln!(
193 s,
194 " token id=\"size.h1\" type=\"dimension\" value=(px)64"
195 );
196 let _ = writeln!(
197 s,
198 " token id=\"size.h2\" type=\"dimension\" value=(px)40"
199 );
200 let _ = writeln!(
201 s,
202 " token id=\"size.body\" type=\"dimension\" value=(px)28"
203 );
204 let _ = writeln!(
205 s,
206 " token id=\"size.caption\" type=\"dimension\" value=(px)18"
207 );
208 if input.shape.depth {
209 let _ = writeln!(
210 s,
211 " token id=\"color.shadow\" type=\"color\" value=\"#0000002a\""
212 );
213 let _ = writeln!(
214 s,
215 " token id=\"shadow.depth\" type=\"shadow\" {{ layer dx=(px)0 dy=(px)8 blur=(px)24 color=(token)\"color.shadow\" }}"
216 );
217 }
218 let _ = writeln!(s, " }}");
219 let _ = writeln!(s, " styles {{}}");
220 let _ = writeln!(
221 s,
222 " document id=\"theme.{name}.preview\" title=\"Theme {name}\" {{"
223 );
224 let _ = writeln!(
225 s,
226 " page id=\"pg\" w=(px)760 h=(px)220 background=(token)\"color.base.100\" {{"
227 );
228 let _ = writeln!(
229 s,
230 " text id=\"t.title\" x=(px)40 y=(px)28 w=(px)680 h=(px)52 fill=(token)\"color.base.content\" font-family=(token)\"font.heading\" font-size=(token)\"size.h2\" {{ span \"{name}\" }}"
231 );
232 let sh = if input.shape.depth {
233 " shadow=(token)\"shadow.depth\""
234 } else {
235 ""
236 };
237 for (x, role) in [
238 (40, "primary"),
239 (220, "secondary"),
240 (400, "accent"),
241 (580, "neutral"),
242 ] {
243 let _ = writeln!(
244 s,
245 " rect id=\"sw.{role}\" x=(px){x} y=(px)104 w=(px)160 h=(px)84 fill=(token)\"color.{role}\" radius=(token)\"radius.box\"{sh}"
246 );
247 }
248 let _ = writeln!(s, " }}");
249 let _ = writeln!(s, " }}");
250 let _ = writeln!(s, "}}");
251 s
252}