From 94c3ff99cc3cca7aa70b3509d6a1f9b0b438b179 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 10:45:14 +0800 Subject: [PATCH 01/26] feat(ipc): add notify message type to IPCServer Route a "notify" IPCMessage to a new notifyReceived(payload) signal on IPCServer, so external apps can post desktop notifications via IPCClient::send(path, "notify", {...}). Dispatch stays registry-based and unchanged; "raise" remains backward compatible. Adds ipc_notify_test covering routing, raise compatibility, and JSON wire round-trip. --- base/ipc/include/cfipc/ipc_server.h | 24 +++++++- base/ipc/src/ipc_server.cpp | 5 ++ test/ipc/CMakeLists.txt | 8 +++ test/ipc/ipc_notify_test.cpp | 86 +++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 test/ipc/ipc_notify_test.cpp diff --git a/base/ipc/include/cfipc/ipc_server.h b/base/ipc/include/cfipc/ipc_server.h index c943ce048..6fdd04295 100644 --- a/base/ipc/include/cfipc/ipc_server.h +++ b/base/ipc/include/cfipc/ipc_server.h @@ -3,9 +3,11 @@ * @brief Single-instance IPC server for the desktop shell. * * Listens on a QLocalServer socket so a second shell instance can signal - * the running one. Uses a line-based protocol: each message is a - * newline-terminated token. The only token defined in this phase is - * "raise", which asks the running shell to come to the foreground. + * the running one. Uses a line-based JSON protocol: each message is a + * newline-terminated object carrying a type tag plus a payload. The shell + * routes incoming messages by type via IPCMessageRegistry. Types defined + * here are "raise" (bring the shell to the foreground) and "notify" (post a + * desktop notification whose payload carries title/message/app_id). * * The server is a process-wide singleton; the early-session boot chain * starts it, and the desktop entity connects to its signals. @@ -19,6 +21,7 @@ #pragma once +#include #include #include #include @@ -102,6 +105,21 @@ class IPCServer : public QObject { */ void raiseRequested(); + /** + * @brief Emitted when a peer sends a "notify" message. + * + * The shell should post a desktop notification from the carried payload + * (title / message / app_id, etc.). + * + * @param[in] payload The notification payload, forwarded verbatim. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup ipc + */ + void notifyReceived(const QJsonObject& payload); + private slots: /** * @brief Handles an incoming peer connection. diff --git a/base/ipc/src/ipc_server.cpp b/base/ipc/src/ipc_server.cpp index 1214d4a1c..3b5310752 100644 --- a/base/ipc/src/ipc_server.cpp +++ b/base/ipc/src/ipc_server.cpp @@ -30,6 +30,11 @@ IPCServer::IPCServer() { // by whoever registered them in the registry. IPCMessageRegistry::instance().registerHandler( "raise", [this](const QJsonObject&) { emit raiseRequested(); }); + // "notify": a peer (external app) asks the shell to post a desktop + // notification. The payload (title/message/app_id/...) is forwarded as-is + // to the NotificationService via the desktop entity's signal wiring. + IPCMessageRegistry::instance().registerHandler( + "notify", [this](const QJsonObject& payload) { emit notifyReceived(payload); }); } bool IPCServer::start(const QString& socket_path) { diff --git a/test/ipc/CMakeLists.txt b/test/ipc/CMakeLists.txt index bf3909158..a25cd3855 100644 --- a/test/ipc/CMakeLists.txt +++ b/test/ipc/CMakeLists.txt @@ -27,6 +27,14 @@ add_gtest_executable( LOG_MODULE ipc_tests ) +add_gtest_executable( + TEST_NAME ipc_notify_test + SOURCE_FILE ipc_notify_test.cpp + LINK_LIBRARIES cfipc;Qt6::Core;GTest::gtest;GTest::gtest_main + LABELS "ipc;unit;notify" + LOG_MODULE ipc_tests +) + # Transport test owns its main() (QCoreApplication for QLocalSocket), # so it links gtest (not gtest_main) and adds Qt6::Network. add_gtest_executable( diff --git a/test/ipc/ipc_notify_test.cpp b/test/ipc/ipc_notify_test.cpp new file mode 100644 index 000000000..979d2420b --- /dev/null +++ b/test/ipc/ipc_notify_test.cpp @@ -0,0 +1,86 @@ +/** + * @file ipc_notify_test.cpp + * @brief Unit tests for the "notify" IPC message routing. + * + * Verifies that dispatching a "notify" IPCMessage makes IPCServer emit + * notifyReceived with the payload, and that "raise" routing still works + * (backward compatibility). Signal capture uses a direct same-thread lambda + * connection so no event loop is needed. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup ipc + */ + +#include "cfipc/ipc_message.h" +#include "cfipc/ipc_message_registry.h" +#include "cfipc/ipc_server.h" + +#include +#include +#include + +#include + +// A direct (same-thread) lambda connection fires synchronously on emit, so +// the counter is updated before the assertion runs -- no QSignalSpy/event +// loop machinery required inside a plain GTest binary. +TEST(IPCServerNotify, EmitsNotifyReceivedOnNotifyDispatch) { + auto& server = cf::ipc::IPCServer::instance(); + int got = 0; + QJsonObject captured; + QObject receiver; + QObject::connect(&server, &cf::ipc::IPCServer::notifyReceived, &receiver, + [&](const QJsonObject& payload) { + captured = payload; + ++got; + }); + + cf::ipc::IPCMessage msg; + msg.type = "notify"; + msg.payload["title"] = "Hello"; + msg.payload["message"] = "World"; + msg.payload["app_id"] = "org.cf.test"; + + EXPECT_TRUE(cf::ipc::IPCMessageRegistry::instance().dispatch(msg)); + ASSERT_EQ(got, 1); + EXPECT_EQ(captured.value("title").toString().toStdString(), "Hello"); + EXPECT_EQ(captured.value("app_id").toString().toStdString(), "org.cf.test"); +} + +TEST(IPCServerNotify, StillEmitsRaiseOnRaiseDispatch) { + // Backward compatibility: adding "notify" must not disturb "raise". + auto& server = cf::ipc::IPCServer::instance(); + int got = 0; + QObject receiver; + QObject::connect(&server, &cf::ipc::IPCServer::raiseRequested, &receiver, [&]() { ++got; }); + + cf::ipc::IPCMessage msg; + msg.type = "raise"; + EXPECT_TRUE(cf::ipc::IPCMessageRegistry::instance().dispatch(msg)); + EXPECT_EQ(got, 1); +} + +TEST(IPCServerNotify, NotifyPayloadSurvivesWireRoundTrip) { + // Round-trip through the JSON wire format before dispatching, so both the + // parser and the routing are exercised. + auto& server = cf::ipc::IPCServer::instance(); + QJsonObject captured; + QObject receiver; + QObject::connect(&server, &cf::ipc::IPCServer::notifyReceived, &receiver, + [&](const QJsonObject& payload) { captured = payload; }); + + cf::ipc::IPCMessage original; + original.type = "notify"; + original.payload["title"] = "Batch done"; + original.payload["actions"] = QJsonArray{"open", "dismiss"}; + const auto parsed = cf::ipc::IPCMessage::fromJson(original.toJson()); + ASSERT_TRUE(parsed.has_value()); + ASSERT_EQ(parsed->type.toStdString(), "notify"); + + EXPECT_TRUE(cf::ipc::IPCMessageRegistry::instance().dispatch(*parsed)); + EXPECT_EQ(captured.value("title").toString().toStdString(), "Batch done"); + EXPECT_EQ(captured.value("actions").toArray().at(0).toString().toStdString(), "open"); +} From 8b96e971f4e84ea1bbc97b7695e889d89eabef23 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 11:07:42 +0800 Subject: [PATCH 02/26] feat(notification): NotificationService + Notification model Add an in-process notification store: a minimal Notification record (id, title, message, app_id, timestamp) and a process-wide NotificationService singleton owning the active list, emitting posted/dismissed/cleared/ dndChanged signals. Do-Not-Disturb is backed by the ConfigStore "notification.dnd.enabled" key so the control-center switch (later commit) and the banner suppress decision share one source. The model is in-memory only; persistence and grouping are deferred. Adds unit tests covering post/dismiss/clear-all, newest-first ordering, signal emission, and DND suppression round-tripped through the real ConfigStore. --- test/CMakeLists.txt | 3 + test/notification/CMakeLists.txt | 22 ++ .../notification_service_test.cpp | 144 ++++++++++++ ui/components/CMakeLists.txt | 1 + ui/components/notification/CMakeLists.txt | 18 ++ ui/components/notification/notification.h | 48 ++++ .../notification/notification_service.cpp | 111 +++++++++ .../notification/notification_service.h | 214 ++++++++++++++++++ 8 files changed, 561 insertions(+) create mode 100644 test/notification/CMakeLists.txt create mode 100644 test/notification/notification_service_test.cpp create mode 100644 ui/components/notification/CMakeLists.txt create mode 100644 ui/components/notification/notification.h create mode 100644 ui/components/notification/notification_service.cpp create mode 100644 ui/components/notification/notification_service.h diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index f08b36a95..1e0c050b6 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -58,6 +58,9 @@ add_subdirectory(crash) # Add desktop tests add_subdirectory(desktop) +# Add notification tests subdirectory +add_subdirectory(notification) + # Add boot_test subdirectory (legacy tests) add_subdirectory(boot_test) diff --git a/test/notification/CMakeLists.txt b/test/notification/CMakeLists.txt new file mode 100644 index 000000000..44e9411b8 --- /dev/null +++ b/test/notification/CMakeLists.txt @@ -0,0 +1,22 @@ +# Notification module unit tests using GoogleTest +include(add_gtest_executable) + +log_info("notification_tests" "Configured notification unit tests:") + +# cfdesktop_notification is STATIC with PRIVATE deps (cfconfig, cflogger), so +# re-link every transitive dependency its .cpp pulls in. mock_path_provider.h +# (for ConfigStore initialization) lives under test/config_manager. +add_gtest_executable( + TEST_NAME notification_service_test + SOURCE_FILE notification_service_test.cpp + LINK_LIBRARIES + cfdesktop_notification + cfconfig + cflogger + Qt6::Core + GTest::gtest + GTest::gtest_main + INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/test/config_manager + LABELS "notification;unit;service" + LOG_MODULE notification_tests +) diff --git a/test/notification/notification_service_test.cpp b/test/notification/notification_service_test.cpp new file mode 100644 index 000000000..1889f4a15 --- /dev/null +++ b/test/notification/notification_service_test.cpp @@ -0,0 +1,144 @@ +/** + * @file notification_service_test.cpp + * @brief Unit tests for NotificationService. + * + * Covers posting / dismissal / clear-all, newest-first ordering, signal + * emission, and Do-Not-Disturb banner suppression backed by the real + * ConfigStore (initialized with a mock path provider). Signal capture uses + * direct same-thread lambda connections, so no event loop is needed. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup notification + */ + +#include "notification_service.h" + +#include "cfconfig.hpp" +#include "mock_path_provider.h" + +#include +#include + +#include + +using cf::desktop::desktop_component::Notification; +using cf::desktop::desktop_component::NotificationService; + +namespace { +/// Counts signal emissions via a direct (same-thread) connection. The QObject +/// member is the connection context; its lifetime tracks the counter. +struct SignalCounter { + QObject ctx; + int posted{0}; + int dismissed{0}; + int cleared{0}; + int dnd{0}; + bool last_suppressed{false}; + Notification last_posted; + + explicit SignalCounter(NotificationService& svc) { + QObject::connect(&svc, &NotificationService::notificationPosted, &ctx, + [&](const Notification& n, bool suppressed) { + last_posted = n; + last_suppressed = suppressed; + ++posted; + }); + QObject::connect(&svc, &NotificationService::notificationDismissed, &ctx, + [&](const QString&) { ++dismissed; }); + QObject::connect(&svc, &NotificationService::allCleared, &ctx, [&]() { ++cleared; }); + QObject::connect(&svc, &NotificationService::dndChanged, &ctx, [&](bool) { ++dnd; }); + } +}; + +Notification make(QString title) { + Notification n; + n.title = std::move(title); + n.message = "body"; + n.app_id = "org.cf.test"; + return n; +} +} // namespace + +class NotificationServiceTest : public ::testing::Test { + protected: + static bool config_initialized_; + + void SetUp() override { + if (!config_initialized_) { + auto provider = std::make_shared(); + cf::config::ConfigStore::instance().initialize(provider); + config_initialized_ = true; + } + // Clean slate per test: drop notifications + reset DND. + NotificationService::instance().clearAll(); + NotificationService::instance().setDndEnabled(false); + } +}; + +bool NotificationServiceTest::config_initialized_ = false; + +TEST_F(NotificationServiceTest, PostAddsToListNewestFirst) { + auto& svc = NotificationService::instance(); + svc.post(make("A")); + svc.post(make("B")); + const auto all = svc.all(); + ASSERT_EQ(all.size(), 2); + EXPECT_EQ(all[0].title.toStdString(), "B"); // newest first + EXPECT_EQ(all[1].title.toStdString(), "A"); +} + +TEST_F(NotificationServiceTest, PostGeneratesIdTimestampAndEmits) { + auto& svc = NotificationService::instance(); + SignalCounter counter(svc); + svc.post(make("Hi")); + ASSERT_EQ(counter.posted, 1); + EXPECT_FALSE(counter.last_posted.id.isEmpty()); + EXPECT_GT(counter.last_posted.timestamp, 0); + EXPECT_FALSE(counter.last_suppressed); // DND off by default +} + +TEST_F(NotificationServiceTest, DismissRemovesByIdAndEmits) { + auto& svc = NotificationService::instance(); + SignalCounter counter(svc); + svc.post(make("X")); + const QString id = svc.all().first().id; + EXPECT_TRUE(svc.dismiss(id)); + EXPECT_TRUE(svc.all().isEmpty()); + EXPECT_EQ(counter.dismissed, 1); + EXPECT_FALSE(svc.dismiss("nope")); // already gone -> no-op +} + +TEST_F(NotificationServiceTest, ClearAllEmitsAndReturnsCount) { + auto& svc = NotificationService::instance(); + SignalCounter counter(svc); + svc.post(make("1")); + svc.post(make("2")); + EXPECT_EQ(svc.clearAll(), 2); + EXPECT_EQ(svc.count(), 0); + EXPECT_EQ(counter.cleared, 1); + EXPECT_EQ(svc.clearAll(), 0); // empty -> no emit, zero removed +} + +TEST_F(NotificationServiceTest, DndSuppressesBannerButKeepsCenter) { + auto& svc = NotificationService::instance(); + SignalCounter counter(svc); + svc.setDndEnabled(true); + ASSERT_EQ(counter.dnd, 1); + EXPECT_TRUE(svc.isDndEnabled()); + + svc.post(make("quiet")); + EXPECT_EQ(counter.posted, 1); + EXPECT_TRUE(counter.last_suppressed); // banner suppressed + EXPECT_EQ(svc.count(), 1); // but still recorded in the center +} + +TEST_F(NotificationServiceTest, DndRoundTripsThroughConfigStore) { + auto& svc = NotificationService::instance(); + svc.setDndEnabled(true); + EXPECT_TRUE(svc.isDndEnabled()); + svc.setDndEnabled(false); + EXPECT_FALSE(svc.isDndEnabled()); +} diff --git a/ui/components/CMakeLists.txt b/ui/components/CMakeLists.txt index d1f651961..4a72c9000 100644 --- a/ui/components/CMakeLists.txt +++ b/ui/components/CMakeLists.txt @@ -14,6 +14,7 @@ add_subdirectory(desktop_icon_layer) add_subdirectory(builtin_apps) add_subdirectory(desktop_widget) add_subdirectory(home_page) +add_subdirectory(notification) # Create interface library for convenience add_library(cf_desktop_components STATIC) diff --git a/ui/components/notification/CMakeLists.txt b/ui/components/notification/CMakeLists.txt new file mode 100644 index 000000000..8542d4d03 --- /dev/null +++ b/ui/components/notification/CMakeLists.txt @@ -0,0 +1,18 @@ +# Notification service + model (in-process desktop notification store). +add_library(cfdesktop_notification STATIC + notification_service.cpp +) + +target_include_directories(cfdesktop_notification PUBLIC + $ + $ + $ +) + +target_link_libraries( + cfdesktop_notification +PRIVATE + Qt6::Core # QObject, QUuid, QDateTime + cfconfig # ConfigStore (Do-Not-Disturb persistence) + cflogger # Diagnostic logging +) diff --git a/ui/components/notification/notification.h b/ui/components/notification/notification.h new file mode 100644 index 000000000..e5bd7bde8 --- /dev/null +++ b/ui/components/notification/notification.h @@ -0,0 +1,48 @@ +/** + * @file notification.h + * @brief In-memory desktop notification record. + * + * A Notification is the unit posted to NotificationService: an id, title, + * message, source app id, and a millisecond timestamp. The model is + * intentionally minimal (no priority / actions / persistence) for the + * Phase F minimal closed loop; richer fields are deferred. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup notification + */ + +#pragma once + +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief A single desktop notification. + * + * Carries the data rendered by the banner and the notification center. The + * id is unique within the running shell; the timestamp is msec since epoch. + * + * @ingroup notification + */ +struct Notification { + /// @brief Unique id (generated by NotificationService when left empty). + QString id; + + /// @brief One-line title shown in the banner / center. + QString title; + + /// @brief Body text shown under the title. + QString message; + + /// @brief Source application id (e.g. "org.cf.mail"). + QString app_id; + + /// @brief Creation time, msec since the Unix epoch. + qint64 timestamp{0}; +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/notification/notification_service.cpp b/ui/components/notification/notification_service.cpp new file mode 100644 index 000000000..9f48f803e --- /dev/null +++ b/ui/components/notification/notification_service.cpp @@ -0,0 +1,111 @@ +/** + * @file notification_service.cpp + * @brief Implementation of NotificationService. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup notification + */ + +#include "notification_service.h" + +#include "cfconfig.hpp" +#include "cfconfig_key.h" +#include "cfconfig_layer.h" +#include "cfconfig_notify_policy.h" +#include "cflog.h" + +#include +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// ConfigStore domain + key for Do-Not-Disturb persistence. +constexpr const char* kDomain = "notification"; +constexpr const char* kDndGroup = "dnd"; +constexpr const char* kDndKey = "enabled"; +/// Tag for log lines. +constexpr const char* kLogTag = "NotificationService"; +} // namespace + +NotificationService& NotificationService::instance() { + static NotificationService service; + return service; +} + +NotificationService::NotificationService() : QObject(nullptr) {} + +void NotificationService::post(Notification notification) { + if (notification.id.isEmpty()) { + notification.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + } + if (notification.timestamp == 0) { + notification.timestamp = QDateTime::currentMSecsSinceEpoch(); + } + list_.prepend(notification); + emit notificationPosted(notification, isDndEnabled()); +} + +bool NotificationService::dismiss(const QString& id) { + for (int i = 0; i < list_.size(); ++i) { + if (list_[i].id == id) { + list_.removeAt(i); + emit notificationDismissed(id); + return true; + } + } + return false; +} + +int NotificationService::clearAll() { + const int removed = list_.size(); + if (removed == 0) { + return 0; + } + list_.clear(); + emit allCleared(); + return removed; +} + +QList NotificationService::all() const { + return list_; +} + +int NotificationService::count() const { + return list_.size(); +} + +bool NotificationService::isDndEnabled() const { + try { + namespace cfg = cf::config; + return cfg::ConfigStore::instance().domain(kDomain).query( + cfg::KeyView{.group = kDndGroup, .key = kDndKey}, false); + } catch (...) { + // ConfigStore not initialized (e.g. unit test without a provider) or + // query failed: treat DND as off so notifications still surface. + return false; + } +} + +void NotificationService::setDndEnabled(bool on) { + try { + namespace cfg = cf::config; + const bool ok = cfg::ConfigStore::instance().domain(kDomain).set( + cfg::KeyView{.group = kDndGroup, .key = kDndKey}, on, cfg::Layer::User, + cfg::NotifyPolicy::Immediate); + cfg::ConfigStore::instance().sync(); + if (!ok) { + // The store rejected the write (key locked / layer read-only): + // surface it rather than silently drop the user's toggle. + cf::log::warningftag(kLogTag, "ConfigStore rejected DND={} set", on); + } + } catch (...) { + cf::log::warningftag(kLogTag, "failed to persist DND={} (ConfigStore unavailable)", on); + } + emit dndChanged(on); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/notification/notification_service.h b/ui/components/notification/notification_service.h new file mode 100644 index 000000000..696fa331c --- /dev/null +++ b/ui/components/notification/notification_service.h @@ -0,0 +1,214 @@ +/** + * @file notification_service.h + * @brief Process-wide in-memory notification store. + * + * NotificationService is the single source of truth for active desktop + * notifications inside the shell process. Producers post notifications here + * (the IPCServer "notify" handler, or any in-process caller); consumers + * (the banner, the notification center, the status bar unread dot) connect + * to its signals. The model is in-memory only: a restart clears the list. + * Do-Not-Disturb is read from / written to the ConfigStore + * "notification.dnd.enabled" key so the control-center switch and the banner + * suppress decision stay in sync without a cached copy. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup notification + */ + +#pragma once + +#include "notification.h" + +#include +#include +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief Process-wide singleton notification store. + * + * Owns the active notification list and emits signals on every change so UI + * consumers stay in sync without polling. Do-Not-Disturb is backed by the + * ConfigStore so it survives a restart and is shared with the control center. + * + * @note Thread-affine: intended for use on the main (Qt) thread. + * @warning None + * @since 0.19.0 + * @ingroup notification + */ +class NotificationService : public QObject { + Q_OBJECT + + public: + /** + * @brief Returns the process-wide singleton instance. + * + * @return Reference to the singleton service. + * @throws None + * @note Created on first access; lives until process exit. + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + static NotificationService& instance(); + + /** + * @brief Posts a notification. + * + * Prepends to the active list (newest first), fills id / timestamp when + * empty, and emits notificationPosted. The banner is suppressed when + * Do-Not-Disturb is on; the center still records it. + * + * @param[in] notification The notification to post. + * @throws None + * @note Generates an id and timestamp when the caller omits them. + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void post(Notification notification); + + /** + * @brief Removes a notification by id. + * + * @param[in] id The id of the notification to remove. + * @return True when a notification was removed. + * @throws None + * @note Emits notificationDismissed on a hit. + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + bool dismiss(const QString& id); + + /** + * @brief Removes every active notification. + * + * @return The number of notifications removed. + * @throws None + * @note Emits allCleared when the list was non-empty. + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + int clearAll(); + + /** + * @brief Returns all active notifications, newest first. + * + * @return A copy of the active list. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + QList all() const; + + /** + * @brief Returns the active notification count. + * + * @return The number of active notifications. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + int count() const; + + /** + * @brief Reports whether Do-Not-Disturb is on. + * + * @return True when DND is enabled (banners suppressed). + * @throws None + * @note Reads the ConfigStore "notification.dnd.enabled" key; + * returns false when the store is unavailable. + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + bool isDndEnabled() const; + + /** + * @brief Enables or disables Do-Not-Disturb. + * + * @param[in] on True to enable DND, false to disable. + * @throws None + * @note Persists to ConfigStore "notification.dnd.enabled" (User + * layer) and emits dndChanged regardless of store outcome. + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void setDndEnabled(bool on); + + signals: + /** + * @brief Emitted right after a notification is posted. + * + * @param[in] notification The posted notification. + * @param[in] banner_suppressed True when DND suppressed the banner. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void notificationPosted(const Notification& notification, bool banner_suppressed); + + /** + * @brief Emitted when a notification is removed by id. + * + * @param[in] id The id of the removed notification. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void notificationDismissed(const QString& id); + + /** + * @brief Emitted when all notifications are cleared at once. + * + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void allCleared(); + + /** + * @brief Emitted when the Do-Not-Disturb state changes. + * + * @param[in] on The new DND state. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void dndChanged(bool on); + + private: + /** + * @brief Constructs the service. + * + * @throws None + * @note Private; access via instance(). + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + NotificationService(); + + QList list_; +}; + +} // namespace cf::desktop::desktop_component From 9a41369e70b89ab0f577b142057c58d99fed7513 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 11:21:23 +0800 Subject: [PATCH 03/26] feat(notification): banner + notification center panels Add the notification UI surfaces on top of NotificationService: - NotificationCardWidget: self-painted rounded card (title, message, app/time meta, dismiss hotspot) following the active Material theme. - NotificationBanner: frameless top-right toast that slides/fades in via the QuarkWidgets MD3 animation pair, shows the newest card, and auto-hides after 5s (ESC + dismiss hotspot close it early). - NotificationCenterPanel: right-edge slide-in panel listing every active notification; subscribes to the service and rebuilds live, with a header "Clear all" hotspot. All three reuse the AppLauncher frameless-child + enter/exit fade/slide pattern (no Qt::Popup, so it does not fight the WM on windowless targets). --- ui/components/notification/CMakeLists.txt | 13 +- .../notification/notification_banner.cpp | 144 +++++++++ .../notification/notification_banner.h | 138 +++++++++ .../notification/notification_card_widget.cpp | 149 ++++++++++ .../notification/notification_card_widget.h | 163 +++++++++++ .../notification_center_panel.cpp | 275 ++++++++++++++++++ .../notification/notification_center_panel.h | 194 ++++++++++++ 7 files changed, 1072 insertions(+), 4 deletions(-) create mode 100644 ui/components/notification/notification_banner.cpp create mode 100644 ui/components/notification/notification_banner.h create mode 100644 ui/components/notification/notification_card_widget.cpp create mode 100644 ui/components/notification/notification_card_widget.h create mode 100644 ui/components/notification/notification_center_panel.cpp create mode 100644 ui/components/notification/notification_center_panel.h diff --git a/ui/components/notification/CMakeLists.txt b/ui/components/notification/CMakeLists.txt index 8542d4d03..bd266c971 100644 --- a/ui/components/notification/CMakeLists.txt +++ b/ui/components/notification/CMakeLists.txt @@ -1,6 +1,9 @@ -# Notification service + model (in-process desktop notification store). +# Notification service + UI (in-process desktop notification system). add_library(cfdesktop_notification STATIC notification_service.cpp + notification_card_widget.cpp + notification_banner.cpp + notification_center_panel.cpp ) target_include_directories(cfdesktop_notification PUBLIC @@ -12,7 +15,9 @@ target_include_directories(cfdesktop_notification PUBLIC target_link_libraries( cfdesktop_notification PRIVATE - Qt6::Core # QObject, QUuid, QDateTime - cfconfig # ConfigStore (Do-Not-Disturb persistence) - cflogger # Diagnostic logging + Qt6::Core # QObject, QUuid, QDateTime + Qt6::Widgets # QWidget, QScrollArea, QVBoxLayout + QuarkWidgets::quarkwidgets # ThemeManager + MD3 fade/slide animations + cfconfig # ConfigStore (Do-Not-Disturb persistence) + cflogger # Diagnostic logging ) diff --git a/ui/components/notification/notification_banner.cpp b/ui/components/notification/notification_banner.cpp new file mode 100644 index 000000000..2a1476718 --- /dev/null +++ b/ui/components/notification/notification_banner.cpp @@ -0,0 +1,144 @@ +/** + * @file notification_banner.cpp + * @brief Implementation of NotificationBanner. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup notification + */ + +#include "notification_banner.h" + +#include "notification_card_widget.h" +#include "notification_service.h" + +#include "components/material/cfmaterial_fade_animation.h" +#include "components/material/cfmaterial_slide_animation.h" +#include "core/theme_manager.h" + +#include +#include +#include + +namespace cf::desktop::desktop_component { + +namespace { +constexpr int kAutoHideMs = 5000; ///< Auto-hide delay (ms). +constexpr int kEnterSlidePx = 24; ///< Enter/exit vertical slide distance (px). +constexpr int kMargin = 16; ///< Gap from the screen edge (px). +} // namespace + +NotificationBanner::NotificationBanner(QWidget* parent) : QWidget(parent) { + // Frameless translucent child: the card paints its own rounded surface, + // the area outside stays transparent. Not Qt::Popup so it does not fight + // the window manager on windowless targets (same rationale as AppLauncher). + setWindowFlags(Qt::FramelessWindowHint); + setAttribute(Qt::WA_TranslucentBackground); + setAttribute(Qt::WA_OpaquePaintEvent, false); + setAutoFillBackground(false); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + card_ = new NotificationCardWidget(this); + layout->addWidget(card_); + connect(card_, &NotificationCardWidget::dismissRequested, this, + &NotificationBanner::hideBanner); + + auto_hide_timer_ = new QTimer(this); + auto_hide_timer_->setSingleShot(true); + connect(auto_hide_timer_, &QTimer::timeout, this, &NotificationBanner::hideBanner); + + setupAnimations(); + hide(); +} + +NotificationBanner::~NotificationBanner() = default; + +void NotificationBanner::showFor(const Notification& notification, const QRect& available) { + card_->setNotification(notification); + adjustSize(); + + QRect avail = available; + if (!avail.isValid() || avail.width() <= 0 || avail.height() <= 0) { + if (const auto* screen = QWidget::window()->screen()) { + avail = screen->availableGeometry(); + } + } + + // Anchor to the top-right of the available area, in desktop-local coords. + const int w = card_->sizeHint().width(); + const int h = card_->sizeHint().height(); + const int x = avail.right() - w - kMargin; + const int y = avail.top() + kMargin; + setGeometry(x, y, w, h); + + // Start the enter pair before show() so the first frame is the animated + // (transparent + offset) state, never a fully opaque flash. + enter_slide_->start(); + enter_fade_->start(); + show(); + raise(); + auto_hide_timer_->start(kAutoHideMs); +} + +void NotificationBanner::hideBanner() { + if (!isVisible()) { + hide(); + return; + } + auto_hide_timer_->stop(); + exit_slide_->start(); + exit_fade_->start(); +} + +bool NotificationBanner::isShowing() const noexcept { + return isVisible(); +} + +void NotificationBanner::keyPressEvent(QKeyEvent* event) { + if (event->key() == Qt::Key_Escape) { + hideBanner(); + return; + } + QWidget::keyPressEvent(event); +} + +void NotificationBanner::setupAnimations() { + qw::core::IMotionSpec* spec = nullptr; + try { + auto& tm = qw::core::ThemeManager::instance(); + spec = &tm.theme(tm.currentThemeName()).motion_spec(); + } catch (...) { + // No theme registered yet; animations fall back to default timing. + } + + using qw::components::material::CFMaterialFadeAnimation; + using qw::components::material::CFMaterialSlideAnimation; + using qw::components::material::SlideDirection; + + enter_fade_ = new CFMaterialFadeAnimation(spec, this); + enter_fade_->setRange(0.0f, 1.0f); + enter_fade_->setMotionToken("shortEnter"); + enter_fade_->setTargetWidget(this); + + enter_slide_ = new CFMaterialSlideAnimation(spec, SlideDirection::Up, this); + enter_slide_->setRange(static_cast(-kEnterSlidePx), 0.0f); + enter_slide_->setMotionToken("mediumEnter"); + enter_slide_->setTargetWidget(this); + + exit_fade_ = new CFMaterialFadeAnimation(spec, this); + exit_fade_->setRange(1.0f, 0.0f); + exit_fade_->setMotionToken("shortExit"); + exit_fade_->setTargetWidget(this); + connect(exit_fade_, &qw::components::ICFAbstractAnimation::finished, this, + [this]() { hide(); }); + + exit_slide_ = new CFMaterialSlideAnimation(spec, SlideDirection::Down, this); + exit_slide_->setRange(0.0f, static_cast(kEnterSlidePx)); + exit_slide_->setMotionToken("mediumExit"); + exit_slide_->setTargetWidget(this); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/notification/notification_banner.h b/ui/components/notification/notification_banner.h new file mode 100644 index 000000000..4f206b3ac --- /dev/null +++ b/ui/components/notification/notification_banner.h @@ -0,0 +1,138 @@ +/** + * @file notification_banner.h + * @brief Transient top-right toast banner for the newest notification. + * + * NotificationBanner is a frameless popup that slides in from the top-right + * (below the status bar) to show the most recent notification as a single + * card, then auto-hides after a few seconds. ESC and the card's dismiss + * hotspot hide it early. It subscribes to NotificationService and only shows + * when a notification is posted with the banner not suppressed (DND off). + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup notification + */ + +#pragma once + +#include "notification.h" + +#include + +class QKeyEvent; +class QRect; +class QTimer; + +namespace qw::components::material { +class CFMaterialFadeAnimation; +class CFMaterialSlideAnimation; +} // namespace qw::components::material + +namespace cf::desktop::desktop_component { + +class NotificationCardWidget; + +/** + * @brief Transient toast banner showing the newest notification. + * + * Frameless child widget (not Qt::Popup, so it does not fight the window + * manager on windowless targets), animated with the QuarkWidgets MD3 fade + + * slide pair. Auto-hides after a short timeout. + * + * @ingroup notification + */ +class NotificationBanner : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the banner (hidden until showFor()). + * + * @param[in] parent Owning widget (the desktop surface). + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + explicit NotificationBanner(QWidget* parent = nullptr); + + /** + * @brief Destructs the banner. + * + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + ~NotificationBanner() override; + + /** + * @brief Positions (top-right of the available area) and shows the banner + * for a notification. + * + * @param[in] notification The notification to display. + * @param[in] available The free screen geometry (excludes docked panels). + * @throws None + * @note Restarts the auto-hide timer. + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void showFor(const Notification& notification, const QRect& available); + + /** + * @brief Starts the exit animation; hides when it finishes. + * + * @throws None + * @note No-op when already hidden. + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void hideBanner(); + + /** + * @brief Reports whether the banner is currently visible. + * + * @return True when the banner is shown. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + bool isShowing() const noexcept; + + protected: + /** + * @brief Hides the banner on Escape. + * + * @param[in] event The key event descriptor. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void keyPressEvent(QKeyEvent* event) override; + + private: + /// @brief Creates the MD3 enter/exit fade + slide animations. + void setupAnimations(); + + /// @brief Card rendering the current notification. + NotificationCardWidget* card_{nullptr}; + /// @brief Auto-hide timer (started on showFor()). + QTimer* auto_hide_timer_{nullptr}; + + qw::components::material::CFMaterialFadeAnimation* enter_fade_{nullptr}; + qw::components::material::CFMaterialSlideAnimation* enter_slide_{nullptr}; + qw::components::material::CFMaterialFadeAnimation* exit_fade_{nullptr}; + qw::components::material::CFMaterialSlideAnimation* exit_slide_{nullptr}; +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/notification/notification_card_widget.cpp b/ui/components/notification/notification_card_widget.cpp new file mode 100644 index 000000000..d20b1cc4b --- /dev/null +++ b/ui/components/notification/notification_card_widget.cpp @@ -0,0 +1,149 @@ +/** + * @file notification_card_widget.cpp + * @brief Implementation of NotificationCardWidget. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup notification + */ + +#include "notification_card_widget.h" + +#include "core/theme_manager.h" +#include "core/token/material_scheme/cfmaterial_token_literals.h" + +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +using namespace qw::core::token::literals; + +namespace { +constexpr int kCardWidth = 320; ///< Fixed card width (px). +constexpr int kCardHeight = 76; ///< Fixed card height (px). +constexpr int kPadX = 14; ///< Horizontal inner padding (px). +constexpr int kPadY = 12; ///< Vertical inner padding (px). +constexpr qreal kRadius = 12.0; ///< Corner radius (px). +constexpr int kCloseSize = 22; ///< Dismiss hotspot edge (px). +} // namespace + +NotificationCardWidget::NotificationCardWidget(QWidget* parent) : QWidget(parent) { + setAutoFillBackground(false); + setFixedSize(kCardWidth, kCardHeight); + // Dismiss hotspot sits in the top-right corner. + close_rect_ = QRect(width() - kCloseSize - kPadX / 2, kPadY / 2, kCloseSize, kCloseSize); + // Follow live theme switches; ThemeManager is the canonical source. + try { + connect(&qw::core::ThemeManager::instance(), &qw::core::ThemeManager::themeChanged, this, + [this](const qw::core::ICFTheme&) { applyTheme(); }); + } catch (...) { + // No theme registered yet; applyTheme() falls back below. + } + applyTheme(); +} + +void NotificationCardWidget::setNotification(const Notification& notification) { + notif_ = notification; + update(); +} + +Notification NotificationCardWidget::notification() const { + return notif_; +} + +QSize NotificationCardWidget::sizeHint() const { + return {kCardWidth, kCardHeight}; +} + +void NotificationCardWidget::paintEvent(QPaintEvent* /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + + // Rounded card surface. + QPainterPath surface; + surface.addRoundedRect(QRectF(rect()), kRadius, kRadius); + p.fillPath(surface, surface_color_); + + // Dismiss "x" hotspot, drawn at the top-right corner. + p.setPen(QPen(close_color_, 1.6)); + const int m = 6; + p.drawLine(close_rect_.left() + m, close_rect_.top() + m, close_rect_.right() - m, + close_rect_.bottom() - m); + p.drawLine(close_rect_.right() - m, close_rect_.top() + m, close_rect_.left() + m, + close_rect_.bottom() - m); + + // Text columns leave room for the dismiss hotspot on the right. + const int text_right = close_rect_.left() - kPadX / 2; + + QFont title_font = font(); + title_font.setBold(true); + title_font.setPixelSize(15); + p.setFont(title_font); + p.setPen(title_color_); + p.drawText(QRect(kPadX, kPadY, text_right - kPadX, 22), Qt::AlignVCenter | Qt::AlignLeft, + notif_.title.isEmpty() ? QStringLiteral("Notification") : notif_.title); + + QFont body_font = font(); + body_font.setPixelSize(13); + p.setFont(body_font); + p.setPen(message_color_); + p.drawText(QRect(kPadX, kPadY + 24, text_right - kPadX, 28), + Qt::AlignTop | Qt::AlignLeft | Qt::TextWordWrap, notif_.message); + + // App id + time meta line. + QFont meta_font = font(); + meta_font.setPixelSize(11); + p.setFont(meta_font); + p.setPen(meta_color_); + const QString time_str = QDateTime::fromMSecsSinceEpoch(notif_.timestamp).toString("HH:mm"); + const QString meta = notif_.app_id.isEmpty() ? time_str : (notif_.app_id + " " + time_str); + p.drawText(QRect(kPadX, height() - kPadY - 14, text_right - kPadX, 14), + Qt::AlignVCenter | Qt::AlignLeft, meta); +} + +void NotificationCardWidget::mousePressEvent(QMouseEvent* event) { + if (event->button() == Qt::LeftButton && close_rect_.contains(event->pos())) { + pressed_on_close_ = true; + return; + } + QWidget::mousePressEvent(event); +} + +void NotificationCardWidget::mouseReleaseEvent(QMouseEvent* event) { + if (pressed_on_close_ && event->button() == Qt::LeftButton && + close_rect_.contains(event->pos())) { + pressed_on_close_ = false; + emit dismissRequested(notif_.id); + return; + } + pressed_on_close_ = false; + QWidget::mouseReleaseEvent(event); +} + +void NotificationCardWidget::applyTheme() { + try { + auto& tm = qw::core::ThemeManager::instance(); + const auto& theme = tm.theme(tm.currentThemeName()); + auto& cs = theme.color_scheme(); + surface_color_ = cs.queryColor(SURFACE_VARIANT); + title_color_ = cs.queryColor(ON_SURFACE); + message_color_ = cs.queryColor(ON_SURFACE_VARIANT); + meta_color_ = cs.queryColor(OUTLINE); + close_color_ = cs.queryColor(ON_SURFACE_VARIANT); + } catch (...) { + // Fallback palette (mirrors MD3 light) when no theme is registered. + surface_color_ = QColor(0xF3, 0xED, 0xF7); + title_color_ = QColor(0x1C, 0x1B, 0x1F); + message_color_ = QColor(0x49, 0x45, 0x4E); + meta_color_ = QColor(0x79, 0x74, 0x7E); + close_color_ = QColor(0x49, 0x45, 0x4E); + } + update(); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/notification/notification_card_widget.h b/ui/components/notification/notification_card_widget.h new file mode 100644 index 000000000..7ea8980c7 --- /dev/null +++ b/ui/components/notification/notification_card_widget.h @@ -0,0 +1,163 @@ +/** + * @file notification_card_widget.h + * @brief Single notification card rendered by the banner and center. + * + * NotificationCardWidget paints one Notification as a rounded Material card + * (title, message, app/time line, dismiss hotspot). The banner shows a + * single card for the newest notification; the notification center stacks + * one card per active notification. Colors follow the active Material theme. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup notification + */ + +#pragma once + +#include "notification.h" + +#include +#include +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief Self-painted single-notification card. + * + * Renders a rounded surface with the notification title, message, and an + * app/time meta line; a dismiss "x" hotspot sits in the top-right corner. + * Clicking the hotspot emits dismissRequested with the notification id. + * + * @ingroup notification + */ +class NotificationCardWidget : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the card. + * + * @param[in] parent Owning widget. + * @throws None + * @note Call setNotification() before first paint to supply data. + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + explicit NotificationCardWidget(QWidget* parent = nullptr); + + /** + * @brief Sets the notification to render. + * + * @param[in] notification The notification to display. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void setNotification(const Notification& notification); + + /** + * @brief Returns the rendered notification. + * + * @return The current notification. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + Notification notification() const; + + /** + * @brief Returns the preferred card size. + * + * @return Size hint (width x height). + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + QSize sizeHint() const override; + + signals: + /** + * @brief Emitted when the dismiss hotspot is clicked. + * + * @param[in] id The id of the notification to dismiss. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void dismissRequested(const QString& id); + + protected: + /** + * @brief Paints the rounded card, text, and dismiss hotspot. + * + * @param[in] event The paint event descriptor. + * @throws None + * @note Theme colors resolve in applyTheme(). + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void paintEvent(QPaintEvent* event) override; + + /** + * @brief Records a press on the dismiss hotspot. + * + * @param[in] event The mouse event descriptor. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void mousePressEvent(QMouseEvent* event) override; + + /** + * @brief Activates the dismiss hotspot when the press releases on it. + * + * @param[in] event The mouse event descriptor. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void mouseReleaseEvent(QMouseEvent* event) override; + + private: + /// @brief Resolves theme colors and repaints. + void applyTheme(); + + /// @brief Currently rendered notification. + Notification notif_; + + /// @brief Dismiss hotspot geometry (top-right), in widget coords. + QRect close_rect_; + + /// @brief True while a press is active inside the dismiss hotspot. + bool pressed_on_close_{false}; + + /// @brief Card surface fill (surfaceContainer). + QColor surface_color_; + /// @brief Title text color (onSurface). + QColor title_color_; + /// @brief Message text color (onSurfaceVariant). + QColor message_color_; + /// @brief App/time meta line color (outline). + QColor meta_color_; + /// @brief Dismiss glyph color (onSurfaceVariant). + QColor close_color_; +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/notification/notification_center_panel.cpp b/ui/components/notification/notification_center_panel.cpp new file mode 100644 index 000000000..115820694 --- /dev/null +++ b/ui/components/notification/notification_center_panel.cpp @@ -0,0 +1,275 @@ +/** + * @file notification_center_panel.cpp + * @brief Implementation of NotificationCenterPanel. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup notification + */ + +#include "notification_center_panel.h" + +#include "notification.h" +#include "notification_card_widget.h" +#include "notification_service.h" + +#include "components/material/cfmaterial_fade_animation.h" +#include "components/material/cfmaterial_slide_animation.h" +#include "core/theme_manager.h" +#include "core/token/material_scheme/cfmaterial_token_literals.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace cf::desktop::desktop_component { + +using namespace qw::core::token::literals; + +namespace { +constexpr int kPanelWidth = 360; ///< Fixed panel width (px). +constexpr int kHeaderHeight = 44; ///< Header band height (px). +constexpr int kRadius = 16.0; ///< Corner radius (px). +constexpr int kSideMargin = 16; ///< Gap from the screen edge (px). +constexpr int kEnterSlidePx = 24; ///< Enter/exit horizontal slide distance (px). +constexpr int kClearAllWidth = 80; ///< Clear-all hotspot width (px). +} // namespace + +NotificationCenterPanel::NotificationCenterPanel(QWidget* parent) : QWidget(parent) { + setWindowFlags(Qt::FramelessWindowHint); + setAttribute(Qt::WA_TranslucentBackground); + setAttribute(Qt::WA_OpaquePaintEvent, false); + setAutoFillBackground(false); + + setupUi(); + applyTheme(); + setupAnimations(); + hide(); + + // Live theme switches. + try { + connect(&qw::core::ThemeManager::instance(), &qw::core::ThemeManager::themeChanged, this, + [this](const qw::core::ICFTheme&) { applyTheme(); }); + } catch (...) { + // No theme registered yet; applyTheme() falls back below. + } + + // Stay live while open: any service change triggers a rebuild. + auto& svc = NotificationService::instance(); + connect(&svc, &NotificationService::notificationPosted, this, + [this](const Notification&, bool) { rebuildList(); }); + connect(&svc, &NotificationService::notificationDismissed, this, + [this](const QString&) { rebuildList(); }); + connect(&svc, &NotificationService::allCleared, this, [this]() { rebuildList(); }); +} + +NotificationCenterPanel::~NotificationCenterPanel() = default; + +void NotificationCenterPanel::setupUi() { + auto* outer = new QVBoxLayout(this); + outer->setContentsMargins(0, kHeaderHeight, 0, 0); + outer->setSpacing(0); + + scroll_ = new QScrollArea(this); + scroll_->setWidgetResizable(true); + scroll_->setFrameShape(QFrame::NoFrame); + scroll_->setAttribute(Qt::WA_TranslucentBackground, true); + + list_container_ = new QWidget(scroll_); + list_layout_ = new QVBoxLayout(list_container_); + list_layout_->setContentsMargins(kSideMargin, 0, kSideMargin, kSideMargin); + list_layout_->setSpacing(8); + list_layout_->addStretch(1); + scroll_->setWidget(list_container_); + outer->addWidget(scroll_); + + clear_all_rect_ = + QRect(kPanelWidth - kClearAllWidth - kSideMargin / 2, 0, kClearAllWidth, kHeaderHeight); +} + +void NotificationCenterPanel::popup(const QRect& available) { + QRect avail = available; + if (!avail.isValid() || avail.width() <= 0 || avail.height() <= 0) { + if (const auto* screen = QGuiApplication::primaryScreen()) { + avail = screen->availableGeometry(); + } + } + + const int w = kPanelWidth; + const int h = std::clamp(avail.height() - kSideMargin * 2, 320, 720); + const int x = avail.right() - w - kSideMargin; + const int y = avail.top() + kSideMargin; + setGeometry(x, y, w, h); + + rebuildList(); + + enter_slide_->start(); + enter_fade_->start(); + show(); + raise(); +} + +void NotificationCenterPanel::hidePanel() { + if (!isVisible()) { + hide(); + return; + } + exit_slide_->start(); + exit_fade_->start(); +} + +bool NotificationCenterPanel::isShowing() const noexcept { + return isVisible(); +} + +void NotificationCenterPanel::paintEvent(QPaintEvent* /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + + // Rounded panel surface. + QPainterPath surface; + surface.addRoundedRect(QRectF(rect()), kRadius, kRadius); + p.fillPath(surface, surface_color_); + + // Header title. + QFont title_font = font(); + title_font.setBold(true); + title_font.setPixelSize(15); + p.setFont(title_font); + p.setPen(title_color_); + p.drawText(QRect(kSideMargin, 0, width() - kSideMargin * 2, kHeaderHeight), + Qt::AlignVCenter | Qt::AlignLeft, QStringLiteral("Notifications")); + + // Clear-all label (right-aligned inside its hotspot). + QFont clear_font = font(); + clear_font.setPixelSize(13); + p.setFont(clear_font); + p.setPen(clear_color_); + p.drawText(clear_all_rect_, Qt::AlignVCenter | Qt::AlignRight, QStringLiteral("Clear all")); + + // Empty-state hint when there is nothing to show. + if (NotificationService::instance().count() == 0) { + QFont hint_font = font(); + hint_font.setPixelSize(13); + p.setFont(hint_font); + p.setPen(hint_color_); + p.drawText(QRect(0, kHeaderHeight, width(), height() - kHeaderHeight), Qt::AlignCenter, + QStringLiteral("No notifications")); + } +} + +void NotificationCenterPanel::keyPressEvent(QKeyEvent* event) { + if (event->key() == Qt::Key_Escape) { + hidePanel(); + return; + } + QWidget::keyPressEvent(event); +} + +void NotificationCenterPanel::mousePressEvent(QMouseEvent* event) { + if (event->button() == Qt::LeftButton && clear_all_rect_.contains(event->pos())) { + pressed_on_clear_ = true; + return; + } + QWidget::mousePressEvent(event); +} + +void NotificationCenterPanel::mouseReleaseEvent(QMouseEvent* event) { + if (pressed_on_clear_ && event->button() == Qt::LeftButton && + clear_all_rect_.contains(event->pos())) { + pressed_on_clear_ = false; + NotificationService::instance().clearAll(); + return; + } + pressed_on_clear_ = false; + QWidget::mouseReleaseEvent(event); +} + +void NotificationCenterPanel::applyTheme() { + try { + auto& tm = qw::core::ThemeManager::instance(); + const auto& theme = tm.theme(tm.currentThemeName()); + auto& cs = theme.color_scheme(); + surface_color_ = cs.queryColor(SURFACE); + title_color_ = cs.queryColor(ON_SURFACE); + clear_color_ = cs.queryColor(PRIMARY); + hint_color_ = cs.queryColor(ON_SURFACE_VARIANT); + } catch (...) { + surface_color_ = QColor(0xF7, 0xF5, 0xF3); + title_color_ = QColor(0x1C, 0x1B, 0x1F); + clear_color_ = QColor(0x67, 0x50, 0xA4); + hint_color_ = QColor(0x49, 0x45, 0x4E); + } + update(); +} + +void NotificationCenterPanel::rebuildList() { + // Drop old cards but keep the trailing stretch item (count == 1 means + // only the stretch remains). Cards are direct children of list_container_. + while (list_layout_->count() > 1) { + QLayoutItem* item = list_layout_->takeAt(0); + if (item != nullptr && item->widget() != nullptr) { + delete item->widget(); // synchronous: no dangling cards between rebuilds + } + delete item; + } + + const auto all = NotificationService::instance().all(); + for (const auto& n : all) { + auto* card = new NotificationCardWidget(list_container_); + card->setNotification(n); + connect(card, &NotificationCardWidget::dismissRequested, this, + [](const QString& id) { NotificationService::instance().dismiss(id); }); + // Insert before the trailing stretch (last index). + list_layout_->insertWidget(list_layout_->count() - 1, card); + } + update(); +} + +void NotificationCenterPanel::setupAnimations() { + qw::core::IMotionSpec* spec = nullptr; + try { + auto& tm = qw::core::ThemeManager::instance(); + spec = &tm.theme(tm.currentThemeName()).motion_spec(); + } catch (...) { + // No theme registered yet; animations fall back to default timing. + } + + using qw::components::material::CFMaterialFadeAnimation; + using qw::components::material::CFMaterialSlideAnimation; + using qw::components::material::SlideDirection; + + enter_fade_ = new CFMaterialFadeAnimation(spec, this); + enter_fade_->setRange(0.0f, 1.0f); + enter_fade_->setMotionToken("shortEnter"); + enter_fade_->setTargetWidget(this); + + // Slide in from the right edge. + enter_slide_ = new CFMaterialSlideAnimation(spec, SlideDirection::Left, this); + enter_slide_->setRange(static_cast(-kEnterSlidePx), 0.0f); + enter_slide_->setMotionToken("mediumEnter"); + enter_slide_->setTargetWidget(this); + + exit_fade_ = new CFMaterialFadeAnimation(spec, this); + exit_fade_->setRange(1.0f, 0.0f); + exit_fade_->setMotionToken("shortExit"); + exit_fade_->setTargetWidget(this); + connect(exit_fade_, &qw::components::ICFAbstractAnimation::finished, this, + [this]() { hide(); }); + + exit_slide_ = new CFMaterialSlideAnimation(spec, SlideDirection::Right, this); + exit_slide_->setRange(0.0f, static_cast(kEnterSlidePx)); + exit_slide_->setMotionToken("mediumExit"); + exit_slide_->setTargetWidget(this); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/notification/notification_center_panel.h b/ui/components/notification/notification_center_panel.h new file mode 100644 index 000000000..e8568854a --- /dev/null +++ b/ui/components/notification/notification_center_panel.h @@ -0,0 +1,194 @@ +/** + * @file notification_center_panel.h + * @brief Slide-in notification center panel listing active notifications. + * + * NotificationCenterPanel is a frameless popup anchored to the right edge + * that lists every active notification as a stack of cards. It subscribes to + * NotificationService and rebuilds the list on every posted / dismissed / + * cleared signal, so it stays live while open. A header "clear all" hotspot + * dismisses the whole list. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup notification + */ + +#pragma once + +#include +#include +#include + +class QKeyEvent; +class QMouseEvent; +class QPaintEvent; +class QScrollArea; +class QVBoxLayout; + +namespace qw::components::material { +class CFMaterialFadeAnimation; +class CFMaterialSlideAnimation; +} // namespace qw::components::material + +namespace cf::desktop::desktop_component { + +/** + * @brief Slide-in panel listing all active notifications. + * + * Frameless child widget animated with the QuarkWidgets MD3 fade + slide + * pair. Reads its contents from NotificationService::all() and rebuilds on + * every service change while visible. + * + * @ingroup notification + */ +class NotificationCenterPanel : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the panel (hidden until popup()). + * + * @param[in] parent Owning widget (the desktop surface). + * @throws None + * @note Subscribes to NotificationService immediately. + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + explicit NotificationCenterPanel(QWidget* parent = nullptr); + + /** + * @brief Destructs the panel. + * + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + ~NotificationCenterPanel() override; + + /** + * @brief Positions (right edge) and shows the panel, then rebuilds. + * + * @param[in] available The free screen geometry (excludes docked panels). + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void popup(const QRect& available); + + /** + * @brief Starts the exit animation; hides when it finishes. + * + * @throws None + * @note No-op when already hidden. + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void hidePanel(); + + /** + * @brief Reports whether the panel is currently visible. + * + * @return True when the panel is shown. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + bool isShowing() const noexcept; + + protected: + /** + * @brief Paints the rounded surface, header title, and clear-all hotspot. + * + * @param[in] event The paint event descriptor. + * @throws None + * @note Theme colors resolve in applyTheme(). + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void paintEvent(QPaintEvent* event) override; + + /** + * @brief Hides the panel on Escape. + * + * @param[in] event The key event descriptor. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void keyPressEvent(QKeyEvent* event) override; + + /** + * @brief Records a press on the clear-all hotspot. + * + * @param[in] event The mouse event descriptor. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void mousePressEvent(QMouseEvent* event) override; + + /** + * @brief Activates clear-all when the press releases on it. + * + * @param[in] event The mouse event descriptor. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup notification + */ + void mouseReleaseEvent(QMouseEvent* event) override; + + private: + /// @brief Builds the header + scroll area layout. + void setupUi(); + /// @brief Creates the MD3 enter/exit fade + slide animations. + void setupAnimations(); + /// @brief Resolves theme colors, then repaints. + void applyTheme(); + /// @brief Rebuilds the card list from NotificationService::all(). + void rebuildList(); + + /// @brief Scroll area hosting the notification cards. + QScrollArea* scroll_{nullptr}; + /// @brief Container widget holding the card QVBoxLayout. + QWidget* list_container_{nullptr}; + /// @brief Layout stacking one card per active notification. + QVBoxLayout* list_layout_{nullptr}; + + /// @brief Clear-all hotspot geometry, in widget coords. + QRect clear_all_rect_; + /// @brief True while a press is active inside the clear-all hotspot. + bool pressed_on_clear_{false}; + + qw::components::material::CFMaterialFadeAnimation* enter_fade_{nullptr}; + qw::components::material::CFMaterialSlideAnimation* enter_slide_{nullptr}; + qw::components::material::CFMaterialFadeAnimation* exit_fade_{nullptr}; + qw::components::material::CFMaterialSlideAnimation* exit_slide_{nullptr}; + + /// @brief Panel surface fill (surface). + QColor surface_color_; + /// @brief Header title color (onSurface). + QColor title_color_; + /// @brief Clear-all glyph color (primary). + QColor clear_color_; + /// @brief Empty-state hint color (onSurfaceVariant). + QColor hint_color_; +}; + +} // namespace cf::desktop::desktop_component From 8c814510cc236416590de32d4e06ed416fbe8cc1 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 11:26:23 +0800 Subject: [PATCH 04/26] feat(control-center): MS6 panel with sliders, toggles, theme switch Add ControlCenter, a frameless quick-settings popup reusing the AppLauncher pattern (MD3 fade/slide enter-exit, rounded surface). Holds: - Brightness + volume sliders (0-100, UI-only this phase; hardware backends arrive with aels-power / aels-network). - Wi-Fi + Bluetooth toggles (UI-only this phase). - DND toggle wired to NotificationService::setDndEnabled -> ConfigStore "notification.dnd.enabled", so it drives banner suppression for real. - Theme toggle button: switches the active Material theme via ThemeManager::setThemeTo (live). - Screenshot placeholder button. Sliders use qw::widget::material::Slider, toggles Switch, buttons Button (QuarkWidgets MD3). Hardware-backed controls + real brightness/volume are deferred to the embedded pivot. --- ui/components/CMakeLists.txt | 1 + ui/components/control_center/CMakeLists.txt | 21 ++ .../control_center/control_center.cpp | 248 ++++++++++++++++++ ui/components/control_center/control_center.h | 170 ++++++++++++ 4 files changed, 440 insertions(+) create mode 100644 ui/components/control_center/CMakeLists.txt create mode 100644 ui/components/control_center/control_center.cpp create mode 100644 ui/components/control_center/control_center.h diff --git a/ui/components/CMakeLists.txt b/ui/components/CMakeLists.txt index 4a72c9000..176f054d8 100644 --- a/ui/components/CMakeLists.txt +++ b/ui/components/CMakeLists.txt @@ -15,6 +15,7 @@ add_subdirectory(builtin_apps) add_subdirectory(desktop_widget) add_subdirectory(home_page) add_subdirectory(notification) +add_subdirectory(control_center) # Create interface library for convenience add_library(cf_desktop_components STATIC) diff --git a/ui/components/control_center/CMakeLists.txt b/ui/components/control_center/CMakeLists.txt new file mode 100644 index 000000000..47e627908 --- /dev/null +++ b/ui/components/control_center/CMakeLists.txt @@ -0,0 +1,21 @@ +# Control center quick-settings popup (MS6 minimal version). +add_library(cfdesktop_control_center STATIC + control_center.cpp +) + +target_include_directories(cfdesktop_control_center PUBLIC + $ + $ + $ +) + +target_link_libraries( + cfdesktop_control_center +PRIVATE + Qt6::Core + Qt6::Widgets + QuarkWidgets::quarkwidgets # ThemeManager + Slider / Switch / Button + animations + cfdesktop_notification # DND toggle via NotificationService + cfconfig # ConfigStore (DND persistence) + cflogger # Diagnostic logging +) diff --git a/ui/components/control_center/control_center.cpp b/ui/components/control_center/control_center.cpp new file mode 100644 index 000000000..f9f0fd21f --- /dev/null +++ b/ui/components/control_center/control_center.cpp @@ -0,0 +1,248 @@ +/** + * @file control_center.cpp + * @brief Implementation of ControlCenter. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup control_center + */ + +#include "control_center.h" + +#include "notification_service.h" + +#include "components/material/cfmaterial_fade_animation.h" +#include "components/material/cfmaterial_slide_animation.h" +#include "core/theme_manager.h" +#include "core/token/material_scheme/cfmaterial_token_literals.h" +#include "ui/widget/material/widget/button/button.h" +#include "ui/widget/material/widget/slider/slider.h" +#include "ui/widget/material/widget/switch/switch.h" + +#include "cflog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace cf::desktop::desktop_component { + +using namespace qw::core::token::literals; + +namespace { +constexpr int kPanelWidth = 360; ///< Fixed panel width (px). +constexpr int kPanelHeight = 360; ///< Fixed panel height (px). +constexpr qreal kRadius = 16.0; ///< Corner radius (px). +constexpr int kSideMargin = 16; ///< Gap from the screen edge (px). +constexpr int kEnterSlidePx = 24; ///< Enter/exit vertical slide distance (px). +constexpr const char* kLogTag = "ControlCenter"; +} // namespace + +ControlCenter::ControlCenter(QWidget* parent) : QWidget(parent) { + setWindowFlags(Qt::FramelessWindowHint); + setAttribute(Qt::WA_TranslucentBackground); + setAttribute(Qt::WA_OpaquePaintEvent, false); + setAutoFillBackground(false); + + setupUi(); + applyTheme(); + setupAnimations(); + hide(); + + try { + connect(&qw::core::ThemeManager::instance(), &qw::core::ThemeManager::themeChanged, this, + [this](const qw::core::ICFTheme&) { applyTheme(); }); + } catch (...) { + // No theme registered yet; applyTheme() falls back below. + } +} + +ControlCenter::~ControlCenter() = default; + +void ControlCenter::setupUi() { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(20, 20, 20, 20); + root->setSpacing(14); + + using qw::widget::material::Button; + using qw::widget::material::Slider; + using qw::widget::material::Switch; + + // Slider row helper: label + horizontal slider. + auto add_slider_row = [&](const QString& label, int init) -> Slider* { + auto* row = new QWidget(this); + auto* h = new QHBoxLayout(row); + h->setContentsMargins(0, 0, 0, 0); + auto* l = new QLabel(label, row); + l->setMinimumWidth(72); + auto* s = new Slider(Qt::Horizontal, row); + s->setRange(0, 100); + s->setValue(init); + h->addWidget(l); + h->addWidget(s, 1); + root->addWidget(row); + return s; + }; + + brightness_ = add_slider_row(QStringLiteral("Brightness"), 80); + volume_ = add_slider_row(QStringLiteral("Volume"), 60); + // UI-only this phase: log the intent, no hardware backend yet. + connect(brightness_, &QSlider::valueChanged, this, + [](int v) { cf::log::infoftag(kLogTag, "brightness -> {} (UI-only)", v); }); + connect(volume_, &QSlider::valueChanged, this, + [](int v) { cf::log::infoftag(kLogTag, "volume -> {} (UI-only)", v); }); + + // Toggle row: Wi-Fi, Bluetooth, DND. + auto* switches = new QWidget(this); + auto* sw = new QHBoxLayout(switches); + sw->setContentsMargins(0, 0, 0, 0); + wifi_ = new Switch(QStringLiteral("Wi-Fi"), switches); + bluetooth_ = new Switch(QStringLiteral("Bluetooth"), switches); + dnd_ = new Switch(QStringLiteral("DND"), switches); + wifi_->setChecked(false); + bluetooth_->setChecked(false); + dnd_->setChecked(NotificationService::instance().isDndEnabled()); + sw->addWidget(wifi_); + sw->addWidget(bluetooth_); + sw->addWidget(dnd_); + sw->addStretch(1); + root->addWidget(switches); + + connect(wifi_, &QCheckBox::toggled, this, + [](bool on) { cf::log::infoftag(kLogTag, "wi-fi={} (UI-only)", on); }); + connect(bluetooth_, &QCheckBox::toggled, this, + [](bool on) { cf::log::infoftag(kLogTag, "bluetooth={} (UI-only)", on); }); + // DND is real: persists to ConfigStore + drives banner suppression. + connect(dnd_, &QCheckBox::toggled, this, + [](bool on) { NotificationService::instance().setDndEnabled(on); }); + + // Button row: theme toggle + screenshot placeholder. + auto* btns = new QWidget(this); + auto* bh = new QHBoxLayout(btns); + bh->setContentsMargins(0, 0, 0, 0); + theme_btn_ = new Button(QStringLiteral("Toggle theme"), Button::ButtonVariant::Tonal, btns); + screenshot_btn_ = + new Button(QStringLiteral("Screenshot"), Button::ButtonVariant::Outlined, btns); + bh->addWidget(theme_btn_); + bh->addWidget(screenshot_btn_); + bh->addStretch(1); + root->addWidget(btns); + + connect(theme_btn_, &QPushButton::clicked, this, []() { + auto& tm = qw::core::ThemeManager::instance(); + const bool dark = tm.currentThemeName() == "dark"; + tm.setThemeTo(dark ? "light" : "dark"); + }); + connect(screenshot_btn_, &QPushButton::clicked, this, + []() { cf::log::infoftag(kLogTag, "screenshot requested (placeholder)"); }); +} + +void ControlCenter::popup(const QRect& available) { + QRect avail = available; + if (!avail.isValid() || avail.width() <= 0 || avail.height() <= 0) { + if (const auto* screen = QGuiApplication::primaryScreen()) { + avail = screen->availableGeometry(); + } + } + const int w = kPanelWidth; + const int h = kPanelHeight; + const int x = avail.right() - w - kSideMargin; + const int y = avail.top() + kSideMargin; + setGeometry(x, y, w, h); + + // Refresh the DND toggle from the store in case it changed elsewhere. + dnd_->setChecked(NotificationService::instance().isDndEnabled()); + + enter_slide_->start(); + enter_fade_->start(); + show(); + raise(); +} + +void ControlCenter::hidePanel() { + if (!isVisible()) { + hide(); + return; + } + exit_slide_->start(); + exit_fade_->start(); +} + +bool ControlCenter::isShowing() const noexcept { + return isVisible(); +} + +void ControlCenter::paintEvent(QPaintEvent* /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + QPainterPath surface; + surface.addRoundedRect(QRectF(rect()), kRadius, kRadius); + p.fillPath(surface, surface_color_); +} + +void ControlCenter::keyPressEvent(QKeyEvent* event) { + if (event->key() == Qt::Key_Escape) { + hidePanel(); + return; + } + QWidget::keyPressEvent(event); +} + +void ControlCenter::applyTheme() { + try { + auto& tm = qw::core::ThemeManager::instance(); + const auto& theme = tm.theme(tm.currentThemeName()); + surface_color_ = theme.color_scheme().queryColor(SURFACE); + } catch (...) { + surface_color_ = QColor(0xF7, 0xF5, 0xF3); + } + update(); +} + +void ControlCenter::setupAnimations() { + qw::core::IMotionSpec* spec = nullptr; + try { + auto& tm = qw::core::ThemeManager::instance(); + spec = &tm.theme(tm.currentThemeName()).motion_spec(); + } catch (...) { + // No theme registered yet; animations fall back to default timing. + } + + using qw::components::material::CFMaterialFadeAnimation; + using qw::components::material::CFMaterialSlideAnimation; + using qw::components::material::SlideDirection; + + enter_fade_ = new CFMaterialFadeAnimation(spec, this); + enter_fade_->setRange(0.0f, 1.0f); + enter_fade_->setMotionToken("shortEnter"); + enter_fade_->setTargetWidget(this); + + enter_slide_ = new CFMaterialSlideAnimation(spec, SlideDirection::Up, this); + enter_slide_->setRange(static_cast(-kEnterSlidePx), 0.0f); + enter_slide_->setMotionToken("mediumEnter"); + enter_slide_->setTargetWidget(this); + + exit_fade_ = new CFMaterialFadeAnimation(spec, this); + exit_fade_->setRange(1.0f, 0.0f); + exit_fade_->setMotionToken("shortExit"); + exit_fade_->setTargetWidget(this); + connect(exit_fade_, &qw::components::ICFAbstractAnimation::finished, this, + [this]() { hide(); }); + + exit_slide_ = new CFMaterialSlideAnimation(spec, SlideDirection::Down, this); + exit_slide_->setRange(0.0f, static_cast(kEnterSlidePx)); + exit_slide_->setMotionToken("mediumExit"); + exit_slide_->setTargetWidget(this); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/control_center/control_center.h b/ui/components/control_center/control_center.h new file mode 100644 index 000000000..0b7a72359 --- /dev/null +++ b/ui/components/control_center/control_center.h @@ -0,0 +1,170 @@ +/** + * @file control_center.h + * @brief Slide-in quick-settings panel (brightness, volume, toggles, theme). + * + * ControlCenter is a frameless popup anchored near the top-right that bundles + * the quick controls: brightness and volume sliders, Wi-Fi / Bluetooth / DND + * switches, and a theme toggle. It follows the AppLauncher frameless-child + * pattern (no Qt::Popup, so it does not fight the window manager on windowless + * targets). Sliders and the Wi-Fi/Bluetooth toggles are UI-only in this phase + * (real hardware backends arrive with the aels-power / aels-network repos); + * the DND switch is wired to the ConfigStore via NotificationService, and the + * theme toggle switches the active Material theme for real. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup control_center + */ + +#pragma once + +#include +#include +#include + +class QKeyEvent; +class QPaintEvent; + +namespace qw::components::material { +class CFMaterialFadeAnimation; +class CFMaterialSlideAnimation; +} // namespace qw::components::material +namespace qw::widget::material { +class Button; +class Slider; +class Switch; +} // namespace qw::widget::material + +namespace cf::desktop::desktop_component { + +/** + * @brief Quick-settings popup (MS6 minimal version). + * + * Frameless child widget animated with the QuarkWidgets MD3 fade + slide + * pair. Hardware-backed brightness/volume/Wi-Fi/Bluetooth are deferred; DND + * and theme switching are live. + * + * @ingroup control_center + */ +class ControlCenter : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the panel (hidden until popup()). + * + * @param[in] parent Owning widget (the desktop surface). + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup control_center + */ + explicit ControlCenter(QWidget* parent = nullptr); + + /** + * @brief Destructs the panel. + * + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup control_center + */ + ~ControlCenter() override; + + /** + * @brief Positions (top-right) and shows the panel. + * + * @param[in] available The free screen geometry (excludes docked panels). + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup control_center + */ + void popup(const QRect& available); + + /** + * @brief Starts the exit animation; hides when it finishes. + * + * @throws None + * @note No-op when already hidden. + * @warning None + * @since 0.19.0 + * @ingroup control_center + */ + void hidePanel(); + + /** + * @brief Reports whether the panel is currently visible. + * + * @return True when the panel is shown. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup control_center + */ + bool isShowing() const noexcept; + + protected: + /** + * @brief Paints the rounded Material surface background. + * + * @param[in] event The paint event descriptor. + * @throws None + * @note Theme colors resolve in applyTheme(). + * @warning None + * @since 0.19.0 + * @ingroup control_center + */ + void paintEvent(QPaintEvent* event) override; + + /** + * @brief Hides the panel on Escape. + * + * @param[in] event The key event descriptor. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup control_center + */ + void keyPressEvent(QKeyEvent* event) override; + + private: + /// @brief Builds the slider / switch / button layout. + void setupUi(); + /// @brief Creates the MD3 enter/exit fade + slide animations. + void setupAnimations(); + /// @brief Resolves theme colors, then repaints. + void applyTheme(); + + /// @brief Brightness slider (UI-only this phase). + qw::widget::material::Slider* brightness_{nullptr}; + /// @brief Volume slider (UI-only this phase). + qw::widget::material::Slider* volume_{nullptr}; + /// @brief Wi-Fi toggle (UI-only this phase). + qw::widget::material::Switch* wifi_{nullptr}; + /// @brief Bluetooth toggle (UI-only this phase). + qw::widget::material::Switch* bluetooth_{nullptr}; + /// @brief Do-Not-Disturb toggle (wired to ConfigStore via NotificationService). + qw::widget::material::Switch* dnd_{nullptr}; + /// @brief Theme toggle button (switches the active Material theme). + qw::widget::material::Button* theme_btn_{nullptr}; + /// @brief Screenshot placeholder button. + qw::widget::material::Button* screenshot_btn_{nullptr}; + + qw::components::material::CFMaterialFadeAnimation* enter_fade_{nullptr}; + qw::components::material::CFMaterialSlideAnimation* enter_slide_{nullptr}; + qw::components::material::CFMaterialFadeAnimation* exit_fade_{nullptr}; + qw::components::material::CFMaterialSlideAnimation* exit_slide_{nullptr}; + + /// @brief Panel surface fill (surface). + QColor surface_color_; +}; + +} // namespace cf::desktop::desktop_component From c34e1a497a743c43acafaec51f4989a573bba0e0 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 11:33:27 +0800 Subject: [PATCH 05/26] feat(statusbar): clock + notification click signals, vector bell icon Add two click entry points to the status bar: - timeClicked(): emitted when the clock region is hit (Split style: the time rect before the icon cluster; Centered: anywhere not on an icon). - notifyIconClicked(): emitted when the new leftmost icon cell is hit. The notification icon is drawn as a vector bell + unread badge (QPainterPath, no PNG mask) so it carries no compiled-resource dependency; the badge lights whenever NotificationService has active notifications. mousePressEvent mirrors paintEvent's cluster geometry (now 5 cells) for hit testing. cfdesktop_statusbar links cfdesktop_notification for the count. --- ui/components/statusbar/CMakeLists.txt | 1 + ui/components/statusbar/status_bar.cpp | 71 +++++++++++++++++++++++++- ui/components/statusbar/status_bar.h | 36 +++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/ui/components/statusbar/CMakeLists.txt b/ui/components/statusbar/CMakeLists.txt index 2020c8196..328c7d306 100644 --- a/ui/components/statusbar/CMakeLists.txt +++ b/ui/components/statusbar/CMakeLists.txt @@ -19,4 +19,5 @@ PRIVATE cfbase # WeakPtr / WeakPtrFactory cflogger # Diagnostic logging for icon-mask loading cfdesktop_frosted_backdrop # Frosted-glass backdrop renderer + cfdesktop_notification # Unread badge: NotificationService::count() ) diff --git a/ui/components/statusbar/status_bar.cpp b/ui/components/statusbar/status_bar.cpp index 0d1cdf195..f4497a884 100644 --- a/ui/components/statusbar/status_bar.cpp +++ b/ui/components/statusbar/status_bar.cpp @@ -18,6 +18,7 @@ #include "base/device_pixel.h" #include "cflog.h" +#include "components/notification/notification_service.h" #include "core/theme_manager.h" #include "core/token/material_scheme/cfmaterial_token_literals.h" #include "core/token/typography/cfmaterial_typography_token_literals.h" @@ -25,8 +26,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -266,7 +269,8 @@ void StatusBar::paintEvent(QPaintEvent* /*event*/) { const qreal sideMargin = h.dpToPx(kSideMarginDp); const qreal iconSize = h.dpToPx(kIconSizeDp); const qreal iconGap = h.dpToPx(kIconGapDp); - const qreal clusterWidth = icons_visible_ ? (4 * iconSize + 3 * iconGap) : 0; + // 4 mask icons (Signal/Battery/Wifi/Volume) + 1 vector notification icon. + const qreal clusterWidth = icons_visible_ ? (5 * iconSize + 4 * iconGap) : 0; const qreal iconClusterLeft = width() - sideMargin - clusterWidth; if (time_visible_) { @@ -312,6 +316,30 @@ void StatusBar::paintEvent(QPaintEvent* /*event*/) { drawStatusIcon(StatusIcon::Battery, x); x -= (iconSize + iconGap); drawStatusIcon(StatusIcon::Signal, x); + x -= (iconSize + iconGap); + + // Notification icon: a vector bell + unread badge. Drawn as a path + // rather than a PNG mask so it needs no compiled resource. + const QRectF nbCell = cell(x); + const qreal cx = nbCell.center().x(); + const qreal bw = iconSize * 0.72; + const qreal bh = iconSize * 0.72; + const qreal baseY = nbCell.center().y() + bh * 0.30; + p.setPen(Qt::NoPen); + p.setBrush(icon_color_); + QPainterPath bell; + bell.moveTo(cx - bw / 2, baseY); + bell.cubicTo(cx - bw / 2, baseY - bh, cx + bw / 2, baseY - bh, cx + bw / 2, baseY); + bell.lineTo(cx + bw / 2, baseY + bh * 0.05); + bell.lineTo(cx - bw / 2, baseY + bh * 0.05); + bell.closeSubpath(); + p.drawPath(bell); + p.drawEllipse(QPointF(cx, baseY + bh * 0.16), bw * 0.09, bw * 0.09); // clapper + if (NotificationService::instance().count() > 0) { + p.setBrush(QColor(0xE2, 0x45, 0x45)); // unread red dot + p.drawEllipse(QPointF(nbCell.right() - 1, nbCell.top() + 1), iconSize * 0.20, + iconSize * 0.20); + } } // In-band soft elevation shadow at the bottom seam (PanelManager locks the @@ -336,4 +364,45 @@ void StatusBar::paintEvent(QPaintEvent* /*event*/) { p.drawLine(QPointF(0, height() - h.dpToPx(0.5)), QPointF(width(), height() - h.dpToPx(0.5))); } +void StatusBar::mousePressEvent(QMouseEvent* event) { + if (event->button() != Qt::LeftButton) { + QWidget::mousePressEvent(event); + return; + } + const CanvasUnitHelper h(devicePixelRatioF()); + const qreal sideMargin = h.dpToPx(kSideMarginDp); + const qreal iconSize = h.dpToPx(kIconSizeDp); + const qreal iconGap = h.dpToPx(kIconGapDp); + // Mirror paintEvent's cluster geometry (5 cells now). + const qreal clusterWidth = icons_visible_ ? (5 * iconSize + 4 * iconGap) : 0; + const qreal iconClusterLeft = width() - sideMargin - clusterWidth; + const int sm = static_cast(sideMargin); + + // Notification icon is the leftmost cell of the cluster. + if (icons_visible_) { + const qreal y = (height() - iconSize) / 2.0; + const QRectF notifCell(iconClusterLeft, y, iconSize, iconSize); + if (notifCell.contains(event->pos())) { + emit notifyIconClicked(); + return; + } + } + + // Clock region. Centered style spans the full width; Split style ends + // just before the icon cluster (same rect paintEvent draws the time into). + if (time_visible_) { + if (style_ == StatusBarStyle::Centered) { + emit timeClicked(); + return; + } + const qreal timeRight = iconClusterLeft - h.dpToPx(kTimeGapDp); + const QRect timeRect(sm, 0, static_cast(timeRight) - sm, height()); + if (timeRect.contains(event->pos())) { + emit timeClicked(); + return; + } + } + QWidget::mousePressEvent(event); +} + } // namespace cf::desktop::desktop_component diff --git a/ui/components/statusbar/status_bar.h b/ui/components/statusbar/status_bar.h index 51cd12f39..0b713484a 100644 --- a/ui/components/statusbar/status_bar.h +++ b/ui/components/statusbar/status_bar.h @@ -24,6 +24,7 @@ #include #include +class QMouseEvent; class QTimer; namespace cf::desktop::desktop_component { @@ -209,6 +210,29 @@ class StatusBar final : public QWidget, public IStatusBar { */ aex::WeakPtr GetWeak() const { return weak_factory_.GetWeakPtr(); } + signals: + /** + * @brief Emitted when the clock region is clicked. + * + * @throws None + * @note Wired to the control center popup in CFDesktopEntity. + * @warning None + * @since 0.19.0 + * @ingroup components + */ + void timeClicked(); + + /** + * @brief Emitted when the notification icon is clicked. + * + * @throws None + * @note Wired to the notification center popup in CFDesktopEntity. + * @warning None + * @since 0.19.0 + * @ingroup components + */ + void notifyIconClicked(); + protected: /** * @brief Paints the background, clock, and icon glyphs. @@ -223,6 +247,18 @@ class StatusBar final : public QWidget, public IStatusBar { */ void paintEvent(QPaintEvent* event) override; + /** + * @brief Routes clicks on the clock / notification icon to signals. + * + * @param[in] event The mouse event descriptor. + * @throws None + * @note Hit geometry mirrors paintEvent's clock / icon layout. + * @warning None + * @since 0.19.0 + * @ingroup components + */ + void mousePressEvent(QMouseEvent* event) override; + private slots: /// @brief Refreshes the clock text each second. void onTimeout(); From aacb972b88963e625e375569f265fb51690d5830 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 11:39:03 +0800 Subject: [PATCH 06/26] feat(shell): wire control center + notifications into CFDesktopEntity Connect the new Phase F components into the desktop shell: - Construct NotificationService (singleton), ControlCenter, NotificationCenterPanel, NotificationBanner as children of the desktop. - StatusBar::timeClicked toggles the control center; notifyIconClicked toggles the notification center (popup/hidePanel with availableGeometry). - NotificationService::notificationPosted drives the banner (only when not DND-suppressed). - IPCServer::notifyReceived routes external "notify" payloads into NotificationService::post (title/message/app_id). - cf_desktop_components aggregates cfdesktop_notification + cfdesktop_control_center so their symbols resolve into CFDesktop_shared. Outside-click dismissal is deferred (matches AppLauncher: ESC + the status bar toggle close either popup). --- ui/CFDesktopEntity.cpp | 52 ++++++++++++++++++++++++++++++++++++ ui/components/CMakeLists.txt | 2 ++ 2 files changed, 54 insertions(+) diff --git a/ui/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index d8ad8c770..f4caf1a8d 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -13,6 +13,7 @@ #include "components/WindowManager.h" #include "components/builtin_apps/about_panel.h" #include "components/builtin_apps/builtin_panel_registry.h" +#include "components/control_center/control_center.h" #include "components/desktop_icon_layer/desktop_icon_layer.h" #include "components/desktop_icon_layer/desktop_shortcut_store.h" #include "components/home_page/home_page.h" @@ -21,6 +22,9 @@ #include "components/launcher/app_launch_service.h" #include "components/launcher/app_launcher.h" #include "components/launcher/desktop_entry_index.h" +#include "components/notification/notification_banner.h" +#include "components/notification/notification_center_panel.h" +#include "components/notification/notification_service.h" #include "components/statusbar/status_bar.h" #include "components/taskbar/centered_taskbar.h" #include "components/window_placement/floating_policy.h" @@ -530,6 +534,54 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { app_launcher->popup(panel_mgr->availableGeometry()); } }); + // ── Control center + notification system ── + // NotificationService is a process singleton; force its construction. + auto& notification_svc = cf::desktop::desktop_component::NotificationService::instance(); + auto* control_center = new cf::desktop::desktop_component::ControlCenter(desktop_entity_); + auto* notif_center = + new cf::desktop::desktop_component::NotificationCenterPanel(desktop_entity_); + auto* notif_banner = new cf::desktop::desktop_component::NotificationBanner(desktop_entity_); + + // Status bar entries: click the clock / notification icon to toggle. + QObject::connect(status_bar, &cf::desktop::desktop_component::StatusBar::timeClicked, this, + [control_center, panel_mgr]() { + if (control_center->isShowing()) { + control_center->hidePanel(); + } else { + control_center->popup(panel_mgr->availableGeometry()); + } + }); + QObject::connect(status_bar, &cf::desktop::desktop_component::StatusBar::notifyIconClicked, + this, [notif_center, panel_mgr]() { + if (notif_center->isShowing()) { + notif_center->hidePanel(); + } else { + notif_center->popup(panel_mgr->availableGeometry()); + } + }); + + // Banner shows for every non-suppressed post (DND off). + QObject::connect(¬ification_svc, + &cf::desktop::desktop_component::NotificationService::notificationPosted, this, + [notif_banner, panel_mgr]( + const cf::desktop::desktop_component::Notification& n, bool suppressed) { + if (!suppressed) { + notif_banner->showFor(n, panel_mgr->availableGeometry()); + } + }); + + // External apps deliver notifications over IPC; payload -> service. + QObject::connect(&cf::ipc::IPCServer::instance(), &cf::ipc::IPCServer::notifyReceived, this, + [¬ification_svc](const QJsonObject& payload) { + cf::desktop::desktop_component::Notification n; + n.title = payload.value("title").toString(); + n.message = payload.value("message").toString(); + n.app_id = payload.value("app_id").toString(); + notification_svc.post(n); + }); + // Outside-click dismissal is deferred (matches AppLauncher, which relies on + // ESC + the toggle entry). ESC closes either popup. + taskbar->show(); panel_mgr->relayout(); diff --git a/ui/components/CMakeLists.txt b/ui/components/CMakeLists.txt index 176f054d8..98028b16a 100644 --- a/ui/components/CMakeLists.txt +++ b/ui/components/CMakeLists.txt @@ -49,5 +49,7 @@ PRIVATE cfdesktop_window_placement cfdesktop_desktop_widget cfdesktop_home_page + cfdesktop_notification # NotificationService + banner + center + cfdesktop_control_center # ControlCenter quick-settings popup Qt6::Core Qt6::Gui Qt6::Widgets ) From fa45e0300a9ef673eb762b1bc7917c48e85bb1a4 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 11:42:38 +0800 Subject: [PATCH 07/26] docs(status): record Phase F minimal closed loop Mark MS6 Step B2 (control center) + the notification minimal closed loop as landed in current.md, milestone_06, and 11_notification_control: ControlCenter MS6 panel, NotificationService (in-process singleton), banner, notification center, statusbar click signals + vector bell icon, and IPC notify routing. Deferred items (hardware backends, persistence, grouping, standalone notification process, outside-click dismiss) are called out explicitly so the next pass knows what's left. --- document/status/current.md | 3 ++- document/todo/desktop/11_notification_control.md | 6 +++++- document/todo/desktop/milestone_06_widget_control_center.md | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/document/status/current.md b/document/status/current.md index 214ffa94b..b94755482 100644 --- a/document/status/current.md +++ b/document/status/current.md @@ -69,7 +69,8 @@ description: CFDesktop 项目进度的唯一事实来源与全局导航。 - **2026-07(主仓重组:cfbase + desktop/base 合并 + desktop/ 平铺)**:删 `desktop/` 包装层——`base/`(cfbase) + 原 `desktop/base/`(logger/config/path/filesystem/fundamental/ascii_art) 合并成新顶层 `base/`(cfbase 子目录化,desktop/base 6 模块同居);`desktop/ui` → `ui/`、`desktop/main` → `main/`(升顶层);`desktop/export.h` + `desktop/main.cpp` → 顶层;`CFDesktop_shared` 组装搬到顶层 CMakeLists。源码**零改动**(内部全相对 include,平移后路径不变,实测 `#include "desktop/"` = 0);CMake 重组(base/ 合并 + 顶层 `add_subdirectory(ui/main)` + CFDesktop_shared + exe,补 `enable_testing()` 顶层)。三层架构图 `base → ui → desktop` 改 `base → ui → main`。cfbase 名副其实(真正的 base 层,含探针 + 桌面基础设施)。验证:build 全绿 + ctest 全过 + desktop 启动正常。 - **2026-07(MS5 窗口状态机 + 任务栏 raise/minimize + FloatingPolicy)**:补齐 milestone_05 除装饰外的全部验收项。`WindowState` 状态机(`isValidTransition`+`applyState`,Normal↔Minimized/Maximized、Closed 终态)+ WindowManager 查询/信号(`getWindowInfo`/`getAllWindowInfos`/`findWindowByPid` + `windowStateChanged`/`windowInfoUpdated`);`IWindow` 加 `minimize/maximize/restore` 非纯虚默认 no-op——Windows 三态全实现(`ShowWindow`),WSL X11 实现 minimize+restore(ICCCM 4.1.4 `WM_CHANGE_STATE` ClientMessage → root + SubstructureRedirectMask,即 `XIconifyWindow`/xdotool 路径)、maximize 留默认 no-op(XWayland 下 EWMH `_NET_WM_STATE` 不可靠);`onWindowCame` 同步填 `windows_` map(原来只填 `window_infos_` 致 `find_window` 对外部窗口失效) + destroyed lambda 先发 Closed 再 erase;Taskbar 点击运行图标走 raise/minimize toggle(不再二次启动);`FloatingPolicy` 新窗口初始位置(居中+24px 级联+缩进 work area,镜像 `WindowPlacementPolicy`);WindowInfo 补 `icon_hint`/`z_index`/`is_always_on_top`/`created_at`。单测:FloatingPolicy 13 例 + WindowManager 14 例(FakeWindow+QSignalSpy)。**装饰 overlay (策略 A) DEFERRED 到 EGLFS 阶段**(WSL X11/Win32 外部窗口自带原生标题栏、overlay 重影;EGLFS/Wayland-compositor 后端将原生持有装饰权)。详见 [milestone_05](../todo/desktop/milestone_05_window_management.md)。 - **2026-07(MS6 Step A 桌面时钟小组件)**:新增桌面 widget 框架——`WidgetBase`(可拖拽基类,press-drag-move + 阈值)+ `WidgetContainer`(QObject 注册表)+ `ClockWidget`(Material 圆角卡片 HH:mm + 日期,1s 刷新,`ThemeManager::themeChanged` 跟随),落 `ui/components/desktop_widget/`(target `cfdesktop_desktop_widget`,link QuarkWidgets),挂 `CFDesktopEntity` 桌面(icon_layer 之上)。范式复用:主题色/字体照 statusbar(`queryColor(SURFACE/ON_SURFACE/ON_SURFACE_VARIANT)` + `queryTargetFont(TYPOGRAPHY_DISPLAY_LARGE/BODY_MEDIUM)`),圆角卡片照 AppLauncher(`QPainterPath addRoundedRect + fillPath`,无 elevation 阴影)。`ControlCenter` 下拉面板属 Step B。详见 [milestone_06](../todo/desktop/milestone_06_widget_control_center.md)。(⚠️ 后续:`ClockWidget`/`WidgetContainer` 随 HomePage 主屏迁移取代删除,见下条;`WidgetBase` 保留。) -- **2026-07(MS6 重定向:迁移 CCIMX HomePage 主屏 + 多页左右滑桌面)**:Step A 自由时钟 widget 范式与主屏冲突,改迁移 CCIMXDesktop `HomePage` 作默认桌面。落 `ui/components/home_page/`(target `cfdesktop_home_page`):`AnalogClockWidget`(QPainter 模拟表盘)+ `DigitalTimeWidget`(hh:mm:ss+日期)+ `CardStackWidget`(垂直滑动卡片堆,1/4 高阈值 + QPropertyAnimation slide/fade,搬自 CCIMX)+ `HomeCardManager` + `GlobalClockSources`(1s 时钟源)+ `DateCard`(占位)+ `HomePage`(左右布局)+ `PageStackWidget`(水平切页)。颜色/字体 MD3 化(`queryColor`/`queryTargetFont` token)。**桌面改多页**:`PageStackWidget`(QStackedWidget)装 HomePage(页0 信息流主屏)↔ icon_layer(页1 Win11 图标桌面),水平拖拽 1/4 宽切页 + pos 插值动画。**修叠加 bug**:HomePage 透明透显 icon_layer → 多页独立显示。**手势分区靠 Qt 冒泡**:HomePage/时钟不 accept press → 冒泡 PageStack(水平);`CardStackWidget` accept → 垂直卡片。删 Step A 的 `ClockWidget`/`WidgetContainer`(HomePage 取代),`WidgetBase` 保留;`icon_layer` 作页1(全 connect 保留,几何改本地坐标)。WSL boot 验证通过;视觉/滑动待用户确认。Step B:复杂卡片(UserInfo/Net/Weather 依赖 CCIMX 基建)+ 控制中心 + 应用页。**Step B1 已落地(2026-07-08)**:`CardStackWidget` 加 Memory/CPU/Disk 实时数据卡(进度条 primary/surfaceVariant + cfbase `getSystemMemoryInfo`/`getCPUProfileInfo`/`std::filesystem::space`,2/5/10s 刷新),`home_page` link cfbase。**CPU probe 移出 UI 线程(2026-07-08,commit 8af7650)**:CPU 使用率测量固有需 100ms 采样窗口(`getCpuUsage` 两次读 `/proc/stat` 算 delta),原在 UI 线程每 tick 卡 ~100ms;改 `QtConcurrent::run` 跑在线程池 + `QFutureWatcher::finished` 回 UI 线程更新;`in_flight_` 防 probe 堆积、worker 仅值捕获 probe 不捕获 `this`、watcher 作 card child 随销毁(在飞 future 被 detach 不 UAF)。memory/disk probe 一并 async(统一范式)。**Step B2 视觉照搬 CCIMX(2026-07-08)**:重做视觉——表盘白径向渐变+8px 黑厚边+黑针+**红秒针**+12/3/6/9 数字(draw-by-draw 照搬)、数字时间白字 40/15(Linux/WSL 无 Helvetica Neue,改用 `painter.font()` 系统默认字体)、卡片从自绘 paintEvent 模板改为 **QSS 渐变**(`DateCard` 蓝渐变 `#4A90E2→#1E3C72`;`SystemUsageCard` 深灰渐变 `#5D6D7E→#2C3E50`+QProgressBar chunk `#1abc9c`+`QGraphicsDropShadowEffect` 阴影)、布局全 0 margin+精确 stretch(1,1/5,2/3,1)+右下渐变面板;**去 MD3 主题化**(硬编码 QSS,Phase G 再接)。`SystemUsageCard` 配置式(title+probe+interval)统一 Memory/CPU/Disk 三卡(替独立卡),数据源仍 cfbase。**Step C 全量迁移(2026-07-08,commit 53a5620+d15f8d)**:卡栈补齐至 CCIMX 对等——`UserInfoCard`(绿渐变,POSIX `getpwuid`/`gethostname`/`uname` 填真实桌面数据,头像画首字母圆;CFDesktop 无 DesktopUserInfo 子系统,不假装有 phone/email)、`ModernCalendarWidget`(QCalendarWidget 子类,自绘 cell+日期标记,照搬 paintCell;去 CCIMX 导航箭头 PNG 用 Qt 默认)、`GaugeWidget`(自绘半圆仪表盘,逐行照搬 CCIMX;顺手修 min/max 初始化笔误 + drawNeedle 除零)+ `DiskGaugeCard`(替 Disk 的 QProgressBar 为 gauge,沿用 async probe)——卡栈顺序对齐 CCIMX(UserInfo/Calendar/Date/Disk/Memory)+ CPU 作附加卡。右下渐变占位换 2 列 gadget 网格:`NetStatusCard`(cfbase `getNetworkInfo` → reachability/transport/IPv4,青绿渐变,替 CCIMX NetAbilityScanner)、`LocalTempCard`(读 `/sys/class/thermal/thermal_zone*` 真实温度,WSL2 无 zone 则诚实 N/A;替 CCIMX 的 ICM20608 传感器——桌面无此硬件,CCIMX 在 x86 亦用随机数假数据)。两 gadget 独立(不继承 AppCardWidget/DesktopToast,该框架 CFDesktop 无)。MS6 HomePage 主屏迁移至此全部完成;仅余 Step B2 控制中心 + 应用页。 +- **2026-07(MS6 重定向:迁移 CCIMX HomePage 主屏 + 多页左右滑桌面)**:Step A 自由时钟 widget 范式与主屏冲突,改迁移 CCIMXDesktop `HomePage` 作默认桌面。落 `ui/components/home_page/`(target `cfdesktop_home_page`):`AnalogClockWidget`(QPainter 模拟表盘)+ `DigitalTimeWidget`(hh:mm:ss+日期)+ `CardStackWidget`(垂直滑动卡片堆,1/4 高阈值 + QPropertyAnimation slide/fade,搬自 CCIMX)+ `HomeCardManager` + `GlobalClockSources`(1s 时钟源)+ `DateCard`(占位)+ `HomePage`(左右布局)+ `PageStackWidget`(水平切页)。颜色/字体 MD3 化(`queryColor`/`queryTargetFont` token)。**桌面改多页**:`PageStackWidget`(QStackedWidget)装 HomePage(页0 信息流主屏)↔ icon_layer(页1 Win11 图标桌面),水平拖拽 1/4 宽切页 + pos 插值动画。**修叠加 bug**:HomePage 透明透显 icon_layer → 多页独立显示。**手势分区靠 Qt 冒泡**:HomePage/时钟不 accept press → 冒泡 PageStack(水平);`CardStackWidget` accept → 垂直卡片。删 Step A 的 `ClockWidget`/`WidgetContainer`(HomePage 取代),`WidgetBase` 保留;`icon_layer` 作页1(全 connect 保留,几何改本地坐标)。WSL boot 验证通过;视觉/滑动待用户确认。Step B:复杂卡片(UserInfo/Net/Weather 依赖 CCIMX 基建)+ 控制中心 + 应用页。**Step B1 已落地(2026-07-08)**:`CardStackWidget` 加 Memory/CPU/Disk 实时数据卡(进度条 primary/surfaceVariant + cfbase `getSystemMemoryInfo`/`getCPUProfileInfo`/`std::filesystem::space`,2/5/10s 刷新),`home_page` link cfbase。**CPU probe 移出 UI 线程(2026-07-08,commit 8af7650)**:CPU 使用率测量固有需 100ms 采样窗口(`getCpuUsage` 两次读 `/proc/stat` 算 delta),原在 UI 线程每 tick 卡 ~100ms;改 `QtConcurrent::run` 跑在线程池 + `QFutureWatcher::finished` 回 UI 线程更新;`in_flight_` 防 probe 堆积、worker 仅值捕获 probe 不捕获 `this`、watcher 作 card child 随销毁(在飞 future 被 detach 不 UAF)。memory/disk probe 一并 async(统一范式)。**Step B2 视觉照搬 CCIMX(2026-07-08)**:重做视觉——表盘白径向渐变+8px 黑厚边+黑针+**红秒针**+12/3/6/9 数字(draw-by-draw 照搬)、数字时间白字 40/15(Linux/WSL 无 Helvetica Neue,改用 `painter.font()` 系统默认字体)、卡片从自绘 paintEvent 模板改为 **QSS 渐变**(`DateCard` 蓝渐变 `#4A90E2→#1E3C72`;`SystemUsageCard` 深灰渐变 `#5D6D7E→#2C3E50`+QProgressBar chunk `#1abc9c`+`QGraphicsDropShadowEffect` 阴影)、布局全 0 margin+精确 stretch(1,1/5,2/3,1)+右下渐变面板;**去 MD3 主题化**(硬编码 QSS,Phase G 再接)。`SystemUsageCard` 配置式(title+probe+interval)统一 Memory/CPU/Disk 三卡(替独立卡),数据源仍 cfbase。**Step C 全量迁移(2026-07-08,commit 53a5620+d15f8d)**:卡栈补齐至 CCIMX 对等——`UserInfoCard`(绿渐变,POSIX `getpwuid`/`gethostname`/`uname` 填真实桌面数据,头像画首字母圆;CFDesktop 无 DesktopUserInfo 子系统,不假装有 phone/email)、`ModernCalendarWidget`(QCalendarWidget 子类,自绘 cell+日期标记,照搬 paintCell;去 CCIMX 导航箭头 PNG 用 Qt 默认)、`GaugeWidget`(自绘半圆仪表盘,逐行照搬 CCIMX;顺手修 min/max 初始化笔误 + drawNeedle 除零)+ `DiskGaugeCard`(替 Disk 的 QProgressBar 为 gauge,沿用 async probe)——卡栈顺序对齐 CCIMX(UserInfo/Calendar/Date/Disk/Memory)+ CPU 作附加卡。右下渐变占位换 2 列 gadget 网格:`NetStatusCard`(cfbase `getNetworkInfo` → reachability/transport/IPv4,青绿渐变,替 CCIMX NetAbilityScanner)、`LocalTempCard`(读 `/sys/class/thermal/thermal_zone*` 真实温度,WSL2 无 zone 则诚实 N/A;替 CCIMX 的 ICM20608 传感器——桌面无此硬件,CCIMX 在 x86 亦用随机数假数据)。两 gadget 独立(不继承 AppCardWidget/DesktopToast,该框架 CFDesktop 无)。MS6 HomePage 主屏迁移至此全部完成;Step B2 控制中心 + 通知系统最小闭环见下条;仅余应用页(PageStack 第 3 页,MS4 launcher 已覆盖,非必需)。 +- **2026-07(Phase F 最小闭环:控制中心 + 通知系统,feat/phase-f-control-center-notifications)**:`ControlCenter`(MS6 版,`ui/components/control_center/`,target `cfdesktop_control_center`)——亮度/音量 `Slider` + WiFi/蓝牙/DND `Switch` + 主题切换 `Button`(`setThemeTo` 真实生效)+ 圆角卡片 + MD3 fade/slide 动画;DND Switch 接 ConfigStore `notification.dnd.enabled`。`NotificationService`(进程内单例,内存模型,`ui/components/notification/`)+ `Notification`(id/title/message/app_id/timestamp)+ `NotificationBanner`(横幅,5s 自动消失)+ `NotificationCenterPanel`(通知中心)+ `StatusBar::timeClicked/notifyIconClicked` 信号 + `mousePressEvent` 命中 + 矢量通知图标(铃铛 + 未读红点)+ IPC `notify` 消息(外部 app 发通知,cfipc,向后兼容 `raise`)。9 例单测全过(notification_service_test 6 + ipc_notify_test 3)。**defer**:硬件后端真实接入(亮度/音量/WiFi/蓝牙,待 aels-power/network 建仓)、通知持久化(重启清空)、分组/优先级/操作按钮、独立 NotificationService 进程、outside-click 关闭(当前 ESC + 状态栏 toggle)。详见 [milestone_06](../todo/desktop/milestone_06_widget_control_center.md) + [11_notification_control](../todo/desktop/11_notification_control.md)。 - **已达成**:Milestone 1「桌面骨架可见」;Phase 0 / 1 / 2 / A(CI) / 6 / G(Widget) / H(显示后端)(详见 [SUMMARY.md](../todo/done/SUMMARY.md)) ## 新人入门 diff --git a/document/todo/desktop/11_notification_control.md b/document/todo/desktop/11_notification_control.md index 64b4d8763..0a79bddf6 100644 --- a/document/todo/desktop/11_notification_control.md +++ b/document/todo/desktop/11_notification_control.md @@ -5,7 +5,7 @@ description: "预计周期: 2~3 周,依赖阶段: Phase 6, Phase 10" # Phase 11: 通知与控制中心 TODO -> **状态**: ⬜ 待开始 +> **状态**: 🚧 最小闭环已落地(2026-07-10);完整版(持久化/分组/优先级/操作按钮/媒体卡片)仍待做 > **预计周期**: 2~3 周 > **依赖阶段**: Phase 6, Phase 10 > **目标交付物**: NotificationService 通知服务、ControlCenter 控制中心 @@ -14,6 +14,10 @@ description: "预计周期: 2~3 周,依赖阶段: Phase 6, Phase 10" > - NotificationService 设计为**独立进程**,跨进程通信**强依赖 Phase A-A5 IPC(当前 0%,见 [06_infrastructure.md](06_infrastructure.md))**,必须与 IPC 同批落地。 > - 勿与本仓已有的 `desktop/base/config_manager/include/cfconfig_notify_policy.h` 混淆——后者是 ConfigStore 的**配置变更回调派发策略**(Manual/Immediate),与用户通知无关。 > - 绝不引入 D-Bus;嵌入式走文件/socket IPC(可借鉴 CCIMXDesktop 的 toast 文件投递范式)。 +> +> ✅ **最小闭环已落地(2026-07-10,feat/phase-f-control-center-notifications)**:`NotificationService`(**进程内单例**,内存模型,`ui/components/notification/`)+ `Notification`(id/title/message/app_id/timestamp)+ `NotificationBanner`(横幅,5s 自动消失)+ `NotificationCenterPanel`(通知中心,状态栏通知图标触发)+ `StatusBar::timeClicked/notifyIconClicked` + 矢量通知图标(铃铛 + 未读红点)+ IPC `notify` 消息(外部 app 发通知,cfipc)+ DND 持久化(ConfigStore `notification.dnd.enabled`)。 +> +> ⚠️ **defer 项**:通知持久化(当前重启清空)、按应用分组、优先级分级(Critical/Normal/Low/Silent)、操作按钮、媒体控制卡片、`NotificationService` 独立进程(当前为 shell 进程内单例,跨进程经 cfipc `notify` 消息投递)、硬件后端真实接入(亮度/音量/WiFi/蓝牙,待 aels-power/network 建仓)。 --- diff --git a/document/todo/desktop/milestone_06_widget_control_center.md b/document/todo/desktop/milestone_06_widget_control_center.md index 361fbdbbd..efafa8c70 100644 --- a/document/todo/desktop/milestone_06_widget_control_center.md +++ b/document/todo/desktop/milestone_06_widget_control_center.md @@ -5,13 +5,13 @@ description: "预计周期: 7-10 天,前置依赖: Milestone 2: 状态栏 (下 # Milestone 6: 小组件 + 控制中心 -> **状态**: 🚧 进行中(Step A ✅ 2026-07-08;Step B 控制中心待做) +> **状态**: ✅ Step A + Step B 完成(2026-07-08 / 2026-07-10) > **预计周期**: 7-10 天 > **前置依赖**: [Milestone 2: 状态栏](milestone_02_status_bar.md) (下拉触发点) > **可与 MS3/MS4/MS5 并行** > **目标**: 桌面上有时钟小组件,从状态栏下拉打开简易控制中心 (亮度/音量/主题切换) > -> **Step A 已落地(2026-07-08,feat/shell-foundation)**:`WidgetBase`(可拖拽基类,press-drag-move + 阈值)+ `WidgetContainer`(QObject 注册表,mount 到 host widget)+ `ClockWidget`(Material 圆角卡片 `surfaceContainer` + `displayLarge` HH:mm + `bodyMedium` 日期,coarse 1s 刷新,`ThemeManager::themeChanged` 跟随),落 [`ui/components/desktop_widget/`](../../../ui/components/desktop_widget/)(target `cfdesktop_desktop_widget`,link `QuarkWidgets::quarkwidgets`),挂 `CFDesktopEntity` 桌面(icon_layer 之上)。**路径订正**:本文档原 `desktop/ui/widget/...` 已过时(07 月重组升顶层),实际落 `ui/components/`(与 statusbar/taskbar 同级独立 target)。**Step B 待做**:`ControlCenter` 下拉面板(Slider/Switch/Button + slide/fade 动画)+ `StatusBar` 新增 `timeClicked()` 信号 + mousePressEvent 命中。 +> **Step A 已落地(2026-07-08,feat/shell-foundation)**:`WidgetBase`(可拖拽基类,press-drag-move + 阈值)+ `WidgetContainer`(QObject 注册表,mount 到 host widget)+ `ClockWidget`(Material 圆角卡片 `surfaceContainer` + `displayLarge` HH:mm + `bodyMedium` 日期,coarse 1s 刷新,`ThemeManager::themeChanged` 跟随),落 [`ui/components/desktop_widget/`](../../../ui/components/desktop_widget/)(target `cfdesktop_desktop_widget`,link `QuarkWidgets::quarkwidgets`),挂 `CFDesktopEntity` 桌面(icon_layer 之上)。**路径订正**:本文档原 `desktop/ui/widget/...` 已过时(07 月重组升顶层),实际落 `ui/components/`(与 statusbar/taskbar 同级独立 target)。**Step B 已落地(2026-07-10,feat/phase-f-control-center-notifications)**:`ControlCenter`(`ui/components/control_center/`,target `cfdesktop_control_center`)— 亮度/音量 `Slider` + WiFi/蓝牙/DND `Switch` + 主题切换 `Button`(`setThemeTo` 真实生效)+ 圆角卡片 + MD3 fade/slide 动画;DND Switch 接 ConfigStore `notification.dnd.enabled`。硬件后端(真实亮度/音量/WiFi/蓝牙)defer 到 aels-power/network 建仓。`StatusBar` 加 `timeClicked()` / `notifyIconClicked()` 信号 + `mousePressEvent` 命中 + 矢量通知图标(铃铛 + 未读红点)。详见 [11_notification_control](11_notification_control.md)。 > > 📌 **路径核对(2026-06-29)**:本文档中所有 bare `ui/...` 路径(ThemeManager / Material 控件 / 动画工厂 / 阴影系统)均已随 Phase 2 抽离迁移至 **QuarkWidgets 子模块** `third_party/QuarkWidgets/ui/...`(CMake 目标 `QuarkWidgets::quarkwidgets`)。`desktop/ui/...` 仍为本仓自有。 From 8cb32673d01bac33f6f615a048746aeffb01e8ac Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 11:56:45 +0800 Subject: [PATCH 08/26] feat(control-center): test notification stub + fix panel height - Add a "Send test notification" button that posts a "Hello World" Notification via NotificationService, so the banner + notification center can be verified end-to-end without an IPC client. Drop once real producers exist. - Replace the fixed 360 px panel height with layout sizeHint() bounded to the work area; the fixed height clipped the bottom switch/button row. --- .../control_center/control_center.cpp | 24 ++++++++++++++++++- ui/components/control_center/control_center.h | 2 ++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/ui/components/control_center/control_center.cpp b/ui/components/control_center/control_center.cpp index f9f0fd21f..9d2e12434 100644 --- a/ui/components/control_center/control_center.cpp +++ b/ui/components/control_center/control_center.cpp @@ -145,6 +145,25 @@ void ControlCenter::setupUi() { }); connect(screenshot_btn_, &QPushButton::clicked, this, []() { cf::log::infoftag(kLogTag, "screenshot requested (placeholder)"); }); + + // Test stub: posts a "Hello World" notification so the banner + center can + // be exercised end-to-end without an IPC client. Drop once real producers + // arrive. + auto* testRow = new QWidget(this); + auto* th = new QHBoxLayout(testRow); + th->setContentsMargins(0, 0, 0, 0); + test_notif_btn_ = new Button(QStringLiteral("Send test notification"), + Button::ButtonVariant::Filled, testRow); + th->addWidget(test_notif_btn_); + th->addStretch(1); + root->addWidget(testRow); + connect(test_notif_btn_, &QPushButton::clicked, this, []() { + Notification n; + n.title = "Hello World"; + n.message = "Test notification from the control center"; + n.app_id = "org.cf.control_center"; + NotificationService::instance().post(n); + }); } void ControlCenter::popup(const QRect& available) { @@ -155,7 +174,10 @@ void ControlCenter::popup(const QRect& available) { } } const int w = kPanelWidth; - const int h = kPanelHeight; + // Height follows the layout so switches / buttons are never clipped (a + // fixed 360 px left the bottom row cut off). Bounded to the work area. + layout()->activate(); + const int h = std::min(layout()->sizeHint().height(), avail.height() - kSideMargin * 2); const int x = avail.right() - w - kSideMargin; const int y = avail.top() + kSideMargin; setGeometry(x, y, w, h); diff --git a/ui/components/control_center/control_center.h b/ui/components/control_center/control_center.h index 0b7a72359..2443b2043 100644 --- a/ui/components/control_center/control_center.h +++ b/ui/components/control_center/control_center.h @@ -157,6 +157,8 @@ class ControlCenter : public QWidget { qw::widget::material::Button* theme_btn_{nullptr}; /// @brief Screenshot placeholder button. qw::widget::material::Button* screenshot_btn_{nullptr}; + /// @brief Test stub: posts a "Hello World" notification (end-to-end check). + qw::widget::material::Button* test_notif_btn_{nullptr}; qw::components::material::CFMaterialFadeAnimation* enter_fade_{nullptr}; qw::components::material::CFMaterialSlideAnimation* enter_slide_{nullptr}; From a46367850e7c688d2fcd582d10288368931001fa Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 12:45:32 +0800 Subject: [PATCH 09/26] fix(notification): center banner, slow enter, guard DND echo loop - Banner: anchor top-center instead of top-right; slow the enter animation one motion-token tier (fade mediumEnter, slide longEnter). - ControlCenter: blockSignals around popup()->setChecked(DND) so the programmatic sync does not re-fire toggled -> setDndEnabled, which had silently written dnd.enabled=true to ConfigStore and suppressed every banner post. --- ui/components/control_center/control_center.cpp | 5 ++++- ui/components/notification/notification_banner.cpp | 9 ++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ui/components/control_center/control_center.cpp b/ui/components/control_center/control_center.cpp index 9d2e12434..ff99654a5 100644 --- a/ui/components/control_center/control_center.cpp +++ b/ui/components/control_center/control_center.cpp @@ -182,8 +182,11 @@ void ControlCenter::popup(const QRect& available) { const int y = avail.top() + kSideMargin; setGeometry(x, y, w, h); - // Refresh the DND toggle from the store in case it changed elsewhere. + // Refresh the DND toggle from the store. Block signals so the programmatic + // setChecked does not re-fire toggled -> setDndEnabled (echo write loop). + dnd_->blockSignals(true); dnd_->setChecked(NotificationService::instance().isDndEnabled()); + dnd_->blockSignals(false); enter_slide_->start(); enter_fade_->start(); diff --git a/ui/components/notification/notification_banner.cpp b/ui/components/notification/notification_banner.cpp index 2a1476718..774088e15 100644 --- a/ui/components/notification/notification_banner.cpp +++ b/ui/components/notification/notification_banner.cpp @@ -12,7 +12,6 @@ #include "notification_banner.h" #include "notification_card_widget.h" -#include "notification_service.h" #include "components/material/cfmaterial_fade_animation.h" #include "components/material/cfmaterial_slide_animation.h" @@ -67,10 +66,10 @@ void NotificationBanner::showFor(const Notification& notification, const QRect& } } - // Anchor to the top-right of the available area, in desktop-local coords. + // Anchor to the top-center of the available area, in desktop-local coords. const int w = card_->sizeHint().width(); const int h = card_->sizeHint().height(); - const int x = avail.right() - w - kMargin; + const int x = avail.center().x() - w / 2; const int y = avail.top() + kMargin; setGeometry(x, y, w, h); @@ -120,12 +119,12 @@ void NotificationBanner::setupAnimations() { enter_fade_ = new CFMaterialFadeAnimation(spec, this); enter_fade_->setRange(0.0f, 1.0f); - enter_fade_->setMotionToken("shortEnter"); + enter_fade_->setMotionToken("mediumEnter"); enter_fade_->setTargetWidget(this); enter_slide_ = new CFMaterialSlideAnimation(spec, SlideDirection::Up, this); enter_slide_->setRange(static_cast(-kEnterSlidePx), 0.0f); - enter_slide_->setMotionToken("mediumEnter"); + enter_slide_->setMotionToken("longEnter"); enter_slide_->setTargetWidget(this); exit_fade_ = new CFMaterialFadeAnimation(spec, this); From 231361a67834d4b072e34e20b047303b2610b0bb Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 12:55:07 +0800 Subject: [PATCH 10/26] =?UTF-8?q?docs(home=5Fpage):=20fix=20stale=20commen?= =?UTF-8?q?t=20=E2=80=94=20gadget=20grid=20replaced=20app-grid=20placehold?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The right-column lower area was rewritten in MS6 Step C as the NetStatus + LocalTemp gadget grid; the file header still described it as a placeholder app-grid area. --- ui/components/home_page/home_page.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/components/home_page/home_page.h b/ui/components/home_page/home_page.h index a2be43008..d7c59f6c4 100644 --- a/ui/components/home_page/home_page.h +++ b/ui/components/home_page/home_page.h @@ -5,7 +5,7 @@ * Migrated from CCIMXDesktop HomePage. Layout is rewritten as hand-coded * QLayout (CFDesktop uses no .ui files): left column holds the analog clock * (5/7) above the digital time (2/7); right column holds the CardStackWidget - * (3/4) above a placeholder app-grid area (1/4, Step B). + * (3/4) above the NetStatus + LocalTemp gadget grid (1/4, Step C). * * @author CFDesktop Team * @date 2026-07-08 From fefb2e860026cf6994b2fb8921881112f9421d61 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 13:01:14 +0800 Subject: [PATCH 11/26] feat(crash): addr2line symbolization for raw_frames + test Phase 2 step 1: resolve bare stack addresses into (function, file, line) triples. - CrashReport gains ResolvedFrame + resolved_frames; toJson serializes it. - symbolizer.cpp runs addr2line (one shot, -f -C) via popen on Linux at finalize time (normal context, not signal-safe); non-Linux returns empty (Windows dbghelp deferred). parseAddr2LineOutput is split out for testing. - CrashHandler::finalizePendingReports takes an exe_path; when non-empty it fills resolved_frames. Defaults to "" so existing callers/tests unchanged. - CrashHandlerStage passes QCoreApplication::applicationFilePath(). - crash_symbolizer_test covers parsing (function/file:line split, "??", truncation, empty). raw_frames kept intact; existing crash tests unchanged. --- base/crash_handler/CMakeLists.txt | 1 + .../include/cfcrash/crash_handler.h | 3 +- .../include/cfcrash/crash_report.h | 25 ++++- .../include/cfcrash/symbolizer.h | 61 ++++++++++++ base/crash_handler/src/crash_handler.cpp | 8 +- base/crash_handler/src/crash_report.cpp | 22 +++++ base/crash_handler/src/symbolizer.cpp | 93 +++++++++++++++++++ .../impl/crash_handler_stage.cpp | 6 +- test/crash/CMakeLists.txt | 9 ++ test/crash/crash_symbolizer_test.cpp | 69 ++++++++++++++ 10 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 base/crash_handler/include/cfcrash/symbolizer.h create mode 100644 base/crash_handler/src/symbolizer.cpp create mode 100644 test/crash/crash_symbolizer_test.cpp diff --git a/base/crash_handler/CMakeLists.txt b/base/crash_handler/CMakeLists.txt index 587c73656..83fa86c40 100644 --- a/base/crash_handler/CMakeLists.txt +++ b/base/crash_handler/CMakeLists.txt @@ -11,6 +11,7 @@ PRIVATE src/crash_handler.cpp src/crash_report.cpp src/crash_signal_handler.cpp + src/symbolizer.cpp ) target_include_directories(cfcrash PUBLIC diff --git a/base/crash_handler/include/cfcrash/crash_handler.h b/base/crash_handler/include/cfcrash/crash_handler.h index c99946a53..1718cb191 100644 --- a/base/crash_handler/include/cfcrash/crash_handler.h +++ b/base/crash_handler/include/cfcrash/crash_handler.h @@ -89,7 +89,8 @@ class CrashHandler { * @ingroup crash */ std::size_t finalizePendingReports(const std::string& logger_path, - std::size_t tail_lines = kDefaultTailLogLines); + std::size_t tail_lines = kDefaultTailLogLines, + const std::string& exe_path = ""); /** * @brief Deletes oldest @c .json reports beyond the retention cap. diff --git a/base/crash_handler/include/cfcrash/crash_report.h b/base/crash_handler/include/cfcrash/crash_report.h index 30dfff79c..ca3b4c371 100644 --- a/base/crash_handler/include/cfcrash/crash_report.h +++ b/base/crash_handler/include/cfcrash/crash_report.h @@ -30,6 +30,26 @@ inline constexpr std::size_t kDefaultTailLogLines{50}; /// @brief Default maximum number of finalized reports kept on disk. inline constexpr std::size_t kDefaultMaxReports{20}; +/** + * @brief One symbol-resolved stack frame. + * + * Produced by the symbolizer (addr2line on Linux) from a bare address: the + * demangled function name, source file, and line. Holds "??"/"0" when the + * address could not be resolved. + * + * @ingroup crash + */ +struct ResolvedFrame { + /// @brief Demangled function name (or "??"). + std::string function; + + /// @brief Source file path (or "??"). + std::string file; + + /// @brief Source line number as a string (or "0"). + std::string line; +}; + /** * @brief A finalized crash report. * @@ -54,9 +74,12 @@ struct CrashReport { /// @brief Human-readable signal name ("SIGSEGV", "SIGABRT", ...). std::string signal_name; - /// @brief Bare stack frame addresses as hex strings; resolved in Phase 2. + /// @brief Bare stack frame addresses as hex strings. std::vector raw_frames; + /// @brief Symbol-resolved frames (function/file/line); empty until finalize. + std::vector resolved_frames; + /// @brief Tail of the logger file captured at finalize time. std::vector last_logs; diff --git a/base/crash_handler/include/cfcrash/symbolizer.h b/base/crash_handler/include/cfcrash/symbolizer.h new file mode 100644 index 000000000..dcf6d683a --- /dev/null +++ b/base/crash_handler/include/cfcrash/symbolizer.h @@ -0,0 +1,61 @@ +/** + * @file symbolizer.h + * @brief Symbol resolution for raw crash stack frames. + * + * Phase 2: converts the bare addresses captured by the signal handler into + * (function, file, line) triples via addr2line on Linux. Non-Linux targets + * get a stub (Windows dbghelp is deferred). resolveFrames() runs in a normal + * context (next boot), never in a signal handler, so it may spawn a subprocess. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#pragma once + +#include "cfcrash/crash_report.h" + +#include +#include +#include + +namespace cf::crash { + +/** + * @brief Resolves bare frame addresses against the executable. + * + * @param[in] raw_frames Hex address strings (e.g. "0x7f..."). + * @param[in] exe_path Path to the executable the addresses belong to. + * @return One ResolvedFrame per input address; empty on non-Linux or when + * exe_path is empty or addr2line is unavailable. + * @throws None + * @note Linux only; spawns addr2line via popen. + * @warning Not async-signal-safe; call from a normal context only. + * @since 0.19.0 + * @ingroup crash + */ +std::vector resolveFrames(const std::vector& raw_frames, + const std::string& exe_path); + +/** + * @brief Parses addr2line output lines into resolved frames. + * + * addr2line prints two lines per address (function, then file:line). This + * helper is split out so it can be unit-tested without spawning the tool. + * + * @param[in] lines Output lines from addr2line. + * @param[in] frame_count Number of addresses that were queried. + * @return Up to frame_count ResolvedFrames; fewer if lines run out. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup crash + */ +std::vector parseAddr2LineOutput(const std::vector& lines, + std::size_t frame_count); + +} // namespace cf::crash diff --git a/base/crash_handler/src/crash_handler.cpp b/base/crash_handler/src/crash_handler.cpp index e9f5d34c3..3c10fcac5 100644 --- a/base/crash_handler/src/crash_handler.cpp +++ b/base/crash_handler/src/crash_handler.cpp @@ -12,6 +12,7 @@ #include "cfcrash/crash_handler.h" #include "cfcrash/crash_report.h" +#include "cfcrash/symbolizer.h" #include #include @@ -129,7 +130,8 @@ void CrashHandler::uninstall() { } std::size_t CrashHandler::finalizePendingReports(const std::string& logger_path, - std::size_t tail_lines) { + std::size_t tail_lines, + const std::string& exe_path) { if (crashes_dir_.empty()) { return 0; } @@ -153,6 +155,10 @@ std::size_t CrashHandler::finalizePendingReports(const std::string& logger_path, // fall back to the caller-supplied path for snapshots without one. const std::string effective_logger = pending_logger.empty() ? logger_path : pending_logger; report.last_logs = tailLines(effective_logger, tail_lines); + // Phase 2: symbolize raw addresses against the running executable. + if (!exe_path.empty()) { + report.resolved_frames = resolveFrames(report.raw_frames, exe_path); + } const auto out_path = fs::path(crashes_dir_) / (stem + ".json"); std::ofstream of(out_path); diff --git a/base/crash_handler/src/crash_report.cpp b/base/crash_handler/src/crash_report.cpp index 04c32c7cf..68be4fa47 100644 --- a/base/crash_handler/src/crash_report.cpp +++ b/base/crash_handler/src/crash_report.cpp @@ -64,6 +64,26 @@ std::string toJsonArray(const std::vector& v) { return out; } +/// @brief Renders resolved frames as a pretty JSON object array (one/line). +std::string toJsonResolvedArray(const std::vector& v) { + if (v.empty()) { + return "[]"; + } + std::string out = "["; + for (std::size_t i = 0; i < v.size(); ++i) { + out += "\n {\"function\": \""; + out += escapeJson(v[i].function); + out += "\", \"file\": \""; + out += escapeJson(v[i].file); + out += "\", \"line\": \""; + out += escapeJson(v[i].line); + out += "\"}"; + out += (i + 1 == v.size()) ? "" : ","; + } + out += "\n ]"; + return out; +} + } // namespace std::string CrashReport::toJson() const { @@ -74,6 +94,8 @@ std::string CrashReport::toJson() const { out += std::format(" \"signal_name\": \"{}\",\n", escapeJson(signal_name)); out += " \"raw_frames\": "; out += toJsonArray(raw_frames); + out += ",\n \"resolved_frames\": "; + out += toJsonResolvedArray(resolved_frames); out += ",\n \"last_logs\": "; out += toJsonArray(last_logs); out += '\n'; diff --git a/base/crash_handler/src/symbolizer.cpp b/base/crash_handler/src/symbolizer.cpp new file mode 100644 index 000000000..4a8f92f44 --- /dev/null +++ b/base/crash_handler/src/symbolizer.cpp @@ -0,0 +1,93 @@ +/** + * @file symbolizer.cpp + * @brief addr2line-based symbol resolution for crash frames. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#include "cfcrash/symbolizer.h" + +#include +#include +#include +#include +#include + +namespace cf::crash { + +std::vector parseAddr2LineOutput(const std::vector& lines, + std::size_t frame_count) { + std::vector out; + out.reserve(frame_count); + for (std::size_t i = 0; i < frame_count; ++i) { + const std::size_t func_idx = i * 2; + const std::size_t file_idx = i * 2 + 1; + if (file_idx >= lines.size()) { + break; // addr2line produced fewer lines than expected + } + ResolvedFrame frame; + frame.function = lines[func_idx]; + const std::string& fl = lines[file_idx]; + // addr2line prints "path/file.cpp:123" (or "??:0"); split on the last ':'. + const auto colon = fl.rfind(':'); + if (colon != std::string::npos) { + frame.file = fl.substr(0, colon); + frame.line = fl.substr(colon + 1); + } else { + frame.file = fl; + } + out.push_back(std::move(frame)); + } + return out; +} + +#ifdef __linux__ +std::vector resolveFrames(const std::vector& raw_frames, + const std::string& exe_path) { + if (raw_frames.empty() || exe_path.empty()) { + return {}; + } + // One addr2line invocation for all addresses; -f prints function then + // file:line per address, -C demangles. exe_path is quoted; paths with + // spaces would still need shell-safe escaping but applicationFilePath() + // does not contain spaces in practice. + std::string cmd = "addr2line -e \""; + cmd += exe_path; + cmd += "\" -f -C"; + for (const auto& addr : raw_frames) { + cmd += ' '; + cmd += addr; + } + cmd += " 2>/dev/null"; + + FILE* pipe = popen(cmd.c_str(), "r"); + if (pipe == nullptr) { + return {}; + } + std::array buf{}; + std::vector lines; + while (std::fgets(buf.data(), static_cast(buf.size()), pipe) != nullptr) { + std::string line(buf.data()); + if (!line.empty() && line.back() == '\n') { + line.pop_back(); + } + lines.push_back(std::move(line)); + } + pclose(pipe); + return parseAddr2LineOutput(lines, raw_frames.size()); +} +#else +std::vector resolveFrames(const std::vector& /*raw_frames*/, + const std::string& /*exe_path*/) { + // Non-Linux (Windows): SetUnhandledExceptionFilter + dbghelp SymFromAddr + // land in a later phase. Returning empty leaves resolved_frames blank, + // which the reporter renders as raw addresses. + return {}; +} +#endif + +} // namespace cf::crash diff --git a/main/early_session/impl/crash_handler_stage.cpp b/main/early_session/impl/crash_handler_stage.cpp index ccb86a84c..fb8bd7643 100644 --- a/main/early_session/impl/crash_handler_stage.cpp +++ b/main/early_session/impl/crash_handler_stage.cpp @@ -39,8 +39,10 @@ CrashHandlerStage::BootResult CrashHandlerStage::run_session() { } // Fold any .pending left by a previous crashed run into a finalized .json. - const auto finalized = - cf::crash::CrashHandler::instance().finalizePendingReports(logger_path.toStdString()); + // Pass the executable path so frames are symbolized via addr2line (Phase 2). + const QString exe_path = QCoreApplication::applicationFilePath(); + const auto finalized = cf::crash::CrashHandler::instance().finalizePendingReports( + logger_path.toStdString(), cf::crash::kDefaultTailLogLines, exe_path.toStdString()); if (finalized > 0) { qInfo("CrashHandlerStage: finalized %lu crash report(s) from previous run", static_cast(finalized)); diff --git a/test/crash/CMakeLists.txt b/test/crash/CMakeLists.txt index d19ad21b0..418c6d47f 100644 --- a/test/crash/CMakeLists.txt +++ b/test/crash/CMakeLists.txt @@ -38,3 +38,12 @@ add_gtest_executable( LOG_MODULE crash_tests INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR} ) + +add_gtest_executable( + TEST_NAME crash_symbolizer_test + SOURCE_FILE crash_symbolizer_test.cpp + LINK_LIBRARIES cfcrash;GTest::gtest;GTest::gtest_main + LABELS "crash;unit;symbolizer" + LOG_MODULE crash_tests + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR} +) diff --git a/test/crash/crash_symbolizer_test.cpp b/test/crash/crash_symbolizer_test.cpp new file mode 100644 index 000000000..0298ff7ac --- /dev/null +++ b/test/crash/crash_symbolizer_test.cpp @@ -0,0 +1,69 @@ +/** + * @file crash_symbolizer_test.cpp + * @brief Unit tests for addr2line output parsing. + * + * Exercises parseAddr2LineOutput() (pure string parse, no subprocess) so the + * symbolizer's line-pairing / file:line splitting is covered without depending + * on addr2line being installed. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash + */ + +#include "cfcrash/symbolizer.h" + +#include +#include + +#include + +TEST(CrashSymbolizer, ParsesFunctionAndFileLine) { + const std::vector lines = { + "cf::crash::CrashHandler::install", + "/home/cf/crash_handler.cpp:120", + "main", + "/home/cf/main.cpp:27", + }; + const auto out = cf::crash::parseAddr2LineOutput(lines, 2); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0].function, "cf::crash::CrashHandler::install"); + EXPECT_EQ(out[0].file, "/home/cf/crash_handler.cpp"); + EXPECT_EQ(out[0].line, "120"); + EXPECT_EQ(out[1].function, "main"); + EXPECT_EQ(out[1].line, "27"); +} + +TEST(CrashSymbolizer, HandlesUnresolvedAddr) { + // addr2line emits "??\n??:0" for an address it cannot resolve. + const std::vector lines = {"??", "??:0"}; + const auto out = cf::crash::parseAddr2LineOutput(lines, 1); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].function, "??"); + EXPECT_EQ(out[0].file, "??"); + EXPECT_EQ(out[0].line, "0"); +} + +TEST(CrashSymbolizer, TruncatesWhenLinesRunOut) { + // One line is not enough for even a single frame (need 2 lines/frame). + const std::vector lines = {"only_one"}; + const auto out = cf::crash::parseAddr2LineOutput(lines, 3); + EXPECT_TRUE(out.empty()); +} + +TEST(CrashSymbolizer, HandlesFewerFramesThanRequested) { + // 2 frames requested, but only one function/file:line pair available. + const std::vector lines = {"foo", "bar.cpp:5"}; + const auto out = cf::crash::parseAddr2LineOutput(lines, 2); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].function, "foo"); + EXPECT_EQ(out[0].line, "5"); +} + +TEST(CrashSymbolizer, EmptyInput) { + const std::vector lines; + const auto out = cf::crash::parseAddr2LineOutput(lines, 0); + EXPECT_TRUE(out.empty()); +} From 925d04651e2aa2b95e6a22fe5c4bc5e0b975eb39 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 13:05:01 +0800 Subject: [PATCH 12/26] feat(crash): CrashReporter dialog + .seen marker Phase 2 step 2: surface a finalized crash report on screen. - CrashReporterDialog: frameless + fade/slide popup (AppLauncher pattern) that reads a crash .json (signal/time, resolved_frames or raw frames, last_logs), with a Copy-to-clipboard and a "Don't show again" action. - seen_marker: writes/reads a sibling .seen file so a report is shown only once per crash. - New cfdesktop_crash_reporter STATIC lib, aggregated into cf_desktop_components. Wired into the boot chain in the next commit. --- ui/components/CMakeLists.txt | 2 + ui/components/crash_reporter/CMakeLists.txt | 20 ++ .../crash_reporter/crash_reporter_dialog.cpp | 241 ++++++++++++++++++ .../crash_reporter/crash_reporter_dialog.h | 176 +++++++++++++ ui/components/crash_reporter/seen_marker.cpp | 39 +++ ui/components/crash_reporter/seen_marker.h | 46 ++++ 6 files changed, 524 insertions(+) create mode 100644 ui/components/crash_reporter/CMakeLists.txt create mode 100644 ui/components/crash_reporter/crash_reporter_dialog.cpp create mode 100644 ui/components/crash_reporter/crash_reporter_dialog.h create mode 100644 ui/components/crash_reporter/seen_marker.cpp create mode 100644 ui/components/crash_reporter/seen_marker.h diff --git a/ui/components/CMakeLists.txt b/ui/components/CMakeLists.txt index 98028b16a..b25694482 100644 --- a/ui/components/CMakeLists.txt +++ b/ui/components/CMakeLists.txt @@ -16,6 +16,7 @@ add_subdirectory(desktop_widget) add_subdirectory(home_page) add_subdirectory(notification) add_subdirectory(control_center) +add_subdirectory(crash_reporter) # Create interface library for convenience add_library(cf_desktop_components STATIC) @@ -51,5 +52,6 @@ PRIVATE cfdesktop_home_page cfdesktop_notification # NotificationService + banner + center cfdesktop_control_center # ControlCenter quick-settings popup + cfdesktop_crash_reporter # CrashReporter dialog + .seen marker Qt6::Core Qt6::Gui Qt6::Widgets ) diff --git a/ui/components/crash_reporter/CMakeLists.txt b/ui/components/crash_reporter/CMakeLists.txt new file mode 100644 index 000000000..ec3778426 --- /dev/null +++ b/ui/components/crash_reporter/CMakeLists.txt @@ -0,0 +1,20 @@ +# CrashReporter dialog + .seen marker (Phase 2 minimal loop). +add_library(cfdesktop_crash_reporter STATIC + crash_reporter_dialog.cpp + seen_marker.cpp +) + +target_include_directories(cfdesktop_crash_reporter PUBLIC + $ + $ + $ +) + +target_link_libraries( + cfdesktop_crash_reporter +PRIVATE + Qt6::Core # QFile, QJsonDocument, QDateTime + Qt6::Widgets # QWidget, QLabel, QTextEdit + QuarkWidgets::quarkwidgets # ThemeManager + MD3 Button + animations + cflogger # Diagnostic logging +) diff --git a/ui/components/crash_reporter/crash_reporter_dialog.cpp b/ui/components/crash_reporter/crash_reporter_dialog.cpp new file mode 100644 index 000000000..fcd1a918a --- /dev/null +++ b/ui/components/crash_reporter/crash_reporter_dialog.cpp @@ -0,0 +1,241 @@ +/** + * @file crash_reporter_dialog.cpp + * @brief Implementation of CrashReporterDialog. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash_reporter + */ + +#include "crash_reporter_dialog.h" + +#include "seen_marker.h" + +#include "components/material/cfmaterial_fade_animation.h" +#include "components/material/cfmaterial_slide_animation.h" +#include "core/theme_manager.h" +#include "core/token/material_scheme/cfmaterial_token_literals.h" +#include "ui/widget/material/widget/button/button.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace cf::desktop::desktop_component { + +using namespace qw::core::token::literals; + +namespace { +constexpr int kDialogWidth = 600; ///< Dialog width (px). +constexpr int kDialogHeight = 440; ///< Dialog height (px). +constexpr qreal kRadius = 16.0; ///< Corner radius (px). +constexpr int kEnterSlidePx = 24; ///< Enter/exit slide distance (px). +} // namespace + +CrashReporterDialog::CrashReporterDialog(const QString& crash_json_path, QWidget* parent) + : QWidget(parent), crash_path_(crash_json_path) { + setWindowFlags(Qt::FramelessWindowHint); + setAttribute(Qt::WA_TranslucentBackground); + setAttribute(Qt::WA_OpaquePaintEvent, false); + setAutoFillBackground(false); + + setupUi(); + loadCrash(); + applyTheme(); + setupAnimations(); + hide(); + + try { + connect(&qw::core::ThemeManager::instance(), &qw::core::ThemeManager::themeChanged, this, + [this](const qw::core::ICFTheme&) { applyTheme(); }); + } catch (...) { + // No theme registered yet; applyTheme() falls back below. + } +} + +CrashReporterDialog::~CrashReporterDialog() = default; + +void CrashReporterDialog::setupUi() { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(20, 20, 20, 20); + root->setSpacing(10); + + summary_label_ = new QLabel(this); + QFont title_font = summary_label_->font(); + title_font.setBold(true); + title_font.setPixelSize(16); + summary_label_->setFont(title_font); + root->addWidget(summary_label_); + + body_edit_ = new QTextEdit(this); + body_edit_->setReadOnly(true); + body_edit_->setAttribute(Qt::WA_TranslucentBackground, true); + root->addWidget(body_edit_, 1); + + auto* btns = new QWidget(this); + auto* bh = new QHBoxLayout(btns); + bh->setContentsMargins(0, 0, 0, 0); + using qw::widget::material::Button; + copy_btn_ = new Button(QStringLiteral("Copy report"), Button::ButtonVariant::Tonal, btns); + dismiss_btn_ = + new Button(QStringLiteral("Don't show again"), Button::ButtonVariant::Filled, btns); + bh->addWidget(copy_btn_); + bh->addStretch(1); + bh->addWidget(dismiss_btn_); + root->addWidget(btns); + + connect(copy_btn_, &QPushButton::clicked, this, + [this]() { QGuiApplication::clipboard()->setText(body_edit_->toPlainText()); }); + connect(dismiss_btn_, &QPushButton::clicked, this, [this]() { + markCrashSeen(crash_path_); + emit dismissed(); + hidePanel(); + }); +} + +void CrashReporterDialog::loadCrash() { + QFile f(crash_path_); + if (!f.open(QIODevice::ReadOnly)) { + summary_label_->setText(QStringLiteral("Crash report (unreadable)")); + body_edit_->setPlainText(QStringLiteral("Could not read ") + crash_path_); + return; + } + const QJsonObject o = QJsonDocument::fromJson(f.readAll()).object(); + const QString sig = o.value("signal_name").toString("UNKNOWN"); + const qint64 ts = o.value("timestamp").toVariant().toLongLong(); + const QString when = QDateTime::fromSecsSinceEpoch(ts).toString("yyyy-MM-dd HH:mm:ss"); + summary_label_->setText(QStringLiteral("Crash: %1 at %2").arg(sig, when)); + + QString body; + body += QStringLiteral("Stack:\n"); + const QJsonArray resolved = o.value("resolved_frames").toArray(); + if (!resolved.isEmpty()) { + for (const auto& v : resolved) { + const QJsonObject rf = v.toObject(); + body += QStringLiteral(" %1 (%2:%3)\n") + .arg(rf.value("function").toString(), rf.value("file").toString(), + rf.value("line").toString()); + } + } else { + const QJsonArray raw = o.value("raw_frames").toArray(); + for (const auto& v : raw) { + body += QStringLiteral(" %1\n").arg(v.toString()); + } + body += QStringLiteral(" (not symbolized)\n"); + } + body += QStringLiteral("\nLast logs:\n"); + const QJsonArray logs = o.value("last_logs").toArray(); + for (const auto& v : logs) { + body += v.toString() + '\n'; + } + body_edit_->setPlainText(body); +} + +void CrashReporterDialog::popup(const QRect& available) { + QRect avail = available; + if (!avail.isValid() || avail.width() <= 0 || avail.height() <= 0) { + if (const auto* screen = QGuiApplication::primaryScreen()) { + avail = screen->availableGeometry(); + } + } + const int w = kDialogWidth; + const int h = kDialogHeight; + setGeometry(avail.center().x() - w / 2, avail.center().y() - h / 2, w, h); + + enter_slide_->start(); + enter_fade_->start(); + show(); + raise(); +} + +void CrashReporterDialog::hidePanel() { + if (!isVisible()) { + hide(); + return; + } + exit_slide_->start(); + exit_fade_->start(); +} + +bool CrashReporterDialog::isShowing() const noexcept { + return isVisible(); +} + +void CrashReporterDialog::paintEvent(QPaintEvent* /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + QPainterPath surface; + surface.addRoundedRect(QRectF(rect()), kRadius, kRadius); + p.fillPath(surface, surface_color_); +} + +void CrashReporterDialog::keyPressEvent(QKeyEvent* event) { + if (event->key() == Qt::Key_Escape) { + hidePanel(); + return; + } + QWidget::keyPressEvent(event); +} + +void CrashReporterDialog::applyTheme() { + try { + auto& tm = qw::core::ThemeManager::instance(); + surface_color_ = tm.theme(tm.currentThemeName()).color_scheme().queryColor(SURFACE); + } catch (...) { + surface_color_ = QColor(0xF7, 0xF5, 0xF3); + } + update(); +} + +void CrashReporterDialog::setupAnimations() { + qw::core::IMotionSpec* spec = nullptr; + try { + auto& tm = qw::core::ThemeManager::instance(); + spec = &tm.theme(tm.currentThemeName()).motion_spec(); + } catch (...) { + // No theme registered yet; animations fall back to default timing. + } + + using qw::components::material::CFMaterialFadeAnimation; + using qw::components::material::CFMaterialSlideAnimation; + using qw::components::material::SlideDirection; + + enter_fade_ = new CFMaterialFadeAnimation(spec, this); + enter_fade_->setRange(0.0f, 1.0f); + enter_fade_->setMotionToken("mediumEnter"); + enter_fade_->setTargetWidget(this); + + enter_slide_ = new CFMaterialSlideAnimation(spec, SlideDirection::Up, this); + enter_slide_->setRange(static_cast(-kEnterSlidePx), 0.0f); + enter_slide_->setMotionToken("mediumEnter"); + enter_slide_->setTargetWidget(this); + + exit_fade_ = new CFMaterialFadeAnimation(spec, this); + exit_fade_->setRange(1.0f, 0.0f); + exit_fade_->setMotionToken("shortExit"); + exit_fade_->setTargetWidget(this); + connect(exit_fade_, &qw::components::ICFAbstractAnimation::finished, this, + [this]() { hide(); }); + + exit_slide_ = new CFMaterialSlideAnimation(spec, SlideDirection::Down, this); + exit_slide_->setRange(0.0f, static_cast(kEnterSlidePx)); + exit_slide_->setMotionToken("shortExit"); + exit_slide_->setTargetWidget(this); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/crash_reporter/crash_reporter_dialog.h b/ui/components/crash_reporter/crash_reporter_dialog.h new file mode 100644 index 000000000..c37d4d082 --- /dev/null +++ b/ui/components/crash_reporter/crash_reporter_dialog.h @@ -0,0 +1,176 @@ +/** + * @file crash_reporter_dialog.h + * @brief Post-crash report dialog shown on the next boot. + * + * CrashReporterDialog reads a finalized crash JSON (written by cfcrash) and + * shows the signal, the symbolized stack (or raw addresses when unresolved), + * and the last log lines, with copy-to-clipboard and dismiss ("don't show + * again" via a .seen marker) actions. Same frameless + fade/slide popup + * pattern as AppLauncher/ControlCenter. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash_reporter + */ + +#pragma once + +#include +#include +#include +#include + +class QKeyEvent; +class QLabel; +class QPaintEvent; +class QTextEdit; + +namespace qw::components::material { +class CFMaterialFadeAnimation; +class CFMaterialSlideAnimation; +} // namespace qw::components::material +namespace qw::widget::material { +class Button; +} // namespace qw::widget::material + +namespace cf::desktop::desktop_component { + +/** + * @brief Popup that surfaces one crash report on the next boot. + * + * @ingroup crash_reporter + */ +class CrashReporterDialog : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the dialog for one crash JSON file. + * + * @param[in] crash_json_path Path to the finalized crash .json. + * @param[in] parent Owning widget (the desktop surface). + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup crash_reporter + */ + explicit CrashReporterDialog(const QString& crash_json_path, QWidget* parent = nullptr); + + /** + * @brief Destructs the dialog. + * + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup crash_reporter + */ + ~CrashReporterDialog() override; + + /** + * @brief Centers and shows the dialog. + * + * @param[in] available The free screen geometry. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup crash_reporter + */ + void popup(const QRect& available); + + /** + * @brief Starts the exit animation; hides when it finishes. + * + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup crash_reporter + */ + void hidePanel(); + + /** + * @brief Reports whether the dialog is currently visible. + * + * @return True when shown. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup crash_reporter + */ + bool isShowing() const noexcept; + + signals: + /** + * @brief Emitted when the user dismisses the report (it was marked seen). + * + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup crash_reporter + */ + void dismissed(); + + protected: + /** + * @brief Paints the rounded Material surface background. + * + * @param[in] event The paint event descriptor. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup crash_reporter + */ + void paintEvent(QPaintEvent* event) override; + + /** + * @brief Hides the dialog on Escape. + * + * @param[in] event The key event descriptor. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup crash_reporter + */ + void keyPressEvent(QKeyEvent* event) override; + + private: + /// @brief Builds the summary / body / button layout. + void setupUi(); + /// @brief Creates the MD3 enter/exit fade + slide animations. + void setupAnimations(); + /// @brief Resolves theme colors, then repaints. + void applyTheme(); + /// @brief Loads the crash JSON into the summary label and body widget. + void loadCrash(); + + /// @brief Path to the crash .json this dialog reports. + QString crash_path_; + + /// @brief One-line summary (signal + time). + QLabel* summary_label_{nullptr}; + /// @brief Scrollable stack + log body (read-only). + QTextEdit* body_edit_{nullptr}; + /// @brief Copy-to-clipboard button. + qw::widget::material::Button* copy_btn_{nullptr}; + /// @brief Dismiss button (marks seen + hides). + qw::widget::material::Button* dismiss_btn_{nullptr}; + + qw::components::material::CFMaterialFadeAnimation* enter_fade_{nullptr}; + qw::components::material::CFMaterialSlideAnimation* enter_slide_{nullptr}; + qw::components::material::CFMaterialFadeAnimation* exit_fade_{nullptr}; + qw::components::material::CFMaterialSlideAnimation* exit_slide_{nullptr}; + + /// @brief Dialog surface fill (surface). + QColor surface_color_; +}; + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/crash_reporter/seen_marker.cpp b/ui/components/crash_reporter/seen_marker.cpp new file mode 100644 index 000000000..feba33e3d --- /dev/null +++ b/ui/components/crash_reporter/seen_marker.cpp @@ -0,0 +1,39 @@ +/** + * @file seen_marker.cpp + * @brief Implementation of the crash ".seen" marker helpers. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash_reporter + */ + +#include "seen_marker.h" + +#include +#include +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// @brief Builds the sibling {@link stem}.seen path for a crash .json. +QString seenPath(const QString& crash_json_path) { + const QFileInfo fi(crash_json_path); + return QDir(fi.absolutePath()).filePath(fi.completeBaseName() + ".seen"); +} +} // namespace + +bool isCrashSeen(const QString& crash_json_path) { + return QFileInfo::exists(seenPath(crash_json_path)); +} + +void markCrashSeen(const QString& crash_json_path) { + QFile f(seenPath(crash_json_path)); + if (f.open(QIODevice::WriteOnly)) { + f.close(); + } +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/crash_reporter/seen_marker.h b/ui/components/crash_reporter/seen_marker.h new file mode 100644 index 000000000..6d9a64bc3 --- /dev/null +++ b/ui/components/crash_reporter/seen_marker.h @@ -0,0 +1,46 @@ +/** + * @file seen_marker.h + * @brief ".seen" marker so a crash report is shown only once. + * + * markCrashSeen() writes an empty {@link stem}.seen file next to a crash + * .json; isCrashSeen() checks for it. The reporter skips seen reports. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup crash_reporter + */ + +#pragma once + +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief Reports whether a crash .json has been marked seen. + * + * @param[in] crash_json_path Path to the crash .json. + * @return True when a sibling {@link stem}.seen marker exists. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup crash_reporter + */ +bool isCrashSeen(const QString& crash_json_path); + +/** + * @brief Marks a crash .json as seen (writes {@link stem}.seen). + * + * @param[in] crash_json_path Path to the crash .json. + * @throws None + * @note Best-effort; a failed write leaves the report re-showable. + * @warning None + * @since 0.19.0 + * @ingroup crash_reporter + */ +void markCrashSeen(const QString& crash_json_path); + +} // namespace cf::desktop::desktop_component From 970ed684a8ecf26758d8b02003f01ab5d1396543 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 13:07:26 +0800 Subject: [PATCH 13/26] feat(shell): show crash reporter on boot for unseen crashes After the desktop is shown, scan /crashes for the newest .json without a sibling .seen marker and pop a CrashReporterDialog (centered) showing the symbolized stack. One report per boot; "Don't show again" writes the .seen so it is not shown again. Deferred 1.5s so the desktop settles first. --- ui/CFDesktopEntity.cpp | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/ui/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index f4caf1a8d..b1303f52a 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -14,6 +14,8 @@ #include "components/builtin_apps/about_panel.h" #include "components/builtin_apps/builtin_panel_registry.h" #include "components/control_center/control_center.h" +#include "components/crash_reporter/crash_reporter_dialog.h" +#include "components/crash_reporter/seen_marker.h" #include "components/desktop_icon_layer/desktop_icon_layer.h" #include "components/desktop_icon_layer/desktop_shortcut_store.h" #include "components/home_page/home_page.h" @@ -35,13 +37,16 @@ #include "qt_format.h" #include "system/hardware_tier/hardware_tier.h" #include +#include #include +#include #include #include #include #include #include #include +#include #include #include @@ -614,6 +619,24 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { // Show the desktop full-screen desktop_entity_->showFullScreen(); + // Phase 2: surface the most recent unseen crash report (from a previous + // crashed run) shortly after boot, so the user sees the symbolized stack. + const QString crashes_dir = QCoreApplication::applicationDirPath() + "/crashes"; + QTimer::singleShot(1500, this, [this, crashes_dir]() { + const QDir dir(crashes_dir); + const auto jsons = dir.entryInfoList(QStringList() << "*.json", QDir::Files, QDir::Time); + for (const QFileInfo& fi : jsons) { + const QString path = fi.absoluteFilePath(); + if (cf::desktop::desktop_component::isCrashSeen(path)) { + continue; + } + auto* reporter = + new cf::desktop::desktop_component::CrashReporterDialog(path, desktop_entity_); + reporter->popup(desktop_entity_->rect()); + break; // one report per boot; the next unseen one shows next time + } + }); + log::trace("Entity Init"); return RunsSetupResult::OK; } From ee9be8ada90221e05addb9a5fe7e551205743e4b Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 13:10:42 +0800 Subject: [PATCH 14/26] feat(settings): SettingsWindow with wallpaper/theme/about tabs Phase 12 minimal version: a frameless + fade/slide popup (AppLauncher pattern) holding a TabView with three tabs. - Wallpaper: animation-enabled switch + switch-interval/transition-duration sliders, backed by ConfigStore domain("wallpaper") (disable_animation, switch_interval_ms, animation_duration_ms). Values persist; the live wallpaper engine picks them up on its next start. - Theme: Light/Dark buttons via ThemeManager::setThemeTo (live; every panel re-themes via themeChanged). - About: version + stack + repo link. The remaining Phase 12 tabs (display/sound/network/bluetooth/input/apps/ accessibility) and a font/icon subsystem are deferred until their backends exist. Wired into the control center in the next commit. --- ui/components/CMakeLists.txt | 2 + ui/components/settings/CMakeLists.txt | 20 ++ ui/components/settings/settings_window.cpp | 280 +++++++++++++++++++++ ui/components/settings/settings_window.h | 149 +++++++++++ 4 files changed, 451 insertions(+) create mode 100644 ui/components/settings/CMakeLists.txt create mode 100644 ui/components/settings/settings_window.cpp create mode 100644 ui/components/settings/settings_window.h diff --git a/ui/components/CMakeLists.txt b/ui/components/CMakeLists.txt index b25694482..976b16f90 100644 --- a/ui/components/CMakeLists.txt +++ b/ui/components/CMakeLists.txt @@ -17,6 +17,7 @@ add_subdirectory(home_page) add_subdirectory(notification) add_subdirectory(control_center) add_subdirectory(crash_reporter) +add_subdirectory(settings) # Create interface library for convenience add_library(cf_desktop_components STATIC) @@ -53,5 +54,6 @@ PRIVATE cfdesktop_notification # NotificationService + banner + center cfdesktop_control_center # ControlCenter quick-settings popup cfdesktop_crash_reporter # CrashReporter dialog + .seen marker + cfdesktop_settings # SettingsWindow (wallpaper/theme/about) Qt6::Core Qt6::Gui Qt6::Widgets ) diff --git a/ui/components/settings/CMakeLists.txt b/ui/components/settings/CMakeLists.txt new file mode 100644 index 000000000..ac8d7dca4 --- /dev/null +++ b/ui/components/settings/CMakeLists.txt @@ -0,0 +1,20 @@ +# Settings window (Phase 12 minimal version: wallpaper/theme/about tabs). +add_library(cfdesktop_settings STATIC + settings_window.cpp +) + +target_include_directories(cfdesktop_settings PUBLIC + $ + $ + $ +) + +target_link_libraries( + cfdesktop_settings +PRIVATE + Qt6::Core + Qt6::Widgets + QuarkWidgets::quarkwidgets # ThemeManager + TabView + Slider/Switch/Button + animations + cfconfig # ConfigStore (wallpaper keys) + cflogger # Diagnostic logging +) diff --git a/ui/components/settings/settings_window.cpp b/ui/components/settings/settings_window.cpp new file mode 100644 index 000000000..6bce17dea --- /dev/null +++ b/ui/components/settings/settings_window.cpp @@ -0,0 +1,280 @@ +/** + * @file settings_window.cpp + * @brief Implementation of SettingsWindow. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup settings + */ + +#include "settings_window.h" + +#include "cfconfig.hpp" +#include "cflog.h" + +#include "components/material/cfmaterial_fade_animation.h" +#include "components/material/cfmaterial_slide_animation.h" +#include "core/theme_manager.h" +#include "core/token/material_scheme/cfmaterial_token_literals.h" +#include "ui/widget/material/widget/button/button.h" +#include "ui/widget/material/widget/slider/slider.h" +#include "ui/widget/material/widget/switch/switch.h" +#include "ui/widget/material/widget/tabview/tabview.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace cf::desktop::desktop_component { + +using namespace qw::core::token::literals; + +namespace { +constexpr int kWindowWidth = 720; ///< Window width (px). +constexpr int kWindowHeight = 520; ///< Window height (px). +constexpr qreal kRadius = 16.0; ///< Corner radius (px). +constexpr int kEnterSlidePx = 24; ///< Enter/exit slide distance (px). +constexpr const char* kLogTag = "SettingsWindow"; + +/// @brief Writes one wallpaper config key (User layer, immediate notify). +void setWallpaperKey(const char* key, auto&& value) { + namespace cfg = cf::config; + cf::config::ConfigStore::instance() + .domain("wallpaper") + .set(cfg::KeyView{.group = "wallpaper", .key = key}, value, cfg::Layer::User, + cfg::NotifyPolicy::Immediate); + cf::config::ConfigStore::instance().sync(); +} +} // namespace + +SettingsWindow::SettingsWindow(QWidget* parent) : QWidget(parent) { + setWindowFlags(Qt::FramelessWindowHint); + setAttribute(Qt::WA_TranslucentBackground); + setAttribute(Qt::WA_OpaquePaintEvent, false); + setAutoFillBackground(false); + + setupUi(); + applyTheme(); + setupAnimations(); + hide(); + + try { + connect(&qw::core::ThemeManager::instance(), &qw::core::ThemeManager::themeChanged, this, + [this](const qw::core::ICFTheme&) { applyTheme(); }); + } catch (...) { + // No theme registered yet; applyTheme() falls back below. + } +} + +SettingsWindow::~SettingsWindow() = default; + +void SettingsWindow::setupUi() { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + auto* tabs = new qw::widget::material::TabView(this); + tabs->addTab(buildWallpaperTab(), QStringLiteral("Wallpaper")); + tabs->addTab(buildThemeTab(), QStringLiteral("Theme")); + tabs->addTab(buildAboutTab(), QStringLiteral("About")); + root->addWidget(tabs); +} + +QWidget* SettingsWindow::buildWallpaperTab() { + auto* tab = new QWidget(this); + auto* layout = new QVBoxLayout(tab); + layout->setContentsMargins(16, 16, 16, 16); + layout->setSpacing(12); + + namespace cfg = cf::config; + auto domain = cf::config::ConfigStore::instance().domain("wallpaper"); + + // Animation enabled = NOT disable_animation. Live wallpaper reload is the + // engine's job; settings only persist. Values apply on next engine start. + auto* anim = new qw::widget::material::Switch(QStringLiteral("Animation enabled"), tab); + anim->setChecked( + !domain.query(cfg::KeyView{.group = "wallpaper", .key = "disable_animation"}, false)); + layout->addWidget(anim); + connect(anim, &QCheckBox::toggled, tab, [](bool on) { + setWallpaperKey("disable_animation", !on); + cf::log::infoftag(kLogTag, "wallpaper disable_animation -> {} (applies next start)", !on); + }); + + // Switch interval slider (2-60 s). + auto* irow = new QWidget(tab); + auto* ih = new QHBoxLayout(irow); + ih->setContentsMargins(0, 0, 0, 0); + auto* ilab = new QLabel(QStringLiteral("Switch interval"), irow); + ilab->setMinimumWidth(130); + auto* isld = new qw::widget::material::Slider(Qt::Horizontal, irow); + isld->setRange(2000, 60000); + isld->setSingleStep(1000); + isld->setValue( + domain.query(cfg::KeyView{.group = "wallpaper", .key = "switch_interval_ms"}, 20000)); + ih->addWidget(ilab); + ih->addWidget(isld, 1); + layout->addWidget(irow); + connect(isld, &QSlider::valueChanged, tab, + [](int v) { setWallpaperKey("switch_interval_ms", v); }); + + // Transition duration slider (200-5000 ms). + auto* drow = new QWidget(tab); + auto* dh = new QHBoxLayout(drow); + dh->setContentsMargins(0, 0, 0, 0); + auto* dlab = new QLabel(QStringLiteral("Transition duration"), drow); + dlab->setMinimumWidth(130); + auto* dsld = new qw::widget::material::Slider(Qt::Horizontal, drow); + dsld->setRange(200, 5000); + dsld->setSingleStep(100); + dsld->setValue(domain.query( + cfg::KeyView{.group = "wallpaper", .key = "animation_duration_ms"}, 2000)); + dh->addWidget(dlab); + dh->addWidget(dsld, 1); + layout->addWidget(drow); + connect(dsld, &QSlider::valueChanged, tab, + [](int v) { setWallpaperKey("animation_duration_ms", v); }); + + layout->addStretch(1); + return tab; +} + +QWidget* SettingsWindow::buildThemeTab() { + auto* tab = new QWidget(this); + auto* layout = new QVBoxLayout(tab); + layout->setContentsMargins(16, 16, 16, 16); + layout->setSpacing(12); + + using qw::widget::material::Button; + auto* light_btn = new Button(QStringLiteral("Light"), Button::ButtonVariant::Tonal, tab); + auto* dark_btn = new Button(QStringLiteral("Dark"), Button::ButtonVariant::Tonal, tab); + layout->addWidget(light_btn); + layout->addWidget(dark_btn); + + // Live: ThemeManager broadcasts themeChanged, so every panel re-themes. + connect(light_btn, &QPushButton::clicked, tab, + []() { qw::core::ThemeManager::instance().setThemeTo("light"); }); + connect(dark_btn, &QPushButton::clicked, tab, + []() { qw::core::ThemeManager::instance().setThemeTo("dark"); }); + + layout->addStretch(1); + return tab; +} + +QWidget* SettingsWindow::buildAboutTab() { + auto* tab = new QWidget(this); + auto* layout = new QVBoxLayout(tab); + layout->setContentsMargins(16, 16, 16, 16); + auto* label = new QLabel(QStringLiteral("CFDesktop v0.19.0\n\n" + "Cross-platform Desktop Environment Framework\n" + "C++23 / Qt 6.8 / Material Design 3\n\n" + "github.com/Charliechen114514/CFDesktop"), + tab); + label->setWordWrap(true); + layout->addWidget(label); + layout->addStretch(1); + return tab; +} + +void SettingsWindow::popup(const QRect& available) { + QRect avail = available; + if (!avail.isValid() || avail.width() <= 0 || avail.height() <= 0) { + if (const auto* screen = QGuiApplication::primaryScreen()) { + avail = screen->availableGeometry(); + } + } + const int w = kWindowWidth; + const int h = kWindowHeight; + setGeometry(avail.center().x() - w / 2, avail.center().y() - h / 2, w, h); + + enter_slide_->start(); + enter_fade_->start(); + show(); + raise(); +} + +void SettingsWindow::hidePanel() { + if (!isVisible()) { + hide(); + return; + } + exit_slide_->start(); + exit_fade_->start(); +} + +bool SettingsWindow::isShowing() const noexcept { + return isVisible(); +} + +void SettingsWindow::paintEvent(QPaintEvent* /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + QPainterPath surface; + surface.addRoundedRect(QRectF(rect()), kRadius, kRadius); + p.fillPath(surface, surface_color_); +} + +void SettingsWindow::keyPressEvent(QKeyEvent* event) { + if (event->key() == Qt::Key_Escape) { + hidePanel(); + return; + } + QWidget::keyPressEvent(event); +} + +void SettingsWindow::applyTheme() { + try { + auto& tm = qw::core::ThemeManager::instance(); + surface_color_ = tm.theme(tm.currentThemeName()).color_scheme().queryColor(SURFACE); + } catch (...) { + surface_color_ = QColor(0xF7, 0xF5, 0xF3); + } + update(); +} + +void SettingsWindow::setupAnimations() { + qw::core::IMotionSpec* spec = nullptr; + try { + auto& tm = qw::core::ThemeManager::instance(); + spec = &tm.theme(tm.currentThemeName()).motion_spec(); + } catch (...) { + // No theme registered yet; animations fall back to default timing. + } + + using qw::components::material::CFMaterialFadeAnimation; + using qw::components::material::CFMaterialSlideAnimation; + using qw::components::material::SlideDirection; + + enter_fade_ = new CFMaterialFadeAnimation(spec, this); + enter_fade_->setRange(0.0f, 1.0f); + enter_fade_->setMotionToken("mediumEnter"); + enter_fade_->setTargetWidget(this); + + enter_slide_ = new CFMaterialSlideAnimation(spec, SlideDirection::Up, this); + enter_slide_->setRange(static_cast(-kEnterSlidePx), 0.0f); + enter_slide_->setMotionToken("mediumEnter"); + enter_slide_->setTargetWidget(this); + + exit_fade_ = new CFMaterialFadeAnimation(spec, this); + exit_fade_->setRange(1.0f, 0.0f); + exit_fade_->setMotionToken("shortExit"); + exit_fade_->setTargetWidget(this); + connect(exit_fade_, &qw::components::ICFAbstractAnimation::finished, this, + [this]() { hide(); }); + + exit_slide_ = new CFMaterialSlideAnimation(spec, SlideDirection::Down, this); + exit_slide_->setRange(0.0f, static_cast(kEnterSlidePx)); + exit_slide_->setMotionToken("shortExit"); + exit_slide_->setTargetWidget(this); +} + +} // namespace cf::desktop::desktop_component diff --git a/ui/components/settings/settings_window.h b/ui/components/settings/settings_window.h new file mode 100644 index 000000000..370f0461a --- /dev/null +++ b/ui/components/settings/settings_window.h @@ -0,0 +1,149 @@ +/** + * @file settings_window.h + * @brief Quick-settings window (Phase 12 minimal version). + * + * SettingsWindow is a frameless popup (AppLauncher pattern) holding a TabView + * with three tabs: Wallpaper (switch/interval/duration backed by ConfigStore), + * Theme (light/dark via ThemeManager), and About (version). The remaining + * Phase 12 tabs (display/sound/network/bluetooth/input/apps/accessibility) and + * a font/icon subsystem are deferred until their backends exist. + * + * @author CFDesktop Team + * @date 2026-07-10 + * @version 0.19.0 + * @since 0.19.0 + * @ingroup settings + */ + +#pragma once + +#include +#include +#include + +class QKeyEvent; +class QPaintEvent; + +namespace qw::components::material { +class CFMaterialFadeAnimation; +class CFMaterialSlideAnimation; +} // namespace qw::components::material + +namespace cf::desktop::desktop_component { + +/** + * @brief Tabbed quick-settings popup. + * + * @ingroup settings + */ +class SettingsWindow : public QWidget { + Q_OBJECT + + public: + /** + * @brief Constructs the window (hidden until popup()). + * + * @param[in] parent Owning widget (the desktop surface). + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup settings + */ + explicit SettingsWindow(QWidget* parent = nullptr); + + /** + * @brief Destructs the window. + * + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup settings + */ + ~SettingsWindow() override; + + /** + * @brief Centers and shows the window. + * + * @param[in] available The free screen geometry. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup settings + */ + void popup(const QRect& available); + + /** + * @brief Starts the exit animation; hides when it finishes. + * + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup settings + */ + void hidePanel(); + + /** + * @brief Reports whether the window is currently visible. + * + * @return True when shown. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup settings + */ + bool isShowing() const noexcept; + + protected: + /** + * @brief Paints the rounded Material surface background. + * + * @param[in] event The paint event descriptor. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup settings + */ + void paintEvent(QPaintEvent* event) override; + + /** + * @brief Hides the window on Escape. + * + * @param[in] event The key event descriptor. + * @throws None + * @note None + * @warning None + * @since 0.19.0 + * @ingroup settings + */ + void keyPressEvent(QKeyEvent* event) override; + + private: + /// @brief Builds the tab view with wallpaper/theme/about tabs. + void setupUi(); + /// @brief Creates the MD3 enter/exit fade + slide animations. + void setupAnimations(); + /// @brief Resolves theme colors, then repaints. + void applyTheme(); + /// @brief Builds the wallpaper tab (switch/interval/duration -> ConfigStore). + QWidget* buildWallpaperTab(); + /// @brief Builds the theme tab (light/dark -> ThemeManager). + QWidget* buildThemeTab(); + /// @brief Builds the about tab (version + repo link). + QWidget* buildAboutTab(); + + qw::components::material::CFMaterialFadeAnimation* enter_fade_{nullptr}; + qw::components::material::CFMaterialSlideAnimation* enter_slide_{nullptr}; + qw::components::material::CFMaterialFadeAnimation* exit_fade_{nullptr}; + qw::components::material::CFMaterialSlideAnimation* exit_slide_{nullptr}; + + /// @brief Window surface fill (surface). + QColor surface_color_; +}; + +} // namespace cf::desktop::desktop_component From 57a3e824b8acc3eb52cefd4d491ef12bde489995 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 13:13:26 +0800 Subject: [PATCH 15/26] feat(shell): wire Settings button in control center -> settings window Add a "Settings" button to the control center (emits settingsRequested) and wire it in CFDesktopEntity to toggle the SettingsWindow popup, mirroring the control-center / notification-center toggle pattern. --- ui/CFDesktopEntity.cpp | 13 +++++++++++++ ui/components/control_center/control_center.cpp | 11 +++++++++++ ui/components/control_center/control_center.h | 14 ++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/ui/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index b1303f52a..781c0094e 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -27,6 +27,7 @@ #include "components/notification/notification_banner.h" #include "components/notification/notification_center_panel.h" #include "components/notification/notification_service.h" +#include "components/settings/settings_window.h" #include "components/statusbar/status_bar.h" #include "components/taskbar/centered_taskbar.h" #include "components/window_placement/floating_policy.h" @@ -565,6 +566,18 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { } }); + // Settings window, toggled from the control center's Settings button. + auto* settings_window = new cf::desktop::desktop_component::SettingsWindow(desktop_entity_); + QObject::connect(control_center, + &cf::desktop::desktop_component::ControlCenter::settingsRequested, this, + [settings_window, panel_mgr]() { + if (settings_window->isShowing()) { + settings_window->hidePanel(); + } else { + settings_window->popup(panel_mgr->availableGeometry()); + } + }); + // Banner shows for every non-suppressed post (DND off). QObject::connect(¬ification_svc, &cf::desktop::desktop_component::NotificationService::notificationPosted, this, diff --git a/ui/components/control_center/control_center.cpp b/ui/components/control_center/control_center.cpp index ff99654a5..514e438c5 100644 --- a/ui/components/control_center/control_center.cpp +++ b/ui/components/control_center/control_center.cpp @@ -164,6 +164,17 @@ void ControlCenter::setupUi() { n.app_id = "org.cf.control_center"; NotificationService::instance().post(n); }); + + // Settings entry: the entity owns the SettingsWindow and toggles it. + auto* settings_row = new QWidget(this); + auto* sh = new QHBoxLayout(settings_row); + sh->setContentsMargins(0, 0, 0, 0); + settings_btn_ = + new Button(QStringLiteral("Settings"), Button::ButtonVariant::Outlined, settings_row); + sh->addWidget(settings_btn_); + sh->addStretch(1); + root->addWidget(settings_row); + connect(settings_btn_, &QPushButton::clicked, this, [this]() { emit settingsRequested(); }); } void ControlCenter::popup(const QRect& available) { diff --git a/ui/components/control_center/control_center.h b/ui/components/control_center/control_center.h index 2443b2043..69d96e1b0 100644 --- a/ui/components/control_center/control_center.h +++ b/ui/components/control_center/control_center.h @@ -110,6 +110,18 @@ class ControlCenter : public QWidget { */ bool isShowing() const noexcept; + signals: + /** + * @brief Emitted when the Settings button is clicked. + * + * @throws None + * @note Wired to the SettingsWindow popup in CFDesktopEntity. + * @warning None + * @since 0.19.0 + * @ingroup control_center + */ + void settingsRequested(); + protected: /** * @brief Paints the rounded Material surface background. @@ -159,6 +171,8 @@ class ControlCenter : public QWidget { qw::widget::material::Button* screenshot_btn_{nullptr}; /// @brief Test stub: posts a "Hello World" notification (end-to-end check). qw::widget::material::Button* test_notif_btn_{nullptr}; + /// @brief Settings button (opens SettingsWindow; handled by the entity). + qw::widget::material::Button* settings_btn_{nullptr}; qw::components::material::CFMaterialFadeAnimation* enter_fade_{nullptr}; qw::components::material::CFMaterialSlideAnimation* enter_slide_{nullptr}; From d05c81a5d0d4012c6d13e1c5980a2305a5f31e36 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 13:14:34 +0800 Subject: [PATCH 16/26] docs(status): record settings window + crash phase2 minimal loop Mark the three follow-ups landed in current.md, 12_theme_settings_lockscreen (settings minimal loop; 6 tabs + theme pack + lockscreen deferred), and CRASH_HANDLE_STEP (Phase 2 addr2line + reporter; watchdog/Windows deferred). --- document/status/current.md | 1 + document/todo/desktop/12_theme_settings_lockscreen.md | 2 +- document/todo/desktop/CRASH_HANDLE_STEP.md | 5 +++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/document/status/current.md b/document/status/current.md index b94755482..7425744af 100644 --- a/document/status/current.md +++ b/document/status/current.md @@ -71,6 +71,7 @@ description: CFDesktop 项目进度的唯一事实来源与全局导航。 - **2026-07(MS6 Step A 桌面时钟小组件)**:新增桌面 widget 框架——`WidgetBase`(可拖拽基类,press-drag-move + 阈值)+ `WidgetContainer`(QObject 注册表)+ `ClockWidget`(Material 圆角卡片 HH:mm + 日期,1s 刷新,`ThemeManager::themeChanged` 跟随),落 `ui/components/desktop_widget/`(target `cfdesktop_desktop_widget`,link QuarkWidgets),挂 `CFDesktopEntity` 桌面(icon_layer 之上)。范式复用:主题色/字体照 statusbar(`queryColor(SURFACE/ON_SURFACE/ON_SURFACE_VARIANT)` + `queryTargetFont(TYPOGRAPHY_DISPLAY_LARGE/BODY_MEDIUM)`),圆角卡片照 AppLauncher(`QPainterPath addRoundedRect + fillPath`,无 elevation 阴影)。`ControlCenter` 下拉面板属 Step B。详见 [milestone_06](../todo/desktop/milestone_06_widget_control_center.md)。(⚠️ 后续:`ClockWidget`/`WidgetContainer` 随 HomePage 主屏迁移取代删除,见下条;`WidgetBase` 保留。) - **2026-07(MS6 重定向:迁移 CCIMX HomePage 主屏 + 多页左右滑桌面)**:Step A 自由时钟 widget 范式与主屏冲突,改迁移 CCIMXDesktop `HomePage` 作默认桌面。落 `ui/components/home_page/`(target `cfdesktop_home_page`):`AnalogClockWidget`(QPainter 模拟表盘)+ `DigitalTimeWidget`(hh:mm:ss+日期)+ `CardStackWidget`(垂直滑动卡片堆,1/4 高阈值 + QPropertyAnimation slide/fade,搬自 CCIMX)+ `HomeCardManager` + `GlobalClockSources`(1s 时钟源)+ `DateCard`(占位)+ `HomePage`(左右布局)+ `PageStackWidget`(水平切页)。颜色/字体 MD3 化(`queryColor`/`queryTargetFont` token)。**桌面改多页**:`PageStackWidget`(QStackedWidget)装 HomePage(页0 信息流主屏)↔ icon_layer(页1 Win11 图标桌面),水平拖拽 1/4 宽切页 + pos 插值动画。**修叠加 bug**:HomePage 透明透显 icon_layer → 多页独立显示。**手势分区靠 Qt 冒泡**:HomePage/时钟不 accept press → 冒泡 PageStack(水平);`CardStackWidget` accept → 垂直卡片。删 Step A 的 `ClockWidget`/`WidgetContainer`(HomePage 取代),`WidgetBase` 保留;`icon_layer` 作页1(全 connect 保留,几何改本地坐标)。WSL boot 验证通过;视觉/滑动待用户确认。Step B:复杂卡片(UserInfo/Net/Weather 依赖 CCIMX 基建)+ 控制中心 + 应用页。**Step B1 已落地(2026-07-08)**:`CardStackWidget` 加 Memory/CPU/Disk 实时数据卡(进度条 primary/surfaceVariant + cfbase `getSystemMemoryInfo`/`getCPUProfileInfo`/`std::filesystem::space`,2/5/10s 刷新),`home_page` link cfbase。**CPU probe 移出 UI 线程(2026-07-08,commit 8af7650)**:CPU 使用率测量固有需 100ms 采样窗口(`getCpuUsage` 两次读 `/proc/stat` 算 delta),原在 UI 线程每 tick 卡 ~100ms;改 `QtConcurrent::run` 跑在线程池 + `QFutureWatcher::finished` 回 UI 线程更新;`in_flight_` 防 probe 堆积、worker 仅值捕获 probe 不捕获 `this`、watcher 作 card child 随销毁(在飞 future 被 detach 不 UAF)。memory/disk probe 一并 async(统一范式)。**Step B2 视觉照搬 CCIMX(2026-07-08)**:重做视觉——表盘白径向渐变+8px 黑厚边+黑针+**红秒针**+12/3/6/9 数字(draw-by-draw 照搬)、数字时间白字 40/15(Linux/WSL 无 Helvetica Neue,改用 `painter.font()` 系统默认字体)、卡片从自绘 paintEvent 模板改为 **QSS 渐变**(`DateCard` 蓝渐变 `#4A90E2→#1E3C72`;`SystemUsageCard` 深灰渐变 `#5D6D7E→#2C3E50`+QProgressBar chunk `#1abc9c`+`QGraphicsDropShadowEffect` 阴影)、布局全 0 margin+精确 stretch(1,1/5,2/3,1)+右下渐变面板;**去 MD3 主题化**(硬编码 QSS,Phase G 再接)。`SystemUsageCard` 配置式(title+probe+interval)统一 Memory/CPU/Disk 三卡(替独立卡),数据源仍 cfbase。**Step C 全量迁移(2026-07-08,commit 53a5620+d15f8d)**:卡栈补齐至 CCIMX 对等——`UserInfoCard`(绿渐变,POSIX `getpwuid`/`gethostname`/`uname` 填真实桌面数据,头像画首字母圆;CFDesktop 无 DesktopUserInfo 子系统,不假装有 phone/email)、`ModernCalendarWidget`(QCalendarWidget 子类,自绘 cell+日期标记,照搬 paintCell;去 CCIMX 导航箭头 PNG 用 Qt 默认)、`GaugeWidget`(自绘半圆仪表盘,逐行照搬 CCIMX;顺手修 min/max 初始化笔误 + drawNeedle 除零)+ `DiskGaugeCard`(替 Disk 的 QProgressBar 为 gauge,沿用 async probe)——卡栈顺序对齐 CCIMX(UserInfo/Calendar/Date/Disk/Memory)+ CPU 作附加卡。右下渐变占位换 2 列 gadget 网格:`NetStatusCard`(cfbase `getNetworkInfo` → reachability/transport/IPv4,青绿渐变,替 CCIMX NetAbilityScanner)、`LocalTempCard`(读 `/sys/class/thermal/thermal_zone*` 真实温度,WSL2 无 zone 则诚实 N/A;替 CCIMX 的 ICM20608 传感器——桌面无此硬件,CCIMX 在 x86 亦用随机数假数据)。两 gadget 独立(不继承 AppCardWidget/DesktopToast,该框架 CFDesktop 无)。MS6 HomePage 主屏迁移至此全部完成;Step B2 控制中心 + 通知系统最小闭环见下条;仅余应用页(PageStack 第 3 页,MS4 launcher 已覆盖,非必需)。 - **2026-07(Phase F 最小闭环:控制中心 + 通知系统,feat/phase-f-control-center-notifications)**:`ControlCenter`(MS6 版,`ui/components/control_center/`,target `cfdesktop_control_center`)——亮度/音量 `Slider` + WiFi/蓝牙/DND `Switch` + 主题切换 `Button`(`setThemeTo` 真实生效)+ 圆角卡片 + MD3 fade/slide 动画;DND Switch 接 ConfigStore `notification.dnd.enabled`。`NotificationService`(进程内单例,内存模型,`ui/components/notification/`)+ `Notification`(id/title/message/app_id/timestamp)+ `NotificationBanner`(横幅,5s 自动消失)+ `NotificationCenterPanel`(通知中心)+ `StatusBar::timeClicked/notifyIconClicked` 信号 + `mousePressEvent` 命中 + 矢量通知图标(铃铛 + 未读红点)+ IPC `notify` 消息(外部 app 发通知,cfipc,向后兼容 `raise`)。9 例单测全过(notification_service_test 6 + ipc_notify_test 3)。**defer**:硬件后端真实接入(亮度/音量/WiFi/蓝牙,待 aels-power/network 建仓)、通知持久化(重启清空)、分组/优先级/操作按钮、独立 NotificationService 进程、outside-click 关闭(当前 ESC + 状态栏 toggle)。详见 [milestone_06](../todo/desktop/milestone_06_widget_control_center.md) + [11_notification_control](../todo/desktop/11_notification_control.md)。 +- **2026-07(Phase 12 设置窗口 + CrashHandler Phase2 + home_page 注释,feat/phase-f-control-center-notifications 叠加)**:`SettingsWindow`(`ui/components/settings/`,target `cfdesktop_settings`)—— TabView 3 tab(壁纸:animation 开关 + interval/duration slider 接 ConfigStore `wallpaper.*`;主题:light/dark 即时切换 `setThemeTo`;关于),AppLauncher 范式弹出,控制中心「Settings」按钮触发。CrashHandler Phase2(addr2line 符号化 `raw_frames`→`resolved_frames`,Linux popen;`CrashReporterDialog` 启动扫 `crashes/*.json` 未 seen 的弹窗,显示符号化栈 + 复制 + 「不再显示」写 `.seen`)+ `crash_symbolizer_test`。`home_page.h:8` 过时注释修正。**defer**:Watchdog 进程、Windows dbghelp、设置 6 tab(显示/声音/网络/蓝牙/输入/辅助)、壁纸即时 reload。详见 [12_theme_settings_lockscreen](../todo/desktop/12_theme_settings_lockscreen.md) + [CRASH_HANDLE_STEP](../todo/desktop/CRASH_HANDLE_STEP.md)。 - **已达成**:Milestone 1「桌面骨架可见」;Phase 0 / 1 / 2 / A(CI) / 6 / G(Widget) / H(显示后端)(详见 [SUMMARY.md](../todo/done/SUMMARY.md)) ## 新人入门 diff --git a/document/todo/desktop/12_theme_settings_lockscreen.md b/document/todo/desktop/12_theme_settings_lockscreen.md index 037b5d438..a9e79c6ee 100644 --- a/document/todo/desktop/12_theme_settings_lockscreen.md +++ b/document/todo/desktop/12_theme_settings_lockscreen.md @@ -5,7 +5,7 @@ description: "预计周期: 4~5 周,依赖阶段: Phase 6, Phase 10, Phase 11" # Phase 12: 主题、设置与锁屏 TODO -> **状态**: ⬜ 待开始 +> **状态**: 🚧 设置窗口最小闭环已落地(2026-07-10);主题包/锁屏/其余 tab 待做 > **预计周期**: 4~5 周 > **依赖阶段**: Phase 6, Phase 10, Phase 11 > **目标交付物**: ThemeStyleManager、iOS/Windows 主题包、系统设置 App、锁屏模块 diff --git a/document/todo/desktop/CRASH_HANDLE_STEP.md b/document/todo/desktop/CRASH_HANDLE_STEP.md index 132b5a6f7..38e594d5e 100644 --- a/document/todo/desktop/CRASH_HANDLE_STEP.md +++ b/document/todo/desktop/CRASH_HANDLE_STEP.md @@ -12,6 +12,11 @@ description: "评估日期:2026-03-30,整体基建就绪度约 60%,核心 > 2. **落地路径不是 `desktop/base/infrastructure/`**(重组前结构),而是 `base/crash_handler/`(07 月 base 平铺重组后)。 > 3. **报告路径不是 `~/.cache/CFDesktop/crashes/`**,而是 `/crashes/` — 跟 logger 同根(`app_runtime_dir() == QCoreApplication::applicationDirPath()`),finalize tail 日志与报告同目录树。 > 详见 [06_infrastructure.md](06_infrastructure.md) CrashHandler 段(Phase 1 完成项已勾选,Phase 2 项保留)。 +> +> **更新(2026-07-10):Phase 2 最小闭环已落地** 于 `feat/phase-f-control-center-notifications`: +> - **addr2line 符号化**:`CrashReport` 加 `resolved_frames`(function/file/line);`symbolizer.cpp`(Linux popen `addr2line -f -C`)在 `finalizePendingReports(exe_path)` 时解析裸地址;`parseAddr2LineOutput` 抽出供 `crash_symbolizer_test` 单测。 +> - **CrashReporter 弹窗**:`ui/components/crash_reporter/`(`CrashReporterDialog` + `.seen` marker),shell 启动扫 `crashes/*.json` 未 seen 的弹窗(符号化栈 + 复制 + 「不再显示」写 `.seen`),AppLauncher 范式。 +> - **defer**:Watchdog 进程、Windows dbghelp(`SymFromAddr`)、独立 CrashReporter 进程。 ## 结论:部分就绪,建议分阶段实施 From 6a9435b1a141c3461c232f45f7c34929d534f0a3 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 13:30:46 +0800 Subject: [PATCH 17/26] feat(settings): MD3 card groups, value readouts, theme highlight, rich about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the three tabs so they are not rough: - Wallpaper: every setting is a rounded gradient card (mirrors the home-page gadget cards). Sliders show their current value ("12000 ms") live. All six wallpaper keys exposed now — animation switch, interval, duration, switch mode (fixed/gradient/movement), order (sequential/random), easing — each as its own card. Wrapped in a QScrollArea so six cards fit. - Theme: the active theme's button is disabled as a "you are here" indicator; clicking the other re-highlights live. - About: rich text with larger title, version, stack, and a clickable repo link (setOpenExternalLinks). - TabView: QSS-styled tab bar (selected tab in primary purple). Also reword the CrashReporter unsymbolized-frames line to "runtime addresses — full symbolization deferred": the PIE/ASLR base-resolution fix is deferred to focus Settings this round. --- .../crash_reporter/crash_reporter_dialog.cpp | 2 +- ui/components/settings/settings_window.cpp | 334 ++++++++++++++---- 2 files changed, 263 insertions(+), 73 deletions(-) diff --git a/ui/components/crash_reporter/crash_reporter_dialog.cpp b/ui/components/crash_reporter/crash_reporter_dialog.cpp index fcd1a918a..01b3becc0 100644 --- a/ui/components/crash_reporter/crash_reporter_dialog.cpp +++ b/ui/components/crash_reporter/crash_reporter_dialog.cpp @@ -136,7 +136,7 @@ void CrashReporterDialog::loadCrash() { for (const auto& v : raw) { body += QStringLiteral(" %1\n").arg(v.toString()); } - body += QStringLiteral(" (not symbolized)\n"); + body += QStringLiteral(" (runtime addresses — full symbolization deferred; see docs)\n"); } body += QStringLiteral("\nLast logs:\n"); const QJsonArray logs = o.value("last_logs").toArray(); diff --git a/ui/components/settings/settings_window.cpp b/ui/components/settings/settings_window.cpp index 6bce17dea..d88d26ad5 100644 --- a/ui/components/settings/settings_window.cpp +++ b/ui/components/settings/settings_window.cpp @@ -23,13 +23,16 @@ #include "ui/widget/material/widget/switch/switch.h" #include "ui/widget/material/widget/tabview/tabview.h" +#include #include #include #include #include #include #include +#include #include +#include #include #include @@ -41,11 +44,28 @@ using namespace qw::core::token::literals; namespace { constexpr int kWindowWidth = 720; ///< Window width (px). -constexpr int kWindowHeight = 520; ///< Window height (px). +constexpr int kWindowHeight = 560; ///< Window height (px). constexpr qreal kRadius = 16.0; ///< Corner radius (px). constexpr int kEnterSlidePx = 24; ///< Enter/exit slide distance (px). constexpr const char* kLogTag = "SettingsWindow"; +/// @brief Card QSS: a rounded gradient surface (mirrors the home-page gadget +/// cards; deliberately not bound to the live theme, matching that style). +constexpr const char* kCardQss = "QWidget { background: qlineargradient(x1:0, y1:0, x2:0, y2:1," + " stop:0 #5D6D7E, stop:1 #2C3E50); border-radius: 12px; }"; +constexpr const char* kTitleQss = "color: white; font-weight: bold; font-size: 13px;" + " background: transparent; border: none;"; +constexpr const char* kValueQss = "color: #1abc9c; font-size: 12px;" + " background: transparent; border: none;"; +constexpr const char* kHintQss = "color: #bdc3c7; font-size: 11px;" + " background: transparent; border: none;"; +constexpr const char* kRadioQss = "color: white; background: transparent; border: none;"; +constexpr const char* kTabQss = + "QTabWidget::pane { border: none; background: transparent; top: 0px; }" + "QTabBar::tab { background: #E5E1E8; color: #2C3E50; padding: 8px 20px;" + " border-top-left-radius: 8px; border-top-right-radius: 8px; margin: 0 2px; }" + "QTabBar::tab::selected { background: #6750A4; color: white; }"; + /// @brief Writes one wallpaper config key (User layer, immediate notify). void setWallpaperKey(const char* key, auto&& value) { namespace cfg = cf::config; @@ -55,6 +75,39 @@ void setWallpaperKey(const char* key, auto&& value) { cfg::NotifyPolicy::Immediate); cf::config::ConfigStore::instance().sync(); } + +/// @brief Reads one wallpaper config key with a default. +template T wallpaperKey(const char* key, T fallback) { + return cf::config::ConfigStore::instance() + .domain("wallpaper") + .query(cf::config::KeyView{.group = "wallpaper", .key = key}, fallback); +} + +/// @brief Builds an empty titled card; caller adds child widgets to its layout. +QWidget* makeCard(const QString& title, const QString& hint, QWidget* parent) { + auto* card = new QWidget(parent); + card->setStyleSheet(QString::fromLatin1(kCardQss)); + auto* layout = new QVBoxLayout(card); + layout->setContentsMargins(16, 12, 16, 12); + layout->setSpacing(6); + auto* t = new QLabel(title, card); + t->setStyleSheet(QString::fromLatin1(kTitleQss)); + layout->addWidget(t); + if (!hint.isEmpty()) { + auto* h = new QLabel(hint, card); + h->setStyleSheet(QString::fromLatin1(kHintQss)); + h->setWordWrap(true); + layout->addWidget(h); + } + return card; +} + +/// @brief Styles a value label that mirrors a slider's current value. +QLabel* makeValueLabel(QWidget* parent, int ms) { + auto* label = new QLabel(QString::number(ms) + QStringLiteral(" ms"), parent); + label->setStyleSheet(QString::fromLatin1(kValueQss)); + return label; +} } // namespace SettingsWindow::SettingsWindow(QWidget* parent) : QWidget(parent) { @@ -80,10 +133,11 @@ SettingsWindow::~SettingsWindow() = default; void SettingsWindow::setupUi() { auto* root = new QVBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(0); + root->setContentsMargins(12, 12, 12, 12); + root->setSpacing(8); auto* tabs = new qw::widget::material::TabView(this); + tabs->setStyleSheet(QString::fromLatin1(kTabQss)); tabs->addTab(buildWallpaperTab(), QStringLiteral("Wallpaper")); tabs->addTab(buildThemeTab(), QStringLiteral("Theme")); tabs->addTab(buildAboutTab(), QStringLiteral("About")); @@ -93,78 +147,204 @@ void SettingsWindow::setupUi() { QWidget* SettingsWindow::buildWallpaperTab() { auto* tab = new QWidget(this); auto* layout = new QVBoxLayout(tab); - layout->setContentsMargins(16, 16, 16, 16); - layout->setSpacing(12); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(10); + + // --- Animation enabled (disable_animation) --- + { + auto* card = makeCard(QStringLiteral("Animation"), + QStringLiteral("Auto-switch the wallpaper on a timer."), tab); + auto* row = new QWidget(card); + auto* h = new QHBoxLayout(row); + h->setContentsMargins(0, 0, 0, 0); + auto* sw = new qw::widget::material::Switch(QStringLiteral("Enabled"), row); + sw->setChecked(!wallpaperKey("disable_animation", false)); + h->addWidget(sw); + h->addStretch(1); + card->layout()->addWidget(row); + connect(sw, &QCheckBox::toggled, tab, [](bool on) { + setWallpaperKey("disable_animation", !on); + cf::log::infoftag(kLogTag, "wallpaper disable_animation -> {} (applies next start)", + !on); + }); + layout->addWidget(card); + } - namespace cfg = cf::config; - auto domain = cf::config::ConfigStore::instance().domain("wallpaper"); - - // Animation enabled = NOT disable_animation. Live wallpaper reload is the - // engine's job; settings only persist. Values apply on next engine start. - auto* anim = new qw::widget::material::Switch(QStringLiteral("Animation enabled"), tab); - anim->setChecked( - !domain.query(cfg::KeyView{.group = "wallpaper", .key = "disable_animation"}, false)); - layout->addWidget(anim); - connect(anim, &QCheckBox::toggled, tab, [](bool on) { - setWallpaperKey("disable_animation", !on); - cf::log::infoftag(kLogTag, "wallpaper disable_animation -> {} (applies next start)", !on); - }); + // --- Switch interval (switch_interval_ms) --- + { + const int cur = wallpaperKey("switch_interval_ms", 20000); + auto* card = + makeCard(QStringLiteral("Switch interval"), + QStringLiteral("How long each wallpaper stays before switching."), tab); + auto* row = new QWidget(card); + auto* h = new QHBoxLayout(row); + h->setContentsMargins(0, 0, 0, 0); + auto* value = makeValueLabel(row, cur); + auto* slider = new qw::widget::material::Slider(Qt::Horizontal, row); + slider->setRange(2000, 60000); + slider->setSingleStep(1000); + slider->setValue(cur); + h->addWidget(value, 0); + h->addWidget(slider, 1); + card->layout()->addWidget(row); + connect(slider, &QSlider::valueChanged, tab, [value](int v) { + value->setText(QString::number(v) + QStringLiteral(" ms")); + setWallpaperKey("switch_interval_ms", v); + }); + layout->addWidget(card); + } + + // --- Transition duration (animation_duration_ms) --- + { + const int cur = wallpaperKey("animation_duration_ms", 2000); + auto* card = makeCard(QStringLiteral("Transition duration"), + QStringLiteral("Fade/move length when switching wallpapers."), tab); + auto* row = new QWidget(card); + auto* h = new QHBoxLayout(row); + h->setContentsMargins(0, 0, 0, 0); + auto* value = makeValueLabel(row, cur); + auto* slider = new qw::widget::material::Slider(Qt::Horizontal, row); + slider->setRange(200, 5000); + slider->setSingleStep(100); + slider->setValue(cur); + h->addWidget(value, 0); + h->addWidget(slider, 1); + card->layout()->addWidget(row); + connect(slider, &QSlider::valueChanged, tab, [value](int v) { + value->setText(QString::number(v) + QStringLiteral(" ms")); + setWallpaperKey("animation_duration_ms", v); + }); + layout->addWidget(card); + } + + // --- Switch mode (switch_mode: fixed/gradient/movement) --- + { + const QString cur = + QString::fromStdString(wallpaperKey("switch_mode", "movement")); + auto* card = makeCard(QStringLiteral("Switch mode"), + QStringLiteral("fixed = no switch; gradient = cross-fade;" + " movement = pan."), + tab); + auto* group = new QButtonGroup(card); + for (const QString& m : + {QStringLiteral("fixed"), QStringLiteral("gradient"), QStringLiteral("movement")}) { + auto* rb = new QRadioButton(m, card); + rb->setStyleSheet(QString::fromLatin1(kRadioQss)); + rb->setChecked(m == cur); + group->addButton(rb); + card->layout()->addWidget(rb); + } + connect(group, &QButtonGroup::idClicked, tab, [group](int) { + auto* rb = qobject_cast(group->checkedButton()); + if (rb != nullptr) { + setWallpaperKey("switch_mode", rb->text().toStdString()); + } + }); + layout->addWidget(card); + } + + // --- Selector (switch_selector: sequential/random) --- + { + const QString cur = + QString::fromStdString(wallpaperKey("switch_selector", "sequential")); + auto* card = + makeCard(QStringLiteral("Order"), QStringLiteral("sequential or random."), tab); + auto* group = new QButtonGroup(card); + for (const QString& m : {QStringLiteral("sequential"), QStringLiteral("random")}) { + auto* rb = new QRadioButton(m, card); + rb->setStyleSheet(QString::fromLatin1(kRadioQss)); + rb->setChecked(m == cur); + group->addButton(rb); + card->layout()->addWidget(rb); + } + connect(group, &QButtonGroup::idClicked, tab, [group](int) { + auto* rb = qobject_cast(group->checkedButton()); + if (rb != nullptr) { + setWallpaperKey("switch_selector", rb->text().toStdString()); + } + }); + layout->addWidget(card); + } - // Switch interval slider (2-60 s). - auto* irow = new QWidget(tab); - auto* ih = new QHBoxLayout(irow); - ih->setContentsMargins(0, 0, 0, 0); - auto* ilab = new QLabel(QStringLiteral("Switch interval"), irow); - ilab->setMinimumWidth(130); - auto* isld = new qw::widget::material::Slider(Qt::Horizontal, irow); - isld->setRange(2000, 60000); - isld->setSingleStep(1000); - isld->setValue( - domain.query(cfg::KeyView{.group = "wallpaper", .key = "switch_interval_ms"}, 20000)); - ih->addWidget(ilab); - ih->addWidget(isld, 1); - layout->addWidget(irow); - connect(isld, &QSlider::valueChanged, tab, - [](int v) { setWallpaperKey("switch_interval_ms", v); }); - - // Transition duration slider (200-5000 ms). - auto* drow = new QWidget(tab); - auto* dh = new QHBoxLayout(drow); - dh->setContentsMargins(0, 0, 0, 0); - auto* dlab = new QLabel(QStringLiteral("Transition duration"), drow); - dlab->setMinimumWidth(130); - auto* dsld = new qw::widget::material::Slider(Qt::Horizontal, drow); - dsld->setRange(200, 5000); - dsld->setSingleStep(100); - dsld->setValue(domain.query( - cfg::KeyView{.group = "wallpaper", .key = "animation_duration_ms"}, 2000)); - dh->addWidget(dlab); - dh->addWidget(dsld, 1); - layout->addWidget(drow); - connect(dsld, &QSlider::valueChanged, tab, - [](int v) { setWallpaperKey("animation_duration_ms", v); }); + // --- Easing (switch_easing: inoutcubic/outcubic/linear) --- + { + const QString cur = + QString::fromStdString(wallpaperKey("switch_easing", "inoutcubic")); + auto* card = makeCard(QStringLiteral("Easing"), + QStringLiteral("inoutcubic / outcubic / linear."), tab); + auto* group = new QButtonGroup(card); + for (const QString& m : + {QStringLiteral("inoutcubic"), QStringLiteral("outcubic"), QStringLiteral("linear")}) { + auto* rb = new QRadioButton(m, card); + rb->setStyleSheet(QString::fromLatin1(kRadioQss)); + rb->setChecked(m == cur); + group->addButton(rb); + card->layout()->addWidget(rb); + } + connect(group, &QButtonGroup::idClicked, tab, [group](int) { + auto* rb = qobject_cast(group->checkedButton()); + if (rb != nullptr) { + setWallpaperKey("switch_easing", rb->text().toStdString()); + } + }); + layout->addWidget(card); + } layout->addStretch(1); - return tab; + + // Wrap in a scroll area: six cards can exceed the window height. + auto* scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + scroll->setWidget(tab); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setAttribute(Qt::WA_TranslucentBackground, true); + return scroll; } QWidget* SettingsWindow::buildThemeTab() { auto* tab = new QWidget(this); auto* layout = new QVBoxLayout(tab); - layout->setContentsMargins(16, 16, 16, 16); - layout->setSpacing(12); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(10); - using qw::widget::material::Button; - auto* light_btn = new Button(QStringLiteral("Light"), Button::ButtonVariant::Tonal, tab); - auto* dark_btn = new Button(QStringLiteral("Dark"), Button::ButtonVariant::Tonal, tab); - layout->addWidget(light_btn); - layout->addWidget(dark_btn); + QString current_name; + try { + current_name = + QString::fromStdString(qw::core::ThemeManager::instance().currentThemeName()); + } catch (...) { + current_name = QStringLiteral("light"); + } + const bool dark = current_name == QStringLiteral("dark"); - // Live: ThemeManager broadcasts themeChanged, so every panel re-themes. - connect(light_btn, &QPushButton::clicked, tab, - []() { qw::core::ThemeManager::instance().setThemeTo("light"); }); - connect(dark_btn, &QPushButton::clicked, tab, - []() { qw::core::ThemeManager::instance().setThemeTo("dark"); }); + auto* card = makeCard(QStringLiteral("Theme"), + QStringLiteral("Switch the active Material theme. The active one is " + "disabled so you can tell which is current."), + tab); + using qw::widget::material::Button; + auto* light_btn = new Button(QStringLiteral("Light"), Button::ButtonVariant::Tonal, card); + auto* dark_btn = new Button(QStringLiteral("Dark"), Button::ButtonVariant::Tonal, card); + // Dim/disable the currently active one as a "you are here" indicator. + light_btn->setEnabled(dark); + dark_btn->setEnabled(!dark); + auto* row = new QWidget(card); + auto* h = new QHBoxLayout(row); + h->setContentsMargins(0, 0, 0, 0); + h->addWidget(light_btn); + h->addWidget(dark_btn); + h->addStretch(1); + card->layout()->addWidget(row); + layout->addWidget(card); + + connect(light_btn, &QPushButton::clicked, tab, [light_btn, dark_btn]() { + qw::core::ThemeManager::instance().setThemeTo("light"); + light_btn->setEnabled(false); + dark_btn->setEnabled(true); + }); + connect(dark_btn, &QPushButton::clicked, tab, [light_btn, dark_btn]() { + qw::core::ThemeManager::instance().setThemeTo("dark"); + dark_btn->setEnabled(false); + light_btn->setEnabled(true); + }); layout->addStretch(1); return tab; @@ -173,14 +353,24 @@ QWidget* SettingsWindow::buildThemeTab() { QWidget* SettingsWindow::buildAboutTab() { auto* tab = new QWidget(this); auto* layout = new QVBoxLayout(tab); - layout->setContentsMargins(16, 16, 16, 16); - auto* label = new QLabel(QStringLiteral("CFDesktop v0.19.0\n\n" - "Cross-platform Desktop Environment Framework\n" - "C++23 / Qt 6.8 / Material Design 3\n\n" - "github.com/Charliechen114514/CFDesktop"), - tab); - label->setWordWrap(true); - layout->addWidget(label); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(10); + + auto* card = makeCard(QStringLiteral("About"), QString(), tab); + auto* rich = new QLabel(card); + rich->setTextFormat(Qt::RichText); + rich->setOpenExternalLinks(true); + rich->setStyleSheet("color: white; font-size: 13px; background: transparent; border: none;"); + rich->setText(QStringLiteral( + "

