feat(panel): 内容工厂常驻、问问小埃大按钮、分词/朗读 Dialog

- 内容工厂固定高度常驻,面板尺寸/位置不再随拖拽变化
- 问问小埃改成与工厂项一致的大按钮,可点击/可 drop,drop 后新开对话并填入内容
- 工厂动作按内容过滤:无 CJK 不出分词,无链接不出提取,朗读仅文本
- 朗读打开语速 Dialog,分词改为可滑动选择的胶囊 + 复制
- BeginPortal 传面板矩形,供概览避让
This commit is contained in:
shenmo
2026-10-30 21:45:10 +08:00
parent 4032de318c
commit 3b92202f2d
17 changed files with 705 additions and 70 deletions
+3
View File
@@ -28,6 +28,9 @@ set(GXDE_AI_SEE_SOURCES
src/factory/contentclassifier.cpp
src/factory/factoryactions.cpp
src/factory/contentfactory.cpp
src/dialogs/flowlayout.cpp
src/dialogs/segmentdialog.cpp
src/dialogs/speechdialog.cpp
src/actions/downloader.cpp
src/actions/texttools.cpp
src/todo/todomodel.cpp
+16
View File
@@ -89,16 +89,25 @@ bool App::init(bool daemon, bool forceShow, bool showTodo, bool showChat)
}
m_dragging = true;
if (!m_model->isEmpty()) {
if (m_panel && m_panel->isVisible()) {
m_panel->suppressPortalIfUnderCursor();
}
return;
}
QTimer::singleShot(120, this, [this]() {
if (m_dragging && m_model->isEmpty()) {
showPanel();
if (m_panel) {
m_panel->suppressPortalIfUnderCursor();
}
}
});
});
connect(portal, &PortalClient::dragEnded, this, [this]() {
m_dragging = false;
if (m_panel) {
m_panel->stopPortalSuppression();
}
if (m_model->isEmpty() && m_panel && m_panel->isVisible()) {
m_panel->dismiss();
}
@@ -131,6 +140,13 @@ void App::ensurePanel()
m_panel = new PortalPanel(m_model);
connect(m_panel, &PortalPanel::openChatRequested, this, &App::showChatWindow);
connect(m_panel, &PortalPanel::askWithContent, this,
[this](const QString &text, const QStringList &files) {
showChatWindow();
if (m_chatWindow) {
m_chatWindow->startConversationWith(text, files);
}
});
}
void App::showPanel()
+30
View File
@@ -1256,6 +1256,36 @@ void ChatWindow::handleDropOnConversation(int index, const QMimeData *mime)
activateWindow();
}
void ChatWindow::startConversationWith(const QString &text, const QStringList &files)
{
activateForAsk();
newConversation();
QString content = text;
if (!files.isEmpty()) {
if (!content.isEmpty()) {
content += QLatin1Char('\n');
}
content += files.join(QLatin1Char('\n'));
}
if (!content.isEmpty()) {
m_input->setPlainText(content);
m_input->moveCursor(QTextCursor::End);
const QString title = text.isEmpty() ? QFileInfo(files.value(0)).fileName()
: text.simplified().left(24);
if (m_current >= 0 && !title.isEmpty()) {
m_conversations[m_current].title = title;
save();
updateSidebar();
renderConversation();
}
}
m_input->setFocus();
m_status->setText(tr("拖到左侧空白处=新对话,拖到左侧会话=追加到该会话,拖到右侧对话区=发给当前会话"));
m_status->show();
}
void ChatWindow::activateForAsk()
{
show();
+3
View File
@@ -43,6 +43,9 @@ public:
/** Bring the window up for the "ask AI" action. */
void activateForAsk();
/** New conversation pre-filled from a portal drop on the ask tile. */
void startConversationWith(const QString &text, const QStringList &files);
/** Insert dropped content into the input of the current/new conversation. */
void handleDroppedMime(const QMimeData *mime);
void handleDropOnConversation(int index, const QMimeData *mime);
+87
View File
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "flowlayout.h"
#include <QStyle>
#include <QWidget>
FlowLayout::FlowLayout(QWidget *parent, int margin, int spacing)
: QLayout(parent)
, m_spacing(spacing)
{
setContentsMargins(margin, margin, margin, margin);
}
FlowLayout::~FlowLayout()
{
while (QLayoutItem *item = takeAt(0)) {
delete item;
}
}
void FlowLayout::addItem(QLayoutItem *item) { m_items.append(item); }
int FlowLayout::count() const { return m_items.size(); }
QLayoutItem *FlowLayout::itemAt(int index) const { return m_items.value(index); }
QLayoutItem *FlowLayout::takeAt(int index)
{
return index >= 0 && index < m_items.size() ? m_items.takeAt(index) : nullptr;
}
Qt::Orientations FlowLayout::expandingDirections() const { return {}; }
bool FlowLayout::hasHeightForWidth() const { return true; }
int FlowLayout::heightForWidth(int width) const { return doLayout(QRect(0, 0, width, 0), true); }
void FlowLayout::setGeometry(const QRect &rect)
{
QLayout::setGeometry(rect);
doLayout(rect, false);
}
QSize FlowLayout::sizeHint() const { return minimumSize(); }
QSize FlowLayout::minimumSize() const
{
QSize size;
for (QLayoutItem *item : m_items) {
size = size.expandedTo(item->minimumSize());
}
const QMargins margins = contentsMargins();
return size + QSize(margins.left() + margins.right(), margins.top() + margins.bottom());
}
int FlowLayout::doLayout(const QRect &rect, bool testOnly) const
{
const QMargins margins = contentsMargins();
const QRect effective = rect.adjusted(margins.left(), margins.top(), -margins.right(),
-margins.bottom());
int x = effective.x();
int y = effective.y();
int lineHeight = 0;
for (QLayoutItem *item : m_items) {
QWidget *widget = item->widget();
int spaceX = m_spacing;
int spaceY = m_spacing;
if (widget) {
spaceX = widget->style()->layoutSpacing(QSizePolicy::PushButton,
QSizePolicy::PushButton, Qt::Horizontal);
spaceY = widget->style()->layoutSpacing(QSizePolicy::PushButton,
QSizePolicy::PushButton, Qt::Vertical);
}
int nextX = x + item->sizeHint().width() + spaceX;
if (nextX - spaceX > effective.right() && lineHeight > 0) {
x = effective.x();
y = y + lineHeight + spaceY;
nextX = x + item->sizeHint().width() + spaceX;
lineHeight = 0;
}
if (!testOnly) {
item->setGeometry(QRect(QPoint(x, y), item->sizeHint()));
}
x = nextX;
lineHeight = qMax(lineHeight, item->sizeHint().height());
}
return y + lineHeight - rect.y() + margins.bottom();
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QLayout>
#include <QList>
/** Simple wrapping layout (Qt flow layout example). */
class FlowLayout : public QLayout
{
public:
explicit FlowLayout(QWidget *parent = nullptr, int margin = 0, int spacing = 6);
~FlowLayout() override;
void addItem(QLayoutItem *item) override;
int count() const override;
QLayoutItem *itemAt(int index) const override;
QLayoutItem *takeAt(int index) override;
Qt::Orientations expandingDirections() const override;
bool hasHeightForWidth() const override;
int heightForWidth(int width) const override;
QSize minimumSize() const override;
void setGeometry(const QRect &rect) override;
QSize sizeHint() const override;
private:
int doLayout(const QRect &rect, bool testOnly) const;
QList<QLayoutItem *> m_items;
int m_spacing;
};
+150
View File
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "segmentdialog.h"
#include "flowlayout.h"
#include <QApplication>
#include <QClipboard>
#include <QEvent>
#include <QLabel>
#include <QMouseEvent>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
SegmentDialog::SegmentDialog(const QStringList &words, QWidget *parent)
: QDialog(parent)
{
setWindowTitle(tr("分词"));
setAttribute(Qt::WA_DeleteOnClose);
resize(560, 420);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 14, 16, 14);
layout->setSpacing(10);
auto *hint = new QLabel(tr("按住并滑过词语可以连续选择,选中后复制。"), this);
hint->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.55); font-size: 12px;"));
layout->addWidget(hint);
auto *scroll = new QScrollArea(this);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
auto *container = new QWidget(scroll);
auto *flow = new FlowLayout(container, 6, 6);
container->setLayout(flow);
scroll->setWidget(container);
layout->addWidget(scroll, 1);
for (const QString &word : words) {
auto *capsule = new QPushButton(word, container);
capsule->setCheckable(true);
capsule->setCursor(Qt::PointingHandCursor);
capsule->setFocusPolicy(Qt::NoFocus);
capsule->setStyleSheet(QStringLiteral(
"QPushButton { color: rgba(255,255,255,0.85); background: rgba(255,255,255,0.07);"
" border: 1px solid rgba(255,255,255,0.10); border-radius: 11px;"
" padding: 3px 10px; font-size: 13px; }"
"QPushButton:hover { background: rgba(255,255,255,0.14); }"
"QPushButton:checked { background: rgba(74,144,255,0.55);"
" border-color: rgba(160,205,255,0.9); color: white; }"));
capsule->installEventFilter(this);
connect(capsule, &QPushButton::released, this, [this]() { m_selecting = false; });
flow->addWidget(capsule);
m_capsules.append(capsule);
}
auto *bottom = new QHBoxLayout;
auto *all = new QPushButton(tr("全选"), this);
auto *none = new QPushButton(tr("清空"), this);
m_count = new QLabel(tr("已选 0 个"), this);
m_count->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.55); font-size: 12px;"));
auto *copy = new QPushButton(tr("复制选中"), this);
copy->setStyleSheet(QStringLiteral(
"QPushButton { color: white; background: rgba(74,144,255,0.85); border: none;"
" border-radius: 8px; padding: 6px 14px; }"
"QPushButton:hover { background: rgba(90,160,255,0.95); }"));
bottom->addWidget(all);
bottom->addWidget(none);
bottom->addStretch();
bottom->addWidget(m_count);
bottom->addSpacing(10);
bottom->addWidget(copy);
layout->addLayout(bottom);
connect(all, &QPushButton::clicked, this, [this]() { setAll(true); });
connect(none, &QPushButton::clicked, this, [this]() { setAll(false); });
connect(copy, &QPushButton::clicked, this, &SegmentDialog::copySelected);
auto *close = new QPushButton(tr("关闭"), this);
layout->addWidget(close, 0, Qt::AlignRight);
connect(close, &QPushButton::clicked, this, &QDialog::close);
}
void SegmentDialog::setAll(bool checked)
{
for (QPushButton *capsule : m_capsules) {
capsule->setChecked(checked);
}
updateCount();
}
void SegmentDialog::updateCount()
{
int count = 0;
for (QPushButton *capsule : m_capsules) {
if (capsule->isChecked()) {
++count;
}
}
m_count->setText(tr("已选 %1 个").arg(count));
}
void SegmentDialog::copySelected()
{
QStringList selected;
for (QPushButton *capsule : m_capsules) {
if (capsule->isChecked()) {
selected.append(capsule->text());
}
}
if (selected.isEmpty()) {
m_count->setText(tr("还没有选中词语"));
return;
}
QApplication::clipboard()->setText(selected.join(QLatin1Char(' ')));
m_count->setText(tr("已复制 %1 个").arg(selected.size()));
}
bool SegmentDialog::eventFilter(QObject *watched, QEvent *event)
{
auto *capsule = qobject_cast<QPushButton *>(watched);
if (!capsule) {
return QDialog::eventFilter(watched, event);
}
if (event->type() == QEvent::MouseButtonPress) {
auto *mouse = static_cast<QMouseEvent *>(event);
if (mouse->button() == Qt::LeftButton) {
m_selecting = true;
m_selectValue = !capsule->isChecked();
capsule->setChecked(m_selectValue);
updateCount();
}
} else if (event->type() == QEvent::Enter && m_selecting &&
(QApplication::mouseButtons() & Qt::LeftButton)) {
if (capsule->isChecked() != m_selectValue) {
capsule->setChecked(m_selectValue);
updateCount();
}
}
return QDialog::eventFilter(watched, event);
}
void SegmentDialog::mouseReleaseEvent(QMouseEvent *event)
{
m_selecting = false;
QDialog::mouseReleaseEvent(event);
}
+34
View File
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDialog>
#include <QStringList>
#include <QVector>
class QLabel;
class QPushButton;
/** Word segmentation result as selectable capsules. */
class SegmentDialog : public QDialog
{
Q_OBJECT
public:
explicit SegmentDialog(const QStringList &words, QWidget *parent = nullptr);
private:
void updateCount();
void copySelected();
void setAll(bool checked);
QVector<QPushButton *> m_capsules;
QLabel *m_count = nullptr;
bool m_selecting = false;
bool m_selectValue = true;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
};
+111
View File
@@ -0,0 +1,111 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#include "speechdialog.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QPlainTextEdit>
#include <QProcess>
#include <QPushButton>
#include <QSlider>
#include <QStandardPaths>
#include <QVBoxLayout>
SpeechDialog::SpeechDialog(const QString &text, QWidget *parent)
: QDialog(parent)
, m_text(text)
{
setWindowTitle(tr("朗读"));
setAttribute(Qt::WA_DeleteOnClose);
resize(480, 380);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 14, 16, 14);
layout->setSpacing(10);
m_preview = new QPlainTextEdit(this);
m_preview->setReadOnly(true);
m_preview->setPlainText(text.left(4000));
m_preview->setStyleSheet(QStringLiteral(
"QPlainTextEdit { background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.9);"
" border: none; border-radius: 8px; padding: 8px; }"));
layout->addWidget(m_preview, 1);
auto *rateRow = new QHBoxLayout;
rateRow->addWidget(new QLabel(tr("语速"), this));
m_rate = new QSlider(Qt::Horizontal, this);
m_rate->setRange(50, 200);
m_rate->setValue(100);
rateRow->addWidget(m_rate, 1);
layout->addLayout(rateRow);
m_status = new QLabel(this);
m_status->setStyleSheet(QStringLiteral("color: rgba(255,255,255,0.55); font-size: 12px;"));
layout->addWidget(m_status);
auto *buttons = new QHBoxLayout;
auto *play = new QPushButton(tr("开始朗读"), this);
play->setStyleSheet(QStringLiteral(
"QPushButton { color: white; background: rgba(74,144,255,0.85); border: none;"
" border-radius: 8px; padding: 7px 16px; }"
"QPushButton:hover { background: rgba(90,160,255,0.95); }"));
auto *stopButton = new QPushButton(tr("停止"), this);
auto *close = new QPushButton(tr("关闭"), this);
buttons->addWidget(play);
buttons->addWidget(stopButton);
buttons->addStretch();
buttons->addWidget(close);
layout->addLayout(buttons);
connect(play, &QPushButton::clicked, this, &SpeechDialog::start);
connect(stopButton, &QPushButton::clicked, this, &SpeechDialog::stop);
connect(close, &QPushButton::clicked, this, &QDialog::close);
}
SpeechDialog::~SpeechDialog()
{
stop();
}
void SpeechDialog::start()
{
stop();
QString program = QStandardPaths::findExecutable(QStringLiteral("spd-say"));
QStringList args;
if (!program.isEmpty()) {
args = { QStringLiteral("-r"), QString::number(m_rate->value() - 100),
QStringLiteral("-w"), m_text.left(4000) };
} else {
program = QStandardPaths::findExecutable(QStringLiteral("espeak-ng"));
if (program.isEmpty()) {
m_status->setText(tr("未找到 spd-say 或 espeak-ng"));
return;
}
args = { QStringLiteral("-s"), QString::number(m_rate->value()),
m_text.left(4000) };
}
m_process = new QProcess(this);
m_process->setProcessChannelMode(QProcess::MergedChannels);
connect(m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) {
m_status->setText(tr("朗读失败:%1").arg(m_process->errorString()));
});
connect(m_process, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), this,
[this](int, QProcess::ExitStatus) {
m_status->setText(tr("朗读结束"));
});
m_status->setText(tr("正在朗读…"));
m_process->start(program, args);
}
void SpeechDialog::stop()
{
if (m_process) {
m_process->kill();
m_process->waitForFinished(300);
m_process->deleteLater();
m_process = nullptr;
m_status->setText(tr("已停止"));
}
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2026 GXDE OS Team
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDialog>
#include <QString>
class QLabel;
class QPlainTextEdit;
class QProcess;
class QSlider;
/** Text-to-speech dialog with a rate control. */
class SpeechDialog : public QDialog
{
Q_OBJECT
public:
explicit SpeechDialog(const QString &text, QWidget *parent = nullptr);
~SpeechDialog() override;
private:
void start();
void stop();
QString m_text;
QPlainTextEdit *m_preview = nullptr;
QSlider *m_rate = nullptr;
QLabel *m_status = nullptr;
QProcess *m_process = nullptr;
};
+70 -13
View File
@@ -125,6 +125,67 @@ private:
QString m_id;
};
class AskTile : public QToolButton
{
Q_OBJECT
public:
explicit AskTile(QWidget *parent = nullptr)
: QToolButton(parent)
{
setText(tr("问问\n小埃同学"));
setToolTip(tr("把内容放到这里:新开一个对话并填入内容"));
setCursor(Qt::PointingHandCursor);
setFocusPolicy(Qt::NoFocus);
setAcceptDrops(true);
setFixedWidth(96);
setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
setStyleSheet(QStringLiteral(
"QToolButton { color: rgba(255,255,255,0.9); background: rgba(74,144,255,0.16);"
" border: 1px solid rgba(120,180,255,0.28); border-radius: 9px;"
" font-size: 12px; padding: 4px; }"
"QToolButton:hover { background: rgba(74,144,255,0.32); color: white;"
" border-color: rgba(160,205,255,0.7); }"
"QToolButton[dragover=\"true\"] { background: rgba(74,144,255,0.45);"
" border: 1px solid rgba(180,215,255,0.95); }"));
}
signals:
void dropped(const QMimeData *mime);
protected:
void dragEnterEvent(QDragEnterEvent *event) override
{
if (!event->mimeData() ||
!(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
event->mimeData()->hasImage())) {
event->ignore();
return;
}
setProperty("dragover", true);
style()->unpolish(this);
style()->polish(this);
event->acceptProposedAction();
}
void dragLeaveEvent(QDragLeaveEvent *event) override
{
Q_UNUSED(event)
setProperty("dragover", false);
style()->unpolish(this);
style()->polish(this);
}
void dropEvent(QDropEvent *event) override
{
setProperty("dragover", false);
style()->unpolish(this);
style()->polish(this);
event->acceptProposedAction();
Q_EMIT dropped(event->mimeData());
}
};
ContentFactory::ContentFactory(QWidget *parent)
: QWidget(parent)
{
@@ -172,19 +233,15 @@ ContentFactory::ContentFactory(QWidget *parent)
outer->addLayout(left, 1);
m_askButton = new QPushButton(tr("问问\n小埃同学"), this);
m_askButton->setCursor(Qt::PointingHandCursor);
m_askButton->setFocusPolicy(Qt::NoFocus);
m_askButton->setFixedWidth(92);
m_askButton->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
m_askButton->setStyleSheet(QStringLiteral(
"QPushButton { color: white; background: rgba(74,144,255,0.22);"
" border: 1px solid rgba(120,180,255,0.45); border-radius: 10px;"
" font-size: 12px; }"
"QPushButton:hover { background: rgba(74,144,255,0.38);"
" border-color: rgba(160,205,255,0.8); }"));
connect(m_askButton, &QPushButton::clicked, this, &ContentFactory::askRequested);
outer->addWidget(m_askButton);
m_askTile = new AskTile(this);
connect(m_askTile, &QToolButton::clicked, this, &ContentFactory::askRequested);
connect(m_askTile, &AskTile::dropped, this, &ContentFactory::askDropped);
outer->addWidget(m_askTile);
}
QSize ContentFactory::sizeHint() const
{
return QSize(size().width(), 148);
}
void ContentFactory::setContent(const DropContent &content)
+5 -1
View File
@@ -34,6 +34,10 @@ signals:
void heightChanged();
void dragActivity();
void askRequested();
void askDropped(const QMimeData *mime);
public:
QSize sizeHint() const override;
protected:
void paintEvent(QPaintEvent *event) override;
@@ -43,7 +47,7 @@ private:
void rebuild();
QLabel *m_summary = nullptr;
class QPushButton *m_askButton = nullptr;
class AskTile *m_askTile = nullptr;
QWidget *m_actions = nullptr;
QGridLayout *m_grid = nullptr;
DropContent m_content;
+38 -26
View File
@@ -4,6 +4,9 @@
#include "factoryactions.h"
#include <QFileInfo>
#include <QRegularExpression>
#include "../actions/texttools.h"
namespace {
@@ -41,8 +44,6 @@ QVector<FactoryAction> linkActions(const DropContent &content)
QStringLiteral("edit-copy")));
actions.append(action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
QStringLiteral("checkbox-checked")));
actions.append(action(QStringLiteral("ask"), QStringLiteral("问问小埃"),
QStringLiteral("dialog-question")));
return actions;
}
@@ -84,12 +85,20 @@ QVector<FactoryAction> fileActions(const DropContent &content)
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")));
const QString fileText = TextTools::readTextFile(path);
if (!fileText.isEmpty() &&
fileText.contains(QRegularExpression(QStringLiteral("[\\x{4e00}-\\x{9fff}]")))) {
actions.append(action(QStringLiteral("segment"), QStringLiteral("分词"),
QStringLiteral("insert-text")));
}
if (!fileText.isEmpty() && !TextTools::extractLinks(fileText).isEmpty()) {
actions.append(action(QStringLiteral("extract_links"), QStringLiteral("提取链接"),
QStringLiteral("insert-link")));
}
if (!fileText.isEmpty()) {
actions.append(action(QStringLiteral("speak"), QStringLiteral("朗读"),
QStringLiteral("audio-volume-high")));
}
}
actions.append(action(QStringLiteral("copy_path"), QStringLiteral("复制路径"),
@@ -120,25 +129,28 @@ QVector<FactoryAction> actionsFor(const DropContent &content)
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::Text: {
QVector<FactoryAction> actions;
const QString &text = content.text;
if (text.contains(QRegularExpression(QStringLiteral("[\\x{4e00}-\\x{9fff}]")))) {
actions.append(action(QStringLiteral("segment"), QStringLiteral("分词"),
QStringLiteral("insert-text")));
}
if (!TextTools::extractLinks(text).isEmpty()) {
actions.append(action(QStringLiteral("extract_links"), QStringLiteral("提取链接"),
QStringLiteral("insert-link")));
}
actions.append(action(QStringLiteral("translate"), QStringLiteral("翻译"),
QStringLiteral("accessories-dictionary")));
actions.append(action(QStringLiteral("speak"), QStringLiteral("朗读"),
QStringLiteral("audio-volume-high")));
actions.append(action(QStringLiteral("copy"), QStringLiteral("复制"),
QStringLiteral("edit-copy")));
actions.append(action(QStringLiteral("todo"), QStringLiteral("添加到待办"),
QStringLiteral("checkbox-checked")));
return actions;
}
case DropContent::Link:
case DropContent::CustomLink:
return linkActions(content);
+2 -1
View File
@@ -52,13 +52,14 @@ PortalClient::PortalClient(QObject *parent)
SLOT(onDropped(QString, bool)));
}
bool PortalClient::beginPortal()
bool PortalClient::beginPortal(const QRect &panelRect)
{
QDBusMessage message =
QDBusMessage::createMethodCall(QString::fromLatin1(kService),
QString::fromLatin1(kPath),
QString::fromLatin1(kInterface),
QStringLiteral("BeginPortal"));
message << panelRect.x() << panelRect.y() << panelRect.width() << panelRect.height();
const QDBusMessage reply = QDBusConnection::sessionBus().call(message, QDBus::Block, 500);
if (reply.type() != QDBusMessage::ReplyMessage || reply.arguments().isEmpty()) {
return false;
+2 -1
View File
@@ -4,6 +4,7 @@
#pragma once
#include <QObject>
#include <QRect>
/**
* Client for the compositor side "portal" (org.gxde.Portal).
@@ -23,7 +24,7 @@ public:
bool available() const { return m_available; }
/** @return true when the compositor opened the overview. */
bool beginPortal();
bool beginPortal(const QRect &panelRect = QRect());
void cancelPortal();
/** Filter the drag mime types we want to react to. */
+86 -27
View File
@@ -10,6 +10,9 @@
#include "actions/texttools.h"
#include "factory/contentclassifier.h"
#include "factory/contentfactory.h"
#include "dialogs/segmentdialog.h"
#include "dialogs/speechdialog.h"
#include "todo/todomodel.h"
#include <QApplication>
@@ -579,7 +582,7 @@ public:
m_portalTimer.setSingleShot(true);
m_portalTimer.setInterval(150);
connect(&m_portalTimer, &QTimer::timeout, this, [this]() {
if (m_active) {
if (m_active && !m_suppressed) {
Q_EMIT portalRequested();
}
});
@@ -598,6 +601,18 @@ public:
}
void showMessage(const QString &message) { showHint(message); }
/* Set when the drag started while the pointer was already over the portal:
* do not open the overview until the pointer leaves the panel. */
void setSuppressPortal(bool suppress)
{
m_suppressed = suppress;
if (suppress) {
m_portalTimer.stop();
}
}
bool suppressed() const { return m_suppressed; }
void triggerPortal() { Q_EMIT portalRequested(); }
void setPortalActive(bool active)
{
m_portalActive = active;
@@ -684,7 +699,7 @@ protected:
}
m_active = true;
update();
if (!m_portalActive) {
if (!m_portalActive && !m_suppressed) {
m_portalTimer.start();
}
Q_EMIT dragContent(event->mimeData());
@@ -736,6 +751,7 @@ private:
bool m_active = false;
bool m_hover = false;
bool m_portalActive = false;
bool m_suppressed = false;
};
/* ------------------------------------------------------------------------- */
@@ -815,7 +831,7 @@ void PortalPanel::setupWindow()
anchors |= LayerShellQt::Window::AnchorBottom;
layer->setAnchors(anchors);
layer->setMargins(
QMargins(0, 0, m_rightMargin, m_bottomMargin + m_factoryOffset));
QMargins(0, 0, m_rightMargin, m_bottomMargin));
layer->setLayer(LayerShellQt::Window::LayerOverlay);
layer->setKeyboardInteractivity(
LayerShellQt::Window::KeyboardInteractivityNone);
@@ -840,9 +856,24 @@ void PortalPanel::setupUi()
m_zone = new PortalZone(this);
m_zone->setFixedWidth(kZoneWidth);
outer->addWidget(m_zone);
m_suppressWatch = new QTimer(this);
m_suppressWatch->setInterval(80);
connect(m_suppressWatch, &QTimer::timeout, this, [this]() {
if (!m_zone->suppressed()) {
m_suppressWatch->stop();
return;
}
if (rect().contains(mapFromGlobal(QCursor::pos()))) {
return;
}
m_suppressWatch->stop();
m_zone->setSuppressPortal(false);
m_zone->triggerPortal();
});
connect(m_zone, &PortalZone::portalRequested, this, [this]() {
PortalClient *portal = PortalClient::instance();
if (portal->beginPortal()) {
if (portal->beginPortal(QRect(mapToGlobal(QPoint(0, 0)), size()))) {
m_zone->setPortalActive(true);
m_zone->showMessage(tr("移动到目标窗口上松手"));
} else {
@@ -912,7 +943,7 @@ void PortalPanel::setupUi()
root->addLayout(outer);
m_factory = new ContentFactory(this);
m_factory->hide();
m_factory->show();
root->addWidget(m_factory);
m_toast = new QLabel(this);
@@ -936,7 +967,6 @@ void PortalPanel::setupUi()
return;
}
m_factory->clearContent();
m_factory->hide();
refreshLayout();
});
@@ -946,6 +976,19 @@ void PortalPanel::setupUi()
connect(m_view, &StagingView::dragFinished, this, &PortalPanel::noteDragFinished);
connect(m_factory, &ContentFactory::actionRequested, this, &PortalPanel::runFactoryAction);
connect(m_factory, &ContentFactory::askRequested, this, &PortalPanel::openChatRequested);
connect(m_factory, &ContentFactory::askDropped, this, [this](const QMimeData *mime) {
if (!mime) {
return;
}
QStringList files;
for (const QUrl &url : mime->urls()) {
if (url.isLocalFile()) {
files.append(url.toLocalFile());
}
}
const QString text = mime->hasText() ? mime->text() : QString();
Q_EMIT askWithContent(text, files);
});
connect(m_factory, &ContentFactory::heightChanged, this, [this]() { refreshLayout(); });
connect(m_factory, &ContentFactory::dragActivity, this, [this]() {
if (m_factoryTimer) {
@@ -975,18 +1018,12 @@ void PortalPanel::updateGeometryForContent()
const int headerHeight = 26;
const int selectionHeight = (m_selectionBar && m_selectionBar->isVisible()) ? 32 : 0;
const int factoryHeight =
(m_factory && m_factory->isVisible()) ? m_factory->sizeHint().height() + 8 : 0;
/* The factory always occupies the same strip, so neither the panel size nor
* its position changes when a drag starts or ends. */
const int factoryHeight = m_factory ? m_factory->sizeHint().height() + 6 : 0;
const int totalHeight =
kMargin * 2 + headerHeight + 6 + selectionHeight + listHeight + factoryHeight;
resize(kPanelWidth, totalHeight);
/* Growing downwards would push the portal/s. Staging area off screen: raise
* the panel by the factory height so the two columns stay where they are. */
if (m_factoryOffset != factoryHeight) {
m_factoryOffset = factoryHeight;
applyMargins();
}
}
void PortalPanel::noteDragContent(const QMimeData *mime)
@@ -1008,8 +1045,6 @@ void PortalPanel::noteDragContent(const QMimeData *mime)
}
m_factory->setContent(content);
m_factory->show();
refreshLayout();
}
void PortalPanel::noteDragFinished()
@@ -1106,7 +1141,10 @@ void PortalPanel::runFactoryAction(const QString &id, const DropContent &content
}
if (id == QLatin1String("segment")) {
const QStringList words = TextTools::segment(sourceText.left(20000));
showTextDialog(tr("分词(%1)").arg(words.size()), words.join(QStringLiteral(" / ")));
auto *dialog = new SegmentDialog(words);
dialog->show();
dialog->raise();
dialog->activateWindow();
return;
}
if (id == QLatin1String("extract_links")) {
@@ -1115,13 +1153,10 @@ void PortalPanel::runFactoryAction(const QString &id, const DropContent &content
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("开始朗读"));
auto *dialog = new SpeechDialog(sourceText.left(20000));
dialog->show();
dialog->raise();
dialog->activateWindow();
return;
}
if (id == QLatin1String("todo")) {
@@ -1202,7 +1237,7 @@ void PortalPanel::applyMargins()
if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
if (auto *layer = LayerShellQt::Window::get(windowHandle())) {
layer->setMargins(
QMargins(0, 0, m_rightMargin, m_bottomMargin + m_factoryOffset));
QMargins(0, 0, m_rightMargin, m_bottomMargin));
return;
}
}
@@ -1210,7 +1245,7 @@ void PortalPanel::applyMargins()
if (QScreen *scr = screen() ? screen() : QGuiApplication::primaryScreen()) {
const QRect available = scr->availableGeometry();
move(available.right() - width() - m_rightMargin,
available.bottom() - height() - m_bottomMargin - m_factoryOffset);
available.bottom() - height() - m_bottomMargin);
}
}
@@ -1407,6 +1442,30 @@ void PortalPanel::showContextMenu(const QPoint &globalPos)
}
}
void PortalPanel::suppressPortalIfUnderCursor()
{
if (!m_zone || !isVisible()) {
return;
}
const bool under = m_zone->rect().contains(m_zone->mapFromGlobal(QCursor::pos()));
m_zone->setSuppressPortal(under);
if (under) {
m_suppressWatch->start();
} else {
m_suppressWatch->stop();
}
}
void PortalPanel::stopPortalSuppression()
{
if (m_suppressWatch) {
m_suppressWatch->stop();
}
if (m_zone) {
m_zone->setSuppressPortal(false);
}
}
void PortalPanel::showEvent(QShowEvent *event)
{
Dtk::Widget::DBlurEffectWidget::showEvent(event);
+4 -1
View File
@@ -33,10 +33,13 @@ public:
void refreshLayout();
void popup();
void suppressPortalIfUnderCursor();
void stopPortalSuppression();
void dismiss();
signals:
void openChatRequested();
void askWithContent(const QString &text, const QStringList &files);
protected:
void showEvent(QShowEvent *event) override;
@@ -75,7 +78,7 @@ private:
int m_rightMargin = 16;
int m_bottomMargin = 16;
int m_manualListHeight = 0;
int m_factoryOffset = 0;
QTimer *m_suppressWatch = nullptr;
bool m_fixedPosition = true;
bool m_fixedSize = false;
int m_dragMode = 0; // 0 none, 1 move, 2 resize