From 1cc1f380d0424b279058d33006e559f6092e4a18 Mon Sep 17 00:00:00 2001 From: John Preston Date: Thu, 1 Apr 2021 13:27:39 +0400 Subject: [PATCH] Implement a nice money input field. --- Telegram/Resources/langs/lang.strings | 7 +- .../payments/payments_checkout_process.cpp | 11 +- .../SourceFiles/payments/ui/payments.style | 10 + .../payments/ui/payments_field.cpp | 314 +++++++++++++++++- .../SourceFiles/payments/ui/payments_field.h | 4 +- .../payments/ui/payments_form_summary.cpp | 4 +- .../payments/ui/payments_panel.cpp | 54 +-- .../SourceFiles/ui/text/format_values.cpp | 229 ++++++------- Telegram/SourceFiles/ui/text/format_values.h | 16 + 9 files changed, 481 insertions(+), 168 deletions(-) diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 7b7241944..9a59fa5c2 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -1891,9 +1891,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_payments_password_title" = "Payment Confirmation"; "lng_payments_password_description" = "Your card {card} is on file. To pay with this card, please enter your 2-Step-Verification password."; "lng_payments_password_submit" = "Pay"; -"lng_payments_tips_label" = "Tips"; -"lng_payments_tips_title" = "Tips"; -"lng_payments_tips_enter" = "Enter tips amount"; +"lng_payments_tips_label" = "Tip (Optional)"; +"lng_payments_tips_add" = "Add Tip"; +"lng_payments_tips_box_title" = "Add Tip"; +"lng_payments_tips_max" = "Max possible tip amount: {amount}"; "lng_call_status_incoming" = "is calling you..."; "lng_call_status_connecting" = "connecting..."; diff --git a/Telegram/SourceFiles/payments/payments_checkout_process.cpp b/Telegram/SourceFiles/payments/payments_checkout_process.cpp index 1a60cf4b8..290b7762c 100644 --- a/Telegram/SourceFiles/payments/payments_checkout_process.cpp +++ b/Telegram/SourceFiles/payments/payments_checkout_process.cpp @@ -418,7 +418,8 @@ bool CheckoutProcess::panelWebviewNavigationAttempt(const QString &uri) { } void CheckoutProcess::panelCancelEdit() { - if (_submitState != SubmitState::None) { + if (_submitState != SubmitState::None + && _submitState != SubmitState::Validated) { return; } showForm(); @@ -463,7 +464,8 @@ void CheckoutProcess::showForm() { } void CheckoutProcess::showEditInformation(Ui::InformationField field) { - if (_submitState != SubmitState::None) { + if (_submitState != SubmitState::None + && _submitState != SubmitState::Validated) { return; } _panel->showEditInformation( @@ -473,6 +475,8 @@ void CheckoutProcess::showEditInformation(Ui::InformationField field) { } void CheckoutProcess::showInformationError(Ui::InformationField field) { + Expects(_submitState != SubmitState::Validated); + if (_submitState != SubmitState::None) { return; } @@ -483,7 +487,8 @@ void CheckoutProcess::showInformationError(Ui::InformationField field) { } void CheckoutProcess::showCardError(Ui::CardField field) { - if (_submitState != SubmitState::None) { + if (_submitState != SubmitState::None + && _submitState != SubmitState::Validated) { return; } _panel->showCardError(_form->paymentMethod().ui.native, field); diff --git a/Telegram/SourceFiles/payments/ui/payments.style b/Telegram/SourceFiles/payments/ui/payments.style index 3dbc9eeaa..5a7ba8214 100644 --- a/Telegram/SourceFiles/payments/ui/payments.style +++ b/Telegram/SourceFiles/payments/ui/payments.style @@ -59,6 +59,10 @@ paymentsIconPhone: icon {{ "payments/payment_phone", menuIconFg }}; paymentsIconShippingMethod: icon {{ "payments/payment_shipping", menuIconFg }}; paymentsField: defaultInputField; +paymentsFieldAdditional: FlatLabel(defaultFlatLabel) { + style: boxTextStyle; +} + paymentsFieldPadding: margins(28px, 0px, 28px, 2px); paymentsSaveCheckboxPadding: margins(28px, 20px, 28px, 8px); paymentsExpireCvcSkip: 34px; @@ -79,3 +83,9 @@ paymentsShippingPrice: FlatLabel(defaultFlatLabel) { } paymentsShippingLabelPosition: point(43px, 8px); paymentsShippingPricePosition: point(43px, 29px); + +paymentTipsErrorLabel: FlatLabel(defaultFlatLabel) { + minWidth: 275px; + textFg: boxTextFgError; +} +paymentTipsErrorPadding: margins(22px, 6px, 22px, 0px); diff --git a/Telegram/SourceFiles/payments/ui/payments_field.cpp b/Telegram/SourceFiles/payments/ui/payments_field.cpp index 20dc96160..287d6046b 100644 --- a/Telegram/SourceFiles/payments/ui/payments_field.cpp +++ b/Telegram/SourceFiles/payments/ui/payments_field.cpp @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/input_fields.h" #include "ui/boxes/country_select_box.h" +#include "ui/text/format_values.h" #include "ui/ui_utility.h" #include "ui/special_fields.h" #include "data/data_countries.h" @@ -16,12 +17,190 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/event_filter.h" #include "styles/style_payments.h" +#include + namespace Payments::Ui { namespace { +struct SimpleFieldState { + QString value; + int position = 0; +}; + +[[nodiscard]] char FieldThousandsSeparator(const CurrencyRule &rule) { + return (rule.thousands == '.' || rule.thousands == ',') + ? ' ' + : rule.thousands; +} + +[[nodiscard]] QString RemoveNonNumbers(QString value) { + return value.replace(QRegularExpression("[^0-9]"), QString()); +} + +[[nodiscard]] SimpleFieldState CleanMoneyState( + const CurrencyRule &rule, + SimpleFieldState state) { + const auto withDecimal = state.value.replace( + QChar('.'), + rule.decimal + ).replace( + QChar(','), + rule.decimal + ); + const auto digitsLimit = 16 - rule.exponent; + const auto beforePosition = state.value.mid(0, state.position); + auto decimalPosition = withDecimal.lastIndexOf(rule.decimal); + if (decimalPosition < 0) { + state = { + .value = RemoveNonNumbers(state.value), + .position = RemoveNonNumbers(beforePosition).size(), + }; + } else { + const auto onlyNumbersBeforeDecimal = RemoveNonNumbers( + state.value.mid(0, decimalPosition)); + state = { + .value = (onlyNumbersBeforeDecimal + + QChar(rule.decimal) + + RemoveNonNumbers(state.value.mid(decimalPosition + 1))), + .position = (RemoveNonNumbers(beforePosition).size() + + (state.position > decimalPosition ? 1 : 0)), + }; + decimalPosition = onlyNumbersBeforeDecimal.size(); + const auto maxLength = decimalPosition + 1 + rule.exponent; + if (state.value.size() > maxLength) { + state = { + .value = state.value.mid(0, maxLength), + .position = std::min(state.position, maxLength), + }; + } + } + if (!state.value.isEmpty() && state.value[0] == QChar(rule.decimal)) { + state = { + .value = QChar('0') + state.value, + .position = state.position + 1, + }; + if (decimalPosition >= 0) { + ++decimalPosition; + } + } + auto skip = 0; + while (state.value.size() > skip + 1 + && state.value[skip] == QChar('0') + && state.value[skip + 1] != QChar(rule.decimal)) { + ++skip; + } + state = { + .value = state.value.mid(skip), + .position = std::max(state.position - skip, 0), + }; + if (decimalPosition >= 0) { + Assert(decimalPosition >= skip); + decimalPosition -= skip; + } + if (decimalPosition > digitsLimit) { + state = { + .value = (state.value.mid(0, digitsLimit) + + state.value.mid(decimalPosition)), + .position = (state.position > digitsLimit + ? std::max( + state.position - (decimalPosition - digitsLimit), + digitsLimit) + : state.position), + }; + } + return state; +} + +[[nodiscard]] SimpleFieldState PostprocessMoneyResult( + const CurrencyRule &rule, + SimpleFieldState result) { + const auto position = result.value.indexOf(rule.decimal); + const auto from = (position >= 0) ? position : result.value.size(); + for (auto insertAt = from - 3; insertAt > 0; insertAt -= 3) { + result.value.insert(insertAt, QChar(FieldThousandsSeparator(rule))); + if (result.position >= insertAt) { + ++result.position; + } + } + return result; +} + +[[nodiscard]] bool IsBackspace(const FieldValidateRequest &request) { + return (request.wasAnchor == request.wasPosition) + && (request.wasPosition == request.nowPosition + 1) + && (request.wasValue.midRef(0, request.wasPosition - 1) + == request.nowValue.midRef(0, request.nowPosition)) + && (request.wasValue.midRef(request.wasPosition) + == request.nowValue.midRef(request.nowPosition)); +} + +[[nodiscard]] bool IsDelete(const FieldValidateRequest &request) { + return (request.wasAnchor == request.wasPosition) + && (request.wasPosition == request.nowPosition) + && (request.wasValue.midRef(0, request.wasPosition) + == request.nowValue.midRef(0, request.nowPosition)) + && (request.wasValue.midRef(request.wasPosition + 1) + == request.nowValue.midRef(request.nowPosition)); +} + +[[nodiscard]] auto MoneyValidator(const CurrencyRule &rule) { + return [=](FieldValidateRequest request) { + const auto realNowState = [&] { + const auto backspaced = IsBackspace(request); + const auto deleted = IsDelete(request); + if (!backspaced && !deleted) { + return CleanMoneyState(rule, { + .value = request.nowValue, + .position = request.nowPosition, + }); + } + const auto realWasState = CleanMoneyState(rule, { + .value = request.wasValue, + .position = request.wasPosition, + }); + const auto changedValue = deleted + ? (realWasState.value.mid(0, realWasState.position) + + realWasState.value.mid(realWasState.position + 1)) + : (realWasState.position > 1) + ? (realWasState.value.mid(0, realWasState.position - 1) + + realWasState.value.mid(realWasState.position)) + : realWasState.value.mid(realWasState.position); + return SimpleFieldState{ + .value = changedValue, + .position = (deleted + ? realWasState.position + : std::max(realWasState.position - 1, 0)) + }; + }(); + const auto postprocessed = PostprocessMoneyResult( + rule, + realNowState); + return FieldValidateResult{ + .value = postprocessed.value, + .position = postprocessed.position, + }; + }; +} + [[nodiscard]] QString Parse(const FieldConfig &config) { if (config.type == FieldType::Country) { return Data::CountryNameByISO2(config.value); + } else if (config.type == FieldType::Money) { + const auto amount = config.value.toLongLong(); + if (!amount) { + return QString(); + } + const auto rule = LookupCurrencyRule(config.currency); + const auto value = std::abs(amount) / std::pow(10., rule.exponent); + const auto precision = (!rule.stripDotZero + || std::floor(value) != value) + ? rule.exponent + : 0; + return FormatWithSeparators( + value, + precision, + rule.decimal, + FieldThousandsSeparator(rule)); } return config.value; } @@ -32,6 +211,20 @@ namespace { const QString &countryIso2) { if (config.type == FieldType::Country) { return countryIso2; + } else if (config.type == FieldType::Money) { + const auto rule = LookupCurrencyRule(config.currency); + const auto real = QString(parsed).replace( + QChar(rule.decimal), + QChar('.') + ).replace( + QChar(','), + QChar('.') + ).replace( + QRegularExpression("[^0-9\\.]"), + QString() + ).toDouble(); + return QString::number( + int64(std::round(real * std::pow(10., rule.exponent)))); } return parsed; } @@ -46,7 +239,7 @@ namespace { case FieldType::CardCVC: case FieldType::Country: case FieldType::Phone: - case FieldType::PriceAmount: + case FieldType::Money: return true; } Unexpected("FieldType in Payments::Ui::UseMaskedField."); @@ -68,7 +261,7 @@ namespace { case FieldType::CardCVC: case FieldType::Country: case FieldType::Phone: - case FieldType::PriceAmount: + case FieldType::Money: return base::make_unique_q(parent); } Unexpected("FieldType in Payments::Ui::CreateWrap."); @@ -82,9 +275,106 @@ namespace { : static_cast(wrap.get()); } +[[nodiscard]] MaskedInputField *CreateMoneyField( + not_null wrap, + FieldConfig &config, + rpl::producer<> textPossiblyChanged) { + struct State { + CurrencyRule rule; + style::InputField st; + QString currencyText; + int currencySkip = 0; + FlatLabel *left = nullptr; + FlatLabel *right = nullptr; + }; + const auto state = wrap->lifetime().make_state(State{ + .rule = LookupCurrencyRule(config.currency), + .st = st::paymentsField, + }); + const auto &rule = state->rule; + state->currencySkip = rule.space ? state->st.font->spacew : 0; + state->currencyText = ((!rule.left && rule.space) + ? QString(QChar(' ')) + : QString()) + (*rule.international + ? QString(rule.international) + : config.currency) + ((rule.left && rule.space) + ? QString(QChar(' ')) + : QString()); + if (rule.left) { + state->left = CreateChild( + wrap.get(), + state->currencyText, + st::paymentsFieldAdditional); + } + state->right = CreateChild( + wrap.get(), + QString(), + st::paymentsFieldAdditional); + const auto leftSkip = state->left + ? (state->left->naturalWidth() + state->currencySkip) + : 0; + const auto rightSkip = st::paymentsFieldAdditional.style.font->width( + QString(QChar(rule.decimal)) + + QString(QChar('0')).repeated(rule.exponent) + + (rule.left ? QString() : state->currencyText)); + state->st.textMargins += QMargins(leftSkip, 0, rightSkip, 0); + state->st.placeholderMargins -= QMargins(leftSkip, 0, rightSkip, 0); + const auto result = CreateChild( + wrap.get(), + state->st, + std::move(config.placeholder), + Parse(config)); + result->setPlaceholderHidden(true); + if (state->left) { + state->left->move(0, state->st.textMargins.top()); + } + const auto updateRight = [=] { + const auto text = result->getLastText(); + const auto width = state->st.font->width(text); + const auto rect = result->getTextRect(); + const auto &rule = state->rule; + const auto symbol = QChar(rule.decimal); + const auto decimal = text.indexOf(symbol); + const auto zeros = (decimal >= 0) + ? std::max(rule.exponent - (text.size() - decimal - 1), 0) + : rule.stripDotZero + ? 0 + : rule.exponent; + const auto valueDecimalSeparator = (decimal >= 0 || !zeros) + ? QString() + : QString(symbol); + const auto zeroString = QString(QChar('0')); + const auto valueRightPart = (text.isEmpty() ? zeroString : QString()) + + valueDecimalSeparator + + zeroString.repeated(zeros); + const auto right = valueRightPart + + (rule.left ? QString() : state->currencyText); + state->right->setText(right); + state->right->setTextColorOverride(valueRightPart.isEmpty() + ? std::nullopt + : std::make_optional(st::windowSubTextFg->c)); + state->right->move( + (state->st.textMargins.left() + + width + + ((rule.left || !valueRightPart.isEmpty()) + ? 0 + : state->currencySkip)), + state->st.textMargins.top()); + }; + std::move( + textPossiblyChanged + ) | rpl::start_with_next(updateRight, result->lifetime()); + if (state->left) { + state->left->raise(); + } + state->right->raise(); + return result; +} + [[nodiscard]] MaskedInputField *LookupMaskedField( not_null wrap, - FieldConfig &config) { + FieldConfig &config, + rpl::producer<> textPossiblyChanged) { if (!UseMaskedField(config.type)) { return nullptr; } @@ -96,7 +386,6 @@ namespace { case FieldType::CardExpireDate: case FieldType::CardCVC: case FieldType::Country: - case FieldType::PriceAmount: return CreateChild( wrap.get(), st::paymentsField, @@ -109,6 +398,11 @@ namespace { std::move(config.placeholder), ExtractPhonePrefix(config.defaultPhone), Parse(config)); + case FieldType::Money: + return CreateMoneyField( + wrap, + config, + std::move(textPossiblyChanged)); } Unexpected("FieldType in Payments::Ui::LookupMaskedField."); } @@ -119,7 +413,10 @@ Field::Field(QWidget *parent, FieldConfig &&config) : _config(config) , _wrap(CreateWrap(parent, config)) , _input(LookupInputField(_wrap.get(), config)) -, _masked(LookupMaskedField(_wrap.get(), config)) +, _masked(LookupMaskedField( + _wrap.get(), + config, + _textPossiblyChanged.events_starting_with({}))) , _countryIso2(config.value) { if (_masked) { setupMaskedGeometry(); @@ -129,6 +426,8 @@ Field::Field(QWidget *parent, FieldConfig &&config) } if (const auto &validator = config.validator) { setupValidator(validator); + } else if (config.type == FieldType::Money) { + setupValidator(MoneyValidator(LookupCurrencyRule(config.currency))); } setupFrontBackspace(); } @@ -210,7 +509,7 @@ void Field::setupValidator(Fn validator) { const auto selectionStart = _masked->selectionStart(); const auto selectionEnd = _masked->selectionEnd(); return { - .value = value(), + .value = _masked->getLastText(), .position = position, .anchor = (selectionStart == selectionEnd ? position @@ -221,7 +520,7 @@ void Field::setupValidator(Fn validator) { } const auto cursor = _input->textCursor(); return { - .value = value(), + .value = _input->getLastText(), .position = cursor.position(), .anchor = cursor.anchor(), }; @@ -253,6 +552,7 @@ void Field::setupValidator(Fn validator) { const auto guard = gsl::finally([&] { _validating = false; save(); + _textPossiblyChanged.fire({}); }); const auto now = state(); diff --git a/Telegram/SourceFiles/payments/ui/payments_field.h b/Telegram/SourceFiles/payments/ui/payments_field.h index e9b9202be..5c9c37109 100644 --- a/Telegram/SourceFiles/payments/ui/payments_field.h +++ b/Telegram/SourceFiles/payments/ui/payments_field.h @@ -29,7 +29,7 @@ enum class FieldType { Country, Phone, Email, - PriceAmount, + Money, }; struct FieldValidateRequest { @@ -83,6 +83,7 @@ struct FieldConfig { QString value; Fn validator; Fn)> showBox; + QString currency; QString defaultPhone; QString defaultCountry; }; @@ -124,6 +125,7 @@ private: const base::unique_qptr _wrap; rpl::event_stream<> _frontBackspace; rpl::event_stream<> _finished; + rpl::event_stream<> _textPossiblyChanged; // Must be above _masked. InputField *_input = nullptr; MaskedInputField *_masked = nullptr; QString _countryIso2; diff --git a/Telegram/SourceFiles/payments/ui/payments_form_summary.cpp b/Telegram/SourceFiles/payments/ui/payments_form_summary.cpp index b1aa3a2b6..10cada6de 100644 --- a/Telegram/SourceFiles/payments/ui/payments_form_summary.cpp +++ b/Telegram/SourceFiles/payments/ui/payments_form_summary.cpp @@ -279,7 +279,9 @@ void FormSummary::setupPrices(not_null layout) { add(tr::lng_payments_tips_label(tr::now), tips); } } else if (_invoice.tipsMax > 0) { - const auto text = formatAmount(_invoice.tipsSelected); + const auto text = _invoice.tipsSelected + ? formatAmount(_invoice.tipsSelected) + : tr::lng_payments_tips_add(tr::now); const auto label = addRow( tr::lng_payments_tips_label(tr::now), Ui::Text::Link(text, "internal:edit_tips")); diff --git a/Telegram/SourceFiles/payments/ui/payments_panel.cpp b/Telegram/SourceFiles/payments/ui/payments_panel.cpp index cf74d02cc..0f457fe47 100644 --- a/Telegram/SourceFiles/payments/ui/payments_panel.cpp +++ b/Telegram/SourceFiles/payments/ui/payments_panel.cpp @@ -14,6 +14,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "payments/ui/payments_field.h" #include "ui/widgets/separate_panel.h" #include "ui/widgets/checkbox.h" +#include "ui/wrap/fade_wrap.h" #include "ui/boxes/single_choice_box.h" #include "ui/text/format_values.h" #include "lang/lang_keys.h" @@ -23,18 +24,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "styles/style_layers.h" namespace Payments::Ui { -namespace { - -[[nodiscard]] auto PriceAmountValidator(int64 min, int64 max) { - return [=](FieldValidateRequest request) { - return FieldValidateResult{ - .value = request.nowValue, - .position = request.nowPosition, - }; - }; -} - -} // namespace Panel::Panel(not_null delegate) : _delegate(delegate) @@ -193,27 +182,52 @@ void Panel::chooseTips(const Invoice &invoice) { const auto min = invoice.tipsMin; const auto max = invoice.tipsMax; const auto now = invoice.tipsSelected; + const auto currency = invoice.currency; showBox(Box([=](not_null box) { - box->setTitle(tr::lng_payments_tips_title()); - + box->setTitle(tr::lng_payments_tips_box_title()); const auto row = box->lifetime().make_state( box, FieldConfig{ - .type = FieldType::PriceAmount, - .placeholder = tr::lng_payments_tips_enter(), + .type = FieldType::Money, .value = QString::number(now), - .validator = PriceAmountValidator(min, max), + .currency = ([&]() -> QString { + static auto counter = 0; + switch (++counter % 9) { + case 0: return "USD"; + case 1: return "EUR"; + case 2: return "IRR"; + case 3: return "BRL"; + case 4: return "ALL"; + case 5: return "AZN"; + case 6: return "CHF"; + case 7: return "DKK"; + case 8: return "KZT"; + } + return currency; + })(), // #TODO payments currency, }); box->setFocusCallback([=] { row->setFocusFast(); }); box->addRow(row->ownedWidget()); - box->addRow(object_ptr(box, "Min: " + QString::number(min), st::defaultFlatLabel)); - box->addRow(object_ptr(box, "Max: " + QString::number(max), st::defaultFlatLabel)); + const auto errorWrap = box->addRow( + object_ptr>( + box, + object_ptr( + box, + tr::lng_payments_tips_max( + lt_amount, + rpl::single(FillAmountAndCurrency(max, currency))), + st::paymentTipsErrorLabel)), + st::paymentTipsErrorPadding); + errorWrap->hide(anim::type::instant); box->addButton(tr::lng_settings_save(), [=] { const auto value = row->value().toLongLong(); - if (value < min || value > max) { + if (value < min) { row->showError(); + } else if (value > max) { + row->showError(); + errorWrap->show(anim::type::normal); } else { _delegate->panelChangeTips(value); box->closeBox(); diff --git a/Telegram/SourceFiles/ui/text/format_values.cpp b/Telegram/SourceFiles/ui/text/format_values.cpp index 9294cc0bc..d469102ec 100644 --- a/Telegram/SourceFiles/ui/text/format_values.cpp +++ b/Telegram/SourceFiles/ui/text/format_values.cpp @@ -45,40 +45,6 @@ namespace { return phrase(tr::now, lt_ready, readyStr, lt_total, totalStr, lt_mb, mb); } -[[nodiscard]] QString FormatWithSeparators( - double amount, - int precision, - char decimal, - char thousands) { - Expects(decimal != 0); - - // Thanks https://stackoverflow.com/a/5058949 - struct FormattingHelper : std::numpunct { - FormattingHelper(char decimal, char thousands) - : decimal(decimal) - , thousands(thousands) { - } - - char do_decimal_point() const override { return decimal; } - char do_thousands_sep() const override { return thousands; } - - char decimal = '.'; - char thousands = ','; - }; - - auto stream = std::ostringstream(); - stream.imbue(std::locale( - stream.getloc(), - new FormattingHelper(decimal, thousands ? thousands : '?'))); - stream.precision(precision); - stream << std::fixed << amount; - auto result = QString::fromStdString(stream.str()); - if (!thousands) { - result.replace('?', QString()); - } - return result; -} - } // namespace QString FormatSizeText(qint64 size) { @@ -162,16 +128,37 @@ QString FormatPlayedText(qint64 played, qint64 duration) { } QString FillAmountAndCurrency(int64 amount, const QString ¤cy) { - struct Rule { - //const char *name = ""; - //const char *native = ""; - const char *international = ""; - char thousands = ','; - char decimal = '.'; - bool left = true; - bool space = false; - }; - static const auto kRules = std::vector>{ + const auto rule = LookupCurrencyRule(currency); + + const auto prefix = (amount < 0) + ? QString::fromUtf8("\xe2\x88\x92") + : QString(); + const auto value = std::abs(amount) / std::pow(10., rule.exponent); + const auto name = (*rule.international) + ? QString::fromUtf8(rule.international) + : currency; + auto result = prefix; + if (rule.left) { + result.append(name); + if (rule.space) result.append(' '); + } + const auto precision = (!rule.stripDotZero || std::floor(value) != value) + ? rule.exponent + : 0; + result.append(FormatWithSeparators( + value, + precision, + rule.decimal, + rule.thousands)); + if (!rule.left) { + if (rule.space) result.append(' '); + result.append(name); + } + return result; +} + +CurrencyRule LookupCurrencyRule(const QString ¤cy) { + static const auto kRules = std::vector>{ { u"AED"_q, { "", ',', '.', true, true } }, { u"AFN"_q, {} }, { u"ALL"_q, { "", '.', ',', false } }, @@ -185,11 +172,11 @@ QString FillAmountAndCurrency(int64 amount, const QString ¤cy) { { u"BND"_q, { "", '.', ',', } }, { u"BOB"_q, { "", '.', ',', true, true } }, { u"BRL"_q, { "R$", '.', ',', true, true } }, - { u"BHD"_q, { "", ',', '.', true, true } }, - { u"BYR"_q, { "", ' ', ',', false, true } }, + { u"BHD"_q, { "", ',', '.', true, true, 3 } }, + { u"BYR"_q, { "", ' ', ',', false, true, 0 } }, { u"CAD"_q, { "CA$" } }, { u"CHF"_q, { "", '\'', '.', false, true } }, - { u"CLP"_q, { "", '.', ',', true, true } }, + { u"CLP"_q, { "", '.', ',', true, true, 0 } }, { u"CNY"_q, { "\x43\x4E\xC2\xA5" } }, { u"COP"_q, { "", '.', ',', true, true } }, { u"CRC"_q, { "", '.', ',', } }, @@ -209,12 +196,12 @@ QString FillAmountAndCurrency(int64 amount, const QString ¤cy) { { u"IDR"_q, { "", '.', ',', } }, { u"ILS"_q, { "\xE2\x82\xAA", ',', '.', true, true } }, { u"INR"_q, { "\xE2\x82\xB9" } }, - { u"ISK"_q, { "", '.', ',', false, true } }, + { u"ISK"_q, { "", '.', ',', false, true, 0 } }, { u"JMD"_q, {} }, - { u"JPY"_q, { "\xC2\xA5" } }, + { u"JPY"_q, { "\xC2\xA5", ',', '.', true, false, 0 } }, { u"KES"_q, {} }, { u"KGS"_q, { "", ' ', '-', false, true } }, - { u"KRW"_q, { "\xE2\x82\xA9" } }, + { u"KRW"_q, { "\xE2\x82\xA9", ',', '.', true, false, 0 } }, { u"KZT"_q, { "", ' ', '-', } }, { u"LBP"_q, { "", ',', '.', true, true } }, { u"LKR"_q, { "", ',', '.', true, true } }, @@ -236,7 +223,7 @@ QString FillAmountAndCurrency(int64 amount, const QString ¤cy) { { u"PHP"_q, {} }, { u"PKR"_q, {} }, { u"PLN"_q, { "", ' ', ',', false, true } }, - { u"PYG"_q, { "", '.', ',', true, true } }, + { u"PYG"_q, { "", '.', ',', true, true, 0 } }, { u"QAR"_q, { "", ',', '.', true, true } }, { u"RON"_q, { "", '.', ',', false, true } }, { u"RSD"_q, { "", '.', ',', false, true } }, @@ -251,27 +238,27 @@ QString FillAmountAndCurrency(int64 amount, const QString ¤cy) { { u"TWD"_q, { "NT$" } }, { u"TZS"_q, {} }, { u"UAH"_q, { "", ' ', ',', false } }, - { u"UGX"_q, {} }, + { u"UGX"_q, { "", ',', '.', true, false, 0 } }, { u"USD"_q, { "$" } }, { u"UYU"_q, { "", '.', ',', true, true } }, { u"UZS"_q, { "", ' ', ',', false, true } }, - { u"VND"_q, { "\xE2\x82\xAB", '.', ',', false, true } }, + { u"VND"_q, { "\xE2\x82\xAB", '.', ',', false, true, 0 } }, { u"YER"_q, { "", ',', '.', true, true } }, { u"ZAR"_q, { "", ',', '.', true, true } }, - { u"IRR"_q, { "", ',', '/', false, true } }, - { u"IQD"_q, { "", ',', '.', true, true } }, + { u"IRR"_q, { "", ',', '/', false, true, 2, true } }, + { u"IQD"_q, { "", ',', '.', true, true, 3 } }, { u"VEF"_q, { "", '.', ',', true, true } }, { u"SYP"_q, { "", ',', '.', true, true } }, - //{ u"VUV"_q, { "", ',', '.', false } }, + //{ u"VUV"_q, { "", ',', '.', false, false, 0 } }, //{ u"WST"_q, {} }, - //{ u"XAF"_q, { "FCFA", ',', '.', false } }, + //{ u"XAF"_q, { "FCFA", ',', '.', false, false, 0 } }, //{ u"XCD"_q, {} }, - //{ u"XOF"_q, { "CFA", ' ', ',', false } }, - //{ u"XPF"_q, { "", ',', '.', false } }, + //{ u"XOF"_q, { "CFA", ' ', ',', false, false, 0 } }, + //{ u"XPF"_q, { "", ',', '.', false, false, 0 } }, //{ u"ZMW"_q, {} }, //{ u"ANG"_q, {} }, - //{ u"RWF"_q, { "", ' ', ',', true, true } }, + //{ u"RWF"_q, { "", ' ', ',', true, true, 0 } }, //{ u"PGK"_q, {} }, //{ u"TOP"_q, {} }, //{ u"SBD"_q, {} }, @@ -286,109 +273,85 @@ QString FillAmountAndCurrency(int64 amount, const QString ¤cy) { //{ u"AOA"_q, {} }, //{ u"AWG"_q, {} }, //{ u"BBD"_q, {} }, - //{ u"BIF"_q, { "", ',', '.', false } }, + //{ u"BIF"_q, { "", ',', '.', false, false, 0 } }, //{ u"BMD"_q, {} }, //{ u"BSD"_q, {} }, //{ u"BWP"_q, {} }, //{ u"BZD"_q, {} }, //{ u"CDF"_q, { "", ',', '.', false } }, - //{ u"CVE"_q, {} }, - //{ u"DJF"_q, { "", ',', '.', false } }, + //{ u"CVE"_q, { "", ',', '.', true, false, 0 } }, + //{ u"DJF"_q, { "", ',', '.', false, false, 0 } }, //{ u"ETB"_q, {} }, //{ u"FJD"_q, {} }, //{ u"FKP"_q, {} }, //{ u"GIP"_q, {} }, //{ u"GMD"_q, { "", ',', '.', false } }, - //{ u"GNF"_q, { "", ',', '.', false } }, + //{ u"GNF"_q, { "", ',', '.', false, false, 0 } }, //{ u"GYD"_q, {} }, //{ u"HTG"_q, {} }, //{ u"KHR"_q, { "", ',', '.', false } }, - //{ u"KMF"_q, { "", ',', '.', false } }, + //{ u"KMF"_q, { "", ',', '.', false, false, 0 } }, //{ u"KYD"_q, {} }, //{ u"LAK"_q, { "", ',', '.', false } }, //{ u"LRD"_q, {} }, //{ u"LSL"_q, { "", ',', '.', false } }, - //{ u"MGA"_q, {} }, + //{ u"MGA"_q, { "", ',', '.', true, false, 0 } }, //{ u"MKD"_q, { "", '.', ',', false, true } }, //{ u"MOP"_q, {} }, //{ u"MWK"_q, {} }, //{ u"NAD"_q, {} }, + //{ u"CLF"_q, { "", ',', '.', true, false, 4 } }, + //{ u"JOD"_q, { "", ',', '.', true, false, 3 } }, + //{ u"KWD"_q, { "", ',', '.', true, false, 3 } }, + //{ u"LYD"_q, { "", ',', '.', true, false, 3 } }, + //{ u"OMR"_q, { "", ',', '.', true, false, 3 } }, + //{ u"TND"_q, { "", ',', '.', true, false, 3 } }, + //{ u"UYI"_q, { "", ',', '.', true, false, 0 } }, + //{ u"MRO"_q, { "", ',', '.', true, false, 1 } }, }; static const auto kRulesMap = [] { // flat_multi_map_pair_type lacks some required constructors :( - auto &&pairs = kRules | ranges::views::transform([](auto &&pair) { - return base::flat_multi_map_pair_type( + auto &&list = kRules | ranges::views::transform([](auto &&pair) { + return base::flat_multi_map_pair_type( pair.first, pair.second); }); - return base::flat_map(begin(pairs), end(pairs)); + return base::flat_map(begin(list), end(list)); }(); - static const auto kExponents = base::flat_map{ - { u"CLF"_q, 4 }, - { u"BHD"_q, 3 }, - { u"IQD"_q, 3 }, - { u"JOD"_q, 3 }, - { u"KWD"_q, 3 }, - { u"LYD"_q, 3 }, - { u"OMR"_q, 3 }, - { u"TND"_q, 3 }, - { u"BIF"_q, 0 }, - { u"BYR"_q, 0 }, - { u"CLP"_q, 0 }, - { u"CVE"_q, 0 }, - { u"DJF"_q, 0 }, - { u"GNF"_q, 0 }, - { u"ISK"_q, 0 }, - { u"JPY"_q, 0 }, - { u"KMF"_q, 0 }, - { u"KRW"_q, 0 }, - { u"MGA"_q, 0 }, - { u"PYG"_q, 0 }, - { u"RWF"_q, 0 }, - { u"UGX"_q, 0 }, - { u"UYI"_q, 0 }, - { u"VND"_q, 0 }, - { u"VUV"_q, 0 }, - { u"XAF"_q, 0 }, - { u"XOF"_q, 0 }, - { u"XPF"_q, 0 }, - { u"MRO"_q, 1 }, + const auto i = kRulesMap.find(currency); + return (i != end(kRulesMap)) ? i->second : CurrencyRule{}; +} + +[[nodiscard]] QString FormatWithSeparators( + double amount, + int precision, + char decimal, + char thousands) { + Expects(decimal != 0); + + // Thanks https://stackoverflow.com/a/5058949 + struct FormattingHelper : std::numpunct { + FormattingHelper(char decimal, char thousands) + : decimal(decimal) + , thousands(thousands) { + } + + char do_decimal_point() const override { return decimal; } + char do_thousands_sep() const override { return thousands; } + + char decimal = '.'; + char thousands = ','; }; - const auto prefix = (amount < 0) - ? QString::fromUtf8("\xe2\x88\x92") - : QString(); - - const auto exponentIt = kExponents.find(currency); - const auto exponent = (exponentIt != end(kExponents)) - ? exponentIt->second - : 2; - const auto value = std::abs(amount) / std::pow(10., exponent); - const auto ruleIt = kRulesMap.find(currency); - if (ruleIt == end(kRulesMap)) { - return prefix + QLocale::system().toCurrencyString(value, currency); - } - const auto &rule = ruleIt->second; - const auto name = (*rule.international) - ? QString::fromUtf8(rule.international) - : currency; - auto result = prefix; - if (rule.left) { - result.append(name); - if (rule.space) result.append(' '); - } - const auto precision = (currency != u"IRR"_q - || std::floor(value) != value) - ? exponent - : 0; - result.append(FormatWithSeparators( - value, - precision, - rule.decimal, - rule.thousands)); - if (!rule.left) { - if (rule.space) result.append(' '); - result.append(name); + auto stream = std::ostringstream(); + stream.imbue(std::locale( + stream.getloc(), + new FormattingHelper(decimal, thousands ? thousands : '?'))); + stream.precision(precision); + stream << std::fixed << amount; + auto result = QString::fromStdString(stream.str()); + if (!thousands) { + result.replace('?', QString()); } return result; } diff --git a/Telegram/SourceFiles/ui/text/format_values.h b/Telegram/SourceFiles/ui/text/format_values.h index 779d3e7ce..0bfb69d9c 100644 --- a/Telegram/SourceFiles/ui/text/format_values.h +++ b/Telegram/SourceFiles/ui/text/format_values.h @@ -23,9 +23,25 @@ inline constexpr auto FileStatusSizeFailed = 0x7FFFFFF2; [[nodiscard]] QString FormatGifAndSizeText(qint64 size); [[nodiscard]] QString FormatPlayedText(qint64 played, qint64 duration); +struct CurrencyRule { + const char *international = ""; + char thousands = ','; + char decimal = '.'; + bool left = true; + bool space = false; + int exponent = 2; + bool stripDotZero = false; +}; + [[nodiscard]] QString FillAmountAndCurrency( int64 amount, const QString ¤cy); +[[nodiscard]] CurrencyRule LookupCurrencyRule(const QString ¤cy); +[[nodiscard]] QString FormatWithSeparators( + double amount, + int precision, + char decimal, + char thousands); [[nodiscard]] QString ComposeNameString( const QString &filename,