CFDesktop

" + "

Version 0.19.0

" + "

Cross-platform Desktop Environment Framework
" + "C++23 / Qt 6.8 / Material Design 3

" + "

" + "github.com/Charliechen114514/CFDesktop

")); + rich->setWordWrap(true); + card->layout()->addWidget(rich); + layout->addWidget(card); layout->addStretch(1); return tab; } From 0d134d3ca0595a631f4352b3c9097625cadb99cc Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 13:33:41 +0800 Subject: [PATCH 18/26] feat(wallpaper): live reload on config change (settings applies instantly) WallPaperEngine now watches the "wallpaper" ConfigStore domain; any change (Settings window slider/radio/switch) fires onConfigChanged -> stop + start, which reloads config and re-evaluates the disable/mode/size guards. The watcher handle is unwatched in the destructor. Settings changes now apply immediately instead of only on the next engine start. --- ui/components/wallpaper/WallPaperEngine.cpp | 18 ++++++++++++++++++ ui/components/wallpaper/WallPaperEngine.h | 7 +++++++ 2 files changed, 25 insertions(+) diff --git a/ui/components/wallpaper/WallPaperEngine.cpp b/ui/components/wallpaper/WallPaperEngine.cpp index b0d08d90a..7478cb470 100644 --- a/ui/components/wallpaper/WallPaperEngine.cpp +++ b/ui/components/wallpaper/WallPaperEngine.cpp @@ -115,6 +115,15 @@ WallPaperEngine::WallPaperEngine(WallPaperLayer* layer, RequestTransition reques : QObject(parent), layer_(layer), request_(std::move(request)), timer_(this) { loadConfig(); connect(&timer_, &QTimer::timeout, this, &WallPaperEngine::onTimerTick); + // Live reload: re-evaluate rotation whenever a wallpaper config key changes + // (the Settings window writes this domain). + config_watch_ = cf::config::ConfigStore::instance() + .domain("wallpaper") + .watch( + "wallpaper.*", + [this](const cf::config::Key&, const std::any*, const std::any*, + cf::config::Layer) { onConfigChanged(); }, + cf::config::NotifyPolicy::Immediate); log::traceftag(kLogTag, "Constructed: mode={} selector={} interval={}ms duration={}ms", static_cast(mode_), static_cast(selector_), interval_ms_, duration_ms_); @@ -122,6 +131,9 @@ WallPaperEngine::WallPaperEngine(WallPaperLayer* layer, RequestTransition reques WallPaperEngine::~WallPaperEngine() { stop(); + if (config_watch_ != 0) { + cf::config::ConfigStore::instance().domain("wallpaper").unwatch(config_watch_); + } } void WallPaperEngine::loadConfig() { @@ -140,6 +152,12 @@ void WallPaperEngine::loadConfig() { cf::config::KeyView{.group = "wallpaper", .key = "disable_animation"}, false); } +void WallPaperEngine::onConfigChanged() { + // start() reloads config and re-evaluates (disable/mode/size guards). + stop(); + start(); +} + void WallPaperEngine::start() { loadConfig(); // pick up the latest values if (disable_animation_) { diff --git a/ui/components/wallpaper/WallPaperEngine.h b/ui/components/wallpaper/WallPaperEngine.h index f5a5a240e..ad3f11211 100644 --- a/ui/components/wallpaper/WallPaperEngine.h +++ b/ui/components/wallpaper/WallPaperEngine.h @@ -19,6 +19,7 @@ #pragma once #include "WallPaperLayer.h" +#include "cfconfig/cfconfig_watcher.h" #include "wallpaper/TransitionComposer.h" #include "wallpaper/WallPaperToken.h" @@ -185,6 +186,9 @@ class WallPaperEngine : public QObject { */ void loadConfig(); + /// @brief Reloads config and re-evaluates rotation when the config changes. + void onConfigChanged(); + WallPaperLayer* layer_; RequestTransition request_; QTimer timer_; @@ -194,6 +198,9 @@ class WallPaperEngine : public QObject { int duration_ms_{2000}; QEasingCurve easing_{QEasingCurve::InOutCubic}; bool disable_animation_{false}; + + /// @brief ConfigStore watcher handle for live wallpaper reload. + cf::config::WatcherHandle config_watch_{0}; }; } // namespace cf::desktop::wallpaper From 7d6018458b5cc0c226cf3230a3a1324aee23e289 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 13:40:02 +0800 Subject: [PATCH 19/26] test(wallpaper): init ConfigStore in test main (fix UB exposed by watch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallpaper test main never initialized ConfigStore; WallPaperEngine's loadConfig() queried the uninitialized singleton — UB that GCC happened to mask (returned defaults). Adding the live-reload watch shifted the UB enough to return garbage (animationDurationMs()=513 instead of 2000). Initialize ConfigStore with MockPathProvider in the test main so queries return real defaults. This is the real fix; "GCC passed" never meant no UB (the CI triple-compiler matrix is what reliably catches it). --- test/desktop/wallpaper_animation/CMakeLists.txt | 2 +- .../wallpaper_animation/wallpaper_animation_test.cpp | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/test/desktop/wallpaper_animation/CMakeLists.txt b/test/desktop/wallpaper_animation/CMakeLists.txt index 044e8da76..02d4361ef 100644 --- a/test/desktop/wallpaper_animation/CMakeLists.txt +++ b/test/desktop/wallpaper_animation/CMakeLists.txt @@ -8,7 +8,7 @@ add_executable(wallpaper_animation_test ) target_include_directories(wallpaper_animation_test PRIVATE - ${CMAKE_SOURCE_DIR}/desktop/ui/components + ${CMAKE_SOURCE_DIR}/test/config_manager ) target_link_libraries(wallpaper_animation_test PRIVATE diff --git a/test/desktop/wallpaper_animation/wallpaper_animation_test.cpp b/test/desktop/wallpaper_animation/wallpaper_animation_test.cpp index 19517ab7c..d47bd812f 100644 --- a/test/desktop/wallpaper_animation/wallpaper_animation_test.cpp +++ b/test/desktop/wallpaper_animation/wallpaper_animation_test.cpp @@ -20,6 +20,9 @@ #include "wallpaper/WallPaperLayer.h" #include "wallpaper/WallPaperToken.h" +#include "cfconfig.hpp" +#include "mock_path_provider.h" + #include #include #include @@ -240,6 +243,11 @@ TEST(WallPaperEngine, StartWithSingleWallpaperIsHarmless) { int main(int argc, char** argv) { QGuiApplication app(argc, argv); + // WallPaperEngine reads and watches the wallpaper ConfigStore domain; give + // the store a provider so queries return real defaults instead of UB on an + // uninitialized singleton. + cf::config::ConfigStore::instance().initialize( + std::make_shared()); ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); } From 26a8a3fea21bb717f3dcaabfaccdc70d114867ba Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 13:53:54 +0800 Subject: [PATCH 20/26] fix(settings): theme switches + About card fills the tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Theme: replace the broken setEnabled(false) "current" indicator (it made the active button unclickable, so toggling back did nothing) with a variant highlight — the active theme is Filled, the other is Tonal, and both stay clickable. setVariant flips on click. - About: the card was stranded at the top with a stretch below ("看着很小"); now the card fills the tab (addWidget(card, 1)) and the text block is vertically centered (addStretch above/below inside the card). --- ui/components/settings/settings_window.cpp | 37 +++++++++++++--------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/ui/components/settings/settings_window.cpp b/ui/components/settings/settings_window.cpp index d88d26ad5..24aceb461 100644 --- a/ui/components/settings/settings_window.cpp +++ b/ui/components/settings/settings_window.cpp @@ -318,14 +318,18 @@ QWidget* SettingsWindow::buildThemeTab() { auto* card = makeCard(QStringLiteral("Theme"), QStringLiteral("Switch the active Material theme. The active one is " - "disabled so you can tell which is current."), + "highlighted (filled); both stay clickable."), tab); using qw::widget::material::Button; - auto* light_btn = new Button(QStringLiteral("Light"), Button::ButtonVariant::Tonal, card); - auto* dark_btn = new Button(QStringLiteral("Dark"), Button::ButtonVariant::Tonal, card); - // Dim/disable the currently active one as a "you are here" indicator. - light_btn->setEnabled(dark); - dark_btn->setEnabled(!dark); + // Filled = current (highlighted), Tonal = the other. Both stay enabled — + // disabling the current one made it unclickable, so toggling back felt + // broken ("theme switch did nothing"). + auto* light_btn = + new Button(QStringLiteral("Light"), + dark ? Button::ButtonVariant::Tonal : Button::ButtonVariant::Filled, card); + auto* dark_btn = + new Button(QStringLiteral("Dark"), + dark ? Button::ButtonVariant::Filled : Button::ButtonVariant::Tonal, card); auto* row = new QWidget(card); auto* h = new QHBoxLayout(row); h->setContentsMargins(0, 0, 0, 0); @@ -333,20 +337,19 @@ QWidget* SettingsWindow::buildThemeTab() { h->addWidget(dark_btn); h->addStretch(1); card->layout()->addWidget(row); - layout->addWidget(card); + layout->addWidget(card, 1); connect(light_btn, &QPushButton::clicked, tab, [light_btn, dark_btn]() { qw::core::ThemeManager::instance().setThemeTo("light"); - light_btn->setEnabled(false); - dark_btn->setEnabled(true); + light_btn->setVariant(Button::ButtonVariant::Filled); + dark_btn->setVariant(Button::ButtonVariant::Tonal); }); connect(dark_btn, &QPushButton::clicked, tab, [light_btn, dark_btn]() { qw::core::ThemeManager::instance().setThemeTo("dark"); - dark_btn->setEnabled(false); - light_btn->setEnabled(true); + dark_btn->setVariant(Button::ButtonVariant::Filled); + light_btn->setVariant(Button::ButtonVariant::Tonal); }); - layout->addStretch(1); return tab; } @@ -369,9 +372,13 @@ QWidget* SettingsWindow::buildAboutTab() { "

