From 3b92202f2d4cfe82c578f869413f048b5f4ac700 Mon Sep 17 00:00:00 2001 From: shenmo Date: Fri, 30 Oct 2026 21:45:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(panel):=20=E5=86=85=E5=AE=B9=E5=B7=A5?= =?UTF-8?q?=E5=8E=82=E5=B8=B8=E9=A9=BB=E3=80=81=E9=97=AE=E9=97=AE=E5=B0=8F?= =?UTF-8?q?=E5=9F=83=E5=A4=A7=E6=8C=89=E9=92=AE=E3=80=81=E5=88=86=E8=AF=8D?= =?UTF-8?q?/=E6=9C=97=E8=AF=BB=20Dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 内容工厂固定高度常驻,面板尺寸/位置不再随拖拽变化 - 问问小埃改成与工厂项一致的大按钮,可点击/可 drop,drop 后新开对话并填入内容 - 工厂动作按内容过滤:无 CJK 不出分词,无链接不出提取,朗读仅文本 - 朗读打开语速 Dialog,分词改为可滑动选择的胶囊 + 复制 - BeginPortal 传面板矩形,供概览避让 --- CMakeLists.txt | 3 + src/app.cpp | 16 ++++ src/chat/chatwindow.cpp | 30 +++++++ src/chat/chatwindow.h | 3 + src/dialogs/flowlayout.cpp | 87 +++++++++++++++++++ src/dialogs/flowlayout.h | 32 +++++++ src/dialogs/segmentdialog.cpp | 150 +++++++++++++++++++++++++++++++++ src/dialogs/segmentdialog.h | 34 ++++++++ src/dialogs/speechdialog.cpp | 111 ++++++++++++++++++++++++ src/dialogs/speechdialog.h | 32 +++++++ src/factory/contentfactory.cpp | 83 +++++++++++++++--- src/factory/contentfactory.h | 6 +- src/factory/factoryactions.cpp | 64 ++++++++------ src/portal/portalclient.cpp | 3 +- src/portal/portalclient.h | 3 +- src/ui/portalpanel.cpp | 113 +++++++++++++++++++------ src/ui/portalpanel.h | 5 +- 17 files changed, 705 insertions(+), 70 deletions(-) create mode 100644 src/dialogs/flowlayout.cpp create mode 100644 src/dialogs/flowlayout.h create mode 100644 src/dialogs/segmentdialog.cpp create mode 100644 src/dialogs/segmentdialog.h create mode 100644 src/dialogs/speechdialog.cpp create mode 100644 src/dialogs/speechdialog.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 5f77ab3..16ac4bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/app.cpp b/src/app.cpp index b234078..051d457 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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() diff --git a/src/chat/chatwindow.cpp b/src/chat/chatwindow.cpp index d06c273..37c8bb5 100644 --- a/src/chat/chatwindow.cpp +++ b/src/chat/chatwindow.cpp @@ -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(); diff --git a/src/chat/chatwindow.h b/src/chat/chatwindow.h index e0a29da..084421a 100644 --- a/src/chat/chatwindow.h +++ b/src/chat/chatwindow.h @@ -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); diff --git a/src/dialogs/flowlayout.cpp b/src/dialogs/flowlayout.cpp new file mode 100644 index 0000000..97f243b --- /dev/null +++ b/src/dialogs/flowlayout.cpp @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "flowlayout.h" + +#include +#include + +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(); +} diff --git a/src/dialogs/flowlayout.h b/src/dialogs/flowlayout.h new file mode 100644 index 0000000..b5f1da8 --- /dev/null +++ b/src/dialogs/flowlayout.h @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +/** 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 m_items; + int m_spacing; +}; diff --git a/src/dialogs/segmentdialog.cpp b/src/dialogs/segmentdialog.cpp new file mode 100644 index 0000000..f134387 --- /dev/null +++ b/src/dialogs/segmentdialog.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +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(watched); + if (!capsule) { + return QDialog::eventFilter(watched, event); + } + + if (event->type() == QEvent::MouseButtonPress) { + auto *mouse = static_cast(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); +} diff --git a/src/dialogs/segmentdialog.h b/src/dialogs/segmentdialog.h new file mode 100644 index 0000000..4c048ea --- /dev/null +++ b/src/dialogs/segmentdialog.h @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +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 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; +}; diff --git a/src/dialogs/speechdialog.cpp b/src/dialogs/speechdialog.cpp new file mode 100644 index 0000000..64d0218 --- /dev/null +++ b/src/dialogs/speechdialog.cpp @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "speechdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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::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("已停止")); + } +} diff --git a/src/dialogs/speechdialog.h b/src/dialogs/speechdialog.h new file mode 100644 index 0000000..0a314cc --- /dev/null +++ b/src/dialogs/speechdialog.h @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +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; +}; diff --git a/src/factory/contentfactory.cpp b/src/factory/contentfactory.cpp index e50a7b1..9ace704 100644 --- a/src/factory/contentfactory.cpp +++ b/src/factory/contentfactory.cpp @@ -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) diff --git a/src/factory/contentfactory.h b/src/factory/contentfactory.h index 48fd40b..5141c5e 100644 --- a/src/factory/contentfactory.h +++ b/src/factory/contentfactory.h @@ -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; diff --git a/src/factory/factoryactions.cpp b/src/factory/factoryactions.cpp index 81cc858..4879477 100644 --- a/src/factory/factoryactions.cpp +++ b/src/factory/factoryactions.cpp @@ -4,6 +4,9 @@ #include "factoryactions.h" #include +#include + +#include "../actions/texttools.h" namespace { @@ -41,8 +44,6 @@ QVector 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 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 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 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); diff --git a/src/portal/portalclient.cpp b/src/portal/portalclient.cpp index 8cc9edc..a7ebe4c 100644 --- a/src/portal/portalclient.cpp +++ b/src/portal/portalclient.cpp @@ -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; diff --git a/src/portal/portalclient.h b/src/portal/portalclient.h index e081ac2..0223f3e 100644 --- a/src/portal/portalclient.h +++ b/src/portal/portalclient.h @@ -4,6 +4,7 @@ #pragma once #include +#include /** * 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. */ diff --git a/src/ui/portalpanel.cpp b/src/ui/portalpanel.cpp index 5af6beb..e8eed55 100644 --- a/src/ui/portalpanel.cpp +++ b/src/ui/portalpanel.cpp @@ -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 @@ -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); diff --git a/src/ui/portalpanel.h b/src/ui/portalpanel.h index 0cc7e76..f3db7ad 100644 --- a/src/ui/portalpanel.h +++ b/src/ui/portalpanel.h @@ -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