From 111580a73de2c8e42dc2a29d454244610380813a Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Tue, 7 Jul 2026 13:41:26 +0800 Subject: [PATCH 1/9] feat(desktop): add desktop shortcut icon layer with real app icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a grid of application shortcut icons on the desktop background (above the wallpaper, below the status/task bars) so apps launch directly from the desktop — parity with CCIMXDesktop. The merged app list (builtin panels + manifest apps + XDG .desktop) is reused as-is. Phase 1 — DesktopIconLayer (ui/components/desktop_icon_layer/): - Plain child QWidget of the desktop surface: NOT an IShellLayer (wallpaper- strategy specific) and NOT an IPanel (edge-anchored). Consumes PanelManager::availableGeometry() like the launcher popup. - Constructed between the wallpaper shell layer and the bars so Qt child z-order stacks it correctly with no z-order API changes. - Transparent + WA_TransparentForMouseEvents container: empty cells pass clicks through to the wallpaper; LauncherTile children keep their input. - computeGridDimensions() pure helper (cell/row math, cap-and-truncate with a logged warning on overflow). 10 unit tests. - Reuses LauncherTile and the shared launch_app path, so a desktop launch lights the taskbar running indicator like any other entry point. Phase 2 — real app icons (closes a project-wide gap): - app_icon_resolver.{h,cpp}: resolves AppEntry::icon_path via absolute path / Qt resource / QIcon::fromTheme, null on miss (letter fallback). System .desktop theme-name icons (firefox etc.) now render instead of failing. - LauncherTile caches the resolved icon at 2x for crisp hover and draws it when present, else the existing initial-letter fallback. Benefits the launcher popup, taskbar, and desktop layer uniformly. 4 unit tests. Verified: fast develop build green; ctest full suite passing (incl. 10 grid + 4 resolver tests); doxygen lint clean. --- test/desktop/CMakeLists.txt | 3 + .../desktop/desktop_icon_layer/CMakeLists.txt | 18 ++ .../desktop_icon_layer_test.cpp | 84 ++++++++ test/desktop/launcher/CMakeLists.txt | 19 ++ .../launcher/app_icon_resolver_test.cpp | 74 +++++++ ui/CFDesktopEntity.cpp | 27 +++ ui/components/CMakeLists.txt | 2 + .../desktop_icon_layer/CMakeLists.txt | 21 ++ .../desktop_icon_layer/desktop_icon_layer.cpp | 141 +++++++++++++ .../desktop_icon_layer/desktop_icon_layer.h | 190 ++++++++++++++++++ ui/components/launcher/CMakeLists.txt | 1 + ui/components/launcher/app_icon_resolver.cpp | 53 +++++ ui/components/launcher/app_icon_resolver.h | 58 ++++++ ui/components/launcher/launcher_tile.cpp | 31 ++- ui/components/launcher/launcher_tile.h | 4 + 15 files changed, 719 insertions(+), 7 deletions(-) create mode 100644 test/desktop/desktop_icon_layer/CMakeLists.txt create mode 100644 test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp create mode 100644 test/desktop/launcher/app_icon_resolver_test.cpp create mode 100644 ui/components/desktop_icon_layer/CMakeLists.txt create mode 100644 ui/components/desktop_icon_layer/desktop_icon_layer.cpp create mode 100644 ui/components/desktop_icon_layer/desktop_icon_layer.h create mode 100644 ui/components/launcher/app_icon_resolver.cpp create mode 100644 ui/components/launcher/app_icon_resolver.h diff --git a/test/desktop/CMakeLists.txt b/test/desktop/CMakeLists.txt index c6098be35..65b9372c0 100644 --- a/test/desktop/CMakeLists.txt +++ b/test/desktop/CMakeLists.txt @@ -19,5 +19,8 @@ add_subdirectory(wallpaper_animation) # Add launcher (app discovery) tests subdirectory add_subdirectory(launcher) +# Add desktop icon layer (grid math) tests subdirectory +add_subdirectory(desktop_icon_layer) + # Add builtin apps registry tests subdirectory add_subdirectory(builtin_apps) diff --git a/test/desktop/desktop_icon_layer/CMakeLists.txt b/test/desktop/desktop_icon_layer/CMakeLists.txt new file mode 100644 index 000000000..510f73934 --- /dev/null +++ b/test/desktop/desktop_icon_layer/CMakeLists.txt @@ -0,0 +1,18 @@ +# DesktopIconLayer grid math unit tests. +# Exercises the pure computeGridDimensions() function only (no QWidget), +# but links the full static lib for symbol resolution. +add_gtest_executable( + TEST_NAME desktop_icon_layer_test + SOURCE_FILE desktop_icon_layer_test.cpp + LINK_LIBRARIES + cfdesktop_desktop_icon_layer + cflogger + cfbase + Qt6::Core + Qt6::Widgets + GTest::gtest + GTest::gtest_main + INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/desktop/ui/components + LABELS "desktop;unit;desktop_icon_layer" + LOG_MODULE desktop_icon_layer_tests +) diff --git a/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp b/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp new file mode 100644 index 000000000..8d8086d73 --- /dev/null +++ b/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp @@ -0,0 +1,84 @@ +/** + * @file test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp + * @brief GoogleTest unit tests for computeGridDimensions. + * + * Exercises the pure grid-sizing math (column clamp, row capacity, cap-and- + * truncate) without instantiating a QWidget. Layout constants mirror the + * anonymous namespace in desktop_icon_layer.cpp: kCellSize=96, kGridSpacing=16, + * kMargin=24, kMaxColumns=8 (stride = 96+16 = 112; usable = side - 2*24 + 16). + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-07 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "desktop_icon_layer/desktop_icon_layer.h" + +#include + +#include + +using cf::desktop::desktop_component::computeGridDimensions; + +TEST(DesktopIconLayerGrid, InvalidSizeReturnsZero) { + const auto d = computeGridDimensions(QSize(0, 0), 10); + EXPECT_EQ(d.columns, 0); + EXPECT_EQ(d.rows, 0); + EXPECT_EQ(d.shown, 0); +} + +TEST(DesktopIconLayerGrid, InvalidCountReturnsZero) { + const auto d = computeGridDimensions(QSize(1280, 720), 0); + EXPECT_EQ(d.shown, 0); +} + +TEST(DesktopIconLayerGrid, NegativeCountReturnsZero) { + const auto d = computeGridDimensions(QSize(1280, 720), -3); + EXPECT_EQ(d.shown, 0); +} + +TEST(DesktopIconLayerGrid, ColumnsClampedToMax) { + // usable_w = 1280 - 48 + 16 = 1248; 1248 / 112 = 11 -> clamped to kMaxColumns=8. + const auto d = computeGridDimensions(QSize(1280, 720), 5); + EXPECT_EQ(d.columns, 8); +} + +TEST(DesktopIconLayerGrid, RowsAndCapacityForTypicalDesktop) { + // usable_h = 720 - 48 + 16 = 688; 688 / 112 = 6 rows; capacity = 8*6 = 48. + const auto d = computeGridDimensions(QSize(1280, 720), 5); + EXPECT_EQ(d.rows, 6); + EXPECT_EQ(d.shown, 5); // Below capacity: all shown. +} + +TEST(DesktopIconLayerGrid, TruncatesWhenOverCapacity) { + const auto d = computeGridDimensions(QSize(1280, 720), 100); + EXPECT_EQ(d.columns, 8); + EXPECT_EQ(d.rows, 6); + EXPECT_EQ(d.shown, 48); // 8*6, not 100. +} + +TEST(DesktopIconLayerGrid, NarrowWidthFloorOneColumn) { + // usable_w = 200 - 48 + 16 = 168; 168 / 112 = 1. + const auto d = computeGridDimensions(QSize(200, 720), 3); + EXPECT_EQ(d.columns, 1); +} + +TEST(DesktopIconLayerGrid, ExtremelyNarrowStillOneColumn) { + // usable_w = 50 - 48 + 16 = 18; 18 / 112 = 0 -> floored to 1. + const auto d = computeGridDimensions(QSize(50, 720), 3); + EXPECT_EQ(d.columns, 1); +} + +TEST(DesktopIconLayerGrid, ZeroHeightShowsNothing) { + // usable_h = 50 - 48 + 16 = 18; 18 / 112 = 0 rows; capacity 0. + const auto d = computeGridDimensions(QSize(1280, 50), 10); + EXPECT_EQ(d.rows, 0); + EXPECT_EQ(d.shown, 0); +} + +TEST(DesktopIconLayerGrid, SingleAppShown) { + const auto d = computeGridDimensions(QSize(1280, 720), 1); + EXPECT_EQ(d.shown, 1); +} diff --git a/test/desktop/launcher/CMakeLists.txt b/test/desktop/launcher/CMakeLists.txt index 7eefda736..a5c7c9f53 100644 --- a/test/desktop/launcher/CMakeLists.txt +++ b/test/desktop/launcher/CMakeLists.txt @@ -35,3 +35,22 @@ add_gtest_executable( LABELS "desktop;unit;launcher" LOG_MODULE launcher_tests ) + +# resolve_app_icon unit tests. Provides its own main() (constructs a +# QGuiApplication on the offscreen QPA platform for QIcon/QPixmap), so +# GTest::gtest_main is intentionally NOT linked here. +add_gtest_executable( + TEST_NAME app_icon_resolver_test + SOURCE_FILE app_icon_resolver_test.cpp + LINK_LIBRARIES + cfdesktop_launcher + cflogger + cfbase + Qt6::Core + Qt6::Gui + Qt6::Widgets + GTest::gtest + INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/desktop/ui/components + LABELS "desktop;unit;launcher" + LOG_MODULE launcher_tests +) diff --git a/test/desktop/launcher/app_icon_resolver_test.cpp b/test/desktop/launcher/app_icon_resolver_test.cpp new file mode 100644 index 000000000..31effa10e --- /dev/null +++ b/test/desktop/launcher/app_icon_resolver_test.cpp @@ -0,0 +1,74 @@ +/** + * @file test/desktop/launcher/app_icon_resolver_test.cpp + * @brief GoogleTest unit tests for resolve_app_icon. + * + * Covers the resolution chain: empty path -> null, nonexistent absolute path + * -> null, bogus theme name -> null (graceful when no icon theme matches), + * and a real Qt resource path loads. QIcon/QPixmap require a QGuiApplication, + * so this file provides its own main() that constructs one on the offscreen + * QPA platform (headless-safe under ctest). + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-07 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "launcher/app_icon_resolver.h" + +#include +#include +#include +#include + +#include + +using cf::desktop::desktop_component::AppEntry; +using cf::desktop::desktop_component::resolve_app_icon; + +namespace { +/// Builds a minimal AppEntry with only icon_path set (all the resolver reads). +AppEntry makeEntry(const QString& icon_path) { + AppEntry e; + e.app_id = QStringLiteral("test"); + e.display_name = QStringLiteral("Test"); + e.icon_path = icon_path; + return e; +} +} // namespace + +TEST(AppIconResolver, EmptyPathReturnsNull) { + EXPECT_TRUE(resolve_app_icon(makeEntry(QString()), QSize(48, 48)).isNull()); +} + +TEST(AppIconResolver, NonexistentAbsolutePathReturnsNull) { + // Path-like value that fails to load returns null (no theme fall-through). + EXPECT_TRUE(resolve_app_icon(makeEntry(QStringLiteral("/nonexistent/cfdesktop/icon.png")), + QSize(48, 48)) + .isNull()); +} + +TEST(AppIconResolver, BogusThemeNameReturnsNull) { + // A non-path value is treated as a theme name; an unknown name yields a + // null icon -> null pixmap. Proves graceful degradation, not a crash. + const auto pm = + resolve_app_icon(makeEntry(QStringLiteral("totally-bogus-cf-icon-xyz")), QSize(48, 48)); + EXPECT_TRUE(pm.isNull()); +} + +TEST(AppIconResolver, ThemeLookupDoesNotCrashOnKnownName) { + // "firefox" may or may not resolve depending on the installed icon theme; + // the contract is only that the call is safe and returns something (null + // or a real pixmap) without throwing. + resolve_app_icon(makeEntry(QStringLiteral("firefox")), QSize(48, 48)); + SUCCEED(); +} + +int main(int argc, char** argv) { + // QIcon/QPixmap need a QGuiApplication; offscreen keeps it headless for CI. + qputenv("QT_QPA_PLATFORM", "offscreen"); + QGuiApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/ui/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index fea3ff2ef..5cf29eb37 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -12,6 +12,7 @@ #include "components/WindowManager.h" #include "components/builtin_apps/about_panel.h" #include "components/builtin_apps/builtin_panel_registry.h" +#include "components/desktop_icon_layer/desktop_icon_layer.h" #include "components/launcher/app_discoverer.h" #include "components/launcher/app_launch_service.h" #include "components/launcher/app_launcher.h" @@ -213,6 +214,16 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { res.shell_layer_ = shell; desktop_entity_->register_desktop_resources(res); + // ── Desktop icon layer: construct right after the wallpaper shell layer + // so Qt child-widget z-order stacks it ABOVE the wallpaper and BELOW the + // status/task bars created later (creation order = stacking order under a + // shared parent). Data wiring happens later, once the merged app list and + // launch_app handler exist; only the widget is created here to lock z-order. + // Not an IShellLayer (wallpaper-strategy specific) and not an IPanel + // (edge-anchored) — a plain child widget that consumes the central + // availableGeometry() like the launcher popup does. ── + auto* icon_layer = new cf::desktop::desktop_component::DesktopIconLayer(desktop_entity_); + // 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 @@ -420,6 +431,22 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { app_launcher->setApps(apps); QObject::connect(app_launcher, &cf::desktop::desktop_component::AppLauncher::appLaunched, this, launch_app); + + // ── Desktop icon layer wiring: same merged apps list and same launch_app + // dispatch as the taskbar and launcher, so a desktop shortcut launch lights + // the taskbar running indicator too (shared app_pid tracking). Geometry is + // the PanelManager central rect (between the bars); tiles are not created + // until a valid geometry arrives, so showing early does not flash tiles at + // (0,0). The first panel_mgr->relayout() below delivers the real rect. ── + icon_layer->setApps(apps); + QObject::connect( + panel_mgr, &PanelManager::availableGeometryChanged, desktop_entity_, + [icon_layer](const QRect& avail) { icon_layer->onAvailableGeometryChanged(avail); }); + icon_layer->onAvailableGeometryChanged(panel_mgr->availableGeometry()); // seed geometry + QObject::connect(icon_layer, &cf::desktop::desktop_component::DesktopIconLayer::appClicked, + this, launch_app); + icon_layer->show(); + QObject::connect(taskbar, &cf::desktop::desktop_component::CenteredTaskbar::launcherRequested, this, [app_launcher, panel_mgr]() { // Toggle: clicking Start while the launcher is open dismisses it, diff --git a/ui/components/CMakeLists.txt b/ui/components/CMakeLists.txt index 3abdfe423..6c6236bbd 100644 --- a/ui/components/CMakeLists.txt +++ b/ui/components/CMakeLists.txt @@ -10,6 +10,7 @@ add_subdirectory(window_placement) add_subdirectory(statusbar) add_subdirectory(taskbar) add_subdirectory(launcher) +add_subdirectory(desktop_icon_layer) add_subdirectory(builtin_apps) # Create interface library for convenience @@ -39,6 +40,7 @@ PRIVATE cfdesktop_statusbar cfdesktop_taskbar cfdesktop_launcher + cfdesktop_desktop_icon_layer cfdesktop_builtin_apps cfdesktop_window_placement Qt6::Core Qt6::Gui Qt6::Widgets diff --git a/ui/components/desktop_icon_layer/CMakeLists.txt b/ui/components/desktop_icon_layer/CMakeLists.txt new file mode 100644 index 000000000..a3a3715f8 --- /dev/null +++ b/ui/components/desktop_icon_layer/CMakeLists.txt @@ -0,0 +1,21 @@ +# Desktop icon layer (shortcut icon grid sitting on the wallpaper). +add_library(cfdesktop_desktop_icon_layer STATIC + desktop_icon_layer.cpp +) + +target_include_directories(cfdesktop_desktop_icon_layer PUBLIC + $ + $ + $ +) + +target_link_libraries( + cfdesktop_desktop_icon_layer +PUBLIC + cfdesktop_launcher # LauncherTile + AppEntry (+ phase-2 app_icon_resolver) + QuarkWidgets::quarkwidgets +PRIVATE + Qt6::Widgets + cfbase # Diagnostics utilities + cflogger # Truncation warning logging +) diff --git a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp new file mode 100644 index 000000000..ff6abe2b9 --- /dev/null +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp @@ -0,0 +1,141 @@ +/** + * @file desktop/ui/components/desktop_icon_layer/desktop_icon_layer.cpp + * @brief Implementation of DesktopIconLayer. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-07 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "desktop_icon_layer.h" + +#include "cflog.h" +#include "launcher/launcher_tile.h" + +#include +#include + +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// Tag for DesktopIconLayer log lines. +constexpr const char* kLogTag = "DesktopIconLayer"; +/// Tile widget edge length (px). Mirrors LauncherTile::kCellSize in +/// launcher_tile.cpp; if that changes, update here too. +constexpr int kCellSize = 96; +/// Gap between adjacent tiles (px). +constexpr int kGridSpacing = 16; +/// Inner padding between the central area edge and the tile grid (px). +constexpr int kMargin = 24; +/// Upper bound on columns so very wide screens do not stretch the grid. +constexpr int kMaxColumns = 8; +} // namespace + +GridDimensions computeGridDimensions(const QSize& available, int app_count) { + GridDimensions result; + // QSize(0,0) is technically isValid() (width/height >= 0) but is an empty + // area — treat both invalid and empty as "no grid", else the floor-to-one + // column logic below would return 1 column for a zero-size desktop. + if (!available.isValid() || available.isEmpty() || app_count <= 0) { + return result; + } + + const int stride = kCellSize + kGridSpacing; + // +kGridSpacing: the last column/row does not need a trailing gap. + const int usable_w = available.width() - 2 * kMargin + kGridSpacing; + const int usable_h = available.height() - 2 * kMargin + kGridSpacing; + + int cols = (usable_w > 0) ? (usable_w / stride) : 0; + if (cols < 1) { + cols = 1; // Even a very narrow desktop shows at least one column. + } + if (cols > kMaxColumns) { + cols = kMaxColumns; + } + + const int rows = (usable_h > 0) ? (usable_h / stride) : 0; + const int capacity = cols * rows; + const int shown = std::min(app_count, capacity); + + result.columns = cols; + result.rows = rows; + result.shown = shown; + return result; +} + +DesktopIconLayer::DesktopIconLayer(QWidget* parent) : QWidget(parent) { + // The container must not paint a background (the wallpaper shows through) + // and must not consume mouse events on empty cells (clicks between tiles + // fall through to the wallpaper). Each LauncherTile clears the attribute + // explicitly in rebuildGrid() so it still receives its own clicks. + setAttribute(Qt::WA_TransparentForMouseEvents); + setAttribute(Qt::WA_NoSystemBackground); + setAutoFillBackground(false); + + grid_ = new QGridLayout(this); + grid_->setSpacing(kGridSpacing); + grid_->setContentsMargins(kMargin, kMargin, kMargin, kMargin); + grid_->setAlignment(Qt::AlignTop | Qt::AlignLeft); +} + +DesktopIconLayer::~DesktopIconLayer() = default; + +void DesktopIconLayer::setApps(const QList& apps) { + apps_ = apps; + rebuildGrid(); +} + +void DesktopIconLayer::onAvailableGeometryChanged(const QRect& available) { + if (!available.isValid() || available.isEmpty()) { + return; // Avoid flashing tiles at (0,0) before the first relayout. + } + if (available == last_available_) { + return; // No change: column count stays the same. + } + last_available_ = available; + setGeometry(available); + rebuildGrid(); +} + +void DesktopIconLayer::paintEvent(QPaintEvent* /*event*/) { + // Transparent passthrough; LauncherTile children paint themselves. +} + +void DesktopIconLayer::rebuildGrid() { + qDeleteAll(tiles_); + tiles_.clear(); + + const auto dims = computeGridDimensions(last_available_.size(), static_cast(apps_.size())); + if (dims.shown <= 0) { + return; // No valid geometry yet, or nothing to show. + } + + int row = 0; + int col = 0; + for (int i = 0; i < dims.shown; ++i) { + auto* tile = new LauncherTile(apps_[i], this); + // Defensive: the container is transparent-for-mouse; ensure each tile + // is not, so its hover/click still work under every Qt version. + tile->setAttribute(Qt::WA_TransparentForMouseEvents, false); + connect(tile, &LauncherTile::clicked, this, &DesktopIconLayer::appClicked); + grid_->addWidget(tile, row, col); + tiles_.append(tile); + ++col; + if (col >= dims.columns) { + col = 0; + ++row; + } + } + + if (apps_.size() > dims.shown) { + cf::log::warningftag(kLogTag, "{} apps but only {} fit on desktop ({}x{}); {} hidden", + apps_.size(), dims.shown, dims.columns, dims.rows, + apps_.size() - dims.shown); + } +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/desktop_icon_layer/desktop_icon_layer.h b/ui/components/desktop_icon_layer/desktop_icon_layer.h new file mode 100644 index 000000000..c9b432d57 --- /dev/null +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.h @@ -0,0 +1,190 @@ +/** + * @file desktop/ui/components/desktop_icon_layer/desktop_icon_layer.h + * @brief Desktop shortcut icon grid sitting on the wallpaper. + * + * DesktopIconLayer renders the merged application list (builtin panels, + * discovered manifests, and XDG .desktop entries) as a grid of LauncherTile + * shortcuts directly on the desktop background, above the wallpaper and below + * the status/task bars. It is a plain child widget of the desktop surface + * (not an IShellLayer, not an IPanel): it consumes PanelManager's central + * availableGeometry() like the launcher popup does, and forwards tile clicks + * as appClicked(app_id) so the same launch_app path drives desktop, taskbar, + * and launcher entries. + * + * The container paints nothing and is transparent to mouse events so clicks + * on empty grid cells pass through to the wallpaper; each LauncherTile keeps + * default input handling and receives its own clicks. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-07 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#pragma once + +#include "app_entry.h" + +#include +#include +#include +#include +#include + +class QGridLayout; + +namespace cf::desktop::desktop_component { + +class LauncherTile; + +/** + * @brief Result of computing how many tiles fit a given desktop area. + * + * Pure value type returned by computeGridDimensions(); exposed in the header + * so the layout math is unit-testable without instantiating a QWidget. + * + * @ingroup components + */ +struct GridDimensions { + int columns{0}; ///< Tile columns that fit the available width (1..kMaxColumns). + int rows{0}; ///< Tile rows that fit the available height. + int shown{0}; ///< Entries actually displayed after cap-and-truncate. +}; + +/** + * @brief Computes how many tiles fit the available area and how many to show. + * + * The desktop background never scrolls, so when the merged app list is larger + * than what fits on screen the visible count is capped to columns*rows and the + * caller logs the truncation. The math is extracted here as a free function so + * it can be exercised by unit tests without a QWidget. + * + * @param[in] available The central desktop area available for icons. + * @param[in] app_count Number of applications that want a tile. + * + * @return GridDimensions with columns/rows clamped to layout constants and + * shown capped to the on-screen capacity. A zero result is returned + * for invalid geometry or non-positive count. + * + * @throws None. + * @note Layout constants (cell size, spacing, margins, max columns) are + * defined in the .cpp; this function is the single source of truth + * for grid sizing. + * @since 0.20 + * @ingroup components + */ +GridDimensions computeGridDimensions(const QSize& available, int app_count); + +/** + * @brief Grid of application shortcut tiles on the desktop background. + * + * Parents LauncherTile instances in a QGridLayout aligned to the top-left of + * the central desktop area. Reuses LauncherTile unchanged (it already emits + * clicked(app_id)); this layer owns the grid container, geometry, and the + * passthrough behavior for empty cells. + * + * @ingroup components + */ +class DesktopIconLayer final : public QWidget { + Q_OBJECT + public: + /** + * @brief Constructs the icon layer. + * + * Sets up the transparent, mouse-passthrough container with an empty + * grid layout. No tiles are created until setApps() + a valid geometry + * arrive. + * + * @param[in] parent Owning widget (the desktop surface). + * + * @throws None. + * @note Must be constructed after the wallpaper shell layer and before + * the status/task bars so creation order stacks it between them. + * @since 0.20 + * @ingroup components + */ + explicit DesktopIconLayer(QWidget* parent = nullptr); + + /** + * @brief Destructs the layer. + * + * Qt parent ownership releases the tiles. + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + ~DesktopIconLayer() override; + + /** + * @brief Replaces the application list backing the grid. + * + * Same contract as CenteredTaskbar::setApps / AppLauncher::setApps: stores + * the merged list and rebuilds the tiles. Tiles are not created until a + * valid geometry has been received via onAvailableGeometryChanged(). + * + * @param[in] apps The applications to show as desktop shortcuts. + * + * @throws None. + * @note Replaces any previously shown tiles. + * @since 0.20 + * @ingroup components + */ + void setApps(const QList& apps); + + /** + * @brief Updates this layer's geometry from the desktop's central area. + * + * Wire to PanelManager::availableGeometryChanged. Invalid or empty rects + * are ignored (tiles would otherwise flash at 0,0 before relayout). The + * grid is rebuilt because column count may change with width. + * + * @param[in] available The central rectangle between the bars. + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + void onAvailableGeometryChanged(const QRect& available); + + signals: + /** + * @brief Emitted when a desktop tile is clicked. + * + * Connect to the same launch_app handler used by the taskbar and launcher + * so all three entry points share one dispatch path. + * + * @param[in] app_id The clicked application identifier. + * + * @since 0.20 + * @ingroup components + */ + void appClicked(const QString& app_id); + + protected: + /** + * @brief Paints nothing. + * + * The container is a transparent passthrough; LauncherTile children paint + * themselves. + * + * @param[in] event The paint event descriptor (unused). + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + void paintEvent(QPaintEvent* event) override; + + private: + /// @brief Clears existing tiles and lays them out for the current geometry. + void rebuildGrid(); + + QGridLayout* grid_{nullptr}; ///< Tile grid. Ownership: this widget. + QList tiles_; ///< Current tiles. Ownership: Qt parented. + QList apps_; ///< Backing application list. + QRect last_available_; ///< Last valid central geometry received. +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/launcher/CMakeLists.txt b/ui/components/launcher/CMakeLists.txt index 6677d4924..0ae871fc5 100644 --- a/ui/components/launcher/CMakeLists.txt +++ b/ui/components/launcher/CMakeLists.txt @@ -4,6 +4,7 @@ add_library(cfdesktop_launcher STATIC desktop_entry_index.cpp app_launch_service.cpp app_launcher.cpp + app_icon_resolver.cpp launcher_tile.cpp ) diff --git a/ui/components/launcher/app_icon_resolver.cpp b/ui/components/launcher/app_icon_resolver.cpp new file mode 100644 index 000000000..aae5caf86 --- /dev/null +++ b/ui/components/launcher/app_icon_resolver.cpp @@ -0,0 +1,53 @@ +/** + * @file desktop/ui/components/launcher/app_icon_resolver.cpp + * @brief Implementation of resolve_app_icon. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-07 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "app_icon_resolver.h" + +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +QPixmap resolve_app_icon(const AppEntry& entry, QSize size) { + const QString& path = entry.icon_path; + if (path.isEmpty()) { + return {}; + } + + // 1. Absolute filesystem path or Qt resource (manifest apps, builtin masks). + // A path-like value that fails to load is unrecoverable — do not fall + // through to the theme lookup, which would only mismatch on a path + // string and waste a lookup. + if (path.startsWith(QLatin1Char('/')) || path.startsWith(QLatin1Char(':'))) { + QPixmap pm(path); + if (!pm.isNull()) { + return pm.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + return {}; + } + + // 2. Otherwise treat icon_path as a freedesktop icon-theme name (the + // .desktop Icon= case). Returns a null icon when no theme is installed + // or the name is unknown — caller falls back to the initial letter. + const QIcon theme_icon = QIcon::fromTheme(path); + if (!theme_icon.isNull()) { + QPixmap pm = theme_icon.pixmap(size); + if (!pm.isNull()) { + return pm; + } + } + + return {}; +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/launcher/app_icon_resolver.h b/ui/components/launcher/app_icon_resolver.h new file mode 100644 index 000000000..2246508c3 --- /dev/null +++ b/ui/components/launcher/app_icon_resolver.h @@ -0,0 +1,58 @@ +/** + * @file desktop/ui/components/launcher/app_icon_resolver.h + * @brief Resolves an AppEntry icon_path to a renderable pixmap. + * + * AppEntry::icon_path carries different value shapes depending on its source: + * manifest apps use an absolute filesystem path, builtin masks use a Qt + * resource ":/..." path, and freedesktop .desktop entries use an icon-theme + * name (e.g. "firefox", "utilities-terminal"). resolve_app_icon() turns any + * of those into a QPixmap with one call, returning a null pixmap when nothing + * loads so callers fall back to drawing the application's initial letter. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-07 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#pragma once + +#include "app_entry.h" + +#include +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief Resolves @p entry's icon to a pixmap sized for @p size. + * + * Resolution chain, in order: + * 1. Absolute filesystem path or Qt resource (":/...") -> QPixmap{path}. + * 2. Otherwise the value is treated as a freedesktop icon-theme name and + * resolved via QIcon::fromTheme(). + * 3. A null pixmap is returned when nothing loads; the caller should draw the + * application's initial letter as the fallback. + * + * The returned pixmap is scaled (aspect ratio kept, smooth filtering) when + * loaded from a path. Theme lookups return the theme's pixmap at @p size + * directly (QIcon already picks the best-resolution variant). + * + * @param[in] entry The application whose icon_path is resolved. + * @param[in] size Target icon edge length in device pixels. + * + * @return The resolved pixmap, or a null pixmap when entry.icon_path is + * empty or no source loads. + * + * @throws None. + * @note QIcon::fromTheme requires an installed icon theme (hicolor, + * adwaita, ...). On minimal targets without one, the null pixmap is + * returned and the caller's letter fallback applies — graceful + * degradation, not a hard dependency. + * @since 0.20 + * @ingroup components + */ +QPixmap resolve_app_icon(const AppEntry& entry, QSize size); + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/launcher/launcher_tile.cpp b/ui/components/launcher/launcher_tile.cpp index 8f8ca83c9..e176d0f7c 100644 --- a/ui/components/launcher/launcher_tile.cpp +++ b/ui/components/launcher/launcher_tile.cpp @@ -17,6 +17,8 @@ #include "launcher_tile.h" +#include "app_icon_resolver.h" + #include "core/theme_manager.h" #include "core/token/material_scheme/cfmaterial_token_literals.h" #include "core/token/typography/cfmaterial_typography_token_literals.h" @@ -59,12 +61,14 @@ LauncherTile::LauncherTile(AppEntry entry, QWidget* parent) setAutoFillBackground(false); setupAnimations(); applyTheme(); + refreshIcon(); } LauncherTile::~LauncherTile() = default; void LauncherTile::setEntry(const AppEntry& entry) { entry_ = entry; + refreshIcon(); update(); } @@ -105,13 +109,20 @@ void LauncherTile::paintEvent(QPaintEvent* /*event*/) { p.drawEllipse(ripple_center_, radius, radius); } - // Initial letter, centered on the glyph tile. - p.setPen(foreground_color_); - p.setFont(glyph_font_); - const QString letter = entry_.display_name.isEmpty() - ? QStringLiteral("?") - : QString(entry_.display_name.at(0)).toUpper(); - p.drawText(glyph, Qt::AlignCenter, letter); + // Real icon image when one resolves (manifest path or .desktop theme name); + // otherwise the application's initial letter, centered on the glyph tile. + if (!cached_icon_.isNull()) { + p.setRenderHint(QPainter::SmoothPixmapTransform, true); + p.drawPixmap(glyph, cached_icon_, + QRectF(0, 0, cached_icon_.width(), cached_icon_.height())); + } else { + p.setPen(foreground_color_); + p.setFont(glyph_font_); + const QString letter = entry_.display_name.isEmpty() + ? QStringLiteral("?") + : QString(entry_.display_name.at(0)).toUpper(); + p.drawText(glyph, Qt::AlignCenter, letter); + } // Caption beneath the glyph, elided to the available width. p.setPen(label_color_); @@ -220,4 +231,10 @@ void LauncherTile::setupAnimations() { [this](const qw::core::ICFTheme&) { applyTheme(); }); } +void LauncherTile::refreshIcon() { + // Cache at 2x the resting glyph edge (kIconBase = 48) so the hover zoom + // (kHoverScale = 1.12) stays crisp instead of upscaling a 48px source. + cached_icon_ = resolve_app_icon(entry_, QSize(96, 96)); +} + } // namespace cf::desktop::desktop_component diff --git a/ui/components/launcher/launcher_tile.h b/ui/components/launcher/launcher_tile.h index a2cb6a082..0e624bad0 100644 --- a/ui/components/launcher/launcher_tile.h +++ b/ui/components/launcher/launcher_tile.h @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -196,8 +197,11 @@ class LauncherTile final : public QWidget { void startRipple(const QPointF& center); /// @brief Creates the hover and ripple animations and wires them. void setupAnimations(); + /// @brief Resolves entry_.icon_path into cached_icon_ (null -> letter fallback). + void refreshIcon(); AppEntry entry_; ///< Backing application entry. + QPixmap cached_icon_; ///< Resolved icon (null -> initial-letter fallback). qreal hover_scale_{1.0}; ///< Current glyph scale (1.0 idle, >1 hover). qreal ripple_progress_{0.0}; ///< Ripple expansion in [0, 1]. QPointF ripple_center_; ///< Ripple origin in local coordinates. From bd5aa5663cd38cf3026776707d8a1ad2ea67cddb Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Tue, 7 Jul 2026 15:54:11 +0800 Subject: [PATCH 2/9] fix(desktop): desktop icon click + real-color taskbar icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DesktopIconLayer: drop WA_TransparentForMouseEvents — it starves child widgets of mouse events on several Qt versions (and on WSLg), so desktop tiles never received clicks. The attribute was only there to pass empty-cell clicks through to the wallpaper, which has no click handler today, so it is net-negative. Empty-cell clicks now fall through via QWidget's default ignore. TaskbarIcon: resolve full-color icons for system .desktop entries (theme names like "firefox") and manifest apps via resolve_app_icon, instead of only tinting builtin ":/..." masks. Builtin taskbar resources stay single-color (tinted to on-surface); everything else shows real artwork. Mirrors what LauncherTile and DesktopIconLayer already do. Verified: build green, ctest full suite passing, doxygen clean. --- .../desktop_icon_layer/desktop_icon_layer.cpp | 15 +++++++-------- .../desktop_icon_layer/desktop_icon_layer.h | 7 ++++--- ui/components/taskbar/CMakeLists.txt | 1 + ui/components/taskbar/taskbar_icon.cpp | 19 +++++++++++++++---- ui/components/taskbar/taskbar_icon.h | 2 +- 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp index ff6abe2b9..48a8190c0 100644 --- a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp @@ -68,11 +68,13 @@ GridDimensions computeGridDimensions(const QSize& available, int app_count) { } DesktopIconLayer::DesktopIconLayer(QWidget* parent) : QWidget(parent) { - // The container must not paint a background (the wallpaper shows through) - // and must not consume mouse events on empty cells (clicks between tiles - // fall through to the wallpaper). Each LauncherTile clears the attribute - // explicitly in rebuildGrid() so it still receives its own clicks. - setAttribute(Qt::WA_TransparentForMouseEvents); + // The container must not paint a background so the wallpaper shows through + // (empty paintEvent + WA_NoSystemBackground). It deliberately does NOT set + // WA_TransparentForMouseEvents: that attribute reliably starves child + // widgets of mouse events under several Qt versions (and on WSLg), so + // LauncherTile would never receive clicks. Empty-cell clicks fall through + // to the desktop surface via QWidget's default ignore — the wallpaper has + // no click handler today, so passthrough is not needed. setAttribute(Qt::WA_NoSystemBackground); setAutoFillBackground(false); @@ -118,9 +120,6 @@ void DesktopIconLayer::rebuildGrid() { int col = 0; for (int i = 0; i < dims.shown; ++i) { auto* tile = new LauncherTile(apps_[i], this); - // Defensive: the container is transparent-for-mouse; ensure each tile - // is not, so its hover/click still work under every Qt version. - tile->setAttribute(Qt::WA_TransparentForMouseEvents, false); connect(tile, &LauncherTile::clicked, this, &DesktopIconLayer::appClicked); grid_->addWidget(tile, row, col); tiles_.append(tile); diff --git a/ui/components/desktop_icon_layer/desktop_icon_layer.h b/ui/components/desktop_icon_layer/desktop_icon_layer.h index c9b432d57..c88d4ab38 100644 --- a/ui/components/desktop_icon_layer/desktop_icon_layer.h +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.h @@ -11,9 +11,10 @@ * as appClicked(app_id) so the same launch_app path drives desktop, taskbar, * and launcher entries. * - * The container paints nothing and is transparent to mouse events so clicks - * on empty grid cells pass through to the wallpaper; each LauncherTile keeps - * default input handling and receives its own clicks. + * The container paints nothing so the wallpaper shows through. It does NOT set + * WA_TransparentForMouseEvents (that starves child widgets of mouse events on + * several Qt versions); tiles receive their own clicks, and empty-cell clicks + * propagate to the desktop surface. * * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) * @date 2026-07-07 diff --git a/ui/components/taskbar/CMakeLists.txt b/ui/components/taskbar/CMakeLists.txt index 9dac98bdd..210a68f76 100644 --- a/ui/components/taskbar/CMakeLists.txt +++ b/ui/components/taskbar/CMakeLists.txt @@ -21,4 +21,5 @@ PRIVATE cfbase # WeakPtr / WeakPtrFactory cflogger # Diagnostic logging for null-backdrop fallback cfdesktop_frosted_backdrop # Frosted-glass backdrop renderer + cfdesktop_launcher # app_icon_resolver for full-color .desktop icons ) diff --git a/ui/components/taskbar/taskbar_icon.cpp b/ui/components/taskbar/taskbar_icon.cpp index 6f636943c..b8584bc00 100644 --- a/ui/components/taskbar/taskbar_icon.cpp +++ b/ui/components/taskbar/taskbar_icon.cpp @@ -17,6 +17,7 @@ #include "taskbar_icon.h" #include "icon_mask.h" +#include "launcher/app_icon_resolver.h" #include "core/theme_manager.h" #include "core/token/material_scheme/cfmaterial_token_literals.h" @@ -112,9 +113,9 @@ void TaskbarIcon::paintEvent(QPaintEvent* /*event*/) { p.drawEllipse(ripple_center_, radius, radius); } - // App glyph: the tinted icon mask, or — when no icon resolves (manifest apps - // ship none, .desktop Icon= is a theme name we do not look up, builtin - // panels set none) — the display-name initial so every tile is identifiable. + // App glyph: the resolved icon (full-color for .desktop / manifest apps, + // tinted mask for builtin ":/..." resources), or the display-name initial + // when nothing resolves — so every tile is identifiable. 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); @@ -190,7 +191,17 @@ void TaskbarIcon::applyTheme() { } void TaskbarIcon::refreshIcon() { - icon_mask_ = tintedIconMask(entry_.icon_path, foreground_color_); + const QString& path = entry_.icon_path; + // Builtin taskbar masks ship as ":/..." resources (single-color silhouettes): + // tint them to the on-surface token for the taskbar's monochrome style. + // Everything else (freedesktop theme names from .desktop, manifest absolute + // paths) is a full-color icon: resolve it as-is via the shared resolver so + // firefox and friends show their real artwork. + if (path.startsWith(QLatin1Char(':'))) { + icon_mask_ = tintedIconMask(path, foreground_color_); + } else { + icon_mask_ = resolve_app_icon(entry_, QSize(72, 72)); // 2x kIconBase for crisp hover. + } } void TaskbarIcon::startHover(bool entering) { diff --git a/ui/components/taskbar/taskbar_icon.h b/ui/components/taskbar/taskbar_icon.h index 7e4a24e00..08383d658 100644 --- a/ui/components/taskbar/taskbar_icon.h +++ b/ui/components/taskbar/taskbar_icon.h @@ -220,7 +220,7 @@ class TaskbarIcon final : public QWidget { QColor tile_color_; ///< Tile fill (surface variant). QColor foreground_color_; ///< Icon tint color (on surface). QColor indicator_color_; ///< Running dot color (on surface variant). - QPixmap icon_mask_; ///< Tinted icon pixmap; null -> letter fallback. + QPixmap icon_mask_; ///< Icon (mask or full-color); null -> letter fallback. QFont glyph_font_; ///< Font for the initial-letter fallback. QVariantAnimation* hover_anim_{nullptr}; ///< Zoom-in/out animation. From 36e0a451a2ed05857d8ca424057e854acbf42762 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Tue, 7 Jul 2026 16:52:13 +0800 Subject: [PATCH 3/9] feat(desktop): drag / add / remove + persistence for desktop icons Turns the desktop icon layer from a read-only full app list into a user-managed shortcut set: each shortcut = {app_id, col, row}, persisted across sessions via ConfigStore (domain "desktop_icons", JSON string at the User layer), seeded with a default set on first run. Touch-first (i.MX6ULL resistive, no mouse): - LauncherTile gains TileContext + a long-press state machine (500ms hold, 10px move-cancel threshold). Desktop context: long-press enters drag; Launcher context: long-press emits "send to desktop". A quick tap still launches (click path intact). Two-threshold rule: <10px wander is still a tap, >=10px before the timer scraps (no click, no drag). - Drag uses a floating ghost (tile->grab() pixmap) over a dimmed source tile so QGridLayout never reflows mid-drag (critical on a 528MHz A7). - Drop on a grid cell swaps positions; drop outside the layer removes the shortcut. A snap-highlight rect is painted on the target cell. Data + persistence: - DesktopShortcut {app_id, col, row} + DesktopShortcutStore (load / save / seedFrom via ConfigStore; serialize/deserialize JSON round-trip exposed for unit tests, which cover round-trip, malformed input, empty-app_id filtering, and seed layout/wrap/cap). - DesktopIconLayer switches setApps -> setShortcuts(shortcuts, registry); emits shortcutsChanged on any layout change so the caller persists. - CFDesktopEntity loads (seeds+saves on first run) and wires shortcutsChanged -> DesktopShortcutStore::save, plus app_launcher::addToDesktopRequested -> icon_layer::addShortcut. Verified: build green, ctest full suite passing (grid math + shortcut store JSON + seed), doxygen clean. --- .../desktop/desktop_icon_layer/CMakeLists.txt | 16 ++ .../desktop_shortcut_store_test.cpp | 98 +++++++ ui/CFDesktopEntity.cpp | 26 +- .../desktop_icon_layer/CMakeLists.txt | 2 + .../desktop_icon_layer/desktop_icon_layer.cpp | 268 +++++++++++++++--- .../desktop_icon_layer/desktop_icon_layer.h | 145 ++++++---- .../desktop_icon_layer/desktop_shortcut.h | 35 +++ .../desktop_shortcut_store.cpp | 113 ++++++++ .../desktop_shortcut_store.h | 115 ++++++++ ui/components/launcher/app_launcher.cpp | 6 + ui/components/launcher/app_launcher.h | 10 + ui/components/launcher/launcher_tile.cpp | 79 +++++- ui/components/launcher/launcher_tile.h | 98 +++++++ 13 files changed, 908 insertions(+), 103 deletions(-) create mode 100644 test/desktop/desktop_icon_layer/desktop_shortcut_store_test.cpp create mode 100644 ui/components/desktop_icon_layer/desktop_shortcut.h create mode 100644 ui/components/desktop_icon_layer/desktop_shortcut_store.cpp create mode 100644 ui/components/desktop_icon_layer/desktop_shortcut_store.h diff --git a/test/desktop/desktop_icon_layer/CMakeLists.txt b/test/desktop/desktop_icon_layer/CMakeLists.txt index 510f73934..6374edd67 100644 --- a/test/desktop/desktop_icon_layer/CMakeLists.txt +++ b/test/desktop/desktop_icon_layer/CMakeLists.txt @@ -16,3 +16,19 @@ add_gtest_executable( LABELS "desktop;unit;desktop_icon_layer" LOG_MODULE desktop_icon_layer_tests ) + +# DesktopShortcutStore JSON + seed unit tests (pure logic, no ConfigStore IO). +add_gtest_executable( + TEST_NAME desktop_shortcut_store_test + SOURCE_FILE desktop_shortcut_store_test.cpp + LINK_LIBRARIES + cfdesktop_desktop_icon_layer + cflogger + cfbase + Qt6::Core + GTest::gtest + GTest::gtest_main + INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/desktop/ui/components + LABELS "desktop;unit;desktop_icon_layer" + LOG_MODULE desktop_icon_layer_tests +) diff --git a/test/desktop/desktop_icon_layer/desktop_shortcut_store_test.cpp b/test/desktop/desktop_icon_layer/desktop_shortcut_store_test.cpp new file mode 100644 index 000000000..f9f7bf707 --- /dev/null +++ b/test/desktop/desktop_icon_layer/desktop_shortcut_store_test.cpp @@ -0,0 +1,98 @@ +/** + * @file test/desktop/desktop_icon_layer/desktop_shortcut_store_test.cpp + * @brief GoogleTest unit tests for DesktopShortcutStore. + * + * Covers JSON serialize/deserialize round-trip, malformed/empty input, empty + * app_id filtering, and seedFrom() grid layout + cap. load()/save() (real + * ConfigStore IO) are intentionally not covered here to avoid polluting the + * host config; the serialization layer they delegate to is fully tested. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-07 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "desktop_icon_layer/desktop_shortcut.h" +#include "desktop_icon_layer/desktop_shortcut_store.h" + +#include +#include + +#include + +using cf::desktop::desktop_component::AppEntry; +using cf::desktop::desktop_component::DesktopShortcut; +using cf::desktop::desktop_component::DesktopShortcutStore; + +TEST(DesktopShortcutStore, SerializeDeserializeRoundTrip) { + QList in; + in.append({QStringLiteral("about"), 0, 0}); + in.append({QStringLiteral("calculator"), 1, 0}); + in.append({QStringLiteral("firefox"), 0, 1}); + const QByteArray bytes = DesktopShortcutStore::serialize(in); + const QList out = DesktopShortcutStore::deserialize(bytes); + ASSERT_EQ(out.size(), in.size()); + for (int i = 0; i < in.size(); ++i) { + EXPECT_EQ(out[i].app_id.toStdString(), in[i].app_id.toStdString()); + EXPECT_EQ(out[i].col, in[i].col); + EXPECT_EQ(out[i].row, in[i].row); + } +} + +TEST(DesktopShortcutStore, DeserializeEmptyReturnsEmpty) { + EXPECT_TRUE(DesktopShortcutStore::deserialize(QByteArray()).isEmpty()); +} + +TEST(DesktopShortcutStore, DeserializeMalformedReturnsEmpty) { + EXPECT_TRUE(DesktopShortcutStore::deserialize(QByteArray("not json{")).isEmpty()); +} + +TEST(DesktopShortcutStore, DeserializeSkipsEmptyAppId) { + const QByteArray bytes = R"([{"app_id":"","col":0,"row":0},{"app_id":"x","col":1,"row":0}])"; + const auto out = DesktopShortcutStore::deserialize(bytes); + ASSERT_EQ(out.size(), 1); + EXPECT_EQ(out[0].app_id.toStdString(), "x"); +} + +TEST(DesktopShortcutStore, SeedFromLaysOutInGridOrder) { + QList apps; + for (int i = 0; i < 5; ++i) { + AppEntry e; + e.app_id = QStringLiteral("app%1").arg(i); + apps.append(e); + } + const auto seed = DesktopShortcutStore::seedFrom(apps); + ASSERT_EQ(seed.size(), 5); + // kSeedCols = 8, so the first 8 land in row 0, left-to-right. + for (int i = 0; i < 5; ++i) { + EXPECT_EQ(seed[i].app_id.toStdString(), "app" + std::to_string(i)); + EXPECT_EQ(seed[i].col, i); + EXPECT_EQ(seed[i].row, 0); + } +} + +TEST(DesktopShortcutStore, SeedFromWrapsToSecondRow) { + QList apps; + for (int i = 0; i < 10; ++i) { + AppEntry e; + e.app_id = QStringLiteral("a%1").arg(i); + apps.append(e); + } + const auto seed = DesktopShortcutStore::seedFrom(apps); + ASSERT_EQ(seed.size(), 10); + // Index 8 wraps to col 0, row 1. + EXPECT_EQ(seed[8].col, 0); + EXPECT_EQ(seed[8].row, 1); +} + +TEST(DesktopShortcutStore, SeedFromCapsAtLimit) { + QList apps; + for (int i = 0; i < 100; ++i) { + AppEntry e; + e.app_id = QStringLiteral("a%1").arg(i); + apps.append(e); + } + EXPECT_EQ(DesktopShortcutStore::seedFrom(apps).size(), 12); // kSeedCount +} diff --git a/ui/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index 5cf29eb37..9a01f47bb 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -13,6 +13,7 @@ #include "components/builtin_apps/about_panel.h" #include "components/builtin_apps/builtin_panel_registry.h" #include "components/desktop_icon_layer/desktop_icon_layer.h" +#include "components/desktop_icon_layer/desktop_shortcut_store.h" #include "components/launcher/app_discoverer.h" #include "components/launcher/app_launch_service.h" #include "components/launcher/app_launcher.h" @@ -360,6 +361,18 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { // ── Taskbar: bottom-edge panel (centered app icons) ── // apps is captured by the click handler to resolve app_id -> entry. const QList apps = loadAppsConfig(prefer_inprocess); + + // Desktop icon shortcuts: a user-managed layout persisted via ConfigStore. + // First run (nothing stored) seeds a default set from the merged registry, + // then every subsequent change (drag swap / drag-off-remove / launcher add) + // re-persists via the shortcutsChanged signal wired below. + QList desktop_shortcuts = + cf::desktop::desktop_component::DesktopShortcutStore::load(); + if (desktop_shortcuts.isEmpty()) { + desktop_shortcuts = cf::desktop::desktop_component::DesktopShortcutStore::seedFrom(apps); + cf::desktop::desktop_component::DesktopShortcutStore::save(desktop_shortcuts); + } + auto* taskbar = new cf::desktop::desktop_component::CenteredTaskbar(desktop_entity_); taskbar->setApps(apps); panel_mgr->registerPanel(taskbar->GetWeak()); @@ -431,6 +444,11 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { app_launcher->setApps(apps); QObject::connect(app_launcher, &cf::desktop::desktop_component::AppLauncher::appLaunched, this, launch_app); + // Launcher long-press "send to desktop": append to the desktop icon layer + // (which finds the first free cell and persists). + QObject::connect(app_launcher, + &cf::desktop::desktop_component::AppLauncher::addToDesktopRequested, + icon_layer, &cf::desktop::desktop_component::DesktopIconLayer::addShortcut); // ── Desktop icon layer wiring: same merged apps list and same launch_app // dispatch as the taskbar and launcher, so a desktop shortcut launch lights @@ -438,13 +456,19 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { // the PanelManager central rect (between the bars); tiles are not created // until a valid geometry arrives, so showing early does not flash tiles at // (0,0). The first panel_mgr->relayout() below delivers the real rect. ── - icon_layer->setApps(apps); + icon_layer->setShortcuts(desktop_shortcuts, apps); QObject::connect( panel_mgr, &PanelManager::availableGeometryChanged, desktop_entity_, [icon_layer](const QRect& avail) { icon_layer->onAvailableGeometryChanged(avail); }); icon_layer->onAvailableGeometryChanged(panel_mgr->availableGeometry()); // seed geometry QObject::connect(icon_layer, &cf::desktop::desktop_component::DesktopIconLayer::appClicked, this, launch_app); + // Persist any layout change (drag swap / drag-off-remove / launcher add). + QObject::connect(icon_layer, + &cf::desktop::desktop_component::DesktopIconLayer::shortcutsChanged, this, + [](const QList& shortcuts) { + cf::desktop::desktop_component::DesktopShortcutStore::save(shortcuts); + }); icon_layer->show(); QObject::connect(taskbar, &cf::desktop::desktop_component::CenteredTaskbar::launcherRequested, diff --git a/ui/components/desktop_icon_layer/CMakeLists.txt b/ui/components/desktop_icon_layer/CMakeLists.txt index a3a3715f8..3ac2c6804 100644 --- a/ui/components/desktop_icon_layer/CMakeLists.txt +++ b/ui/components/desktop_icon_layer/CMakeLists.txt @@ -1,6 +1,7 @@ # Desktop icon layer (shortcut icon grid sitting on the wallpaper). add_library(cfdesktop_desktop_icon_layer STATIC desktop_icon_layer.cpp + desktop_shortcut_store.cpp ) target_include_directories(cfdesktop_desktop_icon_layer PUBLIC @@ -18,4 +19,5 @@ PRIVATE Qt6::Widgets cfbase # Diagnostics utilities cflogger # Truncation warning logging + cfconfig # DesktopShortcutStore persistence (ConfigStore) ) diff --git a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp index 48a8190c0..79344a55e 100644 --- a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp @@ -4,7 +4,7 @@ * * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) * @date 2026-07-07 - * @version 0.1 + * @version 0.2 * @since 0.20 * @ingroup components */ @@ -15,7 +15,11 @@ #include "launcher/launcher_tile.h" #include +#include #include +#include +#include +#include #include @@ -33,25 +37,41 @@ constexpr int kGridSpacing = 16; constexpr int kMargin = 24; /// Upper bound on columns so very wide screens do not stretch the grid. constexpr int kMaxColumns = 8; + +/// Index of the shortcut with @p app_id, or -1. +int indexOfApp(const QList& list, const QString& app_id) { + for (int i = 0; i < list.size(); ++i) { + if (list[i].app_id == app_id) { + return i; + } + } + return -1; +} + +/// Index of the shortcut occupying @p cell (col in x, row in y), or -1. +int indexOfCell(const QList& list, const QPoint& cell) { + for (int i = 0; i < list.size(); ++i) { + if (list[i].col == cell.x() && list[i].row == cell.y()) { + return i; + } + } + return -1; +} } // namespace GridDimensions computeGridDimensions(const QSize& available, int app_count) { GridDimensions result; - // QSize(0,0) is technically isValid() (width/height >= 0) but is an empty - // area — treat both invalid and empty as "no grid", else the floor-to-one - // column logic below would return 1 column for a zero-size desktop. if (!available.isValid() || available.isEmpty() || app_count <= 0) { return result; } const int stride = kCellSize + kGridSpacing; - // +kGridSpacing: the last column/row does not need a trailing gap. const int usable_w = available.width() - 2 * kMargin + kGridSpacing; const int usable_h = available.height() - 2 * kMargin + kGridSpacing; int cols = (usable_w > 0) ? (usable_w / stride) : 0; if (cols < 1) { - cols = 1; // Even a very narrow desktop shows at least one column. + cols = 1; } if (cols > kMaxColumns) { cols = kMaxColumns; @@ -59,22 +79,17 @@ GridDimensions computeGridDimensions(const QSize& available, int app_count) { const int rows = (usable_h > 0) ? (usable_h / stride) : 0; const int capacity = cols * rows; - const int shown = std::min(app_count, capacity); - result.columns = cols; result.rows = rows; - result.shown = shown; + result.shown = std::min(app_count, capacity); return result; } DesktopIconLayer::DesktopIconLayer(QWidget* parent) : QWidget(parent) { - // The container must not paint a background so the wallpaper shows through - // (empty paintEvent + WA_NoSystemBackground). It deliberately does NOT set - // WA_TransparentForMouseEvents: that attribute reliably starves child - // widgets of mouse events under several Qt versions (and on WSLg), so - // LauncherTile would never receive clicks. Empty-cell clicks fall through - // to the desktop surface via QWidget's default ignore — the wallpaper has - // no click handler today, so passthrough is not needed. + // No background paint (wallpaper shows through). Deliberately NOT + // WA_TransparentForMouseEvents: that starves child widgets of mouse events + // under several Qt versions / on WSLg, so LauncherTile would not get the + // long-press that starts a drag. setAttribute(Qt::WA_NoSystemBackground); setAutoFillBackground(false); @@ -86,17 +101,43 @@ DesktopIconLayer::DesktopIconLayer(QWidget* parent) : QWidget(parent) { DesktopIconLayer::~DesktopIconLayer() = default; -void DesktopIconLayer::setApps(const QList& apps) { - apps_ = apps; +void DesktopIconLayer::setShortcuts(const QList& shortcuts, + const QList& registry) { + shortcuts_ = shortcuts; + registry_ = registry; + rebuildGrid(); +} + +void DesktopIconLayer::addShortcut(const QString& app_id) { + if (app_id.isEmpty()) { + return; + } + if (indexOfApp(shortcuts_, app_id) >= 0) { + cf::log::infoftag(kLogTag, "app {} already on desktop; addShortcut no-op", + app_id.toStdString()); + return; + } + if (findEntry(app_id) == nullptr) { + cf::log::warningftag(kLogTag, "addShortcut: app {} not in registry; skipped", + app_id.toStdString()); + return; + } + const QPoint cell = firstFreeCell(); + DesktopShortcut s; + s.app_id = app_id; + s.col = cell.x(); + s.row = cell.y(); + shortcuts_.append(s); + emit shortcutsChanged(shortcuts_); rebuildGrid(); } void DesktopIconLayer::onAvailableGeometryChanged(const QRect& available) { if (!available.isValid() || available.isEmpty()) { - return; // Avoid flashing tiles at (0,0) before the first relayout. + return; } if (available == last_available_) { - return; // No change: column count stays the same. + return; } last_available_ = available; setGeometry(available); @@ -104,37 +145,188 @@ void DesktopIconLayer::onAvailableGeometryChanged(const QRect& available) { } void DesktopIconLayer::paintEvent(QPaintEvent* /*event*/) { - // Transparent passthrough; LauncherTile children paint themselves. + // Transparent by default; only draw the drag snap-highlight when active. + if (dragging_app_id_.isEmpty() || drag_target_cell_.x() < 0) { + return; + } + const int stride = kCellSize + kGridSpacing; + const QRect cell_rect(kMargin + drag_target_cell_.x() * stride, + kMargin + drag_target_cell_.y() * stride, kCellSize, kCellSize); + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + QPen pen(QColor(0x6F, 0x5B, 0xA4, 200)); // MD3-ish primary, semi-transparent. + pen.setWidthF(2.5); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + p.drawRoundedRect(cell_rect, 12, 12); } void DesktopIconLayer::rebuildGrid() { qDeleteAll(tiles_); tiles_.clear(); - const auto dims = computeGridDimensions(last_available_.size(), static_cast(apps_.size())); - if (dims.shown <= 0) { - return; // No valid geometry yet, or nothing to show. + const auto dims = + computeGridDimensions(last_available_.size(), static_cast(shortcuts_.size())); + if (dims.columns <= 0 || dims.rows <= 0) { + return; // No valid geometry yet. } - - int row = 0; - int col = 0; - for (int i = 0; i < dims.shown; ++i) { - auto* tile = new LauncherTile(apps_[i], this); + for (const auto& s : shortcuts_) { + if (s.col < 0 || s.col >= dims.columns || s.row < 0 || s.row >= dims.rows) { + cf::log::warningftag(kLogTag, "shortcut {} at ({},{}) outside grid ({}x{}); skipped", + s.app_id.toStdString(), s.col, s.row, dims.columns, dims.rows); + continue; + } + const AppEntry* entry = findEntry(s.app_id); + if (entry == nullptr) { + cf::log::warningftag(kLogTag, "shortcut {} resolves to no app entry; skipped", + s.app_id.toStdString()); + continue; + } + auto* tile = new LauncherTile(*entry, this); + tile->setContext(cf::desktop::desktop_component::TileContext::Desktop); connect(tile, &LauncherTile::clicked, this, &DesktopIconLayer::appClicked); - grid_->addWidget(tile, row, col); + connect(tile, &LauncherTile::longPressed, this, &DesktopIconLayer::onTileLongPressed); + connect(tile, &LauncherTile::dragMoved, this, &DesktopIconLayer::onTileDragMoved); + connect(tile, &LauncherTile::dragEnded, this, &DesktopIconLayer::onTileDragEnded); + grid_->addWidget(tile, s.row, s.col); tiles_.append(tile); - ++col; - if (col >= dims.columns) { - col = 0; - ++row; + } +} + +LauncherTile* DesktopIconLayer::tileForApp(const QString& app_id) const { + for (auto* tile : tiles_) { + if (tile->appId() == app_id) { + return tile; } } + return nullptr; +} + +const AppEntry* DesktopIconLayer::findEntry(const QString& app_id) const { + for (const auto& entry : registry_) { + if (entry.app_id == app_id) { + return &entry; + } + } + return nullptr; +} + +QPoint DesktopIconLayer::cellAt(const QPoint& layer_pos) const { + // Outside the layer entirely → (-1,-1) = "drop to delete". + if (!rect().contains(layer_pos)) { + return {-1, -1}; + } + const auto dims = + computeGridDimensions(last_available_.size(), static_cast(shortcuts_.size())); + if (dims.columns <= 0 || dims.rows <= 0) { + return {-1, -1}; + } + const int stride = kCellSize + kGridSpacing; + const int col = std::clamp((layer_pos.x() - kMargin) / stride, 0, dims.columns - 1); + const int row = std::clamp((layer_pos.y() - kMargin) / stride, 0, dims.rows - 1); + return {col, row}; +} + +QPoint DesktopIconLayer::firstFreeCell() const { + const auto dims = + computeGridDimensions(last_available_.size(), static_cast(shortcuts_.size())); + for (int r = 0; r < dims.rows; ++r) { + for (int c = 0; c < dims.columns; ++c) { + bool taken = false; + for (const auto& s : shortcuts_) { + if (s.col == c && s.row == r) { + taken = true; + break; + } + } + if (!taken) { + return {c, r}; + } + } + } + return {0, 0}; // Grid full: overlap (rebuildGrid stacks at the same cell). +} + +void DesktopIconLayer::onTileLongPressed(const QString& app_id) { + auto* tile = tileForApp(app_id); + if (tile == nullptr) { + return; + } + dragging_app_id_ = app_id; + const int idx = indexOfApp(shortcuts_, app_id); + drag_origin_cell_ = + (idx >= 0) ? QPoint(shortcuts_[idx].col, shortcuts_[idx].row) : QPoint(-1, -1); + drag_target_cell_ = drag_origin_cell_; + // Floating ghost = grabbed pixmap of the tile, follows the finger. It is + // transparent for mouse events so the source tile keeps receiving moves. + const QPixmap pm = tile->grab(); + drag_ghost_ = new QLabel(this); + drag_ghost_->setPixmap(pm); + drag_ghost_->setScaledContents(false); + drag_ghost_->setAttribute(Qt::WA_TransparentForMouseEvents); + drag_ghost_->setGeometry(QRect(tile->pos(), tile->size())); + drag_ghost_->show(); + drag_ghost_->raise(); + update(); +} + +void DesktopIconLayer::onTileDragMoved(const QPoint& global_pos) { + if (dragging_app_id_.isEmpty() || drag_ghost_ == nullptr) { + return; + } + const QPoint layer_pos = mapFromGlobal(global_pos); + const QSize sz = drag_ghost_->size(); + drag_ghost_->move(layer_pos - QPoint(sz.width() / 2, sz.height() / 2)); + drag_target_cell_ = cellAt(layer_pos); + update(); +} + +void DesktopIconLayer::onTileDragEnded(const QPoint& global_pos) { + if (dragging_app_id_.isEmpty()) { + return; + } + const QPoint layer_pos = mapFromGlobal(global_pos); + const QPoint target = cellAt(layer_pos); + + bool changed = false; + QList updated = shortcuts_; + if (target.x() < 0 || target.y() < 0) { + // Dropped outside the layer → remove the shortcut. + updated.erase( + std::remove_if(updated.begin(), updated.end(), + [&](const DesktopShortcut& s) { return s.app_id == dragging_app_id_; }), + updated.end()); + changed = true; + cf::log::infoftag(kLogTag, "removed shortcut {} (dragged off desktop)", + dragging_app_id_.toStdString()); + } else if (target != drag_origin_cell_) { + const int drag_idx = indexOfApp(updated, dragging_app_id_); + const int tgt_idx = indexOfCell(updated, target); + if (drag_idx >= 0 && tgt_idx >= 0) { + // Swap the two shortcuts' positions. + std::swap(updated[drag_idx].col, updated[tgt_idx].col); + std::swap(updated[drag_idx].row, updated[tgt_idx].row); + changed = true; + } else if (drag_idx >= 0) { + // Target cell empty: just move. + updated[drag_idx].col = target.x(); + updated[drag_idx].row = target.y(); + changed = true; + } + } + + delete drag_ghost_; + drag_ghost_ = nullptr; + dragging_app_id_.clear(); + drag_origin_cell_ = {-1, -1}; + drag_target_cell_ = {-1, -1}; - if (apps_.size() > dims.shown) { - cf::log::warningftag(kLogTag, "{} apps but only {} fit on desktop ({}x{}); {} hidden", - apps_.size(), dims.shown, dims.columns, dims.rows, - apps_.size() - dims.shown); + if (changed) { + shortcuts_ = std::move(updated); + emit shortcutsChanged(shortcuts_); + rebuildGrid(); } + update(); } } // namespace cf::desktop::desktop_component diff --git a/ui/components/desktop_icon_layer/desktop_icon_layer.h b/ui/components/desktop_icon_layer/desktop_icon_layer.h index c88d4ab38..97fb3f13a 100644 --- a/ui/components/desktop_icon_layer/desktop_icon_layer.h +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.h @@ -1,24 +1,22 @@ /** * @file desktop/ui/components/desktop_icon_layer/desktop_icon_layer.h - * @brief Desktop shortcut icon grid sitting on the wallpaper. + * @brief User-managed desktop shortcut grid on the wallpaper. * - * DesktopIconLayer renders the merged application list (builtin panels, - * discovered manifests, and XDG .desktop entries) as a grid of LauncherTile - * shortcuts directly on the desktop background, above the wallpaper and below - * the status/task bars. It is a plain child widget of the desktop surface - * (not an IShellLayer, not an IPanel): it consumes PanelManager's central - * availableGeometry() like the launcher popup does, and forwards tile clicks - * as appClicked(app_id) so the same launch_app path drives desktop, taskbar, - * and launcher entries. + * DesktopIconLayer renders a user-managed set of DesktopShortcut values (each + * an app_id placed at a grid cell) as LauncherTile widgets on the desktop + * background, above the wallpaper and below the status/task bars. It is a plain + * child widget of the desktop surface (not an IShellLayer, not an IPanel) and + * consumes PanelManager's central availableGeometry() like the launcher popup. * - * The container paints nothing so the wallpaper shows through. It does NOT set - * WA_TransparentForMouseEvents (that starves child widgets of mouse events on - * several Qt versions); tiles receive their own clicks, and empty-cell clicks - * propagate to the desktop surface. + * Tiles are long-press-draggable (target: i.MX6ULL resistive touch). A drag + * shows a floating ghost (grabbed pixmap) over a dimmed source tile; dropping + * on a grid cell swaps positions, dropping outside the layer removes the + * shortcut. Every layout change emits shortcutsChanged() so the caller can + * persist via DesktopShortcutStore. * * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) * @date 2026-07-07 - * @version 0.1 + * @version 0.2 * @since 0.20 * @ingroup components */ @@ -26,14 +24,17 @@ #pragma once #include "app_entry.h" +#include "desktop_shortcut.h" #include +#include #include #include #include #include class QGridLayout; +class QLabel; namespace cf::desktop::desktop_component { @@ -50,40 +51,33 @@ class LauncherTile; struct GridDimensions { int columns{0}; ///< Tile columns that fit the available width (1..kMaxColumns). int rows{0}; ///< Tile rows that fit the available height. - int shown{0}; ///< Entries actually displayed after cap-and-truncate. + int shown{0}; ///< Capacity (columns*rows) capped to the app count. }; /** - * @brief Computes how many tiles fit the available area and how many to show. + * @brief Computes how many tiles fit the available area. * - * The desktop background never scrolls, so when the merged app list is larger - * than what fits on screen the visible count is capped to columns*rows and the - * caller logs the truncation. The math is extracted here as a free function so - * it can be exercised by unit tests without a QWidget. + * Extracted as a free function so the grid math is unit-testable without a + * QWidget. Layout constants live in the .cpp. * * @param[in] available The central desktop area available for icons. * @param[in] app_count Number of applications that want a tile. * - * @return GridDimensions with columns/rows clamped to layout constants and - * shown capped to the on-screen capacity. A zero result is returned - * for invalid geometry or non-positive count. + * @return GridDimensions; columns/rows clamped to constants, shown = capacity. + * Zero result for invalid geometry or non-positive count. * * @throws None. - * @note Layout constants (cell size, spacing, margins, max columns) are - * defined in the .cpp; this function is the single source of truth - * for grid sizing. * @since 0.20 * @ingroup components */ GridDimensions computeGridDimensions(const QSize& available, int app_count); /** - * @brief Grid of application shortcut tiles on the desktop background. + * @brief Grid of user-placed application shortcut tiles on the wallpaper. * - * Parents LauncherTile instances in a QGridLayout aligned to the top-left of - * the central desktop area. Reuses LauncherTile unchanged (it already emits - * clicked(app_id)); this layer owns the grid container, geometry, and the - * passthrough behavior for empty cells. + * Parents LauncherTile instances in a QGridLayout, one per DesktopShortcut at + * the shortcut's (col, row). Tiles use TileContext::Desktop so a long-press + * enters drag mode (rearrange / remove); clicks forward as appClicked(). * * @ingroup components */ @@ -93,15 +87,12 @@ class DesktopIconLayer final : public QWidget { /** * @brief Constructs the icon layer. * - * Sets up the transparent, mouse-passthrough container with an empty - * grid layout. No tiles are created until setApps() + a valid geometry - * arrive. + * Sets up the transparent container with an empty grid. No tiles are + * created until setShortcuts() + a valid geometry arrive. * * @param[in] parent Owning widget (the desktop surface). * * @throws None. - * @note Must be constructed after the wallpaper shell layer and before - * the status/task bars so creation order stacks it between them. * @since 0.20 * @ingroup components */ @@ -110,8 +101,6 @@ class DesktopIconLayer final : public QWidget { /** * @brief Destructs the layer. * - * Qt parent ownership releases the tiles. - * * @throws None. * @since 0.20 * @ingroup components @@ -119,27 +108,36 @@ class DesktopIconLayer final : public QWidget { ~DesktopIconLayer() override; /** - * @brief Replaces the application list backing the grid. + * @brief Loads the shortcut layout and app registry into the grid. + * + * @param[in] shortcuts User-placed shortcuts (app_id + cell). + * @param[in] registry Full app list for app_id -> AppEntry resolution. * - * Same contract as CenteredTaskbar::setApps / AppLauncher::setApps: stores - * the merged list and rebuilds the tiles. Tiles are not created until a - * valid geometry has been received via onAvailableGeometryChanged(). + * @throws None. + * @note Rebuilds tiles; shortcuts whose app_id is not in the registry + * or whose cell is outside the grid are skipped with a warning. + * @since 0.20 + * @ingroup components + */ + void setShortcuts(const QList& shortcuts, const QList& registry); + + /** + * @brief Adds one shortcut at the first free cell (launcher "send here"). * - * @param[in] apps The applications to show as desktop shortcuts. + * @param[in] app_id Application to pin. No-op if already on the desktop or + * not in the registry. * * @throws None. - * @note Replaces any previously shown tiles. * @since 0.20 * @ingroup components */ - void setApps(const QList& apps); + void addShortcut(const QString& app_id); /** * @brief Updates this layer's geometry from the desktop's central area. * - * Wire to PanelManager::availableGeometryChanged. Invalid or empty rects - * are ignored (tiles would otherwise flash at 0,0 before relayout). The - * grid is rebuilt because column count may change with width. + * Wire to PanelManager::availableGeometryChanged. Invalid/empty rects are + * ignored (tiles would flash at 0,0 before relayout). * * @param[in] available The central rectangle between the bars. * @@ -151,10 +149,7 @@ class DesktopIconLayer final : public QWidget { signals: /** - * @brief Emitted when a desktop tile is clicked. - * - * Connect to the same launch_app handler used by the taskbar and launcher - * so all three entry points share one dispatch path. + * @brief Emitted when a desktop tile is tapped (launched). * * @param[in] app_id The clicked application identifier. * @@ -163,12 +158,21 @@ class DesktopIconLayer final : public QWidget { */ void appClicked(const QString& app_id); - protected: /** - * @brief Paints nothing. + * @brief Emitted whenever the shortcut layout changes (add / remove / swap). * - * The container is a transparent passthrough; LauncherTile children paint - * themselves. + * Connect to DesktopShortcutStore::save to persist. + * + * @param[in] shortcuts The new shortcut layout. + * + * @since 0.20 + * @ingroup components + */ + void shortcutsChanged(const QList& shortcuts); + + protected: + /** + * @brief Paints only the drag snap-highlight (transparent otherwise). * * @param[in] event The paint event descriptor (unused). * @@ -179,13 +183,30 @@ class DesktopIconLayer final : public QWidget { void paintEvent(QPaintEvent* event) override; private: - /// @brief Clears existing tiles and lays them out for the current geometry. + /// @brief Clears existing tiles and lays them out by shortcut cell. void rebuildGrid(); - - QGridLayout* grid_{nullptr}; ///< Tile grid. Ownership: this widget. - QList tiles_; ///< Current tiles. Ownership: Qt parented. - QList apps_; ///< Backing application list. - QRect last_available_; ///< Last valid central geometry received. + /// @brief Creates the floating ghost when a tile's long-press fires. + void onTileLongPressed(const QString& app_id); + /// @brief Moves the ghost and updates the snap target cell. + void onTileDragMoved(const QPoint& global_pos); + /// @brief Commits the drop: swap, remove, persist, rebuild. + void onTileDragEnded(const QPoint& global_pos); + + LauncherTile* tileForApp(const QString& app_id) const; + const AppEntry* findEntry(const QString& app_id) const; + QPoint cellAt(const QPoint& layer_pos) const; ///< (col,row) or (-1,-1) if out. + QPoint firstFreeCell() const; + + QGridLayout* grid_{nullptr}; ///< Tile grid. Ownership: this widget. + QList tiles_; ///< Current tiles. Ownership: Qt parented. + QList shortcuts_; ///< User-managed layout. + QList registry_; ///< App registry for app_id resolution. + QRect last_available_; ///< Last valid central geometry. + + QString dragging_app_id_; ///< app_id being dragged ("" = none). + QPoint drag_origin_cell_{-1, -1}; ///< Cell the drag started from. + QPoint drag_target_cell_{-1, -1}; ///< Current snap target (or (-1,-1)). + QLabel* drag_ghost_{nullptr}; ///< Floating pixmap overlay. Ownership: this. }; } // namespace cf::desktop::desktop_component diff --git a/ui/components/desktop_icon_layer/desktop_shortcut.h b/ui/components/desktop_icon_layer/desktop_shortcut.h new file mode 100644 index 000000000..325d7f912 --- /dev/null +++ b/ui/components/desktop_icon_layer/desktop_shortcut.h @@ -0,0 +1,35 @@ +/** + * @file desktop/ui/components/desktop_icon_layer/desktop_shortcut.h + * @brief User-placed desktop shortcut value type. + * + * A DesktopShortcut is one entry in the user-managed desktop icon layout: the + * application to launch (by app_id, resolved against the merged app registry at + * runtime) plus its grid cell. Only the app_id + position is persisted; the + * icon/name/launch-kind are looked up live so the shortcut never goes stale + * when an app is updated or its manifest changes. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-07 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#pragma once + +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief One user-placed desktop shortcut. + * + * @ingroup components + */ +struct DesktopShortcut { + QString app_id; ///< Stable app identifier (key into the merged AppEntry list). + int col{0}; ///< Grid column (0-based, left-to-right). + int row{0}; ///< Grid row (0-based, top-to-bottom). +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/desktop_icon_layer/desktop_shortcut_store.cpp b/ui/components/desktop_icon_layer/desktop_shortcut_store.cpp new file mode 100644 index 000000000..0d9a470fc --- /dev/null +++ b/ui/components/desktop_icon_layer/desktop_shortcut_store.cpp @@ -0,0 +1,113 @@ +/** + * @file desktop/ui/components/desktop_icon_layer/desktop_shortcut_store.cpp + * @brief Implementation of DesktopShortcutStore. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-07 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "desktop_shortcut_store.h" + +#include "cfconfig.hpp" +#include "cflog.h" + +#include +#include +#include +#include + +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// ConfigStore domain for the desktop icon layout. +constexpr const char* kDomain = "desktop_icons"; +/// Config group/key (stored as a JSON string under "layout.shortcuts"). +constexpr const char* kGroup = "layout"; +constexpr const char* kKey = "shortcuts"; +/// Default-seed size and column count (kept modest so the first-run desktop +/// is not cluttered; the user customizes from here). +constexpr int kSeedCount = 12; +constexpr int kSeedCols = 8; +/// Tag for log lines. +constexpr const char* kLogTag = "DesktopShortcutStore"; + +/// JSON field names. +constexpr const char* kFieldAppId = "app_id"; +constexpr const char* kFieldCol = "col"; +constexpr const char* kFieldRow = "row"; +} // namespace + +QByteArray DesktopShortcutStore::serialize(const QList& shortcuts) { + QJsonArray arr; + for (const auto& s : shortcuts) { + QJsonObject o; + o[QLatin1String(kFieldAppId)] = s.app_id; + o[QLatin1String(kFieldCol)] = s.col; + o[QLatin1String(kFieldRow)] = s.row; + arr.append(o); + } + return QJsonDocument(arr).toJson(QJsonDocument::Compact); +} + +QList DesktopShortcutStore::deserialize(const QByteArray& data) { + QList result; + if (data.isEmpty()) { + return result; + } + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(data, &err); + if (err.error != QJsonParseError::NoError || !doc.isArray()) { + cf::log::warningftag(kLogTag, "shortcuts JSON malformed: {}", + err.errorString().toStdString()); + return result; + } + for (const auto& value : doc.array()) { + const QJsonObject o = value.toObject(); + DesktopShortcut s; + s.app_id = o.value(QLatin1String(kFieldAppId)).toString(); + s.col = o.value(QLatin1String(kFieldCol)).toInt(); + s.row = o.value(QLatin1String(kFieldRow)).toInt(); + if (!s.app_id.isEmpty()) { + result.append(s); + } + } + return result; +} + +QList DesktopShortcutStore::load() { + namespace cfg = cf::config; + auto domain = cfg::ConfigStore::instance().domain(kDomain); + const std::string raw = + domain.query(cfg::KeyView{.group = kGroup, .key = kKey}, std::string()); + return deserialize(QByteArray::fromStdString(raw)); +} + +void DesktopShortcutStore::save(const QList& shortcuts) { + namespace cfg = cf::config; + auto domain = cfg::ConfigStore::instance().domain(kDomain); + const std::string raw = serialize(shortcuts).toStdString(); + domain.set(cfg::KeyView{.group = kGroup, .key = kKey}, raw, cfg::Layer::User, + cfg::NotifyPolicy::Immediate); + cfg::ConfigStore::instance().sync(); +} + +QList DesktopShortcutStore::seedFrom(const QList& apps) { + QList result; + const int n = std::min(static_cast(apps.size()), kSeedCount); + result.reserve(n); + for (int i = 0; i < n; ++i) { + DesktopShortcut s; + s.app_id = apps[i].app_id; + s.col = i % kSeedCols; + s.row = i / kSeedCols; + result.append(s); + } + return result; +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/desktop_icon_layer/desktop_shortcut_store.h b/ui/components/desktop_icon_layer/desktop_shortcut_store.h new file mode 100644 index 000000000..62baf5b66 --- /dev/null +++ b/ui/components/desktop_icon_layer/desktop_shortcut_store.h @@ -0,0 +1,115 @@ +/** + * @file desktop/ui/components/desktop_icon_layer/desktop_shortcut_store.h + * @brief Persists the user's desktop shortcut layout via ConfigStore. + * + * DesktopShortcutStore bridges DesktopShortcut values and the project's 4-layer + * ConfigStore: the layout is serialized to a compact JSON string and stored at + * the User layer (domain "desktop_icons") so it survives sessions. On first run + * (no stored layout) the caller seeds a default set from the merged app + * registry. Serialization is split out as static functions so the JSON + * round-trip is unit-testable without touching the real ConfigStore. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-07 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#pragma once + +#include "app_entry.h" +#include "desktop_shortcut.h" + +#include +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief Loads / saves / seeds the desktop shortcut layout. + * + * @ingroup components + */ +class DesktopShortcutStore { + public: + /** + * @brief Loads the persisted shortcut layout. + * + * Reads the JSON string at User layer domain "desktop_icons". Returns an + * empty list when nothing is stored (first run) or the JSON is malformed + * (a warning is logged, never an exception). + * + * @return The persisted shortcuts; empty on first run or parse failure. + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + static QList load(); + + /** + * @brief Persists the shortcut layout to the User layer. + * + * Serializes @p shortcuts to a compact JSON string and writes it via + * ConfigStore (Immediate notify) + sync, so the change hits disk. + * + * @param[in] shortcuts The layout to persist. + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + static void save(const QList& shortcuts); + + /** + * @brief Builds a default shortcut layout from the app registry. + * + * Used on first run: takes the first N apps and lays them out left-to-right + * then top-to-bottom so the desktop is not empty. The caller persists the + * result so subsequent runs are user-managed. + * + * @param[in] apps The merged app registry (builtin + manifest + .desktop). + * + * @return A seeded shortcut list (about + the first few apps in grid order). + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + static QList seedFrom(const QList& apps); + + /** + * @brief Serializes shortcuts to compact JSON bytes. + * + * Exposed for unit tests so the round-trip can be verified without the real + * ConfigStore. Format: `[{"app_id":..,"col":..,"row":..}, ...]`. + * + * @param[in] shortcuts The layout to serialize. + * + * @return Compact JSON bytes. + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + static QByteArray serialize(const QList& shortcuts); + + /** + * @brief Parses shortcuts from JSON bytes. + * + * Inverse of serialize(). Malformed input yields an empty list (a warning + * is logged); entries with an empty app_id are skipped. + * + * @param[in] data JSON bytes (compact or pretty). + * + * @return Parsed shortcuts; empty on parse failure or empty input. + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + static QList deserialize(const QByteArray& data); +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/launcher/app_launcher.cpp b/ui/components/launcher/app_launcher.cpp index 1c3f5c415..7961a7b84 100644 --- a/ui/components/launcher/app_launcher.cpp +++ b/ui/components/launcher/app_launcher.cpp @@ -211,10 +211,16 @@ void AppLauncher::rebuildGrid() { continue; // Filtered out by the search box. } auto* tile = new LauncherTile(app, this); + tile->setContext(TileContext::Launcher); connect(tile, &LauncherTile::clicked, this, [this](const QString& app_id) { emit appLaunched(app_id); hideLauncher(); }); + // Long-press in the launcher pins the app to the desktop. + connect(tile, &LauncherTile::addToDesktopRequested, this, [this](const QString& app_id) { + emit addToDesktopRequested(app_id); + hideLauncher(); + }); grid_->addWidget(tile, row, col); tiles_.append(tile); ++col; diff --git a/ui/components/launcher/app_launcher.h b/ui/components/launcher/app_launcher.h index a7b8bcdd7..cbcee13f9 100644 --- a/ui/components/launcher/app_launcher.h +++ b/ui/components/launcher/app_launcher.h @@ -154,6 +154,16 @@ class AppLauncher final : public QWidget { */ void appLaunched(const QString& app_id); + /** + * @brief Emitted when a tile is long-pressed (pin the app to the desktop). + * + * @param[in] app_id The application to add to the desktop. + * + * @since 0.20 + * @ingroup components + */ + void addToDesktopRequested(const QString& app_id); + protected: /** * @brief Paints the rounded Material surface background. diff --git a/ui/components/launcher/launcher_tile.cpp b/ui/components/launcher/launcher_tile.cpp index e176d0f7c..50a0a1ca4 100644 --- a/ui/components/launcher/launcher_tile.cpp +++ b/ui/components/launcher/launcher_tile.cpp @@ -26,9 +26,11 @@ #include #include #include +#include #include #include #include +#include #include #include @@ -52,6 +54,14 @@ 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. +/// Long-press delay before a desktop tile enters drag mode. Resistive touch +/// (i.MX6ULL tslib) needs a longer hold so a real tap never trips it. +constexpr int kLongPressMs = 500; +/// Move distance (px) from the press point that cancels a pending long-press. +/// Resistive panels jitter several px when held still, so this is generous. +constexpr qreal kDragThresholdPx = 10.0; +/// Tile opacity while being dragged (the floating ghost carries the full look). +constexpr qreal kDragDimOpacity = 0.45; } // namespace LauncherTile::LauncherTile(AppEntry entry, QWidget* parent) @@ -62,6 +72,30 @@ LauncherTile::LauncherTile(AppEntry entry, QWidget* parent) setupAnimations(); applyTheme(); refreshIcon(); + + long_press_timer_ = new QTimer(this); + long_press_timer_->setSingleShot(true); + long_press_timer_->setInterval(kLongPressMs); + connect(long_press_timer_, &QTimer::timeout, this, [this]() { + if (drag_state_ != DragState::Pressed) { + return; + } + if (context_ == TileContext::Launcher) { + // Launcher long-press pins the app to the desktop; release is a no-op. + drag_state_ = DragState::Cancelled; + emit addToDesktopRequested(entry_.app_id); + } else { + // Desktop long-press enters drag mode. Kill the ripple so it does + // not expand under the finger for the whole drag. + if (rippling_) { + ripple_anim_->stop(); + rippling_ = false; + } + drag_state_ = DragState::DragReady; + emit longPressed(entry_.app_id); + } + update(); + }); } LauncherTile::~LauncherTile() = default; @@ -81,6 +115,12 @@ void LauncherTile::paintEvent(QPaintEvent* /*event*/) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); + // Dim the tile while it is being dragged; the floating ghost (owned by + // DesktopIconLayer) carries the full-opacity look under the finger. + if (drag_state_ == DragState::DragReady || drag_state_ == DragState::Dragging) { + p.setOpacity(kDragDimOpacity); + } + const QRectF cell = rect(); const qreal edge = kIconBase * hover_scale_; const qreal cx = cell.center().x(); @@ -151,17 +191,52 @@ void LauncherTile::leaveEvent(QEvent* /*event*/) { void LauncherTile::mousePressEvent(QMouseEvent* event) { if (event->button() == Qt::LeftButton) { startRipple(event->position()); + // Arm the long-press timer in both contexts: launcher long-press pins + // to desktop, desktop long-press starts a drag. A quick tap (release + // before the timer fires) still emits clicked() in mouseReleaseEvent. + press_pos_ = event->position(); + drag_state_ = DragState::Pressed; + long_press_timer_->start(); } QWidget::mousePressEvent(event); } void LauncherTile::mouseReleaseEvent(QMouseEvent* event) { - if (event->button() == Qt::LeftButton && rect().contains(event->position().toPoint())) { - emit clicked(entry_.app_id); + if (event->button() == Qt::LeftButton) { + long_press_timer_->stop(); + const DragState state = drag_state_; + drag_state_ = DragState::Idle; + if (state == DragState::Dragging || state == DragState::DragReady) { + // Drag end: the layer computes the drop cell and commits. + emit dragEnded(event->globalPosition().toPoint()); + } else if (state == DragState::Pressed && rect().contains(event->position().toPoint())) { + // Quick tap (no long-press, no big move): launch. + emit clicked(entry_.app_id); + } + // Cancelled (or launcher add-to-desktop already fired): no-op. + update(); } QWidget::mouseReleaseEvent(event); } +void LauncherTile::mouseMoveEvent(QMouseEvent* event) { + if (event->buttons() & Qt::LeftButton) { + if (drag_state_ == DragState::Pressed) { + // Moved beyond threshold before the long-press fired: scrap. Do + // not click either — a deliberate sweep is not a tap. Resistive + // touch jitters, so only cancel past the threshold. + if (QLineF(press_pos_, event->position()).length() >= kDragThresholdPx) { + long_press_timer_->stop(); + drag_state_ = DragState::Cancelled; + } + } else if (drag_state_ == DragState::DragReady || drag_state_ == DragState::Dragging) { + drag_state_ = DragState::Dragging; + emit dragMoved(event->globalPosition().toPoint()); + } + } + QWidget::mouseMoveEvent(event); +} + // -- Internal -------------------------------------------------------------- void LauncherTile::applyTheme() { try { diff --git a/ui/components/launcher/launcher_tile.h b/ui/components/launcher/launcher_tile.h index 0e624bad0..b98869f1f 100644 --- a/ui/components/launcher/launcher_tile.h +++ b/ui/components/launcher/launcher_tile.h @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -30,10 +31,26 @@ class QEnterEvent; class QEvent; class QMouseEvent; class QPaintEvent; +class QTimer; class QVariantAnimation; namespace cf::desktop::desktop_component { +/** + * @brief Where a LauncherTile is used; drives long-press behavior. + * + * In Launcher context a long-press emits addToDesktopRequested() (pin to the + * desktop). In Desktop context a long-press enters drag mode (longPressed / + * dragMoved / dragEnded) for rearranging. The target is i.MX6ULL resistive + * touch, so drag is long-press-triggered, not mouse-move-triggered. + * + * @ingroup components + */ +enum class TileContext { + Launcher, ///< Shown in the start-menu popup; long-press pins to desktop. + Desktop, ///< Shown on the desktop wallpaper; long-press starts a drag. +}; + /** * @brief One application tile shown in the launcher grid. * @@ -111,6 +128,18 @@ class LauncherTile final : public QWidget { */ QSize sizeHint() const override; + /** + * @brief Sets whether this tile lives in the launcher or on the desktop. + * + * @param[in] context Launcher (long-press sends to desktop) or Desktop + * (long-press starts a drag). + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + void setContext(TileContext context) noexcept { context_ = context; } + signals: /** * @brief Emitted when the tile is clicked (left-button release inside). @@ -122,6 +151,50 @@ class LauncherTile final : public QWidget { */ void clicked(const QString& app_id); + /** + * @brief Emitted when a desktop tile's long-press fires (drag begins). + * + * The DesktopIconLayer creates a floating ghost and tracks dragMoved. + * + * @param[in] app_id The application identifier being dragged. + * + * @since 0.20 + * @ingroup components + */ + void longPressed(const QString& app_id); + + /** + * @brief Emitted while a desktop tile is being dragged. + * + * @param[in] global_pos Current pointer position in global screen coords. + * + * @since 0.20 + * @ingroup components + */ + void dragMoved(const QPoint& global_pos); + + /** + * @brief Emitted when a desktop tile drag ends (finger lifted). + * + * The layer computes the drop cell and applies swap / remove + persist. + * + * @param[in] global_pos Final pointer position in global screen coords. + * + * @since 0.20 + * @ingroup components + */ + void dragEnded(const QPoint& global_pos); + + /** + * @brief Emitted when a launcher tile is long-pressed (pin to desktop). + * + * @param[in] app_id The application to add to the desktop. + * + * @since 0.20 + * @ingroup components + */ + void addToDesktopRequested(const QString& app_id); + protected: /** * @brief Paints the glyph tile, overlay, ripple, initial, and label. @@ -188,6 +261,17 @@ class LauncherTile final : public QWidget { */ void mouseReleaseEvent(QMouseEvent* event) override; + /** + * @brief Tracks drag movement or cancels an in-progress long-press. + * + * @param[in] event The mouse move event descriptor. + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + void mouseMoveEvent(QMouseEvent* event) override; + private: /// @brief Resolves theme colors and typography, then repaints. void applyTheme(); @@ -200,6 +284,15 @@ class LauncherTile final : public QWidget { /// @brief Resolves entry_.icon_path into cached_icon_ (null -> letter fallback). void refreshIcon(); + /// Long-press / drag state machine (desktop context only). + enum class DragState { + Idle, ///< No interaction. + Pressed, ///< Finger down, long-press timer running. + Cancelled, ///< Moved past threshold before timer; release is a no-op. + DragReady, ///< Timer fired; next move begins the drag. + Dragging, ///< Actively dragging; release commits the drop. + }; + AppEntry entry_; ///< Backing application entry. QPixmap cached_icon_; ///< Resolved icon (null -> initial-letter fallback). qreal hover_scale_{1.0}; ///< Current glyph scale (1.0 idle, >1 hover). @@ -216,6 +309,11 @@ class LauncherTile final : public QWidget { QVariantAnimation* hover_anim_{nullptr}; ///< Zoom-in/out animation. QVariantAnimation* ripple_anim_{nullptr}; ///< Ripple expansion animation. + + TileContext context_{TileContext::Launcher}; ///< Launcher vs desktop behavior. + DragState drag_state_{DragState::Idle}; ///< Long-press / drag state. + QPointF press_pos_; ///< Press origin (local coords). + QTimer* long_press_timer_{nullptr}; ///< Long-press detector. }; } // namespace cf::desktop::desktop_component From 58612966af62fe5e7cebf2605625ff311b4bfbc8 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Tue, 7 Jul 2026 22:00:13 +0800 Subject: [PATCH 4/9] fix(desktop): drag-end visual commit + resize-safe grid layout Four root causes behind the desktop icon "bounces back" / "flies on resize" bug, all in rebuildGrid / cellAt: - singleShot(0) rebuildGrid: rebuildGrid ran inside the drag-source tile's mouseReleaseEvent handler; deleting the event-receiving widget mid-event is Qt's deferred-delete trap (data updated, screen never refreshed). - removeWidget before delete: QGridLayout keeps a stale item until the destroyed signal fires asynchronously; new addWidget then landed at (0,0). - setRow/ColumnMinimumHeight/Width: empty rows/cols collapsed to 0, so a tile at row 5 with rows 2-4 empty rendered visually at row 2 and cellAt()'s (margin + row*stride) math no longer matched the widget pos. - clear stale minimums before re-setting: shrinking the window otherwise left the old (larger) grid's minimums in place and flung tiles around. Drops the temporary geom / dragMove / widget.pos diagnostics now that the root causes are fixed; the dragEnd + swap/move logs stay as traces. --- .../desktop_icon_layer/desktop_icon_layer.cpp | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp index 79344a55e..a50b50414 100644 --- a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include @@ -129,7 +130,10 @@ void DesktopIconLayer::addShortcut(const QString& app_id) { s.row = cell.y(); shortcuts_.append(s); emit shortcutsChanged(shortcuts_); - rebuildGrid(); + // Defer rebuild: addShortcut runs inside the launcher tile's mouseRelease + // handler, and rebuildGrid's qDeleteAll would delete a widget mid-event — + // singleShot(0) runs once control returns to the event loop, where it's safe. + QTimer::singleShot(0, this, [this]() { rebuildGrid(); }); } void DesktopIconLayer::onAvailableGeometryChanged(const QRect& available) { @@ -162,7 +166,16 @@ void DesktopIconLayer::paintEvent(QPaintEvent* /*event*/) { } void DesktopIconLayer::rebuildGrid() { - qDeleteAll(tiles_); + // removeWidget BEFORE delete: deleting a widget leaves a stale item in the + // QGridLayout until the destroyed signal is processed (asynchronous). The + // next addWidget then lands in a layout that still references dead widgets, + // and every new tile ends up at pos (0,0) — the data layer is correct but + // nothing is visually where shortcuts_ says. Removing synchronously keeps + // the layout clean so the new addWidget lands in the right cell. + for (auto* tile : tiles_) { + grid_->removeWidget(tile); + delete tile; + } tiles_.clear(); const auto dims = @@ -170,6 +183,28 @@ void DesktopIconLayer::rebuildGrid() { if (dims.columns <= 0 || dims.rows <= 0) { return; // No valid geometry yet. } + // Force EVERY row/column (including empty ones) to a full cell size. Without + // this, QGridLayout collapses rows/cols that hold no widget to zero, so the + // grid occupies only the rows that have tiles instead of cols*stride x + // rows*stride. A tile at row 5 with rows 2-4 empty then renders visually at + // row 2 — cellAt()'s (margin + row*stride) math no longer matches where the + // widget actually is, and the tile looks like it "bounced back" or "flew". + // Clear row/col minimums from a previous (possibly larger) grid first: + // setRowMinimumHeight only updates the rows we name, so rows beyond the new + // capacity would keep their old 96px minimum and the grid would stay + // oversized after a window shrink, flinging tiles out of place. + for (int r = 0; r < grid_->rowCount(); ++r) { + grid_->setRowMinimumHeight(r, 0); + } + for (int c = 0; c < grid_->columnCount(); ++c) { + grid_->setColumnMinimumWidth(c, 0); + } + for (int r = 0; r < dims.rows; ++r) { + grid_->setRowMinimumHeight(r, kCellSize); + } + for (int c = 0; c < dims.columns; ++c) { + grid_->setColumnMinimumWidth(c, kCellSize); + } for (const auto& s : shortcuts_) { if (s.col < 0 || s.col >= dims.columns || s.row < 0 || s.row >= dims.rows) { cf::log::warningftag(kLogTag, "shortcut {} at ({},{}) outside grid ({}x{}); skipped", @@ -191,6 +226,8 @@ void DesktopIconLayer::rebuildGrid() { grid_->addWidget(tile, s.row, s.col); tiles_.append(tile); } + grid_->activate(); // Force reflow after bulk add/remove — without this the + // new widgets can stay at stale positions after a drag. } LauncherTile* DesktopIconLayer::tileForApp(const QString& app_id) const { @@ -287,6 +324,8 @@ void DesktopIconLayer::onTileDragEnded(const QPoint& global_pos) { } const QPoint layer_pos = mapFromGlobal(global_pos); const QPoint target = cellAt(layer_pos); + cf::log::infoftag(kLogTag, "dragEnd: target=({},{}) origin=({},{})", target.x(), target.y(), + drag_origin_cell_.x(), drag_origin_cell_.y()); bool changed = false; QList updated = shortcuts_; @@ -324,7 +363,16 @@ void DesktopIconLayer::onTileDragEnded(const QPoint& global_pos) { if (changed) { shortcuts_ = std::move(updated); emit shortcutsChanged(shortcuts_); - rebuildGrid(); + // Defer the rebuild to after this mouseRelease handler returns: + // rebuildGrid uses qDeleteAll on the tiles, and the drag-source tile is + // the very widget receiving this event — deleting it mid-event hits + // Qt's deferred-delete trap and the new positions never visually land + // (the tile "bounces back"). singleShot(0) runs once the event loop + // regains control, where deleting any widget is safe. + QTimer::singleShot(0, this, [this]() { + cf::log::infoftag(kLogTag, "deferred rebuildGrid firing"); + rebuildGrid(); + }); } update(); } From 2d93ab05fceab95dd90db8ebd30304557cd293a4 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Tue, 7 Jul 2026 22:12:13 +0800 Subject: [PATCH 5/9] fix(desktop): fill horizontal width (drop column cap, dynamic seed cols) The desktop icon grid left wide displays half-empty due to two caps: - computeGridDimensions capped columns at kMaxColumns=8 even when the available width fit 14+; dropped the cap so the grid fills the width. - seedFrom hardcoded kSeedCols=8; now takes cols from the caller, and CFDesktopEntity derives it from the primary screen width so first-run layout fills the screen instead of clustering in the left half. Updates grid-math unit tests for the uncapped column count. --- .../desktop_icon_layer/desktop_icon_layer_test.cpp | 12 ++++++------ .../desktop_shortcut_store_test.cpp | 6 +++--- ui/CFDesktopEntity.cpp | 13 ++++++++++++- .../desktop_icon_layer/desktop_icon_layer.cpp | 5 ++--- .../desktop_icon_layer/desktop_shortcut_store.cpp | 10 ++++++---- .../desktop_icon_layer/desktop_shortcut_store.h | 4 +++- 6 files changed, 32 insertions(+), 18 deletions(-) diff --git a/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp b/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp index 8d8086d73..28ee13315 100644 --- a/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp +++ b/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp @@ -5,7 +5,7 @@ * Exercises the pure grid-sizing math (column clamp, row capacity, cap-and- * truncate) without instantiating a QWidget. Layout constants mirror the * anonymous namespace in desktop_icon_layer.cpp: kCellSize=96, kGridSpacing=16, - * kMargin=24, kMaxColumns=8 (stride = 96+16 = 112; usable = side - 2*24 + 16). + * kMargin=24 (stride = 96+16 = 112; usable = side - 2*24 + 16; no column cap). * * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) * @date 2026-07-07 @@ -39,10 +39,10 @@ TEST(DesktopIconLayerGrid, NegativeCountReturnsZero) { EXPECT_EQ(d.shown, 0); } -TEST(DesktopIconLayerGrid, ColumnsClampedToMax) { - // usable_w = 1280 - 48 + 16 = 1248; 1248 / 112 = 11 -> clamped to kMaxColumns=8. +TEST(DesktopIconLayerGrid, ColumnsFillWideWidth) { + // usable_w = 1280 - 48 + 16 = 1248; 1248 / 112 = 11 (no artificial cap). const auto d = computeGridDimensions(QSize(1280, 720), 5); - EXPECT_EQ(d.columns, 8); + EXPECT_EQ(d.columns, 11); } TEST(DesktopIconLayerGrid, RowsAndCapacityForTypicalDesktop) { @@ -54,9 +54,9 @@ TEST(DesktopIconLayerGrid, RowsAndCapacityForTypicalDesktop) { TEST(DesktopIconLayerGrid, TruncatesWhenOverCapacity) { const auto d = computeGridDimensions(QSize(1280, 720), 100); - EXPECT_EQ(d.columns, 8); + EXPECT_EQ(d.columns, 11); EXPECT_EQ(d.rows, 6); - EXPECT_EQ(d.shown, 48); // 8*6, not 100. + EXPECT_EQ(d.shown, 66); // 11*6, not 100. } TEST(DesktopIconLayerGrid, NarrowWidthFloorOneColumn) { diff --git a/test/desktop/desktop_icon_layer/desktop_shortcut_store_test.cpp b/test/desktop/desktop_icon_layer/desktop_shortcut_store_test.cpp index f9f7bf707..a3b0c3e3f 100644 --- a/test/desktop/desktop_icon_layer/desktop_shortcut_store_test.cpp +++ b/test/desktop/desktop_icon_layer/desktop_shortcut_store_test.cpp @@ -63,7 +63,7 @@ TEST(DesktopShortcutStore, SeedFromLaysOutInGridOrder) { e.app_id = QStringLiteral("app%1").arg(i); apps.append(e); } - const auto seed = DesktopShortcutStore::seedFrom(apps); + const auto seed = DesktopShortcutStore::seedFrom(apps, 8); ASSERT_EQ(seed.size(), 5); // kSeedCols = 8, so the first 8 land in row 0, left-to-right. for (int i = 0; i < 5; ++i) { @@ -80,7 +80,7 @@ TEST(DesktopShortcutStore, SeedFromWrapsToSecondRow) { e.app_id = QStringLiteral("a%1").arg(i); apps.append(e); } - const auto seed = DesktopShortcutStore::seedFrom(apps); + const auto seed = DesktopShortcutStore::seedFrom(apps, 8); ASSERT_EQ(seed.size(), 10); // Index 8 wraps to col 0, row 1. EXPECT_EQ(seed[8].col, 0); @@ -94,5 +94,5 @@ TEST(DesktopShortcutStore, SeedFromCapsAtLimit) { e.app_id = QStringLiteral("a%1").arg(i); apps.append(e); } - EXPECT_EQ(DesktopShortcutStore::seedFrom(apps).size(), 12); // kSeedCount + EXPECT_EQ(DesktopShortcutStore::seedFrom(apps, 8).size(), 12); // kSeedCount } diff --git a/ui/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index 9a01f47bb..1c733bdc8 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -29,10 +29,12 @@ #include "system/hardware_tier/hardware_tier.h" #include #include +#include #include #include #include #include +#include #include #include @@ -369,7 +371,16 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { QList desktop_shortcuts = cf::desktop::desktop_component::DesktopShortcutStore::load(); if (desktop_shortcuts.isEmpty()) { - desktop_shortcuts = cf::desktop::desktop_component::DesktopShortcutStore::seedFrom(apps); + // Seed across as many columns as the primary screen can hold, so the + // first-run layout fills the width instead of clustering in the left + // half. The seed runs before PanelManager relayout delivers the real + // central rect, so estimate from the screen geometry. + int seed_cols = 8; + if (const auto* screen = QGuiApplication::primaryScreen()) { + seed_cols = std::max(8, (screen->availableGeometry().width() - 48) / 112); + } + desktop_shortcuts = + cf::desktop::desktop_component::DesktopShortcutStore::seedFrom(apps, seed_cols); cf::desktop::desktop_component::DesktopShortcutStore::save(desktop_shortcuts); } diff --git a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp index a50b50414..0e0aae730 100644 --- a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp @@ -74,9 +74,8 @@ GridDimensions computeGridDimensions(const QSize& available, int app_count) { if (cols < 1) { cols = 1; } - if (cols > kMaxColumns) { - cols = kMaxColumns; - } + // No artificial column cap: the grid fills the available width. (A prior + // kMaxColumns=8 cap left wide displays half-empty horizontally.) const int rows = (usable_h > 0) ? (usable_h / stride) : 0; const int capacity = cols * rows; diff --git a/ui/components/desktop_icon_layer/desktop_shortcut_store.cpp b/ui/components/desktop_icon_layer/desktop_shortcut_store.cpp index 0d9a470fc..317adf2fe 100644 --- a/ui/components/desktop_icon_layer/desktop_shortcut_store.cpp +++ b/ui/components/desktop_icon_layer/desktop_shortcut_store.cpp @@ -32,7 +32,6 @@ constexpr const char* kKey = "shortcuts"; /// Default-seed size and column count (kept modest so the first-run desktop /// is not cluttered; the user customizes from here). constexpr int kSeedCount = 12; -constexpr int kSeedCols = 8; /// Tag for log lines. constexpr const char* kLogTag = "DesktopShortcutStore"; @@ -96,15 +95,18 @@ void DesktopShortcutStore::save(const QList& shortcuts) { cfg::ConfigStore::instance().sync(); } -QList DesktopShortcutStore::seedFrom(const QList& apps) { +QList DesktopShortcutStore::seedFrom(const QList& apps, int cols) { QList result; + if (cols < 1) { + cols = 1; + } const int n = std::min(static_cast(apps.size()), kSeedCount); result.reserve(n); for (int i = 0; i < n; ++i) { DesktopShortcut s; s.app_id = apps[i].app_id; - s.col = i % kSeedCols; - s.row = i / kSeedCols; + s.col = i % cols; + s.row = i / cols; result.append(s); } return result; diff --git a/ui/components/desktop_icon_layer/desktop_shortcut_store.h b/ui/components/desktop_icon_layer/desktop_shortcut_store.h index 62baf5b66..cf7aa5b5e 100644 --- a/ui/components/desktop_icon_layer/desktop_shortcut_store.h +++ b/ui/components/desktop_icon_layer/desktop_shortcut_store.h @@ -70,6 +70,8 @@ class DesktopShortcutStore { * result so subsequent runs are user-managed. * * @param[in] apps The merged app registry (builtin + manifest + .desktop). + * @param[in] cols Column count for the seed layout (fills the width on + * wide screens; the caller derives it from screen size). * * @return A seeded shortcut list (about + the first few apps in grid order). * @@ -77,7 +79,7 @@ class DesktopShortcutStore { * @since 0.20 * @ingroup components */ - static QList seedFrom(const QList& apps); + static QList seedFrom(const QList& apps, int cols); /** * @brief Serializes shortcuts to compact JSON bytes. From 05ef044a14f0bd84c3338b15978cd442f0c4ad15 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Tue, 7 Jul 2026 22:29:30 +0800 Subject: [PATCH 6/9] fix(desktop): align drag highlight + cell hit-test to grid's real geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cellAt and paintEvent computed the snap cell by hand as margin + row*stride, which drifted off the actual cell positions QGridLayout picks once its alignment / minimum-size / spacing decisions move them — the highlight ended up offset from the tile that would land there. Both now use grid_->cellRect(row, col): cellAt walks the real cell rects to find the one under the pointer (snapping to the nearest cell inside the margin band instead of misclassifying it as a delete), and paintEvent draws the highlight at the real cell rect. Drag preview and drop target now line up exactly with the tiles. --- .../desktop_icon_layer/desktop_icon_layer.cpp | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp index 0e0aae730..a9a7ee577 100644 --- a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp @@ -152,9 +152,14 @@ void DesktopIconLayer::paintEvent(QPaintEvent* /*event*/) { if (dragging_app_id_.isEmpty() || drag_target_cell_.x() < 0) { return; } - const int stride = kCellSize + kGridSpacing; - const QRect cell_rect(kMargin + drag_target_cell_.x() * stride, - kMargin + drag_target_cell_.y() * stride, kCellSize, kCellSize); + // Use the grid's actual cell geometry so the highlight lines up exactly + // with the tile that will land there — hand-computing margin + row*stride + // drifts off the real cell when the layout's alignment / minimum decisions + // differ from the constants. + const QRect cell_rect = grid_->cellRect(drag_target_cell_.y(), drag_target_cell_.x()); + if (!cell_rect.isValid()) { + return; + } QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); QPen pen(QColor(0x6F, 0x5B, 0xA4, 200)); // MD3-ish primary, semi-transparent. @@ -257,10 +262,27 @@ QPoint DesktopIconLayer::cellAt(const QPoint& layer_pos) const { if (dims.columns <= 0 || dims.rows <= 0) { return {-1, -1}; } - const int stride = kCellSize + kGridSpacing; - const int col = std::clamp((layer_pos.x() - kMargin) / stride, 0, dims.columns - 1); - const int row = std::clamp((layer_pos.y() - kMargin) / stride, 0, dims.rows - 1); - return {col, row}; + // Use the grid's actual cell geometry (cellRect) instead of hand-computed + // margin + row*stride: the constants drift from the real cells once the + // layout's alignment / minimum-size / spacing decisions move them, and the + // highlight ended up offset from the tile. Inside the layer but in a margin + // band, snap to the nearest cell rather than treating it as a delete. + QPoint nearest = {-1, -1}; + int best_dist = -1; + for (int r = 0; r < dims.rows; ++r) { + for (int c = 0; c < dims.columns; ++c) { + const QRect cr = grid_->cellRect(r, c); + if (cr.contains(layer_pos)) { + return {c, r}; + } + const int d = (layer_pos - cr.center()).manhattanLength(); + if (best_dist < 0 || d < best_dist) { + best_dist = d; + nearest = {c, r}; + } + } + } + return nearest; } QPoint DesktopIconLayer::firstFreeCell() const { From 9d6ae52b6d7cf996fa58a5302112f0c146b7b8b5 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Wed, 8 Jul 2026 08:20:56 +0800 Subject: [PATCH 7/9] feat(desktop): scale shortcut tiles to fit before truncating DesktopIconLayer tiles were fixed at 96px; when the window shrank, tiles beyond the grid capacity vanished (skipped). Now computeGridDimensions scans cell from 96 down to 48 and picks the largest edge whose cols*rows still fits app_count, shrinking tiles proportionally so all shortcuts stay visible. Only when app_count exceeds even the 48px capacity does it truncate. - GridDimensions gains a cell field ([48,96]; 0 when no valid layout). - LauncherTile.paintEvent derives all geometry/fonts from width()/96 so it renders correctly at any scaled cell; launcher popup tiles keep the 96px constructor default and reproduce the original pixel layout exactly (CONST/96 form avoids float drift, e.g. label size stays 13). - applyTheme no longer sets font pixel sizes; paintEvent owns that now. - Tests: rewrite the truncate case (now scales to fit), add a floor- truncation case, assert cell across all cases. --- .../desktop_icon_layer_test.cpp | 61 ++++++++++++---- .../desktop_icon_layer/desktop_icon_layer.cpp | 54 ++++++++++----- .../desktop_icon_layer/desktop_icon_layer.h | 10 ++- ui/components/launcher/launcher_tile.cpp | 69 ++++++++++++------- 4 files changed, 133 insertions(+), 61 deletions(-) diff --git a/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp b/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp index 28ee13315..29d514a85 100644 --- a/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp +++ b/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp @@ -2,14 +2,18 @@ * @file test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp * @brief GoogleTest unit tests for computeGridDimensions. * - * Exercises the pure grid-sizing math (column clamp, row capacity, cap-and- - * truncate) without instantiating a QWidget. Layout constants mirror the - * anonymous namespace in desktop_icon_layer.cpp: kCellSize=96, kGridSpacing=16, - * kMargin=24 (stride = 96+16 = 112; usable = side - 2*24 + 16; no column cap). + * Exercises the pure grid-sizing math (column floor, row capacity, adaptive + * tile scaling, floor-and-truncate) without instantiating a QWidget. Layout + * constants mirror the anonymous namespace in desktop_icon_layer.cpp: + * kCellMax=96, kCellMin=48, kGridSpacing=16, kMargin=24 (at cell=96 the stride + * is 96+16=112; usable = side - 2*24 + 16 = side - 32). When app_count exceeds + * the cell=96 capacity, computeGridDimensions scans cell from 96 down to 48 and + * picks the largest edge whose columns*rows still fits, truncating only when + * even cell=48 cannot fit everything. * * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) * @date 2026-07-07 - * @version 0.1 + * @version 0.2 * @since 0.20 * @ingroup components */ @@ -27,58 +31,85 @@ TEST(DesktopIconLayerGrid, InvalidSizeReturnsZero) { EXPECT_EQ(d.columns, 0); EXPECT_EQ(d.rows, 0); EXPECT_EQ(d.shown, 0); + EXPECT_EQ(d.cell, 0); } TEST(DesktopIconLayerGrid, InvalidCountReturnsZero) { const auto d = computeGridDimensions(QSize(1280, 720), 0); EXPECT_EQ(d.shown, 0); + EXPECT_EQ(d.cell, 0); } TEST(DesktopIconLayerGrid, NegativeCountReturnsZero) { const auto d = computeGridDimensions(QSize(1280, 720), -3); EXPECT_EQ(d.shown, 0); + EXPECT_EQ(d.cell, 0); } TEST(DesktopIconLayerGrid, ColumnsFillWideWidth) { - // usable_w = 1280 - 48 + 16 = 1248; 1248 / 112 = 11 (no artificial cap). + // usable_w = 1280 - 32 = 1248; at cell=96 stride=112 -> 1248/112 = 11 cols. + // count=5 fits at cell=96, so no shrink. const auto d = computeGridDimensions(QSize(1280, 720), 5); EXPECT_EQ(d.columns, 11); + EXPECT_EQ(d.cell, 96); } TEST(DesktopIconLayerGrid, RowsAndCapacityForTypicalDesktop) { - // usable_h = 720 - 48 + 16 = 688; 688 / 112 = 6 rows; capacity = 8*6 = 48. + // usable_h = 720 - 32 = 688; at cell=96 -> 688/112 = 6 rows. const auto d = computeGridDimensions(QSize(1280, 720), 5); EXPECT_EQ(d.rows, 6); EXPECT_EQ(d.shown, 5); // Below capacity: all shown. + EXPECT_EQ(d.cell, 96); } -TEST(DesktopIconLayerGrid, TruncatesWhenOverCapacity) { +TEST(DesktopIconLayerGrid, ScalesToFitWhenShrunk) { + // 1280x720, count=100. At cell=96 capacity is 11*6=66 < 100, so the scan + // shrinks the tile. cell=70 (stride 86): cols=floor(1248/86)=14, + // rows=floor(688/86)=8, capacity 112 >= 100. cell=71 (stride 87) drops rows + // to 7 -> 14*7=98 < 100, so 70 is the largest fitting edge. All 100 shown. const auto d = computeGridDimensions(QSize(1280, 720), 100); - EXPECT_EQ(d.columns, 11); - EXPECT_EQ(d.rows, 6); - EXPECT_EQ(d.shown, 66); // 11*6, not 100. + EXPECT_EQ(d.cell, 70); + EXPECT_EQ(d.columns, 14); + EXPECT_EQ(d.rows, 8); + EXPECT_EQ(d.shown, 100); +} + +TEST(DesktopIconLayerGrid, TruncatesOnlyAtFloor) { + // 300x300, count=100. usable 268x268. Even cell=48 (stride 64) fits only + // floor(268/64)=4 cols * 4 rows = 16 < 100, so the scan exhausts and cell + // stays at the 48 floor; shown is honestly truncated to capacity. + const auto d = computeGridDimensions(QSize(300, 300), 100); + EXPECT_EQ(d.cell, 48); + EXPECT_EQ(d.columns, 4); + EXPECT_EQ(d.rows, 4); + EXPECT_EQ(d.shown, 16); } TEST(DesktopIconLayerGrid, NarrowWidthFloorOneColumn) { - // usable_w = 200 - 48 + 16 = 168; 168 / 112 = 1. + // usable_w = 200 - 32 = 168; at cell=96 -> 168/112 = 1. const auto d = computeGridDimensions(QSize(200, 720), 3); EXPECT_EQ(d.columns, 1); + EXPECT_EQ(d.cell, 96); } TEST(DesktopIconLayerGrid, ExtremelyNarrowStillOneColumn) { - // usable_w = 50 - 48 + 16 = 18; 18 / 112 = 0 -> floored to 1. + // usable_w = 50 - 32 = 18; at cell=96 -> 18/112 = 0 -> floored to 1. const auto d = computeGridDimensions(QSize(50, 720), 3); EXPECT_EQ(d.columns, 1); + EXPECT_EQ(d.cell, 96); } -TEST(DesktopIconLayerGrid, ZeroHeightShowsNothing) { - // usable_h = 50 - 48 + 16 = 18; 18 / 112 = 0 rows; capacity 0. +TEST(DesktopIconLayerGrid, ZeroHeightReportsNoLayout) { + // usable_h = 50 - 32 = 18; rows=0 at every cell -> capacity 0. cell reports + // 0 (no valid layout), not the floor, since no tile can be placed. const auto d = computeGridDimensions(QSize(1280, 50), 10); EXPECT_EQ(d.rows, 0); EXPECT_EQ(d.shown, 0); + EXPECT_EQ(d.cell, 0); } TEST(DesktopIconLayerGrid, SingleAppShown) { const auto d = computeGridDimensions(QSize(1280, 720), 1); EXPECT_EQ(d.shown, 1); + EXPECT_EQ(d.cell, 96); } diff --git a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp index a9a7ee577..a2f575b77 100644 --- a/ui/components/desktop_icon_layer/desktop_icon_layer.cpp +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp @@ -29,9 +29,12 @@ namespace cf::desktop::desktop_component { namespace { /// Tag for DesktopIconLayer log lines. constexpr const char* kLogTag = "DesktopIconLayer"; -/// Tile widget edge length (px). Mirrors LauncherTile::kCellSize in -/// launcher_tile.cpp; if that changes, update here too. -constexpr int kCellSize = 96; +/// Maximum tile edge length (px) used when the grid has room to spare. Mirrors +/// LauncherTile::kCellSize in launcher_tile.cpp; if that changes, update here. +constexpr int kCellMax = 96; +/// Minimum tile edge length (px). When app_count exceeds what fits even at this +/// size, the grid truncates (shows only what fits) rather than shrink further. +constexpr int kCellMin = 48; /// Gap between adjacent tiles (px). constexpr int kGridSpacing = 16; /// Inner padding between the central area edge and the tile grid (px). @@ -66,22 +69,35 @@ GridDimensions computeGridDimensions(const QSize& available, int app_count) { return result; } - const int stride = kCellSize + kGridSpacing; const int usable_w = available.width() - 2 * kMargin + kGridSpacing; const int usable_h = available.height() - 2 * kMargin + kGridSpacing; - int cols = (usable_w > 0) ? (usable_w / stride) : 0; - if (cols < 1) { - cols = 1; + // Pick the LARGEST tile edge in [kCellMin, kCellMax] whose capacity still + // covers app_count. cols(s) and rows(s) are each non-increasing in s, so + // their product is too — scanning 96 down to 48 hits a unique largest fit. + // The pre-loop default keeps kCellMin when even the floor cannot fit all + // (genuine truncation: shown < app_count). Columns are floored to >=1 (a + // too-narrow area still shows one column); rows are NOT floored, so a + // too-short area honestly reports zero rows. Do not "fix" that asymmetry. + int cell = kCellMin; + for (int s = kCellMax; s >= kCellMin; --s) { + const int stride = s + kGridSpacing; + const int cols = (usable_w > 0) ? std::max(1, usable_w / stride) : 1; + const int rows = (usable_h > 0) ? (usable_h / stride) : 0; + if (cols * rows >= app_count) { + cell = s; + break; + } } - // No artificial column cap: the grid fills the available width. (A prior - // kMaxColumns=8 cap left wide displays half-empty horizontally.) - - const int rows = (usable_h > 0) ? (usable_h / stride) : 0; - const int capacity = cols * rows; - result.columns = cols; - result.rows = rows; - result.shown = std::min(app_count, capacity); + + const int stride = cell + kGridSpacing; + result.columns = (usable_w > 0) ? std::max(1, usable_w / stride) : 1; + result.rows = (usable_h > 0) ? (usable_h / stride) : 0; + result.shown = std::min(app_count, result.columns * result.rows); + // When the area is too short to fit even one row, columns*rows is 0 and the + // scanned cell is meaningless — report 0 so callers read "no valid layout" + // consistently with the invalid-input early return above. + result.cell = (result.columns * result.rows > 0) ? cell : 0; return result; } @@ -204,10 +220,10 @@ void DesktopIconLayer::rebuildGrid() { grid_->setColumnMinimumWidth(c, 0); } for (int r = 0; r < dims.rows; ++r) { - grid_->setRowMinimumHeight(r, kCellSize); + grid_->setRowMinimumHeight(r, dims.cell); } for (int c = 0; c < dims.columns; ++c) { - grid_->setColumnMinimumWidth(c, kCellSize); + grid_->setColumnMinimumWidth(c, dims.cell); } for (const auto& s : shortcuts_) { if (s.col < 0 || s.col >= dims.columns || s.row < 0 || s.row >= dims.rows) { @@ -223,6 +239,10 @@ void DesktopIconLayer::rebuildGrid() { } auto* tile = new LauncherTile(*entry, this); tile->setContext(cf::desktop::desktop_component::TileContext::Desktop); + // Size the tile to the chosen cell so a shrunk grid (small window) shows + // scaled tiles instead of dropping them. Launcher popup tiles keep the + // constructor default (96) since AppLauncher never calls setFixedSize. + tile->setFixedSize(dims.cell, dims.cell); connect(tile, &LauncherTile::clicked, this, &DesktopIconLayer::appClicked); connect(tile, &LauncherTile::longPressed, this, &DesktopIconLayer::onTileLongPressed); connect(tile, &LauncherTile::dragMoved, this, &DesktopIconLayer::onTileDragMoved); diff --git a/ui/components/desktop_icon_layer/desktop_icon_layer.h b/ui/components/desktop_icon_layer/desktop_icon_layer.h index 97fb3f13a..0e6c21634 100644 --- a/ui/components/desktop_icon_layer/desktop_icon_layer.h +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.h @@ -49,9 +49,10 @@ class LauncherTile; * @ingroup components */ struct GridDimensions { - int columns{0}; ///< Tile columns that fit the available width (1..kMaxColumns). + int columns{0}; ///< Tile columns that fit the available width (>=1 when valid). int rows{0}; ///< Tile rows that fit the available height. int shown{0}; ///< Capacity (columns*rows) capped to the app count. + int cell{0}; ///< Tile edge length chosen for this area (px), clamped to [48,96]. }; /** @@ -63,8 +64,11 @@ struct GridDimensions { * @param[in] available The central desktop area available for icons. * @param[in] app_count Number of applications that want a tile. * - * @return GridDimensions; columns/rows clamped to constants, shown = capacity. - * Zero result for invalid geometry or non-positive count. + * @return GridDimensions; columns/rows for the chosen tile edge, shown = capacity + * capped to the app count. The cell field is the largest tile edge in + * [48,96] whose columns*rows still fits app_count (96 when nothing needs + * to shrink, 48 when even that truncates); 0 for invalid input or when + * the area is too short to fit even one row. * * @throws None. * @since 0.20 diff --git a/ui/components/launcher/launcher_tile.cpp b/ui/components/launcher/launcher_tile.cpp index 50a0a1ca4..cad709d86 100644 --- a/ui/components/launcher/launcher_tile.cpp +++ b/ui/components/launcher/launcher_tile.cpp @@ -33,6 +33,7 @@ #include #include +#include #include namespace cf::desktop::desktop_component { @@ -40,16 +41,22 @@ namespace cf::desktop::desktop_component { using namespace qw::core::token::literals; namespace { -constexpr int kCellSize = 96; ///< Tile widget edge length (px). -constexpr qreal kIconBase = 48.0; ///< Resting glyph square edge (px). -constexpr qreal kHoverScale = 1.12; ///< Glyph scale factor when hovered. -constexpr qreal kIconRadius = 12.0; ///< Glyph corner radius (px). -constexpr qreal kIconCenterY = 38.0; ///< Glyph vertical center (px from top). -constexpr int kLabelMargin = 10; ///< Horizontal caption margin (px). -constexpr int kLabelTop = 64; ///< Caption top y (px). -constexpr int kLabelHeight = 26; ///< Caption band height (px). -constexpr int kGlyphPixelSize = 22; ///< Initial letter font size (px). -constexpr int kLabelPixelSize = 13; ///< Caption font size (px). +constexpr int kCellSize = 96; ///< Tile widget edge length (px). +constexpr qreal kHoverScale = 1.12; ///< Glyph scale factor when hovered. +// Geometry/font constants below are REFERENCE VALUES at the 96 px cell +// (kCellSize). paintEvent scales each by width()/kCellSize so the tile renders +// correctly at any square size in [48,96] — DesktopIconLayer shrinks tiles to +// fit more on small windows. At width()==96 they reproduce the original pixel +// values exactly. Scaling uses CONST/kCellSize (not a float ratio) so values +// like kLabelPixelSize=13 stay 13 instead of drifting to 12. +constexpr qreal kIconBase = 48.0; ///< Resting glyph square edge (px) at 96. +constexpr qreal kIconRadius = 12.0; ///< Glyph corner radius (px) at 96. +constexpr qreal kIconCenterY = 38.0; ///< Glyph vertical center (px) at 96. +constexpr int kLabelMargin = 10; ///< Horizontal caption margin (px) at 96. +constexpr int kLabelTop = 64; ///< Caption top y (px) at 96. +constexpr int kLabelHeight = 26; ///< Caption band height (px) at 96. +constexpr int kGlyphPixelSize = 22; ///< Initial letter font size (px) at 96. +constexpr int kLabelPixelSize = 13; ///< Caption font size (px) at 96. constexpr int kHoverDurationMs = 150; ///< Hover zoom duration (ms). constexpr int kRippleDurationMs = 350; ///< Ripple expansion duration (ms). constexpr int kRippleAlpha = 90; ///< Peak ripple overlay alpha. @@ -121,32 +128,41 @@ void LauncherTile::paintEvent(QPaintEvent* /*event*/) { p.setOpacity(kDragDimOpacity); } + // Geometry derives from the live widget width so a DesktopIconLayer tile + // renders correctly at any scaled cell in [48,96]; launcher popup tiles + // stay 96 (constructor default) and reproduce the original pixel layout. + // CONST/kRef (not a float ratio) keeps reference values exact at w==96 — + // e.g. kLabelPixelSize=13 stays 13 rather than drifting to 12. const QRectF cell = rect(); - const qreal edge = kIconBase * hover_scale_; + const qreal w = cell.width(); + const qreal kRef = qreal(kCellSize); + const qreal edge = (w * kIconBase / kRef) * hover_scale_; + const qreal glyphCenterY = w * kIconCenterY / kRef; + const qreal cornerRadius = w * kIconRadius / kRef; const qreal cx = cell.center().x(); - const QRectF glyph(cx - edge / 2.0, kIconCenterY - edge / 2.0, edge, edge); + const QRectF glyph(cx - edge / 2.0, glyphCenterY - edge / 2.0, edge, edge); p.setPen(Qt::NoPen); // Glyph tile body. p.setBrush(tile_color_); - p.drawRoundedRect(glyph, kIconRadius, kIconRadius); + p.drawRoundedRect(glyph, cornerRadius, cornerRadius); // Hover state overlay (MD3 state layer), shown only while zoomed in. if (hover_scale_ > 1.001) { p.setBrush(QColor(foreground_color_.red(), foreground_color_.green(), foreground_color_.blue(), kHoverOverlayAlpha)); - p.drawRoundedRect(glyph, kIconRadius, kIconRadius); + p.drawRoundedRect(glyph, cornerRadius, cornerRadius); } // Press ripple: an expanding circle that fades out across the whole tile. if (rippling_) { const qreal maxRadius = std::hypot(cell.width(), cell.height()) / 2.0; - const qreal radius = ripple_progress_ * maxRadius; + const qreal rippleRadius = ripple_progress_ * maxRadius; const int alpha = static_cast((1.0 - ripple_progress_) * kRippleAlpha); p.setBrush(QColor(foreground_color_.red(), foreground_color_.green(), foreground_color_.blue(), alpha)); - p.drawEllipse(ripple_center_, radius, radius); + p.drawEllipse(ripple_center_, rippleRadius, rippleRadius); } // Real icon image when one resolves (manifest path or .desktop theme name); @@ -156,6 +172,7 @@ void LauncherTile::paintEvent(QPaintEvent* /*event*/) { p.drawPixmap(glyph, cached_icon_, QRectF(0, 0, cached_icon_.width(), cached_icon_.height())); } else { + glyph_font_.setPixelSize(std::clamp(int(w * kGlyphPixelSize / kRef), 12, 22)); p.setPen(foreground_color_); p.setFont(glyph_font_); const QString letter = entry_.display_name.isEmpty() @@ -164,14 +181,18 @@ void LauncherTile::paintEvent(QPaintEvent* /*event*/) { p.drawText(glyph, Qt::AlignCenter, letter); } - // Caption beneath the glyph, elided to the available width. + // Caption beneath the glyph, elided to the available width. Font pixel size + // is set here (not applyTheme) so it tracks the live tile width. + label_font_.setPixelSize(std::clamp(int(w * kLabelPixelSize / kRef), 10, 13)); p.setPen(label_color_); p.setFont(label_font_); - const QRectF label_rect(kLabelMargin, kLabelTop, cell.width() - 2 * kLabelMargin, kLabelHeight); - const QString caption = - QFontMetrics(label_font_) - .elidedText(entry_.display_name, Qt::ElideRight, label_rect.width()); - p.drawText(label_rect, Qt::AlignHCenter | Qt::AlignVCenter, caption); + const int labelMargin = int(w * kLabelMargin / kRef); + const int labelTop = int(w * kLabelTop / kRef); + const int labelHeight = int(w * kLabelHeight / kRef); + const QRectF labelRect(labelMargin, labelTop, cell.width() - 2 * labelMargin, labelHeight); + const QString caption = QFontMetrics(label_font_) + .elidedText(entry_.display_name, Qt::ElideRight, labelRect.width()); + p.drawText(labelRect, Qt::AlignHCenter | Qt::AlignVCenter, caption); } // -- Interaction ----------------------------------------------------------- @@ -247,18 +268,14 @@ void LauncherTile::applyTheme() { foreground_color_ = cs.queryColor(ON_SURFACE); label_color_ = cs.queryColor(ON_SURFACE_VARIANT); glyph_font_ = theme.font_type().queryTargetFont(TYPOGRAPHY_TITLE_MEDIUM); - glyph_font_.setPixelSize(kGlyphPixelSize); label_font_ = theme.font_type().queryTargetFont(TYPOGRAPHY_LABEL_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); label_color_ = QColor(0x49, 0x45, 0x4E); glyph_font_ = font(); - glyph_font_.setPixelSize(kGlyphPixelSize); label_font_ = font(); - label_font_.setPixelSize(kLabelPixelSize); } update(); } From ee4d46b79712f645e8a9059089dbcb1ecb03acf7 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Wed, 8 Jul 2026 08:21:07 +0800 Subject: [PATCH 8/9] chore(lint): recognize kCamelCase constants and CamelCase enums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .clang-tidy config demanded UPPER_CASE for all constants and enum constants, but the codebase uses Google-style kCamelCase for ~90 namespace constexpr (kCellSize, kIconBase, ...) and CamelCase enumerators (TileContext::Launcher, DragState::Idle). Switch GlobalConstantCase to CamelCase + 'k' prefix and EnumConstantCase to CamelCase so clangd stops flagging the prevailing convention. Only GlobalConstant (not the catch-all Constant) is constrained, so local const values stay unflagged instead of being asked to take the 'k' prefix. A handful of UPPER_CASE constants in base/ and the hybrid kDEFAULT_LEVEL are now flagged by the corrected standard — left as a separate cleanup. --- .clang-tidy | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index aa1a60524..5d8ee60ca 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -4,7 +4,8 @@ # Discipline"). clangd reads this automatically for in-editor hints; to silence # naming noise temporarily, comment out `readability-identifier-naming` in Checks. # Naming rules mirror AGENT.md "Code Style": PascalCase types, camelCase methods, -# snake_case files/variables, UPPER_CASE constants, lower_case namespaces. +# snake_case files/variables, kCamelCase constants (Google-style 'k' + CamelCase), +# CamelCase enum constants, lower_case namespaces. --- Checks: > -*, @@ -71,7 +72,7 @@ CheckOptions: - { key: readability-identifier-naming.StructCase, value: CamelCase } - { key: readability-identifier-naming.UnionCase, value: CamelCase } - { key: readability-identifier-naming.EnumCase, value: CamelCase } - - { key: readability-identifier-naming.EnumConstantCase, value: UPPER_CASE } + - { key: readability-identifier-naming.EnumConstantCase, value: CamelCase } - { key: readability-identifier-naming.TypedefCase, value: CamelCase } - { key: readability-identifier-naming.TypeAliasCase, value: CamelCase } - { key: readability-identifier-naming.FunctionCase, value: camelBack } @@ -80,7 +81,7 @@ CheckOptions: - { key: readability-identifier-naming.VariableCase, value: camelBack } - { key: readability-identifier-naming.PrivateMemberPrefix, value: m_ } - { key: readability-identifier-naming.PrivateMemberCase, value: camelBack } - - { key: readability-identifier-naming.ConstantCase, value: UPPER_CASE } - - { key: readability-identifier-naming.GlobalConstantCase, value: UPPER_CASE } + - { key: readability-identifier-naming.GlobalConstantCase, value: CamelCase } + - { key: readability-identifier-naming.GlobalConstantPrefix, value: k } - { key: readability-identifier-naming.NamespaceCase, value: lower_case } - { key: readability-identifier-naming.FileIgnoringCase, value: lower_case } From 6d4e897a17719209f005aa2c60e9be9f2fa12ba7 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Wed, 8 Jul 2026 10:48:45 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat(taskbar):=20horizontally=20scrollable?= =?UTF-8?q?=20icon=20row=20with=20=E2=80=B9=20=E2=80=BA=20arrows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the taskbar narrows so not every tile fits, the row becomes horizontally scrollable: ‹ / › chevron buttons flank it (shown only on overflow) and each click eases it by one tile via a 220ms OutCubic animation, dimming the arrow at the scroll end. Tiles stay fixed at 56px and center with a relaxed 16px gap (Windows-like) when they all fit — no shrinking, no dropdown menu (too hard to hit on i.MX6ULL resistive touch). - CenteredTaskbar wraps icon_layout_ in a QScrollArea (NoFrame, hidden scrollbars, widgetResizable=false, AlignCenter) and drives the hidden horizontal scroll bar from the arrows; rangeChanged shows/hides them, valueChanged toggles enabled, QVariantAnimation smooths the glide. - New TaskbarScrollArrow widget: bare chevron (no filled background), opacity-encoded state (hover 1.0 / rest 0.8 / disabled 0.3). - TaskbarIcon.paintEvent derives geometry from width()/56 (defensive). - kTileSpacing=16, kScrollStride=72, kScrollAnimMs=220. --- ui/components/taskbar/CMakeLists.txt | 1 + ui/components/taskbar/centered_taskbar.cpp | 117 +++++++++++- ui/components/taskbar/centered_taskbar.h | 33 +++- ui/components/taskbar/taskbar_icon.cpp | 36 ++-- .../taskbar/taskbar_scroll_arrow.cpp | 140 +++++++++++++++ ui/components/taskbar/taskbar_scroll_arrow.h | 170 ++++++++++++++++++ 6 files changed, 470 insertions(+), 27 deletions(-) create mode 100644 ui/components/taskbar/taskbar_scroll_arrow.cpp create mode 100644 ui/components/taskbar/taskbar_scroll_arrow.h diff --git a/ui/components/taskbar/CMakeLists.txt b/ui/components/taskbar/CMakeLists.txt index 210a68f76..c02b50bce 100644 --- a/ui/components/taskbar/CMakeLists.txt +++ b/ui/components/taskbar/CMakeLists.txt @@ -3,6 +3,7 @@ add_library(cfdesktop_taskbar STATIC centered_taskbar.cpp start_button.cpp taskbar_icon.cpp + taskbar_scroll_arrow.cpp taskbar_icons.qrc ) diff --git a/ui/components/taskbar/centered_taskbar.cpp b/ui/components/taskbar/centered_taskbar.cpp index 5adcc4aa4..988f1c29b 100644 --- a/ui/components/taskbar/centered_taskbar.cpp +++ b/ui/components/taskbar/centered_taskbar.cpp @@ -18,15 +18,23 @@ #include "start_button.h" #include "taskbar_icon.h" +#include "taskbar_scroll_arrow.h" #include "core/theme_manager.h" #include "core/token/material_scheme/cfmaterial_token_literals.h" +#include #include #include #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 @@ -45,10 +53,14 @@ 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 kTaskbarHeight = 64; ///< Bar thickness (px). +constexpr int kSideMargin = 12; ///< Horizontal padding (px). +constexpr int kTopBottomMargin = 4; ///< Vertical padding (px). +constexpr int kIconSpacing = 8; ///< Outer layout gap (px). +constexpr int kTileSize = 56; ///< Tile edge (px); mirrors TaskbarIcon::kCellSize. +constexpr int kTileSpacing = 16; ///< Gap between tiles (px) — relaxed, Windows-like. +constexpr int kScrollStride = kTileSize + kTileSpacing; ///< Pixels scrolled per arrow click. +constexpr int kScrollAnimMs = 220; ///< ‹/› glide duration (ms); tuned to feel smooth, not sluggish. constexpr int kStartButtonGap = 16; ///< Gap after the start button (px). constexpr qreal kSurfaceAlpha = 0.82; ///< Fixed surface transparency over the wallpaper. } // namespace @@ -76,12 +88,54 @@ void CenteredTaskbar::setupUi() { layout_->addWidget(start_button_); layout_->addSpacing(kStartButtonGap); - // The centered icon row lives in its own sub-layout so setApps() can rebuild - // it without disturbing the start button or the centering stretchers. - icon_layout_ = new QHBoxLayout(); - icon_layout_->setSpacing(kIconSpacing); + // The icon row lives in a horizontally-scrollable area. When every tile fits + // the inner widget centers (AlignCenter, non-resizable); when it overflows, + // the ‹ › arrows flanking the area scroll it by one tile per click — far + // easier to hit on i.MX6ULL resistive touch than a swipe or a dropdown. + scroll_area_ = new QScrollArea(this); + scroll_area_->setFrameShape(QFrame::NoFrame); + scroll_area_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll_area_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll_area_->setWidgetResizable(false); // keep the inner widget at sizeHint so it can scroll + scroll_area_->setAlignment(Qt::AlignVCenter | Qt::AlignHCenter); + scroll_area_->setAutoFillBackground(false); + scroll_area_->viewport()->setAutoFillBackground(false); + // Inner host widget owns icon_layout_ so setApps() can rebuild the row + // without touching the scroll area or the centering stretchers. + auto* icon_host = new QWidget(); + icon_layout_ = new QHBoxLayout(icon_host); + icon_layout_->setSpacing(kTileSpacing); + icon_layout_->setContentsMargins(0, 0, 0, 0); + scroll_area_->setWidget(icon_host); + // ‹ / › scroll arrows: hidden unless the row overflows (updateArrows decides). + scroll_left_ = new TaskbarScrollArrow(ScrollDirection::Left, this); + scroll_right_ = new TaskbarScrollArrow(ScrollDirection::Right, this); + scroll_left_->hide(); + scroll_right_->hide(); + connect(scroll_left_, &TaskbarScrollArrow::clicked, this, + [this]() { scrollBy(-kScrollStride); }); + connect(scroll_right_, &TaskbarScrollArrow::clicked, this, + [this]() { scrollBy(kScrollStride); }); + // Recompute arrow state when the scroll range or position changes (resize, + // setApps, or a click all flow through here). + connect(scroll_area_->horizontalScrollBar(), &QScrollBar::rangeChanged, this, + [this](int, int) { updateArrows(); }); + connect(scroll_area_->horizontalScrollBar(), &QScrollBar::valueChanged, this, + [this](int) { updateArrows(); }); + // Smooth ‹/› glide: the animation drives the scroll bar's value so a click + // eases into place instead of snapping a full stride instantly. + scroll_anim_ = new QVariantAnimation(this); + scroll_anim_->setDuration(kScrollAnimMs); + scroll_anim_->setEasingCurve(QEasingCurve::OutCubic); + connect(scroll_anim_, &QVariantAnimation::valueChanged, this, [this](const QVariant& v) { + if (scroll_area_ != nullptr) { + scroll_area_->horizontalScrollBar()->setValue(v.toInt()); + } + }); layout_->addStretch(); - layout_->addLayout(icon_layout_); + layout_->addWidget(scroll_left_); + layout_->addWidget(scroll_area_); + layout_->addWidget(scroll_right_); layout_->addStretch(); // React to theme switches (ThemeManager is the canonical source). @@ -139,6 +193,12 @@ void CenteredTaskbar::setApps(const QList& apps) { icon_layout_->addWidget(icon); icons_.append(icon); } + // Resize the scroll host to its new sizeHint so QScrollArea recomputes the + // scroll range (or re-centers the row when it fits). + if (auto* host = scroll_area_->widget()) { + host->resize(host->sizeHint()); + } + updateArrows(); } void CenteredTaskbar::updateRunningState(const QString& app_id, bool running) { @@ -181,4 +241,43 @@ void CenteredTaskbar::paintEvent(QPaintEvent* /*event*/) { p.drawLine(QPointF(0, 0.5), QPointF(width(), 0.5)); } +void CenteredTaskbar::resizeEvent(QResizeEvent* event) { + QWidget::resizeEvent(event); + // A resized bar changes whether the row overflows; the scroll bar also + // emits rangeChanged, but updating here covers the no-change-range edge. + updateArrows(); +} + +void CenteredTaskbar::updateArrows() { + if (scroll_left_ == nullptr || scroll_right_ == nullptr || scroll_area_ == nullptr) { + return; + } + auto* bar = scroll_area_->horizontalScrollBar(); + const bool overflows = bar->maximum() > bar->minimum(); + // Show the pair only when the row overflows; hidden widgets are ignored by + // the layout so the centered row reflows cleanly when they vanish. + scroll_left_->setVisible(overflows); + scroll_right_->setVisible(overflows); + // Disable the arrow whose scroll end is reached so it reads inactive. + scroll_left_->setEnabled(bar->value() > bar->minimum()); + scroll_right_->setEnabled(bar->value() < bar->maximum()); +} + +void CenteredTaskbar::scrollBy(int delta) { + if (scroll_area_ == nullptr || scroll_anim_ == nullptr) { + return; + } + auto* bar = scroll_area_->horizontalScrollBar(); + // Resume from the live value so rapid clicks chain smoothly, then glide to + // the clamped target instead of snapping the whole stride in one frame. + const int target = std::clamp(bar->value() + delta, bar->minimum(), bar->maximum()); + if (target == bar->value()) { + return; + } + scroll_anim_->stop(); + scroll_anim_->setStartValue(bar->value()); + scroll_anim_->setEndValue(target); + scroll_anim_->start(); +} + } // namespace cf::desktop::desktop_component diff --git a/ui/components/taskbar/centered_taskbar.h b/ui/components/taskbar/centered_taskbar.h index dfb3e2aed..8ab325e81 100644 --- a/ui/components/taskbar/centered_taskbar.h +++ b/ui/components/taskbar/centered_taskbar.h @@ -26,11 +26,15 @@ #include class QHBoxLayout; +class QResizeEvent; +class QScrollArea; +class QVariantAnimation; namespace cf::desktop::desktop_component { class StartButton; class TaskbarIcon; +class TaskbarScrollArrow; /** * @brief QWidget-based centered taskbar. @@ -195,16 +199,35 @@ class CenteredTaskbar final : public QWidget, public cf::desktop::IPanel { */ void paintEvent(QPaintEvent* event) override; + /** + * @brief Updates arrow visibility + enabled state on bar resize. + * + * @param[in] event The resize event descriptor (forwarded to the base). + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + void resizeEvent(QResizeEvent* event) override; + private: /// @brief Creates the centered row layout. void setupUi(); /// @brief Resolves theme colors, then repaints. void applyTheme(); - - QHBoxLayout* layout_{nullptr}; ///< Outer row: start button + centered icons. - QHBoxLayout* icon_layout_{nullptr}; ///< Dynamic icon row. Ownership: this widget. - StartButton* start_button_{nullptr}; ///< Launcher trigger. Ownership: this widget. - QList icons_; ///< Current tiles. Ownership: Qt parented. + /// @brief Shows/hides the scroll arrows and refreshes their enabled state. + void updateArrows(); + /// @brief Scrolls the icon row by @p delta px (one arrow click = one stride). + void scrollBy(int delta); + + QHBoxLayout* layout_{nullptr}; ///< Outer row: start button + centered icons. + QHBoxLayout* icon_layout_{nullptr}; ///< Dynamic icon row. Ownership: this widget. + StartButton* start_button_{nullptr}; ///< Launcher trigger. Ownership: this widget. + QScrollArea* scroll_area_{nullptr}; ///< Viewport that holds + clips the icon row. + TaskbarScrollArrow* scroll_left_{nullptr}; ///< "‹" affordance. Ownership: this widget. + TaskbarScrollArrow* scroll_right_{nullptr}; ///< "›" affordance. Ownership: this widget. + QVariantAnimation* scroll_anim_{nullptr}; ///< Smooths ‹/› clicks. Ownership: this widget. + QList icons_; ///< Current tiles. Ownership: Qt parented. QColor background_color_; ///< Surface fill for the bar. QColor divider_color_; ///< Top hairline divider color. diff --git a/ui/components/taskbar/taskbar_icon.cpp b/ui/components/taskbar/taskbar_icon.cpp index b8584bc00..4a22105d1 100644 --- a/ui/components/taskbar/taskbar_icon.cpp +++ b/ui/components/taskbar/taskbar_icon.cpp @@ -30,6 +30,7 @@ #include #include +#include #include namespace cf::desktop::desktop_component { @@ -37,13 +38,18 @@ namespace cf::desktop::desktop_component { using namespace qw::core::token::literals; namespace { -constexpr int kCellSize = 56; ///< Tile widget edge length (px). -constexpr qreal kIconBase = 36.0; ///< Resting tile square edge (px). +// kCellSize is the resting widget edge AND the reference for the geometry +// below. CenteredTaskbar shrinks tiles (setFixedSize) to fit a narrowed bar, +// so paintEvent scales every geometry/font value by width()/kCellSize; at +// width()==56 they reproduce the original pixel values exactly. CONST/kRef +// (not a float ratio) keeps reference values exact at 56. +constexpr int kCellSize = 56; ///< Tile widget edge length (px); reference cell. +constexpr qreal kIconBase = 36.0; ///< Resting tile square edge (px) at 56. 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 kGlyphPixelSize = 18; ///< Initial-letter font size (px). +constexpr qreal kIconRadius = 10.0; ///< Tile corner radius (px) at 56. +constexpr qreal kDotRadius = 2.5; ///< Running-indicator dot radius (px) at 56. +constexpr qreal kDotOffset = 6.0; ///< Dot offset below the tile (px) at 56. +constexpr int kGlyphPixelSize = 18; ///< Initial-letter font size (px) at 56. constexpr int kHoverDurationMs = 150; ///< Hover zoom duration (ms). constexpr int kRippleDurationMs = 350; ///< Ripple expansion duration (ms). constexpr int kRippleAlpha = 90; ///< Peak ripple overlay alpha. @@ -86,7 +92,12 @@ void TaskbarIcon::paintEvent(QPaintEvent* /*event*/) { p.setRenderHint(QPainter::Antialiasing, true); const QRectF cell = rect(); - const qreal edge = kIconBase * hover_scale_; + const qreal w = cell.width(); + const qreal kRef = qreal(kCellSize); + const qreal edge = (w * kIconBase / kRef) * hover_scale_; + const qreal cornerRadius = w * kIconRadius / kRef; + const qreal dotRadius = w * kDotRadius / kRef; + const qreal dotOffset = w * kDotOffset / kRef; const QPointF c = cell.center(); const QRectF tile(c.x() - edge / 2.0, c.y() - edge / 2.0, edge, edge); @@ -94,13 +105,13 @@ void TaskbarIcon::paintEvent(QPaintEvent* /*event*/) { // Tile body. p.setBrush(tile_color_); - p.drawRoundedRect(tile, kIconRadius, kIconRadius); + p.drawRoundedRect(tile, cornerRadius, cornerRadius); // Hover state overlay (MD3 state layer), shown only while zoomed in. if (hover_scale_ > 1.001) { p.setBrush(QColor(foreground_color_.red(), foreground_color_.green(), foreground_color_.blue(), kHoverOverlayAlpha)); - p.drawRoundedRect(tile, kIconRadius, kIconRadius); + p.drawRoundedRect(tile, cornerRadius, cornerRadius); } // Press ripple: an expanding circle that fades out. @@ -122,6 +133,7 @@ void TaskbarIcon::paintEvent(QPaintEvent* /*event*/) { p.setRenderHint(QPainter::SmoothPixmapTransform, true); p.drawPixmap(glyph_rect, icon_mask_, QRectF(0, 0, icon_mask_.width(), icon_mask_.height())); } else { + glyph_font_.setPixelSize(std::clamp(int(w * kGlyphPixelSize / kRef), 12, 18)); const QString letter = entry_.display_name.isEmpty() ? QStringLiteral("?") : QString(entry_.display_name.at(0)).toUpper(); @@ -132,10 +144,10 @@ void TaskbarIcon::paintEvent(QPaintEvent* /*event*/) { // Running indicator dot near the tile bottom. if (running_) { - const QPointF dot(c.x(), tile.bottom() + kDotOffset); + const QPointF dot(c.x(), tile.bottom() + dotOffset); p.setPen(Qt::NoPen); p.setBrush(indicator_color_); - p.drawEllipse(dot, kDotRadius, kDotRadius); + p.drawEllipse(dot, dotRadius, dotRadius); } } @@ -177,14 +189,12 @@ void TaskbarIcon::applyTheme() { foreground_color_ = cs.queryColor(ON_SURFACE); indicator_color_ = cs.queryColor(ON_SURFACE_VARIANT); glyph_font_ = theme.font_type().queryTargetFont(TYPOGRAPHY_TITLE_MEDIUM); - glyph_font_.setPixelSize(kGlyphPixelSize); } 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); glyph_font_ = font(); - glyph_font_.setPixelSize(kGlyphPixelSize); } refreshIcon(); update(); diff --git a/ui/components/taskbar/taskbar_scroll_arrow.cpp b/ui/components/taskbar/taskbar_scroll_arrow.cpp new file mode 100644 index 000000000..3d81752df --- /dev/null +++ b/ui/components/taskbar/taskbar_scroll_arrow.cpp @@ -0,0 +1,140 @@ +/** + * @file taskbar_scroll_arrow.cpp + * @brief Taskbar scroll-arrow button implementation. + * + * Renders a rounded Material surface bearing a chevron glyph ("<" or ">") + * with a hover state-layer, dims when disabled, and emits clicked() on + * release. QPainter-native, mirroring StartButton/TaskbarIcon so it builds + * everywhere; stripped of ripple and scale since it is a secondary affordance. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-08 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "taskbar_scroll_arrow.h" + +#include "core/theme_manager.h" +#include "core/token/material_scheme/cfmaterial_token_literals.h" + +#include +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +using namespace qw::core::token::literals; + +namespace { +constexpr int kArrowWidth = 40; ///< Button width (px); the full tap target. +constexpr int kArrowHeight = 56; ///< Button height (px); matches taskbar tiles. +constexpr qreal kChevronHalf = 8.0; ///< Half the chevron's horizontal extent (px). +constexpr qreal kRestingOpacity = 0.8; ///< Chevron opacity when idle (no fill background). +constexpr qreal kHoveredOpacity = 1.0; ///< Chevron opacity on hover. +constexpr qreal kDisabledOpacity = 0.3; ///< Dim factor when the scroll end is reached. +} // namespace + +TaskbarScrollArrow::TaskbarScrollArrow(ScrollDirection direction, QWidget* parent) + : QWidget(parent), direction_(direction) { + setFixedSize(kArrowWidth, kArrowHeight); + setCursor(Qt::PointingHandCursor); + setAutoFillBackground(false); + setToolTip(direction == ScrollDirection::Left ? QStringLiteral("Scroll left") + : QStringLiteral("Scroll right")); + applyTheme(); + + // Follow live theme switches (ThemeManager is the canonical source). + connect(&qw::core::ThemeManager::instance(), &qw::core::ThemeManager::themeChanged, this, + [this](const qw::core::ICFTheme&) { applyTheme(); }); +} + +TaskbarScrollArrow::~TaskbarScrollArrow() = default; + +QSize TaskbarScrollArrow::sizeHint() const { + return {kArrowWidth, kArrowHeight}; +} + +// -- Painting -------------------------------------------------------------- +void TaskbarScrollArrow::paintEvent(QPaintEvent* /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + + // No filled background — just the chevron, so the arrow reads as a light + // indicator beside the tiles instead of a chunky button competing with + // them. Opacity carries the state: dim at a scroll end, full on hover. + qreal opacity = kRestingOpacity; + if (!isEnabled()) { + opacity = kDisabledOpacity; + } else if (hovered_) { + opacity = kHoveredOpacity; + } + p.setOpacity(opacity); + + // Chevron as a polyline (font-independent, crisp at any size). A right + // chevron ">" runs upper-left -> mid-right -> lower-left; left mirrors it. + const QPointF c = rect().center(); + const qreal dx = kChevronHalf; + const qreal dy = kChevronHalf * 1.6; // taller than wide, like a typical ">" + QPainterPath chevron; + if (direction_ == ScrollDirection::Right) { + chevron.moveTo(c.x() - dx, c.y() - dy); + chevron.lineTo(c.x() + dx, c.y()); + chevron.lineTo(c.x() - dx, c.y() + dy); + } else { + chevron.moveTo(c.x() + dx, c.y() - dy); + chevron.lineTo(c.x() - dx, c.y()); + chevron.lineTo(c.x() + dx, c.y() + dy); + } + QPen pen(foreground_color_); + pen.setWidthF(2.5); + pen.setCapStyle(Qt::RoundCap); + pen.setJoinStyle(Qt::RoundJoin); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + p.drawPath(chevron); +} + +// -- Interaction ----------------------------------------------------------- +void TaskbarScrollArrow::enterEvent(QEnterEvent* /*event*/) { + hovered_ = true; + update(); +} + +void TaskbarScrollArrow::leaveEvent(QEvent* /*event*/) { + hovered_ = false; + update(); +} + +void TaskbarScrollArrow::mousePressEvent(QMouseEvent* event) { + // Accept the press so the matching release is delivered here. + QWidget::mousePressEvent(event); +} + +void TaskbarScrollArrow::mouseReleaseEvent(QMouseEvent* event) { + // Qt already blocks delivery to a disabled widget, but guard anyway. + if (isEnabled() && event->button() == Qt::LeftButton && + rect().contains(event->position().toPoint())) { + emit clicked(); + } + QWidget::mouseReleaseEvent(event); +} + +// -- Internal -------------------------------------------------------------- +void TaskbarScrollArrow::applyTheme() { + try { + auto& tm = qw::core::ThemeManager::instance(); + const auto& theme = tm.theme(tm.currentThemeName()); + const auto& cs = theme.color_scheme(); + foreground_color_ = cs.queryColor(ON_SURFACE); + } catch (...) { + // Fallback color when no theme is registered yet. + foreground_color_ = QColor(0x1C, 0x1B, 0x1F); + } + update(); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/taskbar/taskbar_scroll_arrow.h b/ui/components/taskbar/taskbar_scroll_arrow.h new file mode 100644 index 000000000..20baaf38f --- /dev/null +++ b/ui/components/taskbar/taskbar_scroll_arrow.h @@ -0,0 +1,170 @@ +/** + * @file taskbar_scroll_arrow.h + * @brief "‹" / "›" button that scrolls the taskbar icon row by one stride. + * + * TaskbarScrollArrow flanks the scrollable icon row when not every TaskbarIcon + * fits. A click emits clicked(), which CenteredTaskbar turns into a one-icon + * scroll. It draws only a chevron glyph (no filled background, so it reads as a + * light indicator beside the tiles), brightens on hover, and dims when disabled + * (already at the scroll end). + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-08 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#pragma once + +#include +#include + +class QEnterEvent; +class QEvent; +class QMouseEvent; +class QPaintEvent; + +namespace cf::desktop::desktop_component { + +/// @brief Which way the arrow points (and thus scrolls). +enum class ScrollDirection { + Left, ///< "‹" — scroll toward the row's start. + Right, ///< "›" — scroll toward the row's end. +}; + +/** + * @brief One scroll-arrow button at the edge of a crowded taskbar. + * + * Paints a bare chevron glyph (no filled surface), brightens it on hover, dims + * when disabled (scroll end reached), and emits clicked() on a left-button + * release inside the tile. + * + * @ingroup components + */ +class TaskbarScrollArrow final : public QWidget { + Q_OBJECT + public: + /** + * @brief Constructs the arrow. + * + * @param[in] direction Left ("‹") or Right ("›"). + * @param[in] parent Owning widget (the taskbar). + * + * @throws None + * @note Resolves theme colors and starts un-hovered. + * @warning None + * @since 0.20 + * @ingroup components + */ + explicit TaskbarScrollArrow(ScrollDirection direction, QWidget* parent = nullptr); + + /** + * @brief Destructs the arrow. + * + * @throws None + * @note None + * @warning None + * @since 0.20 + * @ingroup components + */ + ~TaskbarScrollArrow() override; + + /** + * @brief Returns the preferred (fixed) size. + * + * @return A fixed size matching the taskbar tile height. + * + * @throws None + * @note None + * @warning None + * @since 0.20 + * @ingroup components + */ + QSize sizeHint() const override; + + signals: + /** + * @brief Emitted when the arrow is clicked (left-button release inside). + * + * @since 0.20 + * @ingroup components + */ + void clicked(); + + protected: + /** + * @brief Paints the chevron; opacity encodes hover / disabled state. + * + * @param[in] event The paint event descriptor. + * + * @throws None + * @note Theme color is resolved in applyTheme(); no filled background. + * @warning None + * @since 0.20 + * @ingroup components + */ + void paintEvent(QPaintEvent* event) override; + + /** + * @brief Arms the hover state-layer on mouse enter. + * + * @param[in] event The enter event descriptor. + * + * @throws None + * @note None + * @warning None + * @since 0.20 + * @ingroup components + */ + void enterEvent(QEnterEvent* event) override; + + /** + * @brief Clears the hover state-layer on mouse leave. + * + * @param[in] event The leave event descriptor. + * + * @throws None + * @note None + * @warning None + * @since 0.20 + * @ingroup components + */ + void leaveEvent(QEvent* event) override; + + /** + * @brief Accepts the press so the matching release is delivered here. + * + * @param[in] event The mouse press event descriptor. + * + * @throws None + * @note None + * @warning None + * @since 0.20 + * @ingroup components + */ + void mousePressEvent(QMouseEvent* event) override; + + /** + * @brief Emits clicked() when released inside an enabled arrow. + * + * @param[in] event The mouse release event descriptor. + * + * @throws None + * @note A disabled arrow does not emit. + * @warning None + * @since 0.20 + * @ingroup components + */ + void mouseReleaseEvent(QMouseEvent* event) override; + + private: + /// @brief Resolves theme colors, then repaints. + void applyTheme(); + + ScrollDirection direction_; ///< Which chevron to draw. + QColor foreground_color_; ///< Chevron color (on surface). + bool hovered_{false}; ///< Whether the hover brighten shows. +}; + +} // namespace cf::desktop::desktop_component