diff --git a/cmake/meta_info/desktop_settings.template.h.in b/cmake/meta_info/desktop_settings.template.h.in index bace44470..0deec7ca4 100644 --- a/cmake/meta_info/desktop_settings.template.h.in +++ b/cmake/meta_info/desktop_settings.template.h.in @@ -30,7 +30,13 @@ static constexpr const char* EARLY_SETTINGS_TEMPLATE = R"({ static constexpr const char* WALLPAPER_CONFIG_TEMPLATE = R"({ "source_path": "", "scaling": "fill", - "background_color": "#1c1b1f" + "background_color": "#1c1b1f", + "switch_mode": "movement", + "switch_selector": "sequential", + "switch_interval_ms": 20000, + "animation_duration_ms": 2000, + "switch_easing": "inoutcubic", + "disable_animation": false } )"; } // namespace cf::desktop::early_stage diff --git a/desktop/ui/components/shell_layer_impl/WallpaperShellLayerStrategy.cpp b/desktop/ui/components/shell_layer_impl/WallpaperShellLayerStrategy.cpp index a44f8a4e0..b1ab182c3 100644 --- a/desktop/ui/components/shell_layer_impl/WallpaperShellLayerStrategy.cpp +++ b/desktop/ui/components/shell_layer_impl/WallpaperShellLayerStrategy.cpp @@ -2,23 +2,42 @@ #include "IShellLayer.h" #include "cflog.h" #include "qt_format.h" +#include "wallpaper/WallPaperAccessStorage.h" +#include "wallpaper/WallPaperEngine.h" #include "wallpaper/WallPaperLayer.h" +#include #include #include +#include #include +#include namespace cf::desktop { +namespace { +/// Log tag for the wallpaper shell layer strategy. +constexpr const char* kLogTag = "WallpaperShellLayerStrategy"; +} // namespace + // ============================================================ // WallpaperShellLayerStrategy::Private // ============================================================ struct WallpaperShellLayerStrategy::Private { std::unique_ptr wallpaper_layer; + std::unique_ptr engine; QImage cached_scaled_image; QRect current_geometry; aex::WeakPtr layer; aex::WeakPtr window_manager; + + // Transition state. + bool transitioning{false}; + wallpaper::SwitchingMode pending_mode{wallpaper::SwitchingMode::Movement}; + QImage previous_scaled; ///< Outgoing frame (already scaled to geometry). + QImage current_scaled; ///< Incoming frame during a transition. + std::unique_ptr anim; + std::mt19937 rng; }; // ============================================================ @@ -31,42 +50,58 @@ WallpaperShellLayerStrategy::WallpaperShellLayerStrategy( d->wallpaper_layer = std::move(wallpaper_layer); if (d->wallpaper_layer) { d->wallpaper_layer->setImageChangedCallback([this]() { onWallpaperChanged(); }); + // The engine drives timed rotation; on each tick it asks us to begin a + // transition. It needs a non-owning layer pointer only for its size guard. + wallpaper::WallPaperLayer* raw = d->wallpaper_layer.get(); + d->engine = std::make_unique( + raw, [this](wallpaper::SwitchingMode mode) { beginTransition(mode); }, nullptr); } - log::infoftag("WallpaperShellLayerStrategy", "Created with wallpaper layer"); + log::infoftag(kLogTag, "Created with wallpaper layer and rotation engine"); } WallpaperShellLayerStrategy::~WallpaperShellLayerStrategy() = default; void WallpaperShellLayerStrategy::activate(aex::WeakPtr layer, aex::WeakPtr wm) { - log::traceftag("WallpaperShellLayerStrategy", "Activated"); + log::traceftag(kLogTag, "Activated"); d->layer = layer; d->window_manager = wm; - // Load initial image if available + // Load initial image if available. if (d->wallpaper_layer && !d->wallpaper_layer->currentImage().isNull()) { rescaleImage(); } + if (d->engine) { + d->engine->start(); + } } void WallpaperShellLayerStrategy::deactivate() { - log::traceftag("WallpaperShellLayerStrategy", "Deactivated"); + log::traceftag(kLogTag, "Deactivated"); + if (d->engine) { + d->engine->stop(); + } + resetTransition(); d->layer.Reset(); d->window_manager.Reset(); d->cached_scaled_image = {}; } void WallpaperShellLayerStrategy::onGeometryChanged(const QRect& available) { - log::traceftag("WallpaperShellLayerStrategy", "Geometry changed: QRect({}, {}, {}, {})", - available.x(), available.y(), available.width(), available.height()); + log::traceftag(kLogTag, "Geometry changed: QRect({}, {}, {}, {})", available.x(), available.y(), + available.width(), available.height()); if (d->current_geometry == available) { return; } + // A geometry change invalidates the pre-scaled transition buffers; abort + // any in-flight transition, then re-scale the current image for the new + // geometry (handled below by rescaleImage()). + if (d->transitioning) { + resetTransition(); + } d->current_geometry = available; rescaleImage(); - if (auto l = d->layer.Get()) { - l->requestRepaint(); - } + requestLayerRepaint(); } QImage WallpaperShellLayerStrategy::currentBackgroundImage() const { @@ -97,7 +132,7 @@ void WallpaperShellLayerStrategy::rescaleImage() { QImage scaled; switch (mode) { case wallpaper::ScalingMode::Fill: { - // Scale to cover, then crop center + // Scale to cover, then crop center. QSize scaled_size = raw.size().scaled(target, Qt::KeepAspectRatioByExpanding); scaled = raw.scaled(scaled_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); int x = (scaled.width() - target.width()) / 2; @@ -106,10 +141,10 @@ void WallpaperShellLayerStrategy::rescaleImage() { return; } case wallpaper::ScalingMode::Fit: { - // Scale to fit within, preserving aspect ratio + // Scale to fit within, preserving aspect ratio. QSize scaled_size = raw.size().scaled(target, Qt::KeepAspectRatio); scaled = raw.scaled(scaled_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - // Create background with letterbox bars + // Create background with letterbox bars. QImage result(target, QImage::Format_RGB32); result.fill(d->wallpaper_layer->backgroundColor()); QPainter painter(&result); @@ -120,7 +155,7 @@ void WallpaperShellLayerStrategy::rescaleImage() { return; } case wallpaper::ScalingMode::Stretch: { - // Scale to exact dimensions + // Scale to exact dimensions. d->cached_scaled_image = raw.scaled(target, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); return; @@ -130,11 +165,123 @@ void WallpaperShellLayerStrategy::rescaleImage() { } void WallpaperShellLayerStrategy::onWallpaperChanged() { - log::traceftag("WallpaperShellLayerStrategy", "Wallpaper image changed, rescaling"); + log::traceftag(kLogTag, "Wallpaper image changed, rescaling"); + if (d->transitioning) { + // The layer now holds the new image: re-scale it into the cache, stash + // it as the incoming frame, and start the per-frame animation. + rescaleImage(); + d->current_scaled = d->cached_scaled_image; + if (d->current_scaled.isNull()) { + // Target image failed to load: abort and restore the outgoing frame. + log::warningftag(kLogTag, "Transition target image is null; aborting transition"); + d->cached_scaled_image = d->previous_scaled; + resetTransition(); + requestLayerRepaint(); + return; + } + startTransitionAnim(); + return; + } + // Instant (non-animated) path. rescaleImage(); + requestLayerRepaint(); +} + +void WallpaperShellLayerStrategy::requestLayerRepaint() { if (auto l = d->layer.Get()) { l->requestRepaint(); } } +void WallpaperShellLayerStrategy::triggerNextWallpaper() { + if (d->engine) { + beginTransition(d->engine->mode()); + } +} + +void WallpaperShellLayerStrategy::beginTransition(wallpaper::SwitchingMode mode) { + if (d->transitioning) { + return; // Already mid-transition; ignore the new request. + } + if (!d->wallpaper_layer || d->cached_scaled_image.isNull()) { + return; // Nothing to transition from. + } + d->pending_mode = mode; + d->previous_scaled = d->cached_scaled_image; + d->transitioning = true; + if (!advanceLayerToNext()) { + // No switch occurred (e.g. storage shrank to <=1); roll back state. + d->transitioning = false; + d->previous_scaled = {}; + } + // Otherwise onWallpaperChanged() (fired by the layer switch) takes over. +} + +bool WallpaperShellLayerStrategy::advanceLayerToNext() { + if (!d->wallpaper_layer) { + return false; + } + auto& storage = d->wallpaper_layer->tokenStorage(); + if (storage.size() <= 1) { + return false; + } + const auto ids = storage.tokenIds(); + auto current = storage.current(); + const wallpaper::wallpaper_token_id_t current_id = current ? current->id() : QString{}; + const auto selector = d->engine ? d->engine->selector() : wallpaper::Selector::Sequential; + const wallpaper::wallpaper_token_id_t next_id = + wallpaper::selectNextWallpaper(ids, current_id, selector, d->rng); + if (next_id.isEmpty()) { + return false; + } + return d->wallpaper_layer->showTargetOne(next_id); +} + +void WallpaperShellLayerStrategy::startTransitionAnim() { + d->anim = std::make_unique(); + d->anim->setStartValue(qreal(0.0)); + d->anim->setEndValue(qreal(1.0)); + d->anim->setDuration(d->engine ? d->engine->animationDurationMs() : 2000); + d->anim->setEasingCurve(d->engine ? d->engine->easing() : QEasingCurve::InOutCubic); + QObject::connect(d->anim.get(), &QVariantAnimation::valueChanged, + [this](const QVariant& value) { composeFrame(value.toReal()); }); + QObject::connect(d->anim.get(), &QVariantAnimation::finished, [this]() { finishTransition(); }); + composeFrame(0.0); // Publish the first frame immediately. + d->anim->start(); +} + +void WallpaperShellLayerStrategy::composeFrame(qreal progress) { + if (!d->transitioning || d->cached_scaled_image.isNull()) { + return; + } + // Compose in-place into the cache buffer to avoid a per-frame QImage + // allocation (~8MB at 1080p). The first write detaches once; subsequent + // frames reuse the buffer in place. + wallpaper::composeTransitionFrameInto(d->cached_scaled_image, d->previous_scaled, + d->current_scaled, progress, d->pending_mode); + requestLayerRepaint(); +} + +void WallpaperShellLayerStrategy::finishTransition() { + if (!d->transitioning) { + return; + } + d->cached_scaled_image = d->current_scaled; // Settle on the new frame. + d->previous_scaled = {}; + d->current_scaled = {}; + d->transitioning = false; + d->anim.reset(); + requestLayerRepaint(); +} + +void WallpaperShellLayerStrategy::resetTransition() { + if (d->anim) { + d->anim->stop(); + } + d->anim.reset(); + d->transitioning = false; + d->previous_scaled = {}; + d->current_scaled = {}; +} + } // namespace cf::desktop diff --git a/desktop/ui/components/shell_layer_impl/WallpaperShellLayerStrategy.h b/desktop/ui/components/shell_layer_impl/WallpaperShellLayerStrategy.h index 75754171f..94c555730 100644 --- a/desktop/ui/components/shell_layer_impl/WallpaperShellLayerStrategy.h +++ b/desktop/ui/components/shell_layer_impl/WallpaperShellLayerStrategy.h @@ -7,6 +7,12 @@ * image scaling on geometry changes and triggers repaints when the * wallpaper image changes. * + * A WallPaperEngine drives timed rotation. When a switch is due the + * strategy runs a per-frame transition: the outgoing frame is captured, + * the layer advances, and a QVariantAnimation blends old and new images + * into cached_scaled_image via composeTransitionFrame() until the new + * image is fully revealed. + * * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) * @date 2026-04-09 * @version 0.1 @@ -16,6 +22,7 @@ #pragma once #include "../IShellLayerStrategy.h" +#include "../wallpaper/TransitionComposer.h" #include #include #include @@ -28,7 +35,8 @@ class WindowManager; namespace wallpaper { class WallPaperLayer; -} +class WallPaperEngine; +} // namespace wallpaper /** * @brief Shell layer strategy with wallpaper background support. @@ -122,6 +130,22 @@ class WallpaperShellLayerStrategy : public IShellLayerStrategy { */ QColor backgroundColor() const override; + /** + * @brief Manually triggers a transition to the next wallpaper. + * + * Uses the engine's configured switching mode and selector. Provided + * as the future hook for a wallpaper chooser UI; the same path is + * used by automatic timed rotation. + * + * @throws None. + * + * @note None. + * @warning None. + * @since 0.19 + * @ingroup components + */ + void triggerNextWallpaper(); + private: /** * @brief Re-scales the raw image to fit the current geometry. @@ -133,10 +157,59 @@ class WallpaperShellLayerStrategy : public IShellLayerStrategy { /** * @brief Callback invoked when the wallpaper layer's image changes. * - * Reloads the raw image, re-scales it, and requests a repaint. + * Reloads the raw image, re-scales it, and requests a repaint. During + * an active transition it instead captures the new frame and starts + * the per-frame animation. */ void onWallpaperChanged(); + /** + * @brief Requests a repaint from the bound shell layer (no-op if unset). + */ + void requestLayerRepaint(); + + /** + * @brief Begins a transition to a new wallpaper. + * + * Captures the current frame as the outgoing image, arms the + * transition state, and advances the layer to the next wallpaper. + * + * @param[in] mode Compositing mode for this transition. + */ + void beginTransition(wallpaper::SwitchingMode mode); + + /** + * @brief Advances the layer to the next wallpaper per the selector. + * + * @return True if the layer switched (and thus fired onWallpaperChanged); + * false if there was nothing to switch to. + */ + bool advanceLayerToNext(); + + /** + * @brief Builds and starts the per-frame transition animation. + */ + void startTransitionAnim(); + + /** + * @brief Composes and publishes one transition frame at @p progress. + * + * @param[in] progress Transition progress in [0, 1]. + */ + void composeFrame(qreal progress); + + /** + * @brief Finalizes a transition: settles on the new frame, clears state. + */ + void finishTransition(); + + /** + * @brief Aborts any in-flight transition and clears buffers. + * + * Used by deactivate() and on geometry changes mid-transition. + */ + void resetTransition(); + struct Private; std::unique_ptr d; }; diff --git a/desktop/ui/components/shell_layer_impl/wallpaper_setup.cpp b/desktop/ui/components/shell_layer_impl/wallpaper_setup.cpp index 760266a3b..f96b21ba2 100644 --- a/desktop/ui/components/shell_layer_impl/wallpaper_setup.cpp +++ b/desktop/ui/components/shell_layer_impl/wallpaper_setup.cpp @@ -1,4 +1,5 @@ #include "wallpaper_setup.h" +#include "cfconfig.hpp" #include "cflog.h" #include "cfpath/desktop_main_path_resolvers.h" #include "filter_target.h" @@ -8,9 +9,53 @@ #include "wallpaper/WallPaperToken.h" #include "wallpaper_src_chain.h" +#include + namespace cf::desktop::wallpaper { namespace { +/// Tag for wallpaper setup log lines. +constexpr const char* kLogTag = "WallpaperSetup"; + +/** + * @brief Reads the configured wallpaper scaling mode. + * + * @return Scaling mode; ScalingMode::Fill on unknown values (with a warning). + */ +ScalingMode scaling_from_config() { + auto wp = cf::config::ConfigStore::instance().domain("wallpaper"); + const auto value = + wp.query(cf::config::KeyView{.group = "wallpaper", .key = "scaling"}, "fill"); + if (value == "fit") { + return ScalingMode::Fit; + } + if (value == "stretch") { + return ScalingMode::Stretch; + } + if (value == "fill") { + return ScalingMode::Fill; + } + log::warningftag(kLogTag, "Unknown scaling '{}', falling back to fill", value); + return ScalingMode::Fill; +} + +/** + * @brief Reads the configured wallpaper background color. + * + * @return Background color; default dark grey on invalid input (with a warning). + */ +QColor background_from_config() { + auto wp = cf::config::ConfigStore::instance().domain("wallpaper"); + const auto value = wp.query( + cf::config::KeyView{.group = "wallpaper", .key = "background_color"}, "#1c1b1f"); + QColor color(QString::fromStdString(value)); + if (!color.isValid()) { + log::warningftag(kLogTag, "Invalid background_color '{}', falling back to #1c1b1f", value); + return QColor(0x1c, 0x1b, 0x1f); + } + return color; +} + /** * @brief Wallpaper Layer Inits * @@ -19,7 +64,8 @@ namespace { std::unique_ptr make_layer() { using namespace base::filesystem; - auto layer = std::make_unique(); + auto layer = + std::make_unique(scaling_from_config(), background_from_config()); const QStringList pic_filters = request_filterlist(FilterType::Pictures); // Primary source: policy chain (config source_path -> Pictures), flat scan. diff --git a/desktop/ui/components/wallpaper/CMakeLists.txt b/desktop/ui/components/wallpaper/CMakeLists.txt index 28a6eb6d5..fd02e8a03 100644 --- a/desktop/ui/components/wallpaper/CMakeLists.txt +++ b/desktop/ui/components/wallpaper/CMakeLists.txt @@ -3,6 +3,8 @@ add_library(cfdesktop_wallpaper STATIC WallPaperToken.cpp WallPaperAccessStorage.cpp ImageWallPaperLayer.cpp + WallPaperEngine.cpp + TransitionComposer.cpp ) target_include_directories(cfdesktop_wallpaper PUBLIC @@ -15,4 +17,5 @@ target_link_libraries(cfdesktop_wallpaper PRIVATE cflogger cfbase QuarkWidgets::quarkwidgets + cfconfig ) \ No newline at end of file diff --git a/desktop/ui/components/wallpaper/TransitionComposer.cpp b/desktop/ui/components/wallpaper/TransitionComposer.cpp new file mode 100644 index 000000000..c60298a54 --- /dev/null +++ b/desktop/ui/components/wallpaper/TransitionComposer.cpp @@ -0,0 +1,69 @@ +/** + * @file desktop/ui/components/wallpaper/TransitionComposer.cpp + * @brief Implementation of wallpaper transition frame compositing. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-30 + * @version 0.1 + * @since 0.19 + * @ingroup wallpaper + */ + +#include "TransitionComposer.h" + +#include +#include +#include + +namespace cf::desktop::wallpaper { + +void composeTransitionFrameInto(QImage& dst, const QImage& prev, const QImage& cur, qreal t, + SwitchingMode mode) { + if (dst.isNull() || cur.isNull()) { + return; + } + const qreal progress = std::clamp(t, 0.0, 1.0); + + QPainter painter(&dst); + switch (mode) { + case SwitchingMode::Fixed: { + // Defensive: the engine never requests a transition in Fixed mode. + painter.drawImage(0, 0, cur); + break; + } + case SwitchingMode::Gradient: { + // Current fully visible, previous fades out on top as t -> 1. + painter.drawImage(0, 0, cur); + if (!prev.isNull()) { + painter.setOpacity(1.0 - progress); + painter.drawImage(0, 0, prev); + } + break; + } + case SwitchingMode::Movement: { + // Old exits left, new enters from right (matches CCIMXDesktop). + const double width = static_cast(dst.width()); + const int x_prev = static_cast(std::round(std::lerp(0.0, -width, progress))); + const int x_cur = static_cast(std::round(std::lerp(width, 0.0, progress))); + if (!prev.isNull()) { + painter.setOpacity(1.0); + painter.drawImage(x_prev, 0, prev); + } + painter.drawImage(x_cur, 0, cur); + break; + } + } +} + +QImage composeTransitionFrame(const QImage& prev, const QImage& cur, qreal t, SwitchingMode mode, + const QSize& target) { + if (target.isEmpty() || cur.isNull()) { + return {}; + } + QImage frame(target, QImage::Format_RGB32); + frame.fill(Qt::black); + composeTransitionFrameInto(frame, prev, cur, t, mode); + return frame; +} + +} // namespace cf::desktop::wallpaper diff --git a/desktop/ui/components/wallpaper/TransitionComposer.h b/desktop/ui/components/wallpaper/TransitionComposer.h new file mode 100644 index 000000000..f236401e7 --- /dev/null +++ b/desktop/ui/components/wallpaper/TransitionComposer.h @@ -0,0 +1,94 @@ +/** + * @file desktop/ui/components/wallpaper/TransitionComposer.h + * @brief Pure compositing helper for wallpaper transition frames. + * + * Produces a single QImage per animation frame by blending the outgoing + * and incoming wallpaper images. Backend-agnostic: any renderer that + * consumes a QImage (QPainter shell layer or RHI backend) can drive the + * transition by querying one frame per progress tick. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-30 + * @version 0.1 + * @since 0.19 + * @ingroup wallpaper + */ + +#pragma once +#include +#include + +namespace cf::desktop::wallpaper { + +/** + * @brief Wallpaper transition (switching) mode. + * + * Mirrors CCIMXDesktop's SwitchingMode semantics, re-expressed for a + * QImage-based pipeline instead of QLabel double-buffering. + * + * @ingroup wallpaper + */ +enum class SwitchingMode { + Fixed, ///< No transition; wallpaper stays static. + Gradient, ///< Cross-fade: previous fades out, current revealed beneath. + Movement ///< Slide: previous exits left, current enters from right. +}; + +/** + * @brief Composes a single transition frame at the given progress. + * + * Both @p prev and @p cur are assumed to already be scaled to @p target + * by the caller (the shell layer strategy). This helper only blends them. + * + * - @ref SwitchingMode::Fixed returns @p cur unchanged (defensive; the + * engine never requests a transition in Fixed mode). + * - @ref SwitchingMode::Gradient draws @p cur fully, then @p prev with + * opacity @c (1-t), so the outgoing image fades out. + * - @ref SwitchingMode::Movement slides @p prev from x=0 to x=-W and + * @p cur from x=W to x=0 (W = target width): old exits left, new + * enters from right. + * + * @param[in] prev The outgoing frame (already scaled to @p target). + * @param[in] cur The incoming frame (already scaled to @p target). + * @param[in] t Transition progress; clamped to [0, 1]. + * @param[in] mode Compositing mode. + * @param[in] target Output frame size. + * + * @return Composited frame, or a null QImage if @p target is + * empty or @p cur is null. + * + * @throws None. + * + * @note None. + * @warning None. + * @since 0.19 + * @ingroup wallpaper + */ +QImage composeTransitionFrame(const QImage& prev, const QImage& cur, qreal t, SwitchingMode mode, + const QSize& target); + +/** + * @brief Composes one transition frame in-place into @p dst. + * + * Zero-allocation path for animation: reuses the caller's buffer instead of + * allocating a new QImage each frame. @p dst, @p prev, and @p cur must share + * the same size; prev+cur fully cover @p dst for every mode, so no clear/fill + * is performed. + * + * @param[in,out] dst Target-sized frame buffer to compose into. + * @param[in] prev Outgoing frame (same size as @p dst). + * @param[in] cur Incoming frame (same size as @p dst). + * @param[in] t Progress in [0, 1] (clamped). + * @param[in] mode Compositing mode. + * + * @throws None. + * + * @note None. + * @warning None. + * @since 0.19 + * @ingroup wallpaper + */ +void composeTransitionFrameInto(QImage& dst, const QImage& prev, const QImage& cur, qreal t, + SwitchingMode mode); + +} // namespace cf::desktop::wallpaper diff --git a/desktop/ui/components/wallpaper/WallPaperAccessStorage.cpp b/desktop/ui/components/wallpaper/WallPaperAccessStorage.cpp index a5b7e9441..2bb816325 100644 --- a/desktop/ui/components/wallpaper/WallPaperAccessStorage.cpp +++ b/desktop/ui/components/wallpaper/WallPaperAccessStorage.cpp @@ -75,4 +75,13 @@ size_t WallPaperAccessStorage::size() const { return wallpaper_images.size(); } +std::vector WallPaperAccessStorage::tokenIds() const { + std::vector ids; + ids.reserve(wallpaper_images.size()); + for (const auto& ptr : wallpaper_images) { + ids.push_back(ptr->id()); + } + return ids; +} + } // namespace cf::desktop::wallpaper diff --git a/desktop/ui/components/wallpaper/WallPaperAccessStorage.h b/desktop/ui/components/wallpaper/WallPaperAccessStorage.h index 0904239b0..609558c54 100644 --- a/desktop/ui/components/wallpaper/WallPaperAccessStorage.h +++ b/desktop/ui/components/wallpaper/WallPaperAccessStorage.h @@ -19,6 +19,7 @@ #include #include #include +#include namespace cf::desktop::wallpaper { /** @@ -138,6 +139,23 @@ class WallPaperAccessStorage : public QObject { */ size_t size() const; + /** + * @brief Returns the IDs of all stored tokens in storage order. + * + * Order matches insertion order. Used by randomized wallpaper + * selectors that need the full candidate set. + * + * @return Vector of token IDs; empty if storage is empty. + * + * @throws None. + * + * @note None. + * @warning None. + * @since 0.19 + * @ingroup wallpaper + */ + std::vector tokenIds() const; + enum class OverFlowType { OverFlow, UnderFlow }; signals: /** diff --git a/desktop/ui/components/wallpaper/WallPaperEngine.cpp b/desktop/ui/components/wallpaper/WallPaperEngine.cpp new file mode 100644 index 000000000..b0d08d90a --- /dev/null +++ b/desktop/ui/components/wallpaper/WallPaperEngine.cpp @@ -0,0 +1,179 @@ +/** + * @file desktop/ui/components/wallpaper/WallPaperEngine.cpp + * @brief Implementation of the timed wallpaper rotation engine. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-30 + * @version 0.1 + * @since 0.19 + * @ingroup wallpaper + */ + +#include "WallPaperEngine.h" + +#include "cfconfig.hpp" +#include "cflog.h" + +#include + +namespace cf::desktop::wallpaper { + +namespace { +/// Tag for engine log lines. +constexpr const char* kLogTag = "WallPaperEngine"; + +/** + * @brief Parses a switch_mode string, warning on unknown values. + * + * @param[in] value Raw config string. + * @return Parsed mode; Movement on unknown input. + */ +SwitchingMode parse_switch_mode(const std::string& value) { + if (value == "fixed") { + return SwitchingMode::Fixed; + } + if (value == "gradient") { + return SwitchingMode::Gradient; + } + if (value == "movement") { + return SwitchingMode::Movement; + } + log::warningftag(kLogTag, "Unknown switch_mode '{}', falling back to movement", value); + return SwitchingMode::Movement; +} + +/** + * @brief Parses a switch_selector string, warning on unknown values. + * + * @param[in] value Raw config string. + * @return Parsed selector; Sequential on unknown input. + */ +Selector parse_selector(const std::string& value) { + if (value == "random") { + return Selector::Random; + } + if (value == "sequential") { + return Selector::Sequential; + } + log::warningftag(kLogTag, "Unknown switch_selector '{}', falling back to sequential", value); + return Selector::Sequential; +} + +/** + * @brief Parses a switch_easing string, warning on unknown values. + * + * @param[in] value Raw config string. + * @return Parsed curve; InOutCubic on unknown input. + */ +QEasingCurve parse_easing(const std::string& value) { + if (value == "inoutcubic") { + return QEasingCurve::InOutCubic; + } + if (value == "outcubic") { + return QEasingCurve::OutCubic; + } + if (value == "linear") { + return QEasingCurve::Linear; + } + log::warningftag(kLogTag, "Unknown switch_easing '{}', falling back to InOutCubic", value); + return QEasingCurve::InOutCubic; +} +} // namespace + +wallpaper_token_id_t selectNextWallpaper(const std::vector& ids, + const wallpaper_token_id_t& current_id, Selector selector, + std::mt19937& rng) { + if (ids.empty()) { + return {}; + } + if (selector == Selector::Random) { + std::vector candidates; + candidates.reserve(ids.size()); + for (size_t i = 0; i < ids.size(); ++i) { + if (ids[i] != current_id) { + candidates.push_back(i); + } + } + if (candidates.empty()) { + return ids.front(); + } + std::uniform_int_distribution dis(0, candidates.size() - 1); + return ids[candidates[dis(rng)]]; + } + const auto it = std::find(ids.begin(), ids.end(), current_id); + if (it == ids.end()) { + return ids.front(); + } + auto next = it + 1; + if (next == ids.end()) { + next = ids.begin(); // wrap at the end + } + return *next; +} + +WallPaperEngine::WallPaperEngine(WallPaperLayer* layer, RequestTransition request, QObject* parent) + : QObject(parent), layer_(layer), request_(std::move(request)), timer_(this) { + loadConfig(); + connect(&timer_, &QTimer::timeout, this, &WallPaperEngine::onTimerTick); + log::traceftag(kLogTag, "Constructed: mode={} selector={} interval={}ms duration={}ms", + static_cast(mode_), static_cast(selector_), interval_ms_, + duration_ms_); +} + +WallPaperEngine::~WallPaperEngine() { + stop(); +} + +void WallPaperEngine::loadConfig() { + auto wp = cf::config::ConfigStore::instance().domain("wallpaper"); + mode_ = parse_switch_mode(wp.query( + cf::config::KeyView{.group = "wallpaper", .key = "switch_mode"}, "movement")); + selector_ = parse_selector(wp.query( + cf::config::KeyView{.group = "wallpaper", .key = "switch_selector"}, "sequential")); + interval_ms_ = wp.query( + cf::config::KeyView{.group = "wallpaper", .key = "switch_interval_ms"}, 20000); + duration_ms_ = wp.query( + cf::config::KeyView{.group = "wallpaper", .key = "animation_duration_ms"}, 2000); + easing_ = parse_easing(wp.query( + cf::config::KeyView{.group = "wallpaper", .key = "switch_easing"}, "inoutcubic")); + disable_animation_ = wp.query( + cf::config::KeyView{.group = "wallpaper", .key = "disable_animation"}, false); +} + +void WallPaperEngine::start() { + loadConfig(); // pick up the latest values + if (disable_animation_) { + log::infoftag(kLogTag, "Auto-switch disabled by config (disable_animation)"); + return; + } + if (mode_ == SwitchingMode::Fixed) { + log::infoftag(kLogTag, "Auto-switch disabled (mode is Fixed)"); + return; + } + if (!layer_ || layer_->tokenStorage().size() <= 1) { + log::traceftag(kLogTag, "Not starting timer: need more than one wallpaper"); + return; + } + timer_.setInterval(interval_ms_); + timer_.start(); + log::infoftag(kLogTag, "Started auto-switch (interval={}ms)", interval_ms_); +} + +void WallPaperEngine::stop() { + timer_.stop(); +} + +void WallPaperEngine::onTimerTick() { + // Defensive: start() guards these, but a hot re-entry should still no-op. + if (mode_ == SwitchingMode::Fixed || disable_animation_) { + return; + } + if (!layer_ || layer_->tokenStorage().size() <= 1) { + return; + } + if (request_) { + request_(mode_); + } +} + +} // namespace cf::desktop::wallpaper diff --git a/desktop/ui/components/wallpaper/WallPaperEngine.h b/desktop/ui/components/wallpaper/WallPaperEngine.h new file mode 100644 index 000000000..f5a5a240e --- /dev/null +++ b/desktop/ui/components/wallpaper/WallPaperEngine.h @@ -0,0 +1,199 @@ +/** + * @file desktop/ui/components/wallpaper/WallPaperEngine.h + * @brief Timed wallpaper rotation engine. + * + * Owns the auto-rotation policy: reads the @c wallpaper config domain, + * drives a QTimer, and emits a switch request (with the active + * SwitchingMode) when it is time to advance. The engine deliberately + * does @e not switch the layer itself — switching synchronously fires + * the layer's image-changed callback, so the shell layer strategy must + * arm its transition state first. The strategy therefore consumes the + * request, performs the switch, and runs the per-frame animation. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-30 + * @version 0.1 + * @since 0.19 + * @ingroup wallpaper + */ + +#pragma once +#include "WallPaperLayer.h" +#include "wallpaper/TransitionComposer.h" +#include "wallpaper/WallPaperToken.h" + +#include +#include +#include +#include +#include +#include + +namespace cf::desktop::wallpaper { + +/** + * @brief Auto-rotation selector strategy. + * + * @ingroup wallpaper + */ +enum class Selector { + Sequential, ///< Advance in storage order, wrapping at the end. + Random ///< Pick a uniformly random different wallpaper each tick. +}; + +/** + * @brief Picks the next wallpaper ID per the selector strategy. + * + * Pure function (no layer or Qt state) for unit testability. + * + * - @ref Selector::Sequential locates @p current_id in @p ids and returns + * the following ID, wrapping to the first at the end. If @p current_id + * is not found, returns the first ID. + * - @ref Selector::Random returns an ID chosen uniformly from the + * candidates excluding @p current_id (falls back to @c ids.front() if + * only one distinct candidate exists). + * + * @param[in] ids All wallpaper IDs in storage order; may be empty. + * @param[in] current_id The currently shown wallpaper ID. + * @param[in] selector Sequential or Random. + * @param[in] rng Random engine; consulted only for Random. + * + * @return The next ID, or an empty string if @p ids is empty. + * + * @throws None. + * + * @note None. + * @warning None. + * @since 0.19 + * @ingroup wallpaper + */ +wallpaper_token_id_t selectNextWallpaper(const std::vector& ids, + const wallpaper_token_id_t& current_id, Selector selector, + std::mt19937& rng); + +/** + * @brief Drives timed wallpaper rotation per the @c wallpaper config. + * + * Reads @c switch_mode / @c switch_selector / @c switch_interval_ms / + * @c animation_duration_ms / @c switch_easing / @c disable_animation from + * ConfigStore. Exposes the parsed values so the shell layer strategy can + * size and ease its per-frame animation without re-reading config. + * + * The engine holds a non-owning pointer to the WallPaperLayer only to + * consult storage size for the @c size()>1 guard; it never mutates the + * layer. + * + * @ingroup wallpaper + */ +class WallPaperEngine : public QObject { + public: + /** + * @brief Callback fired when a timed switch is due. + * + * The strategy arms its transition state and performs the actual + * layer switch in this callback. + * + * @param[in] mode The SwitchingMode to use for this transition. + */ + using RequestTransition = std::function; + + /** + * @brief Constructs the engine bound to a layer and switch callback. + * + * @param[in] layer Non-owning pointer to the wallpaper layer; used + * only for the size guard. May be nullptr. + * @param[in] request Invoked on each timer tick with the active mode. + * @param[in] parent QObject parent. + * + * @throws None. + * + * @note None. + * @warning None. + * @since 0.19 + * @ingroup wallpaper + */ + WallPaperEngine(WallPaperLayer* layer, RequestTransition request, QObject* parent = nullptr); + + ~WallPaperEngine() override; + + WallPaperEngine(const WallPaperEngine&) = delete; + WallPaperEngine& operator=(const WallPaperEngine&) = delete; + + /** + * @brief Starts timed rotation if enabled by config and storage. + * + * No-op when @c disable_animation is set, @c switch_mode is Fixed, or + * the layer holds one or fewer wallpapers. + * + * @throws None. + * + * @note None. + * @warning None. + * @since 0.19 + * @ingroup wallpaper + */ + void start(); + + /** + * @brief Stops timed rotation. + * + * @throws None. + * + * @note None. + * @warning None. + * @since 0.19 + * @ingroup wallpaper + */ + void stop(); + + /** + * @brief The active switching mode (Fixed/Gradient/Movement). + * + * @return The configured switching mode. + */ + SwitchingMode mode() const noexcept { return mode_; } + + /** + * @brief The active selector (Sequential/Random). + * + * @return The configured selector. + */ + Selector selector() const noexcept { return selector_; } + + /** + * @brief Transition animation duration in milliseconds. + * + * @return The configured duration. + */ + int animationDurationMs() const noexcept { return duration_ms_; } + + /** + * @brief Transition animation easing curve. + * + * @return The configured easing curve. + */ + QEasingCurve easing() const noexcept { return easing_; } + + private: + /** + * @brief Invoked on each timer timeout; requests a transition when able. + */ + void onTimerTick(); + + /** + * @brief Reads the wallpaper config domain and refreshes cached settings. + */ + void loadConfig(); + + WallPaperLayer* layer_; + RequestTransition request_; + QTimer timer_; + SwitchingMode mode_{SwitchingMode::Movement}; + Selector selector_{Selector::Sequential}; + int interval_ms_{20000}; + int duration_ms_{2000}; + QEasingCurve easing_{QEasingCurve::InOutCubic}; + bool disable_animation_{false}; +}; + +} // namespace cf::desktop::wallpaper diff --git a/document/status/current.md b/document/status/current.md index 521ccef49..1381ddb32 100644 --- a/document/status/current.md +++ b/document/status/current.md @@ -59,7 +59,7 @@ description: CFDesktop 项目进度的唯一事实来源与全局导航。 ## 最近里程碑(git 可证) - **2026-06**:`refactor: refactor the ui subsystem`;`hwtier system enabled`;文档清理 -- **2026-06(壁纸资源包发现)**:壁纸层支持运行时动态发现第三方资源包——新增 `Wallpapers` PathType(`/Wallpapers/`)+ `filter_target_recursive` 递归扫描;`make_layer()` 改为「首个有图的源胜出」(config → Pictures 平铺,空则回退 Wallpapers 递归)。CF-Gallery 等包安装到 `Wallpapers//` 即被发现,**CFDesktop 零编译期耦合**(无 submodule/无 `#ifdef`/无 CMake option)。详见 [aels_cross_repo_deps.md](../todo/desktop/aels_cross_repo_deps.md)。动画轮播引擎(Gradient/Movement)留待下一批。 +- **2026-06(壁纸资源包发现)**:壁纸层支持运行时动态发现第三方资源包——新增 `Wallpapers` PathType(`/Wallpapers/`)+ `filter_target_recursive` 递归扫描;`make_layer()` 改为「首个有图的源胜出」(config → Pictures 平铺,空则回退 Wallpapers 递归)。CF-Gallery 等包安装到 `Wallpapers//` 即被发现,**CFDesktop 零编译期耦合**(无 submodule/无 `#ifdef`/无 CMake option)。详见 [aels_cross_repo_deps.md](../todo/desktop/aels_cross_repo_deps.md)。动画轮播引擎(Gradient 交叉淡入 / Movement 平移 + QTimer 定时 + Sequential/Random 选择器)**已落地**(2026-06-30,`feat/wallpaper-animation-engine`)——新增 `WallPaperEngine` + `TransitionComposer`,strategy 过渡状态机 + 逐帧 QImage 合成,后端无关;6 个 `switch_*` 配置 key + 顺手接 `scaling`/`background_color`;16 例单测全过。详见 [wallpaper_animation_engine.md](../todo/desktop/wallpaper_animation_engine.md) 末尾「实施记录」。 - **已达成**:Milestone 1「桌面骨架可见」;Phase 0 / 1 / 2 / A(CI) / 6 / G(Widget) / H(显示后端)(详见 [SUMMARY.md](../todo/done/SUMMARY.md)) ## 新人入门 diff --git a/document/todo/desktop/13_widget_apps.md b/document/todo/desktop/13_widget_apps.md index c626d5be9..63975c1e9 100644 --- a/document/todo/desktop/13_widget_apps.md +++ b/document/todo/desktop/13_widget_apps.md @@ -12,6 +12,8 @@ description: "预计周期: 4~5 周,依赖阶段: Phase 9, Phase 12" > > 📌 **现实核对与补漏(2026-06-29)**: > - **壁纸引擎已落地**(`desktop/ui/components/wallpaper/`:`ImageWallPaperLayer`/`WallPaperAccessStorage`/`WallPaperToken`/`WallpaperShellLayerStrategy`),本 Phase 壁纸部分只需补轮播/生成式/资源,引擎不必重做。 +> - **资源包运行时发现已合入**(PR #13,2026-06):第三方壁纸包安装到 `/Wallpapers//` 即被递归发现;CF-Gallery 提供 `install-to-cfdesktop.sh`。 +> - **轮播/动画引擎已落地**(2026-06-30,`feat/wallpaper-animation-engine`):新增 `WallPaperEngine` + `TransitionComposer`,strategy 过渡状态机逐帧合成;Gradient/Movement + Sequential/Random,配置驱动(`switch_mode`/`switch_selector`/`switch_interval_ms`/`animation_duration_ms`/`switch_easing`/`disable_animation`),顺手接 `scaling`/`background_color`;16 例单测。详见 [wallpaper_animation_engine.md](wallpaper_animation_engine.md) 末尾「实施记录」。 > - **Widget 框架尺度调和**:本 Phase(Phase J)画的是**全量愿景**(沙箱/进程隔离/API);近期落地以 [milestone_06](milestone_06_widget_control_center.md) **最小版**(ClockWidget + ControlCenter)为准。沙箱依赖 IPC(0%)+CrashHandler;低 RAM 退化同进程崩溃隔离。 > - **FileManagerApp 现实**:① 依赖 P2 控件(ToolBar/ContextMenu/TreeView 等,当前 0%);② 本仓 `desktop/base/file_operations/file_op.h` **只是路径拼接微工具**(copy/move/rename/delete/trash 全无),**不能当文件管理器底座**,需新建;③ 建议参照 CCIMX `FileRamber`(QtConcurrent 异步刷新)移植。 > - **数据源前置**:WeatherWidget 依赖 `aels-network`;ResourceMonitorWidget 依赖 `base/system` 探针(已 90%);均见 [aels_cross_repo_deps.md](aels_cross_repo_deps.md)。 diff --git a/document/todo/desktop/wallpaper_animation_engine.md b/document/todo/desktop/wallpaper_animation_engine.md new file mode 100644 index 000000000..a43a49ec0 --- /dev/null +++ b/document/todo/desktop/wallpaper_animation_engine.md @@ -0,0 +1,231 @@ +--- +title: "壁纸动画轮播引擎——接手实施计划" +description: "移植 CCIMXDesktop 的 WallPaperEngine + WallPaperAnimationHandler 到 CFDesktop,后端无关重表达。自洽接手文档。" +--- + +# 壁纸动画轮播引擎——接手实施计划 + +> **状态**: ✅ 已落地(2026-06-30,分支 `feat/wallpaper-animation-engine`;落地细节见末尾「实施记录」) +> **来源**: 资源包发现(PR #13)已合入,`status/current.md` 标注「动画轮播引擎留待下一批」;本文为该批的详细实施计划,供下一位 AI 直接接手。 +> **所属 Phase**: Phase 13([13_widget_apps.md](13_widget_apps.md) 壁纸段「只需补轮播」)。 +> **预计规模**: 中等(≈2 个新文件 + 1 个扩展 + 配置 + 测试)。 +> **最后更新**: 2026-06-30 + +--- + +## 一、Context(为什么做) + +CFDesktop 壁纸的**数据/token 层**(`ImageWallPaperLayer` / `WallPaperAccessStorage` / `WallPaperToken`)与**运行时资源包发现**(`Wallpapers` PathType + `filter_target_recursive` + `make_layer()` 兜底)已落地。当前壁纸切换是**瞬时换图**:`WallPaperLayer` 的 `ImageChangedCallback` → `WallpaperShellLayerStrategy::onWallpaperChanged()` → `rescaleImage()` → `requestRepaint()`,无过渡动画、无定时轮播。 + +本批目标:移植 CCIMXDesktop 的 `WallPaperEngine` + `WallPaperAnimationHandler`,实现: +- **三种模式**:`Fixed`(不切)/ `Gradient`(交叉淡入)/ `Movement`(平移滑入)。 +- **QTimer 定时自动轮播**(默认 20s)+ 缓动曲线(默认 InOutCubic)+ 随机/顺序选择器。 +- 配置驱动(`wallpaper.json` 新 key)。 + +--- + +## 二、关键约束:必须「重表达」,不能照抄 + +⚠️ CCIMXDesktop 用 **QLabel 双缓冲 + `QGraphicsOpacityEffect` + `QPropertyAnimation` 改 QLabel `pos`** 做动画——它耦合 QWidget 渲染。 + +CFDesktop 的 `WallPaperLayer` 是**后端无关**的纯数据层(只吐 `QImage`),`WallpaperShellLayerStrategy` 通过 `currentBackgroundImage()` 返回**单张预缩放 `QImage`** 供 shell layer 的 `paintEvent` 消费(后端是 QPainter 或 RHI,都吃这张 `QImage`)。 + +**因此动画必须重表达为**: +> 过渡期间,把 `cached_scaled_image` 变成**新旧两图的逐帧合成 QImage**;`QPropertyAnimation` 驱动 `t∈[0,1]`,每个 `valueChanged` 重算合成 + `requestRepaint()`;过渡结束丢弃旧图,回到单图。 + +这套对 QPainter/RHI 都成立(它们都消费 `QImage`),不引入 QWidget 耦合,保持后端无关。 + +--- + +## 三、源参考(CCIMXDesktop,只读参照) + +> 路径在 `~/CCIMXDesktop/`(本机存在)。**仅参照语义,不要照抄 QLabel/QWidget 实现**。 + +| 文件 | 行数 | 要点 | +|---|---|---| +| `core/wallpaper/WallPaperEngine.{h,cpp}` | ~146 | `SwitchingMode{Fixed,Gradient,Movement}`、QTimer(默认 `SWITCH_INTERVAL=20000`)、`ANIMATION_DURATION=2000`、`QEasingCurve(InOutCubic)`、`DEF_MODE=Movement`、随机选择器、`friend WallPaperAnimationHandler` | +| `ui/wallpaperanimationhandler.{h,cpp}` | ~154 | `process_opacity_switch`(双缓冲 QLabel + opacity effect 1→0 淡出旧图)、`process_movement_switch`(`QParallelAnimationGroup`:旧图左移出 + 新图右移入) | + +注意 CCIMX 的边界:`image_list.size() <= 1` 时直接 return(不切)——照搬这个守卫。 + +--- + +## 四、目标架构(CFDesktop) + +### 4.1 渲染缝(已存在,动画插在这里) + +`desktop/ui/components/shell_layer_impl/WallpaperShellLayerStrategy.cpp`: +- `currentBackgroundImage()`(L72)返回 `d->cached_scaled_image`——paintEvent 消费它。 +- `rescaleImage()`(L83)从 `d->wallpaper_layer->currentImage()` 按 `ScalingMode`(Fill/Fit/Stretch)预缩放进 `cached_scaled_image`。 +- `onWallpaperChanged()`(L132,layer 回调)→ `rescaleImage()` + `requestRepaint()`——**当前是瞬时,这是动画改造点**。 + +### 4.2 职责划分(推荐) + +| 组件 | 职责 | 改动 | +|---|---|---| +| `WallPaperLayer`(接口)+ `ImageWallPaperLayer` | 数据层:`currentImage()` / `showNextOne/Prev/Target` / `ImageChangedCallback` / `ScalingMode` | **不改**(已具备手动切换) | +| `WallPaperEngine`(**新**) | 配置(mode/interval/duration/easing)、QTimer 定时、选下一张、发 `requestTransition()` 给 strategy | 新增 | +| `WallpaperShellLayerStrategy`(**扩展**) | 过渡状态机:捕获旧图、启动 `QPropertyAnimation`、每帧合成、帧结束清理 | 扩展 | + +> **为什么过渡状态机放 strategy 而非 engine**:strategy 拥有 `cached_scaled_image` 和 `rescaleImage()`,合成必须在这里;engine 只管「何时切、切到哪、用什么模式」。 + +### 4.3 过渡状态机(strategy 侧) + +新增私有态:`QImage previous_scaled`、`bool transitioning`、`qreal transition_progress`、`SwitchingMode transition_mode`、`std::unique_ptr`。 + +过渡流程(timer 触发或手动 next): +1. `engine` 决定切下一张 → 调 `strategy.beginTransition(mode)`。 +2. strategy:`previous_scaled = cached_scaled_image`(捕获当前)。 +3. strategy 调 `wallpaper_layer->showNextOne()` → layer 换 `currentImage()` + fire `ImageChangedCallback`。 +4. strategy 在 `onWallpaperChanged()` 里检测 `transitioning==true` → 不走瞬时路径,改为 `rescaleImage()` 把**新**图缩放进 `cached_scaled_image`(作为 current),保留 `previous_scaled`,启动 `QPropertyAnimation(0→1, duration, easing)`。 +5. 每个 `valueChanged(t)`:`cached_scaled_image = composite(previous_scaled, current_scaled, t, mode)` → `requestRepaint()`。 +6. `finished`:丢弃 `previous_scaled`、`transitioning=false`、`cached_scaled_image` = 纯 current。 + +合成函数 `composite(prev, cur, t, mode)`(用 `QPainter` 画到目标尺寸 `QImage`): +- **Gradient**:先画 `cur`(opacity=1),再画 `prev`(opacity=`1-t`)——淡出新、露出新。或 prev opacity `1-t` + cur opacity `t`(双向淡入淡出)。选定一种并注释。 +- **Movement**:`prev` 在 `x = lerp(0, -W, t)`、`cur` 在 `x = lerp(W, 0, t)`(沿用 CCIMX 方向:旧左出 / 新右入),均 opacity=1。 + +> `transition_progress` 作为 `Q_PROPERTY` 挂在 strategy 上,`QPropertyAnimation` 绑 `progress` 属性;setter 触发合成 + `requestRepaint()`。 + +### 4.4 `WallPaperEngine`(新,`desktop/ui/components/wallpaper/`) + +``` +namespace cf::desktop::wallpaper { +enum class SwitchingMode { Fixed, Gradient, Movement }; // 对齐 CCIMX + +class WallPaperEngine : public QObject { + Q_OBJECT +public: + // 读 ConfigStore "wallpaper" 域,绑定到 layer(非 owning)与 strategy 回调 + WallPaperEngine(WallPaperLayer* layer, std::function request_transition, QObject* parent); + void start(); // activate 时:mode!=Fixed 且 layer 张数>1 才 startTimer + void stop(); // deactivate 时 + // 可选:配置热更 setMode/setInterval/setDuration +private: + void onTimerTick(); // 选下一张(随机/顺序)→ request_transition(mode) +}; +} +``` + +选择器:默认随机(对齐 CCIMX `ImagePoolEngine::default_index`);可后续加顺序模式。 + +### 4.5 配置(`wallpaper.json` 新 key) + +经 ConfigStore `wallpaper` 域,`KeyView{.group=..., .key=...}`(动态 key,无需注册,见 `cfconfig.hpp`): +- `switch_mode`:`"fixed" | "gradient" | "movement"`(默认 `"movement"`,对齐 CCIMX `DEF_MODE`)。 +- `switch_interval_ms`:默认 `20000`。 +- `animation_duration_ms`:默认 `2000`。 +- (可选/后续)`switch_easing`:默认 `InOutCubic`。 + +在 [desktop_config_init.cpp](../../../desktop/main/init/desktop_config_init/desktop_config_init.cpp) 的 `WALLPAPER_CONFIG_TEMPLATE` 补默认值。 + +### 4.6 生命周期接线 + +[wallpaper_setup.cpp](../../../desktop/ui/components/shell_layer_impl/wallpaper_setup.cpp) 的 `create_wallpaper_strategy()` 现为 `make_unique(make_layer())`: +- strategy 内部构造 `WallPaperEngine`(传 layer 原始指针 + `request_transition` 回调)。 +- `activate()` 末尾 `engine->start()`;`deactivate()` 里 `engine->stop()`。 +- `mode==Fixed` 或 `storage->size()<=1`:`start()` 不启动 timer(照搬 CCIMX 守卫)。 + +### 4.7 CMake + +新文件加入 [desktop/ui/components/wallpaper/CMakeLists.txt](../../../desktop/ui/components/wallpaper/CMakeLists.txt) 的 `cfdesktop_wallpaper` STATIC 目标(`WallPaperEngine.cpp`),或 shell_layer_impl 目标(看 engine 最终落点)。链接 `Qt6::Core`(QTimer/QPropertyAnimation 已由 Gui/Core 覆盖)。 + +--- + +## 五、待办步骤(接手 AI 执行) + +- [ ] 0. 读本文件 + `WallPaperLayer.h` + `WallpaperShellLayerStrategy.{h,cpp}` + `wallpaper_setup.cpp` + `filter_target.h` + CCIMX 两源文件。 +- [ ] 1. **先商量拉分支**(纪律见下),开 `feat/wallpaper-animation-engine`。 +- [ ] 2. 配置先行:`WALLPAPER_CONFIG_TEMPLATE` 补 key + 一个读取 helper(后续 engine/strategy 都依赖)。 +- [ ] 3. 实现 `WallPaperEngine.{h,cpp}` + 注册 CMake。 +- [ ] 4. 扩展 `WallpaperShellLayerStrategy`:过渡状态机 + `Q_PROPERTY(progress)` + `composite()` + `beginTransition()`;改造 `onWallpaperChanged()` 区分瞬时/过渡路径。 +- [ ] 5. 接线:`create_wallpaper_strategy` / `activate` / `deactivate` 构造与启停 engine。 +- [ ] 6. 单元测试(`add_gtest_executable`,标 `"desktop;unit;wallpaper"`):engine 模式/定时/`>1` 守卫(用假 layer);strategy `composite()` 给定两张图 + t 验证输出尺寸与非空;Qt 动画用 `QSignalSpy`。 +- [ ] 7. 文档:HandBook 壁纸页 + `status/current.md` + 本文件勾选 + `13_widget_apps.md` 壁纸段状态。 +- [ ] 8. 验证(见第七节)。 + +--- + +## 六、约束(铁律) + +- **三层依赖**:engine/strategy 在 desktop 层,不得 `#include` 反向。验证:`grep -r '#include.*\(ui/\|desktop/\)' base/` 须空。 +- **Doxygen**:新公共类/函数按 [`document/DOXYGEN_REQUEST.md`](../../DOXYGEN_REQUEST.md);过 `python3 scripts/doxygen/lint.py`。 +- **Ownership**:`WallPaperEngine` 用 `std::unique_ptr` 持有(由 strategy 持有);engine 对 `WallPaperLayer` 只持**非 owning 指针**(strategy 拥有 layer)。避免裸 `new`(除 Qt parent-ownership)。 +- **No silent fallbacks**:配置 `switch_mode` 非法 → 显式 `log::warn` + 回退默认 `movement`,不要静默吞。 +- **性能平衡**(见 [[balance-perf-even-when-told-to-ignore]]):过渡期 2000ms/60fps ≈ 120 次全屏 QImage 合成,可接受,但:① 仅过渡窗口内跑,非过渡空闲零开销(仅 timer);② Movement 合成用 `drawImage` 简单 blit,不走 `SmoothTransformation`;③ geometry 变化时若正在过渡,按既有 `onGeometryChanged` 重缩放逻辑处理(注意过渡中要同步重缩 `previous_scaled`)。 +- **分支/提交纪律**:分支上 `add+commit` 可以;**绝不 push**(用户自己 push);commit message **英文**且**不加 `Co-Authored-By`**(见记忆 `no-co-authored-by-in-commits`、`branch-commit-allowed-push-never`)。 + +--- + +## 七、验证(端到端) + +1. **Fixed**:不轮播,壁纸静止(行为同今天)。 +2. **Gradient**:定时到点 → 交叉淡入,过渡平滑、结束显示新图;日志见 transition start/end。 +3. **Movement**:平移滑入(旧左出 / 新右入),方向同 CCIMX。 +4. **边界**:壁纸库 0 张/1 张 → 不崩溃、不启动 timer(`size()<=1` 守卫);`mode` 非法 → 回退 movement + warn。 +5. **配置**:改 `switch_interval_ms`/`switch_mode`(重启或热更,注明支持哪种)→ 生效。 +6. **回归**:`linux_run_tests.sh` 全绿(含新增动画测试);既有壁纸/发现行为不变(Fixed 模式 ≈ 今天)。 +7. **门禁**:三层依赖 grep + `python3 scripts/doxygen/lint.py` 通过;`linux_fast_develop_build.sh` 通过。 + +--- + +## 八、待定决策(接手 AI 与用户确认) + +1. **手动切换是否也走动画**:现有 `showNext/Prev/Target` 是否复用过渡路径?建议:是(timer 自动 + 手动都动画,体验一致)。 +2. **缓动曲线是否配置化**:先固定 `InOutCubic`,随主题/设置面板(Phase 12)再暴露。 +3. **低性能板降级**:6ULL 等是否默认 Fixed / 关动画?见 `desktop-buildout-handoff` 记忆的 6ULL 铁律;建议加一个 HWTier 判断或配置项。 +4. **资源包 manifest 解析**:CF-Gallery `scripts/manifest.json`(带 photographer/width/height)是否本批接入,用于壁纸选择器 UI 的署名/尺寸?建议**本批不做**,随壁纸选择器 UI(Phase 12/13 后续)。 + +--- + +## 九、关键文件速查 + +| 文件 | 角色 | +|---|---| +| [WallPaperLayer.h](../../../desktop/ui/components/wallpaper/WallPaperLayer.h) | 数据层接口(不改) | +| [ImageWallPaperLayer.h](../../../desktop/ui/components/wallpaper/ImageWallPaperLayer.h) / [.cpp](../../../desktop/ui/components/wallpaper/ImageWallPaperLayer.cpp) | 数据层实现(不改) | +| [WallpaperShellLayerStrategy.h](../../../desktop/ui/components/shell_layer_impl/WallpaperShellLayerStrategy.h) / [.cpp](../../../desktop/ui/components/shell_layer_impl/WallpaperShellLayerStrategy.cpp) | **渲染缝,扩展过渡状态机** | +| [wallpaper_setup.cpp](../../../desktop/ui/components/shell_layer_impl/wallpaper_setup.cpp) | 装配点(`create_wallpaper_strategy`),接线 engine | +| [wallpaper/CMakeLists.txt](../../../desktop/ui/components/wallpaper/CMakeLists.txt) | 注册新 `WallPaperEngine.cpp` | +| [desktop_config_init.cpp](../../../desktop/main/init/desktop_config_init/desktop_config_init.cpp) | `WALLPAPER_CONFIG_TEMPLATE` 补 key | +| `~/CCIMXDesktop/core/wallpaper/WallPaperEngine.{h,cpp}` | 源参照(engine 语义) | +| `~/CCIMXDesktop/ui/wallpaperanimationhandler.{h,cpp}` | 源参照(过渡语义,勿抄 QLabel) | + +--- + +## 十、实施记录(2026-06-30 落地) + +**实际落地文件**: + +| 文件 | 角色 | +|---|---| +| `desktop/ui/components/wallpaper/TransitionComposer.{h,cpp}` | **新**:`SwitchingMode` 枚举 + 纯函数 `composeTransitionFrame`(Gradient/Movement/Fixed 逐帧合成,可单测) | +| `desktop/ui/components/wallpaper/WallPaperEngine.{h,cpp}` | **新**:配置驱动 + QTimer 定时 + `Selector{Sequential,Random}` + 纯函数 `selectNextWallpaper` + `size()>1`/Fixed/`disable_animation` 守卫 | +| `WallPaperAccessStorage.{h,cpp}` | 加 `tokenIds()`(Random 选择器所需) | +| `WallpaperShellLayerStrategy.{h,cpp}` | 过渡状态机(`QVariantAnimation` 驱动逐帧合成)+ `triggerNextWallpaper()` | +| `wallpaper_setup.cpp` | `make_layer()` 接 `scaling`/`background_color` 配置(原僵尸 key) | +| `cmake/meta_info/desktop_settings.template.h.in` | `WALLPAPER_CONFIG_TEMPLATE` 加 6 个动画 key | +| `test/desktop/wallpaper_animation/` | **新**:16 例(composer 7 + selector 6 + engine 3),全过 | + +**对接手文档的纠正**(实施时发现): + +1. 配置模板在 `.h.in`(`cf::desktop::early_stage::WALLPAPER_CONFIG_TEMPLATE`),**非** `desktop_config_init.cpp`——后者只是写入它。 +2. `WallPaperAccessStorage` 的 `indexed_vector` 是 private,原无枚举/按索引接口 → 必须加 `tokenIds()` 才能支持 Random。 +3. CFDesktop 原生 `showNextOne` **顺序不 wrap**(到边界返回 false),与 CCIMX 随机不同 → 用纯函数 `selectNextWallpaper` 统一两种策略,Sequential 自带 wrap,Random 排除当前。 + +**实际决策**(与用户确认): + +- 选择器:**Sequential + Random 两种都做**,`switch_selector` 配置切换(默认 Sequential)。 +- 6ULL/低性能降级:**仅 `disable_animation` 配置项**,不接 HWTier。 +- 手动切换也走动画:`triggerNextWallpaper()` 复用过渡路径(为壁纸选择器 UI 预留)。 +- 顺手接 `scaling`/`background_color` 僵尸 key。 +- 缓动曲线配置化:`switch_easing`(inoutcubic/outcubic/linear)。 + +**工程选择(偏离原文档)**: + +- 用 `QVariantAnimation`(`valueChanged` 驱动)而非 `QPropertyAnimation`——strategy 非 QObject,免 `Q_OBJECT`/`Q_PROPERTY`,改动最小。 +- **engine 不切图**:切图同步触发 layer 回调,必须由 strategy 先设过渡状态;故 engine 只发 `request_(mode)`,strategy 在回调里设状态 + 切图 + 启动动画,时序正确。 +- 过渡中 geometry 变化 → `resetTransition()` 中止(旧图缓存无法重缩),由 `rescaleImage()` 处理新尺寸。 +- 新图加载失败(`currentImage().isNull()`)→ abort 过渡、回退旧图 + warn(no-silent-fallback)。 + +**验证(全绿)**:三层依赖 grep ✅ / `python3 scripts/doxygen/lint.py` ✅ / `linux_fast_develop_build.sh` ✅ / `linux_run_tests.sh` 13/13 ✅(含新增 16 例 wallpaper 测试)。手动端到端验证留待合入后在 WSLg 实机跑(Fixed/Gradient/Movement/边界/配置切换)。 diff --git a/test/desktop/CMakeLists.txt b/test/desktop/CMakeLists.txt index d8c6ddb2d..2c85eb325 100644 --- a/test/desktop/CMakeLists.txt +++ b/test/desktop/CMakeLists.txt @@ -9,3 +9,6 @@ add_subdirectory(frosted_backdrop) # Add window placement tests subdirectory add_subdirectory(window_placement) + +# Add wallpaper animation tests subdirectory +add_subdirectory(wallpaper_animation) diff --git a/test/desktop/wallpaper_animation/CMakeLists.txt b/test/desktop/wallpaper_animation/CMakeLists.txt new file mode 100644 index 000000000..044e8da76 --- /dev/null +++ b/test/desktop/wallpaper_animation/CMakeLists.txt @@ -0,0 +1,38 @@ +# Wallpaper animation unit tests. +# Custom main() (no gtest_main) spins up an offscreen QGuiApplication so +# QPainter/QImage/QTimer work in headless / CI environments. cfdesktop_wallpaper +# is STATIC with PRIVATE dependencies, so this test re-links every transitive +# dependency it pulls in through the wallpaper headers and engine impl. +add_executable(wallpaper_animation_test + wallpaper_animation_test.cpp +) + +target_include_directories(wallpaper_animation_test PRIVATE + ${CMAKE_SOURCE_DIR}/desktop/ui/components +) + +target_link_libraries(wallpaper_animation_test PRIVATE + cfdesktop_wallpaper + cfconfig + cflogger + cfbase + QuarkWidgets::quarkwidgets + aex::aex + Qt6::Gui + Qt6::Core + GTest::gtest +) + +set_target_properties(wallpaper_animation_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/test/bin" +) + +add_test(NAME wallpaper_animation_test COMMAND wallpaper_animation_test) +set_tests_properties(wallpaper_animation_test PROPERTIES + LABELS "desktop;unit;wallpaper" + TIMEOUT 60 + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/test/bin" + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" +) + +log_info("wallpaper_animation_tests" " - wallpaper_animation_test") diff --git a/test/desktop/wallpaper_animation/wallpaper_animation_test.cpp b/test/desktop/wallpaper_animation/wallpaper_animation_test.cpp new file mode 100644 index 000000000..19517ab7c --- /dev/null +++ b/test/desktop/wallpaper_animation/wallpaper_animation_test.cpp @@ -0,0 +1,245 @@ +/** + * @file wallpaper_animation_test.cpp + * @brief Unit tests for wallpaper transition compositing, selector, and engine. + * + * Covers the pure compositing helper (composeTransitionFrame), the rotation + * selector (selectNextWallpaper) for both Sequential and Random, and that the + * WallPaperEngine picks up default configuration. A custom main() spins up an + * offscreen QGuiApplication so QPainter/QImage/QTimer work without a display. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-30 + * @version 0.1 + * @since 0.19 + * @ingroup wallpaper + */ + +#include "wallpaper/TransitionComposer.h" +#include "wallpaper/WallPaperAccessStorage.h" +#include "wallpaper/WallPaperEngine.h" +#include "wallpaper/WallPaperLayer.h" +#include "wallpaper/WallPaperToken.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +using namespace cf::desktop::wallpaper; + +namespace { + +/// @brief Builds a solid-color test image of the given size. +QImage solidImage(const QSize& size, const QColor& color) { + QImage img(size, QImage::Format_RGB32); + img.fill(color); + return img; +} + +/** + * @brief Minimal WallPaperLayer double with a controllable storage size. + * + * Only tokenStorage() is exercised (by the engine's size guard). The image + * and switch methods return inert defaults. + */ +class FakeLayer : public WallPaperLayer { + public: + explicit FakeLayer(size_t token_count) { + QStringList paths; + for (size_t i = 0; i < token_count; ++i) { + paths << QString("/fake/wallpaper_%1.png").arg(i); + } + storage_.addTokens(WallPaperTokenFactory::fromFiles(paths)); + } + + QImage currentImage() const override { return {}; } + ScalingMode scalingMode() const override { return ScalingMode::Fill; } + QColor backgroundColor() const override { return QColor(0, 0, 0); } + void setTokenStorage(std::unique_ptr) override {} + WallPaperAccessStorage& tokenStorage() const override { return storage_; } + bool showNextOne() override { return false; } + bool showPrevOne() override { return false; } + bool showTargetOne(const wallpaper_token_id_t&) override { return false; } + + private: + mutable WallPaperAccessStorage storage_; +}; + +} // namespace + +// ============================================================ +// composeTransitionFrame +// ============================================================ + +TEST(TransitionComposer, EmptyTargetReturnsNull) { + const QImage prev = solidImage({100, 100}, Qt::red); + const QImage cur = solidImage({100, 100}, Qt::blue); + const QImage out = composeTransitionFrame(prev, cur, 0.5, SwitchingMode::Gradient, QSize(0, 0)); + EXPECT_TRUE(out.isNull()); +} + +TEST(TransitionComposer, NullCurrentReturnsNull) { + const QImage prev = solidImage({100, 100}, Qt::red); + const QImage out = + composeTransitionFrame(prev, QImage(), 0.5, SwitchingMode::Gradient, QSize(100, 100)); + EXPECT_TRUE(out.isNull()); +} + +TEST(TransitionComposer, OutputMatchesTargetSize) { + const QImage prev = solidImage({100, 100}, Qt::red); + const QImage cur = solidImage({100, 100}, Qt::blue); + const QImage out = + composeTransitionFrame(prev, cur, 0.5, SwitchingMode::Gradient, QSize(120, 80)); + ASSERT_FALSE(out.isNull()); + EXPECT_EQ(out.size(), QSize(120, 80)); +} + +TEST(TransitionComposer, GradientAtStartShowsPrevious) { + // At t=0 the previous frame is fully opaque over the current one. + const QImage prev = solidImage({50, 50}, Qt::red); + const QImage cur = solidImage({50, 50}, Qt::blue); + const QImage out = + composeTransitionFrame(prev, cur, 0.0, SwitchingMode::Gradient, QSize(50, 50)); + ASSERT_FALSE(out.isNull()); + EXPECT_EQ(out.pixel(0, 0), prev.pixel(0, 0)); +} + +TEST(TransitionComposer, GradientAtEndShowsCurrent) { + // At t=1 the previous frame is fully faded out, leaving the current one. + const QImage prev = solidImage({50, 50}, Qt::red); + const QImage cur = solidImage({50, 50}, Qt::blue); + const QImage out = + composeTransitionFrame(prev, cur, 1.0, SwitchingMode::Gradient, QSize(50, 50)); + ASSERT_FALSE(out.isNull()); + EXPECT_EQ(out.pixel(0, 0), cur.pixel(0, 0)); +} + +TEST(TransitionComposer, MovementSlidesOldOutNewIn) { + // At t=0 the left edge shows the previous frame; at t=1 it shows the current. + const QImage prev = solidImage({60, 10}, Qt::red); + const QImage cur = solidImage({60, 10}, Qt::blue); + const QImage out0 = + composeTransitionFrame(prev, cur, 0.0, SwitchingMode::Movement, QSize(60, 10)); + const QImage out1 = + composeTransitionFrame(prev, cur, 1.0, SwitchingMode::Movement, QSize(60, 10)); + ASSERT_FALSE(out0.isNull()); + ASSERT_FALSE(out1.isNull()); + EXPECT_EQ(out0.pixel(0, 0), prev.pixel(0, 0)); + EXPECT_EQ(out1.pixel(0, 0), cur.pixel(0, 0)); +} + +TEST(TransitionComposer, ClampsProgressBeyondRange) { + const QImage prev = solidImage({40, 40}, Qt::red); + const QImage cur = solidImage({40, 40}, Qt::green); + // t > 1 clamps to 1 (current); t < 0 clamps to 0 (previous); no crash. + const QImage over = + composeTransitionFrame(prev, cur, 5.0, SwitchingMode::Gradient, QSize(40, 40)); + const QImage under = + composeTransitionFrame(prev, cur, -3.0, SwitchingMode::Gradient, QSize(40, 40)); + EXPECT_EQ(over.pixel(0, 0), cur.pixel(0, 0)); + EXPECT_EQ(under.pixel(0, 0), prev.pixel(0, 0)); +} + +// ============================================================ +// selectNextWallpaper +// ============================================================ + +TEST(SelectNextWallpaper, EmptyIdsReturnsEmpty) { + std::mt19937 rng(42); + const auto id = selectNextWallpaper({}, "x", Selector::Sequential, rng); + EXPECT_TRUE(id.isEmpty()); +} + +TEST(SelectNextWallpaper, SequentialAdvancesOne) { + std::mt19937 rng(1); + const std::vector ids = {"a", "b", "c"}; + EXPECT_EQ(selectNextWallpaper(ids, "a", Selector::Sequential, rng).toStdString(), "b"); + EXPECT_EQ(selectNextWallpaper(ids, "b", Selector::Sequential, rng).toStdString(), "c"); +} + +TEST(SelectNextWallpaper, SequentialWrapsAtEnd) { + std::mt19937 rng(1); + const std::vector ids = {"a", "b", "c"}; + EXPECT_EQ(selectNextWallpaper(ids, "c", Selector::Sequential, rng).toStdString(), "a"); +} + +TEST(SelectNextWallpaper, SequentialUnknownCurrentFallsToFirst) { + std::mt19937 rng(1); + const std::vector ids = {"a", "b", "c"}; + EXPECT_EQ(selectNextWallpaper(ids, "zzz", Selector::Sequential, rng).toStdString(), "a"); +} + +TEST(SelectNextWallpaper, RandomNeverReturnsCurrent) { + std::mt19937 rng(7); + const std::vector ids = {"a", "b", "c", "d"}; + for (int i = 0; i < 50; ++i) { + const auto next = selectNextWallpaper(ids, "b", Selector::Random, rng); + EXPECT_NE(next.toStdString(), "b"); + EXPECT_FALSE(next.isEmpty()); + } +} + +TEST(SelectNextWallpaper, RandomCoversAllNonCurrentCandidates) { + // Over many draws with a fixed seed, every non-current id appears at least once. + std::mt19937 rng(123); + const std::vector ids = {"a", "b", "c", "d"}; + std::set seen; + for (int i = 0; i < 300; ++i) { + seen.insert(selectNextWallpaper(ids, "a", Selector::Random, rng).toStdString()); + } + EXPECT_EQ(seen.count("a"), 0u); // current never returned + EXPECT_EQ(seen.count("b"), 1u); + EXPECT_EQ(seen.count("c"), 1u); + EXPECT_EQ(seen.count("d"), 1u); +} + +// ============================================================ +// WallPaperEngine configuration & guards +// ============================================================ + +TEST(WallPaperEngine, DefaultsMatchCcimxSemantics) { + // Relies on ConfigStore returning defaults when the wallpaper domain is unset. + WallPaperEngine engine(nullptr, WallPaperEngine::RequestTransition{}, nullptr); + EXPECT_EQ(engine.mode(), SwitchingMode::Movement); + EXPECT_EQ(engine.selector(), Selector::Sequential); + EXPECT_EQ(engine.animationDurationMs(), 2000); + EXPECT_EQ(engine.easing().type(), QEasingCurve::InOutCubic); +} + +TEST(WallPaperEngine, StartStopWithMultipleWallpapersIsSafe) { + FakeLayer layer(3); + WallPaperEngine engine(&layer, WallPaperEngine::RequestTransition{}, nullptr); + engine.start(); // Movement, size>1 → timer armed; no event loop means no tick. + engine.stop(); + SUCCEED(); +} + +TEST(WallPaperEngine, StartWithSingleWallpaperIsHarmless) { + FakeLayer layer(1); + WallPaperEngine engine(&layer, WallPaperEngine::RequestTransition{}, nullptr); + engine.start(); // size<=1 guard: timer not armed. + engine.stop(); + SUCCEED(); +} + +// ============================================================ +// Custom main: offscreen QGuiApplication for QPainter/QImage/QTimer. +// ============================================================ + +int main(int argc, char** argv) { + QGuiApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}