" "github.com/Charliechen114514/CFDesktop

")); rich->setWordWrap(true); - card->layout()->addWidget(rich); - layout->addWidget(card); - layout->addStretch(1); + // Center the text vertically inside the card; the card itself fills the tab. + // makeCard() builds a QVBoxLayout, so the cast is safe. + auto* card_layout = static_cast(card->layout()); + card_layout->addStretch(1); + card_layout->addWidget(rich); + card_layout->addStretch(2); + layout->addWidget(card, 1); return tab; } From 55ef8a2565a1371546b5b20f25c49900fb17ccda Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 14:07:28 +0800 Subject: [PATCH 21/26] fix(theme): use correct theme token names (theme.material.light/dark) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setThemeTo("light"/"dark") was a no-op: MaterialApplication registers the themes under the token names "theme.material.light"/"theme.material.dark" (MATERIAL_THEME_LIGHT/DARK in core/token/theme_name), and ThemeManager::setThemeTo silently returns when the name is not in factories_. So theme switching never actually worked — not in the settings tab, not in the control center theme button. Use the token constants in both places. --- ui/components/control_center/control_center.cpp | 5 +++-- ui/components/settings/settings_window.cpp | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/ui/components/control_center/control_center.cpp b/ui/components/control_center/control_center.cpp index 514e438c5..4060388fc 100644 --- a/ui/components/control_center/control_center.cpp +++ b/ui/components/control_center/control_center.cpp @@ -17,6 +17,7 @@ #include "components/material/cfmaterial_slide_animation.h" #include "core/theme_manager.h" #include "core/token/material_scheme/cfmaterial_token_literals.h" +#include "core/token/theme_name/material_theme_name.h" #include "ui/widget/material/widget/button/button.h" #include "ui/widget/material/widget/slider/slider.h" #include "ui/widget/material/widget/switch/switch.h" @@ -140,8 +141,8 @@ void ControlCenter::setupUi() { connect(theme_btn_, &QPushButton::clicked, this, []() { auto& tm = qw::core::ThemeManager::instance(); - const bool dark = tm.currentThemeName() == "dark"; - tm.setThemeTo(dark ? "light" : "dark"); + const bool dark = tm.currentThemeName() == MATERIAL_THEME_DARK; + tm.setThemeTo(dark ? MATERIAL_THEME_LIGHT : MATERIAL_THEME_DARK); }); connect(screenshot_btn_, &QPushButton::clicked, this, []() { cf::log::infoftag(kLogTag, "screenshot requested (placeholder)"); }); diff --git a/ui/components/settings/settings_window.cpp b/ui/components/settings/settings_window.cpp index 24aceb461..857611cc1 100644 --- a/ui/components/settings/settings_window.cpp +++ b/ui/components/settings/settings_window.cpp @@ -18,6 +18,7 @@ #include "components/material/cfmaterial_slide_animation.h" #include "core/theme_manager.h" #include "core/token/material_scheme/cfmaterial_token_literals.h" +#include "core/token/theme_name/material_theme_name.h" #include "ui/widget/material/widget/button/button.h" #include "ui/widget/material/widget/slider/slider.h" #include "ui/widget/material/widget/switch/switch.h" @@ -314,7 +315,7 @@ QWidget* SettingsWindow::buildThemeTab() { } catch (...) { current_name = QStringLiteral("light"); } - const bool dark = current_name == QStringLiteral("dark"); + const bool dark = current_name == QString::fromLatin1(MATERIAL_THEME_DARK); auto* card = makeCard(QStringLiteral("Theme"), QStringLiteral("Switch the active Material theme. The active one is " @@ -340,12 +341,12 @@ QWidget* SettingsWindow::buildThemeTab() { layout->addWidget(card, 1); connect(light_btn, &QPushButton::clicked, tab, [light_btn, dark_btn]() { - qw::core::ThemeManager::instance().setThemeTo("light"); + qw::core::ThemeManager::instance().setThemeTo(MATERIAL_THEME_LIGHT); light_btn->setVariant(Button::ButtonVariant::Filled); dark_btn->setVariant(Button::ButtonVariant::Tonal); }); connect(dark_btn, &QPushButton::clicked, tab, [light_btn, dark_btn]() { - qw::core::ThemeManager::instance().setThemeTo("dark"); + qw::core::ThemeManager::instance().setThemeTo(MATERIAL_THEME_DARK); dark_btn->setVariant(Button::ButtonVariant::Filled); light_btn->setVariant(Button::ButtonVariant::Tonal); }); From 88a35dc8bee2d264a6edcdb200389049918af1b2 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 14:23:31 +0800 Subject: [PATCH 22/26] fix(theme): sync QGuiApplication palette on theme change (dark native widgets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MaterialApplication registers the Material themes but never updates the Qt application palette, so MD3 controls (which paint from theme tokens) went dark while plain Qt widgets (QScrollArea, QTabWidget pane, QLabel/QTextEdit defaults) stayed light/white — the notification center list and the settings tab contents showed white on a dark panel ("分家"). Add a themeChanged -> QGuiApplication::setPalette hook in CFDesktopEntity that swaps Window/Base/Text/etc. between a dark and a light palette, plus an initial sync (MaterialApplication's setThemeTo(DEFAULT, false) did not emit). --- ui/CFDesktopEntity.cpp | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/ui/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index 781c0094e..626080c68 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -32,6 +32,8 @@ #include "components/taskbar/centered_taskbar.h" #include "components/window_placement/floating_policy.h" #include "components/window_placement/window_placement_policy.h" +#include "core/theme_manager.h" +#include "core/token/theme_name/material_theme_name.h" #include "platform/DesktopPropertyStrategyFactory.h" #include "platform/display_backend_helper.h" #include "platform/shell_layer_helper.h" @@ -46,6 +48,7 @@ #include #include #include +#include #include #include #include @@ -178,6 +181,31 @@ CFDesktopEntity::CFDesktopEntity() desktop_entity_->activateWindow(); } }); + + // Sync QGuiApplication's palette with the Material theme so plain Qt + // widgets (QScrollArea, QTabWidget pane, QLabel defaults, QTextEdit…) + // follow dark mode too. MD3 controls paint from theme tokens directly and + // are unaffected; this only covers Qt-native widgets. + using qw::core::ThemeManager; + const auto sync_palette = []() { + const bool dark = ThemeManager::instance().currentThemeName() == + qw::core::token::literals::MATERIAL_THEME_DARK; + QPalette p; + const QColor bg = dark ? QColor(0x1C, 0x1B, 0x1F) : QColor(0xF7, 0xF5, 0xF3); + const QColor fg = dark ? QColor(0xE6, 0xE1, 0xE5) : QColor(0x1C, 0x1B, 0x1F); + for (auto role : {QPalette::Window, QPalette::Base, QPalette::AlternateBase, + QPalette::Button, QPalette::ToolTipBase}) { + p.setColor(role, bg); + } + for (auto role : + {QPalette::WindowText, QPalette::Text, QPalette::ButtonText, QPalette::ToolTipText}) { + p.setColor(role, fg); + } + QGuiApplication::setPalette(p); + }; + QObject::connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, + [sync_palette](const qw::core::ICFTheme&) { sync_palette(); }); + sync_palette(); // initial: MaterialApplication setThemeTo(DEFAULT, false) did not emit } CFDesktopEntity::~CFDesktopEntity() { From f2564d8f7a225a8567714f5fded7caac69458fc4 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 14:29:48 +0800 Subject: [PATCH 23/26] fix(settings): enlarge tab/card font sizes (were too small) Bump the QSS font sizes across the three tabs so they are readable: card titles 13->16, value readouts 12->14, hints 11->13, radio labels add an explicit 14, tab bar padding 8/20 -> 10/24 + font 14, about body 13->15 and title 18->22. --- ui/components/settings/settings_window.cpp | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ui/components/settings/settings_window.cpp b/ui/components/settings/settings_window.cpp index 857611cc1..0314baccd 100644 --- a/ui/components/settings/settings_window.cpp +++ b/ui/components/settings/settings_window.cpp @@ -54,17 +54,19 @@ constexpr const char* kLogTag = "SettingsWindow"; /// cards; deliberately not bound to the live theme, matching that style). constexpr const char* kCardQss = "QWidget { background: qlineargradient(x1:0, y1:0, x2:0, y2:1," " stop:0 #5D6D7E, stop:1 #2C3E50); border-radius: 12px; }"; -constexpr const char* kTitleQss = "color: white; font-weight: bold; font-size: 13px;" +constexpr const char* kTitleQss = "color: white; font-weight: bold; font-size: 16px;" " background: transparent; border: none;"; -constexpr const char* kValueQss = "color: #1abc9c; font-size: 12px;" +constexpr const char* kValueQss = "color: #1abc9c; font-size: 14px;" " background: transparent; border: none;"; -constexpr const char* kHintQss = "color: #bdc3c7; font-size: 11px;" +constexpr const char* kHintQss = "color: #bdc3c7; font-size: 13px;" " background: transparent; border: none;"; -constexpr const char* kRadioQss = "color: white; background: transparent; border: none;"; +constexpr const char* kRadioQss = "color: white; font-size: 14px;" + " background: transparent; border: none;"; constexpr const char* kTabQss = "QTabWidget::pane { border: none; background: transparent; top: 0px; }" - "QTabBar::tab { background: #E5E1E8; color: #2C3E50; padding: 8px 20px;" - " border-top-left-radius: 8px; border-top-right-radius: 8px; margin: 0 2px; }" + "QTabBar::tab { background: #E5E1E8; color: #2C3E50; padding: 10px 24px;" + " font-size: 14px; border-top-left-radius: 8px; border-top-right-radius: 8px;" + " margin: 0 2px; }" "QTabBar::tab::selected { background: #6750A4; color: white; }"; /// @brief Writes one wallpaper config key (User layer, immediate notify). @@ -364,9 +366,9 @@ QWidget* SettingsWindow::buildAboutTab() { auto* rich = new QLabel(card); rich->setTextFormat(Qt::RichText); rich->setOpenExternalLinks(true); - rich->setStyleSheet("color: white; font-size: 13px; background: transparent; border: none;"); + rich->setStyleSheet("color: white; font-size: 15px; background: transparent; border: none;"); rich->setText(QStringLiteral( - "

CFDesktop

" + "

CFDesktop

" "

Version 0.19.0

" "

Cross-platform Desktop Environment Framework
" "C++23 / Qt 6.8 / Material Design 3

" From 1284260397a243871ad4a5a92aa44beb0e369387 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 14:33:00 +0800 Subject: [PATCH 24/26] fix(settings): enlarge About tab fonts further (body 15->20, title 22->32) --- ui/components/settings/settings_window.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/components/settings/settings_window.cpp b/ui/components/settings/settings_window.cpp index 0314baccd..a86cad2c1 100644 --- a/ui/components/settings/settings_window.cpp +++ b/ui/components/settings/settings_window.cpp @@ -366,9 +366,9 @@ QWidget* SettingsWindow::buildAboutTab() { auto* rich = new QLabel(card); rich->setTextFormat(Qt::RichText); rich->setOpenExternalLinks(true); - rich->setStyleSheet("color: white; font-size: 15px; background: transparent; border: none;"); + rich->setStyleSheet("color: white; font-size: 20px; background: transparent; border: none;"); rich->setText(QStringLiteral( - "

CFDesktop

" + "

CFDesktop

" "

Version 0.19.0

" "

Cross-platform Desktop Environment Framework
" "C++23 / Qt 6.8 / Material Design 3

" From 9d8545193ec7ccd74c48cce7abf3f94fcaeb148f Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 14:37:04 +0800 Subject: [PATCH 25/26] =?UTF-8?q?fix(settings):=20Theme=20tab=20=E2=80=94?= =?UTF-8?q?=20one=20big=20title=20+=20two=20buttons=20(drop=20the=20card)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the card-with-hint layout with what was asked for: a single 32px "Theme" title (color follows the app palette via QLabel WindowText, so it reads on both light and dark) and the Light/Dark buttons below. No card, no hint paragraph. --- ui/components/settings/settings_window.cpp | 35 +++++++++++----------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/ui/components/settings/settings_window.cpp b/ui/components/settings/settings_window.cpp index a86cad2c1..23ea4e1da 100644 --- a/ui/components/settings/settings_window.cpp +++ b/ui/components/settings/settings_window.cpp @@ -307,40 +307,41 @@ QWidget* SettingsWindow::buildWallpaperTab() { QWidget* SettingsWindow::buildThemeTab() { auto* tab = new QWidget(this); auto* layout = new QVBoxLayout(tab); - layout->setContentsMargins(8, 8, 8, 8); - layout->setSpacing(10); + layout->setContentsMargins(24, 24, 24, 24); + layout->setSpacing(20); QString current_name; try { current_name = QString::fromStdString(qw::core::ThemeManager::instance().currentThemeName()); } catch (...) { - current_name = QStringLiteral("light"); + current_name = QString::fromLatin1(MATERIAL_THEME_LIGHT); } const bool dark = current_name == QString::fromLatin1(MATERIAL_THEME_DARK); - auto* card = makeCard(QStringLiteral("Theme"), - QStringLiteral("Switch the active Material theme. The active one is " - "highlighted (filled); both stay clickable."), - tab); + // One big title (no card). No explicit color: QLabel uses WindowText from + // the app palette, which the entity syncs to the theme, so it reads on + // both light and dark. + auto* title = new QLabel(QStringLiteral("Theme"), tab); + title->setStyleSheet( + "font-size: 32px; font-weight: 600; background: transparent; border: none;"); + layout->addWidget(title); + using qw::widget::material::Button; - // Filled = current (highlighted), Tonal = the other. Both stay enabled — - // disabling the current one made it unclickable, so toggling back felt - // broken ("theme switch did nothing"). + auto* row = new QWidget(tab); + auto* h = new QHBoxLayout(row); + h->setContentsMargins(0, 0, 0, 0); auto* light_btn = new Button(QStringLiteral("Light"), - dark ? Button::ButtonVariant::Tonal : Button::ButtonVariant::Filled, card); + dark ? Button::ButtonVariant::Tonal : Button::ButtonVariant::Filled, row); auto* dark_btn = new Button(QStringLiteral("Dark"), - dark ? Button::ButtonVariant::Filled : Button::ButtonVariant::Tonal, card); - auto* row = new QWidget(card); - auto* h = new QHBoxLayout(row); - h->setContentsMargins(0, 0, 0, 0); + dark ? Button::ButtonVariant::Filled : Button::ButtonVariant::Tonal, row); h->addWidget(light_btn); h->addWidget(dark_btn); h->addStretch(1); - card->layout()->addWidget(row); - layout->addWidget(card, 1); + layout->addWidget(row); + layout->addStretch(1); connect(light_btn, &QPushButton::clicked, tab, [light_btn, dark_btn]() { qw::core::ThemeManager::instance().setThemeTo(MATERIAL_THEME_LIGHT); From 53a24a8218fba8f10b1aff160a4f497b017d4212 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Fri, 10 Jul 2026 14:43:45 +0800 Subject: [PATCH 26/26] fix(theme): push palette onto every widget on theme change QGuiApplication::setPalette alone did not recolor existing QLabels (they kept black text on the dark background). After setting the app palette, also walk the desktop widget tree and setPalette(p) on each widget so QLabels pick up the new WindowText immediately. --- ui/CFDesktopEntity.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ui/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index 626080c68..5cfca1bf4 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -51,6 +51,7 @@ #include #include #include +#include #include #include @@ -187,7 +188,7 @@ CFDesktopEntity::CFDesktopEntity() // follow dark mode too. MD3 controls paint from theme tokens directly and // are unaffected; this only covers Qt-native widgets. using qw::core::ThemeManager; - const auto sync_palette = []() { + const auto sync_palette = [this]() { const bool dark = ThemeManager::instance().currentThemeName() == qw::core::token::literals::MATERIAL_THEME_DARK; QPalette p; @@ -202,6 +203,16 @@ CFDesktopEntity::CFDesktopEntity() p.setColor(role, fg); } QGuiApplication::setPalette(p); + // Existing widgets (QLabels especially) do not always re-render on an + // application-wide palette change, so push the palette onto every + // widget under the desktop too. + if (desktop_entity_ != nullptr) { + desktop_entity_->setPalette(p); + const auto widgets = desktop_entity_->findChildren(); + for (auto* w : widgets) { + w->setPalette(p); + } + } }; QObject::connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, [sync_palette](const qw::core::ICFTheme&) { sync_palette(); });