diff --git a/cmake/custom_target_helper.cmake b/cmake/custom_target_helper.cmake index 5d0ccb2ad..359394e8d 100644 --- a/cmake/custom_target_helper.cmake +++ b/cmake/custom_target_helper.cmake @@ -50,29 +50,39 @@ function(qt_add_windows_deploy target_name) NO_UNSUPPORTED_PLATFORM_ERROR ) - message(STATUS "Deploy script for '${target_name}': ${_deploy_script}") - - add_custom_target(deploy_${target_name} - COMMAND ${CMAKE_COMMAND} - --install "${CMAKE_BINARY_DIR}" + message(STATUS "Deploy script for '${target_name}': ${_deploy_script}") + + set(_locked_deploy_script + "${CMAKE_BINARY_DIR}/.qt/locked_deploy_${target_name}_$.cmake") + file(GENERATE + OUTPUT "${_locked_deploy_script}" + CONTENT +"file(LOCK \"${CMAKE_BINARY_DIR}/.qt/windeployqt.lock\" GUARD PROCESS TIMEOUT 300) +set(QT_DEPLOY_PREFIX \"$\") +set(QT_DEPLOY_BIN_DIR \".\") +set(QT_DEPLOY_LIB_DIR \".\") +set(QT_DEPLOY_PLUGINS_DIR \"plugins\") +set(QT_DEPLOY_QML_DIR \"qml\") +include(\"${_deploy_script}\") +" + ) + + add_custom_target(deploy_${target_name} + COMMAND ${CMAKE_COMMAND} + --install "${CMAKE_BINARY_DIR}" --config $ DEPENDS ${target_name} COMMENT "Deploying Qt dependencies for '${target_name}'..." VERBATIM ) - if(QT_DEPLOY_AUTO_DEPLOY) - add_custom_command(TARGET ${target_name} POST_BUILD - COMMAND ${CMAKE_COMMAND} - -DQT_DEPLOY_PREFIX=$ - -DQT_DEPLOY_BIN_DIR=. - -DQT_DEPLOY_LIB_DIR=. - -DQT_DEPLOY_PLUGINS_DIR=plugins - -DQT_DEPLOY_QML_DIR=qml - -P "${_deploy_script}" - COMMENT "Auto-deploying Qt dependencies for '${target_name}'..." - VERBATIM - ) + if(QT_DEPLOY_AUTO_DEPLOY) + add_custom_command(TARGET ${target_name} POST_BUILD + COMMAND ${CMAKE_COMMAND} + -P "${_locked_deploy_script}" + COMMENT "Auto-deploying Qt dependencies for '${target_name}'..." + VERBATIM + ) message(STATUS "[${target_name}] AUTO_DEPLOY enabled: will run after every build") endif() @@ -81,4 +91,4 @@ function(qt_add_windows_deploy target_name) message(STATUS "[${target_name}] INSTALL_DEPLOY enabled: will run during cmake --install") endif() -endfunction() \ No newline at end of file +endfunction() diff --git a/desktop/ui/CFDesktop.cpp b/desktop/ui/CFDesktop.cpp index d3390636f..33858d75b 100644 --- a/desktop/ui/CFDesktop.cpp +++ b/desktop/ui/CFDesktop.cpp @@ -13,6 +13,7 @@ #include "cflog.h" #include "components/PanelManager.h" +#include #include namespace cf::desktop { @@ -39,6 +40,14 @@ void CFDesktop::resizeEvent(QResizeEvent* event) { } } +void CFDesktop::moveEvent(QMoveEvent* event) { + QWidget::moveEvent(event); + // A pure position change (drag) does not alter the local panel layout, so + // relayout/availableGeometryChanged will not fire — emit geometryChanged so + // screen-coordinate dependents (window placement) re-evaluate. + emit geometryChanged(); +} + bool CFDesktop::component_available(const DesktopComponent d) const noexcept { switch (d) { case DesktopComponent::Common: diff --git a/desktop/ui/CFDesktop.h b/desktop/ui/CFDesktop.h index 0dc4af230..14d6c003b 100644 --- a/desktop/ui/CFDesktop.h +++ b/desktop/ui/CFDesktop.h @@ -103,6 +103,35 @@ class CF_DESKTOP_EXPORT CFDesktop final : public QWidget { protected: void resizeEvent(QResizeEvent* event) override; + /** + * @brief Forwards desktop position changes to interested shell systems. + * + * Widget move events are not otherwise observed; window placement (and any + * screen-coordinate-relative shell logic) needs to re-evaluate when the + * desktop widget is dragged, so this emits geometryChanged(). + * + * @param[in] event The move event descriptor. + * + * @throws None + * @since 0.20 + * @ingroup desktop_ui + */ + void moveEvent(QMoveEvent* event) override; + + signals: + /** + * @brief Emitted when the desktop widget moves (position changes). + * + * Resize is already covered by PanelManager::availableGeometryChanged + * (relayout); this signal covers the pure-position case so screen-coordinate + * dependents (e.g. window placement) can re-evaluate when the desktop is + * dragged without changing size. + * + * @since 0.20 + * @ingroup desktop_ui + */ + void geometryChanged(); + private: aex::WeakPtrFactory weak_ptr_factory_; }; diff --git a/desktop/ui/CFDesktopEntity.cpp b/desktop/ui/CFDesktopEntity.cpp index 4cb3c723c..f3c0f9730 100644 --- a/desktop/ui/CFDesktopEntity.cpp +++ b/desktop/ui/CFDesktopEntity.cpp @@ -4,6 +4,7 @@ #include "IDesktopDisplaySizeStrategy.h" #include "IDesktopPropertyStrategy.h" #include "aex/weak_ptr/weak_ptr.h" +#include "cfconfig.hpp" #include "cflog.h" #include "components/DisplayServerBackendFactory.h" #include "components/IDisplayServerBackend.h" @@ -13,6 +14,7 @@ #include "components/launcher/app_launcher.h" #include "components/statusbar/status_bar.h" #include "components/taskbar/centered_taskbar.h" +#include "components/window_placement/window_placement_policy.h" #include "platform/DesktopPropertyStrategyFactory.h" #include "platform/display_backend_helper.h" #include "platform/shell_layer_helper.h" @@ -104,12 +106,64 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { res.shell_layer_ = shell; desktop_entity_->register_desktop_resources(res); - // Connect PanelManager geometry changes to ShellLayer + // Connect PanelManager geometry changes to ShellLayer. The wallpaper shell + // spans the FULL host geometry (not the panel-reduced central rect) so it + // renders continuously behind the top/bottom bars; each bar composites a + // frosted copy of the strip directly behind it. The launcher popup still + // uses PanelManager::availableGeometry() (the central-rect getter), which is + // independent and unaffected. QObject::connect(panel_mgr, &PanelManager::availableGeometryChanged, desktop_entity_, - [shell](const QRect& r) { shell->onAvailableGeometryChanged(r); }); + [shell, this](const QRect&) { + shell->onAvailableGeometryChanged(desktop_entity_->rect()); + }); + + // Re-constrain every tracked external window into the current screen-space + // work area. The work area is PanelManager's local central rect translated + // to screen coordinates (window->geometry() is root-relative), so it stays + // correct when the desktop is MOVED, not only resized. Shared by the two + // triggers below: availableGeometryChanged (desktop resized / panels + // relaid out) and geometryChanged (desktop moved without resizing) — the + // back-propagation that was missing for the drag/move case. + auto reconstrain_windows = [panel_mgr, this]() { + if (!display_backend_) { + return; + } + auto backend = display_backend_->windowBackend(); + if (!backend) { + return; + } + namespace cfg = cf::config; + const bool enabled = cfg::ConfigStore::instance() + .domain("window_management") + .query(cfg::KeyView{.group = "window_management", + .key = "constrain_to_workarea"}, + true); + const QRect work = panel_mgr->availableGeometry().translated(desktop_entity_->pos()); + const cf::desktop::placement::WindowPlacementPolicy policy; + int moved = 0; + for (const auto& wptr : backend->windows()) { + auto* w = wptr.Get(); + if (w == nullptr) { + continue; + } + const QRect before = w->geometry(); + policy.constrain(*w, work, enabled); + if (w->geometry() != before) { + ++moved; + } + } + if (moved > 0) { + cf::log::infoftag("WindowPlacement", + "re-constrained {} window(s) into screen work area {}", moved, work); + } + }; + QObject::connect(panel_mgr, &PanelManager::availableGeometryChanged, this, + [reconstrain_windows](const QRect&) { reconstrain_windows(); }); + QObject::connect(desktop_entity_, &CFDesktop::geometryChanged, this, reconstrain_windows); // ── Status bar: top-edge panel (clock + system icons) ── auto* status_bar = new cf::desktop::desktop_component::StatusBar(desktop_entity_); + status_bar->setBackdropSource(shell); panel_mgr->registerPanel(status_bar->GetWeak()); status_bar->show(); @@ -125,12 +179,52 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { } } + // ── Window placement: constrain launched windows into the work area ── + // On each external window appearance, clamp it inside the central work area + // (between the status bar and the taskbar) so it neither overlaps a bar nor + // flies off-screen, mirroring a real desktop WM. The policy is stateless, so + // a temporary is fine here. Runtime toggle via config domain + // "window_management" / key "constrain_to_workarea" (default on), read per + // appearance so flipping the config needs no restart. + if (display_backend_) { + if (auto window_backend = display_backend_->windowBackend()) { + QObject::connect( + window_backend.Get(), &cf::desktop::IWindowBackend::window_came, this, + [panel_mgr, this](aex::WeakPtr win) { + if (!win) { + cf::log::warningftag("WindowPlacement", "window_came with null window"); + return; + } + auto* w = win.Get(); + namespace cfg = cf::config; + const bool enabled = + cfg::ConfigStore::instance() + .domain("window_management") + .query(cfg::KeyView{.group = "window_management", + .key = "constrain_to_workarea"}, + true); + const QRect work = + panel_mgr->availableGeometry().translated(desktop_entity_->pos()); + const QRect cur = w->geometry(); + const auto target = + cf::desktop::placement::WindowPlacementPolicy{}.computeConstrain(cur, work, + enabled); + if (target.has_value()) { + w->set_geometry(*target); + cf::log::infoftag("WindowPlacement", "constrained '{}' {} -> {}", + w->title().toStdString(), cur, *target); + } + }); + } + } + // ── Taskbar: bottom-edge panel (centered app icons) ── // apps is captured by the click handler to resolve app_id -> exec_command. const QList apps = cf::desktop::desktop_component::defaultApps(); auto* taskbar = new cf::desktop::desktop_component::CenteredTaskbar(desktop_entity_); taskbar->setApps(apps); + taskbar->setBackdropSource(shell); panel_mgr->registerPanel(taskbar->GetWeak()); // Shared launch path: resolve app_id -> exec, launch, capture PID. Used by // both the taskbar tile click and the launcher popup so the running-state diff --git a/desktop/ui/CMakeLists.txt b/desktop/ui/CMakeLists.txt index e3419575b..cc62bfc84 100644 --- a/desktop/ui/CMakeLists.txt +++ b/desktop/ui/CMakeLists.txt @@ -35,6 +35,7 @@ PRIVATE cf_desktop_ui_widget # Link widget submodule cf_desktop_ui_platform # Link platform submodule cf_desktop_render # Link render backend abstraction + cfconfig # ConfigStore: window_management.constrain_to_workarea toggle QuarkWidgets::quarkwidgets # For qt_format.h (std::formatter) ) diff --git a/desktop/ui/components/CMakeLists.txt b/desktop/ui/components/CMakeLists.txt index af0ec7478..f459ea3d9 100644 --- a/desktop/ui/components/CMakeLists.txt +++ b/desktop/ui/components/CMakeLists.txt @@ -5,6 +5,8 @@ log_info("Dekstop UI" "Start UI Components Configurations") add_subdirectory(wallpaper) add_subdirectory(shell_layer_impl) +add_subdirectory(frosted_backdrop) +add_subdirectory(window_placement) add_subdirectory(statusbar) add_subdirectory(taskbar) add_subdirectory(launcher) @@ -36,5 +38,6 @@ PRIVATE cfdesktop_statusbar cfdesktop_taskbar cfdesktop_launcher + cfdesktop_window_placement Qt6::Core Qt6::Gui Qt6::Widgets ) diff --git a/desktop/ui/components/IShellLayer.h b/desktop/ui/components/IShellLayer.h index 3fc917452..6dbda85f6 100644 --- a/desktop/ui/components/IShellLayer.h +++ b/desktop/ui/components/IShellLayer.h @@ -19,6 +19,7 @@ #pragma once #include "IShellLayerStrategy.h" +#include #include #include @@ -70,6 +71,17 @@ class IShellLayer { * @param[in] available The new available geometry rectangle. */ virtual void onAvailableGeometryChanged(const QRect& available) = 0; + + /** + * @brief Returns the current background image, if any. + * + * The default returns a null QImage. Implementations backed by a wallpaper + * strategy delegate to it. Shell panels use this to composite a frosted + * copy of the backdrop (e.g. the status bar and taskbar acrylic surfaces). + * + * @return Current background image, or a null QImage when none is available. + */ + virtual QImage currentBackgroundImage() const { return {}; } }; } // namespace cf::desktop diff --git a/desktop/ui/components/app_entry.h b/desktop/ui/components/app_entry.h index 466f8c631..2d03c7554 100644 --- a/desktop/ui/components/app_entry.h +++ b/desktop/ui/components/app_entry.h @@ -29,7 +29,7 @@ namespace cf::desktop::desktop_component { struct AppEntry { QString app_id; ///< Stable unique identifier (e.g. "terminal"). QString display_name; ///< Human-readable label (initial is drawn on the tile). - QString icon_path; ///< Optional icon resource path (empty -> use initial). + QString icon_path; ///< Icon resource path; empty leaves the tile blank (no fallback). QString exec_command; ///< Launch command consumed later by QProcess. bool is_running{false}; ///< Whether the app currently has a live window. }; @@ -49,13 +49,15 @@ struct AppEntry { */ inline QList defaultApps() { return { - {QStringLiteral("files"), QStringLiteral("Files"), QString{}, QStringLiteral("xdg-open ."), + {QStringLiteral("files"), QStringLiteral("Files"), + QStringLiteral(":/cfdesktop/taskbar/files.png"), QStringLiteral("xdg-open ."), false}, + {QStringLiteral("terminal"), QStringLiteral("Terminal"), + QStringLiteral(":/cfdesktop/taskbar/terminal.png"), QStringLiteral("xterm"), false}, + {QStringLiteral("settings"), QStringLiteral("Settings"), + QStringLiteral(":/cfdesktop/taskbar/settings.png"), QStringLiteral("cfdesktop-settings"), false}, - {QStringLiteral("terminal"), QStringLiteral("Terminal"), QString{}, QStringLiteral("xterm"), - false}, - {QStringLiteral("settings"), QStringLiteral("Settings"), QString{}, - QStringLiteral("cfdesktop-settings"), false}, - {QStringLiteral("browser"), QStringLiteral("Browser"), QString{}, + {QStringLiteral("browser"), QStringLiteral("Browser"), + QStringLiteral(":/cfdesktop/taskbar/browser.png"), QStringLiteral("xdg-open https://example.com"), false}, }; } diff --git a/desktop/ui/components/frosted_backdrop/CMakeLists.txt b/desktop/ui/components/frosted_backdrop/CMakeLists.txt new file mode 100644 index 000000000..0191b225d --- /dev/null +++ b/desktop/ui/components/frosted_backdrop/CMakeLists.txt @@ -0,0 +1,13 @@ +# Frosted-glass (acrylic) backdrop renderer for shell panels. +# Pure Qt Gui utility: crop -> box blur -> tint -> acrylic grain. Depends on +# nothing in desktop/ (three-layer clean) so it stays unit-testable in isolation. +add_library(cfdesktop_frosted_backdrop STATIC + frosted_backdrop.cpp +) + +target_include_directories(cfdesktop_frosted_backdrop PUBLIC + $ + $ +) + +target_link_libraries(cfdesktop_frosted_backdrop PUBLIC Qt6::Gui) diff --git a/desktop/ui/components/frosted_backdrop/frosted_backdrop.cpp b/desktop/ui/components/frosted_backdrop/frosted_backdrop.cpp new file mode 100644 index 000000000..975a75811 --- /dev/null +++ b/desktop/ui/components/frosted_backdrop/frosted_backdrop.cpp @@ -0,0 +1,244 @@ +/** + * @file frosted_backdrop.cpp + * @brief Cached frosted-glass (acrylic) backdrop renderer implementation. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-26 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "frosted_backdrop.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace cf::desktop { + +namespace { + +/// @brief Box-blurs one scanline using a running-sum window. +/// +/// Edge pixels are clamped (extended) so the window stays full at the borders. +/// Operates per-channel on premultiplied RGBA values; averaging premultiplied +/// pixels yields a valid premultiplied average, so the result stays correct. +/// +/// @param[out] dst Destination scanline. +/// @param[in] src Source scanline. +/// @param[in] n Pixel count. +/// @param[in] radius Box half-width; window size is 2*radius+1. +/// @throws None +void blurScanline(QRgb* dst, const QRgb* src, int n, int radius) { + if (n <= 0 || radius < 1) { + return; + } + const int window = 2 * radius + 1; + const auto clamp = [n](int x) { return x < 0 ? 0 : (x >= n ? n - 1 : x); }; + + // Prime the running sums for the window centered at x = 0. + long long sr = 0, sg = 0, sb = 0, sa = 0; + for (int k = -radius; k <= radius; ++k) { + const QRgb p = src[clamp(k)]; + sr += qRed(p); + sg += qGreen(p); + sb += qBlue(p); + sa += qAlpha(p); + } + + for (int x = 0; x < n; ++x) { + dst[x] = qRgba(static_cast(sr / window), static_cast(sg / window), + static_cast(sb / window), static_cast(sa / window)); + // Slide the window one pixel to the right. + const QRgb out_p = src[clamp(x - radius)]; + const QRgb in_p = src[clamp(x + radius + 1)]; + sr += qRed(in_p) - qRed(out_p); + sg += qGreen(in_p) - qGreen(out_p); + sb += qBlue(in_p) - qBlue(out_p); + sa += qAlpha(in_p) - qAlpha(out_p); + } +} + +/// @brief Applies @p passes separable (horizontal then vertical) box blurs. +/// @param[in,out] img Image to blur (converted to ARGB32_Premultiplied). +/// @param[in] radius Box half-width in the image's own pixel space. +/// @param[in] passes Number of full H+V passes (3 ~ Gaussian). +/// @throws None +void boxBlur(QImage& img, int radius, int passes) { + if (img.isNull() || radius < 1 || passes < 1) { + return; + } + if (img.format() != QImage::Format_ARGB32_Premultiplied) { + img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + const int w = img.width(); + const int h = img.height(); + if (w < 1 || h < 1) { + return; + } + const std::size_t buf_len = static_cast(std::max(w, h)); + std::vector in(buf_len); + std::vector out(buf_len); + + for (int p = 0; p < passes; ++p) { + // Horizontal pass: blur each row in place. + for (int y = 0; y < h; ++y) { + auto* line = reinterpret_cast(img.scanLine(y)); + blurScanline(out.data(), line, w, radius); + std::copy_n(out.data(), static_cast(w), line); + } + // Vertical pass: blur each column in place. + for (int x = 0; x < w; ++x) { + for (int y = 0; y < h; ++y) { + in[static_cast(y)] = reinterpret_cast(img.scanLine(y))[x]; + } + blurScanline(out.data(), in.data(), h, radius); + for (int y = 0; y < h; ++y) { + reinterpret_cast(img.scanLine(y))[x] = out[static_cast(y)]; + } + } + } +} + +/// @brief Returns a lazily-built, process-cached acrylic noise tile. +/// +/// A small grayscale-noise RGBA tile, built once and shared across all panels. +/// Drawn at low opacity to add the subtle "acrylic" grain that distinguishes +/// frosted glass from a flat translucent fill. +/// +/// @return Reference to the shared noise tile pixmap. +/// @throws None +const QPixmap& grainTile() { + static QPixmap tile; + if (!tile.isNull()) { + return tile; + } + constexpr int kTile = 128; + QImage noise(kTile, kTile, QImage::Format_ARGB32); + noise.fill(Qt::transparent); + auto* gen = QRandomGenerator::global(); + for (int y = 0; y < kTile; ++y) { + auto* line = reinterpret_cast(noise.scanLine(y)); + for (int x = 0; x < kTile; ++x) { + const int v = gen->bounded(256); // 0..255 gray. + const int a = gen->bounded(48); // Low alpha so it stays subtle. + line[x] = qRgba(v, v, v, a); + } + } + tile = QPixmap::fromImage(noise); + return tile; +} + +} // namespace + +FrostedBackdrop::Key FrostedBackdrop::makeKey(const QImage& source, const QRect& strip, + qreal device_pixel_ratio, + const FrostedParams& params) noexcept { + Key k; + k.img_key = source.cacheKey(); + k.rect = strip; + k.dpr = device_pixel_ratio; + k.params = params; + return k; +} + +QPixmap FrostedBackdrop::build(const QImage& source, const QRect& strip, + const FrostedParams& params) { + QPixmap out(strip.size()); + out.fill(Qt::transparent); + + QPainter painter(&out); + painter.setRenderHint(QPainter::Antialiasing, true); + + // 1. Blurred wallpaper strip placed at its offset within the panel rect. + const QRect crop_rect = strip.intersected(source.rect()); + if (!crop_rect.isEmpty()) { + const int ds = std::max(1, params.downsample); + const QSize small_size(std::max(1, crop_rect.width() / ds), + std::max(1, crop_rect.height() / ds)); + + QImage small = source.copy(crop_rect).scaled(small_size, Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); + if (small.format() != QImage::Format_ARGB32_Premultiplied) { + small = small.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + const int small_radius = std::max(1, params.blur_radius_px / ds); + boxBlur(small, small_radius, std::max(1, params.box_passes)); + + const QImage blurred = + small.scaled(crop_rect.size(), Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + const QPoint offset = crop_rect.topLeft() - strip.topLeft(); + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.drawImage(offset, blurred); + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + } + + // 2. Surface tint (SourceOver so the blurred wallpaper shows through). + QColor tint = params.tint; + tint.setAlphaF(std::clamp(params.tint_alpha, 0.0, 1.0)); + painter.fillRect(out.rect(), tint); + + // 3. Acrylic grain overlay. + if (params.enable_grain) { + const QPixmap& grain = grainTile(); + if (!grain.isNull()) { + painter.setOpacity(0.06); + for (int y = 0; y < out.height(); y += grain.height()) { + for (int x = 0; x < out.width(); x += grain.width()) { + painter.drawPixmap(x, y, grain); + } + } + painter.setOpacity(1.0); + } + } + + // 4. Optional specular top-edge gloss. + if (params.top_highlight && out.height() > 0) { + QLinearGradient gloss(0, 0, 0, out.height() * 0.5); + QColor c(255, 255, 255); + c.setAlphaF(0.10); + gloss.setColorAt(0.0, c); + c.setAlphaF(0.0); + gloss.setColorAt(1.0, c); + painter.fillRect(QRect(0, 0, out.width(), out.height()), gloss); + } + + painter.end(); + return out; +} + +bool FrostedBackdrop::isCacheValid(const QImage& source, const QRect& strip, + qreal device_pixel_ratio, const FrostedParams& params) const { + if (source.isNull() || strip.isEmpty() || cache_.isNull()) { + return false; + } + return key_ == makeKey(source, strip, device_pixel_ratio, params); +} + +QPixmap FrostedBackdrop::render(const QImage& source, const QRect& strip, qreal device_pixel_ratio, + const FrostedParams& params) { + if (source.isNull() || strip.isEmpty()) { + return {}; + } + const Key k = makeKey(source, strip, device_pixel_ratio, params); + if (!cache_.isNull() && k == key_) { + return cache_; + } + cache_ = build(source, strip, params); + key_ = k; + return cache_; +} + +void FrostedBackdrop::invalidate() noexcept { + cache_ = {}; + key_ = {}; +} + +} // namespace cf::desktop diff --git a/desktop/ui/components/frosted_backdrop/frosted_backdrop.h b/desktop/ui/components/frosted_backdrop/frosted_backdrop.h new file mode 100644 index 000000000..4b9109551 --- /dev/null +++ b/desktop/ui/components/frosted_backdrop/frosted_backdrop.h @@ -0,0 +1,139 @@ +/** + * @file frosted_backdrop.h + * @brief Cached frosted-glass (acrylic) backdrop renderer for shell panels. + * + * WSL/X11 provides no compositor backdrop blur, so a shell panel cannot rely on + * real translucency to look "frosted". Instead each panel paints its own + * blurred-and-tinted strip of the wallpaper. FrostedBackdrop renders that strip + * (downsample -> separable box blur -> smooth upscale -> surface tint -> + * optional acrylic grain -> optional top highlight) and caches the result by a + * key derived from the source image, the strip rect, the tint, the blur radius, + * the device-pixel-ratio, and the full parameter set. Steady repaints (clock + * ticks, hover) are O(1) cache hits with no blur work. + * + * The renderer depends only on Qt Gui; it knows nothing of the shell, panels, + * or themes. Callers resolve theme colors into FrostedParams. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-26 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#pragma once + +#include +#include +#include +#include + +namespace cf::desktop { + +/** + * @brief Tunable parameters for a frosted-glass render. + * + * Every member is part of the render cache key: changing any value forces the + * next render() to rebuild the pixmap. + * + * @ingroup components + */ +struct FrostedParams { + QColor tint{255, 255, 255}; ///< Surface tint baked into the glass. + qreal tint_alpha{0.60}; ///< 0..1 tint opacity; lower shows more wallpaper. + int blur_radius_px{18}; ///< Logical blur radius; downsampled internally. + int downsample{4}; ///< Crop is shrunk by this factor before blur. + int box_passes{3}; ///< Separable box-blur passes (~Gaussian). + bool enable_grain{true}; ///< Overlay a subtle acrylic noise texture. + bool top_highlight{false}; ///< Draw a specular top-edge gloss. + + /// @brief Value equality (all members); used by the render cache key. + bool operator==(const FrostedParams&) const = default; +}; + +/** + * @brief Renders and caches a frosted-glass strip from a wallpaper image. + * + * One instance per panel. render() is deterministic: identical inputs yield an + * identical pixmap, and repeated calls with unchanged inputs return the cached + * pixmap without recomputing. + * + * @ingroup components + */ +class FrostedBackdrop { + public: + /** + * @brief Constructs an empty (uncached) renderer. + * + * @throws None + * @since 0.20 + */ + FrostedBackdrop() = default; + + /** + * @brief Returns the cached frosted pixmap for @p source cropped to + * @p strip, rebuilding only when the cache key changes. + * + * @param[in] source Full-screen wallpaper image (logical px). + * @param[in] strip Region of @p source the panel covers, in + * the same coordinate space as @p source. + * @param[in] device_pixel_ratio Current device-pixel-ratio (cache key). + * @param[in] params Render parameters (cache key). + * + * @return Pixmap at @p strip size, or a null pixmap when @p source is null + * or @p strip is empty (caller falls back to a flat fill). + * + * @throws None + * @note Deterministic: identical inputs produce identical output. + * @warning Returns null when @p source is null; callers must handle it. + * @since 0.20 + */ + QPixmap render(const QImage& source, const QRect& strip, qreal device_pixel_ratio, + const FrostedParams& params); + + /** + * @brief Reports whether the cache matches the given inputs. + * + * @param[in] source Wallpaper image to test against. + * @param[in] strip Strip rect to test against. + * @param[in] device_pixel_ratio Device-pixel-ratio to test against. + * @param[in] params Render parameters to test against. + * + * @return true when a valid cached pixmap exists for these exact inputs. + * + * @throws None + * @since 0.20 + */ + bool isCacheValid(const QImage& source, const QRect& strip, qreal device_pixel_ratio, + const FrostedParams& params) const; + + /** + * @brief Forces the next render() to rebuild the pixmap. + * + * @throws None + * @since 0.20 + */ + void invalidate() noexcept; + + private: + /// Cache key: captures every input that affects the rendered output. + struct Key { + qint64 img_key{0}; ///< Source QImage::cacheKey(). + QRect rect{}; ///< Strip rect. + qreal dpr{1.0}; ///< Device-pixel-ratio. + FrostedParams params{}; ///< Full render parameter set. + bool operator==(const Key&) const = default; + }; + + /// @brief Builds the key from current inputs (never throws). + static Key makeKey(const QImage& source, const QRect& strip, qreal device_pixel_ratio, + const FrostedParams& params) noexcept; + + /// @brief Renders the frosted pixmap for @p source / @p strip (no caching). + static QPixmap build(const QImage& source, const QRect& strip, const FrostedParams& params); + + Key key_{}; + QPixmap cache_{}; +}; + +} // namespace cf::desktop diff --git a/desktop/ui/components/shell_layer_impl/WidgetShellLayer.cpp b/desktop/ui/components/shell_layer_impl/WidgetShellLayer.cpp index 3b21e78d8..a43532dce 100644 --- a/desktop/ui/components/shell_layer_impl/WidgetShellLayer.cpp +++ b/desktop/ui/components/shell_layer_impl/WidgetShellLayer.cpp @@ -33,6 +33,10 @@ QRect WidgetShellLayer::geometry() const { return QWidget::geometry(); } +QImage WidgetShellLayer::currentBackgroundImage() const { + return strategy_ ? strategy_->currentBackgroundImage() : QImage{}; +} + void WidgetShellLayer::onAvailableGeometryChanged(const QRect& rect) { log::traceftag("WidgetShellLayer", "Available geometry changed: QRect({}, {}, {}, {})", rect.x(), rect.y(), rect.width(), rect.height()); diff --git a/desktop/ui/components/shell_layer_impl/WidgetShellLayer.h b/desktop/ui/components/shell_layer_impl/WidgetShellLayer.h index 249dc22d6..f81211ad7 100644 --- a/desktop/ui/components/shell_layer_impl/WidgetShellLayer.h +++ b/desktop/ui/components/shell_layer_impl/WidgetShellLayer.h @@ -60,6 +60,16 @@ class WidgetShellLayer : public QWidget, public IShellLayer { */ QRect geometry() const override; + /** + * @brief Returns the active strategy's background image, if any. + * + * Delegates to the installed strategy's currentBackgroundImage() so shell + * panels can composite a frosted copy of the wallpaper backdrop. + * + * @return Wallpaper image from the strategy, or null when no strategy. + */ + QImage currentBackgroundImage() const override; + // -- Weak reference ------------------------------------------------------- /** * @brief Returns a weak pointer to this shell layer. diff --git a/desktop/ui/components/statusbar/CMakeLists.txt b/desktop/ui/components/statusbar/CMakeLists.txt index 349ecadc7..2020c8196 100644 --- a/desktop/ui/components/statusbar/CMakeLists.txt +++ b/desktop/ui/components/statusbar/CMakeLists.txt @@ -18,4 +18,5 @@ PRIVATE Qt6::Widgets cfbase # WeakPtr / WeakPtrFactory cflogger # Diagnostic logging for icon-mask loading + cfdesktop_frosted_backdrop # Frosted-glass backdrop renderer ) diff --git a/desktop/ui/components/statusbar/status_bar.cpp b/desktop/ui/components/statusbar/status_bar.cpp index c55d7de60..f5bb85ce7 100644 --- a/desktop/ui/components/statusbar/status_bar.cpp +++ b/desktop/ui/components/statusbar/status_bar.cpp @@ -25,9 +25,11 @@ #include #include +#include #include #include #include +#include #include #include @@ -52,12 +54,13 @@ using namespace qw::core::token::literals; namespace { // Fallback palette used when no theme is available (mirrors MD3 light). -constexpr int kBarHeight = 48; ///< Status bar thickness in device pixels. -constexpr int kSideMarginDp = 16; ///< Horizontal padding for clock/icons (dp). -constexpr int kIconGapDp = 12; ///< Spacing between adjacent icons (dp). -constexpr int kIconSizeDp = 16; ///< Icon cell edge length (dp). -constexpr int kTimeGapDp = 10; ///< Gap between the time and the icon cluster (dp). -constexpr qreal kShadowBandDp = 5.0; ///< In-band elevation shadow height (dp). +constexpr int kBarHeight = 48; ///< Status bar thickness in device pixels. +constexpr int kSideMarginDp = 16; ///< Horizontal padding for clock/icons (dp). +constexpr int kIconGapDp = 12; ///< Spacing between adjacent icons (dp). +constexpr int kIconSizeDp = 16; ///< Icon cell edge length (dp). +constexpr int kTimeGapDp = 10; ///< Gap between the time and the icon cluster (dp). +constexpr qreal kShadowBandDp = 5.0; ///< In-band elevation shadow height (dp). +constexpr qreal kFrostTintAlpha = 0.60; ///< Frosted-glass tint opacity (wallpaper bleed). // Icon kinds and their compiled-resource mask paths. Indices align with // StatusBar::icon_masks_[]. A missing mask leaves a visible gap in the bar @@ -90,6 +93,25 @@ void StatusBar::setupUi() { connect(timer_, &QTimer::timeout, this, &StatusBar::onTimeout); timer_->start(1000); + // Coalesce rapid resizes (e.g. a window drag) into a single frosted-backdrop + // reblur instead of rebuilding on every intermediate geometry. + reblur_debounce_ = new QTimer(this); + reblur_debounce_->setSingleShot(true); + reblur_debounce_->setInterval(80); + connect(reblur_debounce_, &QTimer::timeout, this, [this]() { + frosted_.invalidate(); + update(); + }); + + // A screen change (different monitor / device-pixel-ratio) invalidates the + // backdrop cache so the next paint rebuilds at the new DPR. QWidget has no + // screenChanged signal, so listen to the application-wide primary-screen + // change; devicePixelRatioF() is also part of the cache key as a backstop. + connect(qApp, &QGuiApplication::primaryScreenChanged, this, [this]() { + frosted_.invalidate(); + update(); + }); + // React to theme switches (ThemeManager is the canonical source). connect(&qw::core::ThemeManager::instance(), &qw::core::ThemeManager::themeChanged, this, [this](const qw::core::ICFTheme&) { applyTheme(); }); @@ -121,6 +143,13 @@ void StatusBar::applyTheme() { clock_font_ = font(); clock_font_.setPixelSize(15); } + // Frosted-glass tint tracks the resolved surface color (covers both the + // themed and fallback branches above). The top highlight gives the top bar + // its "raised glass" reading. + frosted_params_.tint = background_color_; + frosted_params_.tint_alpha = kFrostTintAlpha; + frosted_params_.top_highlight = true; + frosted_.invalidate(); update(); } @@ -250,19 +279,52 @@ StatusBarStyle StatusBar::style() const { return style_; } +// -- Backdrop -------------------------------------------------------------- +void StatusBar::setBackdropSource(cf::desktop::IShellLayer* source) { + backdrop_source_ = source; + frosted_.invalidate(); + update(); +} + +void StatusBar::resizeEvent(QResizeEvent* event) { + QWidget::resizeEvent(event); + // Debounce: collapse a burst of resizes (e.g. a window drag) into one + // frosted-backdrop reblur rather than rebuilding on every intermediate size. + if (reblur_debounce_ != nullptr) { + reblur_debounce_->start(); + } +} + // -- Painting -------------------------------------------------------------- void StatusBar::paintEvent(QPaintEvent* /*event*/) { const CanvasUnitHelper h(devicePixelRatioF()); QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); p.setRenderHint(QPainter::SmoothPixmapTransform, true); - p.setOpacity(fade_opacity_); + // 1. Frosted-glass backdrop at FULL opacity. The boot fade must only fade + // the content layer (clock, icons, chrome); if the glass faded too, the + // raw wallpaper would bleed through during the 250ms fade-in. + p.setOpacity(1.0); + const QImage backdrop = + backdrop_source_ != nullptr ? backdrop_source_->currentBackgroundImage() : QImage{}; + const QRect strip = geometry(); + if (!backdrop.isNull() && !strip.isEmpty()) { + p.drawPixmap(0, 0, frosted_.render(backdrop, strip, devicePixelRatioF(), frosted_params_)); + backdrop_null_warned_ = false; + } else { + // Flat fallback (e.g. before the wallpaper loads). Fail loud, once. + QLinearGradient surface(0, 0, 0, height()); + surface.setColorAt(0.0, surface_top_color_); + surface.setColorAt(1.0, background_color_); + p.fillRect(rect(), surface); + if (!backdrop_null_warned_) { + backdrop_null_warned_ = true; + cf::log::warningftag("StatusBar", "backdrop image null; using flat fallback"); + } + } - // Tonal elevation surface: gentle vertical gradient (lifted top -> surface). - QLinearGradient surface(0, 0, 0, height()); - surface.setColorAt(0.0, surface_top_color_); - surface.setColorAt(1.0, background_color_); - p.fillRect(rect(), surface); + // 2. Content layer fades in on boot. + p.setOpacity(fade_opacity_); // Clock region: date on the left, time on the right just before the icons // (Split) or centered (Centered). The icon-cluster left edge anchors the time. diff --git a/desktop/ui/components/statusbar/status_bar.h b/desktop/ui/components/statusbar/status_bar.h index b203452a6..dd8508f4f 100644 --- a/desktop/ui/components/statusbar/status_bar.h +++ b/desktop/ui/components/statusbar/status_bar.h @@ -18,6 +18,8 @@ #include "IStatusBar.h" #include "aex/weak_ptr/weak_ptr.h" #include "aex/weak_ptr/weak_ptr_factory.h" +#include "components/IShellLayer.h" +#include "components/frosted_backdrop/frosted_backdrop.h" #include #include @@ -209,6 +211,17 @@ class StatusBar final : public QWidget, public IStatusBar { */ aex::WeakPtr GetWeak() const { return weak_factory_.GetWeakPtr(); } + /** + * @brief Sets the backdrop source for the frosted-glass surface. + * + * @param[in] source Shell layer supplying the wallpaper image (non-owning). + * + * @throws None + * @note @p source must outlive this status bar. + * @since 0.20 + */ + void setBackdropSource(cf::desktop::IShellLayer* source); + protected: /** * @brief Paints the background, clock, and icon glyphs. @@ -223,6 +236,16 @@ class StatusBar final : public QWidget, public IStatusBar { */ void paintEvent(QPaintEvent* event) override; + /** + * @brief Coalesces geometry changes into a debounced backdrop reblur. + * + * @param[in] event The resize event descriptor. + * + * @throws None + * @since 0.20 + */ + void resizeEvent(QResizeEvent* event) override; + private slots: /// @brief Refreshes the clock text each second. void onTimeout(); @@ -271,6 +294,17 @@ class StatusBar final : public QWidget, public IStatusBar { /// back to vector drawing in paintEvent. QPixmap icon_masks_[4]; + /// Shell layer backing the frosted surface (non-owning; may be null). + cf::desktop::IShellLayer* backdrop_source_{nullptr}; + /// Frosted-glass renderer with its own cache (one instance per bar). + cf::desktop::FrostedBackdrop frosted_; + /// Resolved frosted parameters; the tint tracks the active theme surface. + cf::desktop::FrostedParams frosted_params_{}; + /// Coalesces rapid resizes into a single backdrop reblur. Ownership: this. + QTimer* reblur_debounce_{nullptr}; + /// Guards the null-backdrop warning so it fires once per transition. + bool backdrop_null_warned_{false}; + /// Weak pointer factory (must be the last member). mutable aex::WeakPtrFactory weak_factory_{this}; }; diff --git a/desktop/ui/components/taskbar/CMakeLists.txt b/desktop/ui/components/taskbar/CMakeLists.txt index c78d69bd9..9dac98bdd 100644 --- a/desktop/ui/components/taskbar/CMakeLists.txt +++ b/desktop/ui/components/taskbar/CMakeLists.txt @@ -3,6 +3,7 @@ add_library(cfdesktop_taskbar STATIC centered_taskbar.cpp start_button.cpp taskbar_icon.cpp + taskbar_icons.qrc ) target_include_directories(cfdesktop_taskbar PUBLIC @@ -18,4 +19,6 @@ PUBLIC PRIVATE Qt6::Widgets cfbase # WeakPtr / WeakPtrFactory + cflogger # Diagnostic logging for null-backdrop fallback + cfdesktop_frosted_backdrop # Frosted-glass backdrop renderer ) diff --git a/desktop/ui/components/taskbar/centered_taskbar.cpp b/desktop/ui/components/taskbar/centered_taskbar.cpp index 71df0e952..960c29286 100644 --- a/desktop/ui/components/taskbar/centered_taskbar.cpp +++ b/desktop/ui/components/taskbar/centered_taskbar.cpp @@ -19,14 +19,29 @@ #include "start_button.h" #include "taskbar_icon.h" +#include "cflog.h" #include "core/theme_manager.h" #include "core/token/material_scheme/cfmaterial_token_literals.h" +#include #include #include #include #include #include +#include +#include + +// Q_INIT_RESOURCE must run at global scope: the rcc-generated registration +// function lives in the global namespace, but the macro's extern declaration is +// emitted in the surrounding scope, so calling it from inside +// cf::desktop::desktop_component would look up a namespaced symbol that does +// not exist and fail to link. Without this the taskbar_icons.qrc object is +// dropped from the static archive at link time and ":/cfdesktop/taskbar/*.png" +// lookups silently return null (icons render blank). +static void registerTaskbarIconsResource() { + Q_INIT_RESOURCE(taskbar_icons); +} namespace cf::desktop::desktop_component { @@ -34,15 +49,17 @@ using cf::desktop::PanelPosition; using namespace qw::core::token::literals; namespace { -constexpr int kTaskbarHeight = 64; ///< Bar thickness (px). -constexpr int kSideMargin = 12; ///< Horizontal padding (px). -constexpr int kTopBottomMargin = 4; ///< Vertical padding (px). -constexpr int kIconSpacing = 8; ///< Gap between tiles (px). -constexpr int kStartButtonGap = 16; ///< Gap after the start button (px). -constexpr qreal kSurfaceAlpha = 0.92; ///< Surface fill opacity. +constexpr int kTaskbarHeight = 64; ///< Bar thickness (px). +constexpr int kSideMargin = 12; ///< Horizontal padding (px). +constexpr int kTopBottomMargin = 4; ///< Vertical padding (px). +constexpr int kIconSpacing = 8; ///< Gap between tiles (px). +constexpr int kStartButtonGap = 16; ///< Gap after the start button (px). +constexpr qreal kSurfaceAlpha = 0.92; ///< Flat-fallback surface opacity (null backdrop). +constexpr qreal kFrostTintAlpha = 0.60; ///< Frosted-glass tint opacity (wallpaper bleed). } // namespace CenteredTaskbar::CenteredTaskbar(QWidget* parent) : QWidget(parent) { + registerTaskbarIconsResource(); setAttribute(Qt::WA_OpaquePaintEvent); setAutoFillBackground(false); setFixedHeight(kTaskbarHeight); @@ -71,6 +88,23 @@ void CenteredTaskbar::setupUi() { layout_->addLayout(icon_layout_); layout_->addStretch(); + // Coalesce rapid resizes (e.g. a window drag) into a single frosted-backdrop + // reblur instead of rebuilding on every intermediate geometry. + reblur_debounce_ = new QTimer(this); + reblur_debounce_->setSingleShot(true); + reblur_debounce_->setInterval(80); + connect(reblur_debounce_, &QTimer::timeout, this, [this]() { + frosted_.invalidate(); + update(); + }); + + // A screen change (different monitor / device-pixel-ratio) invalidates the + // backdrop cache; devicePixelRatioF() is also part of the cache key. + connect(qApp, &QGuiApplication::primaryScreenChanged, this, [this]() { + frosted_.invalidate(); + update(); + }); + // React to theme switches (ThemeManager is the canonical source). connect(&qw::core::ThemeManager::instance(), &qw::core::ThemeManager::themeChanged, this, [this](const qw::core::ICFTheme&) { applyTheme(); }); @@ -88,6 +122,10 @@ void CenteredTaskbar::applyTheme() { background_color_ = QColor(0xF7, 0xF5, 0xF3); divider_color_ = QColor(0xCA, 0xC4, 0xD0); } + frosted_params_.tint = background_color_; + frosted_params_.tint_alpha = kFrostTintAlpha; + frosted_params_.top_highlight = false; + frosted_.invalidate(); update(); } @@ -136,15 +174,52 @@ void CenteredTaskbar::updateRunningState(const QString& app_id, bool running) { } } +// -- Backdrop -------------------------------------------------------------- +void CenteredTaskbar::setBackdropSource(cf::desktop::IShellLayer* source) { + backdrop_source_ = source; + frosted_.invalidate(); + update(); +} + +void CenteredTaskbar::resizeEvent(QResizeEvent* event) { + QWidget::resizeEvent(event); + // Debounce: collapse a burst of resizes (e.g. a window drag) into one + // frosted-backdrop reblur rather than rebuilding on every intermediate size. + if (reblur_debounce_ != nullptr) { + reblur_debounce_->start(); + } +} + // -- Painting -------------------------------------------------------------- void CenteredTaskbar::paintEvent(QPaintEvent* /*event*/) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); - // Translucent surface. - QColor bg = background_color_; - bg.setAlphaF(kSurfaceAlpha); - p.fillRect(rect(), bg); + // Frosted-glass backdrop: blurred wallpaper strip + surface tint + acrylic + // grain. tint_alpha (~0.6) is deliberately lower than the old flat 0.92 so + // the blur reads through; a fully opaque tint would hide the effect. + const QImage backdrop = + backdrop_source_ != nullptr ? backdrop_source_->currentBackgroundImage() : QImage{}; + const QRect strip = geometry(); + if (!backdrop.isNull() && !strip.isEmpty()) { + p.drawPixmap(0, 0, frosted_.render(backdrop, strip, devicePixelRatioF(), frosted_params_)); + backdrop_null_warned_ = false; + } else { + // Flat fallback (e.g. before the wallpaper loads). Fail loud, once. + QColor bg = background_color_; + bg.setAlphaF(kSurfaceAlpha); + p.fillRect(rect(), bg); + if (!backdrop_null_warned_) { + backdrop_null_warned_ = true; + cf::log::warningftag("CenteredTaskbar", "backdrop image null; using flat fallback"); + } + } + + // Soft top elevation shadow: the bar reads as floating above the shell. + QLinearGradient shadow(0, 0, 0, height() * 0.5); + shadow.setColorAt(0.0, QColor(0, 0, 0, 38)); + shadow.setColorAt(1.0, QColor(0, 0, 0, 0)); + p.fillRect(QRectF(0, 0, width(), height() * 0.5), shadow); // Horizontally-faded top hairline, mirroring the status bar seam. QColor lineMid = divider_color_; diff --git a/desktop/ui/components/taskbar/centered_taskbar.h b/desktop/ui/components/taskbar/centered_taskbar.h index b86adb67a..9e4bdc047 100644 --- a/desktop/ui/components/taskbar/centered_taskbar.h +++ b/desktop/ui/components/taskbar/centered_taskbar.h @@ -20,12 +20,15 @@ #include "aex/weak_ptr/weak_ptr_factory.h" #include "app_entry.h" #include "components/IPanel.h" +#include "components/IShellLayer.h" +#include "components/frosted_backdrop/frosted_backdrop.h" #include #include #include class QHBoxLayout; +class QTimer; namespace cf::desktop::desktop_component { @@ -162,6 +165,17 @@ class CenteredTaskbar final : public QWidget, public cf::desktop::IPanel { */ aex::WeakPtr GetWeak() const { return weak_factory_.GetWeakPtr(); } + /** + * @brief Sets the backdrop source for the frosted-glass surface. + * + * @param[in] source Shell layer supplying the wallpaper image (non-owning). + * + * @throws None + * @note @p source must outlive this taskbar. + * @since 0.20 + */ + void setBackdropSource(cf::desktop::IShellLayer* source); + signals: /** * @brief Emitted when a tile is clicked. @@ -195,6 +209,16 @@ class CenteredTaskbar final : public QWidget, public cf::desktop::IPanel { */ void paintEvent(QPaintEvent* event) override; + /** + * @brief Coalesces geometry changes into a debounced backdrop reblur. + * + * @param[in] event The resize event descriptor. + * + * @throws None + * @since 0.20 + */ + void resizeEvent(QResizeEvent* event) override; + private: /// @brief Creates the centered row layout. void setupUi(); @@ -209,6 +233,17 @@ class CenteredTaskbar final : public QWidget, public cf::desktop::IPanel { QColor background_color_; ///< Surface fill for the bar. QColor divider_color_; ///< Top hairline divider color. + /// Shell layer backing the frosted surface (non-owning; may be null). + cf::desktop::IShellLayer* backdrop_source_{nullptr}; + /// Frosted-glass renderer with its own cache (one instance per bar). + cf::desktop::FrostedBackdrop frosted_; + /// Resolved frosted parameters; the tint tracks the active theme surface. + cf::desktop::FrostedParams frosted_params_{}; + /// Coalesces rapid resizes into a single backdrop reblur. Ownership: this. + QTimer* reblur_debounce_{nullptr}; + /// Guards the null-backdrop warning so it fires once per transition. + bool backdrop_null_warned_{false}; + /// Weak pointer factory (must be the last member). mutable aex::WeakPtrFactory weak_factory_{this}; }; diff --git a/desktop/ui/components/taskbar/icon_mask.h b/desktop/ui/components/taskbar/icon_mask.h new file mode 100644 index 000000000..650481f82 --- /dev/null +++ b/desktop/ui/components/taskbar/icon_mask.h @@ -0,0 +1,65 @@ +/** + * @file icon_mask.h + * @brief Re-tint a monochrome PNG mask to a theme color. + * + * Taskbar tiles ship their icons as single-color (white) PNG masks and recolor + * them at runtime to the active theme's on-surface token. tintedIconMask() does + * that recolor in one call (load mask -> SourceIn fill) so TaskbarIcon and + * StartButton share one implementation. Returns a null pixmap on any miss so + * callers fall back to drawing an initial letter / built-in glyph. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-26 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#pragma once + +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief Loads the mask at @p path and fills its silhouette with @p color. + * + * Uses QPainter::CompositionMode_SourceIn so the mask's alpha channel is kept + * and every opaque pixel becomes @p color: the standard way to theme a flat + * single-color icon without shipping one asset per color. + * + * @param[in] path Qt resource or filesystem path to the PNG mask. + * @param[in] color Solid color to paint the silhouette. + * + * @return The tinted pixmap, or a null pixmap when @p path is empty or the + * resource cannot be loaded (caller falls back to a glyph or letter). + * + * @throws None + * @note Preserves the source device-pixel-ratio for crisp high-dpi tiles. + * @warning None + * @since 0.20 + * @ingroup components + */ +inline QPixmap tintedIconMask(const QString& path, const QColor& color) { + if (path.isEmpty()) { + return {}; + } + QPixmap mask{path}; + if (mask.isNull()) { + return {}; + } + QPixmap out(mask.size()); + out.setDevicePixelRatio(mask.devicePixelRatio()); + out.fill(Qt::transparent); + QPainter painter{&out}; + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.drawPixmap(0, 0, mask); + painter.setCompositionMode(QPainter::CompositionMode_SourceIn); + painter.fillRect(out.rect(), color); + return out; +} + +} // namespace cf::desktop::desktop_component diff --git a/desktop/ui/components/taskbar/icons/README.md b/desktop/ui/components/taskbar/icons/README.md new file mode 100644 index 000000000..fbaf5dd2d --- /dev/null +++ b/desktop/ui/components/taskbar/icons/README.md @@ -0,0 +1,27 @@ +# Taskbar Icons + +Monochrome (white-silhouette) taskbar icons, shipped as 96×96 PNG masks and +tinted at runtime to the active theme's `on-surface` token via +`QPainter::CompositionMode_SourceIn` (same mask-then-tint pipeline the status +bar uses). One color source per icon keeps the set themeable and crisp when +downscaled to the ~28px draw size on 1x and 2x screens. + +| File | Meaning | Used by | +|------|---------|---------| +| `files.png` | Folder | Files app tile | +| `terminal.png` | `>_` prompt | Terminal app tile | +| `settings.png` | Gear | Settings app tile | +| `browser.png` | Globe | Browser app tile | +| `start.png` | 2×2 grid | Start button | + +The paired `*.svg` files are the editable source of each PNG; the build only +embeds the PNGs (see `../taskbar_icons.qrc`). + +## Origin / License + +CFDesktop-original artwork, authored for this project (no third-party license +or attribution required). Re-export a PNG with, e.g.: + +```sh +rsvg-convert -w 96 -h 96 files.svg -o files.png +``` diff --git a/desktop/ui/components/taskbar/icons/browser.png b/desktop/ui/components/taskbar/icons/browser.png new file mode 100644 index 000000000..69cff4e5e Binary files /dev/null and b/desktop/ui/components/taskbar/icons/browser.png differ diff --git a/desktop/ui/components/taskbar/icons/browser.svg b/desktop/ui/components/taskbar/icons/browser.svg new file mode 100644 index 000000000..dea69a12f --- /dev/null +++ b/desktop/ui/components/taskbar/icons/browser.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/ui/components/taskbar/icons/files.png b/desktop/ui/components/taskbar/icons/files.png new file mode 100644 index 000000000..d60d0cad0 Binary files /dev/null and b/desktop/ui/components/taskbar/icons/files.png differ diff --git a/desktop/ui/components/taskbar/icons/files.svg b/desktop/ui/components/taskbar/icons/files.svg new file mode 100644 index 000000000..d0b41c8a8 --- /dev/null +++ b/desktop/ui/components/taskbar/icons/files.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/ui/components/taskbar/icons/settings.png b/desktop/ui/components/taskbar/icons/settings.png new file mode 100644 index 000000000..76c8c4bd7 Binary files /dev/null and b/desktop/ui/components/taskbar/icons/settings.png differ diff --git a/desktop/ui/components/taskbar/icons/settings.svg b/desktop/ui/components/taskbar/icons/settings.svg new file mode 100644 index 000000000..21b049c8c --- /dev/null +++ b/desktop/ui/components/taskbar/icons/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/ui/components/taskbar/icons/start.png b/desktop/ui/components/taskbar/icons/start.png new file mode 100644 index 000000000..57775d564 Binary files /dev/null and b/desktop/ui/components/taskbar/icons/start.png differ diff --git a/desktop/ui/components/taskbar/icons/start.svg b/desktop/ui/components/taskbar/icons/start.svg new file mode 100644 index 000000000..0bb70df5e --- /dev/null +++ b/desktop/ui/components/taskbar/icons/start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/ui/components/taskbar/icons/terminal.png b/desktop/ui/components/taskbar/icons/terminal.png new file mode 100644 index 000000000..17afe0977 Binary files /dev/null and b/desktop/ui/components/taskbar/icons/terminal.png differ diff --git a/desktop/ui/components/taskbar/icons/terminal.svg b/desktop/ui/components/taskbar/icons/terminal.svg new file mode 100644 index 000000000..c7a183598 --- /dev/null +++ b/desktop/ui/components/taskbar/icons/terminal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/ui/components/taskbar/start_button.cpp b/desktop/ui/components/taskbar/start_button.cpp index 20767da0e..e417fec21 100644 --- a/desktop/ui/components/taskbar/start_button.cpp +++ b/desktop/ui/components/taskbar/start_button.cpp @@ -2,8 +2,8 @@ * @file start_button.cpp * @brief Start affordance widget implementation. * - * Renders the leading taskbar tile as a rounded-square bearing a fixed app-grid - * glyph (a 2x2 block of rounded squares). The tile zooms on hover + * Renders the leading taskbar tile as a rounded-square bearing a tinted start + * icon. The tile zooms on hover * (QVariantAnimation), plays a self-drawn press ripple, and emits clicked() on * release. All rendering is QPainter-native, mirroring TaskbarIcon so it builds * everywhere. @@ -17,6 +17,8 @@ #include "start_button.h" +#include "icon_mask.h" + #include "core/theme_manager.h" #include "core/token/material_scheme/cfmaterial_token_literals.h" @@ -42,11 +44,6 @@ constexpr int kHoverDurationMs = 150; ///< Hover zoom duration (ms). constexpr int kRippleDurationMs = 350; ///< Ripple expansion duration (ms). constexpr int kRippleAlpha = 90; ///< Peak ripple overlay alpha. constexpr int kHoverOverlayAlpha = 24; ///< Hover state-layer alpha. - -// App-grid glyph geometry: a 2x2 block of small rounded squares. -constexpr qreal kGlyphCell = 9.0; ///< Edge of one glyph square (px). -constexpr qreal kGlyphGap = 4.0; ///< Gap between glyph squares (px). -constexpr qreal kGlyphRadius = 2.0; ///< Corner radius of a glyph square (px). } // namespace StartButton::StartButton(QWidget* parent) : QWidget(parent) { @@ -55,6 +52,7 @@ StartButton::StartButton(QWidget* parent) : QWidget(parent) { setAutoFillBackground(false); setupAnimations(); applyTheme(); + setToolTip(QStringLiteral("Start")); } StartButton::~StartButton() = default; @@ -96,16 +94,13 @@ void StartButton::paintEvent(QPaintEvent* /*event*/) { p.drawEllipse(ripple_center_, radius, radius); } - // App-grid glyph: 2x2 of small rounded squares, centered on the tile. - const qreal block = 2.0 * kGlyphCell + kGlyphGap; - const QPointF origin(c.x() - block / 2.0, c.y() - block / 2.0); - p.setBrush(foreground_color_); - for (int row = 0; row < 2; ++row) { - for (int col = 0; col < 2; ++col) { - const QRectF sq(origin.x() + col * (kGlyphCell + kGlyphGap), - origin.y() + row * (kGlyphCell + kGlyphGap), kGlyphCell, kGlyphCell); - p.drawRoundedRect(sq, kGlyphRadius, kGlyphRadius); - } + // Start glyph: the tinted icon mask. A missing mask leaves the tile blank + // (no silent 2x2-grid fallback) so a broken resource stays obvious. + if (!icon_mask_.isNull()) { + const qreal glyph = edge * 0.6; + const QRectF glyph_rect(c.x() - glyph / 2.0, c.y() - glyph / 2.0, glyph, glyph); + p.setRenderHint(QPainter::SmoothPixmapTransform, true); + p.drawPixmap(glyph_rect, icon_mask_, QRectF(0, 0, icon_mask_.width(), icon_mask_.height())); } } @@ -150,9 +145,14 @@ void StartButton::applyTheme() { tile_color_ = QColor(0xE7, 0xE0, 0xEC); foreground_color_ = QColor(0x1C, 0x1B, 0x1F); } + refreshIcon(); update(); } +void StartButton::refreshIcon() { + icon_mask_ = tintedIconMask(QStringLiteral(":/cfdesktop/taskbar/start.png"), foreground_color_); +} + void StartButton::startHover(bool entering) { hover_anim_->stop(); hover_anim_->setStartValue(hover_scale_); diff --git a/desktop/ui/components/taskbar/start_button.h b/desktop/ui/components/taskbar/start_button.h index df14004c3..4a0a6215f 100644 --- a/desktop/ui/components/taskbar/start_button.h +++ b/desktop/ui/components/taskbar/start_button.h @@ -4,8 +4,8 @@ * * StartButton is the leading taskbar tile that emits clicked() to open the * AppLauncher popup. It reuses the TaskbarIcon visual language (rounded tile, - * hover zoom, press ripple) but draws a fixed app-grid glyph instead of an - * application initial, and carries no running indicator. + * hover zoom, press ripple) but draws a tinted start icon, and carries no + * running indicator. * * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) * @date 2026-06-26 @@ -17,6 +17,7 @@ #pragma once #include +#include #include #include @@ -31,7 +32,7 @@ namespace cf::desktop::desktop_component { /** * @brief Leading taskbar tile that opens the application launcher. * - * Paints a rounded tile with a fixed app-grid glyph, zooms on hover, plays a + * Paints a rounded tile with a tinted start icon, zooms on hover, plays a * press ripple, and emits clicked() on a left-button release inside the tile. * All colors follow the active Material theme. * @@ -155,6 +156,8 @@ class StartButton final : public QWidget { private: /// @brief Resolves theme colors, then repaints. void applyTheme(); + /// @brief Rebuilds the tinted start-icon mask from the foreground color. + void refreshIcon(); /// @brief Animates the hover scale toward the resting or hovered value. void startHover(bool entering); /// @brief Starts an expanding ripple from a center point. @@ -169,6 +172,7 @@ class StartButton final : public QWidget { QColor tile_color_; ///< Tile fill (surface variant). QColor foreground_color_; ///< Glyph / overlay color (on surface). + QPixmap icon_mask_; ///< Tinted start glyph; null -> draw the 2x2 grid. QVariantAnimation* hover_anim_{nullptr}; ///< Zoom-in/out animation. QVariantAnimation* ripple_anim_{nullptr}; ///< Ripple expansion animation. diff --git a/desktop/ui/components/taskbar/taskbar_icon.cpp b/desktop/ui/components/taskbar/taskbar_icon.cpp index 6de893509..f3435ae83 100644 --- a/desktop/ui/components/taskbar/taskbar_icon.cpp +++ b/desktop/ui/components/taskbar/taskbar_icon.cpp @@ -16,9 +16,10 @@ #include "taskbar_icon.h" +#include "icon_mask.h" + #include "core/theme_manager.h" #include "core/token/material_scheme/cfmaterial_token_literals.h" -#include "core/token/typography/cfmaterial_typography_token_literals.h" #include #include @@ -40,7 +41,6 @@ constexpr qreal kHoverScale = 1.2; ///< Scale factor when hovered. constexpr qreal kIconRadius = 10.0; ///< Tile corner radius (px). constexpr qreal kDotRadius = 2.5; ///< Running-indicator dot radius (px). constexpr qreal kDotOffset = 6.0; ///< Dot offset below the tile (px). -constexpr int kLabelPixelSize = 18; ///< Initial letter font size (px). constexpr int kHoverDurationMs = 150; ///< Hover zoom duration (ms). constexpr int kRippleDurationMs = 350; ///< Ripple expansion duration (ms). constexpr int kRippleAlpha = 90; ///< Peak ripple overlay alpha. @@ -54,12 +54,15 @@ TaskbarIcon::TaskbarIcon(AppEntry entry, QWidget* parent) setAutoFillBackground(false); setupAnimations(); applyTheme(); + setToolTip(entry_.display_name); } TaskbarIcon::~TaskbarIcon() = default; void TaskbarIcon::setEntry(const AppEntry& entry) { entry_ = entry; + setToolTip(entry_.display_name); + refreshIcon(); update(); } @@ -107,13 +110,14 @@ void TaskbarIcon::paintEvent(QPaintEvent* /*event*/) { p.drawEllipse(ripple_center_, radius, radius); } - // Initial letter. - p.setPen(foreground_color_); - p.setFont(label_font_); - const QString letter = entry_.display_name.isEmpty() - ? QStringLiteral("?") - : QString(entry_.display_name.at(0)).toUpper(); - p.drawText(tile, Qt::AlignCenter, letter); + // App glyph: the tinted icon mask. A missing mask leaves the tile blank + // (no silent letter fallback) so a broken resource stays obvious. + if (!icon_mask_.isNull()) { + const qreal glyph = edge * 0.6; + const QRectF glyph_rect(c.x() - glyph / 2.0, c.y() - glyph / 2.0, glyph, glyph); + p.setRenderHint(QPainter::SmoothPixmapTransform, true); + p.drawPixmap(glyph_rect, icon_mask_, QRectF(0, 0, icon_mask_.width(), icon_mask_.height())); + } // Running indicator dot near the tile bottom. if (running_) { @@ -161,19 +165,20 @@ void TaskbarIcon::applyTheme() { tile_color_ = cs.queryColor(SURFACE_VARIANT); foreground_color_ = cs.queryColor(ON_SURFACE); indicator_color_ = cs.queryColor(ON_SURFACE_VARIANT); - label_font_ = theme.font_type().queryTargetFont(TYPOGRAPHY_TITLE_MEDIUM); - label_font_.setPixelSize(kLabelPixelSize); } catch (...) { // Fallback palette when no theme is registered yet. tile_color_ = QColor(0xE7, 0xE0, 0xEC); foreground_color_ = QColor(0x1C, 0x1B, 0x1F); indicator_color_ = QColor(0x49, 0x45, 0x4E); - label_font_ = font(); - label_font_.setPixelSize(kLabelPixelSize); } + refreshIcon(); update(); } +void TaskbarIcon::refreshIcon() { + icon_mask_ = tintedIconMask(entry_.icon_path, foreground_color_); +} + void TaskbarIcon::startHover(bool entering) { hover_anim_->stop(); hover_anim_->setStartValue(hover_scale_); diff --git a/desktop/ui/components/taskbar/taskbar_icon.h b/desktop/ui/components/taskbar/taskbar_icon.h index d46fda3b1..8520818cb 100644 --- a/desktop/ui/components/taskbar/taskbar_icon.h +++ b/desktop/ui/components/taskbar/taskbar_icon.h @@ -3,7 +3,7 @@ * @brief Single application icon widget for the centered taskbar. * * TaskbarIcon renders one launchable application as a rounded-square tile - * bearing the app's initial. It zooms in on hover, plays a self-drawn press + * bearing a tinted app icon. It zooms in on hover, plays a self-drawn press * ripple, and shows a running-state indicator dot. It emits clicked(app_id) * on a left-button release inside the tile. * @@ -19,7 +19,7 @@ #include "app_entry.h" #include -#include +#include #include #include #include @@ -200,6 +200,8 @@ class TaskbarIcon final : public QWidget { private: /// @brief Resolves theme colors and typography, then repaints. void applyTheme(); + /// @brief Rebuilds the tinted icon mask from the entry path and foreground. + void refreshIcon(); /// @brief Animates the hover scale toward the resting or hovered value. void startHover(bool entering); /// @brief Starts an expanding ripple from a center point. @@ -215,9 +217,9 @@ class TaskbarIcon final : public QWidget { bool rippling_{false}; ///< Whether a ripple is animating. QColor tile_color_; ///< Tile fill (surface variant). - QColor foreground_color_; ///< Initial text color (on surface). + QColor foreground_color_; ///< Icon tint color (on surface). QColor indicator_color_; ///< Running dot color (on surface variant). - QFont label_font_; ///< Font used for the initial letter. + QPixmap icon_mask_; ///< Tinted icon pixmap; null -> blank tile (no fallback). QVariantAnimation* hover_anim_{nullptr}; ///< Zoom-in/out animation. QVariantAnimation* ripple_anim_{nullptr}; ///< Ripple expansion animation. diff --git a/desktop/ui/components/taskbar/taskbar_icons.qrc b/desktop/ui/components/taskbar/taskbar_icons.qrc new file mode 100644 index 000000000..34c207be2 --- /dev/null +++ b/desktop/ui/components/taskbar/taskbar_icons.qrc @@ -0,0 +1,9 @@ + + + icons/files.png + icons/terminal.png + icons/settings.png + icons/browser.png + icons/start.png + + diff --git a/desktop/ui/components/window_placement/CMakeLists.txt b/desktop/ui/components/window_placement/CMakeLists.txt new file mode 100644 index 000000000..1f0d255b1 --- /dev/null +++ b/desktop/ui/components/window_placement/CMakeLists.txt @@ -0,0 +1,19 @@ +# Window placement policy: clamps external windows into the desktop work area +# (central rect between top status bar and bottom taskbar). Pure logic over +# IWindow + QRect — no platform branches, no preprocessor conditionals — so it +# stays unit-testable without any backend or display. +add_library(cfdesktop_window_placement STATIC + window_placement_policy.cpp +) + +target_include_directories(cfdesktop_window_placement PUBLIC + $ + $ +) + +target_link_libraries(cfdesktop_window_placement +PUBLIC + Qt6::Core +PRIVATE + cfbase # aex headers pulled in transitively by IWindow.h +) diff --git a/desktop/ui/components/window_placement/window_placement_policy.cpp b/desktop/ui/components/window_placement/window_placement_policy.cpp new file mode 100644 index 000000000..27c9cb0c4 --- /dev/null +++ b/desktop/ui/components/window_placement/window_placement_policy.cpp @@ -0,0 +1,62 @@ +/** + * @file window_placement_policy.cpp + * @brief WindowPlacementPolicy implementation. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-26 + * @version 0.2 + * @since 0.20 + * @ingroup components + */ + +#include "window_placement_policy.h" + +#include "IWindow.h" + +#include +#include + +namespace cf::desktop::placement { + +QRect WindowPlacementPolicy::centerInWorkArea(const QRect& rect, const QRect& work_area) { + if (work_area.isEmpty() || rect.isEmpty()) { + return rect; + } + // Proportional shrink-to-fit: take the smaller axis scale so aspect is + // preserved; never enlarge (scale clamped to 1). + const qreal scale_x = qreal(work_area.width()) / qreal(rect.width()); + const qreal scale_y = qreal(work_area.height()) / qreal(rect.height()); + qreal scale = std::min(scale_x, scale_y); + if (scale > 1.0) { + scale = 1.0; + } + const int w = std::max(1, static_cast(std::round(rect.width() * scale))); + const int h = std::max(1, static_cast(std::round(rect.height() * scale))); + const int x = work_area.x() + (work_area.width() - w) / 2; + const int y = work_area.y() + (work_area.height() - h) / 2; + return {x, y, w, h}; +} + +std::optional WindowPlacementPolicy::computeConstrain(const QRect& current, + const QRect& work_area, bool enabled) { + if (!enabled || work_area.isEmpty() || current.isEmpty()) { + return std::nullopt; + } + // Leave windows already fully inside the work area where they are. + const bool fully_inside = current.x() >= work_area.x() && current.y() >= work_area.y() && + current.right() <= work_area.right() && + current.bottom() <= work_area.bottom(); + if (fully_inside) { + return std::nullopt; + } + return centerInWorkArea(current, work_area); +} + +void WindowPlacementPolicy::constrain(IWindow& window, const QRect& work_area, bool enabled) const { + const auto target = computeConstrain(window.geometry(), work_area, enabled); + if (target.has_value()) { + window.set_geometry(*target); + } +} + +} // namespace cf::desktop::placement diff --git a/desktop/ui/components/window_placement/window_placement_policy.h b/desktop/ui/components/window_placement/window_placement_policy.h new file mode 100644 index 000000000..dff8b1200 --- /dev/null +++ b/desktop/ui/components/window_placement/window_placement_policy.h @@ -0,0 +1,117 @@ +/** + * @file window_placement_policy.h + * @brief Places external windows centered inside the desktop work area. + * + * Real desktop window managers place application windows inside the work area + * (the rect between the top status bar and the bottom taskbar) so they never + * overlap a bar or fly off-screen. WindowPlacementPolicy gives CFDesktop the + * same behavior from the client side: when an external window appears outside + * the work area — or falls outside after the desktop is resized — it is moved + * to the center of the work area, proportionally shrunk to fit when it is + * larger than the work area (aspect preserved). Windows already fully inside + * are left where the app/user put them. + * + * The policy is intentionally pure: it operates only through IWindow (read + * geometry / set geometry) and QRect. There are zero platform branches and zero + * preprocessor conditionals — moving the window is the backend's job, already + * implemented per platform behind IWindow::set_geometry. The placement math is + * exposed as a free static function so it is unit-testable with no backend. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-26 + * @version 0.2 + * @since 0.20 + * @ingroup components + */ + +#pragma once + +#include +#include + +namespace cf::desktop { +class IWindow; +} + +namespace cf::desktop::placement { + +/** + * @brief Places external windows centered in the desktop work area. + * + * Used on window appearance and when the work area changes: any external window + * found outside the work area is re-centered into it (shrunk to fit when too + * large), the way a real desktop WM keeps windows inside the work area. + * + * @ingroup components + */ +class WindowPlacementPolicy { + public: + /** + * @brief Centers @p rect in @p work_area, shrinking to fit (pure math). + * + * If @p rect is larger than @p work_area in either axis it is proportionally + * shrunk (aspect ratio preserved) until it fits; it is never enlarged. The + * result is then centered in @p work_area. An empty @p work_area is treated + * as "no constraint" and @p rect is returned unchanged. The input rect's + * position is ignored — only its size participates. + * + * Exposed as a static free function so the placement math is testable + * without any IWindow or backend. + * + * @param[in] rect The window's current geometry (only size matters). + * @param[in] work_area The work area to center within. + * + * @return The centered, fit-to-work-area rectangle. + * + * @throws None + * @since 0.20 + */ + static QRect centerInWorkArea(const QRect& rect, const QRect& work_area); + + /** + * @brief Decides the geometry to apply to a window, if any (pure). + * + * Encapsulates the full placement decision without touching IWindow, so it + * is unit-testable with no backend. Returns the centered target rectangle + * (see centerInWorkArea()) when @p enabled is true, @p work_area is + * non-empty, @p current is non-empty, and @p current is NOT fully contained + * in @p work_area (i.e. it overlaps a bar or sits off-screen). Returns + * std::nullopt otherwise — in particular windows already fully inside the + * work area are left where they are (no yank). + * + * @param[in] current The window's current geometry. + * @param[in] work_area The work area to place within. + * @param[in] enabled Whether placement is active (runtime toggle). + * + * @return The geometry to set, or std::nullopt when no move is needed. + * + * @throws None + * @since 0.20 + */ + static std::optional computeConstrain(const QRect& current, const QRect& work_area, + bool enabled); + + /** + * @brief Centers @p window into @p work_area when @p enabled and outside. + * + * Reads the window's current geometry; if computeConstrain() yields a target + * that differs from it, applies the result with IWindow::set_geometry. No-op + * when disabled, when the work area is empty, or when the window already + * sits fully inside the work area. + * + * Callers invoke this on window appearance and on work-area change, so the + * policy re-centers windows that fall outside after the desktop is resized + * without disturbing windows the user already placed inside. + * + * @param[in,out] window The window to place. + * @param[in] work_area The work area to place within. + * @param[in] enabled Whether placement is active (runtime toggle). + * + * @throws None + * @note No-op (no set_geometry call) when the window already fits inside. + * @since 0.20 + */ + void constrain(IWindow& window, const QRect& work_area, bool enabled) const; +}; + +} // namespace cf::desktop::placement diff --git a/test/cmake/add_gtest_executable.cmake b/test/cmake/add_gtest_executable.cmake index 5698a35d6..3ffc9e093 100644 --- a/test/cmake/add_gtest_executable.cmake +++ b/test/cmake/add_gtest_executable.cmake @@ -88,6 +88,7 @@ function(add_gtest_executable) # Set working directory to build output dir so DLLs can be found WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/bin" ) + set_tests_properties(${ARG_TEST_NAME} PROPERTIES TIMEOUT 60) # Windows: ensure Qt platform plugins (e.g. offscreen) are discoverable if(WIN32 AND TARGET Qt6::Gui) diff --git a/test/desktop/CMakeLists.txt b/test/desktop/CMakeLists.txt index b17045bf7..d8c6ddb2d 100644 --- a/test/desktop/CMakeLists.txt +++ b/test/desktop/CMakeLists.txt @@ -3,3 +3,9 @@ log_info("test_desktop" "Configuring desktop module tests:") # Add init tests subdirectory add_subdirectory(init) + +# Add frosted backdrop tests subdirectory +add_subdirectory(frosted_backdrop) + +# Add window placement tests subdirectory +add_subdirectory(window_placement) diff --git a/test/desktop/frosted_backdrop/CMakeLists.txt b/test/desktop/frosted_backdrop/CMakeLists.txt new file mode 100644 index 000000000..094e85ead --- /dev/null +++ b/test/desktop/frosted_backdrop/CMakeLists.txt @@ -0,0 +1,66 @@ +# FrostedBackdrop unit tests. +# Custom main() (no gtest_main) to spin up an offscreen QGuiApplication so +# QPixmap/QPainter work in headless / CI environments. +add_executable(frosted_backdrop_test + frosted_backdrop_test.cpp +) + +target_include_directories(frosted_backdrop_test PRIVATE + ${CMAKE_SOURCE_DIR}/desktop/ui/components +) + +target_link_libraries(frosted_backdrop_test PRIVATE + cfdesktop_frosted_backdrop + Qt6::Gui + Qt6::Widgets + GTest::gtest +) + +set_target_properties(frosted_backdrop_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/test/bin" +) + +add_test(NAME frosted_backdrop_test COMMAND frosted_backdrop_test) +set_tests_properties(frosted_backdrop_test PROPERTIES + LABELS "desktop;unit;components" + TIMEOUT 60 + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/test/bin" +) + +log_info("frosted_backdrop_tests" " - frosted_backdrop_test") + +if(WIN32) + qt_add_windows_deploy(frosted_backdrop_test AUTO_DEPLOY INSTALL_DEPLOY) + + set(_CF_QT_OFFSCREEN_PLUGIN_DIR + "$/plugins/platforms") + + if(TARGET Qt6::QOffscreenIntegrationPlugin) + add_custom_command(TARGET frosted_backdrop_test POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory + "${_CF_QT_OFFSCREEN_PLUGIN_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "${_CF_QT_OFFSCREEN_PLUGIN_DIR}" + COMMENT "Copying Qt offscreen platform plugin for frosted_backdrop_test" + VERBATIM + ) + else() + get_filename_component(_CF_QT_PLATFORMS_DIR + "${Qt6Gui_DIR}/../../../plugins/platforms" ABSOLUTE) + add_custom_command(TARGET frosted_backdrop_test POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory + "${_CF_QT_OFFSCREEN_PLUGIN_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_CF_QT_PLATFORMS_DIR}/qoffscreen.dll" + "${_CF_QT_OFFSCREEN_PLUGIN_DIR}" + COMMENT "Copying Qt offscreen platform plugin for frosted_backdrop_test" + VERBATIM + ) + endif() + + set_tests_properties(frosted_backdrop_test PROPERTIES + ENVIRONMENT + "QT_QPA_PLATFORM=offscreen;QT_QPA_PLATFORM_PLUGIN_PATH=${CMAKE_BINARY_DIR}/test/bin/plugins/platforms" + ) +endif() diff --git a/test/desktop/frosted_backdrop/frosted_backdrop_test.cpp b/test/desktop/frosted_backdrop/frosted_backdrop_test.cpp new file mode 100644 index 000000000..82741d9ea --- /dev/null +++ b/test/desktop/frosted_backdrop/frosted_backdrop_test.cpp @@ -0,0 +1,154 @@ +/** + * @file frosted_backdrop_test.cpp + * @brief GoogleTest unit tests for the FrostedBackdrop renderer. + * + * Covers: null-source handling, output sizing, determinism across instances, + * cache validity/hit, and that changing the tint, strip rect, or source image + * forces a rebuild. A custom main() spins up an offscreen QGuiApplication so + * QPixmap/QPainter work without a display (CI / headless friendly). + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-26 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "frosted_backdrop/frosted_backdrop.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +/// @brief Deterministic params (grain disabled so renders are pixel-stable). +cf::desktop::FrostedParams defaultParams() { + cf::desktop::FrostedParams p; + p.tint = QColor(255, 255, 255); + p.tint_alpha = 0.60; + p.blur_radius_px = 12; + p.downsample = 4; + p.box_passes = 2; + p.enable_grain = false; + p.top_highlight = false; + return p; +} + +/// @brief A 400x80 RGB32 image: red left half, blue right half. +QImage twoColorSource() { + QImage src(400, 80, QImage::Format_RGB32); + src.fill(Qt::red); + for (int y = 0; y < src.height(); ++y) { + for (int x = 200; x < src.width(); ++x) { + src.setPixel(x, y, qRgb(0, 0, 255)); + } + } + return src; +} + +} // namespace + +TEST(FrostedBackdrop, NullSourceReturnsNullPixmap) { + cf::desktop::FrostedBackdrop fb; + const QPixmap pm = fb.render(QImage{}, QRect(0, 0, 100, 40), 1.0, defaultParams()); + EXPECT_TRUE(pm.isNull()); +} + +TEST(FrostedBackdrop, EmptyStripReturnsNullPixmap) { + cf::desktop::FrostedBackdrop fb; + const QImage src = twoColorSource(); + const QPixmap pm = fb.render(src, QRect(0, 0, 0, 0), 1.0, defaultParams()); + EXPECT_TRUE(pm.isNull()); +} + +TEST(FrostedBackdrop, OutputSizeMatchesStrip) { + cf::desktop::FrostedBackdrop fb; + const QImage src = twoColorSource(); + const QRect strip(0, 0, 200, 40); + const QPixmap pm = fb.render(src, strip, 1.0, defaultParams()); + ASSERT_FALSE(pm.isNull()); + EXPECT_EQ(pm.size(), strip.size()); +} + +TEST(FrostedBackdrop, DeterministicAcrossInstances) { + cf::desktop::FrostedBackdrop fb1; + cf::desktop::FrostedBackdrop fb2; + const QImage src = twoColorSource(); + const QRect strip(0, 0, 200, 40); + const auto p = defaultParams(); + // Two independent renderers with identical inputs produce identical pixels + // (grain disabled); this is the determinism contract callers rely on. + EXPECT_EQ(fb1.render(src, strip, 1.0, p).toImage(), fb2.render(src, strip, 1.0, p).toImage()); +} + +TEST(FrostedBackdrop, CacheReportsValidAfterRender) { + cf::desktop::FrostedBackdrop fb; + const QImage src = twoColorSource(); + const QRect strip(0, 0, 200, 40); + const auto p = defaultParams(); + EXPECT_FALSE(fb.isCacheValid(src, strip, 1.0, p)); + (void)fb.render(src, strip, 1.0, p); + EXPECT_TRUE(fb.isCacheValid(src, strip, 1.0, p)); +} + +TEST(FrostedBackdrop, RepeatedRenderIsCacheHit) { + cf::desktop::FrostedBackdrop fb; + const QImage src = twoColorSource(); + const QRect strip(0, 0, 200, 40); + const auto p = defaultParams(); + const QPixmap first = fb.render(src, strip, 1.0, p); + const QPixmap second = fb.render(src, strip, 1.0, p); + ASSERT_FALSE(first.isNull()); + // A cache hit returns the cached data (shared) -> identical pixmap key. + EXPECT_EQ(first.cacheKey(), second.cacheKey()); +} + +TEST(FrostedBackdrop, ChangingTintForcesRebuild) { + cf::desktop::FrostedBackdrop fb; + const QImage src = twoColorSource(); + const QRect strip(0, 0, 200, 40); + auto p = defaultParams(); + const QPixmap a = fb.render(src, strip, 1.0, p); + p.tint = QColor(0, 0, 0); + EXPECT_FALSE(fb.isCacheValid(src, strip, 1.0, p)); + const QPixmap b = fb.render(src, strip, 1.0, p); + EXPECT_NE(a.cacheKey(), b.cacheKey()); +} + +TEST(FrostedBackdrop, ChangingStripForcesRebuild) { + cf::desktop::FrostedBackdrop fb; + const QImage src = twoColorSource(); + const QRect strip_a(0, 0, 200, 40); + const QRect strip_b(10, 10, 200, 40); + const auto p = defaultParams(); + const QPixmap a = fb.render(src, strip_a, 1.0, p); + const QPixmap b = fb.render(src, strip_b, 1.0, p); + EXPECT_NE(a.cacheKey(), b.cacheKey()); +} + +TEST(FrostedBackdrop, ChangingSourceForcesRebuild) { + cf::desktop::FrostedBackdrop fb; + QImage src = twoColorSource(); + const QRect strip(0, 0, 200, 40); + const auto p = defaultParams(); + const QPixmap a = fb.render(src, strip, 1.0, p); + src.fill(Qt::green); // New content -> new QImage::cacheKey(). + EXPECT_FALSE(fb.isCacheValid(src, strip, 1.0, p)); + const QPixmap b = fb.render(src, strip, 1.0, p); + EXPECT_NE(a.cacheKey(), b.cacheKey()); +} + +int main(int argc, char** argv) { + // QPixmap/QPainter need a QGuiApplication; offscreen avoids a display. + qputenv("QT_QPA_PLATFORM", "offscreen"); + QGuiApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/desktop/init/CMakeLists.txt b/test/desktop/init/CMakeLists.txt index 567cbdea8..d484fe4f4 100644 --- a/test/desktop/init/CMakeLists.txt +++ b/test/desktop/init/CMakeLists.txt @@ -39,6 +39,7 @@ endif() add_test(NAME init_session_chain_test COMMAND init_session_chain_test) set_tests_properties(init_session_chain_test PROPERTIES LABELS "desktop;init;functional" + TIMEOUT 60 WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/test/bin" ) diff --git a/test/desktop/window_placement/CMakeLists.txt b/test/desktop/window_placement/CMakeLists.txt new file mode 100644 index 000000000..ca2d9e8dc --- /dev/null +++ b/test/desktop/window_placement/CMakeLists.txt @@ -0,0 +1,30 @@ +# WindowPlacementPolicy unit tests. +# Pure geometry tests (clampToWorkArea + computeConstrain) — no IWindow, no +# backend, no display — so a plain gtest_main with no QGuiApplication suffices. +add_executable(window_placement_test + window_placement_test.cpp +) + +target_include_directories(window_placement_test PRIVATE + ${CMAKE_SOURCE_DIR}/desktop/ui/components +) + +target_link_libraries(window_placement_test PRIVATE + cfdesktop_window_placement + Qt6::Core + GTest::gtest + GTest::gtest_main +) + +set_target_properties(window_placement_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/test/bin" +) + +add_test(NAME window_placement_test COMMAND window_placement_test) +set_tests_properties(window_placement_test PROPERTIES + LABELS "desktop;unit;components" + TIMEOUT 60 + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/test/bin" +) + +log_info("window_placement_tests" " - window_placement_test") diff --git a/test/desktop/window_placement/window_placement_test.cpp b/test/desktop/window_placement/window_placement_test.cpp new file mode 100644 index 000000000..9aad3a83a --- /dev/null +++ b/test/desktop/window_placement/window_placement_test.cpp @@ -0,0 +1,110 @@ +/** + * @file window_placement_test.cpp + * @brief GoogleTest unit tests for WindowPlacementPolicy. + * + * Covers the pure geometry seam (centerInWorkArea + computeConstrain) with no + * IWindow, no backend, and no display — the full placement decision is tested + * as a pure function of QRect inputs. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-26 + * @version 0.2 + * @since 0.20 + * @ingroup components + */ + +#include "window_placement/window_placement_policy.h" + +#include +#include + +namespace { + +using cf::desktop::placement::WindowPlacementPolicy; + +/// Work area used throughout: x in [0,999], y in [48,747], center (500,398). +const QRect kWork{0, 48, 1000, 700}; + +/// @brief Asserts a QRect matches the given components with readable failures. +void expectRect(const QRect& actual, int x, int y, int w, int h) { + EXPECT_EQ(actual.x(), x); + EXPECT_EQ(actual.y(), y); + EXPECT_EQ(actual.width(), w); + EXPECT_EQ(actual.height(), h); +} + +} // namespace + +// ── centerInWorkArea (size-driven, aspect-preserving, ignores input position) ─ + +TEST(WindowPlacement, Center_FitsSmaller_CenteredIgnoringInputPos) { + // Input position (200,200) is ignored; result is centered by size. + expectRect(WindowPlacementPolicy::centerInWorkArea({200, 200, 200, 200}, kWork), 400, 298, 200, + 200); +} + +TEST(WindowPlacement, Center_LargerBothAxes_ProportionalShrinkCentered) { + expectRect(WindowPlacementPolicy::centerInWorkArea({0, 0, 2000, 2000}, kWork), 150, 48, 700, + 700); +} + +TEST(WindowPlacement, Center_TallerThanWork_HeightClampedAspectKept) { + // 500x1400 -> scale 0.5 -> 250x700, centered. + expectRect(WindowPlacementPolicy::centerInWorkArea({0, 0, 500, 1400}, kWork), 375, 48, 250, + 700); +} + +TEST(WindowPlacement, Center_WiderThanWork_WidthClampedAspectKept) { + // 2500x350 -> scale 0.4 -> 1000x140, centered. + expectRect(WindowPlacementPolicy::centerInWorkArea({0, 0, 2500, 350}, kWork), 0, 328, 1000, + 140); +} + +TEST(WindowPlacement, Center_ExactWorkSize_FillsWork) { + expectRect(WindowPlacementPolicy::centerInWorkArea({0, 0, 1000, 700}, kWork), 0, 48, 1000, 700); +} + +TEST(WindowPlacement, Center_EmptyWorkArea_Unchanged) { + expectRect(WindowPlacementPolicy::centerInWorkArea({500, 500, 200, 200}, QRect{}), 500, 500, + 200, 200); +} + +// ── computeConstrain (the decision seam: only outside windows get centered) ── + +TEST(WindowPlacement, Constrain_Disabled_ReturnsNullopt) { + EXPECT_FALSE( + WindowPlacementPolicy::computeConstrain({-50, -50, 200, 200}, kWork, false).has_value()); +} + +TEST(WindowPlacement, Constrain_EmptyWorkArea_ReturnsNullopt) { + EXPECT_FALSE( + WindowPlacementPolicy::computeConstrain({100, 100, 200, 200}, QRect{}, true).has_value()); +} + +TEST(WindowPlacement, Constrain_EmptyCurrent_ReturnsNullopt) { + EXPECT_FALSE(WindowPlacementPolicy::computeConstrain(QRect{}, kWork, true).has_value()); +} + +TEST(WindowPlacement, Constrain_FullyInside_ReturnsNullopt_NoYank) { + // Already inside the work area -> leave where it is. + EXPECT_FALSE( + WindowPlacementPolicy::computeConstrain({100, 100, 200, 200}, kWork, true).has_value()); +} + +TEST(WindowPlacement, Constrain_ExactWorkFill_ReturnsNullopt) { + EXPECT_FALSE( + WindowPlacementPolicy::computeConstrain({0, 48, 1000, 700}, kWork, true).has_value()); +} + +TEST(WindowPlacement, Constrain_OutsideRight_ReturnsCenteredTarget) { + // {900,600,300,300}: right=1199 > 999 -> outside -> centered 300x300. + auto target = WindowPlacementPolicy::computeConstrain({900, 600, 300, 300}, kWork, true); + ASSERT_TRUE(target.has_value()); + expectRect(*target, 350, 248, 300, 300); +} + +TEST(WindowPlacement, Constrain_LargerThanWork_ReturnsShrunkCenteredTarget) { + auto target = WindowPlacementPolicy::computeConstrain({0, 0, 2000, 2000}, kWork, true); + ASSERT_TRUE(target.has_value()); + expectRect(*target, 150, 48, 700, 700); +} diff --git a/test/logger/CMakeLists.txt b/test/logger/CMakeLists.txt index faa43ce16..cc3fb2aa5 100644 --- a/test/logger/CMakeLists.txt +++ b/test/logger/CMakeLists.txt @@ -41,6 +41,7 @@ add_gtest_executable( LOG_MODULE logger_tests INCLUDE_DIRS ${MOCK_INCLUDE_DIR} ) +set_tests_properties(logger_error_handling_test PROPERTIES TIMEOUT 180) # ============================================================================= # CFLogger unit tests - logger_formatter_test