feat(portal): 接入合成器传送门,暂存区固定/消耗语义

- 新增 PortalClient:org.gxde.Portal 的 BeginPortal / CancelPortal 与 drag 信号
- 传送门区域悬停 150ms 后打开合成器 overview;无窗口/不支持时给出提示
- 拖拽开始时自动弹出面板(120ms 延迟,mime 过滤),拖拽结束自动收起
- 暂存区每行新增固定按钮;默认拖出后消耗,固定后保留;取消拖拽不消耗
- 拒绝自身拖拽落入暂存区,避免重复条目
This commit is contained in:
2026-10-30 19:55:25 +08:00
parent f9204a03d1
commit 68d3eb1a24
10 changed files with 314 additions and 6 deletions
+1
View File
@@ -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
)
+2
View File
@@ -211,6 +211,8 @@ interface org.gxde.Portal
- 每行:复选框、缩略图/类型图标、名称、关闭按钮。
- 选中任意复选框 → 列表上方出现「全选 / 反选 / 删除选中」;取消全不选后收起。
- 拖动行排序(带动画),拖出到别的 App(`QDrag`),拖到传送门(原生 drop)。
- 每行有「固定」按钮:默认拖出后**消耗**(drop 成功即从暂存区移除);固定后拖出保留。
拖回暂存区或按 Esc 取消拖拽不消耗。
---
+24
View File
@@ -4,10 +4,12 @@
#include "app.h"
#include "config.h"
#include "portal/portalclient.h"
#include "staging/stagingmodel.h"
#include "ui/portalpanel.h"
#include <QLocalServer>
#include <QTimer>
#include <QLocalSocket>
#include <unistd.h>
@@ -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();
+1
View File
@@ -35,4 +35,5 @@ private:
PortalPanel *m_panel = nullptr;
QLocalServer *m_server = nullptr;
bool m_daemon = false;
bool m_dragging = false;
};
+103
View File
@@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "portalclient.h"
#include <QDBusConnection>
#include <QDBusConnectionInterface>
#include <QDBusMessage>
#include <QStringList>
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);
}
+46
View File
@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
/**
* 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;
};
+1
View File
@@ -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;
+18
View File
@@ -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<qint64>(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()) {
+2
View File
@@ -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;
+116 -6
View File
@@ -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<StagingModel *>(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<StagingModel *>(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);