From 8ff42a86d9cdff1a902ddebfccb38bf69f5180ed Mon Sep 17 00:00:00 2001 From: 23rd <23rd@vivaldi.net> Date: Fri, 6 Aug 2021 04:07:34 +0300 Subject: [PATCH] Moved time input widgets to lib_ui. --- CMakeLists.txt | 2 + ui/ui_utility.cpp | 12 + ui/ui_utility.h | 3 + ui/widgets/time_input.cpp | 468 ++++++++++++++++++++++++++++++++++++++ ui/widgets/time_input.h | 78 +++++++ 5 files changed, 563 insertions(+) create mode 100644 ui/widgets/time_input.cpp create mode 100644 ui/widgets/time_input.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f463fed..6119c75 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -188,6 +188,8 @@ PRIVATE ui/widgets/side_bar_button.h ui/widgets/shadow.cpp ui/widgets/shadow.h + ui/widgets/time_input.cpp + ui/widgets/time_input.h ui/widgets/tooltip.cpp ui/widgets/tooltip.h ui/wrap/fade_wrap.cpp diff --git a/ui/ui_utility.cpp b/ui/ui_utility.cpp index f37e607..6388228 100644 --- a/ui/ui_utility.cpp +++ b/ui/ui_utility.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -220,4 +221,15 @@ void DisableCustomScaling() { } } +int WheelDirection(not_null e) { + // Only a mouse wheel is accepted. + constexpr auto step = static_cast(QWheelEvent::DefaultDeltasPerStep); + const auto delta = e->angleDelta().y(); + const auto absDelta = std::abs(delta); + if (absDelta != step) { + return 0; + } + return (delta / absDelta); +} + } // namespace Ui diff --git a/ui/ui_utility.h b/ui/ui_utility.h index ece602d..f7f6fb1 100644 --- a/ui/ui_utility.h +++ b/ui/ui_utility.h @@ -14,6 +14,7 @@ class QPixmap; class QImage; +class QWheelEvent; enum class RectPart; using RectParts = base::flags; @@ -193,4 +194,6 @@ QPointer MakeWeak(not_null object) { void DisableCustomScaling(); +int WheelDirection(not_null e); + } // namespace Ui diff --git a/ui/widgets/time_input.cpp b/ui/widgets/time_input.cpp new file mode 100644 index 0000000..318e1a1 --- /dev/null +++ b/ui/widgets/time_input.cpp @@ -0,0 +1,468 @@ +// This file is part of Desktop App Toolkit, +// a set of libraries for developing nice desktop applications. +// +// For license and copyright information please follow this link: +// https://github.com/desktop-app/legal/blob/master/LEGAL +// +#include "ui/widgets/time_input.h" + +#include "ui/widgets/input_fields.h" +#include "ui/ui_utility.h" + +#include +#include + +namespace Ui { +namespace { + +QTime ValidateTime(const QString &value) { + const auto match = QRegularExpression( + "^(\\d{1,2})\\:(\\d\\d)$").match(value); + if (!match.hasMatch()) { + return QTime(); + } + const auto readInt = [](const QString &value) { + auto ref = value.midRef(0); + while (!ref.isEmpty() && ref.at(0) == '0') { + ref = ref.mid(1); + } + return ref.toInt(); + }; + return QTime(readInt(match.captured(1)), readInt(match.captured(2))); +} + +QString GetHour(const QString &value) { + if (const auto time = ValidateTime(value); time.isValid()) { + return QString::number(time.hour()); + } + return QString(); +} + +QString GetMinute(const QString &value) { + if (const auto time = ValidateTime(value); time.isValid()) { + return QString("%1").arg(time.minute(), 2, 10, QChar('0')); + } + return QString(); +} + +} // namespace + +class TimePart final : public MaskedInputField { +public: + using MaskedInputField::MaskedInputField; + + void setMaxValue(int value); + void setWheelStep(int value); + + rpl::producer<> erasePrevious() const; + rpl::producer putNext() const; + +protected: + void keyPressEvent(QKeyEvent *e) override; + void wheelEvent(QWheelEvent *e) override; + + void correctValue( + const QString &was, + int wasCursor, + QString &now, + int &nowCursor) override; + +private: + int _maxValue = 0; + int _maxDigits = 0; + int _wheelStep = 0; + rpl::event_stream<> _erasePrevious; + rpl::event_stream _putNext; + +}; + +int Number(not_null field) { + const auto text = field->getLastText(); + auto ref = text.midRef(0); + while (!ref.isEmpty() && ref.at(0) == '0') { + ref = ref.mid(1); + } + return ref.toInt(); +} + +void TimePart::setMaxValue(int value) { + _maxValue = value; + _maxDigits = 0; + while (value > 0) { + ++_maxDigits; + value /= 10; + } +} + +void TimePart::setWheelStep(int value) { + _wheelStep = value; +} + +rpl::producer<> TimePart::erasePrevious() const { + return _erasePrevious.events(); +} + +rpl::producer TimePart::putNext() const { + return _putNext.events(); +} + +void TimePart::keyPressEvent(QKeyEvent *e) { + const auto isBackspace = (e->key() == Qt::Key_Backspace); + const auto isBeginning = (cursorPosition() == 0); + if (isBackspace && isBeginning && !hasSelectedText()) { + _erasePrevious.fire({}); + } else { + MaskedInputField::keyPressEvent(e); + } +} + +void TimePart::wheelEvent(QWheelEvent *e) { + const auto direction = WheelDirection(e); + auto time = Number(this) + (direction * _wheelStep); + const auto max = _maxValue + 1; + if (time < 0) { + time += max; + } else if (time >= max) { + time -= max; + } + setText(QString::number(time)); +} + +void TimePart::correctValue( + const QString &was, + int wasCursor, + QString &now, + int &nowCursor) { + auto newText = QString(); + auto newCursor = -1; + const auto oldCursor = nowCursor; + const auto oldLength = now.size(); + auto accumulated = 0; + auto limit = 0; + for (; limit != oldLength; ++limit) { + if (now[limit].isDigit()) { + accumulated *= 10; + accumulated += (now[limit].unicode() - '0'); + if (accumulated > _maxValue || limit == _maxDigits) { + break; + } + } + } + for (auto i = 0; i != limit;) { + if (now[i].isDigit()) { + newText += now[i]; + } + if (++i == oldCursor) { + newCursor = newText.size(); + } + } + if (newCursor < 0) { + newCursor = newText.size(); + } + if (newText != now) { + now = newText; + setText(now); + startPlaceholderAnimation(); + } + if (newCursor != nowCursor) { + nowCursor = newCursor; + setCursorPosition(nowCursor); + } + if (accumulated > _maxValue + || (limit == _maxDigits && oldLength > _maxDigits)) { + if (oldCursor > limit) { + _putNext.fire('0' + (accumulated % 10)); + } else { + _putNext.fire(0); + } + } +} + +TimeInput::TimeInput( + QWidget *parent, + const QString &value, + const style::InputField &stField, + const style::InputField &stDateField, + const style::FlatLabel &stSeparator, + const style::margins &stSeparatorPadding) +: RpWidget(parent) +, _stField(stField) +, _stDateField(stDateField) +, _stSeparator(stSeparator) +, _stSeparatorPadding(stSeparatorPadding) +, _hour( + this, + _stField, + rpl::never(), + GetHour(value)) +, _separator1( + this, + object_ptr( + this, + QString(":"), + _stSeparator), + _stSeparatorPadding) +, _minute( + this, + _stField, + rpl::never(), + GetMinute(value)) +, _value(valueCurrent()) { + const auto focused = [=](const object_ptr &field) { + return [this, pointer = MakeWeak(field.data())]{ + _borderAnimationStart = pointer->borderAnimationStart() + + pointer->x() + - _hour->x(); + setFocused(true); + }; + }; + const auto blurred = [=] { + setFocused(false); + }; + const auto changed = [=] { + _value = valueCurrent(); + }; + connect(_hour, &MaskedInputField::focused, focused(_hour)); + connect(_minute, &MaskedInputField::focused, focused(_minute)); + connect(_hour, &MaskedInputField::blurred, blurred); + connect(_minute, &MaskedInputField::blurred, blurred); + connect(_hour, &MaskedInputField::changed, changed); + connect(_minute, &MaskedInputField::changed, changed); + _hour->setMaxValue(23); + _hour->setWheelStep(1); + _hour->putNext() | rpl::start_with_next([=](QChar ch) { + putNext(_minute, ch); + }, lifetime()); + _minute->setMaxValue(59); + _minute->setWheelStep(10); + _minute->erasePrevious() | rpl::start_with_next([=] { + erasePrevious(_hour); + }, lifetime()); + _separator1->setAttribute(Qt::WA_TransparentForMouseEvents); + setMouseTracking(true); + + _value.changes( + ) | rpl::start_with_next([=] { + setErrorShown(false); + }, lifetime()); + + const auto submitHour = [=] { + if (hour()) { + _minute->setFocus(); + } + }; + const auto submitMinute = [=] { + if (minute()) { + if (hour()) { + _submitRequests.fire({}); + } else { + _hour->setFocus(); + } + } + }; + connect( + _hour, + &MaskedInputField::submitted, + submitHour); + connect( + _minute, + &MaskedInputField::submitted, + submitMinute); +} + +void TimeInput::putNext(const object_ptr &field, QChar ch) { + field->setCursorPosition(0); + if (ch.unicode()) { + field->setText(ch + field->getLastText()); + field->setCursorPosition(1); + } + field->setFocus(); +} + +void TimeInput::erasePrevious(const object_ptr &field) { + const auto text = field->getLastText(); + if (!text.isEmpty()) { + field->setCursorPosition(text.size() - 1); + field->setText(text.mid(0, text.size() - 1)); + } + field->setFocus(); +} + +bool TimeInput::setFocusFast() { + if (hour()) { + _minute->setFocusFast(); + } else { + _hour->setFocusFast(); + } + return true; +} + +int TimeInput::hour() const { + return Number(_hour); +} + +int TimeInput::minute() const { + return Number(_minute); +} + +QString TimeInput::valueCurrent() const { + const auto result = QString("%1:%2" + ).arg(hour() + ).arg(minute(), 2, 10, QChar('0')); + return ValidateTime(result).isValid() ? result : QString(); +} + +rpl::producer TimeInput::value() const { + return _value.value(); +} + +rpl::producer<> TimeInput::submitRequests() const { + return _submitRequests.events(); +} + +void TimeInput::paintEvent(QPaintEvent *e) { + Painter p(this); + + const auto &_st = _stDateField; + const auto height = _st.heightMin; + if (_st.border) { + p.fillRect(0, height - _st.border, width(), _st.border, _st.borderFg); + } + auto errorDegree = _a_error.value(_error ? 1. : 0.); + auto borderShownDegree = _a_borderShown.value(1.); + auto borderOpacity = _a_borderOpacity.value(_borderVisible ? 1. : 0.); + if (_st.borderActive && (borderOpacity > 0.)) { + auto borderStart = std::clamp(_borderAnimationStart, 0, width()); + auto borderFrom = qRound(borderStart * (1. - borderShownDegree)); + auto borderTo = borderStart + qRound((width() - borderStart) * borderShownDegree); + if (borderTo > borderFrom) { + auto borderFg = anim::brush(_st.borderFgActive, _st.borderFgError, errorDegree); + p.setOpacity(borderOpacity); + p.fillRect(borderFrom, height - _st.borderActive, borderTo - borderFrom, _st.borderActive, borderFg); + p.setOpacity(1); + } + } +} + +template +bool TimeInput::insideSeparator(QPoint position, const Widget &widget) const { + const auto x = position.x(); + const auto y = position.y(); + return (x >= widget->x() && x < widget->x() + widget->width()) + && (y >= _hour->y() && y < _hour->y() + _hour->height()); +} + +void TimeInput::mouseMoveEvent(QMouseEvent *e) { + const auto cursor = insideSeparator(e->pos(), _separator1) + ? style::cur_text + : style::cur_default; + if (_cursor != cursor) { + _cursor = cursor; + setCursor(_cursor); + } +} + +void TimeInput::mousePressEvent(QMouseEvent *e) { + const auto x = e->pos().x(); + const auto focus1 = [&] { + if (_hour->getLastText().size() > 1) { + _minute->setFocus(); + } else { + _hour->setFocus(); + } + }; + if (insideSeparator(e->pos(), _separator1)) { + focus1(); + _borderAnimationStart = x - _hour->x(); + } +} + +int TimeInput::resizeGetHeight(int width) { + const auto &_st = _stField; + const auto &font = _st.placeholderFont; + const auto addToWidth = _stSeparatorPadding.left(); + const auto hourWidth = _st.textMargins.left() + + _st.placeholderMargins.left() + + font->width(QString("23")) + + _st.placeholderMargins.right() + + _st.textMargins.right() + + addToWidth; + const auto minuteWidth = _st.textMargins.left() + + _st.placeholderMargins.left() + + font->width(QString("59")) + + _st.placeholderMargins.right() + + _st.textMargins.right() + + addToWidth; + const auto full = hourWidth + - addToWidth + + _separator1->width() + + minuteWidth + - addToWidth; + auto left = (width - full) / 2; + auto top = 0; + _hour->setGeometry(left, top, hourWidth, _hour->height()); + left += hourWidth - addToWidth; + _separator1->resizeToNaturalWidth(width); + _separator1->move(left, top); + left += _separator1->width(); + _minute->setGeometry(left, top, minuteWidth, _minute->height()); + return _stDateField.heightMin; +} + +void TimeInput::showError() { + setErrorShown(true); + if (!_focused) { + setInnerFocus(); + } +} + +void TimeInput::setInnerFocus() { + if (hour()) { + _minute->setFocus(); + } else { + _hour->setFocus(); + } +} + +void TimeInput::setErrorShown(bool error) { + if (_error != error) { + _error = error; + _a_error.start( + [=] { update(); }, + _error ? 0. : 1., + _error ? 1. : 0., + _stDateField.duration); + startBorderAnimation(); + } +} + +void TimeInput::setFocused(bool focused) { + if (_focused != focused) { + _focused = focused; + _a_focused.start( + [=] { update(); }, + _focused ? 0. : 1., + _focused ? 1. : 0., + _stDateField.duration); + startBorderAnimation(); + } +} + +void TimeInput::startBorderAnimation() { + auto borderVisible = (_error || _focused); + if (_borderVisible != borderVisible) { + _borderVisible = borderVisible; + const auto duration = _stDateField.duration; + if (_borderVisible) { + if (_a_borderOpacity.animating()) { + _a_borderOpacity.start([=] { update(); }, 0., 1., duration); + } else { + _a_borderShown.start([=] { update(); }, 0., 1., duration); + } + } else { + _a_borderOpacity.start([=] { update(); }, 1., 0., duration); + } + } +} + +} // namespace Ui diff --git a/ui/widgets/time_input.h b/ui/widgets/time_input.h new file mode 100644 index 0000000..1724a65 --- /dev/null +++ b/ui/widgets/time_input.h @@ -0,0 +1,78 @@ +// This file is part of Desktop App Toolkit, +// a set of libraries for developing nice desktop applications. +// +// For license and copyright information please follow this link: +// https://github.com/desktop-app/legal/blob/master/LEGAL +// +#pragma once + +#include "ui/rp_widget.h" + +#include "ui/effects/animations.h" +#include "ui/widgets/labels.h" + +namespace Ui { + +class TimePart; + +class TimeInput final : public RpWidget { +public: + TimeInput( + QWidget *parent, + const QString &value, + const style::InputField &stField, + const style::InputField &stDateField, + const style::FlatLabel &stSeparator, + const style::margins &stSeparatorPadding); + + bool setFocusFast(); + rpl::producer value() const; + rpl::producer<> submitRequests() const; + QString valueCurrent() const; + void showError(); + + int resizeGetHeight(int width) override; + +protected: + void paintEvent(QPaintEvent *e) override; + void mousePressEvent(QMouseEvent *e) override; + void mouseMoveEvent(QMouseEvent *e) override; + +private: + void setInnerFocus(); + void putNext(const object_ptr &field, QChar ch); + void erasePrevious(const object_ptr &field); + void setErrorShown(bool error); + void setFocused(bool focused); + void startBorderAnimation(); + template + bool insideSeparator(QPoint position, const Widget &widget) const; + + int hour() const; + int minute() const; + + const style::InputField &_stField; + const style::InputField &_stDateField; + const style::FlatLabel &_stSeparator; + const style::margins &_stSeparatorPadding; + + object_ptr _hour; + object_ptr> _separator1; + object_ptr _minute; + rpl::variable _value; + rpl::event_stream<> _submitRequests; + + style::cursor _cursor = style::cur_default; + Animations::Simple _a_borderShown; + int _borderAnimationStart = 0; + Animations::Simple _a_borderOpacity; + bool _borderVisible = false; + + Animations::Simple _a_error; + bool _error = false; + Animations::Simple _a_focused; + bool _focused = false; + +}; + +} // namespace Ui