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 } 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..6374edd67 --- /dev/null +++ b/test/desktop/desktop_icon_layer/CMakeLists.txt @@ -0,0 +1,34 @@ +# 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 +) + +# 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_icon_layer_test.cpp b/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp new file mode 100644 index 000000000..29d514a85 --- /dev/null +++ b/test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp @@ -0,0 +1,115 @@ +/** + * @file test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp + * @brief GoogleTest unit tests for computeGridDimensions. + * + * 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.2 + * @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); + 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 - 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 - 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, 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.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 - 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 - 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, 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/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..a3b0c3e3f --- /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, 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) { + 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, 8); + 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, 8).size(), 12); // kSeedCount +} 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..1c733bdc8 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -12,6 +12,8 @@ #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/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" @@ -27,10 +29,12 @@ #include "system/hardware_tier/hardware_tier.h" #include #include +#include #include #include #include #include +#include #include #include @@ -213,6 +217,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 @@ -349,6 +363,27 @@ 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()) { + // 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); + } + auto* taskbar = new cf::desktop::desktop_component::CenteredTaskbar(desktop_entity_); taskbar->setApps(apps); panel_mgr->registerPanel(taskbar->GetWeak()); @@ -420,6 +455,33 @@ 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 + // 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->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, 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..3ac2c6804 --- /dev/null +++ b/ui/components/desktop_icon_layer/CMakeLists.txt @@ -0,0 +1,23 @@ +# 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 + $ + $ + $ +) + +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 + 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 new file mode 100644 index 000000000..a2f575b77 --- /dev/null +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.cpp @@ -0,0 +1,421 @@ +/** + * @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.2 + * @since 0.20 + * @ingroup components + */ + +#include "desktop_icon_layer.h" + +#include "cflog.h" +#include "launcher/launcher_tile.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// Tag for DesktopIconLayer log lines. +constexpr const char* kLogTag = "DesktopIconLayer"; +/// 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). +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; + if (!available.isValid() || available.isEmpty() || app_count <= 0) { + return result; + } + + const int usable_w = available.width() - 2 * kMargin + kGridSpacing; + const int usable_h = available.height() - 2 * kMargin + kGridSpacing; + + // 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; + } + } + + 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; +} + +DesktopIconLayer::DesktopIconLayer(QWidget* parent) : QWidget(parent) { + // 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); + + grid_ = new QGridLayout(this); + grid_->setSpacing(kGridSpacing); + grid_->setContentsMargins(kMargin, kMargin, kMargin, kMargin); + grid_->setAlignment(Qt::AlignTop | Qt::AlignLeft); +} + +DesktopIconLayer::~DesktopIconLayer() = default; + +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_); + // 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) { + if (!available.isValid() || available.isEmpty()) { + return; + } + if (available == last_available_) { + return; + } + last_available_ = available; + setGeometry(available); + rebuildGrid(); +} + +void DesktopIconLayer::paintEvent(QPaintEvent* /*event*/) { + // Transparent by default; only draw the drag snap-highlight when active. + if (dragging_app_id_.isEmpty() || drag_target_cell_.x() < 0) { + return; + } + // 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. + pen.setWidthF(2.5); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + p.drawRoundedRect(cell_rect, 12, 12); +} + +void DesktopIconLayer::rebuildGrid() { + // 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 = + computeGridDimensions(last_available_.size(), static_cast(shortcuts_.size())); + 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, dims.cell); + } + for (int c = 0; c < dims.columns; ++c) { + 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) { + 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); + // 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); + connect(tile, &LauncherTile::dragEnded, this, &DesktopIconLayer::onTileDragEnded); + 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 { + 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}; + } + // 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 { + 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); + 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_; + 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 (changed) { + shortcuts_ = std::move(updated); + emit shortcutsChanged(shortcuts_); + // 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(); +} + +} // 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..0e6c21634 --- /dev/null +++ b/ui/components/desktop_icon_layer/desktop_icon_layer.h @@ -0,0 +1,216 @@ +/** + * @file desktop/ui/components/desktop_icon_layer/desktop_icon_layer.h + * @brief User-managed desktop shortcut grid on the wallpaper. + * + * 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. + * + * 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.2 + * @since 0.20 + * @ingroup components + */ + +#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 { + +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 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]. +}; + +/** + * @brief Computes how many tiles fit the available area. + * + * 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; 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 + * @ingroup components + */ +GridDimensions computeGridDimensions(const QSize& available, int app_count); + +/** + * @brief Grid of user-placed application shortcut tiles on the wallpaper. + * + * 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 + */ +class DesktopIconLayer final : public QWidget { + Q_OBJECT + public: + /** + * @brief Constructs the icon layer. + * + * 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. + * @since 0.20 + * @ingroup components + */ + explicit DesktopIconLayer(QWidget* parent = nullptr); + + /** + * @brief Destructs the layer. + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + ~DesktopIconLayer() override; + + /** + * @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. + * + * @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] app_id Application to pin. No-op if already on the desktop or + * not in the registry. + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + void addShortcut(const QString& app_id); + + /** + * @brief Updates this layer's geometry from the desktop's central area. + * + * 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. + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + void onAvailableGeometryChanged(const QRect& available); + + signals: + /** + * @brief Emitted when a desktop tile is tapped (launched). + * + * @param[in] app_id The clicked application identifier. + * + * @since 0.20 + * @ingroup components + */ + void appClicked(const QString& app_id); + + /** + * @brief Emitted whenever the shortcut layout changes (add / remove / swap). + * + * 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). + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + void paintEvent(QPaintEvent* event) override; + + private: + /// @brief Clears existing tiles and lays them out by shortcut cell. + void rebuildGrid(); + /// @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..317adf2fe --- /dev/null +++ b/ui/components/desktop_icon_layer/desktop_shortcut_store.cpp @@ -0,0 +1,115 @@ +/** + * @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; +/// 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, 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 % cols; + s.row = i / cols; + 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..cf7aa5b5e --- /dev/null +++ b/ui/components/desktop_icon_layer/desktop_shortcut_store.h @@ -0,0 +1,117 @@ +/** + * @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). + * @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). + * + * @throws None. + * @since 0.20 + * @ingroup components + */ + static QList seedFrom(const QList& apps, int cols); + + /** + * @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/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/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 8f8ca83c9..cad709d86 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" @@ -24,11 +26,14 @@ #include #include #include +#include #include #include #include +#include #include +#include #include namespace cf::desktop::desktop_component { @@ -36,20 +41,34 @@ 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. 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) @@ -59,12 +78,38 @@ LauncherTile::LauncherTile(AppEntry entry, QWidget* parent) setAutoFillBackground(false); 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; void LauncherTile::setEntry(const AppEntry& entry) { entry_ = entry; + refreshIcon(); update(); } @@ -77,50 +122,77 @@ 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); + } + + // 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); } - // 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 { + 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() + ? QStringLiteral("?") + : QString(entry_.display_name.at(0)).toUpper(); + 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 ----------------------------------------------------------- @@ -140,17 +212,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 { @@ -161,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(); } @@ -220,4 +323,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..b98869f1f 100644 --- a/ui/components/launcher/launcher_tile.h +++ b/ui/components/launcher/launcher_tile.h @@ -21,6 +21,8 @@ #include #include +#include +#include #include #include #include @@ -29,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. * @@ -110,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). @@ -121,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. @@ -187,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(); @@ -196,8 +281,20 @@ 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(); + + /// 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). qreal ripple_progress_{0.0}; ///< Ripple expansion in [0, 1]. QPointF ripple_center_; ///< Ripple origin in local coordinates. @@ -212,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 diff --git a/ui/components/taskbar/CMakeLists.txt b/ui/components/taskbar/CMakeLists.txt index 9dac98bdd..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 ) @@ -21,4 +22,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/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 6f636943c..4a22105d1 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" @@ -29,6 +30,7 @@ #include #include +#include #include namespace cf::desktop::desktop_component { @@ -36,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. @@ -85,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); @@ -93,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. @@ -112,15 +124,16 @@ 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); 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(); @@ -131,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); } } @@ -176,21 +189,29 @@ 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(); } 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. 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