feat(m1): 应用骨架、传送门面板与暂存区
- CMake 构建(Qt6 + dtk2widget + LayerShellQt) - 单实例 / --daemon / --show - DBlur 双栏面板:传送门 + 暂存区,layer-shell 定位,不抢焦点 - 暂存区模型:文件/文字/图片、去重、JSON 持久化、文件消失清理 - 列表委托:复选框、缩略图、名称、hover 关闭按钮 - 全选 / 反选 / 删除选中 - 应用图标(多尺寸 hicolor)与 desktop 入口 - 方案更新:确认 layer-shell + 遮挡自动隐藏
@@ -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)
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
@@ -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 / 压缩是否要放进初版?
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
};
|
||||