diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..1e5ba4a --- /dev/null +++ b/CMakeLists.txt @@ -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) diff --git a/data/gxde-ai-see.desktop b/data/gxde-ai-see.desktop new file mode 100644 index 0000000..215554a --- /dev/null +++ b/data/gxde-ai-see.desktop @@ -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 diff --git a/data/icons/hicolor/128x128/apps/gxde-ai-see.png b/data/icons/hicolor/128x128/apps/gxde-ai-see.png new file mode 100644 index 0000000..f116a65 Binary files /dev/null and b/data/icons/hicolor/128x128/apps/gxde-ai-see.png differ diff --git a/data/icons/hicolor/16x16/apps/gxde-ai-see.png b/data/icons/hicolor/16x16/apps/gxde-ai-see.png new file mode 100644 index 0000000..5dd1e04 Binary files /dev/null and b/data/icons/hicolor/16x16/apps/gxde-ai-see.png differ diff --git a/data/icons/hicolor/24x24/apps/gxde-ai-see.png b/data/icons/hicolor/24x24/apps/gxde-ai-see.png new file mode 100644 index 0000000..0b134f6 Binary files /dev/null and b/data/icons/hicolor/24x24/apps/gxde-ai-see.png differ diff --git a/data/icons/hicolor/256x256/apps/gxde-ai-see.png b/data/icons/hicolor/256x256/apps/gxde-ai-see.png new file mode 100644 index 0000000..2774644 Binary files /dev/null and b/data/icons/hicolor/256x256/apps/gxde-ai-see.png differ diff --git a/data/icons/hicolor/32x32/apps/gxde-ai-see.png b/data/icons/hicolor/32x32/apps/gxde-ai-see.png new file mode 100644 index 0000000..d6a86bd Binary files /dev/null and b/data/icons/hicolor/32x32/apps/gxde-ai-see.png differ diff --git a/data/icons/hicolor/48x48/apps/gxde-ai-see.png b/data/icons/hicolor/48x48/apps/gxde-ai-see.png new file mode 100644 index 0000000..8695fc0 Binary files /dev/null and b/data/icons/hicolor/48x48/apps/gxde-ai-see.png differ diff --git a/data/icons/hicolor/512x512/apps/gxde-ai-see.png b/data/icons/hicolor/512x512/apps/gxde-ai-see.png new file mode 100644 index 0000000..590cbd1 Binary files /dev/null and b/data/icons/hicolor/512x512/apps/gxde-ai-see.png differ diff --git a/data/icons/hicolor/64x64/apps/gxde-ai-see.png b/data/icons/hicolor/64x64/apps/gxde-ai-see.png new file mode 100644 index 0000000..d362848 Binary files /dev/null and b/data/icons/hicolor/64x64/apps/gxde-ai-see.png differ diff --git a/docs/PLAN.md b/docs/PLAN.md index ebfd950..2bdf432 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 / 压缩是否要放进初版? diff --git a/src/app.cpp b/src/app.cpp new file mode 100644 index 0000000..c02a8ef --- /dev/null +++ b/src/app.cpp @@ -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 +#include + +#include + +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(); +} diff --git a/src/app.h b/src/app.h new file mode 100644 index 0000000..965e3d5 --- /dev/null +++ b/src/app.h @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +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; +}; diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..6e223ed --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "config.h" + +#include +#include + +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; +} diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..ea1170d --- /dev/null +++ b/src/config.h @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +/** + * 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; +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..4ce83c2 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include + +#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(); +} diff --git a/src/staging/stagingitem.h b/src/staging/stagingitem.h new file mode 100644 index 0000000..517c777 --- /dev/null +++ b/src/staging/stagingitem.h @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +/** + * 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); +}; diff --git a/src/staging/stagingmodel.cpp b/src/staging/stagingmodel.cpp new file mode 100644 index 0000000..f86e307 --- /dev/null +++ b/src/staging/stagingmodel.cpp @@ -0,0 +1,481 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "stagingmodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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(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(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(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(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 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(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 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 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(); +} diff --git a/src/staging/stagingmodel.h b/src/staging/stagingmodel.h new file mode 100644 index 0000000..056aed5 --- /dev/null +++ b/src/staging/stagingmodel.h @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#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 m_items; + QTimer m_pruneTimer; + mutable QHash m_iconCache; + mutable QFileIconProvider m_iconProvider; + QString m_storePath; +}; diff --git a/src/ui/portalpanel.cpp b/src/ui/portalpanel.cpp new file mode 100644 index 0000000..9456f54 --- /dev/null +++ b/src/ui/portalpanel.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef GXDE_HAVE_LAYERSHELLQT +#include +#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(); + 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(event); + const QPoint pos = mouse->position().toPoint(); + + if (checkRect(option.rect).contains(pos)) { + auto *staging = static_cast(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(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(); + 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 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" diff --git a/src/ui/portalpanel.h b/src/ui/portalpanel.h new file mode 100644 index 0000000..c58799c --- /dev/null +++ b/src/ui/portalpanel.h @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +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; +};