feat(factory): 内容工厂与链接处理
- 第三栏内容工厂:按拖入类型展示动作,动作按钮本身可接收 drop - 链接分类:http(s) 按扩展名判定图片/音视频/文档/压缩包/网页 - http 链接动作:打开、下载、另存为、复制、Markdown、复制文件名、待办、问小埃 - 非标准 scheme:尝试打开、复制;thunder 解析 base64 真实地址,magnet 解析 dn - 文字动作:ICU 分词、正则提取链接、朗读(spd-say)、复制、待办 - 文件/图片动作:打开、提取文字(占位)、生成描述、保存图片、复制路径、待办 - Downloader:异步下载到 XDG 下载目录 / 另存为,面板 toast 提示 - TodoModel:持久化到 todo.json,供「添加到待办」使用 - 无头自测 gxde-ai-see-selftest:链接分类/分词/提取链接/动作匹配 - 合成器 portal 自测脚本 tests/portal(虚拟指针 + GTK 源/目标)
This commit is contained in:
@@ -23,3 +23,6 @@ ui_*.h
|
||||
*.so
|
||||
*.a
|
||||
.DS_Store
|
||||
|
||||
# test helpers
|
||||
tests/portal/pointer
|
||||
|
||||
@@ -15,6 +15,7 @@ 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)
|
||||
pkg_check_modules(ICU REQUIRED icu-i18n icu-uc)
|
||||
find_package(LayerShellQt QUIET)
|
||||
|
||||
set(GXDE_AI_SEE_SOURCES
|
||||
@@ -23,6 +24,13 @@ set(GXDE_AI_SEE_SOURCES
|
||||
src/config.cpp
|
||||
src/staging/stagingmodel.cpp
|
||||
src/portal/portalclient.cpp
|
||||
src/factory/linktools.cpp
|
||||
src/factory/contentclassifier.cpp
|
||||
src/factory/factoryactions.cpp
|
||||
src/factory/contentfactory.cpp
|
||||
src/actions/downloader.cpp
|
||||
src/actions/texttools.cpp
|
||||
src/todo/todomodel.cpp
|
||||
src/ui/portalpanel.cpp
|
||||
)
|
||||
|
||||
@@ -31,6 +39,7 @@ add_executable(gxde-ai-see ${GXDE_AI_SEE_SOURCES})
|
||||
target_include_directories(gxde-ai-see PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
${DTK2WIDGET_INCLUDE_DIRS}
|
||||
${ICU_INCLUDE_DIRS}
|
||||
)
|
||||
|
||||
target_link_libraries(gxde-ai-see PRIVATE
|
||||
@@ -41,6 +50,7 @@ target_link_libraries(gxde-ai-see PRIVATE
|
||||
Qt6::DBus
|
||||
Qt6::Svg
|
||||
${DTK2WIDGET_LIBRARIES}
|
||||
${ICU_LIBRARIES}
|
||||
)
|
||||
|
||||
target_link_directories(gxde-ai-see PRIVATE ${DTK2WIDGET_LIBRARY_DIRS})
|
||||
@@ -50,6 +60,16 @@ if (LayerShellQt_FOUND)
|
||||
target_compile_definitions(gxde-ai-see PRIVATE GXDE_HAVE_LAYERSHELLQT=1)
|
||||
endif ()
|
||||
|
||||
add_executable(gxde-ai-see-selftest
|
||||
tests/selftest.cpp
|
||||
src/factory/linktools.cpp
|
||||
src/factory/contentclassifier.cpp
|
||||
src/factory/factoryactions.cpp
|
||||
src/actions/texttools.cpp
|
||||
)
|
||||
target_include_directories(gxde-ai-see-selftest PRIVATE src ${ICU_INCLUDE_DIRS})
|
||||
target_link_libraries(gxde-ai-see-selftest PRIVATE Qt6::Core Qt6::Gui ${ICU_LIBRARIES})
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "downloader.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QStandardPaths>
|
||||
|
||||
#include "factory/linktools.h"
|
||||
|
||||
Downloader *Downloader::instance()
|
||||
{
|
||||
static Downloader *self = new Downloader;
|
||||
return self;
|
||||
}
|
||||
|
||||
Downloader::Downloader(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
m_manager.setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
}
|
||||
|
||||
QString Downloader::suggestedFileName(const QUrl &url)
|
||||
{
|
||||
QString name = LinkTools::displayNameFor(url);
|
||||
name = QFileInfo(name).fileName();
|
||||
if (name.isEmpty()) {
|
||||
name = QStringLiteral("download");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
QString Downloader::uniquePath(const QString &directory, const QString &fileName)
|
||||
{
|
||||
QDir dir(directory);
|
||||
QString path = dir.filePath(fileName);
|
||||
if (!QFileInfo::exists(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const QFileInfo info(path);
|
||||
const QString base = info.completeBaseName();
|
||||
const QString suffix = info.suffix();
|
||||
for (int index = 1; index < 1000; ++index) {
|
||||
const QString candidate =
|
||||
suffix.isEmpty()
|
||||
? dir.filePath(QStringLiteral("%1 (%2)").arg(base).arg(index))
|
||||
: dir.filePath(QStringLiteral("%1 (%2).%3").arg(base).arg(index).arg(suffix));
|
||||
if (!QFileInfo::exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
void Downloader::download(const QUrl &url)
|
||||
{
|
||||
QString directory = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
|
||||
if (directory.isEmpty()) {
|
||||
directory = QDir::homePath();
|
||||
}
|
||||
QDir().mkpath(directory);
|
||||
start(url, uniquePath(directory, suggestedFileName(url)));
|
||||
}
|
||||
|
||||
void Downloader::saveAs(const QUrl &url, QWidget *parent)
|
||||
{
|
||||
QString directory = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
|
||||
if (directory.isEmpty()) {
|
||||
directory = QDir::homePath();
|
||||
}
|
||||
const QString suggested = QDir(directory).filePath(suggestedFileName(url));
|
||||
const QString path = QFileDialog::getSaveFileName(parent, tr("另存为"), suggested);
|
||||
if (path.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
start(url, path);
|
||||
}
|
||||
|
||||
void Downloader::start(const QUrl &url, const QString &path)
|
||||
{
|
||||
auto *file = new QFile(path, this);
|
||||
if (!file->open(QIODevice::WriteOnly)) {
|
||||
Q_EMIT message(tr("无法写入:%1").arg(path));
|
||||
file->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
QNetworkRequest request(url);
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
QNetworkReply *reply = m_manager.get(request);
|
||||
|
||||
Q_EMIT message(tr("开始下载:%1").arg(QFileInfo(path).fileName()));
|
||||
|
||||
connect(reply, &QNetworkReply::readyRead, file, [reply, file]() {
|
||||
file->write(reply->readAll());
|
||||
});
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, file, path]() {
|
||||
file->write(reply->readAll());
|
||||
file->close();
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
Q_EMIT message(tr("下载失败:%1").arg(reply->errorString()));
|
||||
QFile::remove(path);
|
||||
} else {
|
||||
Q_EMIT message(tr("下载完成:%1").arg(path));
|
||||
}
|
||||
file->deleteLater();
|
||||
reply->deleteLater();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QUrl>
|
||||
|
||||
class QWidget;
|
||||
|
||||
/**
|
||||
* Small async downloader for links dropped on the content factory.
|
||||
*
|
||||
* Emits \a message with a human readable status; the panel shows it as a
|
||||
* toast. Downloads go to the XDG download directory unless "save as" is used.
|
||||
*/
|
||||
class Downloader : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
static Downloader *instance();
|
||||
|
||||
void download(const QUrl &url);
|
||||
void saveAs(const QUrl &url, QWidget *parent);
|
||||
|
||||
signals:
|
||||
void message(const QString &text);
|
||||
|
||||
private:
|
||||
explicit Downloader(QObject *parent = nullptr);
|
||||
|
||||
void start(const QUrl &url, const QString &path);
|
||||
static QString suggestedFileName(const QUrl &url);
|
||||
static QString uniquePath(const QString &directory, const QString &fileName);
|
||||
|
||||
QNetworkAccessManager m_manager;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "texttools.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
|
||||
#include <unicode/ubrk.h>
|
||||
#include <unicode/utypes.h>
|
||||
|
||||
namespace TextTools {
|
||||
|
||||
QStringList segment(const QString &text)
|
||||
{
|
||||
QStringList words;
|
||||
if (text.isEmpty()) {
|
||||
return words;
|
||||
}
|
||||
|
||||
UErrorCode status = U_ZERO_ERROR;
|
||||
UBreakIterator *iterator =
|
||||
ubrk_open(UBRK_WORD, "zh", reinterpret_cast<const UChar *>(text.utf16()), text.size(),
|
||||
&status);
|
||||
if (U_FAILURE(status) || !iterator) {
|
||||
return words;
|
||||
}
|
||||
|
||||
int32_t start = ubrk_first(iterator);
|
||||
for (int32_t end = ubrk_next(iterator); end != UBRK_DONE; start = end, end = ubrk_next(iterator)) {
|
||||
if (ubrk_getRuleStatus(iterator) == UBRK_WORD_NONE) {
|
||||
continue;
|
||||
}
|
||||
const QString word = text.mid(start, end - start).trimmed();
|
||||
if (!word.isEmpty()) {
|
||||
words.append(word);
|
||||
}
|
||||
}
|
||||
|
||||
ubrk_close(iterator);
|
||||
return words;
|
||||
}
|
||||
|
||||
QStringList extractLinks(const QString &text)
|
||||
{
|
||||
static const QRegularExpression pattern(
|
||||
QStringLiteral("(https?://[^\\s<>\"'\\x{3000}\\x{FF08}\\x{FF09}\\x{3010}\\x{3011}]+"
|
||||
"|www\\.[^\\s<>\"'\\x{3000}\\x{FF08}\\x{FF09}\\x{3010}\\x{3011}]+"
|
||||
"|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
|
||||
QStringList links;
|
||||
QSet<QString> seen;
|
||||
auto it = pattern.globalMatch(text);
|
||||
while (it.hasNext()) {
|
||||
QString link = it.next().captured(1);
|
||||
while (link.endsWith(QLatin1Char('.')) || link.endsWith(QLatin1Char(',')) ||
|
||||
link.endsWith(QLatin1Char(')'))) {
|
||||
link.chop(1);
|
||||
}
|
||||
if (!link.isEmpty() && !seen.contains(link)) {
|
||||
seen.insert(link);
|
||||
links.append(link);
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
QString readTextFile(const QString &path, qint64 maxBytes)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return QString();
|
||||
}
|
||||
const QByteArray data = file.read(maxBytes);
|
||||
return QString::fromUtf8(data);
|
||||
}
|
||||
|
||||
} // namespace TextTools
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
namespace TextTools {
|
||||
|
||||
/** Chinese word segmentation (ICU word break, zh locale). */
|
||||
QStringList segment(const QString &text);
|
||||
|
||||
/** Extract http(s) links, www links and e-mail addresses. */
|
||||
QStringList extractLinks(const QString &text);
|
||||
|
||||
/** Read a text file with a size cap; returns an empty string on failure. */
|
||||
QString readTextFile(const QString &path, qint64 maxBytes = 4 * 1024 * 1024);
|
||||
|
||||
} // namespace TextTools
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "contentclassifier.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QImageReader>
|
||||
#include <QMimeData>
|
||||
#include <QUrl>
|
||||
|
||||
namespace {
|
||||
|
||||
QString extensionOfFile(const QString &path)
|
||||
{
|
||||
return QFileInfo(path).suffix().toLower();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString DropContent::summary() const
|
||||
{
|
||||
switch (kind) {
|
||||
case Files: {
|
||||
if (filePaths.size() == 1) {
|
||||
const QFileInfo info(filePaths.first());
|
||||
return QStringLiteral("文件 · %1").arg(info.fileName());
|
||||
}
|
||||
return QStringLiteral("%1 个文件").arg(filePaths.size());
|
||||
}
|
||||
case Image:
|
||||
return QStringLiteral("图片");
|
||||
case Text:
|
||||
return QStringLiteral("文字 · %1 字").arg(text.size());
|
||||
case Link:
|
||||
case CustomLink: {
|
||||
const QString label = link.fileType != QStringLiteral("page") && !link.fileType.isEmpty()
|
||||
? link.fileType
|
||||
: link.kindLabel();
|
||||
return link.displayName.isEmpty() ? label
|
||||
: QStringLiteral("%1 · %2").arg(label, link.displayName);
|
||||
}
|
||||
default:
|
||||
return QString();
|
||||
}
|
||||
}
|
||||
|
||||
namespace ContentClassifier {
|
||||
|
||||
DropContent classifyText(const QString &text)
|
||||
{
|
||||
DropContent content;
|
||||
const QString trimmed = text.trimmed();
|
||||
if (trimmed.isEmpty()) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const LinkInfo info = LinkTools::parse(trimmed);
|
||||
if (info.isValid()) {
|
||||
content.kind = info.kind == LinkInfo::Http ? DropContent::Link : DropContent::CustomLink;
|
||||
content.link = info;
|
||||
content.text = trimmed;
|
||||
return content;
|
||||
}
|
||||
|
||||
content.kind = DropContent::Text;
|
||||
content.text = text;
|
||||
return content;
|
||||
}
|
||||
|
||||
DropContent classify(const QMimeData *mime)
|
||||
{
|
||||
DropContent content;
|
||||
if (!mime) {
|
||||
return content;
|
||||
}
|
||||
|
||||
content.mimes = mime->formats();
|
||||
|
||||
if (mime->hasUrls()) {
|
||||
QStringList localFiles;
|
||||
LinkInfo link;
|
||||
for (const QUrl &url : mime->urls()) {
|
||||
if (url.isLocalFile()) {
|
||||
localFiles.append(url.toLocalFile());
|
||||
} else if (!link.isValid()) {
|
||||
link = LinkTools::parseUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (!localFiles.isEmpty()) {
|
||||
content.kind = DropContent::Files;
|
||||
content.filePaths = localFiles;
|
||||
if (localFiles.size() == 1) {
|
||||
const QString suffix = extensionOfFile(localFiles.first());
|
||||
if (LinkTools::fileTypeForExtension(suffix) == QStringLiteral("image") &&
|
||||
QImageReader(localFiles.first()).canRead()) {
|
||||
content.kind = DropContent::Image;
|
||||
content.image = QImage(localFiles.first());
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
if (link.isValid()) {
|
||||
content.kind =
|
||||
link.kind == LinkInfo::Http ? DropContent::Link : DropContent::CustomLink;
|
||||
content.link = link;
|
||||
content.text = link.url.toString();
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
if (mime->hasImage()) {
|
||||
content.image = qvariant_cast<QImage>(mime->imageData());
|
||||
if (!content.image.isNull()) {
|
||||
content.kind = DropContent::Image;
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
if (mime->hasText()) {
|
||||
return classifyText(mime->text());
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
} // namespace ContentClassifier
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QImage>
|
||||
#include <QStringList>
|
||||
|
||||
#include "linktools.h"
|
||||
|
||||
class QMimeData;
|
||||
|
||||
/**
|
||||
* The payload dropped onto the content factory, already classified.
|
||||
*/
|
||||
struct DropContent
|
||||
{
|
||||
enum Kind {
|
||||
None,
|
||||
Files,
|
||||
Image,
|
||||
Text,
|
||||
Link, // http(s)
|
||||
CustomLink, // thunder://, spk://, magnet:, ...
|
||||
};
|
||||
|
||||
Kind kind = None;
|
||||
QStringList filePaths;
|
||||
QString text;
|
||||
QStringList mimes;
|
||||
LinkInfo link;
|
||||
QImage image;
|
||||
|
||||
bool valid() const { return kind != None; }
|
||||
QString summary() const;
|
||||
};
|
||||
|
||||
namespace ContentClassifier {
|
||||
DropContent classify(const QMimeData *mime);
|
||||
DropContent classifyText(const QString &text);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "contentfactory.h"
|
||||
|
||||
#include "factoryactions.h"
|
||||
|
||||
#include <QDragEnterEvent>
|
||||
#include <QDragLeaveEvent>
|
||||
#include <QDragMoveEvent>
|
||||
#include <QDropEvent>
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMimeData>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QStyle>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace {
|
||||
|
||||
bool mimeSupported(const QMimeData *mime)
|
||||
{
|
||||
return mime && (mime->hasUrls() || mime->hasText() || mime->hasImage());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class ActionButton : public QToolButton
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ActionButton(const FactoryAction &action, QWidget *parent)
|
||||
: QToolButton(parent)
|
||||
, m_id(action.id)
|
||||
{
|
||||
setText(action.label);
|
||||
setToolTip(action.hint);
|
||||
setEnabled(action.enabled);
|
||||
setCursor(Qt::PointingHandCursor);
|
||||
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
||||
setFixedHeight(32);
|
||||
setAutoRaise(true);
|
||||
setFocusPolicy(Qt::NoFocus);
|
||||
setAcceptDrops(true);
|
||||
if (!action.icon.isEmpty()) {
|
||||
const QIcon icon = QIcon::fromTheme(action.icon);
|
||||
if (!icon.isNull()) {
|
||||
setIcon(icon);
|
||||
setIconSize(QSize(16, 16));
|
||||
setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
|
||||
}
|
||||
}
|
||||
setStyleSheet(QStringLiteral(
|
||||
"QToolButton { color: rgba(255,255,255,0.86); background: rgba(255,255,255,0.07);"
|
||||
" border: none; border-radius: 7px; padding: 0 8px; font-size: 12px; }"
|
||||
"QToolButton:hover { background: rgba(120,180,255,0.30); color: white; }"
|
||||
"QToolButton[dragover=\"true\"] { background: rgba(120,180,255,0.45); color: white;"
|
||||
" border: 1px solid rgba(160,205,255,0.9); }"
|
||||
"QToolButton:disabled { color: rgba(255,255,255,0.32); background: rgba(255,255,255,0.03); }"));
|
||||
}
|
||||
|
||||
signals:
|
||||
void dropped(const QString &id, const QMimeData *mime);
|
||||
void clickedAction(const QString &id);
|
||||
|
||||
protected:
|
||||
void dragEnterEvent(QDragEnterEvent *event) override
|
||||
{
|
||||
if (!mimeSupported(event->mimeData())) {
|
||||
event->ignore();
|
||||
return;
|
||||
}
|
||||
setProperty("dragover", true);
|
||||
repolish();
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
|
||||
void dragMoveEvent(QDragMoveEvent *event) override
|
||||
{
|
||||
if (mimeSupported(event->mimeData())) {
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
}
|
||||
|
||||
void dragLeaveEvent(QDragLeaveEvent *event) override
|
||||
{
|
||||
Q_UNUSED(event)
|
||||
setProperty("dragover", false);
|
||||
repolish();
|
||||
}
|
||||
|
||||
void dropEvent(QDropEvent *event) override
|
||||
{
|
||||
setProperty("dragover", false);
|
||||
repolish();
|
||||
if (!mimeSupported(event->mimeData())) {
|
||||
event->ignore();
|
||||
return;
|
||||
}
|
||||
event->acceptProposedAction();
|
||||
Q_EMIT dropped(m_id, event->mimeData());
|
||||
}
|
||||
|
||||
void mouseReleaseEvent(QMouseEvent *event) override
|
||||
{
|
||||
QToolButton::mouseReleaseEvent(event);
|
||||
if (isEnabled() && rect().contains(event->position().toPoint())) {
|
||||
Q_EMIT clickedAction(m_id);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
void repolish()
|
||||
{
|
||||
style()->unpolish(this);
|
||||
style()->polish(this);
|
||||
update();
|
||||
}
|
||||
|
||||
QString m_id;
|
||||
};
|
||||
|
||||
ContentFactory::ContentFactory(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
setAcceptDrops(true);
|
||||
setMinimumHeight(44);
|
||||
|
||||
auto *outer = new QVBoxLayout(this);
|
||||
outer->setContentsMargins(12, 7, 12, 7);
|
||||
outer->setSpacing(6);
|
||||
|
||||
auto *header = new QHBoxLayout;
|
||||
header->setContentsMargins(0, 0, 0, 0);
|
||||
header->setSpacing(8);
|
||||
|
||||
auto *title = new QLabel(QStringLiteral("🔧 内容工厂"), this);
|
||||
QFont titleFont = title->font();
|
||||
titleFont.setPixelSize(12);
|
||||
titleFont.setWeight(QFont::DemiBold);
|
||||
title->setFont(titleFont);
|
||||
title->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.88);"));
|
||||
header->addWidget(title);
|
||||
|
||||
m_summary = new QLabel(this);
|
||||
m_summary->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.45); font-size: 11px;"));
|
||||
m_summary->setText(tr("拖到这里,按类型处理"));
|
||||
header->addWidget(m_summary, 1);
|
||||
|
||||
auto *hint = new QLabel(tr("拖到动作上松手"), this);
|
||||
hint->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.32); font-size: 11px;"));
|
||||
header->addWidget(hint);
|
||||
|
||||
outer->addLayout(header);
|
||||
|
||||
m_actions = new QWidget(this);
|
||||
m_grid = new QGridLayout(m_actions);
|
||||
m_grid->setContentsMargins(0, 0, 0, 0);
|
||||
m_grid->setHorizontalSpacing(6);
|
||||
m_grid->setVerticalSpacing(6);
|
||||
m_actions->hide();
|
||||
outer->addWidget(m_actions);
|
||||
}
|
||||
|
||||
void ContentFactory::setContent(const DropContent &content)
|
||||
{
|
||||
m_content = content;
|
||||
m_summary->setText(content.summary());
|
||||
rebuild();
|
||||
m_actions->show();
|
||||
Q_EMIT heightChanged();
|
||||
}
|
||||
|
||||
void ContentFactory::clearContent()
|
||||
{
|
||||
m_content = DropContent();
|
||||
m_summary->setText(tr("拖到这里,按类型处理"));
|
||||
while (QLayoutItem *item = m_grid->takeAt(0)) {
|
||||
if (QWidget *widget = item->widget()) {
|
||||
widget->deleteLater();
|
||||
}
|
||||
delete item;
|
||||
}
|
||||
m_actions->hide();
|
||||
Q_EMIT heightChanged();
|
||||
}
|
||||
|
||||
void ContentFactory::rebuild()
|
||||
{
|
||||
while (QLayoutItem *item = m_grid->takeAt(0)) {
|
||||
if (QWidget *widget = item->widget()) {
|
||||
widget->deleteLater();
|
||||
}
|
||||
delete item;
|
||||
}
|
||||
|
||||
const QVector<FactoryAction> actions = actionsFor(m_content);
|
||||
int column = 0;
|
||||
int row = 0;
|
||||
for (const FactoryAction &action : actions) {
|
||||
auto *button = new ActionButton(action, m_actions);
|
||||
installEventFilter(button);
|
||||
connect(button, &ActionButton::clickedAction, this, [this](const QString &id) {
|
||||
if (m_content.valid()) {
|
||||
Q_EMIT actionRequested(id, m_content);
|
||||
}
|
||||
});
|
||||
connect(button, &ActionButton::dropped, this, [this](const QString &id,
|
||||
const QMimeData *mime) {
|
||||
const DropContent content = ContentClassifier::classify(mime);
|
||||
if (content.valid()) {
|
||||
Q_EMIT actionRequested(id, content);
|
||||
}
|
||||
});
|
||||
m_grid->addWidget(button, row, column);
|
||||
if (++column == 3) {
|
||||
column = 0;
|
||||
++row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ContentFactory::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
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, 12, 12);
|
||||
painter.fillPath(path, QColor(255, 255, 255, m_content.valid() ? 14 : 8));
|
||||
painter.setPen(QPen(QColor(255, 255, 255, 42), 1.0));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.drawPath(path);
|
||||
}
|
||||
|
||||
bool ContentFactory::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (event->type() == QEvent::DragEnter || event->type() == QEvent::DragMove) {
|
||||
Q_EMIT dragActivity();
|
||||
}
|
||||
return QWidget::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
#include "contentfactory.moc"
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
#include "contentclassifier.h"
|
||||
|
||||
class QGridLayout;
|
||||
class QLabel;
|
||||
class QMimeData;
|
||||
|
||||
/**
|
||||
* Third box of the panel: shows type aware actions for the dragged payload.
|
||||
*
|
||||
* Every action button is itself a drop target, so the user can drag the
|
||||
* payload straight onto the action and release.
|
||||
*/
|
||||
class ContentFactory : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ContentFactory(QWidget *parent = nullptr);
|
||||
|
||||
void setContent(const DropContent &content);
|
||||
void clearContent();
|
||||
bool hasContent() const { return m_content.valid(); }
|
||||
const DropContent &content() const { return m_content; }
|
||||
|
||||
signals:
|
||||
void actionRequested(const QString &id, const DropContent &content);
|
||||
void heightChanged();
|
||||
void dragActivity();
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
private:
|
||||
void rebuild();
|
||||
|
||||
QLabel *m_summary = nullptr;
|
||||
QWidget *m_actions = nullptr;
|
||||
QGridLayout *m_grid = nullptr;
|
||||
DropContent m_content;
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "factoryactions.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
|
||||
namespace {
|
||||
|
||||
FactoryAction action(const QString &id, const QString &label, const QString &icon = QString(),
|
||||
const QString &hint = QString(), bool enabled = true)
|
||||
{
|
||||
return FactoryAction{ id, label, icon, hint, enabled };
|
||||
}
|
||||
|
||||
QVector<FactoryAction> linkActions(const DropContent &content)
|
||||
{
|
||||
QVector<FactoryAction> actions;
|
||||
const LinkInfo &link = content.link;
|
||||
|
||||
if (link.kind == LinkInfo::Http) {
|
||||
actions.append(action(QStringLiteral("open"), QStringLiteral("浏览器打开"),
|
||||
QStringLiteral("web-browser")));
|
||||
if (link.fileType == QStringLiteral("image")) {
|
||||
actions.append(action(QStringLiteral("preview"), QStringLiteral("预览图片"),
|
||||
QStringLiteral("image-x-generic")));
|
||||
} else if (link.fileType == QStringLiteral("audio") ||
|
||||
link.fileType == QStringLiteral("video")) {
|
||||
actions.append(action(QStringLiteral("play"), QStringLiteral("在线播放"),
|
||||
QStringLiteral("media-playback-start")));
|
||||
}
|
||||
actions.append(action(QStringLiteral("download"), QStringLiteral("下载"),
|
||||
QStringLiteral("download")));
|
||||
actions.append(action(QStringLiteral("save_as"), QStringLiteral("另存为…"),
|
||||
QStringLiteral("document-save-as")));
|
||||
actions.append(action(QStringLiteral("copy"), QStringLiteral("复制链接"),
|
||||
QStringLiteral("edit-copy")));
|
||||
actions.append(action(QStringLiteral("copy_markdown"), QStringLiteral("复制为 Markdown"),
|
||||
QStringLiteral("edit-copy")));
|
||||
actions.append(action(QStringLiteral("copy_name"), QStringLiteral("复制文件名"),
|
||||
QStringLiteral("edit-copy")));
|
||||
actions.append(action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
|
||||
QStringLiteral("checkbox-checked")));
|
||||
actions.append(action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
|
||||
QStringLiteral("dialog-question")));
|
||||
return actions;
|
||||
}
|
||||
|
||||
actions.append(action(QStringLiteral("try_open"), QStringLiteral("尝试打开"),
|
||||
QStringLiteral("application-x-executable")));
|
||||
if (!link.decodedUrl.isEmpty()) {
|
||||
actions.append(action(QStringLiteral("decode"), QStringLiteral("查看真实地址"),
|
||||
QStringLiteral("edit-find")));
|
||||
actions.append(action(QStringLiteral("copy_decoded"), QStringLiteral("复制真实地址"),
|
||||
QStringLiteral("edit-copy")));
|
||||
}
|
||||
actions.append(action(QStringLiteral("copy"), QStringLiteral("复制链接"),
|
||||
QStringLiteral("edit-copy")));
|
||||
if (!link.displayName.isEmpty()) {
|
||||
actions.append(action(QStringLiteral("copy_name"), QStringLiteral("复制名称"),
|
||||
QStringLiteral("edit-copy")));
|
||||
}
|
||||
actions.append(action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
|
||||
QStringLiteral("checkbox-checked")));
|
||||
actions.append(action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
|
||||
QStringLiteral("dialog-question")));
|
||||
return actions;
|
||||
}
|
||||
|
||||
QVector<FactoryAction> fileActions(const DropContent &content)
|
||||
{
|
||||
QVector<FactoryAction> actions;
|
||||
const QString path = content.filePaths.value(0);
|
||||
const QString type = LinkTools::fileTypeForExtension(QFileInfo(path).suffix().toLower());
|
||||
|
||||
actions.append(action(QStringLiteral("open"), QStringLiteral("打开"),
|
||||
QStringLiteral("document-open")));
|
||||
if (type == QStringLiteral("image")) {
|
||||
actions.append(action(QStringLiteral("ocr"), QStringLiteral("提取文字"),
|
||||
QStringLiteral("insert-text")));
|
||||
actions.append(action(QStringLiteral("describe"), QStringLiteral("生成描述"),
|
||||
QStringLiteral("dialog-question")));
|
||||
} else if (type == QStringLiteral("audio") || type == QStringLiteral("video")) {
|
||||
actions.append(action(QStringLiteral("transcribe"), QStringLiteral("转文字"),
|
||||
QStringLiteral("insert-text"), QStringLiteral("尚未接入"), false));
|
||||
} else if (type == QStringLiteral("document") || type == QStringLiteral("code")) {
|
||||
actions.append(action(QStringLiteral("segment"), QStringLiteral("分词"),
|
||||
QStringLiteral("insert-text")));
|
||||
actions.append(action(QStringLiteral("extract_links"), QStringLiteral("提取链接"),
|
||||
QStringLiteral("insert-link")));
|
||||
actions.append(action(QStringLiteral("speak"), QStringLiteral("朗读"),
|
||||
QStringLiteral("audio-volume-high")));
|
||||
}
|
||||
|
||||
actions.append(action(QStringLiteral("copy_path"), QStringLiteral("复制路径"),
|
||||
QStringLiteral("edit-copy")));
|
||||
actions.append(action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
|
||||
QStringLiteral("checkbox-checked")));
|
||||
actions.append(action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
|
||||
QStringLiteral("dialog-question")));
|
||||
return actions;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QVector<FactoryAction> actionsFor(const DropContent &content)
|
||||
{
|
||||
switch (content.kind) {
|
||||
case DropContent::Files:
|
||||
return fileActions(content);
|
||||
case DropContent::Image:
|
||||
return {
|
||||
action(QStringLiteral("preview"), QStringLiteral("预览图片"),
|
||||
QStringLiteral("image-x-generic")),
|
||||
action(QStringLiteral("save_image"), QStringLiteral("保存图片…"),
|
||||
QStringLiteral("document-save-as")),
|
||||
action(QStringLiteral("ocr"), QStringLiteral("提取文字"),
|
||||
QStringLiteral("insert-text")),
|
||||
action(QStringLiteral("describe"), QStringLiteral("生成描述"),
|
||||
QStringLiteral("dialog-question")),
|
||||
action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
|
||||
QStringLiteral("checkbox-checked")),
|
||||
action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
|
||||
QStringLiteral("dialog-question")),
|
||||
};
|
||||
case DropContent::Text:
|
||||
return {
|
||||
action(QStringLiteral("segment"), QStringLiteral("分词"),
|
||||
QStringLiteral("insert-text")),
|
||||
action(QStringLiteral("extract_links"), QStringLiteral("提取链接"),
|
||||
QStringLiteral("insert-link")),
|
||||
action(QStringLiteral("translate"), QStringLiteral("翻译"),
|
||||
QStringLiteral("accessories-dictionary")),
|
||||
action(QStringLiteral("speak"), QStringLiteral("朗读"),
|
||||
QStringLiteral("audio-volume-high")),
|
||||
action(QStringLiteral("copy"), QStringLiteral("复制"), QStringLiteral("edit-copy")),
|
||||
action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
|
||||
QStringLiteral("checkbox-checked")),
|
||||
action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
|
||||
QStringLiteral("dialog-question")),
|
||||
};
|
||||
case DropContent::Link:
|
||||
case DropContent::CustomLink:
|
||||
return linkActions(content);
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
#include "contentclassifier.h"
|
||||
|
||||
struct FactoryAction
|
||||
{
|
||||
QString id;
|
||||
QString label;
|
||||
QString icon; // theme icon name, optional
|
||||
QString hint;
|
||||
bool enabled = true;
|
||||
};
|
||||
|
||||
/** Actions offered by the content factory for a given payload. */
|
||||
QVector<FactoryAction> actionsFor(const DropContent &content);
|
||||
@@ -0,0 +1,241 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "linktools.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QUrlQuery>
|
||||
|
||||
namespace {
|
||||
|
||||
const QStringList kImageExtensions = {
|
||||
QStringLiteral("jpg"), QStringLiteral("jpeg"), QStringLiteral("png"),
|
||||
QStringLiteral("gif"), QStringLiteral("webp"), QStringLiteral("svg"),
|
||||
QStringLiteral("bmp"), QStringLiteral("avif"), QStringLiteral("heic"),
|
||||
QStringLiteral("ico"),
|
||||
};
|
||||
|
||||
const QStringList kAudioExtensions = {
|
||||
QStringLiteral("mp3"), QStringLiteral("flac"), QStringLiteral("wav"),
|
||||
QStringLiteral("ogg"), QStringLiteral("oga"), QStringLiteral("m4a"),
|
||||
QStringLiteral("aac"), QStringLiteral("opus"), QStringLiteral("wma"),
|
||||
};
|
||||
|
||||
const QStringList kVideoExtensions = {
|
||||
QStringLiteral("mp4"), QStringLiteral("mkv"), QStringLiteral("webm"),
|
||||
QStringLiteral("avi"), QStringLiteral("mov"), QStringLiteral("flv"),
|
||||
QStringLiteral("m4v"), QStringLiteral("ts"),
|
||||
};
|
||||
|
||||
const QStringList kDocumentExtensions = {
|
||||
QStringLiteral("pdf"), QStringLiteral("doc"), QStringLiteral("docx"),
|
||||
QStringLiteral("xls"), QStringLiteral("xlsx"), QStringLiteral("ppt"),
|
||||
QStringLiteral("pptx"), QStringLiteral("odt"), QStringLiteral("ods"),
|
||||
QStringLiteral("txt"), QStringLiteral("md"), QStringLiteral("rtf"),
|
||||
QStringLiteral("epub"),
|
||||
};
|
||||
|
||||
const QStringList kArchiveExtensions = {
|
||||
QStringLiteral("zip"), QStringLiteral("7z"), QStringLiteral("rar"),
|
||||
QStringLiteral("tar"), QStringLiteral("gz"), QStringLiteral("bz2"),
|
||||
QStringLiteral("xz"), QStringLiteral("zst"), QStringLiteral("deb"),
|
||||
QStringLiteral("rpm"), QStringLiteral("apk"), QStringLiteral("iso"),
|
||||
};
|
||||
|
||||
const QStringList kCodeExtensions = {
|
||||
QStringLiteral("json"), QStringLiteral("xml"), QStringLiteral("html"),
|
||||
QStringLiteral("htm"), QStringLiteral("js"), QStringLiteral("css"),
|
||||
QStringLiteral("py"), QStringLiteral("c"), QStringLiteral("cpp"),
|
||||
QStringLiteral("h"), QStringLiteral("sh"), QStringLiteral("go"),
|
||||
QStringLiteral("rs"), QStringLiteral("java"),
|
||||
};
|
||||
|
||||
QString extensionOfPath(const QString &path)
|
||||
{
|
||||
const QString suffix = QFileInfo(path).suffix().toLower();
|
||||
return suffix;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString LinkInfo::kindLabel() const
|
||||
{
|
||||
switch (kind) {
|
||||
case Http:
|
||||
return fileType == QStringLiteral("page") ? QStringLiteral("网页链接")
|
||||
: QStringLiteral("文件链接");
|
||||
case Standard:
|
||||
return QStringLiteral("链接");
|
||||
case Custom:
|
||||
return QStringLiteral("应用链接");
|
||||
default:
|
||||
return QString();
|
||||
}
|
||||
}
|
||||
|
||||
namespace LinkTools {
|
||||
|
||||
QString fileTypeForExtension(const QString &extension)
|
||||
{
|
||||
if (extension.isEmpty()) {
|
||||
return QStringLiteral("page");
|
||||
}
|
||||
if (kImageExtensions.contains(extension)) {
|
||||
return QStringLiteral("image");
|
||||
}
|
||||
if (kAudioExtensions.contains(extension)) {
|
||||
return QStringLiteral("audio");
|
||||
}
|
||||
if (kVideoExtensions.contains(extension)) {
|
||||
return QStringLiteral("video");
|
||||
}
|
||||
if (kDocumentExtensions.contains(extension)) {
|
||||
return QStringLiteral("document");
|
||||
}
|
||||
if (kArchiveExtensions.contains(extension)) {
|
||||
return QStringLiteral("archive");
|
||||
}
|
||||
if (kCodeExtensions.contains(extension)) {
|
||||
return QStringLiteral("code");
|
||||
}
|
||||
return QStringLiteral("unknown");
|
||||
}
|
||||
|
||||
QString decodeThunder(const QString &text)
|
||||
{
|
||||
static const QString prefix = QStringLiteral("thunder://");
|
||||
if (!text.startsWith(prefix, Qt::CaseInsensitive)) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString payload = text.mid(prefix.size()).trimmed();
|
||||
payload.replace(QLatin1Char('-'), QLatin1Char('+'));
|
||||
payload.replace(QLatin1Char('_'), QLatin1Char('/'));
|
||||
|
||||
QByteArray encoded = payload.toLatin1();
|
||||
while (encoded.size() % 4 != 0) {
|
||||
encoded.append('=');
|
||||
}
|
||||
|
||||
const QByteArray decoded = QByteArray::fromBase64(encoded);
|
||||
QString value = QString::fromUtf8(decoded);
|
||||
if (value.startsWith(QStringLiteral("AA"))) {
|
||||
value.remove(0, 2);
|
||||
}
|
||||
if (value.endsWith(QStringLiteral("ZZ"))) {
|
||||
value.chop(2);
|
||||
}
|
||||
return value.trimmed();
|
||||
}
|
||||
|
||||
QString displayNameFor(const QUrl &url)
|
||||
{
|
||||
if (url.scheme().compare(QStringLiteral("magnet"), Qt::CaseInsensitive) == 0) {
|
||||
const QUrlQuery query(url);
|
||||
const QString name = query.queryItemValue(QStringLiteral("dn"));
|
||||
if (!name.isEmpty()) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
QString path = url.path();
|
||||
const QString name = path.section(QLatin1Char('/'), -1, -1, QString::SectionSkipEmpty);
|
||||
if (!name.isEmpty()) {
|
||||
return name;
|
||||
}
|
||||
return url.host();
|
||||
}
|
||||
|
||||
LinkInfo parseUrl(const QUrl &url)
|
||||
{
|
||||
LinkInfo info;
|
||||
if (!url.isValid() || url.scheme().isEmpty()) {
|
||||
return info;
|
||||
}
|
||||
|
||||
info.url = url;
|
||||
info.scheme = url.scheme().toLower();
|
||||
info.displayName = displayNameFor(url);
|
||||
|
||||
if (info.scheme == QStringLiteral("http") || info.scheme == QStringLiteral("https")) {
|
||||
info.kind = LinkInfo::Http;
|
||||
info.extension = extensionOfPath(url.path());
|
||||
info.fileType = fileTypeForExtension(info.extension);
|
||||
info.looksLikeFile = info.fileType != QStringLiteral("page") &&
|
||||
info.fileType != QStringLiteral("unknown");
|
||||
} else if (info.scheme == QStringLiteral("thunder")) {
|
||||
info.kind = LinkInfo::Custom;
|
||||
info.decodedUrl = decodeThunder(url.toString());
|
||||
if (!info.decodedUrl.isEmpty()) {
|
||||
const QUrl real(info.decodedUrl);
|
||||
info.extension = extensionOfPath(real.path());
|
||||
info.fileType = fileTypeForExtension(info.extension);
|
||||
info.displayName = displayNameFor(real);
|
||||
}
|
||||
} else if (info.scheme == QStringLiteral("magnet") || info.scheme == QStringLiteral("ed2k") ||
|
||||
info.scheme == QStringLiteral("spk") ||
|
||||
info.scheme == QStringLiteral("x-scheme-handler")) {
|
||||
info.kind = LinkInfo::Custom;
|
||||
} else if (info.scheme == QStringLiteral("ftp") || info.scheme == QStringLiteral("mailto") ||
|
||||
info.scheme == QStringLiteral("tel") || info.scheme == QStringLiteral("file")) {
|
||||
info.kind = LinkInfo::Standard;
|
||||
} else {
|
||||
info.kind = LinkInfo::Custom;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
LinkInfo parse(const QString &text)
|
||||
{
|
||||
const QString trimmed = text.trimmed();
|
||||
if (trimmed.isEmpty() || trimmed.contains(QLatin1Char('\n'))) {
|
||||
return LinkInfo();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith(QStringLiteral("www."), Qt::CaseInsensitive)) {
|
||||
return parseUrl(QUrl(QStringLiteral("https://") + trimmed));
|
||||
}
|
||||
|
||||
/* QUrl lowercases the host part, which destroys base64 payloads such as
|
||||
* thunder://. Handle these schemes on the raw string. */
|
||||
if (trimmed.startsWith(QStringLiteral("thunder://"), Qt::CaseInsensitive)) {
|
||||
LinkInfo info;
|
||||
info.kind = LinkInfo::Custom;
|
||||
info.raw = trimmed;
|
||||
info.scheme = QStringLiteral("thunder");
|
||||
info.url = QUrl(trimmed);
|
||||
info.decodedUrl = decodeThunder(trimmed);
|
||||
if (!info.decodedUrl.isEmpty()) {
|
||||
const QUrl real(info.decodedUrl);
|
||||
info.extension = extensionOfPath(real.path());
|
||||
info.fileType = fileTypeForExtension(info.extension);
|
||||
info.displayName = displayNameFor(real);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
static const QRegularExpression schemePattern(
|
||||
QStringLiteral("^[A-Za-z][A-Za-z0-9+.-]*://?"), QRegularExpression::CaseInsensitiveOption);
|
||||
if (!schemePattern.match(trimmed).hasMatch() &&
|
||||
!trimmed.startsWith(QStringLiteral("magnet:"), Qt::CaseInsensitive) &&
|
||||
!trimmed.startsWith(QStringLiteral("mailto:"), Qt::CaseInsensitive) &&
|
||||
!trimmed.startsWith(QStringLiteral("tel:"), Qt::CaseInsensitive)) {
|
||||
return LinkInfo();
|
||||
}
|
||||
|
||||
LinkInfo info = parseUrl(QUrl(trimmed));
|
||||
if (info.isValid() && info.raw.isEmpty()) {
|
||||
info.raw = trimmed;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
bool isHttpLink(const QString &text)
|
||||
{
|
||||
const LinkInfo info = parse(text);
|
||||
return info.kind == LinkInfo::Http;
|
||||
}
|
||||
|
||||
} // namespace LinkTools
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
/**
|
||||
* Classification of a link dropped onto the content factory.
|
||||
*/
|
||||
struct LinkInfo
|
||||
{
|
||||
enum Kind {
|
||||
None,
|
||||
Http, // http(s) URL
|
||||
Standard, // other standard URL (ftp, mailto, tel, file)
|
||||
Custom, // app specific scheme (thunder, spk, magnet, ed2k, ...)
|
||||
Other,
|
||||
};
|
||||
|
||||
Kind kind = None;
|
||||
QUrl url;
|
||||
QString scheme;
|
||||
QString extension; // lower case, without dot
|
||||
QString fileType; // image / audio / video / document / archive / code / page / unknown
|
||||
QString raw; // original text, preserved for custom schemes
|
||||
QString decodedUrl; // thunder:// and friends
|
||||
QString displayName; // magnet dn / last path segment
|
||||
bool looksLikeFile = false;
|
||||
|
||||
bool isValid() const { return kind != None; }
|
||||
QString kindLabel() const;
|
||||
};
|
||||
|
||||
namespace LinkTools {
|
||||
|
||||
LinkInfo parse(const QString &text);
|
||||
LinkInfo parseUrl(const QUrl &url);
|
||||
|
||||
/** Map a file extension to a coarse content type. */
|
||||
QString fileTypeForExtension(const QString &extension);
|
||||
|
||||
/** Decode thunder:// (base64 of "AA" + url + "ZZ") and magnet display name. */
|
||||
QString decodeThunder(const QString &text);
|
||||
|
||||
QString displayNameFor(const QUrl &url);
|
||||
|
||||
bool isHttpLink(const QString &text);
|
||||
|
||||
} // namespace LinkTools
|
||||
@@ -0,0 +1,165 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "todomodel.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSaveFile>
|
||||
#include <QUuid>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
TodoModel *TodoModel::instance()
|
||||
{
|
||||
static TodoModel *self = new TodoModel;
|
||||
return self;
|
||||
}
|
||||
|
||||
TodoModel::TodoModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
m_path = Config::instance()->dataDir() + QStringLiteral("/todo.json");
|
||||
load();
|
||||
}
|
||||
|
||||
int TodoModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
return parent.isValid() ? 0 : m_items.size();
|
||||
}
|
||||
|
||||
QVariant TodoModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() >= m_items.size()) {
|
||||
return QVariant();
|
||||
}
|
||||
const TodoItem &item = m_items.at(index.row());
|
||||
switch (role) {
|
||||
case Qt::DisplayRole:
|
||||
case TextRole:
|
||||
return item.text;
|
||||
case DoneRole:
|
||||
return item.done;
|
||||
case IdRole:
|
||||
return item.id;
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
}
|
||||
|
||||
bool TodoModel::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_items.removeAt(row);
|
||||
}
|
||||
endRemoveRows();
|
||||
save();
|
||||
Q_EMIT countChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
Qt::ItemFlags TodoModel::flags(const QModelIndex &index) const
|
||||
{
|
||||
return index.isValid() ? (Qt::ItemIsEnabled | Qt::ItemIsSelectable) : Qt::NoItemFlags;
|
||||
}
|
||||
|
||||
void TodoModel::add(const QString &text)
|
||||
{
|
||||
const QString trimmed = text.trimmed();
|
||||
if (trimmed.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
TodoItem item;
|
||||
item.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
item.text = trimmed;
|
||||
item.createdAt = QDateTime::currentMSecsSinceEpoch();
|
||||
|
||||
beginInsertRows(QModelIndex(), 0, 0);
|
||||
m_items.prepend(item);
|
||||
endInsertRows();
|
||||
save();
|
||||
Q_EMIT countChanged();
|
||||
}
|
||||
|
||||
void TodoModel::setDone(int row, bool done)
|
||||
{
|
||||
if (row < 0 || row >= m_items.size() || m_items[row].done == done) {
|
||||
return;
|
||||
}
|
||||
m_items[row].done = done;
|
||||
const QModelIndex idx = index(row);
|
||||
Q_EMIT dataChanged(idx, idx, { DoneRole });
|
||||
save();
|
||||
}
|
||||
|
||||
const TodoItem *TodoModel::itemAt(int row) const
|
||||
{
|
||||
return (row >= 0 && row < m_items.size()) ? &m_items.at(row) : nullptr;
|
||||
}
|
||||
|
||||
void TodoModel::load()
|
||||
{
|
||||
QFile file(m_path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return;
|
||||
}
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||
if (!doc.isObject()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QList<TodoItem> items;
|
||||
const QJsonArray array = doc.object().value(QStringLiteral("items")).toArray();
|
||||
for (const QJsonValue &value : array) {
|
||||
const QJsonObject object = value.toObject();
|
||||
TodoItem item;
|
||||
item.id = object.value(QStringLiteral("id")).toString();
|
||||
item.text = object.value(QStringLiteral("text")).toString();
|
||||
item.done = object.value(QStringLiteral("done")).toBool();
|
||||
item.createdAt =
|
||||
static_cast<qint64>(object.value(QStringLiteral("createdAt")).toDouble());
|
||||
if (!item.text.isEmpty()) {
|
||||
items.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
beginResetModel();
|
||||
m_items = items;
|
||||
endResetModel();
|
||||
Q_EMIT countChanged();
|
||||
}
|
||||
|
||||
void TodoModel::save() const
|
||||
{
|
||||
QJsonArray array;
|
||||
for (const TodoItem &item : m_items) {
|
||||
QJsonObject object;
|
||||
object.insert(QStringLiteral("id"), item.id);
|
||||
object.insert(QStringLiteral("text"), item.text);
|
||||
object.insert(QStringLiteral("done"), item.done);
|
||||
object.insert(QStringLiteral("createdAt"), static_cast<double>(item.createdAt));
|
||||
array.append(object);
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
root.insert(QStringLiteral("version"), 1);
|
||||
root.insert(QStringLiteral("items"), array);
|
||||
|
||||
QSaveFile file(m_path);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
return;
|
||||
}
|
||||
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
|
||||
file.commit();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
struct TodoItem
|
||||
{
|
||||
QString id;
|
||||
QString text;
|
||||
bool done = false;
|
||||
qint64 createdAt = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persisted todo list backing the todo window and the "add to todo" action.
|
||||
*/
|
||||
class TodoModel : public QAbstractListModel
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum Roles {
|
||||
TextRole = Qt::UserRole + 1,
|
||||
DoneRole,
|
||||
IdRole,
|
||||
};
|
||||
|
||||
static TodoModel *instance();
|
||||
|
||||
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 add(const QString &text);
|
||||
void setDone(int row, bool done);
|
||||
const TodoItem *itemAt(int row) const;
|
||||
int count() const { return m_items.size(); }
|
||||
|
||||
void load();
|
||||
void save() const;
|
||||
|
||||
signals:
|
||||
void countChanged();
|
||||
|
||||
private:
|
||||
explicit TodoModel(QObject *parent = nullptr);
|
||||
|
||||
QList<TodoItem> m_items;
|
||||
QString m_path;
|
||||
};
|
||||
+245
-5
@@ -5,7 +5,21 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "portal/portalclient.h"
|
||||
#include "staging/stagingitem.h"
|
||||
|
||||
#include "actions/downloader.h"
|
||||
#include "actions/texttools.h"
|
||||
#include "factory/contentclassifier.h"
|
||||
#include "factory/contentfactory.h"
|
||||
#include "todo/todomodel.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QClipboard>
|
||||
#include <QDesktopServices>
|
||||
#include <QFileDialog>
|
||||
#include <QMessageBox>
|
||||
#include <QProcess>
|
||||
#include <QStandardPaths>
|
||||
#include <QTemporaryDir>#include "staging/stagingitem.h"
|
||||
#include "staging/stagingmodel.h"
|
||||
|
||||
#include <QApplication>
|
||||
@@ -267,6 +281,10 @@ public:
|
||||
setAcceptDrops(true);
|
||||
}
|
||||
|
||||
signals:
|
||||
void dragContent(const QMimeData *mime);
|
||||
void dragFinished();
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override
|
||||
{
|
||||
@@ -326,6 +344,7 @@ protected:
|
||||
return;
|
||||
}
|
||||
if (mimeSupported(event->mimeData())) {
|
||||
Q_EMIT dragContent(event->mimeData());
|
||||
event->acceptProposedAction();
|
||||
return;
|
||||
}
|
||||
@@ -339,6 +358,7 @@ protected:
|
||||
return;
|
||||
}
|
||||
if (mimeSupported(event->mimeData())) {
|
||||
Q_EMIT dragContent(event->mimeData());
|
||||
event->acceptProposedAction();
|
||||
updateHover(event->position().toPoint());
|
||||
return;
|
||||
@@ -357,9 +377,16 @@ protected:
|
||||
if (staging) {
|
||||
staging->addMimeData(event->mimeData());
|
||||
}
|
||||
Q_EMIT dragFinished();
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
|
||||
void dragLeaveEvent(QDragLeaveEvent *event) override
|
||||
{
|
||||
Q_EMIT dragFinished();
|
||||
QListView::dragLeaveEvent(event);
|
||||
}
|
||||
|
||||
private:
|
||||
void updateHover(const QPoint &pos)
|
||||
{
|
||||
@@ -497,6 +524,8 @@ signals:
|
||||
void chatRequested();
|
||||
void portalRequested();
|
||||
void portalCancelled();
|
||||
void dragContent(const QMimeData *mime);
|
||||
void dragFinished();
|
||||
|
||||
protected:
|
||||
void resizeEvent(QResizeEvent *event) override
|
||||
@@ -573,12 +602,14 @@ protected:
|
||||
if (!m_portalActive) {
|
||||
m_portalTimer.start();
|
||||
}
|
||||
Q_EMIT dragContent(event->mimeData());
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
|
||||
void dragMoveEvent(QDragMoveEvent *event) override
|
||||
{
|
||||
if (mimeSupported(event->mimeData())) {
|
||||
Q_EMIT dragContent(event->mimeData());
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
}
|
||||
@@ -589,6 +620,7 @@ protected:
|
||||
m_active = false;
|
||||
update();
|
||||
m_portalTimer.stop();
|
||||
Q_EMIT dragFinished();
|
||||
}
|
||||
|
||||
void dropEvent(QDropEvent *event) override
|
||||
@@ -596,6 +628,7 @@ protected:
|
||||
m_active = false;
|
||||
m_portalTimer.stop();
|
||||
update();
|
||||
Q_EMIT dragFinished();
|
||||
if (!m_portalActive) {
|
||||
showHint(tr("悬停在「传送门」上,再移动到目标窗口"));
|
||||
event->ignore();
|
||||
@@ -705,8 +738,12 @@ void PortalPanel::setupWindow()
|
||||
|
||||
void PortalPanel::setupUi()
|
||||
{
|
||||
auto *outer = new QHBoxLayout(this);
|
||||
outer->setContentsMargins(kMargin, kMargin, kMargin, kMargin);
|
||||
auto *root = new QVBoxLayout(this);
|
||||
root->setContentsMargins(kMargin, kMargin, kMargin, kMargin);
|
||||
root->setSpacing(8);
|
||||
|
||||
auto *outer = new QHBoxLayout;
|
||||
outer->setContentsMargins(0, 0, 0, 0);
|
||||
outer->setSpacing(10);
|
||||
|
||||
m_zone = new PortalZone(this);
|
||||
@@ -782,9 +819,37 @@ void PortalPanel::setupUi()
|
||||
right->addWidget(m_view, 1);
|
||||
|
||||
outer->addLayout(right, 1);
|
||||
root->addLayout(outer);
|
||||
|
||||
m_factory = new QWidget(this);
|
||||
m_factory = new ContentFactory(this);
|
||||
m_factory->hide();
|
||||
root->addWidget(m_factory);
|
||||
|
||||
m_toast = new QLabel(this);
|
||||
m_toast->hide();
|
||||
m_toast->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
m_toast->setStyleSheet(QStringLiteral(
|
||||
"QLabel { background: rgba(0,0,0,0.72); color: white; border-radius: 8px;"
|
||||
" padding: 6px 12px; font-size: 12px; }"));
|
||||
|
||||
m_factoryTimer = new QTimer(this);
|
||||
m_factoryTimer->setSingleShot(true);
|
||||
m_factoryTimer->setInterval(280);
|
||||
connect(m_factoryTimer, &QTimer::timeout, this, [this]() {
|
||||
if (m_factory) {
|
||||
m_factory->clearContent();
|
||||
m_factory->hide();
|
||||
}
|
||||
refreshLayout();
|
||||
});
|
||||
|
||||
connect(m_zone, &PortalZone::dragContent, this, &PortalPanel::noteDragContent);
|
||||
connect(m_zone, &PortalZone::dragFinished, this, &PortalPanel::noteDragFinished);
|
||||
connect(m_view, &StagingView::dragContent, this, &PortalPanel::noteDragContent);
|
||||
connect(m_view, &StagingView::dragFinished, this, &PortalPanel::noteDragFinished);
|
||||
connect(m_factory, &ContentFactory::actionRequested, this, &PortalPanel::runFactoryAction);
|
||||
connect(m_factory, &ContentFactory::heightChanged, this, [this]() { refreshLayout(); });
|
||||
connect(m_factory, &ContentFactory::dragActivity, this, [this]() { m_factoryTimer->stop(); });
|
||||
}
|
||||
|
||||
void PortalPanel::refreshLayout()
|
||||
@@ -807,10 +872,185 @@ void PortalPanel::updateGeometryForContent()
|
||||
|
||||
const int headerHeight = 26;
|
||||
const int selectionHeight = (m_selectionBar && m_selectionBar->isVisible()) ? 32 : 0;
|
||||
const int totalHeight = kMargin * 2 + headerHeight + 6 + selectionHeight + listHeight;
|
||||
const int factoryHeight =
|
||||
(m_factory && m_factory->isVisible()) ? m_factory->sizeHint().height() + 8 : 0;
|
||||
const int totalHeight =
|
||||
kMargin * 2 + headerHeight + 6 + selectionHeight + listHeight + factoryHeight;
|
||||
resize(kPanelWidth, totalHeight);
|
||||
}
|
||||
|
||||
void PortalPanel::noteDragContent(const QMimeData *mime)
|
||||
{
|
||||
if (!mime) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_factoryTimer->stop();
|
||||
|
||||
const DropContent content = ContentClassifier::classify(mime);
|
||||
if (!content.valid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_factory->isVisible() && m_factory->hasContent() &&
|
||||
m_factory->content().summary() == content.summary()) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_factory->setContent(content);
|
||||
m_factory->show();
|
||||
refreshLayout();
|
||||
}
|
||||
|
||||
void PortalPanel::noteDragFinished()
|
||||
{
|
||||
if (m_factoryTimer) {
|
||||
m_factoryTimer->start();
|
||||
}
|
||||
}
|
||||
|
||||
void PortalPanel::toast(const QString &text)
|
||||
{
|
||||
if (!m_toast) {
|
||||
return;
|
||||
}
|
||||
m_toast->setText(text);
|
||||
m_toast->adjustSize();
|
||||
m_toast->move((width() - m_toast->width()) / 2, height() - m_toast->height() - 14);
|
||||
m_toast->show();
|
||||
m_toast->raise();
|
||||
QTimer::singleShot(2600, m_toast, &QLabel::hide);
|
||||
}
|
||||
|
||||
void PortalPanel::showTextDialog(const QString &title, const QString &text)
|
||||
{
|
||||
QMessageBox box(QMessageBox::Information, title, text.left(4000), QMessageBox::Ok, this);
|
||||
QPushButton *copyButton = box.addButton(tr("复制"), QMessageBox::ActionRole);
|
||||
box.exec();
|
||||
if (box.clickedButton() == copyButton) {
|
||||
QApplication::clipboard()->setText(text);
|
||||
toast(tr("已复制"));
|
||||
}
|
||||
}
|
||||
|
||||
void PortalPanel::runFactoryAction(const QString &id, const DropContent &content)
|
||||
{
|
||||
auto copyText = [this](const QString &text) {
|
||||
QApplication::clipboard()->setText(text);
|
||||
toast(tr("已复制"));
|
||||
};
|
||||
|
||||
const QString link = content.link.url.toString();
|
||||
const QString sourceText = content.kind == DropContent::Text
|
||||
? content.text
|
||||
: (content.kind == DropContent::Files
|
||||
? TextTools::readTextFile(content.filePaths.value(0),
|
||||
512 * 1024)
|
||||
: content.text);
|
||||
|
||||
if (id == QLatin1String("open")) {
|
||||
if (content.kind == DropContent::Files) {
|
||||
QDesktopServices::openUrl(QUrl::fromLocalFile(content.filePaths.value(0)));
|
||||
} else if (!link.isEmpty()) {
|
||||
QDesktopServices::openUrl(content.link.url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("open_browser") || id == QLatin1String("try_open") ||
|
||||
id == QLatin1String("preview") || id == QLatin1String("play")) {
|
||||
QDesktopServices::openUrl(content.link.url);
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("download")) {
|
||||
Downloader::instance()->download(content.link.url);
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("save_as")) {
|
||||
Downloader::instance()->saveAs(content.link.url, this);
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("copy")) {
|
||||
copyText(!content.link.raw.isEmpty() ? content.link.raw
|
||||
: (link.isEmpty() ? content.text : link));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("copy_markdown")) {
|
||||
copyText(QStringLiteral("[%1](%2)").arg(content.link.displayName, link));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("copy_name")) {
|
||||
copyText(content.link.displayName);
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("copy_path")) {
|
||||
copyText(content.filePaths.value(0));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("copy_decoded")) {
|
||||
copyText(content.link.decodedUrl);
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("decode")) {
|
||||
showTextDialog(tr("真实地址"), content.link.decodedUrl);
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("segment")) {
|
||||
const QStringList words = TextTools::segment(sourceText.left(20000));
|
||||
showTextDialog(tr("分词(%1)").arg(words.size()), words.join(QStringLiteral(" / ")));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("extract_links")) {
|
||||
const QStringList links = TextTools::extractLinks(sourceText);
|
||||
showTextDialog(tr("提取链接(%1)").arg(links.size()), links.join(QLatin1Char('\n')));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("speak")) {
|
||||
const QString program = QStandardPaths::findExecutable(QStringLiteral("spd-say"));
|
||||
if (program.isEmpty()) {
|
||||
toast(tr("未找到语音朗读程序"));
|
||||
return;
|
||||
}
|
||||
QProcess::startDetached(program, { sourceText.left(4000) });
|
||||
toast(tr("开始朗读"));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("todo")) {
|
||||
const QString text = content.kind == DropContent::Text ? content.text.left(500)
|
||||
: content.summary();
|
||||
TodoModel::instance()->add(text);
|
||||
toast(tr("已添加到待办"));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("save_image")) {
|
||||
if (content.image.isNull()) {
|
||||
toast(tr("没有可保存的图片"));
|
||||
return;
|
||||
}
|
||||
const QString path = QFileDialog::getSaveFileName(
|
||||
this, tr("保存图片"), QStringLiteral("image.png"), tr("图片 (*.png *.jpg)"));
|
||||
if (!path.isEmpty()) {
|
||||
content.image.save(path);
|
||||
toast(tr("已保存:%1").arg(path));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("ask")) {
|
||||
Q_EMIT openChatRequested();
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("translate")) {
|
||||
toast(tr("翻译接口将在小埃同学接入后可用"));
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("ocr") || id == QLatin1String("describe") ||
|
||||
id == QLatin1String("transcribe")) {
|
||||
toast(tr("该能力尚未接入 GXDE Daemon"));
|
||||
return;
|
||||
}
|
||||
|
||||
toast(tr("未知操作:%1").arg(id));
|
||||
}
|
||||
|
||||
void PortalPanel::updateSelectionBar()
|
||||
{
|
||||
if (!m_selectionBar) {
|
||||
|
||||
+12
-1
@@ -6,8 +6,12 @@
|
||||
#include <DBlurEffectWidget>
|
||||
#include <QWidget>
|
||||
|
||||
class ContentFactory;
|
||||
class QLabel;
|
||||
class QMimeData;
|
||||
class QPushButton;
|
||||
class QTimer;
|
||||
struct DropContent;
|
||||
class PortalZone;
|
||||
class StagingModel;
|
||||
class StagingView;
|
||||
@@ -42,13 +46,20 @@ private:
|
||||
void setupWindow();
|
||||
void updateGeometryForContent();
|
||||
void updateSelectionBar();
|
||||
void noteDragContent(const QMimeData *mime);
|
||||
void noteDragFinished();
|
||||
void runFactoryAction(const QString &id, const DropContent &content);
|
||||
void showTextDialog(const QString &title, const QString &text);
|
||||
void toast(const QString &text);
|
||||
|
||||
StagingModel *m_model = nullptr;
|
||||
StagingView *m_view = nullptr;
|
||||
PortalZone *m_zone = nullptr;
|
||||
QWidget *m_header = nullptr;
|
||||
QWidget *m_selectionBar = nullptr;
|
||||
QWidget *m_factory = nullptr;
|
||||
ContentFactory *m_factory = nullptr;
|
||||
QLabel *m_toast = nullptr;
|
||||
QTimer *m_factoryTimer = nullptr;
|
||||
QLabel *m_countLabel = nullptr;
|
||||
QPushButton *m_selectAllButton = nullptr;
|
||||
QPushButton *m_invertButton = nullptr;
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Build the wlr-virtual-pointer test driver.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
cc -O2 -Wall pointer.c virtual-pointer-protocol.c -o pointer $(pkg-config --cflags --libs wayland-client)
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""GTK3 drag source used by the portal integration test."""
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
from gi.repository import Gdk, Gtk # noqa: E402
|
||||
|
||||
|
||||
class Source(Gtk.Window):
|
||||
def __init__(self):
|
||||
super().__init__(title="PortalDragSource")
|
||||
self.set_default_size(420, 280)
|
||||
self.set_position(Gtk.WindowPosition.CENTER)
|
||||
|
||||
label = Gtk.Label()
|
||||
label.set_markup("<span size='x-large'>DRAG ME</span>\n\nportal test payload")
|
||||
label.set_hexpand(True)
|
||||
label.set_vexpand(True)
|
||||
|
||||
box = Gtk.EventBox()
|
||||
box.add(label)
|
||||
self.add(box)
|
||||
box.add_events(
|
||||
Gdk.EventMask.BUTTON_PRESS_MASK
|
||||
| Gdk.EventMask.BUTTON_RELEASE_MASK
|
||||
| Gdk.EventMask.POINTER_MOTION_MASK
|
||||
)
|
||||
box.connect("button-press-event", lambda *args: print("PRESS", flush=True))
|
||||
box.connect("button-release-event", lambda *args: print("RELEASE", flush=True))
|
||||
box.connect("motion-notify-event", lambda *args: print("MOTION", flush=True))
|
||||
|
||||
box.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, [], Gdk.DragAction.COPY)
|
||||
box.drag_source_add_text_targets()
|
||||
box.connect("drag-data-get", self.on_drag_data_get)
|
||||
box.connect("drag-begin", lambda *args: print("DRAG-BEGIN", flush=True))
|
||||
box.connect("drag-end", lambda *args: print("DRAG-END", flush=True))
|
||||
|
||||
self.connect("destroy", Gtk.main_quit)
|
||||
self.show_all()
|
||||
|
||||
def on_drag_data_get(self, widget, context, data, info, time):
|
||||
data.set_text("portal-payload-42", -1)
|
||||
|
||||
|
||||
Source()
|
||||
Gtk.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""GTK3 drop target used by the portal integration test.
|
||||
|
||||
GtkTextView accepts text drops out of the box; every insertion is printed as
|
||||
``DROP <text> at <offset>`` so the orchestrator can assert that the *native*
|
||||
drop landed in this window.
|
||||
"""
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
from gi.repository import Gtk # noqa: E402
|
||||
|
||||
|
||||
class Target(Gtk.Window):
|
||||
def __init__(self):
|
||||
super().__init__(title="PortalDropTarget")
|
||||
self.set_default_size(420, 280)
|
||||
self.set_position(Gtk.WindowPosition.CENTER)
|
||||
|
||||
view = Gtk.TextView()
|
||||
view.set_wrap_mode(Gtk.WrapMode.WORD)
|
||||
self.buffer = view.get_buffer()
|
||||
self.buffer.connect("insert-text", self.on_insert)
|
||||
|
||||
scroller = Gtk.ScrolledWindow()
|
||||
scroller.add(view)
|
||||
self.add(scroller)
|
||||
|
||||
self.connect("destroy", Gtk.main_quit)
|
||||
self.show_all()
|
||||
|
||||
def on_insert(self, buffer, location, text, length):
|
||||
print("DROP", repr(text), "at", location.get_offset(), flush=True)
|
||||
|
||||
|
||||
Target()
|
||||
Gtk.main()
|
||||
@@ -0,0 +1,234 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Minimal wlr-virtual-pointer driver used by the portal integration test.
|
||||
// Reads commands from stdin, one per line:
|
||||
// move <x> <y> absolute motion in output coordinates
|
||||
// press left button down
|
||||
// release left button up
|
||||
// sleep <ms>
|
||||
// size print "size <w> <h>"
|
||||
// quit
|
||||
// After each command it prints "ok <line>" so the orchestrator can sync.
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include <linux/input-event-codes.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <wayland-client.h>
|
||||
|
||||
#include "virtual-pointer-client.h"
|
||||
|
||||
struct state {
|
||||
struct wl_display *display;
|
||||
struct wl_seat *seat;
|
||||
struct zwlr_virtual_pointer_manager_v1 *manager;
|
||||
struct zwlr_virtual_pointer_v1 *pointer;
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
};
|
||||
|
||||
static uint32_t now_ms(void)
|
||||
{
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (uint32_t)(ts.tv_sec * 1000 + ts.tv_nsec / 1000000);
|
||||
}
|
||||
|
||||
static void output_geometry(void *data, struct wl_output *output, int32_t x, int32_t y,
|
||||
int32_t physical_w, int32_t physical_h, int32_t subpixel,
|
||||
const char *make, const char *model, int32_t transform)
|
||||
{
|
||||
(void)data;
|
||||
(void)output;
|
||||
(void)x;
|
||||
(void)y;
|
||||
(void)physical_w;
|
||||
(void)physical_h;
|
||||
(void)subpixel;
|
||||
(void)make;
|
||||
(void)model;
|
||||
(void)transform;
|
||||
}
|
||||
|
||||
static void output_mode(void *data, struct wl_output *output, uint32_t flags, int32_t width,
|
||||
int32_t height, int32_t refresh)
|
||||
{
|
||||
(void)output;
|
||||
(void)flags;
|
||||
(void)refresh;
|
||||
struct state *state = data;
|
||||
if (width > 0 && height > 0) {
|
||||
state->width = (uint32_t)width;
|
||||
state->height = (uint32_t)height;
|
||||
}
|
||||
}
|
||||
|
||||
static void output_done(void *data, struct wl_output *output)
|
||||
{
|
||||
(void)data;
|
||||
(void)output;
|
||||
}
|
||||
|
||||
static void output_scale(void *data, struct wl_output *output, int32_t factor)
|
||||
{
|
||||
(void)data;
|
||||
(void)output;
|
||||
(void)factor;
|
||||
}
|
||||
|
||||
static void output_name(void *data, struct wl_output *output, const char *name)
|
||||
{
|
||||
(void)data;
|
||||
(void)output;
|
||||
(void)name;
|
||||
}
|
||||
|
||||
static void output_description(void *data, struct wl_output *output, const char *description)
|
||||
{
|
||||
(void)data;
|
||||
(void)output;
|
||||
(void)description;
|
||||
}
|
||||
|
||||
static const struct wl_output_listener output_listener = {
|
||||
.geometry = output_geometry,
|
||||
.mode = output_mode,
|
||||
.done = output_done,
|
||||
.scale = output_scale,
|
||||
.name = output_name,
|
||||
.description = output_description,
|
||||
};
|
||||
|
||||
static void seat_capabilities(void *data, struct wl_seat *seat, uint32_t caps)
|
||||
{
|
||||
(void)data;
|
||||
(void)seat;
|
||||
(void)caps;
|
||||
}
|
||||
|
||||
static void seat_name(void *data, struct wl_seat *seat, const char *name)
|
||||
{
|
||||
(void)data;
|
||||
(void)seat;
|
||||
(void)name;
|
||||
}
|
||||
|
||||
static const struct wl_seat_listener seat_listener = {
|
||||
.capabilities = seat_capabilities,
|
||||
.name = seat_name,
|
||||
};
|
||||
|
||||
static void registry_global(void *data, struct wl_registry *registry, uint32_t name,
|
||||
const char *interface, uint32_t version)
|
||||
{
|
||||
struct state *state = data;
|
||||
|
||||
if (strcmp(interface, wl_output_interface.name) == 0) {
|
||||
struct wl_output *output =
|
||||
wl_registry_bind(registry, name, &wl_output_interface, version < 2 ? version : 2);
|
||||
wl_output_add_listener(output, &output_listener, state);
|
||||
} else if (strcmp(interface, wl_seat_interface.name) == 0) {
|
||||
state->seat = wl_registry_bind(registry, name, &wl_seat_interface, version < 5 ? version : 5);
|
||||
wl_seat_add_listener(state->seat, &seat_listener, state);
|
||||
} else if (strcmp(interface, zwlr_virtual_pointer_manager_v1_interface.name) == 0) {
|
||||
state->manager = wl_registry_bind(registry, name,
|
||||
&zwlr_virtual_pointer_manager_v1_interface,
|
||||
version < 2 ? version : 2);
|
||||
}
|
||||
}
|
||||
|
||||
static void registry_global_remove(void *data, struct wl_registry *registry, uint32_t name)
|
||||
{
|
||||
(void)data;
|
||||
(void)registry;
|
||||
(void)name;
|
||||
}
|
||||
|
||||
static const struct wl_registry_listener registry_listener = {
|
||||
.global = registry_global,
|
||||
.global_remove = registry_global_remove,
|
||||
};
|
||||
|
||||
static void send_motion(struct state *state, double x, double y)
|
||||
{
|
||||
zwlr_virtual_pointer_v1_motion_absolute(state->pointer, now_ms(), wl_fixed_from_double(x),
|
||||
wl_fixed_from_double(y), state->width,
|
||||
state->height);
|
||||
zwlr_virtual_pointer_v1_frame(state->pointer);
|
||||
wl_display_roundtrip(state->display);
|
||||
}
|
||||
|
||||
static void send_button(struct state *state, uint32_t button, uint32_t button_state)
|
||||
{
|
||||
zwlr_virtual_pointer_v1_button(state->pointer, now_ms(), button, button_state);
|
||||
zwlr_virtual_pointer_v1_frame(state->pointer);
|
||||
wl_display_roundtrip(state->display);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
struct state state = { 0 };
|
||||
state.display = wl_display_connect(NULL);
|
||||
if (!state.display) {
|
||||
fprintf(stderr, "cannot connect to wayland display\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
struct wl_registry *registry = wl_display_get_registry(state.display);
|
||||
wl_registry_add_listener(registry, ®istry_listener, &state);
|
||||
wl_display_roundtrip(state.display);
|
||||
wl_display_roundtrip(state.display);
|
||||
|
||||
if (!state.seat || !state.manager) {
|
||||
fprintf(stderr, "missing seat or virtual pointer manager\n");
|
||||
return 1;
|
||||
}
|
||||
if (state.width == 0 || state.height == 0) {
|
||||
state.width = 1280;
|
||||
state.height = 720;
|
||||
}
|
||||
|
||||
state.pointer = zwlr_virtual_pointer_manager_v1_create_virtual_pointer(state.manager, state.seat);
|
||||
if (!state.pointer) {
|
||||
fprintf(stderr, "cannot create virtual pointer\n");
|
||||
return 1;
|
||||
}
|
||||
wl_display_roundtrip(state.display);
|
||||
|
||||
char line[256];
|
||||
while (fgets(line, sizeof(line), stdin)) {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
int ms = 0;
|
||||
|
||||
if (sscanf(line, "move %lf %lf", &x, &y) == 2) {
|
||||
send_motion(&state, x, y);
|
||||
} else if (strncmp(line, "press", 5) == 0) {
|
||||
send_button(&state, BTN_LEFT, WL_POINTER_BUTTON_STATE_PRESSED);
|
||||
} else if (strncmp(line, "release", 7) == 0) {
|
||||
send_button(&state, BTN_LEFT, WL_POINTER_BUTTON_STATE_RELEASED);
|
||||
} else if (sscanf(line, "sleep %d", &ms) == 1) {
|
||||
struct timespec delay = { ms / 1000, (long)(ms % 1000) * 1000000L };
|
||||
nanosleep(&delay, NULL);
|
||||
} else if (strncmp(line, "size", 4) == 0) {
|
||||
printf("size %u %u\n", state.width, state.height);
|
||||
} else if (strncmp(line, "quit", 4) == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
printf("ok %s", line);
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
zwlr_virtual_pointer_v1_destroy(state.pointer);
|
||||
zwlr_virtual_pointer_manager_v1_destroy(state.manager);
|
||||
wl_display_disconnect(state.display);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""End-to-end test for the GXDE portal (native drop into a window).
|
||||
|
||||
Run inside a private session bus:
|
||||
|
||||
dbus-run-session -- python3 tests/portal/run.py [compositor]
|
||||
|
||||
It starts a nested GXDE compositor, a GTK drag source and a GTK drop target,
|
||||
drives the pointer through wlr-virtual-pointer, asks the compositor to open the
|
||||
portal overview and asserts that the drop lands in the target window.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
RUNTIME = f"/run/user/{os.getuid()}"
|
||||
PORTAL = ["org.gxde.Portal", "/org/gxde/Portal", "org.gxde.Portal"]
|
||||
|
||||
|
||||
def log(message):
|
||||
print(f"[test] {message}", flush=True)
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=20.0, interval=0.1):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(interval)
|
||||
return None
|
||||
|
||||
|
||||
def portal_call(method, *args):
|
||||
address = os.environ.get("DBUS_SESSION_BUS_ADDRESS", "")
|
||||
cmd = ["busctl", f"--address={address}", "--json=short", "call", *PORTAL, method, *args]
|
||||
out = subprocess.check_output(cmd, text=True)
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
def list_windows():
|
||||
data = portal_call("ListWindows")["data"]
|
||||
rows = data[0] if data else []
|
||||
return [
|
||||
{
|
||||
"uuid": row[0],
|
||||
"title": row[1],
|
||||
"app": row[2],
|
||||
"x": row[3],
|
||||
"y": row[4],
|
||||
"w": row[5],
|
||||
"h": row[6],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def layout():
|
||||
data = portal_call("GetLayout")["data"]
|
||||
rows = data[0] if data else []
|
||||
return [
|
||||
{
|
||||
"uuid": row[0],
|
||||
"title": row[1],
|
||||
"x": row[3],
|
||||
"y": row[4],
|
||||
"w": row[5],
|
||||
"h": row[6],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def find_by_title(windows, title):
|
||||
for window in windows:
|
||||
if window["title"] == title:
|
||||
return window
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
compositor = (
|
||||
sys.argv[1]
|
||||
if len(sys.argv) > 1
|
||||
else os.path.expanduser("~/playground/flake-wm/build-portal/flakewm")
|
||||
)
|
||||
parent_display = os.environ.get("WAYLAND_DISPLAY", "wayland-0")
|
||||
|
||||
before = set(glob.glob(f"{RUNTIME}/wayland-*"))
|
||||
env = os.environ.copy()
|
||||
env["WAYLAND_DISPLAY"] = parent_display
|
||||
|
||||
compositor_log = open("/tmp/pt-comp.log", "wb")
|
||||
compositor_proc = subprocess.Popen(
|
||||
[compositor], env=env, stdout=compositor_log, stderr=subprocess.STDOUT
|
||||
)
|
||||
|
||||
procs = [compositor_proc]
|
||||
pointer = None
|
||||
try:
|
||||
def nested_display():
|
||||
for path in set(glob.glob(f"{RUNTIME}/wayland-*")) - before:
|
||||
if not path.endswith(".lock"):
|
||||
return os.path.basename(path)
|
||||
return None
|
||||
|
||||
display = wait_for(nested_display, 25.0)
|
||||
assert display, "nested compositor did not create a Wayland socket"
|
||||
log(f"nested display: {display}")
|
||||
env["WAYLAND_DISPLAY"] = display
|
||||
|
||||
target_log = open("/tmp/pt-target.log", "wb")
|
||||
source_log = open("/tmp/pt-source.log", "wb")
|
||||
target = subprocess.Popen(
|
||||
[sys.executable, f"{HERE}/drop_target.py"],
|
||||
env=env, stdout=target_log, stderr=subprocess.STDOUT,
|
||||
)
|
||||
source = subprocess.Popen(
|
||||
[sys.executable, f"{HERE}/drag_source.py"],
|
||||
env=env, stdout=source_log, stderr=subprocess.STDOUT,
|
||||
)
|
||||
procs += [target, source]
|
||||
|
||||
def portal_ready():
|
||||
try:
|
||||
portal_call("ListWindows")
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
assert wait_for(portal_ready, 20.0, 0.3), "org.gxde.Portal never appeared"
|
||||
log("portal D-Bus ready")
|
||||
|
||||
windows = wait_for(
|
||||
lambda: list_windows()
|
||||
if find_by_title(list_windows(), "PortalDragSource")
|
||||
and find_by_title(list_windows(), "PortalDropTarget")
|
||||
else None,
|
||||
20.0,
|
||||
0.2,
|
||||
)
|
||||
assert windows, "windows did not appear"
|
||||
log(f"windows: {json.dumps(windows)}")
|
||||
|
||||
src = find_by_title(windows, "PortalDragSource")
|
||||
dst = find_by_title(windows, "PortalDropTarget")
|
||||
|
||||
pointer = subprocess.Popen(
|
||||
[f"{HERE}/pointer"], env=env, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, text=True, bufsize=1,
|
||||
)
|
||||
procs.append(pointer)
|
||||
|
||||
def pcmd(line):
|
||||
pointer.stdin.write(line + "\n")
|
||||
pointer.stdin.flush()
|
||||
response = pointer.stdout.readline().strip()
|
||||
if not (response.startswith("ok") or response.startswith("size")):
|
||||
raise RuntimeError(f"pointer failed: {response!r}")
|
||||
return response
|
||||
|
||||
log(f"pointer {pcmd('size')}")
|
||||
|
||||
sx = src["x"] + src["w"] / 2
|
||||
sy = src["y"] + src["h"] / 2
|
||||
pcmd(f"move {sx:.0f} {sy:.0f}")
|
||||
time.sleep(0.2)
|
||||
pcmd("press")
|
||||
time.sleep(0.2)
|
||||
pcmd(f"move {sx + 60:.0f} {sy:.0f}")
|
||||
time.sleep(0.8)
|
||||
|
||||
if "DRAG-BEGIN" not in open("/tmp/pt-source.log").read():
|
||||
raise RuntimeError("GTK drag did not start; see /tmp/pt-source.log")
|
||||
log("drag started")
|
||||
|
||||
started = portal_call("BeginPortal")["data"][0]
|
||||
assert started, "BeginPortal returned false"
|
||||
log("portal opened")
|
||||
|
||||
time.sleep(0.7) # let the overview animation settle
|
||||
tiles = wait_for(lambda: layout() or None, 5.0, 0.2)
|
||||
assert tiles, "portal layout is empty"
|
||||
log(f"layout: {json.dumps(tiles)}")
|
||||
|
||||
tile = find_by_title(tiles, "PortalDropTarget") or find_by_title(tiles, "PortalDragSource")
|
||||
assert tile, "target window not in the portal layout"
|
||||
|
||||
# If we only see the source tile, the drop still validates retargeting.
|
||||
tx = tile["x"] + tile["w"] / 2
|
||||
ty = tile["y"] + tile["h"] / 2
|
||||
pcmd(f"move {tx:.0f} {ty:.0f}")
|
||||
time.sleep(0.6)
|
||||
|
||||
subprocess.run(["grim", "/tmp/pt-before-drop.png"], env=env, check=False)
|
||||
pcmd("release")
|
||||
time.sleep(1.0)
|
||||
subprocess.run(["grim", "/tmp/pt-after-drop.png"], env=env, check=False)
|
||||
|
||||
target_text = open("/tmp/pt-target.log").read()
|
||||
log(f"target log: {target_text.strip()!r}")
|
||||
assert "DROP" in target_text, "the drop did not reach the target window"
|
||||
assert "portal-payload-42" in target_text, "wrong payload"
|
||||
|
||||
log("PASS: native drop landed in the target window")
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f"FAIL: {exc}")
|
||||
for path in ("/tmp/pt-comp.log", "/tmp/pt-source.log", "/tmp/pt-target.log"):
|
||||
try:
|
||||
content = open(path).read().strip()
|
||||
except OSError:
|
||||
continue
|
||||
if content:
|
||||
log(f"--- {path} ---\n{content[-2000:]}")
|
||||
return 1
|
||||
finally:
|
||||
if pointer is not None:
|
||||
try:
|
||||
pointer.stdin.write("quit\n")
|
||||
pointer.stdin.flush()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
for proc in reversed(procs):
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
for proc in reversed(procs):
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except Exception: # noqa: BLE001
|
||||
proc.kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,370 @@
|
||||
/* Generated by wayland-scanner 1.23.1 */
|
||||
|
||||
#ifndef WLR_VIRTUAL_POINTER_UNSTABLE_V1_CLIENT_PROTOCOL_H
|
||||
#define WLR_VIRTUAL_POINTER_UNSTABLE_V1_CLIENT_PROTOCOL_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "wayland-client.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @page page_wlr_virtual_pointer_unstable_v1 The wlr_virtual_pointer_unstable_v1 protocol
|
||||
* @section page_ifaces_wlr_virtual_pointer_unstable_v1 Interfaces
|
||||
* - @subpage page_iface_zwlr_virtual_pointer_v1 - virtual pointer
|
||||
* - @subpage page_iface_zwlr_virtual_pointer_manager_v1 - virtual pointer manager
|
||||
* @section page_copyright_wlr_virtual_pointer_unstable_v1 Copyright
|
||||
* <pre>
|
||||
*
|
||||
* Copyright © 2019 Josef Gajdusek
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice (including the next
|
||||
* paragraph) shall be included in all copies or substantial portions of the
|
||||
* Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
* </pre>
|
||||
*/
|
||||
struct wl_output;
|
||||
struct wl_seat;
|
||||
struct zwlr_virtual_pointer_manager_v1;
|
||||
struct zwlr_virtual_pointer_v1;
|
||||
|
||||
#ifndef ZWLR_VIRTUAL_POINTER_V1_INTERFACE
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_INTERFACE
|
||||
/**
|
||||
* @page page_iface_zwlr_virtual_pointer_v1 zwlr_virtual_pointer_v1
|
||||
* @section page_iface_zwlr_virtual_pointer_v1_desc Description
|
||||
*
|
||||
* This protocol allows clients to emulate a physical pointer device. The
|
||||
* requests are mostly mirror opposites of those specified in wl_pointer.
|
||||
* @section page_iface_zwlr_virtual_pointer_v1_api API
|
||||
* See @ref iface_zwlr_virtual_pointer_v1.
|
||||
*/
|
||||
/**
|
||||
* @defgroup iface_zwlr_virtual_pointer_v1 The zwlr_virtual_pointer_v1 interface
|
||||
*
|
||||
* This protocol allows clients to emulate a physical pointer device. The
|
||||
* requests are mostly mirror opposites of those specified in wl_pointer.
|
||||
*/
|
||||
extern const struct wl_interface zwlr_virtual_pointer_v1_interface;
|
||||
#endif
|
||||
#ifndef ZWLR_VIRTUAL_POINTER_MANAGER_V1_INTERFACE
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_INTERFACE
|
||||
/**
|
||||
* @page page_iface_zwlr_virtual_pointer_manager_v1 zwlr_virtual_pointer_manager_v1
|
||||
* @section page_iface_zwlr_virtual_pointer_manager_v1_desc Description
|
||||
*
|
||||
* This object allows clients to create individual virtual pointer objects.
|
||||
* @section page_iface_zwlr_virtual_pointer_manager_v1_api API
|
||||
* See @ref iface_zwlr_virtual_pointer_manager_v1.
|
||||
*/
|
||||
/**
|
||||
* @defgroup iface_zwlr_virtual_pointer_manager_v1 The zwlr_virtual_pointer_manager_v1 interface
|
||||
*
|
||||
* This object allows clients to create individual virtual pointer objects.
|
||||
*/
|
||||
extern const struct wl_interface zwlr_virtual_pointer_manager_v1_interface;
|
||||
#endif
|
||||
|
||||
#ifndef ZWLR_VIRTUAL_POINTER_V1_ERROR_ENUM
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_ERROR_ENUM
|
||||
enum zwlr_virtual_pointer_v1_error {
|
||||
/**
|
||||
* client sent invalid axis enumeration value
|
||||
*/
|
||||
ZWLR_VIRTUAL_POINTER_V1_ERROR_INVALID_AXIS = 0,
|
||||
/**
|
||||
* client sent invalid axis source enumeration value
|
||||
*/
|
||||
ZWLR_VIRTUAL_POINTER_V1_ERROR_INVALID_AXIS_SOURCE = 1,
|
||||
};
|
||||
#endif /* ZWLR_VIRTUAL_POINTER_V1_ERROR_ENUM */
|
||||
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_MOTION 0
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_MOTION_ABSOLUTE 1
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_BUTTON 2
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS 3
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_FRAME 4
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_SOURCE 5
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_STOP 6
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_DISCRETE 7
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_DESTROY 8
|
||||
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_MOTION_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_MOTION_ABSOLUTE_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_BUTTON_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_FRAME_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_SOURCE_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_STOP_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_AXIS_DISCRETE_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_V1_DESTROY_SINCE_VERSION 1
|
||||
|
||||
/** @ingroup iface_zwlr_virtual_pointer_v1 */
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_set_user_data(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, void *user_data)
|
||||
{
|
||||
wl_proxy_set_user_data((struct wl_proxy *) zwlr_virtual_pointer_v1, user_data);
|
||||
}
|
||||
|
||||
/** @ingroup iface_zwlr_virtual_pointer_v1 */
|
||||
static inline void *
|
||||
zwlr_virtual_pointer_v1_get_user_data(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1)
|
||||
{
|
||||
return wl_proxy_get_user_data((struct wl_proxy *) zwlr_virtual_pointer_v1);
|
||||
}
|
||||
|
||||
static inline uint32_t
|
||||
zwlr_virtual_pointer_v1_get_version(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1)
|
||||
{
|
||||
return wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* The pointer has moved by a relative amount to the previous request.
|
||||
*
|
||||
* Values are in the global compositor space.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_motion(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t time, wl_fixed_t dx, wl_fixed_t dy)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_MOTION, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, time, dx, dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* The pointer has moved in an absolute coordinate frame.
|
||||
*
|
||||
* Value of x can range from 0 to x_extent, value of y can range from 0
|
||||
* to y_extent.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_motion_absolute(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t time, uint32_t x, uint32_t y, uint32_t x_extent, uint32_t y_extent)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_MOTION_ABSOLUTE, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, time, x, y, x_extent, y_extent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* A button was pressed or released.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_button(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t time, uint32_t button, uint32_t state)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_BUTTON, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, time, button, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* Scroll and other axis requests.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_axis(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t time, uint32_t axis, wl_fixed_t value)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_AXIS, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, time, axis, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* Indicates the set of events that logically belong together.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_frame(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_FRAME, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* Source information for scroll and other axis.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_axis_source(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t axis_source)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_AXIS_SOURCE, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, axis_source);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* Stop notification for scroll and other axes.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_axis_stop(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t time, uint32_t axis)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_AXIS_STOP, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, time, axis);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*
|
||||
* Discrete step information for scroll and other axes.
|
||||
*
|
||||
* This event allows the client to extend data normally sent using the axis
|
||||
* event with discrete value.
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_axis_discrete(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1, uint32_t time, uint32_t axis, wl_fixed_t value, int32_t discrete)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_AXIS_DISCRETE, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), 0, time, axis, value, discrete);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_v1
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_v1_destroy(struct zwlr_virtual_pointer_v1 *zwlr_virtual_pointer_v1)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_v1,
|
||||
ZWLR_VIRTUAL_POINTER_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_v1), WL_MARSHAL_FLAG_DESTROY);
|
||||
}
|
||||
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_CREATE_VIRTUAL_POINTER 0
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_DESTROY 1
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_CREATE_VIRTUAL_POINTER_WITH_OUTPUT 2
|
||||
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_manager_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_CREATE_VIRTUAL_POINTER_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_manager_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_DESTROY_SINCE_VERSION 1
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_manager_v1
|
||||
*/
|
||||
#define ZWLR_VIRTUAL_POINTER_MANAGER_V1_CREATE_VIRTUAL_POINTER_WITH_OUTPUT_SINCE_VERSION 2
|
||||
|
||||
/** @ingroup iface_zwlr_virtual_pointer_manager_v1 */
|
||||
static inline void
|
||||
zwlr_virtual_pointer_manager_v1_set_user_data(struct zwlr_virtual_pointer_manager_v1 *zwlr_virtual_pointer_manager_v1, void *user_data)
|
||||
{
|
||||
wl_proxy_set_user_data((struct wl_proxy *) zwlr_virtual_pointer_manager_v1, user_data);
|
||||
}
|
||||
|
||||
/** @ingroup iface_zwlr_virtual_pointer_manager_v1 */
|
||||
static inline void *
|
||||
zwlr_virtual_pointer_manager_v1_get_user_data(struct zwlr_virtual_pointer_manager_v1 *zwlr_virtual_pointer_manager_v1)
|
||||
{
|
||||
return wl_proxy_get_user_data((struct wl_proxy *) zwlr_virtual_pointer_manager_v1);
|
||||
}
|
||||
|
||||
static inline uint32_t
|
||||
zwlr_virtual_pointer_manager_v1_get_version(struct zwlr_virtual_pointer_manager_v1 *zwlr_virtual_pointer_manager_v1)
|
||||
{
|
||||
return wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_manager_v1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_manager_v1
|
||||
*
|
||||
* Creates a new virtual pointer. The optional seat is a suggestion to the
|
||||
* compositor.
|
||||
*/
|
||||
static inline struct zwlr_virtual_pointer_v1 *
|
||||
zwlr_virtual_pointer_manager_v1_create_virtual_pointer(struct zwlr_virtual_pointer_manager_v1 *zwlr_virtual_pointer_manager_v1, struct wl_seat *seat)
|
||||
{
|
||||
struct wl_proxy *id;
|
||||
|
||||
id = wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_manager_v1,
|
||||
ZWLR_VIRTUAL_POINTER_MANAGER_V1_CREATE_VIRTUAL_POINTER, &zwlr_virtual_pointer_v1_interface, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_manager_v1), 0, seat, NULL);
|
||||
|
||||
return (struct zwlr_virtual_pointer_v1 *) id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_manager_v1
|
||||
*/
|
||||
static inline void
|
||||
zwlr_virtual_pointer_manager_v1_destroy(struct zwlr_virtual_pointer_manager_v1 *zwlr_virtual_pointer_manager_v1)
|
||||
{
|
||||
wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_manager_v1,
|
||||
ZWLR_VIRTUAL_POINTER_MANAGER_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_manager_v1), WL_MARSHAL_FLAG_DESTROY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ingroup iface_zwlr_virtual_pointer_manager_v1
|
||||
*
|
||||
* Creates a new virtual pointer. The seat and the output arguments are
|
||||
* optional. If the seat argument is set, the compositor should assign the
|
||||
* input device to the requested seat. If the output argument is set, the
|
||||
* compositor should map the input device to the requested output.
|
||||
*/
|
||||
static inline struct zwlr_virtual_pointer_v1 *
|
||||
zwlr_virtual_pointer_manager_v1_create_virtual_pointer_with_output(struct zwlr_virtual_pointer_manager_v1 *zwlr_virtual_pointer_manager_v1, struct wl_seat *seat, struct wl_output *output)
|
||||
{
|
||||
struct wl_proxy *id;
|
||||
|
||||
id = wl_proxy_marshal_flags((struct wl_proxy *) zwlr_virtual_pointer_manager_v1,
|
||||
ZWLR_VIRTUAL_POINTER_MANAGER_V1_CREATE_VIRTUAL_POINTER_WITH_OUTPUT, &zwlr_virtual_pointer_v1_interface, wl_proxy_get_version((struct wl_proxy *) zwlr_virtual_pointer_manager_v1), 0, seat, output, NULL);
|
||||
|
||||
return (struct zwlr_virtual_pointer_v1 *) id;
|
||||
}
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,87 @@
|
||||
/* Generated by wayland-scanner 1.23.1 */
|
||||
|
||||
/*
|
||||
* Copyright © 2019 Josef Gajdusek
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice (including the next
|
||||
* paragraph) shall be included in all copies or substantial portions of the
|
||||
* Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include "wayland-util.h"
|
||||
|
||||
#ifndef __has_attribute
|
||||
# define __has_attribute(x) 0 /* Compatibility with non-clang compilers. */
|
||||
#endif
|
||||
|
||||
#if (__has_attribute(visibility) || defined(__GNUC__) && __GNUC__ >= 4)
|
||||
#define WL_PRIVATE __attribute__ ((visibility("hidden")))
|
||||
#else
|
||||
#define WL_PRIVATE
|
||||
#endif
|
||||
|
||||
extern const struct wl_interface wl_output_interface;
|
||||
extern const struct wl_interface wl_seat_interface;
|
||||
extern const struct wl_interface zwlr_virtual_pointer_v1_interface;
|
||||
|
||||
static const struct wl_interface *wlr_virtual_pointer_unstable_v1_types[] = {
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
&wl_seat_interface,
|
||||
&zwlr_virtual_pointer_v1_interface,
|
||||
&wl_seat_interface,
|
||||
&wl_output_interface,
|
||||
&zwlr_virtual_pointer_v1_interface,
|
||||
};
|
||||
|
||||
static const struct wl_message zwlr_virtual_pointer_v1_requests[] = {
|
||||
{ "motion", "uff", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "motion_absolute", "uuuuu", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "button", "uuu", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "axis", "uuf", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "frame", "", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "axis_source", "u", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "axis_stop", "uu", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "axis_discrete", "uufi", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "destroy", "", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
};
|
||||
|
||||
WL_PRIVATE const struct wl_interface zwlr_virtual_pointer_v1_interface = {
|
||||
"zwlr_virtual_pointer_v1", 2,
|
||||
9, zwlr_virtual_pointer_v1_requests,
|
||||
0, NULL,
|
||||
};
|
||||
|
||||
static const struct wl_message zwlr_virtual_pointer_manager_v1_requests[] = {
|
||||
{ "create_virtual_pointer", "?on", wlr_virtual_pointer_unstable_v1_types + 5 },
|
||||
{ "destroy", "", wlr_virtual_pointer_unstable_v1_types + 0 },
|
||||
{ "create_virtual_pointer_with_output", "2?o?on", wlr_virtual_pointer_unstable_v1_types + 7 },
|
||||
};
|
||||
|
||||
WL_PRIVATE const struct wl_interface zwlr_virtual_pointer_manager_v1_interface = {
|
||||
"zwlr_virtual_pointer_manager_v1", 2,
|
||||
3, zwlr_virtual_pointer_manager_v1_requests,
|
||||
0, NULL,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Headless checks for the pure (non widget) parts of the content factory.
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QTextStream>
|
||||
|
||||
#include "actions/texttools.h"
|
||||
#include "factory/contentclassifier.h"
|
||||
#include "factory/factoryactions.h"
|
||||
#include "factory/linktools.h"
|
||||
|
||||
static int failures = 0;
|
||||
|
||||
static void check(bool condition, const QString &what)
|
||||
{
|
||||
QTextStream out(stdout);
|
||||
if (condition) {
|
||||
out << "PASS " << what << "\n";
|
||||
} else {
|
||||
out << "FAIL " << what << "\n";
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
static bool hasAction(const QVector<FactoryAction> &actions, const QString &id)
|
||||
{
|
||||
for (const FactoryAction &action : actions) {
|
||||
if (action.id == id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
|
||||
// http link with a video extension
|
||||
const LinkInfo video = LinkTools::parse(QStringLiteral("https://example.com/media/clip.mp4"));
|
||||
check(video.kind == LinkInfo::Http, "https link recognised");
|
||||
check(video.extension == QStringLiteral("mp4"), "mp4 extension parsed");
|
||||
check(video.fileType == QStringLiteral("video"), "mp4 classified as video");
|
||||
check(hasAction(actionsFor(ContentClassifier::classifyText(QStringLiteral(
|
||||
"https://example.com/media/clip.mp4"))),
|
||||
QStringLiteral("download")),
|
||||
"video link offers download");
|
||||
check(hasAction(actionsFor(ContentClassifier::classifyText(QStringLiteral(
|
||||
"https://example.com/media/clip.mp4"))),
|
||||
QStringLiteral("play")),
|
||||
"video link offers play");
|
||||
|
||||
const LinkInfo image = LinkTools::parse(QStringLiteral("https://example.com/a.png?x=1"));
|
||||
check(image.fileType == QStringLiteral("image"), "png classified as image");
|
||||
|
||||
const LinkInfo page = LinkTools::parse(QStringLiteral("https://example.com/article"));
|
||||
check(page.fileType == QStringLiteral("page"), "extensionless url is a page");
|
||||
|
||||
// thunder:// -> base64("AA" + url + "ZZ")
|
||||
const QByteArray thunderPayload =
|
||||
QByteArray("AA") + "http://example.com/file.zip" + QByteArray("ZZ");
|
||||
const QString thunder =
|
||||
QStringLiteral("thunder://") + QString::fromLatin1(thunderPayload.toBase64());
|
||||
const LinkInfo thunderInfo = LinkTools::parse(thunder);
|
||||
check(thunderInfo.kind == LinkInfo::Custom, "thunder link classified as custom");
|
||||
check(thunderInfo.decodedUrl == QStringLiteral("http://example.com/file.zip"),
|
||||
"thunder payload decoded");
|
||||
check(hasAction(actionsFor(ContentClassifier::classifyText(thunder)),
|
||||
QStringLiteral("decode")),
|
||||
"thunder offers decode action");
|
||||
|
||||
// magnet
|
||||
const LinkInfo magnet =
|
||||
LinkTools::parse(QStringLiteral("magnet:?xt=urn:btih:abc&dn=Some%20File"));
|
||||
check(magnet.kind == LinkInfo::Custom, "magnet classified as custom");
|
||||
check(magnet.displayName == QStringLiteral("Some File"), "magnet display name parsed");
|
||||
|
||||
// spk://
|
||||
const LinkInfo spk = LinkTools::parse(QStringLiteral("spk://store/app/com.example"));
|
||||
check(spk.kind == LinkInfo::Custom, "spk link classified as custom");
|
||||
|
||||
// plain text is not a link
|
||||
const LinkInfo text = LinkTools::parse(QStringLiteral("hello world"));
|
||||
check(!text.isValid(), "plain text is not a link");
|
||||
|
||||
// segmentation
|
||||
const QStringList words = TextTools::segment(QStringLiteral("我爱北京天安门"));
|
||||
check(words.size() >= 3, "segmentation produced words");
|
||||
check(words.contains(QStringLiteral("北京")), "segmentation keeps 北京 together");
|
||||
|
||||
// link extraction
|
||||
const QStringList links = TextTools::extractLinks(
|
||||
QStringLiteral("看 https://a.example/x?y=1 和 test@example.com,还有 www.b.example"));
|
||||
check(links.size() == 3, QStringLiteral("extractLinks found 3 links, got %1").arg(links.size()));
|
||||
|
||||
// classifier
|
||||
const DropContent textContent = ContentClassifier::classifyText(QStringLiteral("just words"));
|
||||
check(textContent.kind == DropContent::Text, "text classified as text");
|
||||
const DropContent linkContent =
|
||||
ContentClassifier::classifyText(QStringLiteral("https://example.com/a.zip"));
|
||||
check(linkContent.kind == DropContent::Link, "link classified as link");
|
||||
check(hasAction(actionsFor(linkContent), QStringLiteral("save_as")), "link offers save as");
|
||||
|
||||
QTextStream(stdout) << (failures == 0 ? "ALL PASS\n" : "FAILURES\n");
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user