From 9b337a0e1395a0b1466f0577a32ddcdfa979987c Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Wed, 1 Jul 2026 09:25:11 +0800 Subject: [PATCH 1/4] feat(launcher): add app search + .desktop indexing + port Noter MS4 launcher closes up: search box, system-app discovery, and a second standalone App ported from CCIMXDesktop. - DesktopEntryIndex: scan XDG .desktop (~/.local/share/applications + /usr/share/applications), parse Type=Application && !NoDisplay, strip Exec %-fields (firefox %u -> firefox). loadAppsConfig merges builtin + manifest + .desktop sources. - AppLauncher: QLineEdit search box, textChanged live-filters the grid by display_name (case-insensitive). - Noter app (apps/noter/): ported from CCIMXNoter, QuarkWidgets MD3 Button toolbar (Open/Save/Bold/Italic + size slider) + QTextEdit, manifest launch_kind=auto. Second standalone App, validates that the migration recipe (calculator was first) reproduces. - Tests: DesktopEntryIndex 7 cases (parse / NoDisplay / non-Application / Exec cleanup / basename fallback); full suite green. MS4 launcher now closes (grid + search + launch); only entry/exit animation remains. --- apps/CMakeLists.txt | 1 + apps/noter/CMakeLists.txt | 25 +++ apps/noter/app.json | 6 + apps/noter/main.cpp | 27 +++ apps/noter/noter_panel.cpp | 154 ++++++++++++++++++ apps/noter/noter_panel.h | 96 +++++++++++ desktop/ui/CFDesktopEntity.cpp | 8 +- desktop/ui/components/launcher/CMakeLists.txt | 1 + .../ui/components/launcher/app_launcher.cpp | 28 +++- desktop/ui/components/launcher/app_launcher.h | 8 +- .../launcher/desktop_entry_index.cpp | 148 +++++++++++++++++ .../components/launcher/desktop_entry_index.h | 73 +++++++++ document/status/current.md | 3 +- .../todo/desktop/milestone_04_app_launcher.md | 18 +- test/desktop/launcher/CMakeLists.txt | 16 ++ .../launcher/desktop_entry_index_test.cpp | 90 ++++++++++ 16 files changed, 691 insertions(+), 11 deletions(-) create mode 100644 apps/noter/CMakeLists.txt create mode 100644 apps/noter/app.json create mode 100644 apps/noter/main.cpp create mode 100644 apps/noter/noter_panel.cpp create mode 100644 apps/noter/noter_panel.h create mode 100644 desktop/ui/components/launcher/desktop_entry_index.cpp create mode 100644 desktop/ui/components/launcher/desktop_entry_index.h create mode 100644 test/desktop/launcher/desktop_entry_index_test.cpp diff --git a/apps/CMakeLists.txt b/apps/CMakeLists.txt index f009adf28..404ad0112 100644 --- a/apps/CMakeLists.txt +++ b/apps/CMakeLists.txt @@ -3,3 +3,4 @@ # AppLaunchService (QProcess) — they are not linked into the desktop shell, # so a crash or hang in an app cannot take down the desktop. add_subdirectory(calculator) +add_subdirectory(noter) diff --git a/apps/noter/CMakeLists.txt b/apps/noter/CMakeLists.txt new file mode 100644 index 000000000..3ddaafd60 --- /dev/null +++ b/apps/noter/CMakeLists.txt @@ -0,0 +1,25 @@ +# Noter standalone application. +# Ported from CCIMXDesktop's CCIMXNoter; UI uses QuarkWidgets MD3 widgets. +# CFDesktop launches this binary via AppLaunchService (QProcess), so it runs in +# its own process (isolated from the desktop shell). + +# Standalone Noter executable (consumed by CFDesktop via QProcess launch). +qt_add_executable(noter + main.cpp + noter_panel.cpp +) + +target_link_libraries(noter PRIVATE + QuarkWidgets::quarkwidgets + Qt6::Widgets +) + +# App package is self-contained under /../apps/noter/ (executable + +# manifest in the same dir). AppDiscoverer scans /../apps//app.json. +set(NOTER_APP_DIR "${CMAKE_BINARY_DIR}/apps/noter") +set_target_properties(noter PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${NOTER_APP_DIR}" +) + +# Deploy the manifest next to the executable (at configure time). +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/app.json" "${NOTER_APP_DIR}/app.json" COPYONLY) diff --git a/apps/noter/app.json b/apps/noter/app.json new file mode 100644 index 000000000..7335e2d41 --- /dev/null +++ b/apps/noter/app.json @@ -0,0 +1,6 @@ +{ + "app_id": "noter", + "display_name": "Noter", + "exec": "noter", + "launch_kind": "auto" +} diff --git a/apps/noter/main.cpp b/apps/noter/main.cpp new file mode 100644 index 000000000..83b8d0e2c --- /dev/null +++ b/apps/noter/main.cpp @@ -0,0 +1,27 @@ +/** + * @file apps/noter/main.cpp + * @brief Standalone Noter application entry point. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup noter + */ + +#include "noter_panel.h" + +#include +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QCoreApplication::setApplicationName(QStringLiteral("CFDesktop Noter")); + + cf::desktop::desktop_component::NoterPanel panel; + panel.setWindowTitle(QStringLiteral("Noter")); + panel.resize(480, 560); + panel.show(); + + return app.exec(); +} diff --git a/apps/noter/noter_panel.cpp b/apps/noter/noter_panel.cpp new file mode 100644 index 000000000..46e7060e4 --- /dev/null +++ b/apps/noter/noter_panel.cpp @@ -0,0 +1,154 @@ +/** + * @file apps/noter/noter_panel.cpp + * @brief Implementation of the Noter panel. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup noter + */ + +#include "noter_panel.h" + +#include "ui/widget/material/widget/button/button.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +using qw::widget::material::Button; +using Variant = Button::ButtonVariant; + +namespace { +constexpr qreal kCornerRadius = 16.0; ///< Card corner radius (px). +constexpr int kMinFontSize = 8; ///< Minimum font size (pt). +constexpr int kMaxFontSize = 40; ///< Maximum font size (pt). +constexpr int kDefaultFontSize = 15; ///< Initial font size (pt). +} // namespace + +NoterPanel::NoterPanel(QWidget* parent) : QWidget(parent) { + setupUi(); +} + +NoterPanel::~NoterPanel() = default; + +void NoterPanel::setupUi() { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(12, 12, 12, 12); + layout->setSpacing(8); + + // Toolbar: Open | Save | B | I | size slider | size readout. + auto* toolbar = new QHBoxLayout; + toolbar->setSpacing(8); + + auto* open_btn = new Button(QStringLiteral("Open"), Variant::Outlined, this); + auto* save_btn = new Button(QStringLiteral("Save"), Variant::Outlined, this); + auto* bold_btn = new Button(QStringLiteral("B"), Variant::Tonal, this); + bold_btn->setCheckable(true); + auto* italic_btn = new Button(QStringLiteral("I"), Variant::Tonal, this); + italic_btn->setCheckable(true); + + font_slider_ = new QSlider(Qt::Horizontal, this); + font_slider_->setMinimum(kMinFontSize); + font_slider_->setMaximum(kMaxFontSize); + font_slider_->setValue(kDefaultFontSize); + font_slider_->setFixedWidth(120); + + size_label_ = new QLabel(QString::number(kDefaultFontSize), this); + size_label_->setFixedWidth(28); + + toolbar->addWidget(open_btn); + toolbar->addWidget(save_btn); + toolbar->addWidget(bold_btn); + toolbar->addWidget(italic_btn); + toolbar->addWidget(font_slider_); + toolbar->addWidget(size_label_); + toolbar->addStretch(); + layout->addLayout(toolbar); + + // Editor. + editor_ = new QTextEdit(this); + editor_->setFontPointSize(kDefaultFontSize); + layout->addWidget(editor_); + + connect(open_btn, &QPushButton::clicked, this, &NoterPanel::onOpen); + connect(save_btn, &QPushButton::clicked, this, &NoterPanel::onSave); + connect(font_slider_, &QSlider::valueChanged, this, &NoterPanel::onFontSizeChanged); + connect(bold_btn, &QPushButton::toggled, this, &NoterPanel::onBoldToggled); + connect(italic_btn, &QPushButton::toggled, this, &NoterPanel::onItalicToggled); +} + +void NoterPanel::applyCharFormat(const QTextCharFormat& format) { + QTextCursor cursor = editor_->textCursor(); + if (!cursor.hasSelection()) { + cursor.select(QTextCursor::WordUnderCursor); + } + cursor.mergeCharFormat(format); + editor_->mergeCurrentCharFormat(format); +} + +void NoterPanel::onOpen() { + const QString file_name = QFileDialog::getOpenFileName(this, tr("Open File")); + if (file_name.isEmpty()) { + return; + } + QFile file(file_name); + if (file.open(QIODevice::ReadOnly | QIODevice::Text)) { + QTextStream in(&file); + editor_->setPlainText(in.readAll()); + } +} + +void NoterPanel::onSave() { + const QString file_name = QFileDialog::getSaveFileName(this, tr("Save File")); + if (file_name.isEmpty()) { + return; + } + QFile file(file_name); + if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream out(&file); + out << editor_->toPlainText(); + } +} + +void NoterPanel::onFontSizeChanged(int size) { + size_label_->setText(QString::number(size)); + QTextCharFormat format; + format.setFontPointSize(size); + applyCharFormat(format); +} + +void NoterPanel::onBoldToggled(bool checked) { + QTextCharFormat format; + format.setFontWeight(checked ? QFont::Bold : QFont::Normal); + applyCharFormat(format); +} + +void NoterPanel::onItalicToggled(bool checked) { + QTextCharFormat format; + format.setFontItalic(checked); + applyCharFormat(format); +} + +void NoterPanel::paintEvent(QPaintEvent* /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + const QColor surface_color(0xF7, 0xF5, 0xF3); + QPainterPath card; + card.addRoundedRect(QRectF(rect()), kCornerRadius, kCornerRadius); + p.fillPath(card, surface_color); +} + +} // namespace cf::desktop::desktop_component diff --git a/apps/noter/noter_panel.h b/apps/noter/noter_panel.h new file mode 100644 index 000000000..ca147034f --- /dev/null +++ b/apps/noter/noter_panel.h @@ -0,0 +1,96 @@ +/** + * @file apps/noter/noter_panel.h + * @brief Noter main panel (standalone app). + * + * Root widget of the standalone Noter executable. Plain-text editor with + * basic font formatting (size/bold/italic) and open/save, rendered with + * QuarkWidgets MD3 buttons. Ported from CCIMXDesktop's CCIMXNoter. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup noter + */ + +#pragma once + +#include + +class QKeyEvent; +class QLabel; +class QPaintEvent; +class QSlider; +class QTextCharFormat; +class QTextEdit; + +namespace qw::widget::material { +class Button; +} + +namespace cf::desktop::desktop_component { + +/** + * @brief Root widget of the standalone Noter application. + * + * @ingroup noter + */ +class NoterPanel final : public QWidget { + Q_OBJECT + public: + /** + * @brief Constructs the Noter panel. + * + * @param[in] parent Parent widget (nullptr for a top-level window). + * + * @throws None + * @since 0.20 + * @ingroup noter + */ + explicit NoterPanel(QWidget* parent = nullptr); + + /** + * @brief Destructs the panel. + * + * @throws None + * @since 0.20 + * @ingroup noter + */ + ~NoterPanel() override; + + protected: + /** + * @brief Paints the rounded Material card background. + * + * @param[in] event The paint event descriptor. + * + * @throws None + * @since 0.20 + * @ingroup noter + */ + void paintEvent(QPaintEvent* event) override; + + private slots: + /// @brief Opens a file dialog and loads text into the editor. + void onOpen(); + /// @brief Opens a file dialog and saves the editor text. + void onSave(); + /// @brief Applies the slider font size to the current selection. + void onFontSizeChanged(int size); + /// @brief Toggles bold on the current selection. + void onBoldToggled(bool checked); + /// @brief Toggles italic on the current selection. + void onItalicToggled(bool checked); + + private: + /// @brief Builds the toolbar + editor layout. + void setupUi(); + /// @brief Merges a char format onto the current word or selection. + void applyCharFormat(const QTextCharFormat& format); + + QTextEdit* editor_{nullptr}; ///< Plain-text edit area. + QSlider* font_slider_{nullptr}; ///< Font size slider (8..40). + QLabel* size_label_{nullptr}; ///< Current font size readout. +}; + +} // namespace cf::desktop::desktop_component diff --git a/desktop/ui/CFDesktopEntity.cpp b/desktop/ui/CFDesktopEntity.cpp index 6806d9bfa..ffc76537b 100644 --- a/desktop/ui/CFDesktopEntity.cpp +++ b/desktop/ui/CFDesktopEntity.cpp @@ -16,6 +16,7 @@ #include "components/launcher/app_discoverer.h" #include "components/launcher/app_launch_service.h" #include "components/launcher/app_launcher.h" +#include "components/launcher/desktop_entry_index.h" #include "components/statusbar/status_bar.h" #include "components/taskbar/centered_taskbar.h" #include "components/window_placement/window_placement_policy.h" @@ -88,7 +89,12 @@ QList loadAppsConfig(bool prefer_inprocess) { upsert(entry); } - // 3. Legacy fallback when nothing is discovered: /../apps.json, + // 3. XDG .desktop entries (system applications: firefox, etc.). + for (auto& entry : desktop_component::DesktopEntryIndex::index()) { + upsert(entry); + } + + // 4. Legacy fallback when nothing is discovered: /../apps.json, // then defaultApps() placeholder entries. if (discovered.isEmpty()) { const QString path = diff --git a/desktop/ui/components/launcher/CMakeLists.txt b/desktop/ui/components/launcher/CMakeLists.txt index 23ba3d4aa..0e0aaca99 100644 --- a/desktop/ui/components/launcher/CMakeLists.txt +++ b/desktop/ui/components/launcher/CMakeLists.txt @@ -1,6 +1,7 @@ # App launcher service (QProcess-based external application launching). add_library(cfdesktop_launcher STATIC app_discoverer.cpp + desktop_entry_index.cpp app_launch_service.cpp app_launcher.cpp launcher_tile.cpp diff --git a/desktop/ui/components/launcher/app_launcher.cpp b/desktop/ui/components/launcher/app_launcher.cpp index 4ac9d0036..2b6404b6c 100644 --- a/desktop/ui/components/launcher/app_launcher.cpp +++ b/desktop/ui/components/launcher/app_launcher.cpp @@ -24,10 +24,12 @@ #include #include #include +#include #include #include #include #include +#include #include @@ -132,10 +134,23 @@ void AppLauncher::keyPressEvent(QKeyEvent* event) { // -- Internal -------------------------------------------------------------- void AppLauncher::setupUi() { - grid_ = new QGridLayout(this); + auto* outer = new QVBoxLayout(this); + outer->setContentsMargins(kMargin, kMargin, kMargin, kMargin); + outer->setSpacing(kGridSpacing); + + search_edit_ = new QLineEdit(this); + search_edit_->setPlaceholderText(QStringLiteral("Search apps...")); + outer->addWidget(search_edit_); + + auto* grid_container = new QWidget(this); + grid_ = new QGridLayout(grid_container); grid_->setSpacing(kGridSpacing); - grid_->setContentsMargins(kMargin, kMargin, kMargin, kMargin); grid_->setAlignment(Qt::AlignTop | Qt::AlignHCenter); + outer->addWidget(grid_container); + + // Live filter: any change to the search box rebuilds the grid with only + // tiles whose display_name contains the (case-insensitive) query. + connect(search_edit_, &QLineEdit::textChanged, this, [this](const QString&) { rebuildGrid(); }); } void AppLauncher::applyTheme() { @@ -157,11 +172,14 @@ void AppLauncher::rebuildGrid() { qDeleteAll(tiles_); tiles_.clear(); - const int n = apps_.size(); - const int cols = std::max(1, std::min(kMaxColumns, n)); + const QString filter = + search_edit_ != nullptr ? search_edit_->text().trimmed().toLower() : QString(); int row = 0; int col = 0; for (const auto& app : apps_) { + if (!filter.isEmpty() && !app.display_name.toLower().contains(filter)) { + continue; // Filtered out by the search box. + } auto* tile = new LauncherTile(app, this); connect(tile, &LauncherTile::clicked, this, [this](const QString& app_id) { emit appLaunched(app_id); @@ -170,7 +188,7 @@ void AppLauncher::rebuildGrid() { grid_->addWidget(tile, row, col); tiles_.append(tile); ++col; - if (col >= cols) { + if (col >= kMaxColumns) { col = 0; ++row; } diff --git a/desktop/ui/components/launcher/app_launcher.h b/desktop/ui/components/launcher/app_launcher.h index 91fd1531a..dc0b0dd85 100644 --- a/desktop/ui/components/launcher/app_launcher.h +++ b/desktop/ui/components/launcher/app_launcher.h @@ -28,6 +28,7 @@ class QGridLayout; class QKeyEvent; +class QLineEdit; class QPaintEvent; class QRect; @@ -181,9 +182,10 @@ class AppLauncher final : public QWidget { /// @brief Rebuilds the tile grid from apps_. void rebuildGrid(); - QGridLayout* grid_{nullptr}; ///< Tile grid. Ownership: this widget. - QList tiles_; ///< Current tiles. Ownership: Qt parented. - QList apps_; ///< Backing application list. + QLineEdit* search_edit_{nullptr}; ///< Filter box (ownership: this widget). + QGridLayout* grid_{nullptr}; ///< Tile grid (ownership: grid container). + QList tiles_; ///< Current tiles. Ownership: Qt parented. + QList apps_; ///< Backing application list. QColor surface_color_; ///< Popup background fill (surface). QColor outline_color_; ///< Reserved for future border (outline variant). diff --git a/desktop/ui/components/launcher/desktop_entry_index.cpp b/desktop/ui/components/launcher/desktop_entry_index.cpp new file mode 100644 index 000000000..776e761c2 --- /dev/null +++ b/desktop/ui/components/launcher/desktop_entry_index.cpp @@ -0,0 +1,148 @@ +/** + * @file desktop/ui/components/launcher/desktop_entry_index.cpp + * @brief Implementation of DesktopEntryIndex. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "desktop_entry_index.h" + +#include "cflog.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// Tag for DesktopEntryIndex log lines. +constexpr const char* kLogTag = "DesktopEntryIndex"; +/// The only section parsed in a .desktop file. +constexpr const char* kDesktopSection = "[Desktop Entry]"; + +/** + * @brief Strips freedesktop %-fields from an Exec= value. + * + * "firefox %u" -> "firefox"; "code --foo %F" -> "code --foo". Tokens starting + * with '%' are dropped per the freedesktop Exec field spec. + * + * @param[in] exec The raw Exec= value. + * + * @return The cleaned command string. + */ +QString cleanExec(const QString& exec) { + QStringList parts = QProcess::splitCommand(exec); + QStringList kept; + kept.reserve(parts.size()); + for (const auto& part : parts) { + if (part.startsWith(QLatin1Char('%'))) { + continue; + } + kept.append(part); + } + return kept.join(QLatin1Char(' ')); +} + +/** + * @brief Parses one .desktop file into an AppEntry. + * + * @param[in] path Absolute path to the .desktop file. + * + * @return AppEntry with launch_kind=DetachedProcess, or an empty + * entry (empty app_id) when Type!=Application or NoDisplay=true + * or the file is unreadable. + */ +AppEntry parseDesktopFile(const QString& path) { + AppEntry entry; // empty by default; launch_kind defaults DetachedProcess. + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return entry; + } + entry.app_id = QFileInfo(path).completeBaseName(); // "firefox.desktop" -> "firefox" + + bool in_section = false; + bool is_application = false; + bool no_display = false; + QTextStream in(&file); + while (!in.atEnd()) { + const QString raw = in.readLine(); + const QString line = raw.trimmed(); + if (line.isEmpty() || line.startsWith(QLatin1Char('#'))) { + continue; + } + if (line.startsWith(QLatin1Char('[')) && line.endsWith(QLatin1Char(']'))) { + in_section = (line == QLatin1String(kDesktopSection)); + continue; + } + if (!in_section) { + continue; + } + const int eq = line.indexOf(QLatin1Char('=')); + if (eq < 0) { + continue; + } + const QString key = line.left(eq).trimmed(); + const QString value = line.mid(eq + 1).trimmed(); + if (key == QLatin1String("Type")) { + is_application = (value == QLatin1String("Application")); + } else if (key == QLatin1String("NoDisplay")) { + no_display = (value == QLatin1String("true")); + } else if (key == QLatin1String("Name") && entry.display_name.isEmpty()) { + entry.display_name = value; + } else if (key == QLatin1String("Icon")) { + entry.icon_path = value; // theme name or absolute path; unresolved here. + } else if (key == QLatin1String("Exec")) { + entry.exec_command = cleanExec(value); + } + } + + if (!is_application || no_display) { + return AppEntry{}; // signal skip via empty app_id. + } + if (entry.display_name.isEmpty()) { + entry.display_name = entry.app_id; // fall back to basename. + } + return entry; +} +} // namespace + +QList DesktopEntryIndex::index() { + QList result; + const QStringList dirs = { + QDir::homePath() + QStringLiteral("/.local/share/applications"), + QStringLiteral("/usr/share/applications"), + }; + for (const auto& dir : dirs) { + result.append(indexFrom(dir)); + } + return result; +} + +QList DesktopEntryIndex::indexFrom(const QString& dir) { + QList result; + QDir d(dir); + if (!d.exists()) { + return result; + } + const QFileInfoList files = + d.entryInfoList(QStringList() << QStringLiteral("*.desktop"), QDir::Files); + for (const auto& info : files) { + AppEntry entry = parseDesktopFile(info.absoluteFilePath()); + if (entry.app_id.isEmpty() || entry.exec_command.isEmpty()) { + continue; // unreadable, non-Application, or NoDisplay. + } + result.append(entry); + } + return result; +} + +} // namespace cf::desktop::desktop_component diff --git a/desktop/ui/components/launcher/desktop_entry_index.h b/desktop/ui/components/launcher/desktop_entry_index.h new file mode 100644 index 000000000..e79ee88b6 --- /dev/null +++ b/desktop/ui/components/launcher/desktop_entry_index.h @@ -0,0 +1,73 @@ +/** + * @file desktop/ui/components/launcher/desktop_entry_index.h + * @brief Indexes XDG .desktop entries into AppEntry values. + * + * DesktopEntryIndex scans freedesktop .desktop files (Type=Application, + * NoDisplay!=true) and returns AppEntry values with launch_kind=DetachedProcess, + * so the launcher grid can surface system applications (firefox, etc.) the + * user already has installed, alongside CFDesktop's own manifest apps. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#pragma once + +#include "../app_entry.h" + +#include +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief Indexes XDG .desktop entries into AppEntry values. + * + * The discovery contract: + * - A .desktop file's [Desktop Entry] section is parsed. + * - Only Type=Application with NoDisplay!=true is indexed. + * - app_id = file basename without ".desktop" (e.g. "firefox.desktop" -> "firefox"). + * - display_name = Name=, icon_path = Icon= (theme name or path, unresolved), + * exec_command = Exec= with %-fields stripped (e.g. "firefox %u" -> "firefox"). + * + * @ingroup components + */ +class DesktopEntryIndex { + public: + DesktopEntryIndex() = delete; + + /** + * @brief Indexes default XDG application dirs. + * + * Scans @c ~/.local/share/applications and @c /usr/share/applications. + * Missing dirs contribute nothing (no error). Returns DetachedProcess + * AppEntry values for each valid Application-type .desktop. + * + * @return Indexed AppEntry list; empty if no .desktop files are found. + * + * @throws None. + * + * @since 0.20 + * @ingroup components + */ + static QList index(); + + /** + * @brief Indexes .desktop files under @p dir. + * + * @param[in] dir Directory containing *.desktop files. + * + * @return Parsed AppEntry list. + * + * @throws None. + * @note Used by index(); exposed for unit tests. + * @since 0.20 + * @ingroup components + */ + static QList indexFrom(const QString& dir); +}; + +} // namespace cf::desktop::desktop_component diff --git a/document/status/current.md b/document/status/current.md index 39f07ee40..7eb762fd2 100644 --- a/document/status/current.md +++ b/document/status/current.md @@ -49,7 +49,7 @@ description: CFDesktop 项目进度的唯一事实来源与全局导航。 1. **MS2 状态栏**(顶部时间 + 系统图标)— ✅ 功能落地(`StatusBar` 实现 + 注册 PanelManager + 主题跟随 + MD3 美化;offscreen 启动通过,待真机视觉确认) 2. **MS3 任务栏**(底部居中图标条 + hover 动画)— ✅ 最小切片完成(`CenteredTaskbar` 注册 Bottom 面板 + `TaskbarIcon` 居中图标/hover 放大/自绘 ripple/运行指示器;`appClicked` 已接 `AppLaunchService::launch` 真启动并记 PID,运行指示器经 `WindowManager` 窗口追踪联动——见 `desktop/ui/CFDesktopEntity.cpp:133-180`) -3. **MS4 应用启动器**(应用网格 + QProcess 启动 + 双态框架)— 🚧 进行中(启动闭环 + 双态框架落地:`AppLaunchService` 真启动;`LaunchKind`/`IBuiltinPanel`/`BuiltinPanelRegistry` 把 `builtin:` hack 正式化、消灭 if 链,calculator 双态,`HardwareTier::prefer_inprocess_apps` 按 Low/High 裁决 manifest `launch_kind:"auto"`;开始菜单弹窗网格待做。详见 [milestone_04 §六](../todo/desktop/milestone_04_app_launcher.md)) +3. **MS4 应用启动器**(网格 + 搜索 + QProcess + 双态)— 🚧 进行中(双态框架 + 网格 + 搜索框落地:`LaunchKind`/`IBuiltinPanel`/`BuiltinPanelRegistry` 把 `builtin:` hack 正式化、消灭 if 链,calculator 双态,`HardwareTier::prefer_inprocess_apps` 按 Low/High 裁决;`AppLauncher` 网格 + 搜索 QLineEdit 实时过滤;`DesktopEntryIndex` 扫 XDG `.desktop`(firefox 等);Noter 移植成第二个独立 App。仅余入场/退场动画。详见 [milestone_04](../todo/desktop/milestone_04_app_launcher.md)) 4. **MS5 窗口管理**(窗口装饰 + 任务栏联动)— 🚧 进行中(追踪+联动切片跑通:`WindowManager` 追踪外部窗口 + `IWindow::pid()` + Taskbar 运行指示器联动;靠 PID 匹配,直接启动(xterm)可靠、间接启动(xdg-open)受限;**窗口装饰已定策→overlay 渲染**〔策略 A:CFDesktop 自绘 overlay 层呈现标题栏+控制按钮作纯视觉指示,因 WSL X11 客户端模式下外部窗口由 XWayland 管理无法直接装饰;X11 WM〔B〕/ Wayland Compositor〔C〕延后到 EGLFS/Wayland 后端,详见 [milestone_05](../todo/desktop/milestone_05_window_management.md):157-160〕) 闭环达成后按需推进:CrashHandler、IPC、EGLFS 嵌入式后端、输入抽象层、P2/P3 控件。 @@ -63,6 +63,7 @@ description: CFDesktop 项目进度的唯一事实来源与全局导航。 - **2026-06(首个独立 App:Calculator)**:移植 CCIMXDesktop `Caculator` 为**独立可执行**(`apps/calculator/`)—— parser(递归下降 AST)保留 QString 建 `cfdesktop_calculator_parser` lib(53 例单测);UI cfui MD3(`Button` 网格 + `Label`)重写;CFDesktop 经 `AppLaunchService::launch`(QProcess)启动(隔离进程),`AppLaunchService` 加 `applicationDirPath()` 解析(自家 app 同 `bin/` 优先)。确立「工具型 App → 独立可执行」范式(展示型 about 仍 builtin)。 - **2026-06(App 发现机制)**:`AppDiscoverer` 扫描 `/../apps//app.json` 自动发现注册 app(manifest:app_id/display_name/icon/exec);`loadAppsConfig` fallback 链 **discover → apps.json → defaultApps**;calculator 改首个 manifest app(包自包含 `apps/calculator/{calculator, app.json}`)。App 即插即用,无需 recompile。 - **2026-07(双态框架正式化)**:独立进程 + 进程内 builtin 两条腿**正式化**——`LaunchKind{Auto,DetachedProcess,BuiltinPanel}` 替代 `builtin:` 字符串 hack;`IBuiltinPanel` 接口 + `BuiltinPanelRegistry`(map-based 自写,aex 无 named 变体)消灭 `if(id==about)` 链;`loadAppsConfig` 合并 builtin+discovered 两源,`HardwareTierCapabilities::prefer_inprocess_apps`(Low=true)把 manifest `launch_kind:"auto"` 按 tier 裁决(Low→builtin,High→detached,无 builtin 实现则降级 detached+记日志);calculator 双态验证(`CalculatorBuiltinPanel` 组合适配器,同一份 panel 源码两种加载);补 AboutPanel 缺失入口(原 `builtin:about` 点不出)。架构债:desktop 引用 apps/calculator 源文件,待迁中立层([milestone_07](../todo/desktop/milestone_07_app_ecosystem.md))。 +- **2026-07(MS4 搜索 + .desktop + Noter 移植)**:`DesktopEntryIndex` 扫 `~/.local/share/applications` + `/usr/share/applications` 的 `.desktop`(freedesktop:Type=Application && !NoDisplay,Exec 清理 `%` 占位符)→ 进网格作 DetachedProcess;`AppLauncher` 加搜索 QLineEdit,`textChanged` 实时按 display_name 模糊过滤;移植 CCIMXNoter → `apps/noter/`(QuarkWidgets MD3 Button 重写工具栏 + QTextEdit 编辑,manifest `launch_kind:auto`),第二个独立 App 验证移植范式可复制。MS4 launcher 闭环(网格+搜索+启动),仅余入场动画。 - **已达成**:Milestone 1「桌面骨架可见」;Phase 0 / 1 / 2 / A(CI) / 6 / G(Widget) / H(显示后端)(详见 [SUMMARY.md](../todo/done/SUMMARY.md)) ## 新人入门 diff --git a/document/todo/desktop/milestone_04_app_launcher.md b/document/todo/desktop/milestone_04_app_launcher.md index 0078817ad..45180800d 100644 --- a/document/todo/desktop/milestone_04_app_launcher.md +++ b/document/todo/desktop/milestone_04_app_launcher.md @@ -262,4 +262,20 @@ desktop 编译期引用 `apps/calculator/calculator_panel.cpp` 源文件 + `cfde --- -*最后更新: 2026-07-01(builtin 正式化 + 双态框架 + auto 裁决落地,`feat/app-dual-mode` 分支;第三方 App 平台路线见 milestone_07)* +## 七、搜索 + .desktop 扫描 + Noter 移植(2026-07 落地) + +> **状态**: ✅ 已落地(`feat/noter-and-search` 分支) + +- **`DesktopEntryIndex`**([desktop_entry_index.{h,cpp}](../../../desktop/ui/components/launcher/)):扫 XDG `.desktop`(`~/.local/share/applications` + `/usr/share/applications`),freedesktop Type=Application && !NoDisplay,Exec 清理 `%` 占位符(`firefox %u`→`firefox`),app_id=文件 basename,进网格作 DetachedProcess。`loadAppsConfig` 合并 builtin + manifest + .desktop 三源 +- **AppLauncher 搜索框**:加 QLineEdit,`textChanged` 实时按 display_name 模糊过滤网格(大小写不敏感,不区分平台) +- **Noter 移植**([apps/noter/](../../../apps/noter/)):CCIMXNoter → CFDesktop 第二个独立 App,QuarkWidgets MD3 Button 重写工具栏(Open/Save/Bold/Italic + 字号 QSlider),QTextEdit 编辑,manifest `launch_kind:auto` +- 单测:DesktopEntryIndex 7 例(解析/NoDisplay/非 Application/Exec 清理/basename 回退),全过 + +### 待做 + +- 入场/退场动画(Day 5 原计划:Slide+Fade 组合,复用 `ui/components/animation/`) +- 搜索框换 QuarkWidgets MD3 TextField(现用 QLineEdit 占位) + +--- + +*最后更新: 2026-07-01(搜索 + .desktop + Noter 移植落地,`feat/noter-and-search` 分支;前置双态框架见 §六;第三方平台见 milestone_07)* diff --git a/test/desktop/launcher/CMakeLists.txt b/test/desktop/launcher/CMakeLists.txt index 6c85e517b..7eefda736 100644 --- a/test/desktop/launcher/CMakeLists.txt +++ b/test/desktop/launcher/CMakeLists.txt @@ -19,3 +19,19 @@ add_gtest_executable( LABELS "desktop;unit;launcher" LOG_MODULE launcher_tests ) + +add_gtest_executable( + TEST_NAME desktop_entry_index_test + SOURCE_FILE desktop_entry_index_test.cpp + LINK_LIBRARIES + cfdesktop_launcher + cflogger + cfbase + Qt6::Core + Qt6::Widgets + GTest::gtest + GTest::gtest_main + INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/desktop/ui/components + LABELS "desktop;unit;launcher" + LOG_MODULE launcher_tests +) diff --git a/test/desktop/launcher/desktop_entry_index_test.cpp b/test/desktop/launcher/desktop_entry_index_test.cpp new file mode 100644 index 000000000..04d9ca81d --- /dev/null +++ b/test/desktop/launcher/desktop_entry_index_test.cpp @@ -0,0 +1,90 @@ +/** + * @file test/desktop/launcher/desktop_entry_index_test.cpp + * @brief GoogleTest unit tests for DesktopEntryIndex. + * + * Covers: empty/missing dir, valid Application parse (Exec %-fields stripped, + * icon/exec resolved), skipping NoDisplay, skipping non-Application types. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "launcher/desktop_entry_index.h" + +#include +#include +#include + +#include + +using cf::desktop::desktop_component::DesktopEntryIndex; +using cf::desktop::desktop_component::LaunchKind; + +namespace { +/// Writes a .desktop file body to /. +void writeDesktop(const QString& dir, const QString& name, const QString& body) { + QFile f(dir + QLatin1Char('/') + name); + f.open(QIODevice::WriteOnly); + f.write(body.toUtf8()); +} +} // namespace + +TEST(DesktopEntryIndex, EmptyDirReturnsEmpty) { + QTemporaryDir tmp; + EXPECT_TRUE(DesktopEntryIndex::indexFrom(tmp.path()).isEmpty()); +} + +TEST(DesktopEntryIndex, NonexistentDirReturnsEmpty) { + EXPECT_TRUE( + DesktopEntryIndex::indexFrom(QStringLiteral("/nonexistent/cfdesktop/desktop")).isEmpty()); +} + +TEST(DesktopEntryIndex, ParsesApplicationEntry) { + QTemporaryDir tmp; + writeDesktop( + tmp.path(), QStringLiteral("firefox.desktop"), + "[Desktop Entry]\nType=Application\nName=Firefox\nExec=firefox %u\nIcon=firefox\n"); + const auto apps = DesktopEntryIndex::indexFrom(tmp.path()); + ASSERT_EQ(apps.size(), 1); + EXPECT_EQ(apps[0].app_id.toStdString(), "firefox"); + EXPECT_EQ(apps[0].display_name.toStdString(), "Firefox"); + EXPECT_EQ(apps[0].exec_command.toStdString(), "firefox"); // %u stripped + EXPECT_EQ(apps[0].icon_path.toStdString(), "firefox"); + EXPECT_EQ(apps[0].launch_kind, LaunchKind::DetachedProcess); +} + +TEST(DesktopEntryIndex, SkipsNoDisplay) { + QTemporaryDir tmp; + writeDesktop(tmp.path(), QStringLiteral("hidden.desktop"), + "[Desktop Entry]\nType=Application\nName=Hidden\nExec=hidden\nNoDisplay=true\n"); + EXPECT_TRUE(DesktopEntryIndex::indexFrom(tmp.path()).isEmpty()); +} + +TEST(DesktopEntryIndex, SkipsNonApplication) { + QTemporaryDir tmp; + writeDesktop(tmp.path(), QStringLiteral("link.desktop"), + "[Desktop Entry]\nType=Link\nName=Link\nURL=http://example.com\n"); + EXPECT_TRUE(DesktopEntryIndex::indexFrom(tmp.path()).isEmpty()); +} + +TEST(DesktopEntryIndex, CleansMultipleExecFields) { + QTemporaryDir tmp; + writeDesktop(tmp.path(), QStringLiteral("code.desktop"), + "[Desktop Entry]\nType=Application\nName=Code\nExec=code --foo %F\n"); + const auto apps = DesktopEntryIndex::indexFrom(tmp.path()); + ASSERT_EQ(apps.size(), 1); + EXPECT_EQ(apps[0].exec_command.toStdString(), "code --foo"); // %F stripped, --foo kept +} + +TEST(DesktopEntryIndex, FallsBackToBasenameWhenNameMissing) { + QTemporaryDir tmp; + writeDesktop(tmp.path(), QStringLiteral("ghost.desktop"), + "[Desktop Entry]\nType=Application\nExec=ghost\n"); // no Name= + const auto apps = DesktopEntryIndex::indexFrom(tmp.path()); + ASSERT_EQ(apps.size(), 1); + EXPECT_EQ(apps[0].app_id.toStdString(), "ghost"); + EXPECT_EQ(apps[0].display_name.toStdString(), "ghost"); // falls back to basename +} From b833e13f298d96fa64e115ade432424217fb0abe Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Wed, 1 Jul 2026 11:43:03 +0800 Subject: [PATCH 2/4] feat(apps): port SystemState, AlarmyClock, Calendar from CCIMXDesktop Three standalone apps migrated in parallel (agent-driven), validating that the Noter migration recipe reproduces at scale. - SystemState (apps/system_state/): CPU + memory readout via cfbase probes (getCPUProfileInfo, getCPUBonusInfo, getSystemMemoryInfo) + QTimer refresh; reuses the base layer instead of CCIMX's platform driver chain. Process browser page skipped (base has no process enumerator yet). - AlarmyClock (apps/alarm_clock/): 1s wall-clock poll, QSpinBox editor + QListWidget armed list, QMessageBox ring (audio TODO); the event-bus/ClockEventProcessor abstraction collapsed to a direct call. - Calendar (apps/calendar/): QCalendarWidget + per-date notes (in-memory QMap, persistence TODO). Fixes during integration: - alarm_clock: Q_DECLARE_METATYPE moved to the global namespace (moc requires it outside the type's own namespace). - calendar: dropped QCalendarWidget::setVerticalGridLineVisible (no such API) and Qt::DefaultLocaleLongDate (removed in Qt6) -> Qt::TextDate. All three build green; Doxygen passes. --- apps/CMakeLists.txt | 3 + apps/alarm_clock/CMakeLists.txt | 27 +++ apps/alarm_clock/alarm_clock_panel.cpp | 223 +++++++++++++++++++++ apps/alarm_clock/alarm_clock_panel.h | 135 +++++++++++++ apps/alarm_clock/app.json | 6 + apps/alarm_clock/main.cpp | 27 +++ apps/calendar/CMakeLists.txt | 27 +++ apps/calendar/app.json | 6 + apps/calendar/calendar_panel.cpp | 125 ++++++++++++ apps/calendar/calendar_panel.h | 106 ++++++++++ apps/calendar/main.cpp | 27 +++ apps/system_state/CMakeLists.txt | 32 +++ apps/system_state/app.json | 6 + apps/system_state/main.cpp | 27 +++ apps/system_state/system_state_panel.cpp | 239 +++++++++++++++++++++++ apps/system_state/system_state_panel.h | 108 ++++++++++ document/status/current.md | 1 + 17 files changed, 1125 insertions(+) create mode 100644 apps/alarm_clock/CMakeLists.txt create mode 100644 apps/alarm_clock/alarm_clock_panel.cpp create mode 100644 apps/alarm_clock/alarm_clock_panel.h create mode 100644 apps/alarm_clock/app.json create mode 100644 apps/alarm_clock/main.cpp create mode 100644 apps/calendar/CMakeLists.txt create mode 100644 apps/calendar/app.json create mode 100644 apps/calendar/calendar_panel.cpp create mode 100644 apps/calendar/calendar_panel.h create mode 100644 apps/calendar/main.cpp create mode 100644 apps/system_state/CMakeLists.txt create mode 100644 apps/system_state/app.json create mode 100644 apps/system_state/main.cpp create mode 100644 apps/system_state/system_state_panel.cpp create mode 100644 apps/system_state/system_state_panel.h diff --git a/apps/CMakeLists.txt b/apps/CMakeLists.txt index 404ad0112..7354d686f 100644 --- a/apps/CMakeLists.txt +++ b/apps/CMakeLists.txt @@ -4,3 +4,6 @@ # so a crash or hang in an app cannot take down the desktop. add_subdirectory(calculator) add_subdirectory(noter) +add_subdirectory(system_state) +add_subdirectory(alarm_clock) +add_subdirectory(calendar) diff --git a/apps/alarm_clock/CMakeLists.txt b/apps/alarm_clock/CMakeLists.txt new file mode 100644 index 000000000..49f5ff75a --- /dev/null +++ b/apps/alarm_clock/CMakeLists.txt @@ -0,0 +1,27 @@ +# Alarm Clock standalone application. +# Ported from CCIMXDesktop's AlarmyClock; UI uses QuarkWidgets MD3 widgets. +# CFDesktop launches this binary via AppLaunchService (QProcess), so it runs in +# its own process (isolated from the desktop shell). + +# Standalone Alarm Clock executable (consumed by CFDesktop via QProcess launch). +qt_add_executable(alarm_clock + main.cpp + alarm_clock_panel.cpp +) + +target_link_libraries(alarm_clock PRIVATE + QuarkWidgets::quarkwidgets + Qt6::Widgets +) + +# App package is self-contained under /../apps/alarm_clock/ (executable + +# manifest in the same dir). AppDiscoverer scans /../apps//app.json, +# resolves exec to the co-located absolute path, and AppLaunchService launches +# it directly — no working-directory dependency, no PATH search needed. +set(ALARM_CLOCK_APP_DIR "${CMAKE_BINARY_DIR}/apps/alarm_clock") +set_target_properties(alarm_clock PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${ALARM_CLOCK_APP_DIR}" +) + +# Deploy the manifest next to the executable (at configure time). +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/app.json" "${ALARM_CLOCK_APP_DIR}/app.json" COPYONLY) diff --git a/apps/alarm_clock/alarm_clock_panel.cpp b/apps/alarm_clock/alarm_clock_panel.cpp new file mode 100644 index 000000000..5263e59ba --- /dev/null +++ b/apps/alarm_clock/alarm_clock_panel.cpp @@ -0,0 +1,223 @@ +/** + * @file apps/alarm_clock/alarm_clock_panel.cpp + * @brief Implementation of the Alarm Clock panel. + * + * Ported from CCIMXDesktop's AlarmyClock. The original AlarmyNotifier polling + * loop (1 s QTimer + per-second QTime match) is preserved verbatim in @ref + * onTick; the broadcaster/processor event bus is collapsed into the direct + * @ref fireAlarm call, and the QPainter analog dial + QMainWindow/.ui shell + * are replaced by a QuarkWidgets MD3 panel built in code. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup alarm_clock + */ + +#include "alarm_clock_panel.h" + +#include "ui/widget/material/widget/button/button.h" +#include "ui/widget/material/widget/label/label.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +namespace { +using qw::widget::material::Button; +using qw::widget::material::Label; +using qw::widget::material::TypographyStyle; +using Variant = Button::ButtonVariant; + +constexpr qreal kCornerRadius = 16.0; ///< Card corner radius (px). + +/// @brief Qt item-data role key for the stored AlarmEntry. +constexpr int kAlarmRole = Qt::UserRole + 1; + +/// @brief Renders an alarm entry as "hh:mm — note". +QString formatEntry(const AlarmEntry& e) { + return e.time.toString(QStringLiteral("hh:mm")) + QStringLiteral(" — ") + + (e.note.isEmpty() ? QStringLiteral("(no note)") : e.note); +} +} // namespace + +AlarmClockPanel::AlarmClockPanel(QWidget* parent) : QWidget(parent) { + setupUi(); + + // Poll the wall clock once per second (mirrors AlarmyNotifier::check_time). + ticker_ = new QTimer(this); + ticker_->setInterval(1000); + connect(ticker_, &QTimer::timeout, this, &AlarmClockPanel::onTick); + ticker_->start(); + onTick(); +} + +AlarmClockPanel::~AlarmClockPanel() = default; + +void AlarmClockPanel::setupUi() { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(16, 16, 16, 16); + layout->setSpacing(12); + + // Live clock readout. + clock_label_ = new Label("--:--:--", TypographyStyle::DisplayLarge, this); + clock_label_->setAlignment(Qt::AlignCenter); + layout->addWidget(clock_label_); + + // --- Time editor row: [- hour +] : [- minute +] --- + auto* editor_row = new QHBoxLayout; + editor_row->setSpacing(6); + + auto make_spin = [this](int max) { + auto* spin = new QSpinBox(this); + spin->setRange(0, max); + spin->setFixedHeight(40); + spin->setButtonSymbols(QSpinBox::PlusMinus); + return spin; + }; + hour_spin_ = make_spin(23); + hour_spin_->setValue(QTime::currentTime().hour()); + minute_spin_ = make_spin(59); + minute_spin_->setValue(QTime::currentTime().minute()); + + auto make_step = [this](const QString& glyph, Variant v) { + auto* btn = new Button(glyph, v, this); + btn->setFixedSize(40, 40); + return btn; + }; + auto* h_minus = make_step(QStringLiteral("-"), Variant::Tonal); + auto* h_plus = make_step(QStringLiteral("+"), Variant::Tonal); + auto* m_minus = make_step(QStringLiteral("-"), Variant::Tonal); + auto* m_plus = make_step(QStringLiteral("+"), Variant::Tonal); + + connect(h_minus, &QPushButton::clicked, this, + [this] { hour_spin_->setValue(hour_spin_->value() - 1); }); + connect(h_plus, &QPushButton::clicked, this, + [this] { hour_spin_->setValue(hour_spin_->value() + 1); }); + connect(m_minus, &QPushButton::clicked, this, + [this] { minute_spin_->setValue(minute_spin_->value() - 1); }); + connect(m_plus, &QPushButton::clicked, this, + [this] { minute_spin_->setValue(minute_spin_->value() + 1); }); + + editor_row->addWidget(h_minus); + editor_row->addWidget(hour_spin_); + editor_row->addWidget(h_plus); + auto* colon = new QLabel(QStringLiteral(":"), this); + colon->setAlignment(Qt::AlignCenter); + QFont colon_font = colon->font(); + colon_font.setPointSize(20); + colon->setFont(colon_font); + editor_row->addWidget(colon); + editor_row->addWidget(m_minus); + editor_row->addWidget(minute_spin_); + editor_row->addWidget(m_plus); + editor_row->addStretch(); + layout->addLayout(editor_row); + + // Note editor. + note_edit_ = new QTextEdit(this); + note_edit_->setPlaceholderText(QStringLiteral("Reminder note (optional)")); + note_edit_->setFixedHeight(60); + layout->addWidget(note_edit_); + + // Add / Remove control row. + auto* control_row = new QHBoxLayout; + control_row->setSpacing(8); + auto* add_btn = new Button(QStringLiteral("Add Alarm"), Variant::Filled, this); + auto* remove_btn = new Button(QStringLiteral("Remove Selected"), Variant::Outlined, this); + control_row->addWidget(add_btn); + control_row->addWidget(remove_btn); + control_row->addStretch(); + layout->addLayout(control_row); + + // Armed alarms list. + alarm_list_ = new QListWidget(this); + layout->addWidget(alarm_list_, /*stretch=*/1); + + connect(add_btn, &QPushButton::clicked, this, &AlarmClockPanel::onAddAlarm); + connect(remove_btn, &QPushButton::clicked, this, &AlarmClockPanel::onRemoveAlarm); +} + +void AlarmClockPanel::onTick() { + const QTime now = QTime::currentTime(); + clock_label_->setText(now.toString(QStringLiteral("hh:mm:ss"))); + + // Match hour+minute+second against every armed alarm (the original + // AlarmyNotifier matches all three fields; entries are stored with second + // forced to 0, so an alarm fires in the first second of its minute). + for (int i = 0; i < alarm_list_->count(); ++i) { + auto* item = alarm_list_->item(i); + const auto entry = item->data(kAlarmRole).value(); + if (entry.time.hour() == now.hour() && entry.time.minute() == now.minute() && + entry.time.second() == now.second()) { + fireAlarm(item); + } + } +} + +void AlarmClockPanel::onAddAlarm() { + AlarmEntry entry; + entry.time = QTime(hour_spin_->value(), minute_spin_->value(), 0); + entry.note = note_edit_->toPlainText().trimmed(); + + // Reject duplicates at the same minute to avoid a double-fire. + for (int i = 0; i < alarm_list_->count(); ++i) { + const auto existing = alarm_list_->item(i)->data(kAlarmRole).value(); + if (existing.time.hour() == entry.time.hour() && + existing.time.minute() == entry.time.minute()) { + return; + } + } + + auto* item = new QListWidgetItem(formatEntry(entry), alarm_list_); + item->setData(kAlarmRole, QVariant::fromValue(entry)); + alarm_list_->addItem(item); + + note_edit_->clear(); +} + +void AlarmClockPanel::onRemoveAlarm() { + auto selected = alarm_list_->selectedItems(); + for (auto* item : selected) { + delete item; + } +} + +void AlarmClockPanel::fireAlarm(QListWidgetItem* item) { + const auto entry = item->data(kAlarmRole).value(); + + // TODO(alarm_clock): add audible ringing. The CCIMX original had no audio + // asset either (it popped a QMessageBox). Wire QSoundEffect with a bundled + // .wav (requires Qt6::Multimedia + a qrc asset) or a platform system sound + // here. For now the visual alert mirrors the original DefaultProcessor. + QMessageBox::information( + this, QStringLiteral("Alarm"), + QStringLiteral("⏰ %1\n\n%2") + .arg(entry.time.toString(QStringLiteral("hh:mm")), + entry.note.isEmpty() ? QStringLiteral("Time's up!") : entry.note)); + + // One-shot: disarm after firing. + delete item; +} + +void AlarmClockPanel::paintEvent(QPaintEvent* /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + const QColor surface_color(0xF7, 0xF5, 0xF3); + QPainterPath card; + card.addRoundedRect(QRectF(rect()), kCornerRadius, kCornerRadius); + p.fillPath(card, surface_color); +} + +} // namespace cf::desktop::desktop_component diff --git a/apps/alarm_clock/alarm_clock_panel.h b/apps/alarm_clock/alarm_clock_panel.h new file mode 100644 index 000000000..c20957ce5 --- /dev/null +++ b/apps/alarm_clock/alarm_clock_panel.h @@ -0,0 +1,135 @@ +/** + * @file apps/alarm_clock/alarm_clock_panel.h + * @brief Alarm Clock main panel (standalone app). + * + * Root widget of the standalone Alarm Clock executable. Provides a live + * digital clock readout, a time editor (hour/minute spinboxes with +/- MD3 + * buttons), a note field, an add/remove control row, and a list of armed + * alarms. Each armed alarm is checked against the wall clock once per second; + * when its time arrives the alarm fires and a Material message box shows the + * note. Ported from CCIMXDesktop's AlarmyClock — the original QPainter analog + * dial, QMainWindow shell, .ui files, and broadcaster/processor event bus are + * replaced by a single QuarkWidgets MD3 panel built in code. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup alarm_clock + */ + +#pragma once + +#include +#include +#include + +class QListWidget; +class QListWidgetItem; +class QPaintEvent; +class QSpinBox; +class QTextEdit; +class QTimer; + +namespace qw::widget::material { +class Button; +class Label; +} // namespace qw::widget::material + +namespace cf::desktop::desktop_component { + +/** + * @brief Represents one armed alarm (target time + user note). + * + * @ingroup alarm_clock + */ +struct AlarmEntry { + QTime time; ///< Target wall-clock time (hh:mm, seconds ignored). + QString note; ///< Reminder text shown when the alarm fires. +}; + +/** + * @brief Root widget of the standalone Alarm Clock application. + * + * @ingroup alarm_clock + */ +class AlarmClockPanel final : public QWidget { + Q_OBJECT + public: + /** + * @brief Constructs the Alarm Clock panel. + * + * @param[in] parent Parent widget (nullptr for a top-level window). + * + * @throws None + * @since 0.20 + * @ingroup alarm_clock + */ + explicit AlarmClockPanel(QWidget* parent = nullptr); + + /** + * @brief Destructs the panel. + * + * @throws None + * @since 0.20 + * @ingroup alarm_clock + */ + ~AlarmClockPanel() override; + + protected: + /** + * @brief Paints the rounded Material card background. + * + * @param[in] event The paint event descriptor. + * + * @throws None + * @since 0.20 + * @ingroup alarm_clock + */ + void paintEvent(QPaintEvent* event) override; + + private slots: + /// @brief Refreshes the live clock label from the wall clock. + void onTick(); + /// @brief Arms a new alarm from the editor fields. + void onAddAlarm(); + /// @brief Removes the currently selected armed alarm. + void onRemoveAlarm(); + + private: + /** + * @brief Builds the editor + armed-alarms layout. + * + * @throws None + * @since 0.20 + * @ingroup alarm_clock + */ + void setupUi(); + + /** + * @brief Fires the alarm identified by @p item. + * + * Shows a Material message box with the alarm note and, if the alarm is + * non-repeating, disarms it. Audible ringing is a TODO (see @ref onTick). + * + * @param[in] item The list item whose alarm time has arrived. + * + * @throws None + * @since 0.20 + * @ingroup alarm_clock + */ + void fireAlarm(QListWidgetItem* item); + + /// @brief Live digital clock readout. + qw::widget::material::Label* clock_label_{nullptr}; + QSpinBox* hour_spin_{nullptr}; ///< Hour editor (0-23). + QSpinBox* minute_spin_{nullptr}; ///< Minute editor (0-59). + QTextEdit* note_edit_{nullptr}; ///< Reminder note editor. + QListWidget* alarm_list_{nullptr}; ///< Armed alarms list. + QTimer* ticker_{nullptr}; ///< Per-second wall-clock poll. +}; + +} // namespace cf::desktop::desktop_component + +/// @brief Enables storing AlarmEntry in a QVariant (QListWidgetItem data). +Q_DECLARE_METATYPE(cf::desktop::desktop_component::AlarmEntry) diff --git a/apps/alarm_clock/app.json b/apps/alarm_clock/app.json new file mode 100644 index 000000000..53722e853 --- /dev/null +++ b/apps/alarm_clock/app.json @@ -0,0 +1,6 @@ +{ + "app_id": "alarm_clock", + "display_name": "Alarm Clock", + "exec": "alarm_clock", + "launch_kind": "auto" +} diff --git a/apps/alarm_clock/main.cpp b/apps/alarm_clock/main.cpp new file mode 100644 index 000000000..613e76d1e --- /dev/null +++ b/apps/alarm_clock/main.cpp @@ -0,0 +1,27 @@ +/** + * @file apps/alarm_clock/main.cpp + * @brief Standalone Alarm Clock application entry point. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup alarm_clock + */ + +#include "alarm_clock_panel.h" + +#include +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QCoreApplication::setApplicationName(QStringLiteral("CFDesktop Alarm Clock")); + + cf::desktop::desktop_component::AlarmClockPanel panel; + panel.setWindowTitle(QStringLiteral("Alarm Clock")); + panel.resize(380, 560); + panel.show(); + + return app.exec(); +} diff --git a/apps/calendar/CMakeLists.txt b/apps/calendar/CMakeLists.txt new file mode 100644 index 000000000..028a922ea --- /dev/null +++ b/apps/calendar/CMakeLists.txt @@ -0,0 +1,27 @@ +# Calendar standalone application. +# Ported from CCIMXDesktop's CCCalendar; UI uses QuarkWidgets MD3 widgets plus +# Qt's QCalendarWidget for the month grid (MVP — a self-drawn MD3 calendar may +# replace it later). +# CFDesktop launches this binary via AppLaunchService (QProcess), so it runs in +# its own process (isolated from the desktop shell). + +# Standalone Calendar executable (consumed by CFDesktop via QProcess launch). +qt_add_executable(calendar + main.cpp + calendar_panel.cpp +) + +target_link_libraries(calendar PRIVATE + QuarkWidgets::quarkwidgets + Qt6::Widgets +) + +# App package is self-contained under /../apps/calendar/ (executable + +# manifest in the same dir). AppDiscoverer scans /../apps//app.json. +set(CALENDAR_APP_DIR "${CMAKE_BINARY_DIR}/apps/calendar") +set_target_properties(calendar PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CALENDAR_APP_DIR}" +) + +# Deploy the manifest next to the executable (at configure time). +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/app.json" "${CALENDAR_APP_DIR}/app.json" COPYONLY) diff --git a/apps/calendar/app.json b/apps/calendar/app.json new file mode 100644 index 000000000..88429f8e3 --- /dev/null +++ b/apps/calendar/app.json @@ -0,0 +1,6 @@ +{ + "app_id": "calendar", + "display_name": "Calendar", + "exec": "calendar", + "launch_kind": "auto" +} diff --git a/apps/calendar/calendar_panel.cpp b/apps/calendar/calendar_panel.cpp new file mode 100644 index 000000000..39e4c4640 --- /dev/null +++ b/apps/calendar/calendar_panel.cpp @@ -0,0 +1,125 @@ +/** + * @file apps/calendar/calendar_panel.cpp + * @brief Implementation of the Calendar panel. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup calendar + */ + +#include "calendar_panel.h" + +#include "ui/widget/material/widget/button/button.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +using qw::widget::material::Button; +using Variant = Button::ButtonVariant; + +namespace { +constexpr qreal kCornerRadius = 16.0; ///< Card corner radius (px). +} // namespace + +CalendarPanel::CalendarPanel(QWidget* parent) : QWidget(parent) { + setupUi(); + refreshDateDescription(); +} + +CalendarPanel::~CalendarPanel() = default; + +void CalendarPanel::setupUi() { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(12, 12, 12, 12); + root->setSpacing(8); + + // Top split: calendar (left) | note editor (right). + auto* split = new QHBoxLayout; + split->setSpacing(8); + + calendar_ = new QCalendarWidget(this); + split->addWidget(calendar_, /*stretch=*/3); + + auto* editor_col = new QVBoxLayout; + editor_col->setSpacing(6); + + date_label_ = new QLabel(this); + date_label_->setWordWrap(true); + editor_col->addWidget(date_label_); + + editor_ = new QTextEdit(this); + editor_->setPlaceholderText(QStringLiteral("Write a note for the selected date...")); + editor_col->addWidget(editor_); + + auto* toolbar = new QHBoxLayout; + toolbar->setSpacing(6); + auto* save_btn = new Button(QStringLiteral("Save"), Variant::Filled, this); + auto* delete_btn = new Button(QStringLiteral("Delete"), Variant::Outlined, this); + toolbar->addWidget(save_btn); + toolbar->addWidget(delete_btn); + toolbar->addStretch(); + editor_col->addLayout(toolbar); + + split->addLayout(editor_col, /*stretch=*/2); + root->addLayout(split); + + connect(calendar_, &QCalendarWidget::selectionChanged, this, + &CalendarPanel::onSelectionChanged); + connect(save_btn, &QPushButton::clicked, this, &CalendarPanel::onSaveNote); + connect(delete_btn, &QPushButton::clicked, this, &CalendarPanel::onDeleteNote); +} + +void CalendarPanel::onSelectionChanged() { + const QDate date = calendar_->selectedDate(); + editor_->setPlainText(notes_.value(date)); + refreshDateDescription(); +} + +void CalendarPanel::onSaveNote() { + const QDate date = calendar_->selectedDate(); + const QString text = editor_->toPlainText(); + if (text.isEmpty()) { + notes_.remove(date); + } else { + notes_.insert(date, text); + } +} + +void CalendarPanel::onDeleteNote() { + const QDate date = calendar_->selectedDate(); + notes_.remove(date); + editor_->clear(); +} + +void CalendarPanel::refreshDateDescription() { + date_label_->setText(describeDate(calendar_->selectedDate())); +} + +QString CalendarPanel::describeDate(const QDate& date) { + if (!date.isValid()) { + return QStringLiteral("Invalid date"); + } + return QStringLiteral("%1 %2").arg( + date.toString(Qt::TextDate), + QStringLiteral("(day %1 of %2)").arg(date.dayOfYear()).arg(date.daysInYear())); +} + +void CalendarPanel::paintEvent(QPaintEvent* /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + const QColor surface_color(0xF7, 0xF5, 0xF3); + QPainterPath card; + card.addRoundedRect(QRectF(rect()), kCornerRadius, kCornerRadius); + p.fillPath(card, surface_color); +} + +} // namespace cf::desktop::desktop_component diff --git a/apps/calendar/calendar_panel.h b/apps/calendar/calendar_panel.h new file mode 100644 index 000000000..9a907dbbb --- /dev/null +++ b/apps/calendar/calendar_panel.h @@ -0,0 +1,106 @@ +/** + * @file apps/calendar/calendar_panel.h + * @brief Calendar main panel (standalone app). + * + * Root widget of the standalone Calendar executable. Renders a month calendar + * (Qt QCalendarWidget, MVP) with a side panel that shows and edits notes bound + * to the selected date. Notes are kept in an in-memory map for the MVP; + * persistence is deferred (see TODO in the .cpp). Ported from CCIMXDesktop's + * CCCalendar, with the original QMainWindow + .ui layout rewritten as a plain + * QWidget using QuarkWidgets MD3 buttons. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup calendar + */ + +#pragma once + +#include +#include +#include +#include + +class QCalendarWidget; +class QLabel; +class QPaintEvent; +class QTextEdit; + +namespace qw::widget::material { +class Button; +} + +namespace cf::desktop::desktop_component { + +/** + * @brief Root widget of the standalone Calendar application. + * + * @ingroup calendar + */ +class CalendarPanel final : public QWidget { + Q_OBJECT + public: + /** + * @brief Constructs the Calendar panel. + * + * @param[in] parent Parent widget (nullptr for a top-level window). + * + * @throws None + * @since 0.20 + * @ingroup calendar + */ + explicit CalendarPanel(QWidget* parent = nullptr); + + /** + * @brief Destructs the panel. + * + * @throws None + * @since 0.20 + * @ingroup calendar + */ + ~CalendarPanel() override; + + protected: + /** + * @brief Paints the rounded Material card background. + * + * @param[in] event The paint event descriptor. + * + * @throws None + * @since 0.20 + * @ingroup calendar + */ + void paintEvent(QPaintEvent* event) override; + + private slots: + /// @brief Loads the selected date's note into the editor and updates the + /// date description label. + void onSelectionChanged(); + /// @brief Commits the editor text back into the note store for the current + /// date. + void onSaveNote(); + /// @brief Removes the current date's note and clears the editor. + void onDeleteNote(); + + private: + /// @brief Builds the calendar + editor layout. + void setupUi(); + /// @brief Refreshes the date description label for the selected date. + void refreshDateDescription(); + /// @brief Returns a human-readable description of @p date. + static QString describeDate(const QDate& date); + + QCalendarWidget* calendar_{nullptr}; ///< Month calendar (MVP). + QTextEdit* editor_{nullptr}; ///< Note editor for the selected date. + QLabel* date_label_{nullptr}; ///< Selected-date read-out / description. + + /// @brief In-memory note store: selected date -> note text. + /// + /// TODO: replace with persistent storage (JSON file under the user data + /// dir, or desktop ConfigStore) in a follow-up. Lost on app exit today. + QMap notes_; +}; + +} // namespace cf::desktop::desktop_component diff --git a/apps/calendar/main.cpp b/apps/calendar/main.cpp new file mode 100644 index 000000000..b1662ca43 --- /dev/null +++ b/apps/calendar/main.cpp @@ -0,0 +1,27 @@ +/** + * @file apps/calendar/main.cpp + * @brief Standalone Calendar application entry point. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup calendar + */ + +#include "calendar_panel.h" + +#include +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QCoreApplication::setApplicationName(QStringLiteral("CFDesktop Calendar")); + + cf::desktop::desktop_component::CalendarPanel panel; + panel.setWindowTitle(QStringLiteral("Calendar")); + panel.resize(720, 480); + panel.show(); + + return app.exec(); +} diff --git a/apps/system_state/CMakeLists.txt b/apps/system_state/CMakeLists.txt new file mode 100644 index 000000000..c28ffd835 --- /dev/null +++ b/apps/system_state/CMakeLists.txt @@ -0,0 +1,32 @@ +# System State standalone application. +# Ported from CCIMXDesktop's SystemState. Unlike the original (which shipped +# its own platform-specific CPU/memory fetchers), this version reuses the +# mature base/ probes (cfbase) for system data — showcasing the base-layer +# capability. The UI is rebuilt with QuarkWidgets MD3 widgets + plain Qt +# labels (no QtCharts dependency, no .ui files). +# CFDesktop launches this binary via AppLaunchService (QProcess), so it runs +# in its own process (isolated from the desktop shell). + +# Standalone System State executable (consumed by CFDesktop via QProcess). +qt_add_executable(system_state + main.cpp + system_state_panel.cpp +) + +target_link_libraries(system_state PRIVATE + cfbase + QuarkWidgets::quarkwidgets + Qt6::Widgets +) + +# App package is self-contained under /../apps/system_state/ (executable + +# manifest in the same dir). AppDiscoverer scans /../apps//app.json, +# resolves exec to the co-located absolute path, and AppLaunchService launches +# it directly — no working-directory dependency, no PATH search needed. +set(SYSTEM_STATE_APP_DIR "${CMAKE_BINARY_DIR}/apps/system_state") +set_target_properties(system_state PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${SYSTEM_STATE_APP_DIR}" +) + +# Deploy the manifest next to the executable (at configure time). +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/app.json" "${SYSTEM_STATE_APP_DIR}/app.json" COPYONLY) diff --git a/apps/system_state/app.json b/apps/system_state/app.json new file mode 100644 index 000000000..f3388fd4a --- /dev/null +++ b/apps/system_state/app.json @@ -0,0 +1,6 @@ +{ + "app_id": "system_state", + "display_name": "System State", + "exec": "system_state", + "launch_kind": "auto" +} diff --git a/apps/system_state/main.cpp b/apps/system_state/main.cpp new file mode 100644 index 000000000..f24d64cdc --- /dev/null +++ b/apps/system_state/main.cpp @@ -0,0 +1,27 @@ +/** + * @file apps/system_state/main.cpp + * @brief Standalone System State application entry point. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup system_state + */ + +#include "system_state_panel.h" + +#include +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QCoreApplication::setApplicationName(QStringLiteral("CFDesktop System State")); + + cf::desktop::desktop_component::SystemStatePanel panel; + panel.setWindowTitle(QStringLiteral("System State")); + panel.resize(520, 640); + panel.show(); + + return app.exec(); +} diff --git a/apps/system_state/system_state_panel.cpp b/apps/system_state/system_state_panel.cpp new file mode 100644 index 000000000..8ccb8689d --- /dev/null +++ b/apps/system_state/system_state_panel.cpp @@ -0,0 +1,239 @@ +/** + * @file apps/system_state/system_state_panel.cpp + * @brief Implementation of the System State panel. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup system_state + */ + +#include "system_state_panel.h" + +#include "ui/widget/material/widget/button/button.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "system/cpu/cfcpu.h" +#include "system/cpu/cfcpu_bonus.h" +#include "system/cpu/cfcpu_profile.h" +#include "system/memory/memory_info.h" + +namespace cf::desktop::desktop_component { + +using qw::widget::material::Button; +using Variant = Button::ButtonVariant; + +namespace { +constexpr qreal kCornerRadius = 16.0; ///< Card corner radius (px). +constexpr int kRefreshIntervalMs = 2000; ///< Auto-refresh interval (ms). + +/// @brief Formats a byte count into a human-readable binary string. +/// @param bytes The size in bytes. +/// @return String like "8.00 GB", "512.00 MB", or "1.23 KB". +QString formatBytes(uint64_t bytes) { + constexpr uint64_t kb = 1024ULL; + constexpr uint64_t mb = kb * 1024ULL; + constexpr uint64_t gb = mb * 1024ULL; + if (bytes < kb) { + return QStringLiteral("%1 B").arg(static_cast(bytes)); + } + if (bytes < mb) { + return QString::number(static_cast(bytes) / kb, 'f', 2) + " KB"; + } + if (bytes < gb) { + return QString::number(static_cast(bytes) / mb, 'f', 2) + " MB"; + } + return QString::number(static_cast(bytes) / gb, 'f', 2) + " GB"; +} + +/// @brief Builds a section heading label. +/// @param title The heading text. +/// @return A styled, bold label. +QLabel* makeSectionLabel(const QString& title) { + auto* label = new QLabel(title); + QFont font = label->font(); + font.setBold(true); + font.setPointSize(font.pointSize() + 1); + label->setFont(font); + return label; +} + +/// @brief Builds a two-column key/value row. +/// @param key The left column caption. +/// @return The value label (caller updates its text). +QLabel* makeRow(QVBoxLayout* layout, const QString& key) { + auto* row = new QHBoxLayout; + row->setSpacing(12); + auto* key_label = new QLabel(key); + key_label->setMinimumWidth(170); + auto* value_label = new QLabel(QStringLiteral("—")); + value_label->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + value_label->setTextInteractionFlags(Qt::TextSelectableByMouse); + row->addWidget(key_label); + row->addWidget(value_label, 1); + layout->addLayout(row); + return value_label; +} +} // namespace + +SystemStatePanel::SystemStatePanel(QWidget* parent) : QWidget(parent) { + setupUi(); + loadStaticInfo(); + loadLiveInfo(); + + refresh_timer_ = new QTimer(this); + refresh_timer_->setInterval(kRefreshIntervalMs); + connect(refresh_timer_, &QTimer::timeout, this, &SystemStatePanel::loadLiveInfo); + refresh_timer_->start(); +} + +SystemStatePanel::~SystemStatePanel() = default; + +void SystemStatePanel::setupUi() { + auto* outer = new QVBoxLayout(this); + outer->setContentsMargins(12, 12, 12, 12); + outer->setSpacing(8); + + // Toolbar: Refresh | Auto (toggle). + auto* toolbar = new QHBoxLayout; + toolbar->setSpacing(8); + auto* refresh_btn = new Button(QStringLiteral("Refresh"), Variant::Outlined, this); + auto* auto_btn = new Button(QStringLiteral("Auto"), Variant::Tonal, this); + auto_btn->setCheckable(true); + auto_btn->setChecked(true); + toolbar->addWidget(refresh_btn); + toolbar->addWidget(auto_btn); + toolbar->addStretch(); + outer->addLayout(toolbar); + + // Scrollable readout area. + auto* scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + auto* content = new QWidget; + auto* layout = new QVBoxLayout(content); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(4); + + // CPU static section. + layout->addWidget(makeSectionLabel(QStringLiteral("CPU"))); + cpu_model_label_ = makeRow(layout, QStringLiteral("Model")); + cpu_arch_label_ = makeRow(layout, QStringLiteral("Architecture")); + cpu_manufacturer_label_ = makeRow(layout, QStringLiteral("Manufacturer")); + cpu_cores_label_ = makeRow(layout, QStringLiteral("Cores (logical/physical)")); + cpu_freq_label_ = makeRow(layout, QStringLiteral("Frequency (current/max)")); + cpu_usage_label_ = makeRow(layout, QStringLiteral("Usage")); + cpu_temp_label_ = makeRow(layout, QStringLiteral("Temperature")); + + layout->addSpacing(8); + + // Memory section. + layout->addWidget(makeSectionLabel(QStringLiteral("Memory"))); + mem_phys_total_label_ = makeRow(layout, QStringLiteral("Physical total")); + mem_phys_used_label_ = makeRow(layout, QStringLiteral("Physical used")); + mem_phys_avail_label_ = makeRow(layout, QStringLiteral("Physical available")); + mem_swap_total_label_ = makeRow(layout, QStringLiteral("Swap total")); + mem_swap_used_label_ = makeRow(layout, QStringLiteral("Swap used")); + + layout->addStretch(); + scroll->setWidget(content); + outer->addWidget(scroll, 1); + + connect(refresh_btn, &QPushButton::clicked, this, &SystemStatePanel::refreshNow); + connect(auto_btn, &QPushButton::toggled, this, &SystemStatePanel::toggleAutoRefresh); +} + +void SystemStatePanel::loadStaticInfo() { + const auto info_result = cf::getCPUInfo(); + if (info_result.has_value()) { + const auto& info = info_result.value(); + cpu_model_label_->setText(QString::fromStdString(std::string(info.model))); + cpu_arch_label_->setText(QString::fromStdString(std::string(info.arch))); + cpu_manufacturer_label_->setText(QString::fromStdString(std::string(info.manufacturer))); + } else { + cpu_model_label_->setText(QStringLiteral("Unavailable")); + cpu_arch_label_->setText(QStringLiteral("Unavailable")); + cpu_manufacturer_label_->setText(QStringLiteral("Unavailable")); + } +} + +void SystemStatePanel::loadLiveInfo() { + // CPU profile (cores / frequency / usage) — real-time each call. + const auto profile_result = cf::getCPUProfileInfo(); + if (profile_result.has_value()) { + const auto& p = profile_result.value(); + cpu_cores_label_->setText(QStringLiteral("%1 / %2").arg(p.logical_cnt).arg(p.physical_cnt)); + cpu_freq_label_->setText( + QStringLiteral("%1 / %2 MHz").arg(p.current_frequecy).arg(p.max_frequency)); + cpu_usage_label_->setText(QString::number(p.cpu_usage_percentage, 'f', 1) + " %"); + } else { + cpu_cores_label_->setText(QStringLiteral("Unavailable")); + cpu_freq_label_->setText(QStringLiteral("Unavailable")); + cpu_usage_label_->setText(QStringLiteral("Unavailable")); + } + + // CPU bonus (temperature) — cached, refreshed on demand. + const auto bonus_result = cf::getCPUBonusInfo(true); + if (bonus_result.has_value() && bonus_result.value().temperature.has_value()) { + cpu_temp_label_->setText(QString::number(*bonus_result.value().temperature) + " C"); + } else { + cpu_temp_label_->setText(QStringLiteral("Unavailable")); + } + + // Memory — real-time each call. + cf::MemoryInfo mem{}; + cf::getSystemMemoryInfo(mem); + + const uint64_t phys_total = mem.physical.total_bytes; + const uint64_t phys_avail = mem.physical.available_bytes; + const uint64_t phys_used = (phys_total > phys_avail) ? (phys_total - phys_avail) : 0; + mem_phys_total_label_->setText(formatBytes(phys_total)); + mem_phys_avail_label_->setText(formatBytes(phys_avail)); + if (phys_total > 0) { + const double phys_pct = static_cast(phys_used) / phys_total * 100.0; + mem_phys_used_label_->setText( + QStringLiteral("%1 (%2%)").arg(formatBytes(phys_used)).arg(phys_pct, 0, 'f', 1)); + } else { + mem_phys_used_label_->setText(formatBytes(phys_used)); + } + + const uint64_t swap_total = mem.swap.total_bytes; + const uint64_t swap_free = mem.swap.free_bytes; + const uint64_t swap_used = (swap_total > swap_free) ? (swap_total - swap_free) : 0; + mem_swap_total_label_->setText(formatBytes(swap_total)); + if (swap_total > 0) { + const double swap_pct = static_cast(swap_used) / swap_total * 100.0; + mem_swap_used_label_->setText( + QStringLiteral("%1 (%2%)").arg(formatBytes(swap_used)).arg(swap_pct, 0, 'f', 1)); + } else { + mem_swap_used_label_->setText(formatBytes(swap_used)); + } +} + +void SystemStatePanel::refreshNow() { + loadStaticInfo(); + loadLiveInfo(); +} + +void SystemStatePanel::toggleAutoRefresh(bool checked) { + checked ? refresh_timer_->start() : refresh_timer_->stop(); +} + +void SystemStatePanel::paintEvent(QPaintEvent* /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + const QColor surface_color(0xF7, 0xF5, 0xF3); + QPainterPath card; + card.addRoundedRect(QRectF(rect()), kCornerRadius, kCornerRadius); + p.fillPath(card, surface_color); +} + +} // namespace cf::desktop::desktop_component diff --git a/apps/system_state/system_state_panel.h b/apps/system_state/system_state_panel.h new file mode 100644 index 000000000..d39fd80c5 --- /dev/null +++ b/apps/system_state/system_state_panel.h @@ -0,0 +1,108 @@ +/** + * @file apps/system_state/system_state_panel.h + * @brief System State main panel (standalone app). + * + * Root widget of the standalone System State executable. Surveys live system + * telemetry (CPU model/cores/frequency/usage/temperature, physical and swap + * memory) and renders it as a scrollable Material card with QuarkWidgets MD3 + * buttons (refresh/pause). Ported from CCIMXDesktop's SystemState. + * + * Unlike the CCIMX original (which bundled its own platform-specific CPU and + * memory fetchers plus QtCharts), this port reuses the mature base/ probes + * (cf::getCPUInfo, cf::getCPUBonusInfo, cf::getCPUProfileInfo, + * cf::getSystemMemoryInfo) and replaces the charts with plain text readouts, + * so it depends only on cfbase + QuarkWidgets + Qt Widgets. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-07-01 + * @version 0.1 + * @since 0.20 + * @ingroup system_state + */ + +#pragma once + +#include + +class QLabel; +class QPaintEvent; +class QTimer; + +namespace cf::desktop::desktop_component { + +/** + * @brief Root widget of the standalone System State application. + * + * @ingroup system_state + */ +class SystemStatePanel final : public QWidget { + Q_OBJECT + public: + /** + * @brief Constructs the System State panel. + * + * @param[in] parent Parent widget (nullptr for a top-level window). + * + * @throws None + * @since 0.20 + * @ingroup system_state + */ + explicit SystemStatePanel(QWidget* parent = nullptr); + + /** + * @brief Destructs the panel. + * + * @throws None + * @since 0.20 + * @ingroup system_state + */ + ~SystemStatePanel() override; + + protected: + /** + * @brief Paints the rounded Material card background. + * + * @param[in] event The paint event descriptor. + * + * @throws None + * @since 0.20 + * @ingroup system_state + */ + void paintEvent(QPaintEvent* event) override; + + private slots: + /// @brief Performs a one-shot refresh of all telemetry readouts. + void refreshNow(); + /// @brief Toggles the periodic auto-refresh timer on or off. + void toggleAutoRefresh(bool checked); + + private: + /// @brief Builds the toolbar + readout layout. + void setupUi(); + /// @brief Queries the base/ probes once and fills the static labels. + void loadStaticInfo(); + /// @brief Queries the base/ probes and updates the live labels. + void loadLiveInfo(); + + QTimer* refresh_timer_{nullptr}; ///< Periodic auto-refresh timer. + + // Static (cached) CPU info labels. + QLabel* cpu_model_label_{nullptr}; ///< CPU model name. + QLabel* cpu_arch_label_{nullptr}; ///< CPU architecture. + QLabel* cpu_manufacturer_label_{nullptr}; ///< CPU manufacturer. + + // Live CPU labels. + QLabel* cpu_cores_label_{nullptr}; ///< Logical/physical core counts. + QLabel* cpu_freq_label_{nullptr}; ///< Current/max frequency (MHz). + QLabel* cpu_usage_label_{nullptr}; ///< Current CPU usage (%). + QLabel* cpu_temp_label_{nullptr}; ///< CPU temperature (C). + + // Live memory labels. + QLabel* mem_phys_total_label_{nullptr}; ///< Total physical memory. + QLabel* mem_phys_used_label_{nullptr}; ///< Used physical memory + %. + QLabel* mem_phys_avail_label_{nullptr}; ///< Available physical memory. + QLabel* mem_swap_total_label_{nullptr}; ///< Total swap memory. + QLabel* mem_swap_used_label_{nullptr}; ///< Used swap memory + %. +}; + +} // namespace cf::desktop::desktop_component diff --git a/document/status/current.md b/document/status/current.md index 7eb762fd2..bbeaeed4f 100644 --- a/document/status/current.md +++ b/document/status/current.md @@ -64,6 +64,7 @@ description: CFDesktop 项目进度的唯一事实来源与全局导航。 - **2026-06(App 发现机制)**:`AppDiscoverer` 扫描 `/../apps//app.json` 自动发现注册 app(manifest:app_id/display_name/icon/exec);`loadAppsConfig` fallback 链 **discover → apps.json → defaultApps**;calculator 改首个 manifest app(包自包含 `apps/calculator/{calculator, app.json}`)。App 即插即用,无需 recompile。 - **2026-07(双态框架正式化)**:独立进程 + 进程内 builtin 两条腿**正式化**——`LaunchKind{Auto,DetachedProcess,BuiltinPanel}` 替代 `builtin:` 字符串 hack;`IBuiltinPanel` 接口 + `BuiltinPanelRegistry`(map-based 自写,aex 无 named 变体)消灭 `if(id==about)` 链;`loadAppsConfig` 合并 builtin+discovered 两源,`HardwareTierCapabilities::prefer_inprocess_apps`(Low=true)把 manifest `launch_kind:"auto"` 按 tier 裁决(Low→builtin,High→detached,无 builtin 实现则降级 detached+记日志);calculator 双态验证(`CalculatorBuiltinPanel` 组合适配器,同一份 panel 源码两种加载);补 AboutPanel 缺失入口(原 `builtin:about` 点不出)。架构债:desktop 引用 apps/calculator 源文件,待迁中立层([milestone_07](../todo/desktop/milestone_07_app_ecosystem.md))。 - **2026-07(MS4 搜索 + .desktop + Noter 移植)**:`DesktopEntryIndex` 扫 `~/.local/share/applications` + `/usr/share/applications` 的 `.desktop`(freedesktop:Type=Application && !NoDisplay,Exec 清理 `%` 占位符)→ 进网格作 DetachedProcess;`AppLauncher` 加搜索 QLineEdit,`textChanged` 实时按 display_name 模糊过滤;移植 CCIMXNoter → `apps/noter/`(QuarkWidgets MD3 Button 重写工具栏 + QTextEdit 编辑,manifest `launch_kind:auto`),第二个独立 App 验证移植范式可复制。MS4 launcher 闭环(网格+搜索+启动),仅余入场动画。 +- **2026-07(并发迁移 3 App)**:SystemState/AlarmyClock/CCCalendar 三 App 用 Agent **并行迁移**(参照 noter 范式,验证批量可复制):**SystemState**(`apps/system_state/`)复用 `cfbase` 的 CPU/memory probe(`getCPUProfileInfo`/`getCPUBonusInfo`/`getSystemMemoryInfo`)+ QTimer 刷新,展示 base 层能力;**AlarmyClock**(`apps/alarm_clock/`)1s 轮询 + QSpinBox 编辑器 + QListWidget 已设列表 + QMessageBox 响铃(音频 TODO);**CCCalendar**(`apps/calendar/`)`QCalendarWidget` + 日期笔记(内存 QMap,持久化 TODO)。集成时修:alarm_clock 的 `Q_DECLARE_METATYPE` 移到全局命名空间(moc 要求)、calendar 的 Qt6 API(`setVerticalGridLineVisible` 不存在、`DefaultLocaleLongDate` Qt6 删除→`Qt::TextDate`)。三 App 全 build 绿 + Doxygen 过。 - **已达成**:Milestone 1「桌面骨架可见」;Phase 0 / 1 / 2 / A(CI) / 6 / G(Widget) / H(显示后端)(详见 [SUMMARY.md](../todo/done/SUMMARY.md)) ## 新人入门 From 6617ca715a320c3decab6bc0ec05fe20fd354d8c Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Wed, 1 Jul 2026 12:00:52 +0800 Subject: [PATCH 3/4] fix(shell): enforce single-instance via QLockFile Two CFDesktop shells on one screen fought over fullscreen geometry and the WindowManagers cross-tracked each other's windows (bug: window shuffle + desktop shrinking). The shell is meant to be single-instance but had no guard. Add acquireSingleInstanceLock(): a QLockFile on $TMPDIR/cfdesktop-shell.lock held for the whole process lifetime. main() exits cleanly when it cannot acquire. QtCore-only QLockFile -- no QtNetwork/D-Bus, suits the 6ULL rule; stale locks from a crash are reclaimed automatically via PID check. Verified: launching a second instance exits immediately, leaving one process and the lock file in place. --- desktop/main.cpp | 12 ++++++++++++ desktop/main/desktop_entry.cpp | 14 ++++++++++++++ desktop/main/desktop_entry.h | 17 +++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/desktop/main.cpp b/desktop/main.cpp index 5c0823682..046137885 100644 --- a/desktop/main.cpp +++ b/desktop/main.cpp @@ -1,9 +1,21 @@ #include "desktop_entry.h" + +#include + #include "ui/widget/material/application/material_application.h" int main(int argc, char* argv[]) { // MaterialApplication registers the MD3 themes (light/dark) into ThemeManager // on construction, so panels and widgets resolve real theme tokens at runtime. qw::widget::material::MaterialApplication cf_desktop_app(argc, argv); + + // Refuse to start a second desktop shell -- two fullscreen shells on one + // screen fight over geometry and WindowManagers cross-track each other's + // windows. See acquireSingleInstanceLock() docs. + if (!cf::desktop::acquireSingleInstanceLock()) { + qWarning("CFDesktop: another instance is already running; exiting."); + return 0; + } + return cf::desktop::run_desktop_session(); } diff --git a/desktop/main/desktop_entry.cpp b/desktop/main/desktop_entry.cpp index 619953523..525b65170 100644 --- a/desktop/main/desktop_entry.cpp +++ b/desktop/main/desktop_entry.cpp @@ -3,8 +3,22 @@ #include "ui/CFDesktopEntity.h" #include "ui/CFDesktopWindowProxy.h" +#include +#include +#include + namespace cf::desktop { +bool acquireSingleInstanceLock() { + // QLockFile (QtCore only -- no QtNetwork/D-Bus, suits the 6ULL rule). The + // static instance lives for the whole process and auto-unlocks on exit; a + // stale lock left by a crash is reclaimed because QLockFile verifies the + // owning PID. + static QLockFile lock(QDir::tempPath() + QStringLiteral("/cfdesktop-shell.lock")); + lock.setStaleLockTime(0); + return lock.tryLock(100); // false if another live shell holds the lock +} + DesktopExitResult boot_desktop() { /* Desktop Placing here, for easy access handle... by manual :) */ // Make A Instance Here diff --git a/desktop/main/desktop_entry.h b/desktop/main/desktop_entry.h index 7beb6935a..322f3d3af 100644 --- a/desktop/main/desktop_entry.h +++ b/desktop/main/desktop_entry.h @@ -52,4 +52,21 @@ DesktopExitResult boot_desktop(); */ CF_DESKTOP_EXPORT int run_desktop_session(); +/** + * @brief Tries to become the single desktop shell instance. + * + * Acquires a lock file ($TMPDIR/cfdesktop-shell.lock) via QLockFile; + * if a live shell already holds it, this returns false so the caller can exit + * cleanly. A stale lock from a crashed shell is reclaimed because QLockFile + * verifies the owning PID. Uses QLockFile (QtCore only -- no D-Bus, suits the + * 6ULL rule). + * + * @return true if this process is the sole shell; false if another + * instance is already running (caller should exit). + * @throws None + * @since 0.20 + * @ingroup none + */ +CF_DESKTOP_EXPORT bool acquireSingleInstanceLock(); + } // namespace cf::desktop From e0e34fe6b6e4fdfa7cd9d40d34f7580ac327186d Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Wed, 1 Jul 2026 12:42:04 +0800 Subject: [PATCH 4/4] fix(shell): keep single-instance guard in exe target (MSVC link fix) The previous single-instance commit declared acquireSingleInstanceLock() with CF_DESKTOP_EXPORT in desktop_entry.h, but desktop_entry.cpp lives in the CFDesktopMain STATIC lib which is compiled WITHOUT CFDESKTOP_EXPORTS. On MSVC the function was therefore treated as dllimport (not dllexport) -- no actual definition was emitted -> LNK2019 unresolved external when main.cpp referenced it. Linux linked fine because static linking merges symbols regardless of declspec. Move acquireSingleInstanceLock() into main.cpp's anonymous namespace (the CFDesktop exe target). It only serves main() and never needs to cross the CFDesktop_shared dll boundary, so dropping the export is correct, not a workaround. desktop_entry reverts to just boot_desktop/run_desktop_session. Verified on Linux: single-instance still works (second instance exits, lock file created). The MSVC link error should be gone since the symbol no longer crosses the dll boundary. --- desktop/main.cpp | 23 +++++++++++++++++++++-- desktop/main/desktop_entry.cpp | 14 -------------- desktop/main/desktop_entry.h | 17 ----------------- 3 files changed, 21 insertions(+), 33 deletions(-) diff --git a/desktop/main.cpp b/desktop/main.cpp index 046137885..e8c96397e 100644 --- a/desktop/main.cpp +++ b/desktop/main.cpp @@ -1,9 +1,28 @@ #include "desktop_entry.h" +#include +#include +#include #include #include "ui/widget/material/application/material_application.h" +namespace { +/// @brief Tries to become the single desktop shell instance. +/// +/// Acquires a QLockFile ($TMPDIR/cfdesktop-shell.lock) held for the whole +/// process; a stale lock from a crashed shell is reclaimed because QLockFile +/// verifies the owning PID. QtCore-only (no QtNetwork/D-Bus, suits the 6ULL +/// rule). Lives in main.cpp's anonymous namespace so it stays in the exe +/// target and never crosses the CFDesktop_shared dll boundary (no export +/// headaches on MSVC). +bool acquireSingleInstanceLock() { + static QLockFile lock(QDir::tempPath() + QStringLiteral("/cfdesktop-shell.lock")); + lock.setStaleLockTime(0); + return lock.tryLock(100); // false if another live shell holds the lock +} +} // namespace + int main(int argc, char* argv[]) { // MaterialApplication registers the MD3 themes (light/dark) into ThemeManager // on construction, so panels and widgets resolve real theme tokens at runtime. @@ -11,8 +30,8 @@ int main(int argc, char* argv[]) { // Refuse to start a second desktop shell -- two fullscreen shells on one // screen fight over geometry and WindowManagers cross-track each other's - // windows. See acquireSingleInstanceLock() docs. - if (!cf::desktop::acquireSingleInstanceLock()) { + // windows. + if (!acquireSingleInstanceLock()) { qWarning("CFDesktop: another instance is already running; exiting."); return 0; } diff --git a/desktop/main/desktop_entry.cpp b/desktop/main/desktop_entry.cpp index 525b65170..619953523 100644 --- a/desktop/main/desktop_entry.cpp +++ b/desktop/main/desktop_entry.cpp @@ -3,22 +3,8 @@ #include "ui/CFDesktopEntity.h" #include "ui/CFDesktopWindowProxy.h" -#include -#include -#include - namespace cf::desktop { -bool acquireSingleInstanceLock() { - // QLockFile (QtCore only -- no QtNetwork/D-Bus, suits the 6ULL rule). The - // static instance lives for the whole process and auto-unlocks on exit; a - // stale lock left by a crash is reclaimed because QLockFile verifies the - // owning PID. - static QLockFile lock(QDir::tempPath() + QStringLiteral("/cfdesktop-shell.lock")); - lock.setStaleLockTime(0); - return lock.tryLock(100); // false if another live shell holds the lock -} - DesktopExitResult boot_desktop() { /* Desktop Placing here, for easy access handle... by manual :) */ // Make A Instance Here diff --git a/desktop/main/desktop_entry.h b/desktop/main/desktop_entry.h index 322f3d3af..7beb6935a 100644 --- a/desktop/main/desktop_entry.h +++ b/desktop/main/desktop_entry.h @@ -52,21 +52,4 @@ DesktopExitResult boot_desktop(); */ CF_DESKTOP_EXPORT int run_desktop_session(); -/** - * @brief Tries to become the single desktop shell instance. - * - * Acquires a lock file ($TMPDIR/cfdesktop-shell.lock) via QLockFile; - * if a live shell already holds it, this returns false so the caller can exit - * cleanly. A stale lock from a crashed shell is reclaimed because QLockFile - * verifies the owning PID. Uses QLockFile (QtCore only -- no D-Bus, suits the - * 6ULL rule). - * - * @return true if this process is the sole shell; false if another - * instance is already running (caller should exit). - * @throws None - * @since 0.20 - * @ingroup none - */ -CF_DESKTOP_EXPORT bool acquireSingleInstanceLock(); - } // namespace cf::desktop