From e3c8da1a3f7bd1ae78de2bbc384593361c3217b0 Mon Sep 17 00:00:00 2001 From: shenmo Date: Fri, 30 Oct 2026 20:12:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat,todo):=20=E5=B0=8F=E5=9F=83=E5=90=8C?= =?UTF-8?q?=E5=AD=A6=E5=AF=B9=E8=AF=9D=E7=AA=97=E5=8F=A3=E4=B8=8E=E5=BE=85?= =?UTF-8?q?=E5=8A=9E=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat: - DTK2 窗口:左侧会话列表 + 右侧对话区 + 输入框 - DeepSeek / 自定义 API,SSE 流式输出,首字即显示 - 会话持久化到 chat.json,新建/切换/标题自动生成 - 设置对话框:API Key、API 地址、模型、系统提示,含开通指引 - 拖放:左侧空白=新建对话,左侧会话=指定会话,右侧对话区=当前会话 - Ctrl+Enter 发送,错误以消息形式回显 Todo: - 待办窗口(layer-shell,不抢焦点、不进 dock),复选框/删除/新建 - 待办持久化 todo.json,--todo 打开,独立 desktop 入口 其他: - desktop 入口改为打开对话窗口,--chat 参数 - 单实例消息支持 show/todo/chat --- CMakeLists.txt | 5 +- data/gxde-ai-see-todo.desktop | 12 + data/gxde-ai-see.desktop | 2 +- src/app.cpp | 40 ++- src/app.h | 8 +- src/chat/chatclient.cpp | 182 ++++++++++ src/chat/chatclient.h | 46 +++ src/chat/chatwindow.cpp | 650 ++++++++++++++++++++++++++++++++++ src/chat/chatwindow.h | 74 ++++ src/main.cpp | 9 +- src/todo/todowindow.cpp | 304 ++++++++++++++++ src/todo/todowindow.h | 41 +++ 12 files changed, 1363 insertions(+), 10 deletions(-) create mode 100644 data/gxde-ai-see-todo.desktop create mode 100644 src/chat/chatclient.cpp create mode 100644 src/chat/chatclient.h create mode 100644 src/chat/chatwindow.cpp create mode 100644 src/chat/chatwindow.h create mode 100644 src/todo/todowindow.cpp create mode 100644 src/todo/todowindow.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 7373caf..1ba0901 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,9 @@ set(GXDE_AI_SEE_SOURCES src/actions/downloader.cpp src/actions/texttools.cpp src/todo/todomodel.cpp + src/todo/todowindow.cpp + src/chat/chatclient.cpp + src/chat/chatwindow.cpp src/ui/portalpanel.cpp ) @@ -71,7 +74,7 @@ target_include_directories(gxde-ai-see-selftest PRIVATE src ${ICU_INCLUDE_DIRS}) target_link_libraries(gxde-ai-see-selftest PRIVATE Qt6::Core Qt6::Gui ${ICU_LIBRARIES}) install(TARGETS gxde-ai-see RUNTIME DESTINATION bin) -install(FILES data/gxde-ai-see.desktop DESTINATION share/applications) +install(FILES data/gxde-ai-see.desktop data/gxde-ai-see-todo.desktop DESTINATION share/applications) install(DIRECTORY data/icons/ DESTINATION share/icons/hicolor) include(GNUInstallDirs) diff --git a/data/gxde-ai-see-todo.desktop b/data/gxde-ai-see-todo.desktop new file mode 100644 index 0000000..1aaed5d --- /dev/null +++ b/data/gxde-ai-see-todo.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Version=1.0 +Name=GXDE Todo +Name[zh_CN]=GXDE 待办 +Comment=Floating todo list of GXDE AI See +Comment[zh_CN]=小埃同学的待办 +Exec=gxde-ai-see --todo +Icon=gxde-ai-see +Terminal=false +Categories=Utility; +StartupNotify=false diff --git a/data/gxde-ai-see.desktop b/data/gxde-ai-see.desktop index 215554a..387fcce 100644 --- a/data/gxde-ai-see.desktop +++ b/data/gxde-ai-see.desktop @@ -7,7 +7,7 @@ GenericName=AI Assistant GenericName[zh_CN]=AI 助手 Comment=Drag to a window and it lands in the input box Comment[zh_CN]=拖一下,世界就到你手边 -Exec=gxde-ai-see --show +Exec=gxde-ai-see --chat Icon=gxde-ai-see Terminal=false Categories=Utility;Office; diff --git a/src/app.cpp b/src/app.cpp index e799b64..dc14e18 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -3,9 +3,12 @@ #include "app.h" +#include "chat/chatwindow.h" #include "config.h" #include "portal/portalclient.h" #include "staging/stagingmodel.h" +#include "todo/todomodel.h" +#include "todo/todowindow.h" #include "ui/portalpanel.h" #include @@ -19,7 +22,7 @@ App::App(QObject *parent) { } -bool App::init(bool daemon, bool forceShow) +bool App::init(bool daemon, bool forceShow, bool showTodo, bool showChat) { Config::instance()->init(); m_daemon = daemon; @@ -29,7 +32,7 @@ bool App::init(bool daemon, bool forceShow) QLocalSocket probe; probe.connectToServer(serverName); if (probe.waitForConnected(200)) { - probe.write("show"); + probe.write(showChat ? "chat" : (showTodo ? "todo" : "show")); probe.waitForBytesWritten(200); probe.disconnectFromServer(); return false; @@ -41,7 +44,12 @@ bool App::init(bool daemon, bool forceShow) connect(m_server, &QLocalServer::newConnection, this, [this]() { while (QLocalSocket *socket = m_server->nextPendingConnection()) { connect(socket, &QLocalSocket::readyRead, this, [this, socket]() { - if (socket->readAll().contains("show")) { + const QByteArray data = socket->readAll(); + if (data.contains("chat")) { + showChatWindow(); + } else if (data.contains("todo")) { + showTodoWindow(); + } else if (data.contains("show")) { showPanel(); } }); @@ -80,6 +88,12 @@ bool App::init(bool daemon, bool forceShow) } }); + if (showChat) { + showChatWindow(); + } + if (showTodo) { + showTodoWindow(); + } if (!daemon || forceShow || !m_model->isEmpty()) { showPanel(); } @@ -94,9 +108,7 @@ void App::ensurePanel() } m_panel = new PortalPanel(m_model); - connect(m_panel, &PortalPanel::openChatRequested, this, []() { - // TODO(M4): open the AI See chat window. - }); + connect(m_panel, &PortalPanel::openChatRequested, this, &App::showChatWindow); } void App::showPanel() @@ -104,3 +116,19 @@ void App::showPanel() ensurePanel(); m_panel->popup(); } + +void App::showChatWindow() +{ + if (!m_chatWindow) { + m_chatWindow = new ChatWindow; + } + m_chatWindow->activateForAsk(); +} + +void App::showTodoWindow() +{ + if (!m_todoWindow) { + m_todoWindow = new TodoWindow(TodoModel::instance()); + } + m_todoWindow->popup(); +} diff --git a/src/app.h b/src/app.h index ded8ec2..faa3295 100644 --- a/src/app.h +++ b/src/app.h @@ -5,9 +5,11 @@ #include +class ChatWindow; class PortalPanel; class QLocalServer; class StagingModel; +class TodoWindow; /** * Application controller: owns the staging model, the panel and the single @@ -25,14 +27,18 @@ public: * @param forceShow always show the panel on startup * @return false when another instance is already running */ - bool init(bool daemon, bool forceShow); + bool init(bool daemon, bool forceShow, bool showTodo = false, bool showChat = false); private: void ensurePanel(); void showPanel(); + void showTodoWindow(); + void showChatWindow(); StagingModel *m_model = nullptr; PortalPanel *m_panel = nullptr; + TodoWindow *m_todoWindow = nullptr; + ChatWindow *m_chatWindow = nullptr; QLocalServer *m_server = nullptr; bool m_daemon = false; bool m_dragging = false; diff --git a/src/chat/chatclient.cpp b/src/chat/chatclient.cpp new file mode 100644 index 0000000..bc1b4c7 --- /dev/null +++ b/src/chat/chatclient.cpp @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "chatclient.h" + +#include +#include +#include +#include +#include +#include + +#include "config.h" + +ChatClient::ChatClient(QObject *parent) + : QObject(parent) +{ + m_manager.setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy); +} + +void ChatClient::send(const QVector &messages) +{ + cancel(); + m_buffer.clear(); + m_fullText.clear(); + m_done = false; + + const QString apiKey = + Config::instance()->get(QStringLiteral("chat/apiKey")).toString().trimmed(); + if (apiKey.isEmpty()) { + Q_EMIT failed(tr("还没有配置 API Key。请先点右上角设置,填入 DeepSeek API Key。")); + return; + } + + QString baseUrl = Config::instance() + ->get(QStringLiteral("chat/baseUrl"), + QStringLiteral("https://api.deepseek.com")) + .toString() + .trimmed(); + while (baseUrl.endsWith(QLatin1Char('/'))) { + baseUrl.chop(1); + } + if (!baseUrl.endsWith(QStringLiteral("/chat/completions"))) { + baseUrl += QStringLiteral("/chat/completions"); + } + + const QString model = Config::instance() + ->get(QStringLiteral("chat/model"), + QStringLiteral("deepseek-chat")) + .toString(); + + QJsonArray jsonMessages; + for (const ChatMessage &message : messages) { + QJsonObject object; + object.insert(QStringLiteral("role"), message.role); + object.insert(QStringLiteral("content"), message.content); + jsonMessages.append(object); + } + + QJsonObject body; + body.insert(QStringLiteral("model"), model); + body.insert(QStringLiteral("messages"), jsonMessages); + body.insert(QStringLiteral("stream"), true); + body.insert(QStringLiteral("temperature"), 0.7); + + QNetworkRequest request{QUrl(baseUrl)}; + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + request.setRawHeader("Authorization", "Bearer " + apiKey.toUtf8()); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + + m_reply = m_manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact)); + connect(m_reply, &QNetworkReply::readyRead, this, &ChatClient::handleReadyRead); + connect(m_reply, &QNetworkReply::finished, this, &ChatClient::handleFinished); +} + +void ChatClient::cancel() +{ + if (!m_reply) { + return; + } + QNetworkReply *reply = m_reply; + m_reply = nullptr; + reply->disconnect(this); + reply->abort(); + reply->deleteLater(); +} + +void ChatClient::handleReadyRead() +{ + if (!m_reply) { + return; + } + m_buffer.append(m_reply->readAll()); + + int newline; + while ((newline = m_buffer.indexOf('\n')) >= 0) { + QByteArray line = m_buffer.left(newline); + m_buffer.remove(0, newline + 1); + if (line.endsWith('\r')) { + line.chop(1); + } + if (!line.startsWith("data:")) { + continue; + } + QByteArray payload = line.mid(5).trimmed(); + if (payload == "[DONE]") { + m_done = true; + continue; + } + handlePayload(payload); + } +} + +void ChatClient::handlePayload(const QByteArray &payload) +{ + const QJsonDocument document = QJsonDocument::fromJson(payload); + if (!document.isObject()) { + return; + } + const QJsonObject object = document.object(); + + if (object.contains(QStringLiteral("error"))) { + const QJsonObject error = object.value(QStringLiteral("error")).toObject(); + Q_EMIT failed(error.value(QStringLiteral("message")).toString(tr("请求失败"))); + return; + } + + const QJsonArray choices = object.value(QStringLiteral("choices")).toArray(); + if (choices.isEmpty()) { + return; + } + const QJsonObject deltaObject = + choices.first().toObject().value(QStringLiteral("delta")).toObject(); + const QString content = deltaObject.value(QStringLiteral("content")).toString(); + if (!content.isEmpty()) { + m_fullText += content; + Q_EMIT delta(content); + } +} + +void ChatClient::handleFinished() +{ + if (!m_reply) { + return; + } + QNetworkReply *reply = m_reply; + m_reply = nullptr; + + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError && reply->error() != QNetworkReply::OperationCanceledError) { + const QByteArray body = reply->readAll(); + QString message = reply->errorString(); + const QJsonDocument document = QJsonDocument::fromJson(body); + if (document.isObject()) { + const QJsonObject error = + document.object().value(QStringLiteral("error")).toObject(); + const QString detail = error.value(QStringLiteral("message")).toString(); + if (!detail.isEmpty()) { + message = detail; + } + } + if (status == 401) { + message = tr("API Key 无效或已过期"); + } + Q_EMIT failed(message); + reply->deleteLater(); + return; + } + + /* flush a final partial line */ + if (!m_buffer.isEmpty()) { + QByteArray line = m_buffer.trimmed(); + if (line.startsWith("data:")) { + handlePayload(line.mid(5).trimmed()); + } + m_buffer.clear(); + } + + Q_EMIT finished(m_fullText); + reply->deleteLater(); +} diff --git a/src/chat/chatclient.h b/src/chat/chatclient.h new file mode 100644 index 0000000..f7b374d --- /dev/null +++ b/src/chat/chatclient.h @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +struct ChatMessage +{ + QString role; // system / user / assistant + QString content; +}; + +/** + * Minimal streaming client for the DeepSeek (OpenAI compatible) chat API. + */ +class ChatClient : public QObject +{ + Q_OBJECT + +public: + explicit ChatClient(QObject *parent = nullptr); + + bool busy() const { return m_reply != nullptr; } + void send(const QVector &messages); + void cancel(); + +signals: + void delta(const QString &text); + void finished(const QString &text); + void failed(const QString &error); + +private: + void handleReadyRead(); + void handleFinished(); + void handlePayload(const QByteArray &payload); + + QNetworkAccessManager m_manager; + class QNetworkReply *m_reply = nullptr; + QByteArray m_buffer; + QString m_fullText; + bool m_done = false; +}; diff --git a/src/chat/chatwindow.cpp b/src/chat/chatwindow.cpp new file mode 100644 index 0000000..c186640 --- /dev/null +++ b/src/chat/chatwindow.cpp @@ -0,0 +1,650 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "chatwindow.h" + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +bool mimeOk(const QMimeData *mime) +{ + return mime && (mime->hasUrls() || mime->hasText() || mime->hasImage()); +} + +QString escapedHtml(const QString &text) +{ + QString html = text.toHtmlEscaped(); + html.replace(QLatin1Char('\n'), QStringLiteral("
")); + return html; +} + +} // namespace + +class ChatSidebar : public QListWidget +{ + Q_OBJECT + +public: + explicit ChatSidebar(QWidget *parent = nullptr) + : QListWidget(parent) + { + setAcceptDrops(true); + } + +signals: + void droppedAt(int index, const QMimeData *mime); + +protected: + void dragEnterEvent(QDragEnterEvent *event) override + { + if (mimeOk(event->mimeData())) { + event->acceptProposedAction(); + } else { + event->ignore(); + } + } + + void dragMoveEvent(QDragMoveEvent *event) override + { + if (mimeOk(event->mimeData())) { + event->acceptProposedAction(); + } + } + + void dropEvent(QDropEvent *event) override + { + if (!mimeOk(event->mimeData())) { + event->ignore(); + return; + } + const QModelIndex index = indexAt(event->position().toPoint()); + Q_EMIT droppedAt(index.isValid() ? index.row() : -1, event->mimeData()); + event->acceptProposedAction(); + } +}; + +class ChatView : public QTextBrowser +{ + Q_OBJECT + +public: + explicit ChatView(QWidget *parent = nullptr) + : QTextBrowser(parent) + { + setAcceptDrops(true); + } + +signals: + void dropped(const QMimeData *mime); + +protected: + void dragEnterEvent(QDragEnterEvent *event) override + { + if (mimeOk(event->mimeData())) { + event->acceptProposedAction(); + } else { + event->ignore(); + } + } + + void dragMoveEvent(QDragMoveEvent *event) override + { + if (mimeOk(event->mimeData())) { + event->acceptProposedAction(); + } + } + + void dropEvent(QDropEvent *event) override + { + if (!mimeOk(event->mimeData())) { + event->ignore(); + return; + } + Q_EMIT dropped(event->mimeData()); + event->acceptProposedAction(); + } +}; + +ChatWindow::ChatWindow(QWidget *parent) + : QWidget(parent) + , m_client(new ChatClient(this)) +{ + setWindowTitle(tr("小埃同学")); + setMinimumSize(760, 520); + resize(1000, 700); + m_storePath = Config::instance()->dataDir() + QStringLiteral("/chat.json"); + + setupUi(); + load(); + m_current = m_conversations.isEmpty() ? -1 : qBound(0, m_current, m_conversations.size() - 1); + updateSidebar(); + renderConversation(); + + if (QScreen *screen = QGuiApplication::primaryScreen()) { + move(screen->availableGeometry().center() - rect().center()); + } + + connect(m_client, &ChatClient::delta, this, [this](const QString &text) { + if (m_current < 0 || m_streamingIndex < 0 || + m_streamingIndex >= m_conversations[m_current].messages.size()) { + return; + } + m_conversations[m_current].messages[m_streamingIndex].content += text; + renderConversation(); + }); + connect(m_client, &ChatClient::finished, this, [this](const QString &) { + m_streamingIndex = -1; + if (m_current >= 0) { + m_conversations[m_current].updatedAt = QDateTime::currentMSecsSinceEpoch(); + } + save(); + renderConversation(); + }); + connect(m_client, &ChatClient::failed, this, [this](const QString &error) { + if (m_current >= 0 && m_streamingIndex >= 0 && + m_streamingIndex < m_conversations[m_current].messages.size()) { + m_conversations[m_current].messages[m_streamingIndex].content = + tr("⚠️ %1").arg(error); + } + m_streamingIndex = -1; + save(); + renderConversation(); + }); +} + +void ChatWindow::setupUi() +{ + auto *root = new QHBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + auto *sidebarPane = new QWidget(this); + sidebarPane->setObjectName(QStringLiteral("sidebar")); + sidebarPane->setFixedWidth(250); + sidebarPane->setStyleSheet(QStringLiteral( + "#sidebar { background: rgba(255,255,255,0.045);" + " border-right: 1px solid rgba(255,255,255,0.08); }")); + + auto *sidebarLayout = new QVBoxLayout(sidebarPane); + sidebarLayout->setContentsMargins(12, 14, 12, 12); + sidebarLayout->setSpacing(10); + + auto *brand = new QLabel(tr("小埃同学"), sidebarPane); + QFont brandFont = brand->font(); + brandFont.setPixelSize(17); + brandFont.setWeight(QFont::DemiBold); + brand->setFont(brandFont); + brand->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.92);")); + sidebarLayout->addWidget(brand); + + auto *subtitle = new QLabel(tr("把内容拖进对话即可提问"), sidebarPane); + subtitle->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.4); font-size: 11px;")); + sidebarLayout->addWidget(subtitle); + + m_sidebar = new ChatSidebar(sidebarPane); + m_sidebar->setFrameShape(QFrame::NoFrame); + m_sidebar->setStyleSheet(QStringLiteral( + "QListWidget { background: transparent; border: none; color: rgba(255,255,255,0.82); }" + "QListWidget::item { padding: 8px 6px; border-radius: 7px; }" + "QListWidget::item:hover { background: rgba(255,255,255,0.07); }" + "QListWidget::item:selected { background: rgba(120,180,255,0.24); color: white; }")); + sidebarLayout->addWidget(m_sidebar, 1); + + auto *newButton = new QPushButton(tr("+ 新建对话"), sidebarPane); + newButton->setCursor(Qt::PointingHandCursor); + newButton->setStyleSheet(QStringLiteral( + "QPushButton { color: rgba(255,255,255,0.85); background: rgba(255,255,255,0.08);" + " border: none; border-radius: 8px; padding: 7px; font-size: 12px; }" + "QPushButton:hover { background: rgba(120,180,255,0.28); color: white; }")); + sidebarLayout->addWidget(newButton); + + auto *settingsButton = new QPushButton(tr("设置"), sidebarPane); + settingsButton->setCursor(Qt::PointingHandCursor); + settingsButton->setStyleSheet(QStringLiteral( + "QPushButton { color: rgba(255,255,255,0.55); background: transparent;" + " border: none; padding: 4px; font-size: 12px; }" + "QPushButton:hover { color: white; }")); + sidebarLayout->addWidget(settingsButton); + + root->addWidget(sidebarPane); + + auto *right = new QWidget(this); + auto *rightLayout = new QVBoxLayout(right); + rightLayout->setContentsMargins(14, 14, 14, 12); + rightLayout->setSpacing(10); + + m_view = new ChatView(right); + m_view->setOpenExternalLinks(true); + m_view->setFrameShape(QFrame::NoFrame); + m_view->setStyleSheet(QStringLiteral( + "QTextBrowser { background: transparent; color: rgba(240,240,240,0.94);" + " border: none; font-size: 14px; }")); + rightLayout->addWidget(m_view, 1); + + m_status = new QLabel(right); + m_status->setStyleSheet(QStringLiteral("color: rgba(255,170,120,0.9); font-size: 12px;")); + m_status->hide(); + rightLayout->addWidget(m_status); + + auto *inputRow = new QHBoxLayout; + inputRow->setSpacing(10); + + m_input = new QPlainTextEdit(right); + m_input->setPlaceholderText(tr("输入消息,Ctrl+Enter 发送;也可以把文件/文字拖进窗口")); + m_input->setFixedHeight(84); + m_input->setStyleSheet(QStringLiteral( + "QPlainTextEdit { background: rgba(255,255,255,0.06); color: rgba(245,245,245,0.95);" + " border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 8px;" + " font-size: 13px; }")); + inputRow->addWidget(m_input, 1); + + m_sendButton = new QPushButton(tr("发送"), right); + m_sendButton->setFixedSize(76, 40); + m_sendButton->setCursor(Qt::PointingHandCursor); + m_sendButton->setStyleSheet(QStringLiteral( + "QPushButton { color: white; background: rgba(74,144,255,0.85); border: none;" + " border-radius: 10px; font-size: 13px; }" + "QPushButton:hover { background: rgba(90,160,255,0.95); }" + "QPushButton:disabled { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.4); }")); + inputRow->addWidget(m_sendButton); + + rightLayout->addLayout(inputRow); + root->addWidget(right, 1); + + connect(newButton, &QPushButton::clicked, this, &ChatWindow::newConversation); + connect(settingsButton, &QPushButton::clicked, this, &ChatWindow::openSettings); + connect(m_sendButton, &QPushButton::clicked, this, &ChatWindow::sendMessage); + connect(m_input, &QPlainTextEdit::textChanged, this, [this]() { + m_sendButton->setEnabled(!m_input->toPlainText().trimmed().isEmpty() && + !m_client->busy()); + }); + m_sendButton->setEnabled(false); + + connect(m_sidebar, &QListWidget::currentRowChanged, this, &ChatWindow::selectConversation); + connect(static_cast(m_sidebar), &ChatSidebar::droppedAt, this, + &ChatWindow::handleDropOnConversation); + connect(static_cast(m_view), &ChatView::dropped, this, + &ChatWindow::handleDroppedMime); + + auto *sendShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return), m_input); + connect(sendShortcut, &QShortcut::activated, this, &ChatWindow::sendMessage); + auto *sendShortcut2 = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Enter), m_input); + connect(sendShortcut2, &QShortcut::activated, this, &ChatWindow::sendMessage); +} + +void ChatWindow::load() +{ + QFile file(m_storePath); + if (!file.open(QIODevice::ReadOnly)) { + return; + } + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) { + return; + } + const QJsonObject root = document.object(); + + m_conversations.clear(); + for (const QJsonValue &value : + root.value(QStringLiteral("conversations")).toArray()) { + const QJsonObject object = value.toObject(); + ChatConversation conversation; + conversation.id = object.value(QStringLiteral("id")).toString(); + conversation.title = object.value(QStringLiteral("title")).toString(); + conversation.updatedAt = + static_cast(object.value(QStringLiteral("updatedAt")).toDouble()); + for (const QJsonValue &messageValue : + object.value(QStringLiteral("messages")).toArray()) { + const QJsonObject messageObject = messageValue.toObject(); + ChatMessage message; + message.role = messageObject.value(QStringLiteral("role")).toString(); + message.content = messageObject.value(QStringLiteral("content")).toString(); + conversation.messages.append(message); + } + m_conversations.append(conversation); + } + m_current = root.value(QStringLiteral("current")).toInt(-1); +} + +void ChatWindow::save() const +{ + QJsonArray conversations; + for (const ChatConversation &conversation : m_conversations) { + QJsonArray messages; + for (const ChatMessage &message : conversation.messages) { + QJsonObject object; + object.insert(QStringLiteral("role"), message.role); + object.insert(QStringLiteral("content"), message.content); + messages.append(object); + } + QJsonObject object; + object.insert(QStringLiteral("id"), conversation.id); + object.insert(QStringLiteral("title"), conversation.title); + object.insert(QStringLiteral("updatedAt"), + static_cast(conversation.updatedAt)); + object.insert(QStringLiteral("messages"), messages); + conversations.append(object); + } + + QJsonObject root; + root.insert(QStringLiteral("version"), 1); + root.insert(QStringLiteral("current"), m_current); + root.insert(QStringLiteral("conversations"), conversations); + + QSaveFile file(m_storePath); + if (!file.open(QIODevice::WriteOnly)) { + return; + } + file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + file.commit(); +} + +void ChatWindow::newConversation() +{ + if (m_client->busy()) { + m_client->cancel(); + m_streamingIndex = -1; + } + + ChatConversation conversation; + conversation.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + conversation.title = tr("新对话"); + conversation.updatedAt = QDateTime::currentMSecsSinceEpoch(); + + m_conversations.prepend(conversation); + m_current = 0; + updateSidebar(); + renderConversation(); + save(); + m_input->setFocus(); +} + +void ChatWindow::selectConversation(int index) +{ + if (index < 0 || index >= m_conversations.size() || index == m_current) { + return; + } + if (m_client->busy()) { + m_client->cancel(); + m_streamingIndex = -1; + } + m_current = index; + renderConversation(); +} + +void ChatWindow::updateSidebar() +{ + const QSignalBlocker blocker(m_sidebar); + m_sidebar->clear(); + for (const ChatConversation &conversation : m_conversations) { + m_sidebar->addItem(conversation.title); + } + if (m_current >= 0 && m_current < m_sidebar->count()) { + m_sidebar->setCurrentRow(m_current); + } +} + +void ChatWindow::renderConversation() +{ + QString html; + html += QStringLiteral( + ""); + + if (m_current < 0 || m_conversations.isEmpty()) { + html += QStringLiteral( + "

