feat(portal): 接入合成器传送门,暂存区固定/消耗语义
- 新增 PortalClient:org.gxde.Portal 的 BeginPortal / CancelPortal 与 drag 信号 - 传送门区域悬停 150ms 后打开合成器 overview;无窗口/不支持时给出提示 - 拖拽开始时自动弹出面板(120ms 延迟,mime 过滤),拖拽结束自动收起 - 暂存区每行新增固定按钮;默认拖出后消耗,固定后保留;取消拖拽不消耗 - 拒绝自身拖拽落入暂存区,避免重复条目
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -211,6 +211,8 @@ interface org.gxde.Portal
|
||||
- 每行:复选框、缩略图/类型图标、名称、关闭按钮。
|
||||
- 选中任意复选框 → 列表上方出现「全选 / 反选 / 删除选中」;取消全不选后收起。
|
||||
- 拖动行排序(带动画),拖出到别的 App(`QDrag`),拖到传送门(原生 drop)。
|
||||
- 每行有「固定」按钮:默认拖出后**消耗**(drop 成功即从暂存区移除);固定后拖出保留。
|
||||
拖回暂存区或按 Esc 取消拖拽不消耗。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+24
@@ -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();
|
||||
|
||||
@@ -35,4 +35,5 @@ private:
|
||||
PortalPanel *m_panel = nullptr;
|
||||
QLocalServer *m_server = nullptr;
|
||||
bool m_daemon = false;
|
||||
bool m_dragging = false;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user