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/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/document/status/current.md b/document/status/current.md index 214ffa94b..7425744af 100644 --- a/document/status/current.md +++ b/document/status/current.md @@ -69,7 +69,9 @@ 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)。 +- **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/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/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 进程。 ## 结论:部分就绪,建议分阶段实施 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/...` 仍为本仓自有。 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/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/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()); +} 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(); } 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"); +} 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/CFDesktopEntity.cpp b/ui/CFDesktopEntity.cpp index d8ad8c770..5cfca1bf4 100644 --- a/ui/CFDesktopEntity.cpp +++ b/ui/CFDesktopEntity.cpp @@ -13,6 +13,9 @@ #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/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" @@ -21,23 +24,34 @@ #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/settings/settings_window.h" #include "components/statusbar/status_bar.h" #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" #include "qt_format.h" #include "system/hardware_tier/hardware_tier.h" #include +#include #include +#include #include #include #include #include #include +#include #include +#include +#include #include #include @@ -168,6 +182,41 @@ 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 = [this]() { + 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); + // 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(); }); + sync_palette(); // initial: MaterialApplication setThemeTo(DEFAULT, false) did not emit } CFDesktopEntity::~CFDesktopEntity() { @@ -530,6 +579,66 @@ 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()); + } + }); + + // 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, + [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(); @@ -562,6 +671,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; } diff --git a/ui/components/CMakeLists.txt b/ui/components/CMakeLists.txt index d1f651961..976b16f90 100644 --- a/ui/components/CMakeLists.txt +++ b/ui/components/CMakeLists.txt @@ -14,6 +14,10 @@ add_subdirectory(desktop_icon_layer) add_subdirectory(builtin_apps) add_subdirectory(desktop_widget) 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) @@ -47,5 +51,9 @@ PRIVATE cfdesktop_window_placement cfdesktop_desktop_widget cfdesktop_home_page + 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/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..4060388fc --- /dev/null +++ b/ui/components/control_center/control_center.cpp @@ -0,0 +1,285 @@ +/** + * @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 "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" + +#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() == 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)"); }); + + // 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); + }); + + // 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) { + 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; + // 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); + + // 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(); + 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..69d96e1b0 --- /dev/null +++ b/ui/components/control_center/control_center.h @@ -0,0 +1,186 @@ +/** + * @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; + + 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. + * + * @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}; + /// @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}; + 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 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..01b3becc0 --- /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(" (runtime addresses — full symbolization deferred; see docs)\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 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 diff --git a/ui/components/notification/CMakeLists.txt b/ui/components/notification/CMakeLists.txt new file mode 100644 index 000000000..bd266c971 --- /dev/null +++ b/ui/components/notification/CMakeLists.txt @@ -0,0 +1,23 @@ +# 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 + $ + $ + $ +) + +target_link_libraries( + cfdesktop_notification +PRIVATE + 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.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_banner.cpp b/ui/components/notification/notification_banner.cpp new file mode 100644 index 000000000..774088e15 --- /dev/null +++ b/ui/components/notification/notification_banner.cpp @@ -0,0 +1,143 @@ +/** + * @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 "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-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.center().x() - w / 2; + 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("mediumEnter"); + enter_fade_->setTargetWidget(this); + + enter_slide_ = new CFMaterialSlideAnimation(spec, SlideDirection::Up, this); + enter_slide_->setRange(static_cast(-kEnterSlidePx), 0.0f); + enter_slide_->setMotionToken("longEnter"); + 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 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 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..23ea4e1da --- /dev/null +++ b/ui/components/settings/settings_window.cpp @@ -0,0 +1,481 @@ +/** + * @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 "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" +#include "ui/widget/material/widget/tabview/tabview.h" + +#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 kWindowWidth = 720; ///< Window width (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: 16px;" + " background: transparent; border: none;"; +constexpr const char* kValueQss = "color: #1abc9c; font-size: 14px;" + " background: transparent; border: none;"; +constexpr const char* kHintQss = "color: #bdc3c7; font-size: 13px;" + " 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: 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). +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(); +} + +/// @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) { + 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(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")); + root->addWidget(tabs); +} + +QWidget* SettingsWindow::buildWallpaperTab() { + auto* tab = new QWidget(this); + auto* layout = new QVBoxLayout(tab); + 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); + } + + // --- 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); + } + + // --- 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); + + // 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(24, 24, 24, 24); + layout->setSpacing(20); + + QString current_name; + try { + current_name = + QString::fromStdString(qw::core::ThemeManager::instance().currentThemeName()); + } catch (...) { + current_name = QString::fromLatin1(MATERIAL_THEME_LIGHT); + } + const bool dark = current_name == QString::fromLatin1(MATERIAL_THEME_DARK); + + // 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; + 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, row); + auto* dark_btn = + new Button(QStringLiteral("Dark"), + dark ? Button::ButtonVariant::Filled : Button::ButtonVariant::Tonal, row); + h->addWidget(light_btn); + h->addWidget(dark_btn); + h->addStretch(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); + 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(MATERIAL_THEME_DARK); + dark_btn->setVariant(Button::ButtonVariant::Filled); + light_btn->setVariant(Button::ButtonVariant::Tonal); + }); + + return tab; +} + +QWidget* SettingsWindow::buildAboutTab() { + auto* tab = new QWidget(this); + auto* layout = new QVBoxLayout(tab); + 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: 20px; 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); + // 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; +} + +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 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(); 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