feat(m1): 应用骨架、传送门面板与暂存区

- CMake 构建(Qt6 + dtk2widget + LayerShellQt)
- 单实例 / --daemon / --show
- DBlur 双栏面板:传送门 + 暂存区,layer-shell 定位,不抢焦点
- 暂存区模型:文件/文字/图片、去重、JSON 持久化、文件消失清理
- 列表委托:复选框、缩略图、名称、hover 关闭按钮
- 全选 / 反选 / 删除选中
- 应用图标(多尺寸 hicolor)与 desktop 入口
- 方案更新:确认 layer-shell + 遮挡自动隐藏
This commit is contained in:
2026-10-30 19:47:07 +08:00
parent 472af9722e
commit f9204a03d1
21 changed files with 1763 additions and 5 deletions
+56
View File
@@ -0,0 +1,56 @@
cmake_minimum_required(VERSION 3.20)
project(gxde-ai-see
VERSION 0.1.0
DESCRIPTION "GXDE AI See - 小埃同学"
LANGUAGES CXX
)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_INCLUDE_CURRENT_DIR ON)
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network DBus Svg)
find_package(PkgConfig REQUIRED)
pkg_check_modules(DTK2WIDGET REQUIRED dtk2widget)
find_package(LayerShellQt QUIET)
set(GXDE_AI_SEE_SOURCES
src/main.cpp
src/app.cpp
src/config.cpp
src/staging/stagingmodel.cpp
src/ui/portalpanel.cpp
)
add_executable(gxde-ai-see ${GXDE_AI_SEE_SOURCES})
target_include_directories(gxde-ai-see PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${DTK2WIDGET_INCLUDE_DIRS}
)
target_link_libraries(gxde-ai-see PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Widgets
Qt6::Network
Qt6::DBus
Qt6::Svg
${DTK2WIDGET_LIBRARIES}
)
target_link_directories(gxde-ai-see PRIVATE ${DTK2WIDGET_LIBRARY_DIRS})
if (LayerShellQt_FOUND)
target_link_libraries(gxde-ai-see PRIVATE LayerShellQt::Interface)
target_compile_definitions(gxde-ai-see PRIVATE GXDE_HAVE_LAYERSHELLQT=1)
endif ()
install(TARGETS gxde-ai-see RUNTIME DESTINATION bin)
install(FILES data/gxde-ai-see.desktop DESTINATION share/applications)
install(DIRECTORY data/icons/ DESTINATION share/icons/hicolor)
include(GNUInstallDirs)
+16
View File
@@ -0,0 +1,16 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=GXDE AI See
Name[zh_CN]=GXDE 小埃同学
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
Icon=gxde-ai-see
Terminal=false
Categories=Utility;Office;
Keywords=ai;chat;deepseek;drag;portal;
Keywords[zh_CN]=小埃;AI;聊天;拖拽;传送门;
StartupNotify=false
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+2 -5
View File
@@ -374,11 +374,8 @@ gxde-ai-see/
## 13. 待确认
1. **「置顶」的语义**:v1 面板用 layer-shell,天然不抢焦点、不进 dock、位置精确,
但 layer-shell 无法做到「被普通窗口盖住」。可选:
- A(推荐 v1):layer-shell;「取消置顶」= 被窗口遮挡时自动隐藏;
- B:普通窗口 + 合成器窗口 hints(真正的 kept_above 开关,改动更大);
- C:layer-shell + 合成器扩展「普通层」。
1. ✅ **已确认(2026-10-30)**:面板采用 **layer-shell**;「取消置顶」实现为
**被窗口遮挡时自动隐藏**(需要合成器提供窗口几何,放在 M2 之后)。
2. **传送门原生 drop 方案**(§4.3)是否认可?这是整个方案的地基。
3. **内容工厂**的动作集合与「朝有空间方向展开」的交互是否 OK?
4. **Chat 初版范围**(§9.1)是否合适?MCP / 压缩是否要放进初版?
+82
View File
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "app.h"
#include "config.h"
#include "staging/stagingmodel.h"
#include "ui/portalpanel.h"
#include <QLocalServer>
#include <QLocalSocket>
#include <unistd.h>
App::App(QObject *parent)
: QObject(parent)
{
}
bool App::init(bool daemon, bool forceShow)
{
Config::instance()->init();
m_daemon = daemon;
const QString serverName = QStringLiteral("gxde-ai-see-%1").arg(getuid());
QLocalSocket probe;
probe.connectToServer(serverName);
if (probe.waitForConnected(200)) {
probe.write("show");
probe.waitForBytesWritten(200);
probe.disconnectFromServer();
return false;
}
QLocalServer::removeServer(serverName);
m_server = new QLocalServer(this);
m_server->listen(serverName);
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")) {
showPanel();
}
});
connect(socket, &QLocalSocket::disconnected, socket,
&QLocalSocket::deleteLater);
}
});
m_model = new StagingModel(this);
connect(m_model, &StagingModel::countChanged, this, [this]() {
if (!m_daemon && !m_model->isEmpty() && m_panel) {
m_panel->popup();
}
});
if (!daemon || forceShow || !m_model->isEmpty()) {
showPanel();
}
return true;
}
void App::ensurePanel()
{
if (m_panel) {
return;
}
m_panel = new PortalPanel(m_model);
connect(m_panel, &PortalPanel::openChatRequested, this, []() {
// TODO(M4): open the AI See chat window.
});
}
void App::showPanel()
{
ensurePanel();
m_panel->popup();
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
class PortalPanel;
class QLocalServer;
class StagingModel;
/**
* Application controller: owns the staging model, the panel and the single
* instance machinery.
*/
class App : public QObject
{
Q_OBJECT
public:
explicit App(QObject *parent = nullptr);
/**
* @param daemon run in the background, only show the panel when needed
* @param forceShow always show the panel on startup
* @return false when another instance is already running
*/
bool init(bool daemon, bool forceShow);
private:
void ensurePanel();
void showPanel();
StagingModel *m_model = nullptr;
PortalPanel *m_panel = nullptr;
QLocalServer *m_server = nullptr;
bool m_daemon = false;
};
+81
View File
@@ -0,0 +1,81 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "config.h"
#include <QDir>
#include <QStandardPaths>
Config *Config::instance()
{
static Config *self = new Config;
return self;
}
Config::Config(QObject *parent)
: QObject(parent)
{
}
void Config::init()
{
if (m_settings) {
return;
}
const QString configDir =
QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
QDir().mkpath(configDir);
m_dataDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir().mkpath(m_dataDir);
m_cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
QDir().mkpath(m_cacheDir);
m_settings = new QSettings(configDir + QStringLiteral("/config.ini"),
QSettings::IniFormat, this);
}
QVariant Config::get(const QString &key, const QVariant &fallback) const
{
if (!m_settings) {
return fallback;
}
return m_settings->value(key, fallback);
}
bool Config::getBool(const QString &key, bool fallback) const
{
return get(key, fallback).toBool();
}
int Config::getInt(const QString &key, int fallback) const
{
return get(key, fallback).toInt();
}
void Config::set(const QString &key, const QVariant &value)
{
if (!m_settings) {
return;
}
m_settings->setValue(key, value);
}
void Config::sync()
{
if (m_settings) {
m_settings->sync();
}
}
QString Config::dataDir() const
{
return m_dataDir;
}
QString Config::cacheDir() const
{
return m_cacheDir;
}
+40
View File
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QSettings>
#include <QVariant>
/**
* Thin wrapper around QSettings for the application configuration.
*
* Stored at ~/.config/gxde-ai-see/config.ini. A GSettings schema will be
* added once the setting set is stable.
*/
class Config : public QObject
{
Q_OBJECT
public:
static Config *instance();
void init();
QVariant get(const QString &key, const QVariant &fallback = QVariant()) const;
bool getBool(const QString &key, bool fallback = false) const;
int getInt(const QString &key, int fallback = 0) const;
void set(const QString &key, const QVariant &value);
void sync();
QString dataDir() const;
QString cacheDir() const;
private:
explicit Config(QObject *parent = nullptr);
QSettings *m_settings = nullptr;
QString m_dataDir;
QString m_cacheDir;
};
+40
View File
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include <DApplication>
#include <QCommandLineOption>
#include <QCommandLineParser>
#include "app.h"
DWIDGET_USE_NAMESPACE
int main(int argc, char *argv[])
{
DApplication app(argc, argv);
app.setOrganizationName(QStringLiteral("GXDE"));
app.setApplicationName(QStringLiteral("gxde-ai-see"));
app.setApplicationDisplayName(QStringLiteral("GXDE 小埃同学"));
app.setApplicationVersion(QStringLiteral("0.1.0"));
app.setQuitOnLastWindowClosed(false);
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("GXDE 小埃同学"));
parser.addHelpOption();
parser.addVersionOption();
QCommandLineOption daemonOption(QStringLiteral("daemon"),
QStringLiteral("后台常驻,需要时再显示面板"));
QCommandLineOption showOption(QStringLiteral("show"),
QStringLiteral("启动后立即显示传送门面板"));
parser.addOption(daemonOption);
parser.addOption(showOption);
parser.process(app);
App gxde;
if (!gxde.init(parser.isSet(daemonOption), parser.isSet(showOption))) {
return 0;
}
return app.exec();
}
+39
View File
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QFileInfo>
#include <QJsonObject>
#include <QString>
/**
* One entry in the staging area (暂存区).
*
* File - a local file, kept as a URI. Watched for deletion.
* Image - an image either dropped from a file or as raw image data. Raw
* images are written to the cache directory so the entry survives
* the original source being removed.
* Text - a plain text snippet, stored inline.
*/
struct StagingItem
{
enum Type {
File = 0,
Text = 1,
Image = 2,
};
Type type = File;
QString id;
QString uri; // file path (File), cache path (Image)
QString text; // full text (Text)
QString name; // display name
bool checked = false;
qint64 createdAt = 0;
bool exists() const;
QString dedupeKey() const;
QJsonObject toJson() const;
static StagingItem fromJson(const QJsonObject &obj);
};
+481
View File
@@ -0,0 +1,481 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "stagingmodel.h"
#include <QBuffer>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QImage>
#include <QImageReader>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMimeData>
#include <QSaveFile>
#include <QUrl>
#include <QUuid>
#include "config.h"
namespace {
constexpr int kPruneIntervalMs = 2000;
constexpr int kDefaultMaxItems = 200;
QString newId()
{
return QUuid::createUuid().toString(QUuid::WithoutBraces);
}
bool isImageFile(const QString &path)
{
QImageReader reader(path);
return reader.canRead();
}
QString elidedName(const QString &text)
{
QString line = text.simplified();
if (line.size() > 60) {
line = line.left(60) + QStringLiteral("…");
}
return line;
}
} // namespace
bool StagingItem::exists() const
{
switch (type) {
case Text:
return !text.isEmpty();
case File:
case Image:
return !uri.isEmpty() && QFileInfo::exists(uri);
}
return false;
}
QString StagingItem::dedupeKey() const
{
switch (type) {
case File:
return QStringLiteral("f:") + uri;
case Image:
return QStringLiteral("i:") + uri;
case Text:
return QStringLiteral("t:") + text;
}
return id;
}
QJsonObject StagingItem::toJson() const
{
QJsonObject obj;
obj.insert(QStringLiteral("type"), static_cast<int>(type));
obj.insert(QStringLiteral("id"), id);
obj.insert(QStringLiteral("uri"), uri);
obj.insert(QStringLiteral("text"), text);
obj.insert(QStringLiteral("name"), name);
obj.insert(QStringLiteral("checked"), checked);
obj.insert(QStringLiteral("createdAt"), createdAt);
return obj;
}
StagingItem StagingItem::fromJson(const QJsonObject &obj)
{
StagingItem item;
item.type = static_cast<Type>(obj.value(QStringLiteral("type")).toInt());
item.id = obj.value(QStringLiteral("id")).toString();
item.uri = obj.value(QStringLiteral("uri")).toString();
item.text = obj.value(QStringLiteral("text")).toString();
item.name = obj.value(QStringLiteral("name")).toString();
item.checked = obj.value(QStringLiteral("checked")).toBool();
item.createdAt = static_cast<qint64>(obj.value(QStringLiteral("createdAt")).toDouble());
if (item.id.isEmpty()) {
item.id = newId();
}
return item;
}
StagingModel::StagingModel(QObject *parent)
: QAbstractListModel(parent)
{
m_storePath = Config::instance()->dataDir() + QStringLiteral("/staging.json");
load();
m_pruneTimer.setInterval(kPruneIntervalMs);
connect(&m_pruneTimer, &QTimer::timeout, this, &StagingModel::pruneMissing);
m_pruneTimer.start();
}
StagingModel::~StagingModel() = default;
int StagingModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_items.size();
}
QVariant StagingModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return QVariant();
}
const StagingItem &item = m_items.at(index.row());
switch (role) {
case Qt::DisplayRole:
case NameRole:
return item.name;
case TypeRole:
return static_cast<int>(item.type);
case IdRole:
return item.id;
case UriRole:
return item.uri;
case TextRole:
return item.text;
case CheckedRole:
return item.checked;
case ExistsRole:
return item.exists();
case IconRole:
return iconFor(item);
default:
return QVariant();
}
}
bool StagingModel::removeRows(int row, int count, const QModelIndex &parent)
{
if (parent.isValid() || row < 0 || count <= 0 || row + count > m_items.size()) {
return false;
}
beginRemoveRows(QModelIndex(), row, row + count - 1);
for (int i = 0; i < count; ++i) {
m_iconCache.remove(m_items.at(row).id);
m_items.removeAt(row);
}
endRemoveRows();
save();
Q_EMIT countChanged();
return true;
}
Qt::ItemFlags StagingModel::flags(const QModelIndex &index) const
{
if (!index.isValid()) {
return Qt::NoItemFlags;
}
return Qt::ItemIsEnabled | Qt::ItemIsSelectable;
}
void StagingModel::addMimeData(const QMimeData *mimeData, int *addedRow)
{
if (!mimeData) {
return;
}
if (mimeData->hasUrls()) {
QStringList paths;
const QList<QUrl> urls = mimeData->urls();
for (const QUrl &url : urls) {
if (url.isLocalFile()) {
paths.append(url.toLocalFile());
}
}
if (!paths.isEmpty()) {
addFiles(paths, addedRow);
return;
}
}
if (mimeData->hasImage()) {
const QImage image = qvariant_cast<QImage>(mimeData->imageData());
if (!image.isNull()) {
addImage(image, addedRow);
return;
}
}
if (mimeData->hasText()) {
addText(mimeData->text(), addedRow);
}
}
void StagingModel::addFiles(const QStringList &localPaths, int *addedRow)
{
QString lastPath;
for (const QString &path : localPaths) {
const QFileInfo info(path);
if (!info.exists() || !info.isFile()) {
continue;
}
StagingItem item;
item.id = newId();
item.uri = info.absoluteFilePath();
item.name = info.fileName();
item.createdAt = QDateTime::currentMSecsSinceEpoch();
item.type = isImageFile(item.uri) ? StagingItem::Image : StagingItem::File;
insertItem(item, addedRow);
lastPath = item.uri;
}
Q_UNUSED(lastPath)
}
void StagingModel::addText(const QString &text, int *addedRow)
{
const QString trimmed = text.trimmed();
if (trimmed.isEmpty()) {
return;
}
StagingItem item;
item.id = newId();
item.type = StagingItem::Text;
item.text = text;
item.name = elidedName(trimmed);
item.createdAt = QDateTime::currentMSecsSinceEpoch();
insertItem(item, addedRow);
}
void StagingModel::addImage(const QImage &image, int *addedRow)
{
if (image.isNull()) {
return;
}
QByteArray png;
{
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
}
const QString hash =
QString::fromLatin1(QCryptographicHash::hash(png, QCryptographicHash::Sha1).toHex());
const QString dir = imageCacheDir();
QDir().mkpath(dir);
const QString path = dir + QLatin1Char('/') + hash + QStringLiteral(".png");
if (!QFileInfo::exists(path)) {
QFile file(path);
if (file.open(QIODevice::WriteOnly)) {
file.write(png);
file.close();
}
}
StagingItem item;
item.id = newId();
item.type = StagingItem::Image;
item.uri = path;
item.name = QStringLiteral("图片 %1")
.arg(QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss")));
item.createdAt = QDateTime::currentMSecsSinceEpoch();
insertItem(item, addedRow);
}
void StagingModel::setChecked(int row, bool checked)
{
if (row < 0 || row >= m_items.size()) {
return;
}
if (m_items[row].checked == checked) {
return;
}
m_items[row].checked = checked;
const QModelIndex idx = index(row);
Q_EMIT dataChanged(idx, idx, {CheckedRole});
save();
}
void StagingModel::clearAll()
{
if (m_items.isEmpty()) {
return;
}
beginResetModel();
m_items.clear();
m_iconCache.clear();
endResetModel();
save();
Q_EMIT countChanged();
}
const StagingItem *StagingModel::itemAt(int row) const
{
if (row < 0 || row >= m_items.size()) {
return nullptr;
}
return &m_items.at(row);
}
void StagingModel::insertItem(const StagingItem &item, int *addedRow)
{
const QString key = item.dedupeKey();
for (int i = 0; i < m_items.size(); ++i) {
if (m_items.at(i).dedupeKey() != key) {
continue;
}
if (i != 0) {
beginMoveRows(QModelIndex(), i, i, QModelIndex(), 0);
m_items.move(i, 0);
endMoveRows();
}
m_items[0].createdAt = item.createdAt;
const QModelIndex idx = index(0);
Q_EMIT dataChanged(idx, idx);
save();
if (addedRow) {
*addedRow = 0;
}
Q_EMIT itemAdded(0);
return;
}
const int maxItems = Config::instance()->getInt(QStringLiteral("staging/maxItems"),
kDefaultMaxItems);
while (m_items.size() >= maxItems && !m_items.isEmpty()) {
beginRemoveRows(QModelIndex(), m_items.size() - 1, m_items.size() - 1);
m_iconCache.remove(m_items.last().id);
m_items.removeLast();
endRemoveRows();
}
beginInsertRows(QModelIndex(), 0, 0);
m_items.prepend(item);
endInsertRows();
save();
if (addedRow) {
*addedRow = 0;
}
Q_EMIT countChanged();
Q_EMIT itemAdded(0);
}
QIcon StagingModel::iconFor(const StagingItem &item) const
{
auto it = m_iconCache.constFind(item.id);
if (it != m_iconCache.constEnd()) {
return it.value();
}
QIcon icon;
switch (item.type) {
case StagingItem::File:
icon = m_iconProvider.icon(QFileInfo(item.uri));
break;
case StagingItem::Image: {
QImageReader reader(item.uri);
reader.setAutoTransform(true);
const QImage image = reader.read();
if (!image.isNull()) {
icon = QIcon(QPixmap::fromImage(
image.scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)));
}
break;
}
case StagingItem::Text:
icon = QIcon::fromTheme(QStringLiteral("text-x-generic"));
break;
}
if (icon.isNull()) {
icon = QIcon::fromTheme(QStringLiteral("text-x-generic"));
}
if (m_iconCache.size() > 512) {
m_iconCache.clear();
}
m_iconCache.insert(item.id, icon);
return icon;
}
QString StagingModel::imageCacheDir() const
{
return Config::instance()->cacheDir() + QStringLiteral("/images");
}
void StagingModel::pruneMissing()
{
QList<int> stale;
for (int i = 0; i < m_items.size(); ++i) {
if (!m_items.at(i).exists()) {
stale.append(i);
}
}
if (stale.isEmpty()) {
return;
}
for (int i = stale.size() - 1; i >= 0; --i) {
removeRows(stale.at(i), 1);
}
}
void StagingModel::load()
{
QFile file(m_storePath);
if (!file.open(QIODevice::ReadOnly)) {
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
if (!doc.isObject()) {
return;
}
const QJsonArray array = doc.object().value(QStringLiteral("items")).toArray();
QList<StagingItem> items;
for (const QJsonValue &value : array) {
if (!value.isObject()) {
continue;
}
StagingItem item = StagingItem::fromJson(value.toObject());
if (item.exists()) {
items.append(item);
}
}
if (items.isEmpty()) {
return;
}
beginResetModel();
m_items = items;
endResetModel();
}
void StagingModel::save() const
{
QJsonArray array;
for (const StagingItem &item : m_items) {
array.append(item.toJson());
}
QJsonObject root;
root.insert(QStringLiteral("version"), 1);
root.insert(QStringLiteral("items"), array);
QSaveFile file(m_storePath);
if (!file.open(QIODevice::WriteOnly)) {
return;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
file.commit();
}
+76
View File
@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QAbstractListModel>
#include <QFileIconProvider>
#include <QHash>
#include <QIcon>
#include <QTimer>
#include "stagingitem.h"
class QMimeData;
/**
* Model backing the staging area.
*
* Entries are persisted as JSON in the application data directory and entries
* whose backing file disappeared are pruned periodically.
*/
class StagingModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
TypeRole = Qt::UserRole + 1,
IdRole,
NameRole,
UriRole,
TextRole,
CheckedRole,
ExistsRole,
IconRole,
};
explicit StagingModel(QObject *parent = nullptr);
~StagingModel() override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
bool removeRows(int row, int count, const QModelIndex &parent = QModelIndex()) override;
Qt::ItemFlags flags(const QModelIndex &index) const override;
void addMimeData(const QMimeData *mimeData, int *addedRow = nullptr);
void addFiles(const QStringList &localPaths, int *addedRow = nullptr);
void addText(const QString &text, int *addedRow = nullptr);
void addImage(const QImage &image, int *addedRow = nullptr);
void setChecked(int row, bool checked);
void clearAll();
const StagingItem *itemAt(int row) const;
bool isEmpty() const { return m_items.isEmpty(); }
int count() const { return m_items.size(); }
void load();
void save() const;
signals:
void countChanged();
void itemAdded(int row);
private:
void insertItem(const StagingItem &item, int *addedRow);
QIcon iconFor(const StagingItem &item) const;
QString imageCacheDir() const;
void pruneMissing();
QList<StagingItem> m_items;
QTimer m_pruneTimer;
mutable QHash<QString, QIcon> m_iconCache;
mutable QFileIconProvider m_iconProvider;
QString m_storePath;
};
+756
View File
@@ -0,0 +1,756 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "portalpanel.h"
#include "config.h"
#include "staging/stagingitem.h"
#include "staging/stagingmodel.h"
#include <QApplication>
#include <QCursor>
#include <QDrag>
#include <QDragEnterEvent>
#include <QDragLeaveEvent>
#include <QDragMoveEvent>
#include <QDropEvent>
#include <QFontMetrics>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QLabel>
#include <QListView>
#include <QMimeData>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QScreen>
#include <QStyledItemDelegate>
#include <QTimer>
#include <QUrl>
#include <QVBoxLayout>
#ifdef GXDE_HAVE_LAYERSHELLQT
#include <LayerShellQt/Window>
#endif
namespace {
constexpr int kPanelWidth = 500;
constexpr int kZoneWidth = 176;
constexpr int kRowHeight = 56;
constexpr int kMargin = 12;
constexpr int kMaxListHeight = 0; // computed from the screen
QRect checkRect(const QRect &row)
{
return QRect(row.left() + 12, row.center().y() - 9, 18, 18);
}
QRect iconRect(const QRect &row)
{
return QRect(row.left() + 42, row.center().y() - 17, 34, 34);
}
QRect closeRect(const QRect &row)
{
return QRect(row.right() - 36, 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,
row.height() - 12);
}
bool mimeSupported(const QMimeData *mime)
{
return mime && (mime->hasUrls() || mime->hasText() || mime->hasImage());
}
QString typeLabel(const QModelIndex &index)
{
const int type = index.data(StagingModel::TypeRole).toInt();
switch (type) {
case StagingItem::File:
return QObject::tr("文件");
case StagingItem::Image:
return QObject::tr("图片");
case StagingItem::Text:
return QObject::tr("文字 · %1 字")
.arg(index.data(StagingModel::TextRole).toString().size());
default:
return QString();
}
}
} // namespace
/* ------------------------------------------------------------------------- */
/* Delegate */
/* ------------------------------------------------------------------------- */
class StagingDelegate : public QStyledItemDelegate
{
public:
explicit StagingDelegate(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;
if (hovered) {
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(255, 255, 255, 26));
painter->drawRoundedRect(row, 8, 8);
}
const bool checked = index.data(StagingModel::CheckedRole).toBool();
const QRect check = checkRect(option.rect);
painter->setPen(QPen(checked ? QColor(74, 144, 255) : QColor(255, 255, 255, 80), 1.4));
painter->setBrush(checked ? QColor(74, 144, 255) : QColor(255, 255, 255, 12));
painter->drawRoundedRect(check, 5, 5);
if (checked) {
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);
}
const QIcon icon = index.data(StagingModel::IconRole).value<QIcon>();
if (!icon.isNull()) {
icon.paint(painter, iconRect(option.rect), Qt::AlignCenter);
}
const QRect text = textRect(option.rect);
QFont nameFont = option.font;
nameFont.setPixelSize(14);
nameFont.setWeight(QFont::Medium);
painter->setFont(nameFont);
painter->setPen(QColor(255, 255, 255, 236));
const QFontMetrics nameMetrics(nameFont);
const QString name = index.data(StagingModel::NameRole).toString();
const QRect nameRect(text.left(), text.top() + 2, text.width(), 20);
painter->drawText(nameRect, Qt::AlignLeft | Qt::AlignVCenter,
nameMetrics.elidedText(name, Qt::ElideMiddle, nameRect.width()));
QFont subFont = option.font;
subFont.setPixelSize(11);
painter->setFont(subFont);
painter->setPen(QColor(255, 255, 255, 120));
const QRect subRect(text.left(), text.top() + 24, text.width(), 16);
painter->drawText(subRect, Qt::AlignLeft | Qt::AlignVCenter, typeLabel(index));
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));
painter->drawLine(close.topLeft(), close.bottomRight());
painter->drawLine(close.topRight(), close.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<QMouseEvent *>(event);
const QPoint pos = mouse->position().toPoint();
if (checkRect(option.rect).contains(pos)) {
auto *staging = static_cast<StagingModel *>(model);
staging->setChecked(index.row(), !index.data(StagingModel::CheckedRole).toBool());
return true;
}
if (closeRect(option.rect).contains(pos)) {
model->removeRow(index.row());
return true;
}
return false;
}
private:
int m_hoverRow = -1;
};
/* ------------------------------------------------------------------------- */
/* View */
/* ------------------------------------------------------------------------- */
class StagingView : public QListView
{
Q_OBJECT
public:
explicit StagingView(StagingModel *model, QWidget *parent = nullptr)
: QListView(parent)
, m_delegate(new StagingDelegate(this))
{
setModel(model);
setItemDelegate(m_delegate);
setSelectionMode(QAbstractItemView::NoSelection);
setEditTriggers(QAbstractItemView::NoEditTriggers);
setFrameShape(QFrame::NoFrame);
setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setUniformItemSizes(true);
setSpacing(0);
setMouseTracking(true);
viewport()->setMouseTracking(true);
viewport()->setAutoFillBackground(false);
setStyleSheet(QStringLiteral("QListView { background: transparent; }"));
setAcceptDrops(true);
}
protected:
void paintEvent(QPaintEvent *event) override
{
QListView::paintEvent(event);
if (model() && model()->rowCount() == 0) {
QPainter painter(viewport());
painter.setRenderHint(QPainter::Antialiasing, true);
QFont font = this->font();
font.setPixelSize(12);
painter.setFont(font);
painter.setPen(QColor(255, 255, 255, 105));
painter.drawText(viewport()->rect().adjusted(12, 12, -12, -12),
Qt::AlignCenter | Qt::TextWordWrap,
tr("把文件、文字或图片拖到这里"));
}
}
void mousePressEvent(QMouseEvent *event) override
{
m_pressPos = event->position().toPoint();
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)) {
m_pressIndex = QModelIndex();
}
}
QListView::mousePressEvent(event);
}
void mouseMoveEvent(QMouseEvent *event) override
{
const QPoint pos = event->position().toPoint();
if ((event->buttons() & Qt::LeftButton) && m_pressIndex.isValid()) {
if ((pos - m_pressPos).manhattanLength() >= QApplication::startDragDistance()) {
const QModelIndex index = m_pressIndex;
m_pressIndex = QModelIndex();
startDragForIndex(index);
return;
}
}
updateHover(pos);
QListView::mouseMoveEvent(event);
}
void leaveEvent(QEvent *event) override
{
updateHover(QPoint(-1, -1));
QListView::leaveEvent(event);
}
void dragEnterEvent(QDragEnterEvent *event) override
{
if (mimeSupported(event->mimeData())) {
event->acceptProposedAction();
return;
}
event->ignore();
}
void dragMoveEvent(QDragMoveEvent *event) override
{
if (mimeSupported(event->mimeData())) {
event->acceptProposedAction();
updateHover(event->position().toPoint());
return;
}
event->ignore();
}
void dropEvent(QDropEvent *event) override
{
if (!mimeSupported(event->mimeData())) {
event->ignore();
return;
}
auto *staging = qobject_cast<StagingModel *>(model());
if (staging) {
staging->addMimeData(event->mimeData());
}
event->acceptProposedAction();
}
private:
void updateHover(const QPoint &pos)
{
const int row = pos.x() < 0 ? -1 : indexAt(pos).row();
if (row == m_hoverRow) {
return;
}
m_hoverRow = row;
m_delegate->setHoverRow(row);
viewport()->update();
}
void startDragForIndex(const QModelIndex &index)
{
if (!index.isValid()) {
return;
}
const int type = index.data(StagingModel::TypeRole).toInt();
const QString uri = index.data(StagingModel::UriRole).toString();
const QString text = index.data(StagingModel::TextRole).toString();
auto *mime = new QMimeData;
if (type == StagingItem::File || type == StagingItem::Image) {
if (!uri.isEmpty()) {
mime->setUrls({QUrl::fromLocalFile(uri)});
mime->setText(uri);
}
if (type == StagingItem::Image) {
const QImage image(uri);
if (!image.isNull()) {
mime->setImageData(image);
}
}
} else {
mime->setText(text);
}
auto *drag = new QDrag(this);
drag->setMimeData(mime);
const QIcon icon = index.data(StagingModel::IconRole).value<QIcon>();
if (!icon.isNull()) {
drag->setPixmap(icon.pixmap(48, 48));
}
drag->exec(Qt::CopyAction | Qt::MoveAction, Qt::CopyAction);
}
StagingDelegate *m_delegate = nullptr;
QPoint m_pressPos;
QModelIndex m_pressIndex;
int m_hoverRow = -1;
};
/* ------------------------------------------------------------------------- */
/* Portal zone */
/* ------------------------------------------------------------------------- */
class PortalZone : public QWidget
{
Q_OBJECT
public:
explicit PortalZone(QWidget *parent = nullptr)
: QWidget(parent)
{
setAcceptDrops(true);
setMinimumHeight(190);
m_chatButton = new QPushButton(tr("问问小埃 →"), this);
m_chatButton->setCursor(Qt::PointingHandCursor);
m_chatButton->setFlat(true);
m_chatButton->setFocusPolicy(Qt::NoFocus);
m_chatButton->setStyleSheet(QStringLiteral(
"QPushButton { color: #9ecbff; background: transparent; border: none;"
" font-size: 13px; }"
"QPushButton:hover { color: #cfe5ff; }"));
connect(m_chatButton, &QPushButton::clicked, this, &PortalZone::chatRequested);
m_hintLabel = new QLabel(this);
m_hintLabel->setAlignment(Qt::AlignCenter);
m_hintLabel->setWordWrap(true);
m_hintLabel->setAttribute(Qt::WA_TransparentForMouseEvents);
m_hintLabel->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.62);"
"font-size: 11px;"));
m_hintLabel->hide();
m_hintTimer.setSingleShot(true);
m_hintTimer.setInterval(2600);
connect(&m_hintTimer, &QTimer::timeout, m_hintLabel, &QLabel::hide);
}
signals:
void chatRequested();
protected:
void resizeEvent(QResizeEvent *event) override
{
QWidget::resizeEvent(event);
m_chatButton->setGeometry(12, height() - 42, width() - 24, 30);
m_hintLabel->setGeometry(12, height() - 84, width() - 24, 36);
}
void paintEvent(QPaintEvent *event) override
{
Q_UNUSED(event)
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
const QRectF box = QRectF(rect()).adjusted(1.0, 1.0, -1.0, -1.0);
QPainterPath path;
path.addRoundedRect(box, 14, 14);
QColor fill = QColor(255, 255, 255, 10);
if (m_active) {
fill = QColor(74, 144, 255, 62);
} else if (m_hover) {
fill = QColor(255, 255, 255, 20);
}
painter.fillPath(path, fill);
const QColor border = m_active ? QColor(120, 180, 255, 230)
: QColor(255, 255, 255, 58);
painter.setPen(QPen(border, 1.5, Qt::DashLine));
painter.setBrush(Qt::NoBrush);
painter.drawPath(path);
QFont titleFont = font();
titleFont.setPixelSize(22);
titleFont.setWeight(QFont::DemiBold);
painter.setFont(titleFont);
painter.setPen(QColor(255, 255, 255, 242));
painter.drawText(QRect(0, height() / 3 - 18, width(), 32), Qt::AlignHCenter,
tr("传送门"));
QFont subFont = font();
subFont.setPixelSize(12);
painter.setFont(subFont);
painter.setPen(QColor(255, 255, 255, 150));
painter.drawText(QRect(12, height() / 3 + 20, width() - 24, 40),
Qt::AlignHCenter | Qt::TextWordWrap,
tr("拖到这里\n选择窗口跃迁"));
}
void enterEvent(QEnterEvent *event) override
{
Q_UNUSED(event)
m_hover = true;
update();
}
void leaveEvent(QEvent *event) override
{
QWidget::leaveEvent(event);
m_hover = false;
m_active = false;
update();
}
void dragEnterEvent(QDragEnterEvent *event) override
{
if (!mimeSupported(event->mimeData())) {
event->ignore();
return;
}
m_active = true;
update();
event->acceptProposedAction();
}
void dragMoveEvent(QDragMoveEvent *event) override
{
if (mimeSupported(event->mimeData())) {
event->acceptProposedAction();
}
}
void dragLeaveEvent(QDragLeaveEvent *event) override
{
Q_UNUSED(event)
m_active = false;
update();
}
void dropEvent(QDropEvent *event) override
{
m_active = false;
update();
event->acceptProposedAction();
showHint(tr("传送门将在接入合成器后可用"));
}
private:
void showHint(const QString &hint)
{
m_hintLabel->setText(hint);
m_hintLabel->show();
m_hintTimer.start();
}
QPushButton *m_chatButton = nullptr;
QLabel *m_hintLabel = nullptr;
QTimer m_hintTimer;
bool m_active = false;
bool m_hover = false;
};
/* ------------------------------------------------------------------------- */
/* Panel */
/* ------------------------------------------------------------------------- */
PortalPanel::PortalPanel(StagingModel *model, QWidget *parent)
: Dtk::Widget::DBlurEffectWidget(parent)
, m_model(model)
{
setWindowTitle(tr("GXDE 小埃同学"));
setWindowFlags(Qt::FramelessWindowHint | Qt::Tool);
setAttribute(Qt::WA_TranslucentBackground);
setAttribute(Qt::WA_ShowWithoutActivating);
setMaskColor(QColor(24, 24, 28, 190));
setBlurRectXRadius(16);
setBlurRectYRadius(16);
setFixedWidth(kPanelWidth);
setupWindow();
setupUi();
connect(m_model, &StagingModel::countChanged, this, [this]() {
if (m_countLabel) {
m_countLabel->setText(tr("%1 项").arg(m_model->count()));
}
refreshLayout();
});
connect(m_model, &StagingModel::countChanged, m_view->viewport(),
qOverload<>(&QWidget::update));
connect(m_model, &StagingModel::dataChanged, this, [this]() { updateSelectionBar(); });
connect(m_model, &StagingModel::rowsRemoved, this, [this]() { updateSelectionBar(); });
connect(m_selectAllButton, &QPushButton::clicked, this, [this]() {
for (int row = 0; row < m_model->count(); ++row) {
m_model->setChecked(row, true);
}
});
connect(m_invertButton, &QPushButton::clicked, this, [this]() {
for (int row = 0; row < m_model->count(); ++row) {
const StagingItem *item = m_model->itemAt(row);
if (item) {
m_model->setChecked(row, !item->checked);
}
}
});
connect(m_deleteButton, &QPushButton::clicked, this, [this]() {
QList<int> rows;
for (int row = 0; row < m_model->count(); ++row) {
const StagingItem *item = m_model->itemAt(row);
if (item && item->checked) {
rows.append(row);
}
}
for (int i = rows.size() - 1; i >= 0; --i) {
m_model->removeRows(rows.at(i), 1);
}
});
}
PortalPanel::~PortalPanel() = default;
void PortalPanel::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::AnchorBottom;
layer->setAnchors(anchors);
layer->setMargins(QMargins(0, 0, 16, 16));
layer->setLayer(LayerShellQt::Window::LayerOverlay);
layer->setKeyboardInteractivity(
LayerShellQt::Window::KeyboardInteractivityNone);
layer->setExclusiveZone(0);
layer->setScope(QStringLiteral("gxde-ai-see"));
layer->setScreenConfiguration(LayerShellQt::Window::ScreenFromQWindow);
}
}
#endif
}
void PortalPanel::setupUi()
{
auto *outer = new QHBoxLayout(this);
outer->setContentsMargins(kMargin, kMargin, kMargin, kMargin);
outer->setSpacing(10);
m_zone = new PortalZone(this);
m_zone->setFixedWidth(kZoneWidth);
outer->addWidget(m_zone);
connect(m_zone, &PortalZone::chatRequested, this, &PortalPanel::openChatRequested);
auto *right = new QVBoxLayout;
right->setContentsMargins(0, 0, 0, 0);
right->setSpacing(6);
m_header = new QWidget(this);
auto *headerLayout = new QHBoxLayout(m_header);
headerLayout->setContentsMargins(12, 0, 12, 0);
headerLayout->setSpacing(8);
auto *title = new QLabel(tr("暂存区"), m_header);
QFont titleFont = title->font();
titleFont.setPixelSize(13);
titleFont.setWeight(QFont::DemiBold);
title->setFont(titleFont);
title->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.88);"));
headerLayout->addWidget(title);
headerLayout->addStretch();
m_countLabel = new QLabel(tr("%1 项").arg(m_model->count()), m_header);
m_countLabel->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.45); font-size: 11px;"));
headerLayout->addWidget(m_countLabel);
right->addWidget(m_header);
m_selectionBar = new QWidget(this);
auto *selectionLayout = new QHBoxLayout(m_selectionBar);
selectionLayout->setContentsMargins(12, 0, 12, 0);
selectionLayout->setSpacing(6);
auto makeButton = [this, selectionLayout](const QString &text) {
auto *button = new QPushButton(text, m_selectionBar);
button->setFlat(true);
button->setFocusPolicy(Qt::NoFocus);
button->setCursor(Qt::PointingHandCursor);
button->setStyleSheet(QStringLiteral(
"QPushButton { color: rgba(255,255,255,0.78); background: rgba(255,255,255,0.08);"
" border: none; border-radius: 6px; padding: 4px 10px; font-size: 12px; }"
"QPushButton:hover { background: rgba(255,255,255,0.16); }"));
selectionLayout->addWidget(button);
return button;
};
m_selectAllButton = makeButton(tr("全选"));
m_invertButton = makeButton(tr("反选"));
m_deleteButton = makeButton(tr("删除选中"));
selectionLayout->addStretch();
m_selectionBar->hide();
right->addWidget(m_selectionBar);
m_view = new StagingView(m_model, this);
right->addWidget(m_view, 1);
outer->addLayout(right, 1);
m_factory = new QWidget(this);
m_factory->hide();
}
void PortalPanel::refreshLayout()
{
updateGeometryForContent();
}
void PortalPanel::updateGeometryForContent()
{
QScreen *scr = screen();
if (!scr) {
scr = QGuiApplication::primaryScreen();
}
const int screenHeight = scr ? scr->availableGeometry().height() : 900;
const int maxListHeight = qMax(120, screenHeight / 3);
const int contentHeight = m_model->count() * kRowHeight + 8;
const int listHeight = qBound(140, contentHeight, maxListHeight);
m_view->setFixedHeight(listHeight);
const int headerHeight = 26;
const int selectionHeight = (m_selectionBar && m_selectionBar->isVisible()) ? 32 : 0;
const int totalHeight = kMargin * 2 + headerHeight + 6 + selectionHeight + listHeight;
resize(kPanelWidth, totalHeight);
}
void PortalPanel::updateSelectionBar()
{
if (!m_selectionBar) {
return;
}
bool anyChecked = false;
for (int row = 0; row < m_model->count(); ++row) {
const StagingItem *item = m_model->itemAt(row);
if (item && item->checked) {
anyChecked = true;
break;
}
}
if (m_selectionBar->isVisible() == anyChecked) {
return;
}
m_selectionBar->setVisible(anyChecked);
updateGeometryForContent();
}
void PortalPanel::popup()
{
refreshLayout();
show();
raise();
}
void PortalPanel::dismiss()
{
hide();
}
void PortalPanel::showEvent(QShowEvent *event)
{
Dtk::Widget::DBlurEffectWidget::showEvent(event);
refreshLayout();
if (!QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
QScreen *scr = screen();
if (!scr) {
scr = QGuiApplication::primaryScreen();
}
if (scr) {
const QRect available = scr->availableGeometry();
move(available.right() - width() - 16, available.bottom() - height() - 16);
}
}
}
#include "portalpanel.moc"
+56
View File
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <DBlurEffectWidget>
#include <QWidget>
class QLabel;
class QPushButton;
class PortalZone;
class StagingModel;
class StagingView;
/**
* The floating staging panel (传送门 + 暂存区).
*
* On Wayland this is a layer-shell overlay surface so that it does not appear
* in the dock, does not steal keyboard focus and can be positioned exactly.
* On X11 it falls back to a frameless tool window placed bottom-right.
*/
class PortalPanel : public Dtk::Widget::DBlurEffectWidget
{
Q_OBJECT
public:
explicit PortalPanel(StagingModel *model, QWidget *parent = nullptr);
~PortalPanel() override;
void refreshLayout();
void popup();
void dismiss();
signals:
void openChatRequested();
protected:
void showEvent(QShowEvent *event) override;
private:
void setupUi();
void setupWindow();
void updateGeometryForContent();
void updateSelectionBar();
StagingModel *m_model = nullptr;
StagingView *m_view = nullptr;
PortalZone *m_zone = nullptr;
QWidget *m_header = nullptr;
QWidget *m_selectionBar = nullptr;
QWidget *m_factory = nullptr;
QLabel *m_countLabel = nullptr;
QPushButton *m_selectAllButton = nullptr;
QPushButton *m_invertButton = nullptr;
QPushButton *m_deleteButton = nullptr;
};