diff --git a/apps/CMakeLists.txt b/apps/CMakeLists.txt index f009adf28..7354d686f 100644 --- a/apps/CMakeLists.txt +++ b/apps/CMakeLists.txt @@ -3,3 +3,7 @@ # 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) +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/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/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/desktop/main.cpp b/desktop/main.cpp index 5c0823682..e8c96397e 100644 --- a/desktop/main.cpp +++ b/desktop/main.cpp @@ -1,9 +1,40 @@ #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. 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. + if (!acquireSingleInstanceLock()) { + qWarning("CFDesktop: another instance is already running; exiting."); + return 0; + } + return cf::desktop::run_desktop_session(); } 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..bbeaeed4f 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,8 @@ 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 闭环(网格+搜索+启动),仅余入场动画。 +- **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)) ## 新人入门 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 +}