ltk/theme/schema/
refs.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

use std::collections::HashMap;

use super::super::ThemeError;

/// Read the top-level `colors` object from the raw JSON value and validate
/// each entry is a literal hex string. Returns an empty map if no `colors`
/// section is present.
pub( super ) fn extract_colors_map( value: &serde_json::Value )
	-> Result<HashMap<String, String>, ThemeError>
{
	let mut out = HashMap::new();
	let raw = match value.get( "colors" )
	{
		Some( c ) => c,
		None      => return Ok( out ),
	};
	let map = raw.as_object().ok_or_else( ||
		ThemeError::InvalidColor( "`colors` must be an object".to_string() )
	)?;
	for ( k, v ) in map
	{
		let s = v.as_str().ok_or_else( ||
			ThemeError::InvalidColor( format!( "`colors.{}` must be a hex string", k ) )
		)?;
		// References inside `colors` itself are not supported — every entry
		// must be a literal hex value the rest of the document can point at.
		let h = s.trim_start_matches( '#' );
		let valid = ( h.len() == 6 || h.len() == 8 ) && h.chars().all( |c| c.is_ascii_hexdigit() );
		if !valid
		{
			return Err( ThemeError::InvalidColor(
				format!( "`colors.{}` = `{}` (expected #RRGGBB or #RRGGBBAA)", k, s )
			));
		}
		out.insert( k.clone(), s.to_string() );
	}
	Ok( out )
}

/// Read the top-level `gradients` object from the raw JSON value. Each
/// entry must be a paint object (`{ "type": "linear", "angle_deg": …,
/// "stops": [ … ] }`); shape correctness beyond "is an object" is left
/// for downstream serde deserialisation to enforce when the gradient is
/// substituted into a paint position.
pub( super ) fn extract_gradients_map( value: &serde_json::Value )
	-> Result<HashMap<String, serde_json::Value>, ThemeError>
{
	extract_token_map( value, "gradients", "paint object", serde_json::Value::is_object )
}

/// Read the top-level `inset_stacks` object from the raw JSON value. Each
/// entry must be an array of inset-shadow definitions — substituted
/// wholesale into an `inset_shadows` field on a surface.
pub( super ) fn extract_inset_stacks_map( value: &serde_json::Value )
	-> Result<HashMap<String, serde_json::Value>, ThemeError>
{
	extract_token_map( value, "inset_stacks", "array of inset-shadow definitions", serde_json::Value::is_array )
}

/// Generic helper for the `gradients` / `inset_stacks` extractors above:
/// reads a top-level object, validates each entry against `valid`, and
/// returns the entries cloned into a `HashMap`. Returns an empty map if
/// `section` is absent.
fn extract_token_map(
	value:         &serde_json::Value,
	section:       &str,
	expected_kind: &str,
	valid:         impl Fn( &serde_json::Value ) -> bool,
) -> Result<HashMap<String, serde_json::Value>, ThemeError>
{
	let mut out = HashMap::new();
	let raw = match value.get( section )
	{
		Some( c ) => c,
		None      => return Ok( out ),
	};
	let map = raw.as_object().ok_or_else( ||
		ThemeError::InvalidColor( format!( "`{}` must be an object", section ) )
	)?;
	for ( k, v ) in map
	{
		if !valid( v )
		{
			return Err( ThemeError::InvalidColor(
				format!( "`{}.{}` must be {}", section, k, expected_kind )
			));
		}
		out.insert( k.clone(), v.clone() );
	}
	Ok( out )
}

/// Walk `value` and replace `@name` / `@name/AA` references with their
/// resolved form. A reference whose name appears in `tokens` (gradient
/// objects or inset-shadow arrays) is substituted by the cloned token
/// value; a reference whose name appears in `colors` is substituted by
/// its hex literal. After a substitution the new node is recursed into
/// so a gradient that uses `@cyan-soft` in its stops or an inset stack
/// that uses `@glass-hi` is fully expanded.
pub( super ) fn resolve_refs(
	value:  &mut serde_json::Value,
	colors: &HashMap<String, String>,
	tokens: &HashMap<String, serde_json::Value>,
) -> Result<(), ThemeError>
{
	// Step 1: if this node is a `@ref` string, resolve and overwrite.
	let replacement = match value
	{
		serde_json::Value::String( s ) => match s.strip_prefix( '@' )
		{
			Some( rest ) => Some( resolve_one_ref( rest, colors, tokens )? ),
			None         => None,
		},
		_ => None,
	};
	if let Some( new_value ) = replacement
	{
		*value = new_value;
		// The replacement may itself contain references (typically a
		// gradient with `@color` stops). Recurse into the new node.
		resolve_refs( value, colors, tokens )?;
		return Ok( () );
	}
	// Step 2: walk children of objects / arrays.
	match value
	{
		serde_json::Value::Object( map ) =>
		{
			for ( _, v ) in map.iter_mut()
			{
				resolve_refs( v, colors, tokens )?;
			}
		}
		serde_json::Value::Array( arr ) =>
		{
			for v in arr.iter_mut()
			{
				resolve_refs( v, colors, tokens )?;
			}
		}
		_ => {}
	}
	Ok( () )
}

/// Resolve a single `@name[/AA]` reference. Looks up `tokens` first
/// (gradient or inset-stack), then `colors`. The `/AA` alpha override
/// only applies to colour references; using it on a non-colour token is
/// rejected.
fn resolve_one_ref(
	rest:   &str,
	colors: &HashMap<String, String>,
	tokens: &HashMap<String, serde_json::Value>,
) -> Result<serde_json::Value, ThemeError>
{
	let ( name, alpha_hex ) = match rest.split_once( '/' )
	{
		Some( ( n, a ) ) => ( n, Some( a ) ),
		None             => ( rest, None ),
	};
	if let Some( tok ) = tokens.get( name )
	{
		if let Some( a ) = alpha_hex
		{
			return Err( ThemeError::InvalidColor( format!(
				"@{} is a paint/inset token — alpha override `/{}` is not applicable", name, a
			)));
		}
		return Ok( tok.clone() );
	}
	let base = colors.get( name )
		.ok_or_else( || ThemeError::UnknownColorRef( name.to_string() ) )?;
	let h    = base.trim_start_matches( '#' );
	// `extract_colors_map` already validated the base is 6 or 8 hex digits.
	let rgb  = &h[0..6];
	let s = match alpha_hex
	{
		Some( a ) =>
		{
			if a.len() != 2 || u8::from_str_radix( a, 16 ).is_err()
			{
				return Err( ThemeError::InvalidColor(
					format!( "@{}/{} (alpha must be two hex digits)", name, a )
				));
			}
			format!( "#{}{}", rgb.to_uppercase(), a.to_uppercase() )
		}
		None => format!( "#{}", h.to_uppercase() ),
	};
	Ok( serde_json::Value::String( s ) )
}