你好,我是小埃同学。

" + "

把小埃当成桌面上的一个抽屉:
" + "· 把文件、链接、文字拖到左侧列表空白处 = 新建对话
" + "· 拖到左侧某个对话 = 给那个对话补充内容
" + "· 拖到右侧对话区 = 发给当前对话

" + "

第一次使用需要配置 DeepSeek API Key:点左下角「设置」," + "在 platform.deepseek.com " + "创建 Key 后填进来。

"); + m_view->setHtml(html); + return; + } + + const ChatConversation &conversation = m_conversations.at(m_current); + if (conversation.messages.isEmpty()) { + html += QStringLiteral("

%1

" + "

直接输入问题,或者把内容拖进来。

") + .arg(escapedHtml(conversation.title)); + } + + for (const ChatMessage &message : conversation.messages) { + const QString role = message.role == QStringLiteral("user") ? tr("你") : tr("小埃"); + const QString cssClass = + message.role == QStringLiteral("user") ? QStringLiteral("user") + : QStringLiteral("assistant"); + html += QStringLiteral("
%1
").arg(role); + html += QStringLiteral("
%2
") + .arg(cssClass, escapedHtml(message.content)); + } + + if (m_client->busy() && m_streamingIndex >= 0) { + html += QStringLiteral("
%1
…
") + .arg(tr("小埃")); + } + + m_view->setHtml(html); + m_view->moveCursor(QTextCursor::End); + m_view->ensureCursorVisible(); +} + +void ChatWindow::sendMessage() +{ + if (m_client->busy()) { + return; + } + const QString text = m_input->toPlainText().trimmed(); + if (text.isEmpty()) { + return; + } + if (m_current < 0) { + newConversation(); + } + + ChatConversation &conversation = m_conversations[m_current]; + if (conversation.title == tr("新对话")) { + conversation.title = text.left(24); + } + conversation.messages.append(ChatMessage{ QStringLiteral("user"), text }); + conversation.messages.append(ChatMessage{ QStringLiteral("assistant"), QString() }); + conversation.updatedAt = QDateTime::currentMSecsSinceEpoch(); + m_streamingIndex = conversation.messages.size() - 1; + + m_input->clear(); + m_status->hide(); + m_sendButton->setEnabled(false); + updateSidebar(); + renderConversation(); + + QVector payload; + const QString systemPrompt = + Config::instance()->get(QStringLiteral("chat/systemPrompt")).toString().trimmed(); + if (!systemPrompt.isEmpty()) { + payload.append(ChatMessage{ QStringLiteral("system"), systemPrompt }); + } + for (int i = 0; i < conversation.messages.size() - 1; ++i) { + payload.append(conversation.messages.at(i)); + } + m_client->send(payload); +} + +void ChatWindow::appendError(const QString &message) +{ + m_status->setText(message); + m_status->show(); +} + +void ChatWindow::openSettings() +{ + QDialog dialog(this); + dialog.setWindowTitle(tr("小埃同学设置")); + dialog.setMinimumWidth(460); + + auto *layout = new QVBoxLayout(&dialog); + auto *form = new QFormLayout; + + auto *keyEdit = new QLineEdit(Config::instance() + ->get(QStringLiteral("chat/apiKey")) + .toString(), + &dialog); + keyEdit->setEchoMode(QLineEdit::Password); + keyEdit->setPlaceholderText(QStringLiteral("sk-…")); + form->addRow(tr("DeepSeek API Key"), keyEdit); + + auto *baseEdit = new QLineEdit(Config::instance() + ->get(QStringLiteral("chat/baseUrl"), + QStringLiteral("https://api.deepseek.com")) + .toString(), + &dialog); + form->addRow(tr("API 地址"), baseEdit); + + auto *modelBox = new QComboBox(&dialog); + modelBox->setEditable(true); + modelBox->addItems({ QStringLiteral("deepseek-chat"), + QStringLiteral("deepseek-reasoner") }); + modelBox->setCurrentText(Config::instance() + ->get(QStringLiteral("chat/model"), + QStringLiteral("deepseek-chat")) + .toString()); + form->addRow(tr("模型"), modelBox); + + auto *systemEdit = new QPlainTextEdit(&dialog); + systemEdit->setFixedHeight(80); + systemEdit->setPlainText(Config::instance() + ->get(QStringLiteral("chat/systemPrompt")) + .toString()); + form->addRow(tr("系统提示"), systemEdit); + + layout->addLayout(form); + + auto *hint = new QLabel( + tr("还没有账号?前往 platform.deepseek.com " + "开通并创建 API Key。Key 只保存在本机配置文件中。"), + &dialog); + hint->setWordWrap(true); + hint->setOpenExternalLinks(true); + hint->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.55); font-size: 12px;")); + layout->addWidget(hint); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel, + &dialog); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + layout->addWidget(buttons); + + if (dialog.exec() == QDialog::Accepted) { + Config::instance()->set(QStringLiteral("chat/apiKey"), keyEdit->text().trimmed()); + Config::instance()->set(QStringLiteral("chat/baseUrl"), baseEdit->text().trimmed()); + Config::instance()->set(QStringLiteral("chat/model"), modelBox->currentText()); + Config::instance()->set(QStringLiteral("chat/systemPrompt"), + systemEdit->toPlainText()); + Config::instance()->sync(); + renderConversation(); + } +} + +void ChatWindow::handleDroppedMime(const QMimeData *mime) +{ + if (!mime) { + return; + } + + QString text; + if (mime->hasUrls()) { + for (const QUrl &url : mime->urls()) { + if (!text.isEmpty()) { + text += QLatin1Char('\n'); + } + text += url.isLocalFile() ? QStringLiteral("[文件] %1").arg(url.toLocalFile()) + : url.toString(); + } + } else if (mime->hasImage()) { + const QImage image = qvariant_cast(mime->imageData()); + if (!image.isNull()) { + const QString dir = Config::instance()->cacheDir() + QStringLiteral("/chat"); + QDir().mkpath(dir); + const QString path = dir + QLatin1Char('/') + + QUuid::createUuid().toString(QUuid::WithoutBraces) + QStringLiteral(".png"); + image.save(path); + text = QStringLiteral("[图片] %1").arg(path); + } + } else if (mime->hasText()) { + text = mime->text(); + } + + if (text.isEmpty()) { + return; + } + + m_input->moveCursor(QTextCursor::End); + if (!m_input->toPlainText().isEmpty()) { + m_input->insertPlainText(QStringLiteral("\n")); + } + m_input->insertPlainText(text); + m_input->setFocus(); +} + +void ChatWindow::handleDropOnConversation(int index, const QMimeData *mime) +{ + if (index < 0) { + newConversation(); + } else { + selectConversation(index); + } + handleDroppedMime(mime); + show(); + raise(); + activateWindow(); +} + +void ChatWindow::activateForAsk() +{ + show(); + raise(); + activateWindow(); + m_input->setFocus(); +} + +void ChatWindow::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + m_input->setFocus(); +} + +#include "chatwindow.moc" diff --git a/src/chat/chatwindow.h b/src/chat/chatwindow.h new file mode 100644 index 0000000..671e64e --- /dev/null +++ b/src/chat/chatwindow.h @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#include "chatclient.h" + +class QLabel; +class QListWidget; +class QPlainTextEdit; +class QPushButton; +class QTextBrowser; +class QMimeData; + +struct ChatConversation +{ + QString id; + QString title; + qint64 updatedAt = 0; + QVector messages; +}; + +/** + * GXDE AI See chat window. + * + * Left: conversation list (also a drop target: empty area creates a new + * conversation, an item targets that conversation). + * Right: conversation view (drop targets the current conversation) and input. + */ +class ChatWindow : public QWidget +{ + Q_OBJECT + +public: + explicit ChatWindow(QWidget *parent = nullptr); + + /** Bring the window up for the "ask AI" action. */ + void activateForAsk(); + + /** Insert dropped content into the input of the current/new conversation. */ + void handleDroppedMime(const QMimeData *mime); + void handleDropOnConversation(int index, const QMimeData *mime); + +protected: + void showEvent(QShowEvent *event) override; + +private: + void setupUi(); + void load(); + void save() const; + void newConversation(); + void selectConversation(int index); + void renderConversation(); + void updateSidebar(); + void sendMessage(); + void openSettings(); + void appendError(const QString &message); + int currentIndex() const { return m_current; } + + QListWidget *m_sidebar = nullptr; + QTextBrowser *m_view = nullptr; + QPlainTextEdit *m_input = nullptr; + QPushButton *m_sendButton = nullptr; + QLabel *m_status = nullptr; + ChatClient *m_client = nullptr; + + QVector m_conversations; + int m_current = -1; + int m_streamingIndex = -1; + QString m_storePath; +}; diff --git a/src/main.cpp b/src/main.cpp index 4ce83c2..cc94edf 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -27,12 +27,19 @@ int main(int argc, char *argv[]) QStringLiteral("后台常驻,需要时再显示面板")); QCommandLineOption showOption(QStringLiteral("show"), QStringLiteral("启动后立即显示传送门面板")); + QCommandLineOption todoOption(QStringLiteral("todo"), + QStringLiteral("打开待办窗口")); + QCommandLineOption chatOption(QStringLiteral("chat"), + QStringLiteral("打开小埃同学对话窗口")); parser.addOption(daemonOption); parser.addOption(showOption); + parser.addOption(todoOption); + parser.addOption(chatOption); parser.process(app); App gxde; - if (!gxde.init(parser.isSet(daemonOption), parser.isSet(showOption))) { + if (!gxde.init(parser.isSet(daemonOption), parser.isSet(showOption), + parser.isSet(todoOption), parser.isSet(chatOption))) { return 0; } diff --git a/src/todo/todowindow.cpp b/src/todo/todowindow.cpp new file mode 100644 index 0000000..6af1231 --- /dev/null +++ b/src/todo/todowindow.cpp @@ -0,0 +1,304 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "todowindow.h" + +#include "todomodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef GXDE_HAVE_LAYERSHELLQT +#include +#endif + +namespace { + +constexpr int kWidth = 320; +constexpr int kRowHeight = 40; + +QRect checkRect(const QRect &row) +{ + return QRect(row.left() + 12, row.center().y() - 9, 18, 18); +} + +QRect deleteRect(const QRect &row) +{ + return QRect(row.right() - 34, row.center().y() - 13, 26, 26); +} + +} // namespace + +class TodoDelegate : public QStyledItemDelegate +{ +public: + explicit TodoDelegate(QObject *parent = nullptr) + : QStyledItemDelegate(parent) + { + } + + void setHoverRow(int row) { m_hoverRow = row; } + + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + Q_UNUSED(option) + Q_UNUSED(index) + return QSize(0, kRowHeight); + } + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override + { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + + const QRect row = option.rect.adjusted(4, 2, -4, -2); + const bool hovered = index.row() == m_hoverRow; + const bool done = index.data(TodoModel::DoneRole).toBool(); + + if (hovered) { + painter->setPen(Qt::NoPen); + painter->setBrush(QColor(255, 255, 255, 24)); + painter->drawRoundedRect(row, 8, 8); + } + + const QRect check = checkRect(option.rect); + painter->setPen(QPen(done ? QColor(74, 144, 255) : QColor(255, 255, 255, 90), 1.4)); + painter->setBrush(done ? QColor(74, 144, 255) : QColor(255, 255, 255, 12)); + painter->drawRoundedRect(check, 5, 5); + if (done) { + QPainterPath mark; + mark.moveTo(check.left() + 4.5, check.center().y()); + mark.lineTo(check.left() + 7.5, check.bottom() - 5); + mark.lineTo(check.right() - 4.0, check.top() + 5.0); + painter->setPen(QPen(Qt::white, 2.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter->setBrush(Qt::NoBrush); + painter->drawPath(mark); + } + + QFont font = option.font; + font.setPixelSize(13); + font.setStrikeOut(done); + painter->setFont(font); + painter->setPen(done ? QColor(255, 255, 255, 100) : QColor(255, 255, 255, 232)); + + const QRect textRect(check.right() + 12, option.rect.top(), + deleteRect(option.rect).left() - check.right() - 22, + option.rect.height()); + const QString text = index.data(TodoModel::TextRole).toString(); + const QFontMetrics metrics(font); + painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, + metrics.elidedText(text, Qt::ElideRight, textRect.width())); + + if (hovered) { + const QRect del = deleteRect(option.rect).adjusted(8, 8, -8, -8); + painter->setPen(QPen(QColor(255, 255, 255, 165), 1.6, Qt::SolidLine, Qt::RoundCap)); + painter->drawLine(del.topLeft(), del.bottomRight()); + painter->drawLine(del.topRight(), del.bottomLeft()); + } + + painter->restore(); + } + + bool editorEvent(QEvent *event, QAbstractItemModel *model, + const QStyleOptionViewItem &option, const QModelIndex &index) override + { + if (event->type() != QEvent::MouseButtonRelease) { + return false; + } + auto *mouse = static_cast(event); + const QPoint pos = mouse->position().toPoint(); + auto *todo = static_cast(model); + + if (checkRect(option.rect).contains(pos)) { + todo->setDone(index.row(), !index.data(TodoModel::DoneRole).toBool()); + return true; + } + if (deleteRect(option.rect).contains(pos)) { + todo->removeRow(index.row()); + return true; + } + return false; + } + +private: + int m_hoverRow = -1; +}; + +TodoWindow::TodoWindow(TodoModel *model, QWidget *parent) + : Dtk::Widget::DBlurEffectWidget(parent) + , m_model(model) +{ + setWindowTitle(tr("待办")); + setWindowFlags(Qt::FramelessWindowHint | Qt::Tool); + setAttribute(Qt::WA_TranslucentBackground); + setAttribute(Qt::WA_ShowWithoutActivating); + setMaskColor(QColor(24, 24, 28, 190)); + setBlurRectXRadius(16); + setBlurRectYRadius(16); + setFixedWidth(kWidth); + + setupWindow(); + setupUi(); + + connect(m_model, &TodoModel::countChanged, this, [this]() { + m_countLabel->setText(tr("%1 项").arg(m_model->count())); + refreshLayout(); + m_view->viewport()->update(); + }); +} + +void TodoWindow::setupWindow() +{ +#ifdef GXDE_HAVE_LAYERSHELLQT + if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) { + createWinId(); + if (auto *layer = LayerShellQt::Window::get(windowHandle())) { + LayerShellQt::Window::Anchors anchors; + anchors |= LayerShellQt::Window::AnchorRight; + anchors |= LayerShellQt::Window::AnchorTop; + layer->setAnchors(anchors); + layer->setMargins(QMargins(0, 72, 16, 0)); + layer->setLayer(LayerShellQt::Window::LayerOverlay); + layer->setKeyboardInteractivity(LayerShellQt::Window::KeyboardInteractivityNone); + layer->setExclusiveZone(0); + layer->setScope(QStringLiteral("gxde-ai-see-todo")); + layer->setScreenConfiguration(LayerShellQt::Window::ScreenFromQWindow); + } + } +#endif +} + +void TodoWindow::setupUi() +{ + auto *outer = new QVBoxLayout(this); + outer->setContentsMargins(12, 10, 12, 10); + outer->setSpacing(8); + + auto *header = new QHBoxLayout; + header->setContentsMargins(4, 0, 4, 0); + header->setSpacing(8); + + auto *title = new QLabel(tr("待办"), this); + QFont titleFont = title->font(); + titleFont.setPixelSize(13); + titleFont.setWeight(QFont::DemiBold); + title->setFont(titleFont); + title->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.9);")); + header->addWidget(title); + + m_countLabel = new QLabel(tr("%1 项").arg(m_model->count()), this); + m_countLabel->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.45); font-size: 11px;")); + header->addWidget(m_countLabel); + header->addStretch(); + + auto *addButton = new QPushButton(tr("+"), this); + addButton->setFixedSize(26, 26); + addButton->setCursor(Qt::PointingHandCursor); + addButton->setFocusPolicy(Qt::NoFocus); + addButton->setToolTip(tr("新建待办")); + addButton->setStyleSheet(QStringLiteral( + "QPushButton { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.08);" + " border: none; border-radius: 6px; font-size: 14px; }" + "QPushButton:hover { background: rgba(120,180,255,0.3); color: white; }")); + connect(addButton, &QPushButton::clicked, this, &TodoWindow::addEntry); + header->addWidget(addButton); + + auto *closeButton = new QPushButton(tr("×"), this); + closeButton->setFixedSize(26, 26); + closeButton->setCursor(Qt::PointingHandCursor); + closeButton->setFocusPolicy(Qt::NoFocus); + closeButton->setToolTip(tr("隐藏")); + closeButton->setStyleSheet(QStringLiteral( + "QPushButton { color: rgba(255,255,255,0.7); background: transparent;" + " border: none; font-size: 16px; }" + "QPushButton:hover { color: white; }")); + connect(closeButton, &QPushButton::clicked, this, &QWidget::hide); + header->addWidget(closeButton); + + outer->addLayout(header); + + m_view = new QListView(this); + m_view->setFrameShape(QFrame::NoFrame); + m_view->setSelectionMode(QAbstractItemView::NoSelection); + m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_view->setMouseTracking(true); + m_view->viewport()->setMouseTracking(true); + m_view->setStyleSheet(QStringLiteral("QListView { background: transparent; }")); + + auto *delegate = new TodoDelegate(m_view); + m_view->setModel(m_model); + m_view->setItemDelegate(delegate); + m_view->setUniformItemSizes(true); + + connect(m_view, &QListView::entered, this, [delegate](const QModelIndex &index) { + delegate->setHoverRow(index.row()); + }); + + outer->addWidget(m_view, 1); +} + +void TodoWindow::popup() +{ + refreshLayout(); + show(); + raise(); +} + +void TodoWindow::refreshLayout() +{ + QScreen *screen = this->screen(); + if (!screen) { + screen = QGuiApplication::primaryScreen(); + } + const int screenHeight = screen ? screen->availableGeometry().height() : 900; + const int content = m_model->count() * kRowHeight + 8; + const int listHeight = qBound(80, content, qMax(160, screenHeight / 2)); + if (m_view) { + m_view->setFixedHeight(listHeight); + } + resize(kWidth, listHeight + 56); +} + +void TodoWindow::addEntry() +{ + QDialog dialog(nullptr); + dialog.setWindowTitle(tr("新建待办")); + dialog.setMinimumWidth(320); + + auto *layout = new QVBoxLayout(&dialog); + auto *edit = new QLineEdit(&dialog); + edit->setPlaceholderText(tr("要做的事…")); + layout->addWidget(edit); + + auto *buttons = + new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + layout->addWidget(buttons); + + if (dialog.exec() == QDialog::Accepted) { + m_model->add(edit->text()); + refreshLayout(); + } +} + +void TodoWindow::showEvent(QShowEvent *event) +{ + Dtk::Widget::DBlurEffectWidget::showEvent(event); + refreshLayout(); +} diff --git a/src/todo/todowindow.h b/src/todo/todowindow.h new file mode 100644 index 0000000..829ea75 --- /dev/null +++ b/src/todo/todowindow.h @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +class QLabel; +class QListView; +class TodoModel; + +/** + * Floating todo window (optional keep-above, draggable, hidden from the dock). + * + * On Wayland it is a layer-shell overlay surface (keyboard interactivity is + * none so it never steals focus); new entries are added from the panel or the + * "+" button which opens a normal dialog. + */ +class TodoWindow : public Dtk::Widget::DBlurEffectWidget +{ + Q_OBJECT + +public: + explicit TodoWindow(TodoModel *model, QWidget *parent = nullptr); + + void popup(); + void refreshLayout(); + +protected: + void showEvent(QShowEvent *event) override; + +private: + void setupWindow(); + void setupUi(); + void addEntry(); + + TodoModel *m_model = nullptr; + QListView *m_view = nullptr; + QLabel *m_countLabel = nullptr; +};