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:
2026-10-30 20:09:01 +08:00
parent 68d3eb1a24
commit 3a73a8c5d1
26 changed files with 2822 additions and 6 deletions
+3
View File
@@ -23,3 +23,6 @@ ui_*.h
*.so
*.a
.DS_Store
# test helpers
tests/portal/pointer
+20
View File
@@ -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)
+116
View File
@@ -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();
});
}
+39
View File
@@ -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;
};
+80
View File
@@ -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
+20
View File
@@ -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
+128
View File
@@ -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
+41
View File
@@ -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);
}
+250
View File
@@ -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"
+48
View File
@@ -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;
};
+148
View File
@@ -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 {};
}
}
+21
View File
@@ -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);
+241
View File
@@ -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
+52
View File
@@ -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
+165
View File
@@ -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();
}
+55
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+5
View File
@@ -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)
+50
View File
@@ -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()
+41
View File
@@ -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()
+234
View File
@@ -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, &registry_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;
}
+242
View File
@@ -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())
+370
View File
@@ -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
+87
View File
@@ -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,
};
+109
View File
@@ -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;
}