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
+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;