diff --git a/.gitignore b/.gitignore index 793cba8..2fe4120 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ ui_*.h *.so *.a .DS_Store + +# test helpers +tests/portal/pointer diff --git a/CMakeLists.txt b/CMakeLists.txt index 7d27cf8..7373caf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/src/actions/downloader.cpp b/src/actions/downloader.cpp new file mode 100644 index 0000000..1fd936a --- /dev/null +++ b/src/actions/downloader.cpp @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "downloader.h" + +#include +#include +#include +#include +#include +#include +#include + +#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(); + }); +} diff --git a/src/actions/downloader.h b/src/actions/downloader.h new file mode 100644 index 0000000..f19502d --- /dev/null +++ b/src/actions/downloader.h @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +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; +}; diff --git a/src/actions/texttools.cpp b/src/actions/texttools.cpp new file mode 100644 index 0000000..72aea53 --- /dev/null +++ b/src/actions/texttools.cpp @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "texttools.h" + +#include +#include +#include + +#include +#include + +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(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 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 diff --git a/src/actions/texttools.h b/src/actions/texttools.h new file mode 100644 index 0000000..c133c9e --- /dev/null +++ b/src/actions/texttools.h @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +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 diff --git a/src/factory/contentclassifier.cpp b/src/factory/contentclassifier.cpp new file mode 100644 index 0000000..3da885d --- /dev/null +++ b/src/factory/contentclassifier.cpp @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "contentclassifier.h" + +#include +#include +#include +#include + +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(mime->imageData()); + if (!content.image.isNull()) { + content.kind = DropContent::Image; + return content; + } + } + + if (mime->hasText()) { + return classifyText(mime->text()); + } + + return content; +} + +} // namespace ContentClassifier diff --git a/src/factory/contentclassifier.h b/src/factory/contentclassifier.h new file mode 100644 index 0000000..2649b2c --- /dev/null +++ b/src/factory/contentclassifier.h @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#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); +} diff --git a/src/factory/contentfactory.cpp b/src/factory/contentfactory.cpp new file mode 100644 index 0000000..a1d5981 --- /dev/null +++ b/src/factory/contentfactory.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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" diff --git a/src/factory/contentfactory.h b/src/factory/contentfactory.h new file mode 100644 index 0000000..17c4b72 --- /dev/null +++ b/src/factory/contentfactory.h @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#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; +}; diff --git a/src/factory/factoryactions.cpp b/src/factory/factoryactions.cpp new file mode 100644 index 0000000..81cc858 --- /dev/null +++ b/src/factory/factoryactions.cpp @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "factoryactions.h" + +#include + +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 linkActions(const DropContent &content) +{ + QVector 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 fileActions(const DropContent &content) +{ + QVector 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 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 {}; + } +} diff --git a/src/factory/factoryactions.h b/src/factory/factoryactions.h new file mode 100644 index 0000000..18d9402 --- /dev/null +++ b/src/factory/factoryactions.h @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#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 actionsFor(const DropContent &content); diff --git a/src/factory/linktools.cpp b/src/factory/linktools.cpp new file mode 100644 index 0000000..748fe89 --- /dev/null +++ b/src/factory/linktools.cpp @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "linktools.h" + +#include +#include +#include + +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 diff --git a/src/factory/linktools.h b/src/factory/linktools.h new file mode 100644 index 0000000..368fbab --- /dev/null +++ b/src/factory/linktools.h @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +/** + * 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 diff --git a/src/todo/todomodel.cpp b/src/todo/todomodel.cpp new file mode 100644 index 0000000..2f11f70 --- /dev/null +++ b/src/todo/todomodel.cpp @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "todomodel.h" + +#include +#include +#include +#include +#include +#include +#include + +#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 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(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(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(); +} diff --git a/src/todo/todomodel.h b/src/todo/todomodel.h new file mode 100644 index 0000000..ca0ced1 --- /dev/null +++ b/src/todo/todomodel.h @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +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 m_items; + QString m_path; +}; diff --git a/src/ui/portalpanel.cpp b/src/ui/portalpanel.cpp index bf46587..a7f5056 100644 --- a/src/ui/portalpanel.cpp +++ b/src/ui/portalpanel.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include #include "staging/stagingitem.h" #include "staging/stagingmodel.h" #include @@ -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) { diff --git a/src/ui/portalpanel.h b/src/ui/portalpanel.h index c58799c..a523936 100644 --- a/src/ui/portalpanel.h +++ b/src/ui/portalpanel.h @@ -6,8 +6,12 @@ #include #include +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; diff --git a/tests/portal/build.sh b/tests/portal/build.sh new file mode 100755 index 0000000..817efa4 --- /dev/null +++ b/tests/portal/build.sh @@ -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) diff --git a/tests/portal/drag_source.py b/tests/portal/drag_source.py new file mode 100644 index 0000000..5aeffce --- /dev/null +++ b/tests/portal/drag_source.py @@ -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("DRAG ME\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() diff --git a/tests/portal/drop_target.py b/tests/portal/drop_target.py new file mode 100644 index 0000000..1ef72ca --- /dev/null +++ b/tests/portal/drop_target.py @@ -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 at `` 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() diff --git a/tests/portal/pointer.c b/tests/portal/pointer.c new file mode 100644 index 0000000..7f0d1e0 --- /dev/null +++ b/tests/portal/pointer.c @@ -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 absolute motion in output coordinates +// press left button down +// release left button up +// sleep +// size print "size " +// quit +// After each command it prints "ok " so the orchestrator can sync. + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include +#include + +#include + +#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; +} diff --git a/tests/portal/run.py b/tests/portal/run.py new file mode 100644 index 0000000..80abceb --- /dev/null +++ b/tests/portal/run.py @@ -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()) diff --git a/tests/portal/virtual-pointer-client.h b/tests/portal/virtual-pointer-client.h new file mode 100644 index 0000000..e8eebcf --- /dev/null +++ b/tests/portal/virtual-pointer-client.h @@ -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 +#include +#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 + *
+ *
+ * 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.
+ * 
+ */ +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 diff --git a/tests/portal/virtual-pointer-protocol.c b/tests/portal/virtual-pointer-protocol.c new file mode 100644 index 0000000..30ef7f4 --- /dev/null +++ b/tests/portal/virtual-pointer-protocol.c @@ -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 +#include +#include +#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, +}; + diff --git a/tests/selftest.cpp b/tests/selftest.cpp new file mode 100644 index 0000000..9243260 --- /dev/null +++ b/tests/selftest.cpp @@ -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 +#include +#include + +#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 &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; +}