Skip to main content

bevy_color_macros/
lib.rs

1//! Procedural macros for bevy-color-palettes
2
3use proc_macro::TokenStream;
4use proc_macro_crate::{FoundCrate, crate_name};
5use proc_macro2::Span;
6use quote::{format_ident, quote};
7use syn::braced;
8use syn::token::{Colon, Comma};
9use syn::{
10	Ident, LitStr, Result, parenthesized,
11	parse::{Parse, ParseStream},
12	parse_macro_input,
13};
14
15/// Get the crate root (rename-safe)
16fn crate_root() -> proc_macro2::TokenStream {
17	match crate_name("bevy-color-palettes") {
18		Ok(FoundCrate::Itself) => quote!(crate),
19		Ok(FoundCrate::Name(name)) => {
20			let ident = Ident::new(&name, Span::call_site());
21			quote!(::#ident)
22		}
23		Err(_) => quote!(::bevy_color_palettes),
24	}
25}
26
27/// A color definition with a name and RGBA values
28struct ColorDef {
29	name: String,
30	r8: u8,
31	g8: u8,
32	b8: u8,
33	a8: u8,
34}
35
36/// A palette definition with a name and a list of color definitions
37struct PaletteDef {
38	name: Ident,
39	colors: Vec<ColorDef>,
40}
41
42/// Parse a color definition from a stream
43impl Parse for ColorDef {
44	fn parse(input: ParseStream) -> Result<Self> {
45		// Parse the color name as a string literal
46		let name_lit = input.parse::<LitStr>()?;
47		let name = name_lit.value();
48
49		// Parse the colon
50		input.parse::<Colon>()?;
51
52		// Check if the next token is a string
53		if input.peek(syn::LitStr) {
54			let lit = input.parse::<LitStr>()?;
55			let (r8, g8, b8, a8) = parse_hex_color(&lit.value(), lit.span())?;
56			Ok(ColorDef {
57				name,
58				r8,
59				g8,
60				b8,
61				a8,
62			})
63		} else {
64			// Otherwise, fall back to the version in weirdboi_bevy_colour (so we can merge upstream palettes with no issue)
65			let content;
66			parenthesized!(content in input);
67
68			let r: f32 = content.parse::<syn::LitFloat>()?.base10_parse()?;
69			content.parse::<Comma>()?;
70			let g: f32 = content.parse::<syn::LitFloat>()?.base10_parse()?;
71			content.parse::<Comma>()?;
72			let b: f32 = content.parse::<syn::LitFloat>()?.base10_parse()?;
73
74			#[allow(clippy::cast_possible_truncation)]
75			let r32 = (r * 255.0) as i32;
76			#[allow(clippy::cast_possible_truncation)]
77			let g32 = (g * 255.0) as i32;
78			#[allow(clippy::cast_possible_truncation)]
79			let b32 = (b * 255.0) as i32;
80
81			let r8 = if r32 <= 255 {
82				#[allow(clippy::cast_possible_truncation)]
83				#[allow(clippy::cast_sign_loss)]
84				if r32 >= 0 { r32 as u8 } else { 0 }
85			} else {
86				255
87			};
88			let g8 = if g32 <= 255 {
89				#[allow(clippy::cast_possible_truncation)]
90				#[allow(clippy::cast_sign_loss)]
91				if g32 >= 0 { g32 as u8 } else { 0 }
92			} else {
93				255
94			};
95			let b8 = if b32 <= 255 {
96				#[allow(clippy::cast_possible_truncation)]
97				#[allow(clippy::cast_sign_loss)]
98				if b32 >= 0 { b32 as u8 } else { 0 }
99			} else {
100				255
101			};
102
103			let a8 = 255;
104
105			Ok(ColorDef {
106				name,
107				r8,
108				g8,
109				b8,
110				a8,
111			})
112		}
113	}
114}
115
116fn parse_hex_color(html_hex_color_string: &str, span: Span) -> Result<(u8, u8, u8, u8)> {
117	let hex = html_hex_color_string
118		.strip_prefix('#')
119		.ok_or_else(|| syn::Error::new(span, "HTML hex color string must start with '#'."))?; // So we can support named colors in the future.
120
121	let (r, g, b, a) = match hex.len() {
122		8 => {
123			let r = u8::from_str_radix(&hex[0..2], 16)
124				.map_err(|_| syn::Error::new(span, "#RRggbbaa RR was invalid."))?;
125			let g = u8::from_str_radix(&hex[2..4], 16)
126				.map_err(|_| syn::Error::new(span, "#rrGGbbaa GG was invalid."))?;
127			let b = u8::from_str_radix(&hex[4..6], 16)
128				.map_err(|_| syn::Error::new(span, "#rrggBBaa BB was invalid."))?;
129			let a = u8::from_str_radix(&hex[6..8], 16)
130				.map_err(|_| syn::Error::new(span, "#rrggbbAA AA was invalid."))?;
131			(r, g, b, a)
132		}
133		6 => {
134			let r = u8::from_str_radix(&hex[0..2], 16)
135				.map_err(|_| syn::Error::new(span, "#RRggbb RR was invalid."))?;
136			let g = u8::from_str_radix(&hex[2..4], 16)
137				.map_err(|_| syn::Error::new(span, "#rrGGbb GG was invalid."))?;
138			let b = u8::from_str_radix(&hex[4..6], 16)
139				.map_err(|_| syn::Error::new(span, "#rrggBB BB was invalid."))?;
140			(r, g, b, 255_u8)
141		}
142		4 => {
143			let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
144				.map_err(|_| syn::Error::new(span, "#Rgba R was invalid."))?;
145			let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
146				.map_err(|_| syn::Error::new(span, "#rGba G was invalid."))?;
147			let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
148				.map_err(|_| syn::Error::new(span, "#rgBa B was invalid."))?;
149			let a = u8::from_str_radix(&hex[3..4].repeat(2), 16)
150				.map_err(|_| syn::Error::new(span, "#rgbA A was invalid."))?;
151			(r, g, b, a)
152		}
153		3 => {
154			let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
155				.map_err(|_| syn::Error::new(span, "#Rgb R was invalid."))?;
156			let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
157				.map_err(|_| syn::Error::new(span, "#rGb G was invalid."))?;
158			let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
159				.map_err(|_| syn::Error::new(span, "#rgB B was invalid."))?;
160			(r, g, b, 255_u8)
161		}
162		_ => {
163			return Err(syn::Error::new(
164				span,
165				"Hex color must be in #rrggbb, #rrggbbaa, #rgb, or #rgba format.",
166			));
167		}
168	};
169
170	Ok((r, g, b, a))
171}
172
173/// Parse a palette definition from a stream
174impl Parse for PaletteDef {
175	fn parse(input: ParseStream) -> Result<Self> {
176		// Parse the palette name as an identifier
177		let name = input.parse::<Ident>()?;
178
179		// Parse the color definitions inside braces
180		let content;
181		braced!(content in input);
182
183		// Parse the color definitions
184		let mut colors = Vec::new();
185		while !content.is_empty() {
186			colors.push(content.parse::<ColorDef>()?);
187
188			// Parse the comma if there is one and we're not at the end
189			if content.peek(Comma) {
190				content.parse::<Comma>()?;
191			} else if !content.is_empty() {
192				return Err(content.error("Expected comma or end of block."));
193			}
194		}
195
196		Ok(PaletteDef { name, colors })
197	}
198}
199
200/// Convert a string to `UPPER_SNAKE_CASE`
201fn to_upper_snake_case(s: &str) -> String {
202	let mut result = String::new();
203	for (i, c) in s.chars().enumerate() {
204		if c.is_uppercase() && i > 0 && !s.chars().nth(i - 1).unwrap_or(' ').is_uppercase() {
205			result.push('_');
206		}
207		result.push(c.to_ascii_uppercase());
208	}
209	result
210}
211
212/// Convert a string to `lower_snake_case`
213fn to_lower_snake_case(s: &str) -> String {
214	let mut result = String::new();
215	for (i, c) in s.chars().enumerate() {
216		if c.is_uppercase() {
217			if i > 0 && !s.chars().nth(i - 1).unwrap_or(' ').is_uppercase() {
218				result.push('_');
219			}
220			result.push(c.to_ascii_lowercase());
221		} else {
222			result.push(c);
223		}
224	}
225	result
226}
227
228/// Generate a palette struct and implementation
229///
230/// # Example
231///
232/// ```ignore
233/// use macros::palette;
234/// palette!(MyPalette {
235///     "red": "#ff0000",
236///     "green": "#00ff00",
237///     "blue": "#0000ff",
238/// });
239/// ```
240///
241/// This will generate:
242///
243/// ```ignore
244/// pub struct MyPalette;
245///
246/// impl MyPalette {
247///     /// RED; <div style="background-color: rgb(100% 0% 0% 100%); height: 20px"></div>
248///     pub const RED: Color = Color::new(255, 0, 0, 255);
249///     /// GREEN; <div style="background-color: rgb(0% 100% 0% 100%); height: 20px"></div>
250///     pub const GREEN: Color = Color::new(0, 255, 0, 255);
251///     /// BLUE; <div style="background-color: rgb(0% 0% 100% 100%); height: 20px"></div>
252///     pub const BLUE: Color = Color::new(0, 0, 255, 255);
253///
254///     pub fn red(&self) -> Color { Self::RED }
255///     pub fn green(&self) -> Color { Self::GREEN }
256///     pub fn blue(&self) -> Color { Self::BLUE }
257/// }
258///
259/// impl Palette for MyPalette {
260///     // Implementation of Palette trait
261/// }
262/// ```
263#[allow(clippy::too_many_lines)]
264#[proc_macro]
265pub fn palette(input: TokenStream) -> TokenStream {
266	// Parse the input
267	let palette_def = parse_macro_input!(input as PaletteDef);
268
269	// Generate the struct definition
270	let palette_name = &palette_def.name;
271	let crate_root = crate_root();
272	let crate_color = quote! { #crate_root::color::Color };
273
274	// Generate the color constants and methods
275	let mut const_defs = Vec::new();
276	let mut method_defs = Vec::new();
277	let mut get_color_match_arms = Vec::new();
278	let mut color_values = Vec::new();
279	let mut doc_grid_entry = Vec::new();
280	let mut color_rgba = Vec::new();
281
282	for color in &palette_def.colors {
283		let color_name = &color.name;
284		let normalised = normalize_color_name(color_name);
285		let const_name = Ident::new(&to_upper_snake_case(color_name), Span::call_site());
286		let method_name = format_ident!("{}", to_lower_snake_case(color_name));
287
288		let r8 = color.r8;
289		let g8 = color.g8;
290		let b8 = color.b8;
291		let a8 = color.a8;
292
293		let current_rgba = format!(
294			"rgba({:.0}%, {:.0}%, {:.0}%, {:.2})",
295			f32::from(r8) * (100.0 / 255.0),
296			f32::from(g8) * (100.0 / 255.0),
297			f32::from(b8) * (100.0 / 255.0),
298			f32::from(a8) / 255.0,
299		);
300
301		let rustdoc =
302			format!(r#"<div style="background-color: {current_rgba}; height: 20px"></div>"#,);
303
304		let funcdoc =
305			format!(r"Returns the value of [{palette_name}::{const_name}]<br/>{rustdoc}",);
306		color_rgba.push(current_rgba);
307
308		// Add the constant definition
309		const_defs.push(quote! {
310			#[doc = #rustdoc]
311			pub const #const_name: #crate_color = #crate_color::new(#r8, #g8, #b8, #a8);
312		});
313
314		// Add the method definition (static, no &self)
315		method_defs.push(quote! {
316			#[doc = #funcdoc]
317			pub const fn #method_name() -> #crate_color {
318				Self::#const_name
319			}
320		});
321
322		// Add the match arm for get_color
323		get_color_match_arms.push(quote! {
324			#normalised => Some(Self::#const_name),
325		});
326
327		// Add the color value for the iterator
328		color_values.push(quote! {
329			Self::#const_name,
330		});
331
332		doc_grid_entry.push(format!(
333			r#"<div style="background-color: rgba({:.0}% {:.0}% {:.0}% {:.2}); width: 20px; height: 20px;"></div>"#,
334			f32::from(r8) * (100.0 / 255.0),
335			f32::from(g8) * (100.0 / 255.0),
336			f32::from(b8) * (100.0 / 255.0),
337			f32::from(a8) / 255.0,
338		));
339	}
340
341	// Get the number of colors
342	let num_colors = palette_def.colors.len();
343	let num_colors_lit = proc_macro2::Literal::usize_unsuffixed(num_colors);
344	let iter_type = quote! { ::core::array::IntoIter<#crate_color, #num_colors_lit> };
345
346	let root_doc = format!(
347		r#"<span>The {palette_name} palette, containing {num_colors} colors.</span> <br />
348		<div style="display: grid; grid-template-columns: repeat(8, 20px); grid-auto-rows: 20px;">{}</div>"#,
349		doc_grid_entry.join("\n")
350	);
351
352	// Generate the final code
353	let expanded = quote! {
354		#[doc = #root_doc]
355		#[derive(::core::fmt::Debug, ::core::clone::Clone, ::core::marker::Copy)]
356		pub struct #palette_name;
357
358		impl #palette_name {
359			#(#const_defs)*
360
361			#(#method_defs)*
362
363			// Helper function to normalize color names for case-insensitive and format-agnostic comparison
364			#[doc(hidden)]
365			fn normalize_color_name(s: &str) -> String {
366				s.chars()
367					.filter(|c| c.is_alphanumeric())
368					.map(|c| c.to_ascii_lowercase())
369					.collect()
370			}
371
372			/// Returns all colors in the palette as a fixed-size array
373			pub const fn all() -> [#crate_color; #num_colors_lit] {
374				[#(#color_values)*]
375			}
376
377			/// Returns the number of colours in the palette
378			pub const fn len() -> usize {
379				#num_colors_lit
380			}
381
382			/// Returns an iterator over all colors in the palette
383			pub fn iter() -> impl Iterator<Item = #crate_color> {
384				Self::all().into_iter()
385			}
386
387			/// Returns a color by name, if it exists in the palette
388			pub fn get(name: &str) -> Option<#crate_color> {
389				let name = Self::normalize_color_name(name);
390				match name.as_str() {
391					#(#get_color_match_arms)*
392					_ => None,
393				}
394			}
395		}
396
397		impl IntoIterator for #palette_name {
398			type Item = #crate_color;
399			type IntoIter = #iter_type;
400
401			fn into_iter(self) -> Self::IntoIter {
402				Self::all().into_iter()
403			}
404		}
405
406		impl<'a> IntoIterator for &'a #palette_name {
407			type Item = #crate_color;
408			type IntoIter = #iter_type;
409
410			fn into_iter(self) -> Self::IntoIter {
411				#palette_name::all().into_iter()
412			}
413		}
414	};
415
416	// Return the generated code
417	expanded.into()
418}
419
420fn normalize_color_name(s: &str) -> String {
421	s.chars()
422		.filter(|c| c.is_alphanumeric())
423		.map(|c| c.to_ascii_lowercase())
424		.collect()
425}