morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
values.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4
5/// @file forms/engine/values.hpp
6/// @brief The forms engine's encoders and decoders: a field's draft text to its exact wire
7/// literal, a wire value back to the draft a control shows, exact unit conversion, and
8/// Choice option rows.
9///
10/// Specified in `docs/spec/forms/engine.md`, "Values and exactness".
11
12#include <algorithm>
13#include <array>
14#include <chrono>
15#include <compare>
16#include <cstddef>
17#include <cstdint>
18#include <expected>
19#include <optional>
20#include <string>
21#include <string_view>
22#include <utility>
23#include <vector>
24
25#include "../../render/i18n.hpp"
27#include "../../ui/value.hpp"
30#include "detail/text.hpp"
31#include "field_model.hpp"
32#include "rules.hpp"
33
34namespace morph::forms {
35
36/// @brief Why a draft does not encode: a stable code and the message shown beside the field.
37struct FieldError {
38 /// @brief One of `malformed`, `too-precise`, `below-minimum`, `above-maximum`, `not-a-multiple`,
39 /// `not-an-option`, `out-of-range`, `incomplete`, `unrepresentable`.
40 std::string code;
41 /// @brief The message, resolved through the session's translations
42 /// (key `morph.forms.error.` followed by the code), else the English text.
43 std::string message;
44 /// @brief Member-wise equality.
45 /// @param other The other error.
46 /// @return `true` when code and message match.
47 bool operator==(FieldError const& other) const = default;
48};
49
50/// @brief One field's encoded value.
51struct WireValue {
52 /// @brief The JSON literal the body carries, byte for byte.
53 std::string json;
54 /// @brief The exact value rules compare, in the expression language's terms: an `int`, a `decimal`, a `bool`,
55 /// a `string` or a `timestamp`; `null` for objects and for a value the language cannot hold exactly
56 /// (an integer beyond int64).
58 /// @brief Member-wise equality.
59 /// @param other The other value.
60 /// @return `true` when literal and value match.
61 bool operator==(WireValue const& other) const = default;
62};
63
64/// @brief A draft's encoding: `std::nullopt` when blank, else the value or why it does not encode.
65using Encoding = std::optional<std::expected<WireValue, FieldError>>;
66
67/// @brief The session options a value conversion reads.
69 /// @brief How numbers are typed and shown. The views must outlive the context.
71 /// @brief The display zone for date-times, in minutes east of UTC.
73 /// @brief The host's catalog, for error messages; empty means none.
75 /// @brief The locale to resolve messages in.
76 std::string bcp47 = "en";
77};
78
79/// @brief The error for @p code, its message resolved through @p context's translations.
80/// @param code A `FieldError::code`.
81/// @param context The session's value context.
82/// @return The error.
83[[nodiscard]] inline FieldError fieldError(std::string_view code, ValueContext const& context) {
84 constexpr std::array<std::pair<std::string_view, std::string_view>, 9> kEnglish{{
85 {"malformed", "Not a valid value"},
86 {"too-precise", "Too many decimal places"},
87 {"below-minimum", "Below the minimum"},
88 {"above-maximum", "Above the maximum"},
89 {"not-a-multiple", "Not an allowed step"},
90 {"not-an-option", "Not one of the options"},
91 {"out-of-range", "Too large to send exactly"},
92 {"incomplete", "A required part is missing"},
93 {"unrepresentable", "Nested too deeply to edit here"},
94 }};
95 std::string_view english = "Not a valid value";
96 for (auto const& [known, text] : kEnglish) {
97 if (known == code) {
98 english = text;
99 }
100 }
101 std::string const key = "morph.forms.error." + std::string{code};
102 return FieldError{.code = std::string{code},
103 .message = render::resolveText(context.translations, context.bcp47, std::nullopt, key, english)};
104}
105
106namespace detail::engine {
107
108/// @brief The value a JSON literal denotes, for rules: what `ui::valueFromJson` reads, and a number with a fraction
109/// as the exact decimal its digits spell (never through a double's value).
110/// @param literal The literal.
111/// @return The value; `null` for what the expression language cannot hold exactly.
112[[nodiscard]] inline ui::Value valueOfLiteral(Json const& literal) {
113 if (literal.get_if<double>() != nullptr) {
114 auto const text = numberText(literal);
115 auto const exact = text ? render::parseDecimal(*text, render::NumericLocale{}) : std::nullopt;
116 return exact ? ui::Value{ui::Decimal{*exact}} : ui::Value{};
117 }
118 auto value = ui::valueFromJson(literal);
119 return value ? *std::move(value) : ui::Value{};
120}
121
122/// @brief The `int` canonical integer digits spell.
123/// @param digits Canonical integer text.
124/// @return The value; `null` beyond int64.
125[[nodiscard]] inline ui::Value integerValue(std::string_view digits) {
126 auto const exact = render::parseDecimal(digits, render::NumericLocale{});
127 return exact && exact->isInteger() ? ui::Value{exact->numerator} : ui::Value{};
128}
129
130/// @brief The `decimal` canonical decimal text spells.
131/// @param canonical Canonical decimal text.
132/// @return The value; `null` beyond what `math::Rational` holds.
133[[nodiscard]] inline ui::Value decimalValue(std::string_view canonical) {
134 auto const exact = render::parseDecimal(canonical, render::NumericLocale{});
135 return exact ? ui::Value{ui::Decimal{*exact}} : ui::Value{};
136}
137
138/// @brief Whether @p value is a whole multiple of @p step, exactly.
139/// @param value The value.
140/// @param step The step; positive.
141/// @return `false` as well when the quotient does not fit `math::Rational`.
142[[nodiscard]] inline bool isMultiple(math::Rational const& value, math::Rational const& step) {
143 auto const quotient = math::checkedDiv(value, step);
144 return quotient.has_value() && quotient->isInteger();
145}
146
147/// @brief A canonical decimal in @p locale's spelling, without grouping — what a control is seeded with.
148/// @param canonical Canonical decimal text.
149/// @param locale The display locale.
150/// @return The draft text.
151[[nodiscard]] inline std::string draftNumber(std::string_view canonical, render::NumericLocale const& locale) {
152 render::NumericLocale ungrouped = locale;
153 ungrouped.groupSeparator = {};
154 return render::formatCanonicalNumber(canonical, ungrouped);
155}
156
157/// @brief Checks @p canonical against a field's `minimum`, `maximum` and `multipleOf`.
158/// @param spec The field.
159/// @param canonical The value as canonical decimal text.
160/// @param context For the message.
161/// @return The violation, or `std::nullopt`.
162[[nodiscard]] inline std::optional<FieldError> decimalBounds(FieldSpec const& spec, std::string_view canonical,
163 ValueContext const& context) {
164 if (spec.minimum && compareDecimal(canonical, *spec.minimum) == std::strong_ordering::less) {
165 return fieldError("below-minimum", context);
166 }
167 if (spec.maximum && compareDecimal(canonical, *spec.maximum) == std::strong_ordering::greater) {
168 return fieldError("above-maximum", context);
169 }
170 if (spec.multipleOf) {
171 auto const value = render::parseDecimal(canonical, render::NumericLocale{});
172 auto const step = render::parseDecimal(*spec.multipleOf, render::NumericLocale{});
173 if (!value || !step || !isMultiple(*value, *step)) {
174 return fieldError("not-a-multiple", context);
175 }
176 }
177 return std::nullopt;
178}
179
180/// @brief Encodes an integer draft.
181/// @param spec The field.
182/// @param text The trimmed draft.
183/// @param context For messages.
184/// @return The encoding.
185[[nodiscard]] inline Encoding encodeInteger(FieldSpec const& spec, std::string_view text,
186 ValueContext const& context) {
187 if (!isCanonicalInteger(text)) {
188 return std::unexpected(fieldError("malformed", context));
189 }
190 std::string const normalised = stripLeadingZeros(text);
191 auto const lower = spec.exactMinimum ? spec.exactMinimum : spec.minimum;
192 auto const upper = spec.exactMaximum ? spec.exactMaximum : spec.maximum;
193 if (lower && compareDecimal(normalised, *lower) == std::strong_ordering::less) {
194 return std::unexpected(fieldError("below-minimum", context));
195 }
196 if (upper && compareDecimal(normalised, *upper) == std::strong_ordering::greater) {
197 return std::unexpected(fieldError("above-maximum", context));
198 }
199 if (spec.multipleOf) {
200 if (auto const violation = decimalBounds(FieldSpec{.multipleOf = spec.multipleOf}, normalised, context)) {
201 return std::unexpected(*violation);
202 }
203 }
204 return WireValue{.json = normalised, .value = integerValue(normalised)};
205}
206
207/// @brief Encodes a plain JSON number draft.
208/// @param spec The field.
209/// @param text The trimmed draft.
210/// @param context The locale and messages.
211/// @return The encoding.
212[[nodiscard]] inline Encoding encodeNumber(FieldSpec const& spec, std::string_view text, ValueContext const& context) {
213 auto const canonical = render::normalizeLocaleNumber(text, context.locale);
214 if (!canonical || !isCanonicalDecimal(*canonical)) {
215 return std::unexpected(fieldError("malformed", context));
216 }
217 auto const dot = canonical->find('.');
218 std::size_t const places = dot == std::string::npos ? 0 : canonical->size() - dot - 1;
219 if (spec.displayDecimals && places > *spec.displayDecimals) {
220 return std::unexpected(fieldError("too-precise", context));
221 }
222 if (auto const violation = decimalBounds(spec, *canonical, context)) {
223 return std::unexpected(*violation);
224 }
225 std::string const literal = stripLeadingZeros(*canonical);
226 return WireValue{.json = literal, .value = decimalValue(literal)};
227}
228
229/// @brief Encodes a Decimal or Quantity draft typed in `spec.units[unit]` as the exact
230/// `{num, den, dp}` in the canonical unit, unreduced: the scaled digits times the unit's
231/// numerator over `10^decimals` times its denominator.
232/// @param spec The field.
233/// @param text The trimmed draft.
234/// @param unit Index into `spec.units`.
235/// @param context The locale and messages.
236/// @return The encoding.
237[[nodiscard]] inline Encoding encodeExact(FieldSpec const& spec, std::string_view text, std::size_t unit,
238 ValueContext const& context) {
239 UnitOption const fallback{
240 .id = spec.unitAscii, .display = spec.unit, .decimals = spec.decimalPlaces.value_or(0), .num = 1, .den = 1};
241 UnitOption const& entry = unit < spec.units.size() ? spec.units.at(unit) : fallback;
242 auto const canonical = render::normalizeLocaleNumber(text, context.locale);
243 if (!canonical || !isCanonicalDecimal(*canonical)) {
244 return std::unexpected(fieldError("malformed", context));
245 }
246 auto const dot = canonical->find('.');
247 std::size_t const places = dot == std::string::npos ? 0 : canonical->size() - dot - 1;
248 if (places > entry.decimals) {
249 return std::unexpected(fieldError("too-precise", context));
250 }
251 auto const typed = render::parseDecimal(*canonical, render::NumericLocale{}, entry.decimals);
252 std::int64_t const scale = math::detail::powerOfTen(entry.decimals);
253 if (!typed || scale == 0) {
254 return std::unexpected(fieldError("out-of-range", context));
255 }
256 std::int64_t const factor = scale / typed->denominator;
257 if (math::detail::mulOverflows(typed->numerator, factor)) {
258 return std::unexpected(fieldError("out-of-range", context));
259 }
260 std::int64_t const scaled = typed->numerator * factor;
261 if (math::detail::mulOverflows(scaled, entry.num) || math::detail::mulOverflows(scale, entry.den)) {
262 return std::unexpected(fieldError("out-of-range", context));
263 }
264 std::int64_t const num = scaled * entry.num;
265 std::int64_t const den = scale * entry.den;
266 std::uint32_t const canonDp = spec.decimalPlaces.value_or(0);
267 math::Rational const value{math::Numerator{num}, math::Denominator{den}, math::DecimalPlaces{canonDp}};
268 if (spec.minimum && compareRationalToDecimal(value, *spec.minimum) == std::strong_ordering::less) {
269 return std::unexpected(fieldError("below-minimum", context));
270 }
271 if (spec.maximum && compareRationalToDecimal(value, *spec.maximum) == std::strong_ordering::greater) {
272 return std::unexpected(fieldError("above-maximum", context));
273 }
274 if (spec.instanceMinimum && value < *spec.instanceMinimum) {
275 return std::unexpected(fieldError("below-minimum", context));
276 }
277 if (spec.instanceMaximum && value > *spec.instanceMaximum) {
278 return std::unexpected(fieldError("above-maximum", context));
279 }
280 if (spec.multipleOf) {
281 auto const step = render::parseDecimal(*spec.multipleOf, render::NumericLocale{});
282 if (!step || !isMultiple(value, *step)) {
283 return std::unexpected(fieldError("not-a-multiple", context));
284 }
285 }
286 std::string const json = R"({"num":)" + std::to_string(num) + R"(,"den":)" + std::to_string(den) + R"(,"dp":)" +
287 std::to_string(canonDp) + "}";
288 return WireValue{.json = json, .value = ui::Value{ui::Decimal{value}}};
289}
290
291/// @brief Encodes a comma-separated array draft by the item type.
292/// @param spec The field.
293/// @param text The trimmed draft.
294/// @param context For messages.
295/// @return The encoding; blank items are dropped, so `" , "` is `[]`.
296[[nodiscard]] inline Encoding encodeArray(FieldSpec const& spec, std::string_view text, ValueContext const& context) {
297 std::string json = "[";
298 bool first = true;
299 std::size_t start = 0;
300 while (start <= text.size()) {
301 auto const comma = text.find(',', start);
302 std::string_view const item =
303 trim(text.substr(start, comma == std::string_view::npos ? std::string_view::npos : comma - start));
304 start = comma == std::string_view::npos ? text.size() + 1 : comma + 1;
305 if (item.empty()) {
306 continue;
307 }
308 std::string literal;
309 if (spec.itemType == "integer") {
310 if (!isCanonicalInteger(item)) {
311 return std::unexpected(fieldError("malformed", context));
312 }
313 literal = stripLeadingZeros(item);
314 } else if (spec.itemType == "number") {
315 if (!isCanonicalDecimal(item)) {
316 return std::unexpected(fieldError("malformed", context));
317 }
318 literal = stripLeadingZeros(item);
319 } else if (spec.itemType == "boolean") {
320 if (item != "true" && item != "false") {
321 return std::unexpected(fieldError("malformed", context));
322 }
323 literal = std::string{item};
324 } else {
325 literal = quote(item);
326 }
327 json += (first ? "" : ",") + literal;
328 first = false;
329 }
330 json += ']';
331 auto const parsed = parseJson(json);
332 return WireValue{.json = json, .value = parsed ? valueOfLiteral(*parsed) : ui::Value{}};
333}
334
335} // namespace detail::engine
336
337/// @brief Encodes one scalar field's draft (every kind but `Object` and `ObjectArray`).
338///
339/// The draft is trimmed first. Blank is `std::nullopt` — except a required Boolean, which encodes
340/// `false`: an unchecked box is an answer, and a required box always shows one.
341/// @param spec The field.
342/// @param draft What the user typed, or what a control holds (an option's `valueJson`, `true`/`false`).
343/// @param unit For a Decimal or Quantity, the index into `spec.units` the draft is typed in.
344/// @param context The locale, display zone and translations.
345/// @return The encoding.
346// NOLINTNEXTLINE(readability-function-cognitive-complexity): one case per kind.
347[[nodiscard]] inline Encoding encodeScalar(FieldSpec const& spec, std::string_view draft, std::size_t unit,
348 ValueContext const& context) {
349 using namespace detail::engine;
350 std::string_view const text = trim(draft);
351 if (text.empty()) {
352 if (spec.kind == FieldKind::Boolean && spec.required) {
353 return WireValue{.json = "false", .value = ui::Value{false}};
354 }
355 return std::nullopt;
356 }
357 switch (spec.kind) {
358 case FieldKind::Text:
360 return WireValue{.json = quote(text), .value = ui::Value{std::string{text}}};
361 case FieldKind::Date:
362 if (!isIsoDate(text)) {
363 return std::unexpected(fieldError("malformed", context));
364 }
365 return WireValue{.json = quote(text), .value = ui::Value{std::string{text}}};
366 case FieldKind::DateTime: {
367 auto const wall = parseWallClock(text);
368 if (!wall) {
369 return std::unexpected(fieldError("malformed", context));
370 }
371 std::string const utc = formatUtc(*wall - std::chrono::minutes{context.displayOffsetMinutes});
372 auto const instant = time::DateTime::fromIso8601(utc);
373 return WireValue{.json = quote(utc), .value = instant ? ui::Value{*instant} : ui::Value{}};
374 }
376 auto const wall = parseWallClock(text);
377 if (!wall) {
378 return std::unexpected(fieldError("malformed", context));
379 }
380 auto const seconds =
381 (*wall - std::chrono::minutes{context.displayOffsetMinutes}).time_since_epoch().count();
382 return WireValue{.json = std::to_string(seconds), .value = ui::Value{static_cast<std::int64_t>(seconds)}};
383 }
385 if (!isIsoDate(text)) {
386 return std::unexpected(fieldError("malformed", context));
387 }
388 // NOLINTNEXTLINE(bugprone-unchecked-optional-access): an ISO date at midnight always reads.
389 std::chrono::sys_seconds const midnight = parseWallClock(std::string{text} + "T00:00").value();
390 auto const days = std::chrono::floor<std::chrono::days>(midnight).time_since_epoch().count();
391 return WireValue{.json = std::to_string(days), .value = ui::Value{static_cast<std::int64_t>(days)}};
392 }
394 if (text == "true" || text == "false") {
395 return WireValue{.json = std::string{text}, .value = ui::Value{text == "true"}};
396 }
397 return std::unexpected(fieldError("malformed", context));
398 case FieldKind::Enum: {
399 auto const found = std::ranges::find(spec.enumOptions, text, &ChoiceOption::valueJson);
400 if (found == spec.enumOptions.end()) {
401 return std::unexpected(fieldError("not-an-option", context));
402 }
403 auto const literal = parseJson(text);
404 return WireValue{.json = std::string{text}, .value = literal ? valueOfLiteral(*literal) : ui::Value{}};
405 }
406 case FieldKind::Choice: {
407 auto const literal = parseJson(text);
408 if (!literal || literal->is_object() || literal->is_array() || literal->is_null()) {
409 return std::unexpected(fieldError("malformed", context));
410 }
411 return WireValue{.json = std::string{text}, .value = valueOfLiteral(*literal)};
412 }
414 return encodeInteger(spec, text, context);
416 return spec.jsonType == "number" ? encodeNumber(spec, text, context) : encodeInteger(spec, text, context);
418 return encodeNumber(spec, text, context);
421 return encodeExact(spec, text, unit, context);
422 case FieldKind::Array:
423 return encodeArray(spec, text, context);
426 default:
427 return std::unexpected(fieldError("unrepresentable", context));
428 }
429}
430
431/// @brief The draft a control shows for the wire value @p value — the inverse of `encodeScalar`,
432/// so encoding the result re-encodes @p value (numbers in the display locale, without
433/// grouping; instants in the display zone).
434/// @param spec The field.
435/// @param value The wire value (JSON null and a mismatched shape decode to blank).
436/// @param context The locale and display zone.
437/// @return The draft text.
438// NOLINTNEXTLINE(readability-function-cognitive-complexity): one case per kind.
439[[nodiscard]] inline std::string decodeScalar(FieldSpec const& spec, detail::engine::Json const& value,
440 ValueContext const& context) {
441 using namespace detail::engine;
442 if (value.is_null()) {
443 return {};
444 }
445 switch (spec.kind) {
446 case FieldKind::Text:
448 case FieldKind::Date: {
449 auto const* const text = value.get_if<std::string>();
450 return text == nullptr ? std::string{} : *text;
451 }
452 case FieldKind::DateTime: {
453 auto const* const text = value.get_if<std::string>();
454 auto const instant = text == nullptr ? std::nullopt : parseInstant(*text);
455 return instant ? formatWallClock(*instant + std::chrono::minutes{context.displayOffsetMinutes})
456 : std::string{};
457 }
460 auto const count = SchemaReader::signedOf(&value);
461 if (!count || value.get_if<double>() != nullptr) {
462 return {};
463 }
464 if (spec.kind == FieldKind::EpochDate) {
465 return formatWallClock(std::chrono::sys_seconds{std::chrono::days{*count}}).substr(0, 10);
466 }
467 return formatWallClock(std::chrono::sys_seconds{std::chrono::seconds{*count}} +
468 std::chrono::minutes{context.displayOffsetMinutes});
469 }
470 case FieldKind::Boolean: {
471 auto const* const flag = value.get_if<bool>();
472 if (flag == nullptr) {
473 return {};
474 }
475 return *flag ? "true" : "false";
476 }
477 case FieldKind::Enum:
479 return toJson(value);
482 return numberText(value).value_or(std::string{});
483 case FieldKind::Number: {
484 auto canonical = numberText(value);
485 if (!canonical) {
486 return {};
487 }
488 if (spec.displayDecimals) {
489 auto const dot = canonical->find('.');
490 std::size_t const places = dot == std::string::npos ? 0 : canonical->size() - dot - 1;
491 if (places < *spec.displayDecimals) {
492 if (dot == std::string::npos) {
493 *canonical += '.';
494 }
495 canonical->append(*spec.displayDecimals - places, '0');
496 }
497 }
498 return draftNumber(*canonical, context.locale);
499 }
501 case FieldKind::Quantity: {
502 auto const num = SchemaReader::signedOf(member(value, "num"));
503 auto const den = SchemaReader::signedOf(member(value, "den"));
504 if (!num || !den || *den == 0) {
505 return {};
506 }
508 return draftNumber(decimalText(exact, spec.decimalPlaces.value_or(0)), context.locale);
509 }
510 case FieldKind::Array: {
511 if (!value.is_array()) {
512 return {};
513 }
514 std::string out;
515 for (Json const& item : value.get_array()) {
516 out += (out.empty() ? "" : ", ") + displayText(item);
517 }
518 return out;
519 }
522 default:
523 return {};
524 }
525}
526
527/// @brief Converts a draft from one unit to another exactly, rounding half away from zero to
528/// the target unit's decimals.
529/// @param draft The draft, in @p locale's spelling.
530/// @param from The unit it is typed in.
531/// @param target The unit to convert to.
532/// @param locale The display locale (the result is grouped in it).
533/// @return The converted draft, or `std::nullopt` when the draft is not a number or the
534/// conversion leaves the exact range.
535[[nodiscard]] inline std::optional<std::string> convertDraft(std::string_view draft, UnitOption const& from,
536 UnitOption const& target,
537 render::NumericLocale const& locale) {
538 auto const typed = render::parseDecimal(detail::engine::trim(draft), locale);
539 if (!typed || from.den == 0 || target.num == 0) {
540 return std::nullopt;
541 }
543 math::Rational const fromCanonical{math::Numerator{target.den}, math::Denominator{target.num},
545 auto const canonical = math::checkedMul(*typed, toCanonical);
546 auto const converted = canonical ? math::checkedMul(*canonical, fromCanonical) : canonical;
547 if (!converted) {
548 return std::nullopt;
549 }
550 return render::formatCanonicalNumber(detail::engine::decimalText(*converted, target.decimals), locale);
551}
552
553/// @brief The options an options action's reply carries: the reply itself when it is an array,
554/// else its first array-valued member; rows without the value member are skipped.
555/// @param reply The reply JSON.
556/// @param spec Which row members are value and label.
557/// @return The options; empty for a reply that is not JSON or has no rows.
558[[nodiscard]] inline std::vector<ChoiceOption> parseOptions(std::string_view reply, ChoiceSpec const& spec) {
559 using namespace detail::engine;
560 std::vector<ChoiceOption> options{};
561 auto const dom = parseJson(reply);
562 if (!dom) {
563 return options;
564 }
565 Json const* rows = dom->is_array() ? &*dom : nullptr;
566 if (rows == nullptr && dom->is_object()) {
567 for (auto const& [key, candidate] : dom->get_object()) {
568 if (candidate.is_array()) {
569 rows = &candidate;
570 break;
571 }
572 }
573 }
574 if (rows == nullptr) {
575 return options;
576 }
577 for (Json const& row : rows->get_array()) {
578 Json const* const value = member(row, spec.valueField);
579 if (value == nullptr) {
580 continue;
581 }
582 Json const* const label = member(row, spec.labelField);
583 options.push_back(
584 ChoiceOption{.valueJson = toJson(*value), .label = displayText(label != nullptr ? *label : *value)});
585 }
586 return options;
587}
588
589/// @brief The placeholder a control shows when the schema declares none.
590/// @param spec The field.
591/// @return `x-placeholder` when declared; else `0.000…` for a Decimal or Quantity (at least one
592/// place), `0` for an integer, the declared precision for a Number, a hint for an Array,
593/// and empty otherwise.
594[[nodiscard]] inline std::string defaultPlaceholder(FieldSpec const& spec) {
595 if (!spec.placeholder.empty()) {
596 return spec.placeholder;
597 }
598 switch (spec.kind) {
601 return "0." + std::string(std::max<std::uint32_t>(1, spec.decimalPlaces.value_or(0)), '0');
604 return "0";
606 if (!spec.displayDecimals) {
607 return {};
608 }
609 return *spec.displayDecimals == 0 ? std::string{"0"} : "0." + std::string(*spec.displayDecimals, '0');
610 case FieldKind::Array:
611 return "comma-separated (e.g. red, green, blue)";
612 case FieldKind::Text:
615 case FieldKind::Enum:
618 case FieldKind::Date:
623 default:
624 return {};
625 }
626}
627
628} // namespace morph::forms
Node text(TextOptions const &options)
A text node.
Definition author.hpp:3126
The document's typed value (spec 5 §3): what every expression evaluates to.
Definition value.hpp:157
UTC timestamps for morph actions.
FormModel: the immutable runtime model of one action's form, read from the schema JSON schemaJson<A>(...
@ EpochDateTime
An instant as int64 seconds since the epoch (UTC): x-widget: epochSeconds on an integer.
@ Slider
A number on a track (x-widget: slider with x-min/x-max).
@ Array
A list of scalars, entered comma-separated.
@ Date
A calendar date, YYYY-MM-DD.
@ Choice
A set fetched by executing an options action.
@ Object
A nested object.
@ Quantity
An exact {num, den, dp} in a unit, with optional convertible alternatives.
@ EpochDate
A calendar date as int64 days since the epoch: x-widget: epochDays on an integer.
@ DateTime
An instant, entered in the display zone, sent as ISO-8601 UTC.
@ Decimal
An exact {num, den, dp} with a declared precision and no unit.
@ Multiline
A string edited over several lines (x-widget: textarea).
@ Integer
An integer of any width; every digit is kept.
@ Number
A plain JSON number (a double/float member).
@ Enum
A closed set the schema states.
@ ObjectArray
A list of nested objects.
Locale numeric-entry normalisation for the control edge.
std::optional< math::Rational > parseDecimal(std::string_view text, NumericLocale const &loc, std::optional< std::uint32_t > maxDecimalPlaces=std::nullopt)
Parses locale-formatted decimal entry text into the exact value it spells.
std::string formatCanonicalNumber(std::string_view canonicalText, NumericLocale const &loc)
Converts canonical (.-decimal) numeric text to locale-formatted display text, grouping the integer pa...
Exact rational arithmetic for morph model/action values.
constexpr std::expected< Rational, RationalError > checkedMul(const Rational &lhs, const Rational &rhs) noexcept
Multiplies two Rationals, reporting overflow instead of saturating.
The renderer-side translation-catalog seam over morph's derived message keys (morph::forms::i18n,...
std::function< std::optional< std::string >(std::string_view key, std::string_view bcp47Locale)> TranslationProvider
A host-supplied catalog lookup: key/bcp47Locale -> translated text, or std::nullopt on a miss.
Definition i18n.hpp:27
std::string resolveText(const TranslationProvider &provider, std::string_view bcp47Locale, const std::optional< std::string > &explicitKey, std::string_view derivedKey, std::string_view schemaLiteral)
Resolves one display slot's text: explicit key, then derived key, then the schema literal.
Definition i18n.hpp:65
x-rules read into RuleExpr trees, translated into the UI document's expression language (expr/1) and ...
One selectable value of an Enum or a Choice.
std::string valueJson
The value as the JSON literal the body carries ("\"Viewer\\"\"</tt>, <tt>\"9007199254740993\").
Where a Choice's options come from (x-optionsAction and its companions).
std::string valueField
The row member submitted as the value (x-optionValue).
std::string labelField
The row member shown (x-optionLabel).
Why a draft does not encode: a stable code and the message shown beside the field.
Definition values.hpp:37
std::string message
The message, resolved through the session's translations (key morph.forms.error. followed by the code...
Definition values.hpp:43
bool operator==(FieldError const &other) const =default
Member-wise equality.
std::string code
One of malformed, too-precise, below-minimum, above-maximum, not-a-multiple, not-an-option,...
Definition values.hpp:40
Everything the engine knows about one field, from its schema property.
std::optional< std::uint32_t > displayDecimals
x-displayDecimals: a plain Number's entry and display precision.
std::string placeholder
x-placeholder.
std::vector< ChoiceOption > enumOptions
An Enum's options, in schema order.
std::optional< std::uint32_t > decimalPlaces
x-decimalPlaces: the declared precision of a Decimal or Quantity.
std::string jsonType
The first declared JSON type ("integer", …), empty when the property declares none.
FieldKind kind
The control and encoder.
bool required
Named in the containing object's required.
One unit a Quantity can be entered in; the first of a field's units is canonical.
std::int64_t num
Numerator of the exact unit-to-canonical ratio.
std::int64_t den
Denominator of the exact unit-to-canonical ratio.
std::uint32_t decimals
Fraction digits accepted in this unit.
The session options a value conversion reads.
Definition values.hpp:68
std::string bcp47
The locale to resolve messages in.
Definition values.hpp:76
int displayOffsetMinutes
The display zone for date-times, in minutes east of UTC.
Definition values.hpp:72
render::TranslationProvider translations
The host's catalog, for error messages; empty means none.
Definition values.hpp:74
render::NumericLocale locale
How numbers are typed and shown. The views must outlive the context.
Definition values.hpp:70
One field's encoded value.
Definition values.hpp:51
bool operator==(WireValue const &other) const =default
Member-wise equality.
ui::Value value
The exact value rules compare, in the expression language's terms: an int, a decimal,...
Definition values.hpp:57
std::string json
The JSON literal the body carries, byte for byte.
Definition values.hpp:53
Strong type for a decimal-precision count.
Definition rational.hpp:121
Strong type for a Rational denominator.
Definition rational.hpp:154
Strong type for a Rational numerator.
Definition rational.hpp:140
Exact rational number with a runtime decimal-precision tag.
Definition rational.hpp:375
The locale facts both control-edge conversions need, in one designated-initialisable aggregate.
static std::optional< DateTime > fromIso8601(std::string_view text) noexcept
Strictly parses ISO-8601 UTC: YYYY-MM-DDTHH:MM:SS, optionally followed by .m/.mm/....
Definition datetime.hpp:148
Exact text helpers the forms engine shares: JSON quoting and reading, canonical decimal comparison an...
The UI document's typed values (ui::Value), its types (ui::Type), their JSON form,...
Encoding encodeScalar(FieldSpec const &spec, std::string_view draft, std::size_t unit, ValueContext const &context)
Encodes one scalar field's draft (every kind but Object and ObjectArray).
Definition values.hpp:347
FieldError fieldError(std::string_view code, ValueContext const &context)
The error for code, its message resolved through context's translations.
Definition values.hpp:83
std::optional< std::string > convertDraft(std::string_view draft, UnitOption const &from, UnitOption const &target, render::NumericLocale const &locale)
Converts a draft from one unit to another exactly, rounding half away from zero to the target unit's ...
Definition values.hpp:535
std::string decodeScalar(FieldSpec const &spec, detail::engine::Json const &value, ValueContext const &context)
The draft a control shows for the wire value value — the inverse of encodeScalar, so encoding the res...
Definition values.hpp:439
std::optional< std::expected< WireValue, FieldError > > Encoding
A draft's encoding: std::nullopt when blank, else the value or why it does not encode.
Definition values.hpp:65
std::string defaultPlaceholder(FieldSpec const &spec)
The placeholder a control shows when the schema declares none.
Definition values.hpp:594
std::vector< ChoiceOption > parseOptions(std::string_view reply, ChoiceSpec const &spec)
The options an options action's reply carries: the reply itself when it is an array,...
Definition values.hpp:558