morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
theme.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <array>
6#include <cmath>
7#include <cstddef>
8#include <cstdint>
9#include <expected>
10#include <functional>
11#include <initializer_list>
12#include <map>
13#include <optional>
14#include <string>
15#include <string_view>
16#include <utility>
17#include <variant>
18#include <vector>
19
20#include "bundle.hpp"
21#include "value.hpp"
22
23/// @file
24/// @brief Theme tokens (`ui-theme/1`, spec 6 §6): DTCG 2025.10 token files read and checked, layered into a variant,
25/// their aliases resolved, and the closed `ui-theme/1` set filled from them with its defaults. No toolkit: the
26/// Qt Quick renderer applies the result (`qt_quick::applyTheme`).
27///
28/// Specified in `docs/spec/ui/theme.md`.
29
30namespace morph::ui {
31
32/// @brief The vocabulary of a theme index.
33inline constexpr std::string_view kThemeVocabulary = "ui-theme/1";
34
35/// @brief The most tokens one token file may hold.
36inline constexpr std::size_t kMaxThemeTokens = 16384;
37
38/// @brief The most variants, densities or surfaces a theme index may list, each.
39inline constexpr std::size_t kMaxThemeVariants = 16;
40
41/// @brief The most names a theme index's icon set may hold.
42inline constexpr std::size_t kMaxThemeIcons = 256;
43
44/// @brief The icon names the built-in components draw: an empty state without an icon of its own, a banner's tone,
45/// a chevron that opens and closes, a close button, and a date input's calendar. A bundle's icon set replaces
46/// any of them by name.
47inline constexpr std::array<std::string_view, 11> kFrameworkIcons = {
48 "empty", "tone.info", "tone.ok", "tone.warn", "tone.err", "nav.back",
49 "nav.forward", "nav.expand", "nav.collapse", "close", "date.pick"};
50
51/// @brief Whether @p name is an icon name: `[a-z][a-z0-9.-]{0,63}`.
52/// @param name The name.
53/// @return It.
54[[nodiscard]] inline bool isIconName(std::string_view name) {
55 if (name.empty() || name.size() > 64 || name.front() < 'a' || name.front() > 'z') {
56 return false;
57 }
58 return std::ranges::all_of(name, [](char const chr) {
59 return (chr >= 'a' && chr <= 'z') || (chr >= '0' && chr <= '9') || chr == '.' || chr == '-';
60 });
61}
62
63/// @brief The glyph a text renderer draws for a framework icon name.
64/// @param name The icon name.
65/// @return The glyph, or empty for a name that has none (`empty`, which a text renderer draws as nothing).
66[[nodiscard]] inline std::string_view iconGlyph(std::string_view name) {
67 static constexpr std::array<std::pair<std::string_view, std::string_view>, 10> kGlyphs = {{
68 {"tone.ok", "✓"},
69 {"tone.warn", "!"},
70 {"tone.err", "✗"},
71 {"tone.info", "i"},
72 {"nav.expand", "▸"},
73 {"nav.collapse", "▾"},
74 {"nav.back", "‹"},
75 {"nav.forward", "›"},
76 {"close", "×"},
77 {"date.pick", "▦"},
78 }};
79 for (auto const& [icon, glyph] : kGlyphs) {
80 if (icon == name) {
81 return glyph;
82 }
83 }
84 return {};
85}
86
87/// @brief The longest chain of aliases a token may resolve through.
88inline constexpr std::size_t kMaxAliasChain = 64;
89
90/// @brief One token: its DTCG type and its value, which may hold aliases until the set is resolved.
91struct Token {
92 /// @brief The `$type`, own or inherited; empty for an alias whose type is its target's.
93 std::string type;
94 /// @brief The `$value`.
96};
97
98/// @brief Tokens by their dotted path, such as `color.accent`.
99using TokenSet = std::map<std::string, Token, std::less<>>;
100
101/// @brief A colour, 8 bits per channel.
102struct Rgba {
103 /// @brief Red.
104 std::uint8_t r = 0;
105 /// @brief Green.
106 std::uint8_t g = 0;
107 /// @brief Blue.
108 std::uint8_t b = 0;
109 /// @brief Alpha; 255 is opaque.
110 std::uint8_t a = 255;
111
112 /// @brief Field-wise equality.
113 /// @return Whether every channel is equal.
114 bool operator==(Rgba const&) const = default;
115
116 /// @brief The colour as `#rrggbb`, or `#rrggbbaa` when it is not opaque.
117 /// @return The text.
118 [[nodiscard]] std::string hex() const {
119 static constexpr std::string_view kDigits = "0123456789abcdef";
120 std::string out = "#";
121 auto const put = [&out](std::uint8_t const channel) {
122 out += kDigits.at(static_cast<std::size_t>(channel >> 4U));
123 out += kDigits.at(static_cast<std::size_t>(channel & 0xFU));
124 };
125 put(r);
126 put(g);
127 put(b);
128 if (a != 255) {
129 put(a);
130 }
131 return out;
132 }
133};
134
135/// @brief The index of a theme: which token files make up each variant.
137 /// @brief The file every variant layers over.
138 std::string base;
139 /// @brief Named variants, such as `light` and `dark`, each one file.
140 std::map<std::string, std::string, std::less<>> variants;
141 /// @brief Densities, such as `comfortable` and `compact`, each one file.
142 std::map<std::string, std::string, std::less<>> densities;
143 /// @brief Named surfaces, such as `sidebar`, each one file that overrides the variant for a subtree.
144 std::map<std::string, std::string, std::less<>> surfaces;
145 /// @brief The icon set: icon name to the bundle's `icon` file that draws it; empty for none.
146 std::map<std::string, std::string, std::less<>> icons;
147};
148
149namespace detail {
150
151/// @brief A number of a JSON value.
152/// @param json The value.
153/// @return It, or nothing when it is not a number.
154[[nodiscard]] inline std::optional<double> numberOf(Json const& json) {
155 if (auto const* const value = std::get_if<double>(&json.data)) {
156 return *value;
157 }
158 if (auto const* const value = std::get_if<std::int64_t>(&json.data)) {
159 return static_cast<double>(*value);
160 }
161 if (auto const* const value = std::get_if<std::uint64_t>(&json.data)) {
162 return static_cast<double>(*value);
163 }
164 return std::nullopt;
165}
166
167/// @brief A member of a JSON object.
168/// @param object The object.
169/// @param name The member.
170/// @return It, or null.
171[[nodiscard]] inline Json const* memberOf(Json::object_t const& object, std::string_view name) {
172 auto const* const found = object.find(name);
173 return found == object.end() ? nullptr : &found->second;
174}
175
176/// @brief How many px a `rem` is: the default root font size, which the renderers do not change.
177inline constexpr double kPixelsPerRem = 16.0;
178
179/// @brief A DTCG `dimension` value in px.
180/// @param json The value: an object of a number `value` and the unit `px` or `rem`.
181/// @return Its length in px, or nothing when it is not a dimension.
182[[nodiscard]] inline std::optional<double> pixelsOf(Json const& json) {
183 auto const* const object = std::get_if<Json::object_t>(&json.data);
184 if (object == nullptr) {
185 return std::nullopt;
186 }
187 Json const* const value = memberOf(*object, "value");
188 Json const* const unit = memberOf(*object, "unit");
189 auto const length = value != nullptr ? numberOf(*value) : std::nullopt;
190 auto const* const unitText = unit != nullptr ? std::get_if<std::string>(&unit->data) : nullptr;
191 if (!length || unitText == nullptr) {
192 return std::nullopt;
193 }
194 if (*unitText == "px") {
195 return length;
196 }
197 if (*unitText == "rem") {
198 return *length * kPixelsPerRem;
199 }
200 return std::nullopt;
201}
202
203/// @brief Reads `#rgb`, `#rrggbb` or `#rrggbbaa`.
204/// @param text The text.
205/// @return The colour, or nothing.
206[[nodiscard]] inline std::optional<Rgba> hexColor(std::string_view text) {
207 if (!text.starts_with('#')) {
208 return std::nullopt;
209 }
210 text.remove_prefix(1);
211 auto const digit = [](char const symbol) -> int {
212 if (symbol >= '0' && symbol <= '9') {
213 return symbol - '0';
214 }
215 if (symbol >= 'a' && symbol <= 'f') {
216 return symbol - 'a' + 10;
217 }
218 if (symbol >= 'A' && symbol <= 'F') {
219 return symbol - 'A' + 10;
220 }
221 return -1;
222 };
223 if (!std::ranges::all_of(text, [&digit](char const symbol) { return digit(symbol) >= 0; })) {
224 return std::nullopt;
225 }
226 auto const pair = [&digit, text](std::size_t const offset) {
227 return static_cast<std::uint8_t>((digit(text.at(offset)) * 16) + digit(text.at(offset + 1)));
228 };
229 if (text.size() == 3) {
230 auto const single = [&digit, text](std::size_t const offset) {
231 return static_cast<std::uint8_t>(digit(text.at(offset)) * 17);
232 };
233 return Rgba{.r = single(0), .g = single(1), .b = single(2), .a = 255};
234 }
235 if (text.size() == 6 || text.size() == 8) {
236 return Rgba{.r = pair(0), .g = pair(2), .b = pair(4), .a = text.size() == 8 ? pair(6) : std::uint8_t{255}};
237 }
238 return std::nullopt;
239}
240
241/// @brief A channel from 0 to 1, as 8 bits.
242/// @param value The channel.
243/// @return It, or nothing outside 0 to 1.
244[[nodiscard]] inline std::optional<std::uint8_t> channelOf(double const value) {
245 if (value < 0.0 || value > 1.0) {
246 return std::nullopt;
247 }
248 return static_cast<std::uint8_t>(std::lround(value * 255.0));
249}
250
251/// @brief Whether a string is an alias, `{a.b}`.
252/// @param text The text.
253/// @return The path it names, or nothing.
254[[nodiscard]] inline std::optional<std::string_view> aliasOf(std::string_view text) {
255 if (text.size() < 3 || !text.starts_with('{') || !text.ends_with('}')) {
256 return std::nullopt;
257 }
258 return text.substr(1, text.size() - 2);
259}
260
261/// @brief Whether a value holds an alias anywhere.
262/// @param json The value.
263/// @return Whether it does.
264// NOLINTNEXTLINE(misc-no-recursion): a composite value is walked member by member, bounded by kMaxJsonDepth.
265[[nodiscard]] inline bool holdsAlias(Json const& json) {
266 if (auto const* const text = std::get_if<std::string>(&json.data)) {
267 return aliasOf(*text).has_value();
268 }
269 if (auto const* const array = std::get_if<Json::array_t>(&json.data)) {
270 for (Json const& item : *array) {
271 if (holdsAlias(item)) {
272 return true;
273 }
274 }
275 }
276 if (auto const* const object = std::get_if<Json::object_t>(&json.data)) {
277 for (auto const& [name, member] : *object) {
278 static_cast<void>(name);
279 if (holdsAlias(member)) {
280 return true;
281 }
282 }
283 }
284 return false;
285}
286
287/// @brief The types the client checks; a token of another `$type` the client still knows is accepted unchecked.
288inline constexpr std::array<std::string_view, 13> kKnownTokenTypes = {
289 "color", "dimension", "number", "fontFamily", "fontWeight", "duration", "cubicBezier",
290 "shadow", "typography", "border", "strokeStyle", "gradient", "transition"};
291
292/// @brief The named font weights of DTCG.
293inline constexpr std::array<std::string_view, 18> kFontWeights = {
294 "thin", "hairline", "extra-light", "ultra-light", "light", "normal",
295 "regular", "book", "medium", "semi-bold", "demi-bold", "bold",
296 "extra-bold", "ultra-bold", "black", "heavy", "extra-black", "ultra-black"};
297
298/// @brief Whether a resolved value has its type's shape.
299/// @param type The `$type`.
300/// @param value The value, with no alias left.
301/// @return Why it does not, or nothing.
302[[nodiscard]] inline std::optional<std::string> checkTokenValue(std::string_view type, Json const& value);
303
304} // namespace detail
305
306/// @brief Reads a colour value: `#rgb`, `#rrggbb`, `#rrggbbaa`, or the DTCG 2025.10 object, by its `hex` or by its
307/// `srgb` `components` (0 to 1) and `alpha`.
308/// @param json The value.
309/// @return The colour, or nothing when it is none of those.
310[[nodiscard]] inline std::optional<Rgba> colorOf(Json const& json) {
311 if (auto const* const text = std::get_if<std::string>(&json.data)) {
312 return detail::hexColor(*text);
313 }
314 auto const* const object = std::get_if<Json::object_t>(&json.data);
315 if (object == nullptr) {
316 return std::nullopt;
317 }
318 std::optional<double> alpha = 1.0;
319 if (Json const* const given = detail::memberOf(*object, "alpha")) {
320 alpha = detail::numberOf(*given);
321 }
322 auto const opacity = alpha ? detail::channelOf(*alpha) : std::nullopt;
323 if (!opacity) {
324 return std::nullopt;
325 }
326 if (Json const* const hex = detail::memberOf(*object, "hex")) {
327 auto const* const text = std::get_if<std::string>(&hex->data);
328 auto color = text != nullptr ? detail::hexColor(*text) : std::nullopt;
329 if (color) {
330 color->a = *opacity;
331 }
332 return color;
333 }
334 Json const* const space = detail::memberOf(*object, "colorSpace");
335 Json const* const components = detail::memberOf(*object, "components");
336 auto const* const spaceName = space != nullptr ? std::get_if<std::string>(&space->data) : nullptr;
337 auto const* const list = components != nullptr ? std::get_if<Json::array_t>(&components->data) : nullptr;
338 if (spaceName == nullptr || *spaceName != "srgb" || list == nullptr || list->size() != 3) {
339 return std::nullopt;
340 }
341 std::array<std::uint8_t, 3> channels{};
342 for (std::size_t index = 0; index < 3; ++index) {
343 auto const number = detail::numberOf(list->at(index));
344 auto const channel = number ? detail::channelOf(*number) : std::nullopt;
345 if (!channel) {
346 return std::nullopt;
347 }
348 channels.at(index) = *channel;
349 }
350 return Rgba{.r = channels.at(0), .g = channels.at(1), .b = channels.at(2), .a = *opacity};
351}
352
353namespace detail {
354
355/// @brief Checks a `dimension` or a `duration`: an object of a number `value` and a known `unit`.
356/// @param type `dimension` or `duration`.
357/// @param value The value.
358/// @return Why it is not one, or nothing.
359[[nodiscard]] inline std::optional<std::string> checkMeasure(std::string_view type, Json const& value) {
360 bool const dimension = type == "dimension";
361 auto const* const object = std::get_if<Json::object_t>(&value.data);
362 Json const* const number = object != nullptr ? memberOf(*object, "value") : nullptr;
363 Json const* const unit = object != nullptr ? memberOf(*object, "unit") : nullptr;
364 auto const* const unitText = unit != nullptr ? std::get_if<std::string>(&unit->data) : nullptr;
365 std::array<std::string_view, 2> const units =
366 dimension ? std::array<std::string_view, 2>{"px", "rem"} : std::array<std::string_view, 2>{"ms", "s"};
367 bool const known = unitText != nullptr && std::ranges::find(units, *unitText) != units.end();
368 if (number != nullptr && numberOf(*number) && known) {
369 return std::nullopt;
370 }
371 return dimension ? std::string{"a dimension is an object of a number value and the unit px or rem"}
372 : std::string{"a duration is an object of a number value and the unit ms or s"};
373}
374
375/// @brief Checks a `fontFamily`: a name, or a non-empty list of names.
376/// @param value The value.
377/// @return Why it is not one, or nothing.
378[[nodiscard]] inline std::optional<std::string> checkFontFamily(Json const& value) {
379 if (std::holds_alternative<std::string>(value.data)) {
380 return std::nullopt;
381 }
382 auto const* const names = std::get_if<Json::array_t>(&value.data);
383 bool const allNames = names != nullptr && !names->empty() && std::ranges::all_of(*names, [](Json const& name) {
384 return std::holds_alternative<std::string>(name.data);
385 });
386 return allNames ? std::nullopt : std::optional<std::string>{"a font family is a name or a list of names"};
387}
388
389/// @brief Checks a `fontWeight`: 1 to 1000, or a DTCG weight name.
390/// @param value The value.
391/// @return Why it is not one, or nothing.
392[[nodiscard]] inline std::optional<std::string> checkFontWeight(Json const& value) {
393 if (auto const number = numberOf(value)) {
394 return *number >= 1.0 && *number <= 1000.0 ? std::nullopt
395 : std::optional<std::string>{"a weight is 1 to 1000"};
396 }
397 auto const* const name = std::get_if<std::string>(&value.data);
398 return name != nullptr && std::ranges::find(kFontWeights, *name) != kFontWeights.end()
399 ? std::nullopt
400 : std::optional<std::string>{"not a font weight"};
401}
402
403/// @brief Checks a `shadow`: an object of a `color` and the dimensions `offsetX`, `offsetY`, `blur` and `spread`, or a
404/// non-empty list of them.
405/// @param value The value.
406/// @return Why it is not one, or nothing.
407[[nodiscard]] inline std::optional<std::string> checkShadow(Json const& value) {
408 auto const one = [](Json const& layer) {
409 auto const* const object = std::get_if<Json::object_t>(&layer.data);
410 if (object == nullptr) {
411 return false;
412 }
413 Json const* const color = memberOf(*object, "color");
414 if (color == nullptr || !colorOf(*color)) {
415 return false;
416 }
417 return std::ranges::all_of(std::array<std::string_view, 4>{"offsetX", "offsetY", "blur", "spread"},
418 [object](std::string_view name) {
419 Json const* const length = memberOf(*object, name);
420 return length != nullptr && !checkMeasure("dimension", *length);
421 });
422 };
423 if (auto const* const layers = std::get_if<Json::array_t>(&value.data)) {
424 if (!layers->empty() && std::ranges::all_of(*layers, one)) {
425 return std::nullopt;
426 }
427 } else if (one(value)) {
428 return std::nullopt;
429 }
430 return std::string{"a shadow is an object of a colour and the dimensions offsetX, offsetY, blur and spread"};
431}
432
433inline std::optional<std::string> checkTokenValue(std::string_view type, Json const& value) {
434 if (type == "shadow") {
435 return checkShadow(value);
436 }
437 if (type == "color") {
438 return colorOf(value) ? std::nullopt : std::optional<std::string>{"not a colour in sRGB or hex"};
439 }
440 if (type == "dimension" || type == "duration") {
441 return checkMeasure(type, value);
442 }
443 if (type == "number") {
444 return numberOf(value) ? std::nullopt : std::optional<std::string>{"not a number"};
445 }
446 if (type == "fontFamily") {
447 return checkFontFamily(value);
448 }
449 if (type == "fontWeight") {
450 return checkFontWeight(value);
451 }
452 return std::nullopt;
453}
454
455/// @brief Joins text, for a refusal built inside a loop.
456/// @param parts The pieces.
457/// @return Them, in order.
458[[nodiscard]] inline std::string joined(std::initializer_list<std::string_view> parts) {
459 std::string out;
460 for (std::string_view const part : parts) {
461 out += part;
462 }
463 return out;
464}
465
466/// @brief The `$type` an object declares, or the one it inherits.
467/// @param object The group's or token's members.
468/// @param where Its path with a trailing `.`, for the refusal.
469/// @param inherited The nearest enclosing `$type`.
470/// @return The type, or why the declaration is refused.
471// NOLINTNEXTLINE(bugprone-easily-swappable-parameters): the path, then the type passed down, as readGroup takes them.
472[[nodiscard]] inline std::expected<std::string, std::string> declaredType(Json::object_t const& object,
473 std::string_view where,
474 std::string const& inherited) {
475 Json const* const declared = memberOf(object, "$type");
476 if (declared == nullptr) {
477 return inherited;
478 }
479 auto const* const text = std::get_if<std::string>(&declared->data);
480 if (text == nullptr) {
481 return std::unexpected(joined({"`", where, "$type` is a string"}));
482 }
483 return *text;
484}
485
486/// @brief Reads one token into @p out.
487/// @param path The token's path.
488/// @param object The token's members.
489/// @param value Its `$value`.
490/// @param type The type it inherits.
491/// @param out Where it goes.
492/// @return Why the file is refused, or nothing.
493[[nodiscard]] inline std::optional<std::string> readToken(std::string const& path, Json::object_t const& object,
494 Json const& value, std::string const& type, TokenSet& out) {
495 auto tokenType = declaredType(object, path + ".", type);
496 if (!tokenType) {
497 return std::move(tokenType.error());
498 }
499 bool const alias = holdsAlias(value);
500 if (tokenType->empty()) {
501 if (alias && std::holds_alternative<std::string>(value.data)) {
502 out.insert_or_assign(path, Token{.type = {}, .value = value});
503 return std::nullopt;
504 }
505 return joined({"the token `", path, "` has no `$type`"});
506 }
507 if (std::ranges::find(kKnownTokenTypes, *tokenType) == kKnownTokenTypes.end()) {
508 return std::nullopt;
509 }
510 if (!alias) {
511 if (auto reason = checkTokenValue(*tokenType, value)) {
512 return joined({"the token `", path, "`: ", *reason});
513 }
514 }
515 out.insert_or_assign(path, Token{.type = std::move(*tokenType), .value = value});
516 return std::nullopt;
517}
518
519/// @brief Refuses a member name a token file may not have.
520/// @param prefix The group's path, with a trailing `.`.
521/// @param name The member's name.
522/// @return Why it is refused, or nothing.
523[[nodiscard]] inline std::optional<std::string> checkMemberName(std::string const& prefix, std::string const& name) {
524 if (name.starts_with('!')) {
525 return joined({"the must-understand key `", prefix, name, "` is not understood"});
526 }
527 if (name.find_first_of(".{}") != std::string::npos) {
528 return joined({"the token or group name `", prefix, name, "` has `.`, `{` or `}`"});
529 }
530 return std::nullopt;
531}
532
533/// @brief Reads the tokens of one group into @p out.
534/// @param group The group's members.
535/// @param prefix The group's path, with a trailing `.`; empty at the top.
536/// @param inherited The nearest enclosing `$type`.
537/// @param out Where the tokens go.
538/// @return Why the file is refused, or nothing.
539// NOLINTBEGIN(misc-no-recursion,bugprone-easily-swappable-parameters): groups nest, bounded by kMaxJsonDepth; the
540// group's path, then the type it passes down.
541[[nodiscard]] inline std::optional<std::string> readGroup(Json::object_t const& group, std::string const& prefix,
542 std::string const& inherited, TokenSet& out) {
543 // NOLINTEND(misc-no-recursion,bugprone-easily-swappable-parameters)
544 auto const type = declaredType(group, prefix, inherited);
545 if (!type) {
546 return type.error();
547 }
548 if (memberOf(group, "$extends") != nullptr) {
549 return joined({"`", prefix, "$extends`: group inheritance is not supported"});
550 }
551 for (auto const& [name, member] : group) {
552 if (name.starts_with('$')) {
553 continue;
554 }
555 if (auto reason = checkMemberName(prefix, name)) {
556 return reason;
557 }
558 auto const* const object = std::get_if<Json::object_t>(&member.data);
559 if (object == nullptr) {
560 return joined({"`", prefix, name, "` is a token or a group, an object"});
561 }
562 std::string const path = prefix + name;
563 Json const* const value = memberOf(*object, "$value");
564 auto reason = value == nullptr ? readGroup(*object, path + ".", *type, out)
565 : readToken(path, *object, *value, *type, out);
566 if (reason) {
567 return reason;
568 }
569 if (out.size() > kMaxThemeTokens) {
570 return "a token file holds more than " + std::to_string(kMaxThemeTokens) + " tokens";
571 }
572 }
573 return std::nullopt;
574}
575
576/// @brief Resolves tokens' aliases, depth first, each token once.
578public:
579 /// @brief A resolver over @p merged.
580 /// @param merged The layered tokens.
581 explicit AliasResolver(TokenSet const& merged) : _merged{&merged} {}
582
583 /// @brief Resolves every token.
584 /// @return The resolved set, or why it is refused.
585 [[nodiscard]] std::expected<TokenSet, std::string> all() {
586 for (auto const& [path, token] : *_merged) {
587 static_cast<void>(token);
588 if (auto reason = resolve(path)) {
589 return std::unexpected(std::move(*reason));
590 }
591 }
592 return std::move(_done);
593 }
594
595private:
596 /// Resolves one token into `_done`.
597 // NOLINTNEXTLINE(misc-no-recursion): an alias resolves its target first, bounded by kMaxAliasChain.
598 [[nodiscard]] std::optional<std::string> resolve(std::string const& path) {
599 if (_done.contains(path)) {
600 return std::nullopt;
601 }
602 if (std::ranges::find(_chain, path) != _chain.end()) {
603 std::string cycle;
604 for (std::string const& step : _chain) {
605 cycle += step + " -> ";
606 }
607 return "an alias cycle: " + cycle + path;
608 }
609 if (_chain.size() >= kMaxAliasChain) {
610 return "an alias chain longer than " + std::to_string(kMaxAliasChain) + " at `" + path + "`";
611 }
612 auto const found = _merged->find(path);
613 if (found == _merged->end()) {
614 // Only an alias names a path the set may lack, and the aliasing token is on the chain.
615 return "`" + _chain.back() + "` refers to `" + path + "`, which no token file of the variant has";
616 }
617 _chain.push_back(path);
618 Token token = found->second;
619 if (auto const* const text = std::get_if<std::string>(&token.value.data)) {
620 if (auto const target = aliasOf(*text)) {
621 std::string const targetPath{*target};
622 if (auto reason = resolve(targetPath)) {
623 return reason;
624 }
625 Token const& resolved = _done.at(targetPath);
626 if (!token.type.empty() && token.type != resolved.type) {
627 return "the " + token.type + " token `" + path + "` aliases the " + resolved.type + " token `" +
628 targetPath + "`";
629 }
630 token = Token{.type = resolved.type, .value = resolved.value};
631 }
632 } else if (auto reason = replaceAliases(token.value)) {
633 return reason;
634 }
635 if (auto reason = checkTokenValue(token.type, token.value)) {
636 return "the token `" + path + "`: " + *reason;
637 }
638 _chain.pop_back();
639 _done.insert_or_assign(path, std::move(token));
640 return std::nullopt;
641 }
642
643 /// Replaces each alias inside a composite value with its target's resolved value.
644 // NOLINTNEXTLINE(misc-no-recursion): a composite value is walked member by member, bounded by kMaxJsonDepth.
645 [[nodiscard]] std::optional<std::string> replaceAliases(Json& value) {
646 if (auto const* const text = std::get_if<std::string>(&value.data)) {
647 if (auto const target = aliasOf(*text)) {
648 std::string const targetPath{*target};
649 if (auto reason = resolve(targetPath)) {
650 return reason;
651 }
652 value = _done.at(targetPath).value;
653 }
654 return std::nullopt;
655 }
656 if (auto* const array = std::get_if<Json::array_t>(&value.data)) {
657 for (Json& item : *array) {
658 if (auto reason = replaceAliases(item)) {
659 return reason;
660 }
661 }
662 } else if (auto* const object = std::get_if<Json::object_t>(&value.data)) {
663 for (auto& [name, member] : *object) {
664 static_cast<void>(name);
665 if (auto reason = replaceAliases(member)) {
666 return reason;
667 }
668 }
669 }
670 return std::nullopt;
671 }
672
673 TokenSet const* _merged;
674 TokenSet _done;
675 std::vector<std::string> _chain;
676};
677
678/// @brief Reads a map of name to file id from a theme index.
679/// @param index The index's members.
680/// @param key `density` or `surfaces`.
681/// @param out Where the entries go.
682/// @return Why the index is refused, or nothing.
683[[nodiscard]] inline std::optional<std::string> readThemeMap(Json::object_t const& index, std::string_view key,
684 std::map<std::string, std::string, std::less<>>& out) {
685 Json const* const member = memberOf(index, key);
686 if (member == nullptr) {
687 return std::nullopt;
688 }
689 auto const* const object = std::get_if<Json::object_t>(&member->data);
690 if (object == nullptr) {
691 return "`" + std::string{key} + "` is an object of name to token file";
692 }
693 for (auto const& [name, file] : *object) {
694 auto const* const fileId = std::get_if<std::string>(&file.data);
695 if (fileId == nullptr || fileId->empty()) {
696 return "`" + std::string{key} + "." + name + "` names a token file";
697 }
698 out.insert_or_assign(name, *fileId);
699 }
700 return std::nullopt;
701}
702
703/// @brief Reads a theme index's `icons`: an object of icon name (`isIconName`) to icon file id, at most
704/// `kMaxThemeIcons` names.
705/// @param index The index.
706/// @param out Where the set is read into.
707/// @return Why it is refused, or nothing; nothing for an index without `icons`.
708[[nodiscard]] inline std::optional<std::string> readIcons(Json::object_t const& index,
709 std::map<std::string, std::string, std::less<>>& out) {
710 Json const* const icons = memberOf(index, "icons");
711 if (icons == nullptr) {
712 return std::nullopt;
713 }
714 auto const* const set = std::get_if<Json::object_t>(&icons->data);
715 if (set == nullptr) {
716 return std::string{"`icons` is an object of icon name to icon file"};
717 }
718 if (set->size() > kMaxThemeIcons) {
719 return "an icon set holds at most " + std::to_string(kMaxThemeIcons) + " names";
720 }
721 for (auto const& [name, file] : *set) {
722 if (!isIconName(name)) {
723 return "`" + name + "` is not an icon name: icon names match [a-z][a-z0-9.-]{0,63}";
724 }
725 auto const* const fileId = std::get_if<std::string>(&file.data);
726 if (fileId == nullptr || fileId->empty()) {
727 return "`icons." + name + "` names an icon file";
728 }
729 out.insert_or_assign(name, *fileId);
730 }
731 return std::nullopt;
732}
733
734} // namespace detail
735
736/// @brief Whether a theme file is a theme index rather than a token file: its top level has a string `vocab`.
737/// @param text The file.
738/// @return Whether it is an index; text that is not a JSON object is not.
739[[nodiscard]] inline bool isThemeIndex(std::string_view text) {
740 auto const json = parseJson(text);
741 if (!json) {
742 return false;
743 }
744 auto const* const object = std::get_if<Json::object_t>(&json->data);
745 Json const* const vocab = object != nullptr ? detail::memberOf(*object, "vocab") : nullptr;
746 return vocab != nullptr && std::holds_alternative<std::string>(vocab->data);
747}
748
749/// @brief Parses a DTCG 2025.10 token file.
750///
751/// Groups nest; a token is an object with `$value`, and its `$type` is its own or the nearest group's. Refused: text
752/// that is not a JSON object or nests deeper than `kMaxJsonDepth`, a must-understand (`!`) name, a name with `.`, `{`
753/// or `}`, a member that is not an object, a token with no type that is not a plain alias, a value that does not
754/// have its checked type's shape, `$extends`, and more than `kMaxThemeTokens` tokens. A token of a `$type` the client
755/// does not know is dropped.
756/// @param text The file.
757/// @return The tokens by path, aliases unresolved, or why the file is refused.
758[[nodiscard]] inline std::expected<TokenSet, std::string> parseTokens(std::string_view text) {
759 auto const json = parseJson(text);
760 if (!json) {
761 return std::unexpected(json.error());
762 }
763 auto const* const object = std::get_if<Json::object_t>(&json->data);
764 if (object == nullptr) {
765 return std::unexpected(std::string{"a token file is an object of groups and tokens"});
766 }
767 TokenSet tokens;
768 if (auto reason = detail::readGroup(*object, {}, {}, tokens)) {
769 return std::unexpected(std::move(*reason));
770 }
771 return tokens;
772}
773
774/// @brief Layers token files, later ones winning per path, and resolves every alias.
775///
776/// Refused: an alias cycle, an alias to a token no layer has, a chain of more than `kMaxAliasChain` aliases, an
777/// alias whose declared type differs from its target's, and a resolved value that does not have its type's shape.
778/// @param layers The files, `base` first.
779/// @return The resolved tokens, or why the combination is refused.
780[[nodiscard]] inline std::expected<TokenSet, std::string> resolveTokens(std::vector<TokenSet const*> const& layers) {
781 TokenSet merged;
782 for (TokenSet const* const layer : layers) {
783 for (auto const& [path, token] : *layer) {
784 merged.insert_or_assign(path, token);
785 }
786 }
787 return detail::AliasResolver{merged}.all();
788}
789
790/// @brief Parses a theme index: `{"vocab": "ui-theme/1", "base": id, "dark": id, …, "density": {...},
791/// "surfaces": {...}, "icons": {...}}`.
792///
793/// Refused: not a JSON object, a `vocab` other than `ui-theme/1`, no `base`, a must-understand (`!`) key, a variant
794/// that is not a file id, a `density` or `surfaces` that is not a map of name to file id, and more than
795/// `kMaxThemeVariants` variants, densities or surfaces, and an `icons` that is not an object of icon name
796/// (`isIconName`) to file id or holds more than `kMaxThemeIcons` names.
797/// @param text The index.
798/// @return The index, or why it is refused.
799[[nodiscard]] inline std::expected<ThemeIndex, std::string> parseThemeIndex(std::string_view text) {
800 auto const json = parseJson(text);
801 if (!json) {
802 return std::unexpected(json.error());
803 }
804 auto const* const object = std::get_if<Json::object_t>(&json->data);
805 if (object == nullptr) {
806 return std::unexpected(std::string{"a theme index is an object"});
807 }
808 if (auto reason = detail::mustUnderstand(*json, "the theme index")) {
809 return std::unexpected(std::move(*reason));
810 }
811 Json const* const vocab = detail::memberOf(*object, "vocab");
812 auto const* const vocabText = vocab != nullptr ? std::get_if<std::string>(&vocab->data) : nullptr;
813 if (vocabText == nullptr || *vocabText != kThemeVocabulary) {
814 return std::unexpected(std::string{"a theme index's vocab is ui-theme/1"});
815 }
816 ThemeIndex index;
817 for (auto const& [key, member] : *object) {
818 if (key == "vocab" || key == "density" || key == "surfaces" || key == "icons") {
819 continue;
820 }
821 auto const* const fileId = std::get_if<std::string>(&member.data);
822 if (fileId == nullptr || fileId->empty()) {
823 return std::unexpected("the variant `" + key + "` names a token file");
824 }
825 if (key == "base") {
826 index.base = *fileId;
827 } else {
828 index.variants.insert_or_assign(key, *fileId);
829 }
830 }
831 if (index.base.empty()) {
832 return std::unexpected(std::string{"a theme index names its `base` token file"});
833 }
834 if (auto reason = detail::readThemeMap(*object, "density", index.densities)) {
835 return std::unexpected(std::move(*reason));
836 }
837 if (auto reason = detail::readThemeMap(*object, "surfaces", index.surfaces)) {
838 return std::unexpected(std::move(*reason));
839 }
840 if (auto reason = detail::readIcons(*object, index.icons)) {
841 return std::unexpected(std::move(*reason));
842 }
843 if (index.variants.size() > kMaxThemeVariants || index.densities.size() > kMaxThemeVariants ||
844 index.surfaces.size() > kMaxThemeVariants) {
845 return std::unexpected("a theme index lists more than " + std::to_string(kMaxThemeVariants) +
846 " variants, densities or surfaces");
847 }
848 return index;
849}
850
851/// @brief Finds a token file of the bundle by id.
852using TokenFileLookup = std::function<TokenSet const*(std::string_view fileId)>;
853
854/// @brief Resolves one combination of a theme: `base`, then the variant, the density and the surface, each optional.
855/// @param index The theme index.
856/// @param files The bundle's token files.
857/// @param variant A variant name, or empty for `base` alone.
858/// @param density A density name, or empty for none.
859/// @param surface A surface name, or empty for none.
860/// @return The resolved tokens, or why they are refused: a name the index does not list, a file the bundle lacks, or
861/// `resolveTokens`' refusals.
862// NOLINTBEGIN(bugprone-easily-swappable-parameters): variant, density, surface: the order they layer in.
863[[nodiscard]] inline std::expected<TokenSet, std::string> resolveTheme(ThemeIndex const& index,
864 TokenFileLookup const& files,
865 std::string_view variant,
866 std::string_view density,
867 std::string_view surface) {
868 // NOLINTEND(bugprone-easily-swappable-parameters)
869 std::vector<TokenSet const*> layers;
870 auto const layer = [&](std::string const& fileId) -> std::optional<std::string> {
871 TokenSet const* const tokens = files(fileId);
872 if (tokens == nullptr) {
873 return "the token file `" + fileId + "` is not in the bundle";
874 }
875 layers.push_back(tokens);
876 return std::nullopt;
877 };
878 auto const named = [&](std::map<std::string, std::string, std::less<>> const& from, std::string_view name,
879 std::string_view what) -> std::optional<std::string> {
880 if (name.empty()) {
881 return std::nullopt;
882 }
883 auto const found = from.find(name);
884 if (found == from.end()) {
885 return "the theme has no " + std::string{what} + " `" + std::string{name} + "`";
886 }
887 return layer(found->second);
888 };
889 std::optional<std::string> reason = layer(index.base);
890 if (!reason) {
891 reason = named(index.variants, variant, "variant");
892 }
893 if (!reason) {
894 reason = named(index.densities, density, "density");
895 }
896 if (!reason) {
897 reason = named(index.surfaces, surface, "surface");
898 }
899 if (reason) {
900 return std::unexpected(std::move(*reason));
901 }
902 return resolveTokens(layers);
903}
904
905/// @brief Checks that every combination of a theme resolves: each variant (or none), density (or none) and surface
906/// (or none) over `base`.
907/// @param index The theme index.
908/// @param files The bundle's token files.
909/// @return Why the theme is refused, naming the combination, or nothing.
910[[nodiscard]] inline std::optional<std::string> checkTheme(ThemeIndex const& index, TokenFileLookup const& files) {
911 auto const namesOf = [](std::map<std::string, std::string, std::less<>> const& from) {
912 std::vector<std::string> names{std::string{}};
913 for (auto const& [name, fileId] : from) {
914 static_cast<void>(fileId);
915 names.push_back(name);
916 }
917 return names;
918 };
919 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): variant, density, surface: the order they layer in.
920 auto const label = [](std::string const& variant, std::string const& density, std::string const& surface) {
921 std::string out = "base";
922 if (!variant.empty()) {
923 out += " + " + variant;
924 }
925 if (!density.empty()) {
926 out += " + density " + density;
927 }
928 if (!surface.empty()) {
929 out += " + surface " + surface;
930 }
931 return out;
932 };
933 std::vector<std::string> const surfaces = namesOf(index.surfaces);
934 for (std::string const& variant : namesOf(index.variants)) {
935 for (std::string const& density : namesOf(index.densities)) {
936 for (std::string const& surface : surfaces) {
937 auto const resolved = resolveTheme(index, files, variant, density, surface);
938 if (!resolved) {
939 return label(variant, density, surface) + ": " + resolved.error();
940 }
941 }
942 }
943 }
944 return std::nullopt;
945}
946
947/// @brief What a `ui-theme/1` token is read as.
948enum class ThemeTokenKind : std::uint8_t {
949 Color, ///< A `color` token.
950 Number, ///< A `number` token.
951 Dimension, ///< A `dimension` token, read in px (a `rem` is 16 px).
952 Shadow, ///< A `shadow` token: a colour and four px lengths; of a list of layers, the first.
953 FontFamily, ///< A `fontFamily` token: a family name, or a list of them in order of preference.
954};
955
956/// @brief A `shadow` token's default, in the form a `constexpr` table can hold.
958 /// @brief The colour, `#rrggbbaa`.
959 std::string_view color;
960 /// @brief The horizontal offset, in px.
961 double offsetX = 0.0;
962 /// @brief The vertical offset, in px.
963 double offsetY = 0.0;
964 /// @brief The blur radius, in px.
965 double blur = 0.0;
966 /// @brief The spread, in px.
967 double spread = 0.0;
968};
969
970/// @brief One token of the closed `ui-theme/1` set: its path, kind and the `MorphUi` default.
972 /// @brief The dotted path.
973 std::string_view path;
974 /// @brief How it is read.
976 /// @brief A colour's default, `#rrggbb`; empty for a number.
977 std::string_view color;
978 /// @brief A number's default, or a dimension's in px.
979 double number = 0.0;
980 /// @brief A shadow's default.
982 /// @brief A font family's default; empty for none, which draws in the platform font.
983 std::string_view family;
984};
985
986/// @brief The closed `ui-theme/1` token set, with the `MorphUi` defaults: the colour roles, the opacity and the
987/// spacing scale (`space.0` to `space.5`, one per spacing step) the built-in components read, and the semantic
988/// sets (`ok`, `warn`, `err`, `info` with `bg`, `border` and `dot`).
989inline constexpr std::array<ThemeTokenSpec, 40> kThemeTokens = {{
990 {.path = "color.surface", .kind = ThemeTokenKind::Color, .color = "#ffffff", .number = 0.0},
991 {.path = "color.text", .kind = ThemeTokenKind::Color, .color = "#1d1d1f", .number = 0.0},
992 {.path = "color.muted", .kind = ThemeTokenKind::Color, .color = "#6e6e73", .number = 0.0},
993 {.path = "color.accent", .kind = ThemeTokenKind::Color, .color = "#1565c0", .number = 0.0},
994 {.path = "color.focus", .kind = ThemeTokenKind::Color, .color = "#1565c0", .number = 0.0},
995 {.path = "color.border", .kind = ThemeTokenKind::Color, .color = "#c7c7cc", .number = 0.0},
996 {.path = "color.neutral", .kind = ThemeTokenKind::Color, .color = "#e5e5ea", .number = 0.0},
997 {.path = "color.selection", .kind = ThemeTokenKind::Color, .color = "#cfe3ff", .number = 0.0},
998 {.path = "color.ok", .kind = ThemeTokenKind::Color, .color = "#2e7d32", .number = 0.0},
999 {.path = "color.ok.bg", .kind = ThemeTokenKind::Color, .color = "#e8f5e9", .number = 0.0},
1000 {.path = "color.ok.border", .kind = ThemeTokenKind::Color, .color = "#a5d6a7", .number = 0.0},
1001 {.path = "color.ok.dot", .kind = ThemeTokenKind::Color, .color = "#2e7d32", .number = 0.0},
1002 {.path = "color.warn", .kind = ThemeTokenKind::Color, .color = "#b26a00", .number = 0.0},
1003 {.path = "color.warn.bg", .kind = ThemeTokenKind::Color, .color = "#fff4e5", .number = 0.0},
1004 {.path = "color.warn.border", .kind = ThemeTokenKind::Color, .color = "#ffcc80", .number = 0.0},
1005 {.path = "color.warn.dot", .kind = ThemeTokenKind::Color, .color = "#b26a00", .number = 0.0},
1006 {.path = "color.err", .kind = ThemeTokenKind::Color, .color = "#c62828", .number = 0.0},
1007 {.path = "color.err.bg", .kind = ThemeTokenKind::Color, .color = "#fdecea", .number = 0.0},
1008 {.path = "color.err.border", .kind = ThemeTokenKind::Color, .color = "#ef9a9a", .number = 0.0},
1009 {.path = "color.err.dot", .kind = ThemeTokenKind::Color, .color = "#c62828", .number = 0.0},
1010 {.path = "color.info", .kind = ThemeTokenKind::Color, .color = "#1565c0", .number = 0.0},
1011 {.path = "color.info.bg", .kind = ThemeTokenKind::Color, .color = "#e3f2fd", .number = 0.0},
1012 {.path = "color.info.border", .kind = ThemeTokenKind::Color, .color = "#90caf9", .number = 0.0},
1013 {.path = "color.info.dot", .kind = ThemeTokenKind::Color, .color = "#1565c0", .number = 0.0},
1014 {.path = "opacity.stale", .kind = ThemeTokenKind::Number, .color = "", .number = 0.55},
1015 {.path = "space.0", .kind = ThemeTokenKind::Dimension, .color = "", .number = 0.0},
1016 {.path = "space.1", .kind = ThemeTokenKind::Dimension, .color = "", .number = 4.0},
1017 {.path = "space.2", .kind = ThemeTokenKind::Dimension, .color = "", .number = 8.0},
1018 {.path = "space.3", .kind = ThemeTokenKind::Dimension, .color = "", .number = 12.0},
1019 {.path = "space.4", .kind = ThemeTokenKind::Dimension, .color = "", .number = 16.0},
1020 {.path = "space.5", .kind = ThemeTokenKind::Dimension, .color = "", .number = 24.0},
1021 {.path = "color.background", .kind = ThemeTokenKind::Color, .color = "#f2f3f7", .number = 0.0},
1022 {.path = "radius.control", .kind = ThemeTokenKind::Dimension, .color = "", .number = 6.0},
1023 {.path = "radius.card", .kind = ThemeTokenKind::Dimension, .color = "", .number = 10.0},
1024 {.path = "control.height", .kind = ThemeTokenKind::Dimension, .color = "", .number = 32.0},
1025 {.path = "font.size.body", .kind = ThemeTokenKind::Dimension, .color = "", .number = 0.0},
1026 {.path = "font.size.heading", .kind = ThemeTokenKind::Dimension, .color = "", .number = 0.0},
1027 {.path = "font.family", .kind = ThemeTokenKind::FontFamily, .color = "", .number = 0.0},
1028 {.path = "elevation.card",
1029 .kind = ThemeTokenKind::Shadow,
1030 .color = "",
1031 .number = 0.0,
1032 .shadow = {.color = "#00000014", .offsetX = 0.0, .offsetY = 2.0, .blur = 10.0, .spread = 0.0}},
1033 // Transparent: an unset theme fills a table's header with nothing, matching today's appearance exactly.
1034 {.path = "color.table.header", .kind = ThemeTokenKind::Color, .color = "#00000000", .number = 0.0},
1035}};
1036
1037/// @brief A `shadow` of the set, resolved.
1039 /// @brief The colour.
1041 /// @brief The horizontal offset, in px.
1042 double offsetX = 0.0;
1043 /// @brief The vertical offset, in px.
1044 double offsetY = 0.0;
1045 /// @brief The blur radius, in px.
1046 double blur = 0.0;
1047 /// @brief The spread, in px.
1048 double spread = 0.0;
1049};
1050
1051/// @brief The `ui-theme/1` set's values for one resolved theme.
1053 /// @brief Every colour of the set, by path.
1054 std::map<std::string, Rgba, std::less<>> colors;
1055 /// @brief Every number of the set, by path.
1056 std::map<std::string, double, std::less<>> numbers;
1057 /// @brief Every dimension of the set, by path, in px.
1058 std::map<std::string, double, std::less<>> dimensions;
1059 /// @brief Every shadow of the set, by path.
1060 std::map<std::string, ThemeShadow, std::less<>> shadows;
1061 /// @brief Every font family list of the set, by path, in order of preference; empty for none.
1062 std::map<std::string, std::vector<std::string>, std::less<>> families;
1063};
1064
1065namespace detail {
1066
1067/// @brief A checked `shadow` value as a `ThemeShadow`; of a list of layers, the first.
1068/// @param json The value, checked by `checkShadow`.
1069/// @return It, or nothing when it is not a shadow.
1070[[nodiscard]] inline std::optional<ThemeShadow> shadowOf(Json const& json) {
1071 Json const* layer = &json;
1072 if (auto const* const layers = std::get_if<Json::array_t>(&json.data)) {
1073 if (layers->empty()) {
1074 return std::nullopt;
1075 }
1076 layer = &layers->front();
1077 }
1078 auto const* const object = std::get_if<Json::object_t>(&layer->data);
1079 if (object == nullptr) {
1080 return std::nullopt;
1081 }
1082 Json const* const color = memberOf(*object, "color");
1083 auto const rgba = color != nullptr ? colorOf(*color) : std::nullopt;
1084 auto const length = [object](std::string_view name) -> std::optional<double> {
1085 Json const* const member = memberOf(*object, name);
1086 return member != nullptr ? pixelsOf(*member) : std::nullopt;
1087 };
1088 auto const x = length("offsetX");
1089 auto const y = length("offsetY");
1090 auto const blur = length("blur");
1091 auto const spread = length("spread");
1092 if (!rgba || !x || !y || !blur || !spread) {
1093 return std::nullopt;
1094 }
1095 return ThemeShadow{.color = *rgba, .offsetX = *x, .offsetY = *y, .blur = *blur, .spread = *spread};
1096}
1097
1098/// @brief A checked `fontFamily` value as its names, in order: one name, or a list of them.
1099/// @param json The value, checked by `checkFontFamily`.
1100/// @return The names; empty when it is neither.
1101[[nodiscard]] inline std::vector<std::string> familiesOf(Json const& json) {
1102 std::vector<std::string> names;
1103 if (auto const* const name = std::get_if<std::string>(&json.data)) {
1104 names.push_back(*name);
1105 } else if (auto const* const list = std::get_if<Json::array_t>(&json.data)) {
1106 for (Json const& entry : *list) {
1107 if (auto const* const listed = std::get_if<std::string>(&entry.data)) {
1108 names.push_back(*listed);
1109 }
1110 }
1111 }
1112 return names;
1113}
1114
1115} // namespace detail
1116
1117namespace detail {
1118
1119/// @brief A resolved theme's token at a path.
1120/// @param resolved The resolved tokens.
1121/// @param path The token's path.
1122/// @return It, or null when the theme does not have it.
1123[[nodiscard]] inline Token const* tokenAt(TokenSet const& resolved, std::string_view path) {
1124 auto const found = resolved.find(path);
1125 return found == resolved.end() ? nullptr : &found->second;
1126}
1127
1128/// @brief A `font.family` token's value: the theme's names when it has the token as a `fontFamily`, else the default.
1129/// @param spec The token of the set.
1130/// @param found The theme's token at that path, or null.
1131/// @return The names, in order; empty for none.
1132[[nodiscard]] inline std::vector<std::string> familyValue(ThemeTokenSpec const& spec, Token const* found) {
1133 if (found != nullptr && found->type == "fontFamily") {
1134 return familiesOf(found->value);
1135 }
1136 std::vector<std::string> names;
1137 if (!spec.family.empty()) {
1138 names.emplace_back(spec.family);
1139 }
1140 return names;
1141}
1142
1143} // namespace detail
1144
1145/// @brief Fills the closed `ui-theme/1` set from a resolved theme.
1146///
1147/// A path of the set takes the theme's value when the theme has it with the set's type, and its default otherwise; a
1148/// token outside the set is ignored.
1149/// @param resolved The resolved tokens; empty gives every default.
1150/// @return Every path of the set with its value.
1151[[nodiscard]] inline ThemeValues themeValues(TokenSet const& resolved = {}) {
1152 ThemeValues values;
1153 for (ThemeTokenSpec const& spec : kThemeTokens) {
1154 Token const* const token = detail::tokenAt(resolved, spec.path);
1155 if (spec.kind == ThemeTokenKind::Color) {
1156 std::optional<Rgba> color;
1157 if (token != nullptr && token->type == "color") {
1158 color = colorOf(token->value);
1159 }
1160 values.colors.insert_or_assign(std::string{spec.path},
1161 color.value_or(detail::hexColor(spec.color).value_or(Rgba{})));
1162 } else if (spec.kind == ThemeTokenKind::Shadow) {
1163 std::optional<ThemeShadow> shadow;
1164 if (token != nullptr && token->type == "shadow") {
1165 shadow = detail::shadowOf(token->value);
1166 }
1167 values.shadows.insert_or_assign(
1168 std::string{spec.path},
1169 shadow.value_or(ThemeShadow{.color = detail::hexColor(spec.shadow.color).value_or(Rgba{}),
1170 .offsetX = spec.shadow.offsetX,
1171 .offsetY = spec.shadow.offsetY,
1172 .blur = spec.shadow.blur,
1173 .spread = spec.shadow.spread}));
1174 } else if (spec.kind == ThemeTokenKind::FontFamily) {
1175 values.families.insert_or_assign(std::string{spec.path}, detail::familyValue(spec, token));
1176 } else if (spec.kind == ThemeTokenKind::Dimension) {
1177 std::optional<double> pixels;
1178 if (token != nullptr && token->type == "dimension") {
1179 pixels = detail::pixelsOf(token->value);
1180 }
1181 values.dimensions.insert_or_assign(std::string{spec.path}, pixels.value_or(spec.number));
1182 } else {
1183 std::optional<double> number;
1184 if (token != nullptr && token->type == "number") {
1185 number = detail::numberOf(token->value);
1186 }
1187 values.numbers.insert_or_assign(std::string{spec.path}, number.value_or(spec.number));
1188 }
1189 }
1190 return values;
1191}
1192
1193} // namespace morph::ui
What the UI service's client and server share (spec 5 §12): content hashes, the bundle's manifest,...
std::optional< std::string > mustUnderstand(Json const &json, std::string_view where)
The must-understand key (!name) of one object, if it has one.
Definition bundle.hpp:369
Handle< Member > at(Member Class::*member) const
A field of the value, by member pointer: a renamed field is a compile error.
Definition author.hpp:703
Resolves tokens' aliases, depth first, each token once.
Definition theme.hpp:577
std::expected< TokenSet, std::string > all()
Resolves every token.
Definition theme.hpp:585
AliasResolver(TokenSet const &merged)
A resolver over merged.
Definition theme.hpp:581
bool listed(std::span< std::string_view const > names, std::string_view name)
Whether a list of names holds one.
Resolved const & resolved()
The process registries, matched and validated on the first call.
Definition effects.hpp:511
std::shared_ptr< ModelChannel > channelOf(ModelAlias &alias, ScreenCore &core)
The channel an alias is bound to for its current token, binding it when the token changed.
std::optional< std::pair< math::Rational, std::uint32_t > > numberOf(Value const &value)
The places a number is shown with: an int has none, a decimal or quantity its declared places.
Definition message.hpp:78
A colour, 8 bits per channel.
Definition theme.hpp:102
std::uint8_t r
Red.
Definition theme.hpp:104
std::uint8_t g
Green.
Definition theme.hpp:106
std::uint8_t a
Alpha; 255 is opaque.
Definition theme.hpp:110
bool operator==(Rgba const &) const =default
Field-wise equality.
std::uint8_t b
Blue.
Definition theme.hpp:108
std::string hex() const
The colour as #rrggbb, or #rrggbbaa when it is not opaque.
Definition theme.hpp:118
The index of a theme: which token files make up each variant.
Definition theme.hpp:136
std::string base
The file every variant layers over.
Definition theme.hpp:138
std::map< std::string, std::string, std::less<> > densities
Densities, such as comfortable and compact, each one file.
Definition theme.hpp:142
std::map< std::string, std::string, std::less<> > icons
The icon set: icon name to the bundle's icon file that draws it; empty for none.
Definition theme.hpp:146
std::map< std::string, std::string, std::less<> > surfaces
Named surfaces, such as sidebar, each one file that overrides the variant for a subtree.
Definition theme.hpp:144
std::map< std::string, std::string, std::less<> > variants
Named variants, such as light and dark, each one file.
Definition theme.hpp:140
A shadow token's default, in the form a constexpr table can hold.
Definition theme.hpp:957
std::string_view color
The colour, #rrggbbaa.
Definition theme.hpp:959
double offsetX
The horizontal offset, in px.
Definition theme.hpp:961
double spread
The spread, in px.
Definition theme.hpp:967
double blur
The blur radius, in px.
Definition theme.hpp:965
double offsetY
The vertical offset, in px.
Definition theme.hpp:963
A shadow of the set, resolved.
Definition theme.hpp:1038
Rgba color
The colour.
Definition theme.hpp:1040
double blur
The blur radius, in px.
Definition theme.hpp:1046
double offsetY
The vertical offset, in px.
Definition theme.hpp:1044
double offsetX
The horizontal offset, in px.
Definition theme.hpp:1042
double spread
The spread, in px.
Definition theme.hpp:1048
One token of the closed ui-theme/1 set: its path, kind and the MorphUi default.
Definition theme.hpp:971
double number
A number's default, or a dimension's in px.
Definition theme.hpp:979
ThemeTokenKind kind
How it is read.
Definition theme.hpp:975
ThemeShadowSpec shadow
A shadow's default.
Definition theme.hpp:981
std::string_view path
The dotted path.
Definition theme.hpp:973
std::string_view family
A font family's default; empty for none, which draws in the platform font.
Definition theme.hpp:983
std::string_view color
A colour's default, #rrggbb; empty for a number.
Definition theme.hpp:977
The ui-theme/1 set's values for one resolved theme.
Definition theme.hpp:1052
std::map< std::string, std::vector< std::string >, std::less<> > families
Every font family list of the set, by path, in order of preference; empty for none.
Definition theme.hpp:1062
std::map< std::string, ThemeShadow, std::less<> > shadows
Every shadow of the set, by path.
Definition theme.hpp:1060
std::map< std::string, double, std::less<> > dimensions
Every dimension of the set, by path, in px.
Definition theme.hpp:1058
std::map< std::string, double, std::less<> > numbers
Every number of the set, by path.
Definition theme.hpp:1056
std::map< std::string, Rgba, std::less<> > colors
Every colour of the set, by path.
Definition theme.hpp:1054
One token: its DTCG type and its value, which may hold aliases until the set is resolved.
Definition theme.hpp:91
std::string type
The $type, own or inherited; empty for an alias whose type is its target's.
Definition theme.hpp:93
Json value
The $value.
Definition theme.hpp:95
std::optional< std::string_view > aliasOf(std::string_view text)
Whether a string is an alias, {a.b}.
Definition theme.hpp:254
std::map< std::string, Token, std::less<> > TokenSet
Tokens by their dotted path, such as color.accent.
Definition theme.hpp:99
std::expected< std::string, std::string > declaredType(Json::object_t const &object, std::string_view where, std::string const &inherited)
The $type an object declares, or the one it inherits.
Definition theme.hpp:472
std::optional< std::string > readIcons(Json::object_t const &index, std::map< std::string, std::string, std::less<> > &out)
Reads a theme index's icons: an object of icon name (isIconName) to icon file id, at most kMaxThemeIc...
Definition theme.hpp:708
std::optional< std::string > checkTheme(ThemeIndex const &index, TokenFileLookup const &files)
Checks that every combination of a theme resolves: each variant (or none), density (or none) and surf...
Definition theme.hpp:910
bool isIconName(std::string_view name)
Whether name is an icon name: [a-z][a-z0-9.-]{0,63}.
Definition theme.hpp:54
constexpr std::array< std::string_view, 18 > kFontWeights
The named font weights of DTCG.
Definition theme.hpp:293
constexpr std::array< std::string_view, 13 > kKnownTokenTypes
The types the client checks; a token of another $type the client still knows is accepted unchecked.
Definition theme.hpp:288
bool isThemeIndex(std::string_view text)
Whether a theme file is a theme index rather than a token file: its top level has a string vocab.
Definition theme.hpp:739
Json const * memberOf(Json::object_t const &object, std::string_view name)
A member of a JSON object.
Definition theme.hpp:171
std::string_view iconGlyph(std::string_view name)
The glyph a text renderer draws for a framework icon name.
Definition theme.hpp:66
std::expected< TokenSet, std::string > resolveTokens(std::vector< TokenSet const * > const &layers)
Layers token files, later ones winning per path, and resolves every alias.
Definition theme.hpp:780
bool holdsAlias(Json const &json)
Whether a value holds an alias anywhere.
Definition theme.hpp:265
ThemeValues themeValues(TokenSet const &resolved={})
Fills the closed ui-theme/1 set from a resolved theme.
Definition theme.hpp:1151
std::vector< std::string > familyValue(ThemeTokenSpec const &spec, Token const *found)
A font.family token's value: the theme's names when it has the token as a fontFamily,...
Definition theme.hpp:1132
std::optional< ThemeShadow > shadowOf(Json const &json)
A checked shadow value as a ThemeShadow; of a list of layers, the first.
Definition theme.hpp:1070
std::expected< TokenSet, std::string > parseTokens(std::string_view text)
Parses a DTCG 2025.10 token file.
Definition theme.hpp:758
std::string joined(std::initializer_list< std::string_view > parts)
Joins text, for a refusal built inside a loop.
Definition theme.hpp:458
std::vector< std::string > familiesOf(Json const &json)
A checked fontFamily value as its names, in order: one name, or a list of them.
Definition theme.hpp:1101
constexpr std::size_t kMaxAliasChain
The longest chain of aliases a token may resolve through.
Definition theme.hpp:88
std::optional< std::string > readGroup(Json::object_t const &group, std::string const &prefix, std::string const &inherited, TokenSet &out)
Reads the tokens of one group into out.
Definition theme.hpp:541
std::optional< Rgba > hexColor(std::string_view text)
Reads #rgb, #rrggbb or #rrggbbaa.
Definition theme.hpp:206
std::optional< std::string > checkMemberName(std::string const &prefix, std::string const &name)
Refuses a member name a token file may not have.
Definition theme.hpp:523
std::optional< std::string > checkMeasure(std::string_view type, Json const &value)
Checks a dimension or a duration: an object of a number value and a known unit.
Definition theme.hpp:359
std::optional< std::string > readToken(std::string const &path, Json::object_t const &object, Json const &value, std::string const &type, TokenSet &out)
Reads one token into out.
Definition theme.hpp:493
constexpr std::size_t kMaxThemeIcons
The most names a theme index's icon set may hold.
Definition theme.hpp:42
std::function< TokenSet const *(std::string_view fileId)> TokenFileLookup
Finds a token file of the bundle by id.
Definition theme.hpp:852
std::optional< Rgba > colorOf(Json const &json)
Reads a colour value: #rgb, #rrggbb, #rrggbbaa, or the DTCG 2025.10 object, by its hex or by its srgb...
Definition theme.hpp:310
std::optional< double > pixelsOf(Json const &json)
A DTCG dimension value in px.
Definition theme.hpp:182
std::optional< std::string > checkFontFamily(Json const &value)
Checks a fontFamily: a name, or a non-empty list of names.
Definition theme.hpp:378
constexpr double kPixelsPerRem
How many px a rem is: the default root font size, which the renderers do not change.
Definition theme.hpp:177
constexpr std::array< std::string_view, 11 > kFrameworkIcons
The icon names the built-in components draw: an empty state without an icon of its own,...
Definition theme.hpp:47
constexpr std::array< ThemeTokenSpec, 40 > kThemeTokens
The closed ui-theme/1 token set, with the MorphUi defaults: the colour roles, the opacity and the spa...
Definition theme.hpp:989
std::optional< std::string > readThemeMap(Json::object_t const &index, std::string_view key, std::map< std::string, std::string, std::less<> > &out)
Reads a map of name to file id from a theme index.
Definition theme.hpp:683
std::expected< ThemeIndex, std::string > parseThemeIndex(std::string_view text)
Parses a theme index: {"vocab": "ui-theme/1", "base": id, "dark": id, …, "density": {....
Definition theme.hpp:799
constexpr std::size_t kMaxThemeTokens
The most tokens one token file may hold.
Definition theme.hpp:36
constexpr std::size_t kMaxThemeVariants
The most variants, densities or surfaces a theme index may list, each.
Definition theme.hpp:39
ThemeTokenKind
What a ui-theme/1 token is read as.
Definition theme.hpp:948
@ Shadow
A shadow token: a colour and four px lengths; of a list of layers, the first.
Definition theme.hpp:952
@ Dimension
A dimension token, read in px (a rem is 16 px).
Definition theme.hpp:951
@ Number
A number token.
Definition theme.hpp:950
@ Color
A color token.
Definition theme.hpp:949
@ FontFamily
A fontFamily token: a family name, or a list of them in order of preference.
Definition theme.hpp:953
std::optional< std::string > checkShadow(Json const &value)
Checks a shadow: an object of a color and the dimensions offsetX, offsetY, blur and spread,...
Definition theme.hpp:407
Token const * tokenAt(TokenSet const &resolved, std::string_view path)
A resolved theme's token at a path.
Definition theme.hpp:1123
std::optional< std::string > checkFontWeight(Json const &value)
Checks a fontWeight: 1 to 1000, or a DTCG weight name.
Definition theme.hpp:392
std::expected< TokenSet, std::string > resolveTheme(ThemeIndex const &index, TokenFileLookup const &files, std::string_view variant, std::string_view density, std::string_view surface)
Resolves one combination of a theme: base, then the variant, the density and the surface,...
Definition theme.hpp:863
std::optional< std::string > checkTokenValue(std::string_view type, Json const &value)
Whether a resolved value has its type's shape.
Definition theme.hpp:433
constexpr std::string_view kThemeVocabulary
The vocabulary of a theme index.
Definition theme.hpp:33
The UI document's typed values (ui::Value), its types (ui::Type), their JSON form,...
std::expected< Json, std::string > parseJson(std::string_view text)
Parses JSON text into the document layer's JSON tree.
Definition value.hpp:752
glz::generic_u64 Json
The JSON tree the document layer reads and writes: glaze's generic value in its integer-preserving mo...
Definition value.hpp:35
Node text(Text spec)
Static or bound text.
Definition view.hpp:1194