34namespace morph::forms {
65using Encoding = std::optional<std::expected<WireValue, FieldError>>;
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"},
95 std::string_view english =
"Not a valid value";
96 for (
auto const& [known, text] : kEnglish) {
101 std::string
const key =
"morph.forms.error." + std::string{code};
106namespace detail::engine {
112[[nodiscard]]
inline ui::Value valueOfLiteral(Json
const& literal) {
113 if (literal.get_if<
double>() !=
nullptr) {
114 auto const text = numberText(literal);
116 return exact ? ui::Value{ui::Decimal{*exact}} : ui::Value{};
118 auto value = ui::valueFromJson(literal);
119 return value ? *std::move(value) : ui::Value{};
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{};
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{};
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();
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);
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) {
167 if (spec.maximum && compareDecimal(canonical, *spec.maximum) == std::strong_ordering::greater) {
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)) {
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));
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));
196 if (upper && compareDecimal(normalised, *upper) == std::strong_ordering::greater) {
197 return std::unexpected(
fieldError(
"above-maximum", context));
199 if (spec.multipleOf) {
200 if (
auto const violation = decimalBounds(FieldSpec{.multipleOf = spec.multipleOf}, normalised, context)) {
201 return std::unexpected(*violation);
204 return WireValue{.json = normalised, .value = integerValue(normalised)};
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));
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));
222 if (
auto const violation = decimalBounds(spec, *canonical, context)) {
223 return std::unexpected(*violation);
225 std::string
const literal = stripLeadingZeros(*canonical);
226 return WireValue{.json = literal, .value = decimalValue(literal)};
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));
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));
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));
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));
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));
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));
271 if (spec.maximum && compareRationalToDecimal(value, *spec.maximum) == std::strong_ordering::greater) {
272 return std::unexpected(
fieldError(
"above-maximum", context));
274 if (spec.instanceMinimum && value < *spec.instanceMinimum) {
275 return std::unexpected(
fieldError(
"below-minimum", context));
277 if (spec.instanceMaximum && value > *spec.instanceMaximum) {
278 return std::unexpected(
fieldError(
"above-maximum", context));
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));
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}}};
296[[nodiscard]]
inline Encoding encodeArray(FieldSpec
const& spec, std::string_view text, ValueContext
const& context) {
297 std::string json =
"[";
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;
309 if (spec.itemType ==
"integer") {
310 if (!isCanonicalInteger(item)) {
311 return std::unexpected(
fieldError(
"malformed", context));
313 literal = stripLeadingZeros(item);
314 }
else if (spec.itemType ==
"number") {
315 if (!isCanonicalDecimal(item)) {
316 return std::unexpected(
fieldError(
"malformed", context));
318 literal = stripLeadingZeros(item);
319 }
else if (spec.itemType ==
"boolean") {
320 if (item !=
"true" && item !=
"false") {
321 return std::unexpected(
fieldError(
"malformed", context));
323 literal = std::string{item};
325 literal = quote(item);
327 json += (first ?
"" :
",") + literal;
331 auto const parsed = parseJson(json);
332 return WireValue{.json = json, .value = parsed ? valueOfLiteral(*parsed) : ui::Value{}};
349 using namespace detail::engine;
350 std::string_view
const text = trim(draft);
362 if (!isIsoDate(text)) {
363 return std::unexpected(
fieldError(
"malformed", context));
367 auto const wall = parseWallClock(text);
369 return std::unexpected(
fieldError(
"malformed", context));
376 auto const wall = parseWallClock(text);
378 return std::unexpected(
fieldError(
"malformed", context));
382 return WireValue{.json = std::to_string(seconds), .value =
ui::Value{
static_cast<std::int64_t
>(seconds)}};
385 if (!isIsoDate(text)) {
386 return std::unexpected(
fieldError(
"malformed", context));
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)}};
394 if (text ==
"true" || text ==
"false") {
397 return std::unexpected(
fieldError(
"malformed", context));
401 return std::unexpected(
fieldError(
"not-an-option", context));
403 auto const literal = parseJson(text);
404 return WireValue{.json = std::string{text}, .value = literal ? valueOfLiteral(*literal) :
ui::Value{}};
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));
411 return WireValue{.json = std::string{text}, .value = valueOfLiteral(*literal)};
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);
423 return encodeArray(spec, text, context);
427 return std::unexpected(
fieldError(
"unrepresentable", context));
441 using namespace detail::engine;
442 if (value.is_null()) {
449 auto const*
const text = value.get_if<std::string>();
450 return text ==
nullptr ? std::string{} : *text;
453 auto const*
const text = value.get_if<std::string>();
454 auto const instant = text ==
nullptr ? std::nullopt : parseInstant(*text);
460 auto const count = SchemaReader::signedOf(&value);
461 if (!count || value.get_if<
double>() !=
nullptr) {
465 return formatWallClock(std::chrono::sys_seconds{std::chrono::days{*count}}).substr(0, 10);
467 return formatWallClock(std::chrono::sys_seconds{std::chrono::seconds{*count}} +
471 auto const*
const flag = value.get_if<
bool>();
472 if (flag ==
nullptr) {
475 return *flag ?
"true" :
"false";
479 return toJson(value);
482 return numberText(value).value_or(std::string{});
484 auto canonical = numberText(value);
489 auto const dot = canonical->find(
'.');
490 std::size_t
const places = dot == std::string::npos ? 0 : canonical->size() - dot - 1;
492 if (dot == std::string::npos) {
498 return draftNumber(*canonical, context.
locale);
502 auto const num = SchemaReader::signedOf(member(value,
"num"));
503 auto const den = SchemaReader::signedOf(member(value,
"den"));
504 if (!num || !den || *den == 0) {
511 if (!value.is_array()) {
515 for (Json
const& item : value.get_array()) {
516 out += (out.empty() ?
"" :
", ") + displayText(item);
539 if (!typed || from.
den == 0 || target.
num == 0) {
546 auto const converted = canonical ?
math::checkedMul(*canonical, fromCanonical) : canonical;
559 using namespace detail::engine;
560 std::vector<ChoiceOption> options{};
561 auto const dom = parseJson(reply);
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()) {
574 if (rows ==
nullptr) {
577 for (Json
const& row : rows->get_array()) {
578 Json
const*
const value = member(row, spec.
valueField);
579 if (value ==
nullptr) {
582 Json
const*
const label = member(row, spec.
labelField);
584 ChoiceOption{.valueJson = toJson(*value), .label = displayText(label !=
nullptr ? *label : *value)});
601 return "0." + std::string(std::max<std::uint32_t>(1, spec.
decimalPlaces.value_or(0)),
'0');
611 return "comma-separated (e.g. red, green, blue)";
Node text(TextOptions const &options)
A text node.
The document's typed value (spec 5 §3): what every expression evaluates to.
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.
@ 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.
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.
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.
x-rules read into RuleExpr trees, translated into the UI document's expression language (expr/1) and ...
The session options a value conversion reads.
std::string bcp47
The locale to resolve messages in.
int displayOffsetMinutes
The display zone for date-times, in minutes east of UTC.
render::TranslationProvider translations
The host's catalog, for error messages; empty means none.
render::NumericLocale locale
How numbers are typed and shown. The views must outlive the context.
Strong type for a decimal-precision count.
Strong type for a Rational denominator.
Strong type for a Rational numerator.
Exact rational number with a runtime decimal-precision tag.
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/....
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).
FieldError fieldError(std::string_view code, ValueContext const &context)
The error for code, its message resolved through context's translations.
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 ...
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...
std::optional< std::expected< WireValue, FieldError > > Encoding
A draft's encoding: std::nullopt when blank, else the value or why it does not encode.
std::string defaultPlaceholder(FieldSpec const &spec)
The placeholder a control shows when the schema declares none.
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,...