diff --git a/CMakeLists.txt b/CMakeLists.txt index 1e5ba4a..7d27cf8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,6 +22,7 @@ set(GXDE_AI_SEE_SOURCES src/app.cpp src/config.cpp src/staging/stagingmodel.cpp + src/portal/portalclient.cpp src/ui/portalpanel.cpp ) diff --git a/docs/PLAN.md b/docs/PLAN.md index 2bdf432..f963bab 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -211,6 +211,8 @@ interface org.gxde.Portal - 每行:复选框、缩略图/类型图标、名称、关闭按钮。 - 选中任意复选框 → 列表上方出现「全选 / 反选 / 删除选中」;取消全不选后收起。 - 拖动行排序(带动画),拖出到别的 App(`QDrag`),拖到传送门(原生 drop)。 +- 每行有「固定」按钮:默认拖出后**消耗**(drop 成功即从暂存区移除);固定后拖出保留。 + 拖回暂存区或按 Esc 取消拖拽不消耗。 --- diff --git a/src/app.cpp b/src/app.cpp index c02a8ef..e799b64 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -4,10 +4,12 @@ #include "app.h" #include "config.h" +#include "portal/portalclient.h" #include "staging/stagingmodel.h" #include "ui/portalpanel.h" #include +#include #include #include @@ -50,6 +52,28 @@ bool App::init(bool daemon, bool forceShow) m_model = new StagingModel(this); + PortalClient *portal = PortalClient::instance(); + connect(portal, &PortalClient::dragStarted, this, [this](const QString &mimeTypes) { + if (!PortalClient::mimeInteresting(mimeTypes)) { + return; + } + m_dragging = true; + if (!m_model->isEmpty()) { + return; + } + QTimer::singleShot(120, this, [this]() { + if (m_dragging && m_model->isEmpty()) { + showPanel(); + } + }); + }); + connect(portal, &PortalClient::dragEnded, this, [this]() { + m_dragging = false; + if (m_model->isEmpty() && m_panel && m_panel->isVisible()) { + m_panel->dismiss(); + } + }); + connect(m_model, &StagingModel::countChanged, this, [this]() { if (!m_daemon && !m_model->isEmpty() && m_panel) { m_panel->popup(); diff --git a/src/app.h b/src/app.h index 965e3d5..ded8ec2 100644 --- a/src/app.h +++ b/src/app.h @@ -35,4 +35,5 @@ private: PortalPanel *m_panel = nullptr; QLocalServer *m_server = nullptr; bool m_daemon = false; + bool m_dragging = false; }; diff --git a/src/portal/portalclient.cpp b/src/portal/portalclient.cpp new file mode 100644 index 0000000..8cc9edc --- /dev/null +++ b/src/portal/portalclient.cpp @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "portalclient.h" + +#include +#include +#include +#include + +namespace { +constexpr const char *kService = "org.gxde.Portal"; +constexpr const char *kPath = "/org/gxde/Portal"; +constexpr const char *kInterface = "org.gxde.Portal"; +} + +PortalClient *PortalClient::instance() +{ + static PortalClient *self = new PortalClient; + return self; +} + +PortalClient::PortalClient(QObject *parent) + : QObject(parent) +{ + auto bus = QDBusConnection::sessionBus(); + + if (auto *iface = bus.interface()) { + m_available = iface->isServiceRegistered(QString::fromLatin1(kService)); + connect(iface, &QDBusConnectionInterface::serviceRegistered, this, + [this](const QString &name) { + if (name == QString::fromLatin1(kService)) { + m_available = true; + } + }); + connect(iface, &QDBusConnectionInterface::serviceUnregistered, this, + [this](const QString &name) { + if (name == QString::fromLatin1(kService)) { + m_available = false; + } + }); + } + + bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath), + QString::fromLatin1(kInterface), QStringLiteral("DragStarted"), this, + SLOT(onDragStarted(QString))); + bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath), + QString::fromLatin1(kInterface), QStringLiteral("DragEnded"), this, + SLOT(onDragEnded())); + bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath), + QString::fromLatin1(kInterface), QStringLiteral("PortalDropped"), this, + SLOT(onDropped(QString, bool))); +} + +bool PortalClient::beginPortal() +{ + QDBusMessage message = + QDBusMessage::createMethodCall(QString::fromLatin1(kService), + QString::fromLatin1(kPath), + QString::fromLatin1(kInterface), + QStringLiteral("BeginPortal")); + const QDBusMessage reply = QDBusConnection::sessionBus().call(message, QDBus::Block, 500); + if (reply.type() != QDBusMessage::ReplyMessage || reply.arguments().isEmpty()) { + return false; + } + return reply.arguments().constFirst().toBool(); +} + +void PortalClient::cancelPortal() +{ + QDBusMessage message = + QDBusMessage::createMethodCall(QString::fromLatin1(kService), + QString::fromLatin1(kPath), + QString::fromLatin1(kInterface), + QStringLiteral("CancelPortal")); + QDBusConnection::sessionBus().call(message, QDBus::NoBlock); +} + +bool PortalClient::mimeInteresting(const QString &mimeTypes) +{ + const QStringList types = mimeTypes.split(QLatin1Char(';'), Qt::SkipEmptyParts); + for (const QString &type : types) { + if (type.startsWith(QLatin1String("text/")) || type.startsWith(QLatin1String("image/"))) { + return true; + } + } + return false; +} + +void PortalClient::onDragStarted(const QString &mimeTypes) +{ + Q_EMIT dragStarted(mimeTypes); +} + +void PortalClient::onDragEnded() +{ + Q_EMIT dragEnded(); +} + +void PortalClient::onDropped(const QString &uuid, bool accepted) +{ + Q_EMIT dropped(uuid, accepted); +} diff --git a/src/portal/portalclient.h b/src/portal/portalclient.h new file mode 100644 index 0000000..e081ac2 --- /dev/null +++ b/src/portal/portalclient.h @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +/** + * Client for the compositor side "portal" (org.gxde.Portal). + * + * Start with \a beginPortal() while a drag hovers the portal zone. The + * compositor opens the window overview in portal mode and forwards the drag + * to the window under the cursor, so the target application receives a real + * wl_data_device drop at the cursor position. + */ +class PortalClient : public QObject +{ + Q_OBJECT + +public: + static PortalClient *instance(); + + bool available() const { return m_available; } + + /** @return true when the compositor opened the overview. */ + bool beginPortal(); + void cancelPortal(); + + /** Filter the drag mime types we want to react to. */ + static bool mimeInteresting(const QString &mimeTypes); + +signals: + void dragStarted(const QString &mimeTypes); + void dragEnded(); + void dropped(const QString &uuid, bool accepted); + +private slots: + void onDragStarted(const QString &mimeTypes); + void onDragEnded(); + void onDropped(const QString &uuid, bool accepted); + +private: + explicit PortalClient(QObject *parent = nullptr); + + bool m_available = false; +}; diff --git a/src/staging/stagingitem.h b/src/staging/stagingitem.h index 517c777..137db57 100644 --- a/src/staging/stagingitem.h +++ b/src/staging/stagingitem.h @@ -30,6 +30,7 @@ struct StagingItem QString text; // full text (Text) QString name; // display name bool checked = false; + bool pinned = false; qint64 createdAt = 0; bool exists() const; diff --git a/src/staging/stagingmodel.cpp b/src/staging/stagingmodel.cpp index f86e307..de36126 100644 --- a/src/staging/stagingmodel.cpp +++ b/src/staging/stagingmodel.cpp @@ -82,6 +82,7 @@ QJsonObject StagingItem::toJson() const obj.insert(QStringLiteral("text"), text); obj.insert(QStringLiteral("name"), name); obj.insert(QStringLiteral("checked"), checked); + obj.insert(QStringLiteral("pinned"), pinned); obj.insert(QStringLiteral("createdAt"), createdAt); return obj; } @@ -95,6 +96,7 @@ StagingItem StagingItem::fromJson(const QJsonObject &obj) item.text = obj.value(QStringLiteral("text")).toString(); item.name = obj.value(QStringLiteral("name")).toString(); item.checked = obj.value(QStringLiteral("checked")).toBool(); + item.pinned = obj.value(QStringLiteral("pinned")).toBool(); item.createdAt = static_cast(obj.value(QStringLiteral("createdAt")).toDouble()); if (item.id.isEmpty()) { item.id = newId(); @@ -144,6 +146,8 @@ QVariant StagingModel::data(const QModelIndex &index, int role) const return item.text; case CheckedRole: return item.checked; + case PinnedRole: + return item.pinned; case ExistsRole: return item.exists(); case IconRole: @@ -302,6 +306,20 @@ void StagingModel::setChecked(int row, bool checked) save(); } +void StagingModel::setPinned(int row, bool pinned) +{ + if (row < 0 || row >= m_items.size()) { + return; + } + if (m_items[row].pinned == pinned) { + return; + } + m_items[row].pinned = pinned; + const QModelIndex idx = index(row); + Q_EMIT dataChanged(idx, idx, {PinnedRole}); + save(); +} + void StagingModel::clearAll() { if (m_items.isEmpty()) { diff --git a/src/staging/stagingmodel.h b/src/staging/stagingmodel.h index 056aed5..529bd89 100644 --- a/src/staging/stagingmodel.h +++ b/src/staging/stagingmodel.h @@ -31,6 +31,7 @@ public: UriRole, TextRole, CheckedRole, + PinnedRole, ExistsRole, IconRole, }; @@ -49,6 +50,7 @@ public: void addImage(const QImage &image, int *addedRow = nullptr); void setChecked(int row, bool checked); + void setPinned(int row, bool pinned); void clearAll(); const StagingItem *itemAt(int row) const; diff --git a/src/ui/portalpanel.cpp b/src/ui/portalpanel.cpp index 9456f54..bf46587 100644 --- a/src/ui/portalpanel.cpp +++ b/src/ui/portalpanel.cpp @@ -4,6 +4,7 @@ #include "portalpanel.h" #include "config.h" +#include "portal/portalclient.h" #include "staging/stagingitem.h" #include "staging/stagingmodel.h" @@ -57,14 +58,31 @@ QRect closeRect(const QRect &row) return QRect(row.right() - 36, row.center().y() - 14, 28, 28); } +QRect pinRect(const QRect &row) +{ + return QRect(row.right() - 66, row.center().y() - 14, 28, 28); +} + QRect textRect(const QRect &row) { const QRect icon = iconRect(row); - const QRect close = closeRect(row); - return QRect(icon.right() + 12, row.top() + 6, close.left() - icon.right() - 20, + const QRect pin = pinRect(row); + return QRect(icon.right() + 12, row.top() + 6, pin.left() - icon.right() - 20, row.height() - 12); } +void drawPushpin(QPainter *painter, const QRect &rect, const QColor &color, bool pinned) +{ + painter->save(); + painter->translate(rect.center()); + painter->rotate(45.0); + painter->setPen(QPen(color, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter->setBrush(pinned ? QBrush(color) : Qt::NoBrush); + painter->drawRoundedRect(QRectF(-4.5, -9.0, 9.0, 7.0), 3.0, 3.0); + painter->drawLine(QPointF(0.0, -2.0), QPointF(0.0, 7.5)); + painter->restore(); +} + bool mimeSupported(const QMimeData *mime) { return mime && (mime->hasUrls() || mime->hasText() || mime->hasImage()); @@ -169,6 +187,13 @@ public: const QRect subRect(text.left(), text.top() + 24, text.width(), 16); painter->drawText(subRect, Qt::AlignLeft | Qt::AlignVCenter, typeLabel(index)); + const bool pinned = index.data(StagingModel::PinnedRole).toBool(); + if (pinned || hovered) { + drawPushpin(painter, pinRect(option.rect), + pinned ? QColor(120, 180, 255, 235) : QColor(255, 255, 255, 150), + pinned); + } + if (hovered) { const QRect close = closeRect(option.rect).adjusted(9, 9, -9, -9); painter->setPen(QPen(QColor(255, 255, 255, 170), 1.7, Qt::SolidLine, Qt::RoundCap)); @@ -195,6 +220,12 @@ public: return true; } + if (pinRect(option.rect).contains(pos)) { + auto *staging = static_cast(model); + staging->setPinned(index.row(), !index.data(StagingModel::PinnedRole).toBool()); + return true; + } + if (closeRect(option.rect).contains(pos)) { model->removeRow(index.row()); return true; @@ -259,7 +290,8 @@ protected: m_pressIndex = indexAt(m_pressPos); if (m_pressIndex.isValid()) { const QRect row = visualRect(m_pressIndex); - if (checkRect(row).contains(m_pressPos) || closeRect(row).contains(m_pressPos)) { + if (checkRect(row).contains(m_pressPos) || closeRect(row).contains(m_pressPos) || + pinRect(row).contains(m_pressPos)) { m_pressIndex = QModelIndex(); } } @@ -289,6 +321,10 @@ protected: void dragEnterEvent(QDragEnterEvent *event) override { + if (isOwnDrag(event->mimeData())) { + event->ignore(); + return; + } if (mimeSupported(event->mimeData())) { event->acceptProposedAction(); return; @@ -298,6 +334,10 @@ protected: void dragMoveEvent(QDragMoveEvent *event) override { + if (isOwnDrag(event->mimeData())) { + event->ignore(); + return; + } if (mimeSupported(event->mimeData())) { event->acceptProposedAction(); updateHover(event->position().toPoint()); @@ -308,7 +348,7 @@ protected: void dropEvent(QDropEvent *event) override { - if (!mimeSupported(event->mimeData())) { + if (isOwnDrag(event->mimeData()) || !mimeSupported(event->mimeData())) { event->ignore(); return; } @@ -332,6 +372,11 @@ private: viewport()->update(); } + static bool isOwnDrag(const QMimeData *mime) + { + return mime && mime->hasFormat(QStringLiteral("application/x-gxde-ai-see-staging")); + } + void startDragForIndex(const QModelIndex &index) { if (!index.isValid()) { @@ -341,8 +386,11 @@ private: const int type = index.data(StagingModel::TypeRole).toInt(); const QString uri = index.data(StagingModel::UriRole).toString(); const QString text = index.data(StagingModel::TextRole).toString(); + const QString id = index.data(StagingModel::IdRole).toString(); + const bool pinned = index.data(StagingModel::PinnedRole).toBool(); auto *mime = new QMimeData; + mime->setData(QStringLiteral("application/x-gxde-ai-see-staging"), id.toUtf8()); if (type == StagingItem::File || type == StagingItem::Image) { if (!uri.isEmpty()) { mime->setUrls({QUrl::fromLocalFile(uri)}); @@ -364,7 +412,24 @@ private: if (!icon.isNull()) { drag->setPixmap(icon.pixmap(48, 48)); } - drag->exec(Qt::CopyAction | Qt::MoveAction, Qt::CopyAction); + + const Qt::DropAction action = + drag->exec(Qt::CopyAction | Qt::MoveAction, Qt::CopyAction); + + /* Dragging an item out of the staging area consumes it unless it is + * pinned. A cancelled drag (Esc / dropped on nothing) keeps it. */ + if (action != Qt::IgnoreAction && !pinned) { + auto *staging = qobject_cast(model()); + if (staging) { + for (int row = 0; row < staging->count(); ++row) { + const StagingItem *item = staging->itemAt(row); + if (item && item->id == id) { + staging->removeRows(row, 1); + break; + } + } + } + } } StagingDelegate *m_delegate = nullptr; @@ -388,6 +453,14 @@ public: setAcceptDrops(true); setMinimumHeight(190); + m_portalTimer.setSingleShot(true); + m_portalTimer.setInterval(150); + connect(&m_portalTimer, &QTimer::timeout, this, [this]() { + if (m_active) { + Q_EMIT portalRequested(); + } + }); + m_chatButton = new QPushButton(tr("问问小埃 →"), this); m_chatButton->setCursor(Qt::PointingHandCursor); m_chatButton->setFlat(true); @@ -411,8 +484,19 @@ public: connect(&m_hintTimer, &QTimer::timeout, m_hintLabel, &QLabel::hide); } + void showMessage(const QString &message) { showHint(message); } + void setPortalActive(bool active) + { + m_portalActive = active; + if (!active) { + m_portalTimer.stop(); + } + } + signals: void chatRequested(); + void portalRequested(); + void portalCancelled(); protected: void resizeEvent(QResizeEvent *event) override @@ -486,6 +570,9 @@ protected: } m_active = true; update(); + if (!m_portalActive) { + m_portalTimer.start(); + } event->acceptProposedAction(); } @@ -501,14 +588,20 @@ protected: Q_UNUSED(event) m_active = false; update(); + m_portalTimer.stop(); } void dropEvent(QDropEvent *event) override { m_active = false; + m_portalTimer.stop(); update(); + if (!m_portalActive) { + showHint(tr("悬停在「传送门」上,再移动到目标窗口")); + event->ignore(); + return; + } event->acceptProposedAction(); - showHint(tr("传送门将在接入合成器后可用")); } private: @@ -522,8 +615,10 @@ private: QPushButton *m_chatButton = nullptr; QLabel *m_hintLabel = nullptr; QTimer m_hintTimer; + QTimer m_portalTimer; bool m_active = false; bool m_hover = false; + bool m_portalActive = false; }; /* ------------------------------------------------------------------------- */ @@ -618,6 +713,21 @@ void PortalPanel::setupUi() m_zone->setFixedWidth(kZoneWidth); outer->addWidget(m_zone); connect(m_zone, &PortalZone::chatRequested, this, &PortalPanel::openChatRequested); + connect(m_zone, &PortalZone::portalRequested, this, [this]() { + PortalClient *portal = PortalClient::instance(); + if (portal->beginPortal()) { + m_zone->setPortalActive(true); + m_zone->showMessage(tr("移动到目标窗口上松手")); + } else { + m_zone->showMessage(portal->available() ? tr("当前无已打开的窗口") + : tr("当前环境不支持传送门")); + } + }); + connect(m_zone, &PortalZone::portalCancelled, this, []() { + PortalClient::instance()->cancelPortal(); + }); + connect(PortalClient::instance(), &PortalClient::dragEnded, this, + [this]() { m_zone->setPortalActive(false); }); auto *right = new QVBoxLayout; right->setContentsMargins(0, 0, 0, 0);