diff --git a/CMakeLists.txt b/CMakeLists.txt index 5268434..5c473c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,6 +61,8 @@ PRIVATE ui/effects/radial_animation.h ui/effects/ripple_animation.cpp ui/effects/ripple_animation.h + ui/effects/round_area_with_shadow.cpp + ui/effects/round_area_with_shadow.h ui/effects/show_animation.cpp ui/effects/show_animation.h ui/effects/slide_animation.cpp diff --git a/ui/effects/panel_animation.cpp b/ui/effects/panel_animation.cpp index 9109599..1162214 100644 --- a/ui/effects/panel_animation.cpp +++ b/ui/effects/panel_animation.cpp @@ -361,30 +361,64 @@ void PanelAnimation::start() { checkCorner(_bottomRight); } -void PanelAnimation::paintFrame(QPainter &p, int x, int y, int outerWidth, float64 dt, float64 opacity) { +auto PanelAnimation::computeState(float64 dt, float64 opacity) const +-> PaintState { + auto &transition = anim::easeOutCirc; + if (dt < _alphaDuration) { + opacity *= transition(1., dt / _alphaDuration); + } + const auto widthProgress = (_startWidth < 0 || dt >= _widthDuration) + ? 1. + : transition(1., dt / _widthDuration); + const auto heightProgress = (_startHeight < 0 || dt >= _heightDuration) + ? 1. + : transition(1., dt / _heightDuration); + auto frameWidth = (widthProgress < 1.) + ? anim::interpolate(_startWidth, _finalInnerWidth, widthProgress) + : _finalInnerWidth; + auto frameHeight = (heightProgress < 1.) + ? anim::interpolate(_startHeight, _finalInnerHeight, heightProgress) + : _finalInnerHeight; + if (auto decrease = (frameWidth % style::DevicePixelRatio())) { + frameWidth -= decrease; + } + if (auto decrease = (frameHeight % style::DevicePixelRatio())) { + frameHeight -= decrease; + } + return { + .opacity = opacity, + .widthProgress = widthProgress, + .heightProgress = heightProgress, + .fade = transition(1., dt), + .width = frameWidth, + .height = frameHeight, + }; +} + +auto PanelAnimation::paintFrame( + QPainter &p, + int x, + int y, + int outerWidth, + float64 dt, + float64 opacity) +-> PaintState { Assert(started()); Assert(dt >= 0.); const auto pixelRatio = style::DevicePixelRatio(); - auto &transition = anim::easeOutCirc; - if (dt < _alphaDuration) opacity *= transition(1., dt / _alphaDuration); + const auto state = computeState(dt, opacity); + opacity = state.opacity; _frameAlpha = anim::interpolate(1, 256, opacity); - - auto frameWidth = (_startWidth < 0 || dt >= _widthDuration) ? _finalInnerWidth : anim::interpolate(_startWidth, _finalInnerWidth, transition(1., dt / _widthDuration)); - auto frameHeight = (_startHeight < 0 || dt >= _heightDuration) ? _finalInnerHeight : anim::interpolate(_startHeight, _finalInnerHeight, transition(1., dt / _heightDuration)); - if (auto decrease = (frameWidth % pixelRatio)) { - frameWidth -= decrease; - } - if (auto decrease = (frameHeight % pixelRatio)) { - frameHeight -= decrease; - } + const auto frameWidth = state.width; + const auto frameHeight = state.height; auto frameLeft = (_origin == Origin::TopLeft || _origin == Origin::BottomLeft) ? _finalInnerLeft : (_finalInnerRight - frameWidth); auto frameTop = (_origin == Origin::TopLeft || _origin == Origin::TopRight) ? _finalInnerTop : (_finalInnerBottom - frameHeight); auto frameRight = frameLeft + frameWidth; auto frameBottom = frameTop + frameHeight; - auto fadeTop = (_fadeHeight > 0) ? std::clamp(anim::interpolate(_startFadeTop, _finalInnerHeight, transition(1., dt)), 0, frameHeight) : frameHeight; + auto fadeTop = (_fadeHeight > 0) ? std::clamp(anim::interpolate(_startFadeTop, _finalInnerHeight, state.fade), 0, frameHeight) : frameHeight; if (auto decrease = (fadeTop % pixelRatio)) { fadeTop -= decrease; } @@ -503,6 +537,8 @@ void PanelAnimation::paintFrame(QPainter &p, int x, int y, int outerWidth, float //} p.drawImage(style::rtlpoint(x + (outerLeft / pixelRatio), y + (outerTop / pixelRatio), outerWidth), _frame, QRect(outerLeft, outerTop, outerRight - outerLeft, outerBottom - outerTop)); + + return state; } } // namespace Ui diff --git a/ui/effects/panel_animation.h b/ui/effects/panel_animation.h index 0f6844d..e114eb9 100644 --- a/ui/effects/panel_animation.h +++ b/ui/effects/panel_animation.h @@ -79,11 +79,27 @@ public: PanelAnimation(const style::PanelAnimation &st, Origin origin) : _st(st), _origin(origin) { } + struct PaintState { + float64 opacity = 0.; + float64 widthProgress = 0.; + float64 heightProgress = 0.; + float64 fade = 0.; + int width = 0; + int height = 0; + }; + void setFinalImage(QImage &&finalImage, QRect inner); void setSkipShadow(bool skipShadow); void start(); - void paintFrame(QPainter &p, int x, int y, int outerWidth, float64 dt, float64 opacity); + [[nodiscard]] PaintState computeState(float64 dt, float64 opacity) const; + PaintState paintFrame( + QPainter &p, + int x, + int y, + int outerWidth, + float64 dt, + float64 opacity); private: void setStartWidth(); diff --git a/ui/effects/round_area_with_shadow.cpp b/ui/effects/round_area_with_shadow.cpp new file mode 100644 index 0000000..320993c --- /dev/null +++ b/ui/effects/round_area_with_shadow.cpp @@ -0,0 +1,388 @@ +// 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/effects/round_area_with_shadow.h" + +#include "ui/style/style_core.h" +#include "ui/image/image_prepare.h" +#include "ui/painter.h" + +namespace Ui { +namespace { + +constexpr auto kBgCacheIndex = 0; +constexpr auto kShadowCacheIndex = 0; +constexpr auto kOverlayMaskCacheIndex = 0; +constexpr auto kOverlayShadowCacheIndex = 1; +constexpr auto kOverlayCacheColumsCount = 2; +constexpr auto kDivider = 4; + +} // namespace + +[[nodiscard]] QImage RoundAreaWithShadow::PrepareImage(QSize size) { + const auto ratio = style::DevicePixelRatio(); + auto result = QImage( + size * ratio, + QImage::Format_ARGB32_Premultiplied); + result.setDevicePixelRatio(ratio); + return result; +} + +[[nodiscard]] QImage RoundAreaWithShadow::PrepareFramesCache( + QSize frame, + int columns) { + static_assert(!(kFramesCount % kDivider)); + + return PrepareImage(QSize( + frame.width() * kDivider * columns, + frame.height() * kFramesCount / kDivider)); +} + +[[nodiscard]] QRect RoundAreaWithShadow::FrameCacheRect( + int frameIndex, + int column, + QSize frame) { + const auto ratio = style::DevicePixelRatio(); + const auto origin = QPoint( + frame.width() * (kDivider * column + (frameIndex % kDivider)), + frame.height() * (frameIndex / kDivider)); + return QRect(ratio * origin, ratio * frame); +} + +RoundAreaWithShadow::RoundAreaWithShadow( + QSize inner, + QMargins shadow, + int twiceRadiusMax) +: _inner({}, inner) +, _outer(_inner.marginsAdded(shadow).size()) +, _overlay(QRect( + 0, + 0, + std::max(inner.width(), twiceRadiusMax), + std::max(inner.height(), twiceRadiusMax)).marginsAdded(shadow).size()) +, _cacheBg(PrepareFramesCache(_outer)) +, _shadowParts(PrepareFramesCache(_outer)) +, _overlayCacheParts(PrepareFramesCache(_overlay, kOverlayCacheColumsCount)) +, _overlayMaskScaled(PrepareImage(_overlay)) +, _overlayShadowScaled(PrepareImage(_overlay)) +, _shadowBuffer(PrepareImage(_outer)) { + _inner.translate(QRect({}, _outer).center() - _inner.center()); +} + +ImageSubrect RoundAreaWithShadow::validateOverlayMask( + int frameIndex, + QSize innerSize, + float64 radius, + int twiceRadius, + float64 scale) { + const auto ratio = style::DevicePixelRatio(); + const auto cached = (scale == 1.); + const auto full = cached + ? FrameCacheRect(frameIndex, kOverlayMaskCacheIndex, _overlay) + : QRect(QPoint(), _overlay * ratio); + + const auto minWidth = twiceRadius + _outer.width() - _inner.width(); + const auto minHeight = twiceRadius + _outer.height() - _inner.height(); + const auto maskSize = QSize( + std::max(_outer.width(), minWidth), + std::max(_outer.height(), minHeight)); + + const auto result = ImageSubrect{ + cached ? &_overlayCacheParts : &_overlayMaskScaled, + QRect(full.topLeft(), maskSize * ratio), + }; + if (cached && _validOverlayMask[frameIndex]) { + return result; + } + + auto p = QPainter(result.image.get()); + const auto position = full.topLeft() / ratio; + p.setCompositionMode(QPainter::CompositionMode_Source); + p.fillRect(QRect(position, maskSize), Qt::transparent); + + auto hq = PainterHighQualityEnabler(p); + const auto inner = QRect(position + _inner.topLeft(), innerSize); + p.setPen(Qt::NoPen); + p.setBrush(Qt::white); + if (scale != 1.) { + const auto center = inner.center(); + p.save(); + p.translate(center); + p.scale(scale, scale); + p.translate(-center); + } + p.drawRoundedRect(inner, radius, radius); + if (scale != 1.) { + p.restore(); + } + + if (cached) { + _validOverlayMask[frameIndex] = true; + } + return result; +} + +ImageSubrect RoundAreaWithShadow::validateOverlayShadow( + int frameIndex, + QSize innerSize, + float64 radius, + int twiceRadius, + float64 scale, + const ImageSubrect &mask) { + const auto ratio = style::DevicePixelRatio(); + const auto cached = (scale == 1.); + const auto full = cached + ? FrameCacheRect(frameIndex, kOverlayShadowCacheIndex, _overlay) + : QRect(QPoint(), _overlay * ratio); + + const auto minWidth = twiceRadius + _outer.width() - _inner.width(); + const auto minHeight = twiceRadius + _outer.height() - _inner.height(); + const auto maskSize = QSize( + std::max(_outer.width(), minWidth), + std::max(_outer.height(), minHeight)); + + const auto result = ImageSubrect{ + cached ? &_overlayCacheParts : &_overlayShadowScaled, + QRect(full.topLeft(), maskSize * ratio), + }; + if (cached && _validOverlayShadow[frameIndex]) { + return result; + } + + const auto position = full.topLeft() / ratio; + + _overlayShadowScaled.fill(Qt::transparent); + const auto inner = QRect(_inner.topLeft(), innerSize); + const auto add = style::ConvertScale(2.5); + const auto shift = style::ConvertScale(0.5); + const auto extended = QRectF(inner).marginsAdded({ add, add, add, add }); + { + auto p = QPainter(&_overlayShadowScaled); + p.setCompositionMode(QPainter::CompositionMode_Source); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(_shadow); + if (scale != 1.) { + const auto center = inner.center(); + p.translate(center); + p.scale(scale, scale); + p.translate(-center); + } + p.drawRoundedRect(extended.translated(0, shift), radius, radius); + p.end(); + } + + _overlayShadowScaled = Images::Blur(std::move(_overlayShadowScaled)); + + auto q = Painter(result.image); + if (result.image != &_overlayShadowScaled) { + q.setCompositionMode(QPainter::CompositionMode_Source); + q.drawImage( + QRect(position, maskSize), + _overlayShadowScaled, + QRect(QPoint(), maskSize * ratio)); + } + q.setCompositionMode(QPainter::CompositionMode_DestinationOut); + q.drawImage(QRect(position, maskSize), *mask.image, mask.rect); + + if (cached) { + _validOverlayShadow[frameIndex] = true; + } + return result; +} + +void RoundAreaWithShadow::overlayExpandedBorder( + QPainter &p, + QSize size, + float64 expandRatio, + float64 radius, + float64 scale) { + const auto progress = expandRatio; + const auto frame = int(base::SafeRound(progress * (kFramesCount - 1))); + const auto twiceRadius = int(base::SafeRound(radius * 2)); + const auto innerSize = QSize( + std::max(_inner.width(), twiceRadius), + std::max(_inner.height(), twiceRadius)); + + const auto overlayMask = validateOverlayMask( + frame, + innerSize, + radius, + twiceRadius, + scale); + const auto overlayShadow = validateOverlayShadow( + frame, + innerSize, + radius, + twiceRadius, + scale, + overlayMask); + + p.setCompositionMode(QPainter::CompositionMode_DestinationIn); + FillWithImage(p, QRect(QPoint(), size), overlayMask); + p.setCompositionMode(QPainter::CompositionMode_SourceOver); + FillWithImage(p, QRect(QPoint(), size), overlayShadow); +} + +void RoundAreaWithShadow::FillWithImage( + QPainter &p, + QRect geometry, + const ImageSubrect &pattern) { + const auto factor = style::DevicePixelRatio(); + const auto &image = *pattern.image; + const auto source = pattern.rect; + const auto sourceWidth = (source.width() / factor); + const auto sourceHeight = (source.height() / factor); + if (geometry.width() == sourceWidth) { + const auto part = (sourceHeight / 2) - 1; + const auto fill = geometry.height() - 2 * part; + const auto half = part * factor; + const auto top = source.height() - half; + p.drawImage( + geometry.topLeft(), + image, + QRect(source.x(), source.y(), source.width(), half)); + if (fill > 0) { + p.drawImage( + QRect( + geometry.topLeft() + QPoint(0, part), + QSize(sourceWidth, fill)), + image, + QRect( + source.x(), + source.y() + half, + source.width(), + top - half)); + } + p.drawImage( + geometry.topLeft() + QPoint(0, part + fill), + image, + QRect(source.x(), source.y() + top, source.width(), half)); + } else if (geometry.height() == sourceHeight) { + const auto part = (sourceWidth / 2) - 1; + const auto fill = geometry.width() - 2 * part; + const auto half = part * factor; + const auto left = source.width() - half; + p.drawImage( + geometry.topLeft(), + image, + QRect(source.x(), source.y(), half, source.height())); + if (fill > 0) { + p.drawImage( + QRect( + geometry.topLeft() + QPoint(part, 0), + QSize(fill, sourceHeight)), + image, + QRect( + source.x() + half, + source.y(), + left - half, + source.height())); + } + p.drawImage( + geometry.topLeft() + QPoint(part + fill, 0), + image, + QRect(source.x() + left, source.y(), half, source.height())); + } else { + Unexpected("Values in RoundAreaWithShadow::fillWithImage."); + } +} + +void RoundAreaWithShadow::setShadowColor(const QColor &shadow) { + if (_shadow == shadow) { + return; + } + _shadow = shadow; + ranges::fill(_validBg, false); + ranges::fill(_validShadow, false); + ranges::fill(_validOverlayShadow, false); +} + +QRect RoundAreaWithShadow::validateShadow( + int frameIndex, + float64 scale, + float64 radius) { + const auto rect = FrameCacheRect(frameIndex, kShadowCacheIndex, _outer); + if (_validShadow[frameIndex]) { + return rect; + } + + _shadowBuffer.fill(Qt::transparent); + auto p = QPainter(&_shadowBuffer); + auto hq = PainterHighQualityEnabler(p); + const auto center = _inner.center(); + const auto add = style::ConvertScale(2.5); + const auto shift = style::ConvertScale(0.5); + const auto big = QRectF(_inner).marginsAdded({ add, add, add, add }); + p.setPen(Qt::NoPen); + p.setBrush(_shadow); + if (scale != 1.) { + p.translate(center); + p.scale(scale, scale); + p.translate(-center); + } + p.drawRoundedRect(big.translated(0, shift), radius, radius); + p.end(); + _shadowBuffer = Images::Blur(std::move(_shadowBuffer)); + + auto q = QPainter(&_shadowParts); + q.setCompositionMode(QPainter::CompositionMode_Source); + q.drawImage(rect.topLeft() / style::DevicePixelRatio(), _shadowBuffer); + + _validShadow[frameIndex] = true; + return rect; +} + +void RoundAreaWithShadow::setBackgroundColor(const QColor &background) { + if (_background == background) { + return; + } + _background = background; + ranges::fill(_validBg, false); +} + +ImageSubrect RoundAreaWithShadow::validateFrame( + int frameIndex, + float64 scale) { + const auto result = ImageSubrect{ + &_cacheBg, + FrameCacheRect(frameIndex, kBgCacheIndex, _outer) + }; + if (_validBg[frameIndex]) { + return result; + } + + const auto position = result.rect.topLeft() / style::DevicePixelRatio(); + const auto inner = _inner.translated(position); + const auto radius = inner.height() / 2.; + + const auto shadowSource = validateShadow(frameIndex, scale, radius); + + auto p = QPainter(&_cacheBg); + p.setCompositionMode(QPainter::CompositionMode_Source); + p.drawImage(position, _shadowParts, shadowSource); + p.setCompositionMode(QPainter::CompositionMode_SourceOver); + + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(_background); + if (scale != 1.) { + const auto center = inner.center(); + p.save(); + p.translate(center); + p.scale(scale, scale); + p.translate(-center); + } + p.drawRoundedRect(inner, radius, radius); + if (scale != 1.) { + p.restore(); + } + + _validBg[frameIndex] = true; + return result; +} + +} // namespace Ui diff --git a/ui/effects/round_area_with_shadow.h b/ui/effects/round_area_with_shadow.h new file mode 100644 index 0000000..3fe4e1d --- /dev/null +++ b/ui/effects/round_area_with_shadow.h @@ -0,0 +1,87 @@ +// 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 + +namespace Ui { + +struct ImageSubrect { + not_null image; + QRect rect; +}; + +class RoundAreaWithShadow final { +public: + static constexpr auto kFramesCount = 32; + + [[nodiscard]] static QImage PrepareImage(QSize size); + [[nodiscard]] static QImage PrepareFramesCache( + QSize frame, + int columns = 1); + [[nodiscard]] static QRect FrameCacheRect( + int frameIndex, + int column, + QSize frame); + + static void FillWithImage( + QPainter &p, + QRect geometry, + const ImageSubrect &pattern); + + RoundAreaWithShadow(QSize inner, QMargins shadow, int twiceRadiusMax); + + void setBackgroundColor(const QColor &background); + void setShadowColor(const QColor &shadow); + + [[nodiscard]] ImageSubrect validateFrame(int frameIndex, float64 scale); + [[nodiscard]] ImageSubrect validateOverlayMask( + int frameIndex, + QSize innerSize, + float64 radius, + int twiceRadius, + float64 scale); + [[nodiscard]] ImageSubrect validateOverlayShadow( + int frameIndex, + QSize innerSize, + float64 radius, + int twiceRadius, + float64 scale, + const ImageSubrect &mask); + + void overlayExpandedBorder( + QPainter &p, + QSize size, + float64 expandRatio, + float64 radius, + float64 scale); + +private: + [[nodiscard]] QRect validateShadow( + int frameIndex, + float64 scale, + float64 radius); + + QRect _inner; + QSize _outer; + QSize _overlay; + + std::array _validBg = { { false } }; + std::array _validShadow = { { false } }; + std::array _validOverlayMask = { { false } }; + std::array _validOverlayShadow = { { false } }; + QColor _background; + QColor _gradient; + QColor _shadow; + QImage _cacheBg; + QImage _shadowParts; + QImage _overlayCacheParts; + QImage _overlayMaskScaled; + QImage _overlayShadowScaled; + QImage _shadowBuffer; + +}; + +} // namespace Ui diff --git a/ui/widgets/popup_menu.cpp b/ui/widgets/popup_menu.cpp index db6d0b7..9f979b4 100644 --- a/ui/widgets/popup_menu.cpp +++ b/ui/widgets/popup_menu.cpp @@ -430,12 +430,15 @@ void PopupMenu::paintEvent(QPaintEvent *e) { if (_a_show.animating()) { const auto opacity = _a_opacity.value(_hiding ? 0. : 1.); const auto progress = _a_show.value(1.); - if (opacity) { - _showAnimation->paintFrame(p, 0, 0, width(), progress, opacity); - } + const auto state = (opacity > 0.) + ? _showAnimation->paintFrame(p, 0, 0, width(), progress, opacity) + : PanelAnimation::PaintState(); _showStateChanges.fire({ - .opacity = opacity, - .progress = progress, + .opacity = state.opacity, + .widthProgress = state.widthProgress, + .heightProgress = state.heightProgress, + .appearingWidth = state.width, + .appearingHeight = state.height, .appearing = true, }); } else if (_a_opacity.animating()) { @@ -703,11 +706,7 @@ void PopupMenu::prepareCache() { _showAnimation = base::take(showAnimationData); _a_show = base::take(showAnimation); if (_a_show.animating()) { - _showStateChanges.fire({ - .opacity = _a_opacity.value(1.), - .progress = _a_show.value(1.), - .appearing = true, - }); + fireCurrentShowState(); } } @@ -773,9 +772,19 @@ void PopupMenu::startShowAnimation() { } hideChildren(); _a_show.start([this] { showAnimationCallback(); }, 0., 1., _st.showDuration); + fireCurrentShowState(); +} + +void PopupMenu::fireCurrentShowState() { + const auto state = _showAnimation->computeState( + _a_show.value(1.), + _a_opacity.value(1.)); _showStateChanges.fire({ - .opacity = _a_opacity.value(1.), - .progress = _a_show.value(1.), + .opacity = state.opacity, + .widthProgress = state.widthProgress, + .heightProgress = state.heightProgress, + .appearingWidth = state.width, + .appearingHeight = state.height, .appearing = true, }); } diff --git a/ui/widgets/popup_menu.h b/ui/widgets/popup_menu.h index 3a0488d..53c1029 100644 --- a/ui/widgets/popup_menu.h +++ b/ui/widgets/popup_menu.h @@ -89,7 +89,10 @@ public: struct ShowState { float64 opacity = 1.; - float64 progress = 1.; + float64 widthProgress = 1.; + float64 heightProgress = 1.; + int appearingWidth = 0; + int appearingHeight = 0; bool appearing = false; bool toggling = false; }; @@ -123,6 +126,7 @@ private: void hideFinished(); void showStarted(); + void fireCurrentShowState(); using TriggeredSource = Menu::TriggeredSource; void validateCompositingSupport();