Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8a0391abe | ||
|
|
ca088fbaa6 | ||
|
|
630ca6cadc | ||
|
|
3b92202f2d |
@@ -0,0 +1,13 @@
|
||||
name: Building Program
|
||||
run-name: Building ${{ GITHUB.REPOSITORY }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
call:
|
||||
uses: GXDE-OS/GXDE/.github/workflows/building-deb.yml@master
|
||||
secrets: inherit
|
||||
@@ -1,3 +1,18 @@
|
||||
# 更新日志
|
||||
|
||||
## 1.0.0~beta — 2026-10-30
|
||||
|
||||
首个 beta 版本。
|
||||
|
||||
- 传送门:合成器 portal 原生 drop,拖到窗口即落在光标位置
|
||||
- 面板:layer-shell 双栏、暂存区、拖拽/删除/排序动画、位置与大小持久化
|
||||
- 内容工厂:常驻面板底部,按拖拽内容分类展示动作,动作可直接 drop
|
||||
- 待办:独立浮窗与图标,添加待办后自动打开
|
||||
- 小埃同学:DTK 主窗口、气泡消息、多会话、流式输出、MCP、上下文压缩
|
||||
- 分词胶囊选择、朗读 Dialog、DTK 原生关于窗口
|
||||
- 传送门桌面入口:再次点击回到后台模式
|
||||
- 英文翻译与本地化加载
|
||||
|
||||
# Changelog
|
||||
|
||||
## 0.1.0 - 2026-10-30
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
project(gxde-ai-see
|
||||
VERSION 0.1.0
|
||||
VERSION 1.0.0
|
||||
DESCRIPTION "GXDE AI See - 小埃同学"
|
||||
LANGUAGES CXX
|
||||
)
|
||||
@@ -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
|
||||
@@ -38,6 +41,7 @@ set(GXDE_AI_SEE_SOURCES
|
||||
src/chat/markdown.cpp
|
||||
src/chat/chatview.cpp
|
||||
src/ui/portalpanel.cpp
|
||||
src/ui/aboutdialog.cpp
|
||||
)
|
||||
|
||||
add_executable(gxde-ai-see ${GXDE_AI_SEE_SOURCES})
|
||||
@@ -116,7 +120,8 @@ if (LRELEASE_EXECUTABLE AND TS_FILES)
|
||||
endif()
|
||||
|
||||
install(TARGETS gxde-ai-see RUNTIME DESTINATION bin)
|
||||
install(FILES data/gxde-ai-see.desktop data/gxde-ai-see-todo.desktop DESTINATION share/applications)
|
||||
install(FILES data/gxde-ai-see.desktop data/gxde-ai-see-todo.desktop
|
||||
data/gxde-ai-see-portal.desktop DESTINATION share/applications)
|
||||
install(DIRECTORY data/icons/hicolor/ DESTINATION share/icons/hicolor)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Version=1.0
|
||||
Name=GXDE Portal
|
||||
Name[zh_CN]=传送门
|
||||
Comment=Show or hide the GXDE AI See portal panel
|
||||
Comment[zh_CN]=显示或隐藏小埃同学的传送门面板
|
||||
Exec=gxde-ai-see --show
|
||||
Icon=gxde-ai-see
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
StartupNotify=false
|
||||
@@ -6,7 +6,7 @@ Name[zh_CN]=GXDE 待办
|
||||
Comment=Floating todo list of GXDE AI See
|
||||
Comment[zh_CN]=小埃同学的待办
|
||||
Exec=gxde-ai-see --todo
|
||||
Icon=gxde-ai-see
|
||||
Icon=gxde-ai-see-todo
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
StartupNotify=false
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#4a90ff"/>
|
||||
<stop offset="1" stop-color="#2f6bd8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="240" height="240" rx="54" fill="url(#g)"/>
|
||||
<rect x="64" y="56" width="128" height="152" rx="16" fill="#ffffff" opacity="0.96"/>
|
||||
<rect x="96" y="40" width="64" height="30" rx="10" fill="#dbe8ff"/>
|
||||
<g stroke="#3d7fe8" stroke-width="9" stroke-linecap="round" fill="none">
|
||||
<path d="M84 114 l14 14 26 -28"/>
|
||||
<path d="M84 158 l14 14 26 -28"/>
|
||||
<path d="M146 118 h30"/>
|
||||
<path d="M146 162 h30"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 727 B |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 560 B |
|
After Width: | Height: | Size: 855 B |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 955 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,3 +1,13 @@
|
||||
gxde-ai-see (1.0.0~beta) unstable; urgency=medium
|
||||
|
||||
* 首个 beta:传送门原生 drop、暂存区、内容工厂、待办、小埃同学对话
|
||||
* 对话改为 DTK 主窗口,气泡消息、会话管理、MCP、上下文压缩
|
||||
* 内容工厂常驻并支持拖拽展开,动作按内容过滤
|
||||
* 分词胶囊选择、朗读 Dialog、DTK 原生关于窗口
|
||||
* 传送门桌面入口与显隐切换;暂存区清空自动隐藏面板
|
||||
|
||||
-- shenmo <shenmo@spark-app.store> Fri, 30 Oct 2026 22:30:00 +0800
|
||||
|
||||
gxde-ai-see (0.1.0) unstable; urgency=medium
|
||||
|
||||
* Initial release: 传送门面板、暂存区、内容工厂、待办与小埃同学对话。
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 1.0.0~beta 之后待办
|
||||
|
||||
按优先级排列,部分项需要单独一轮实现与测试。
|
||||
|
||||
## 对话
|
||||
|
||||
- [ ] 会话列表支持拖拽排序、右键删除、批量多选;先在本仓库实现,随后抽成
|
||||
`dtk2widget-qt6` 的可复用列表控件(避免每个项目重写)
|
||||
- [ ] 浅色/深色主题跟随 DTK,默认浅色
|
||||
- [ ] 消息版本树:重试/编辑保留历史,可在旧版本之间左右切换
|
||||
- [ ] 每条消息下方常驻 重试 / 编辑 / 复制(已做基础版,版本树未做)
|
||||
|
||||
## 国际化
|
||||
|
||||
- [ ] 源码中的 `tr()` 文案改为英文,中文放进 `zh_CN` 翻译;保证非中文环境
|
||||
显示英文而不是中文
|
||||
- [ ] 更新 `translations/*.ts` 并补齐中文翻译
|
||||
|
||||
## 系统能力
|
||||
|
||||
- [ ] OCR / TTS / 翻译对接 GXDE Daemon 的接口(当前为占位)
|
||||
- [ ] 全屏时隐藏:合成器侧判断全屏窗口,面板自动隐藏
|
||||
- [ ] 待办窗口「取消置顶」:可切换为普通窗口
|
||||
- [ ] 上下文压缩支持按 token(128k / 1024k)而不是只按条数
|
||||
|
||||
## 兼容性
|
||||
|
||||
- [ ] X11 / 非 GXDE 合成器的降级方案:传送门旁展示窗口图标网格,
|
||||
悬停最小化其他窗口并激活目标,松手完成 drop
|
||||
- [ ] DTK Wayland 应用拖拽文本时合成器侧生成文本预览(当前无 drag icon)
|
||||
- [ ] 更多窗口改用 DTK 控件与原生样式
|
||||
|
||||
## 图标
|
||||
|
||||
- [ ] 清理小埃同学 logo 背景并重新生成应用图标,改善小尺寸清晰度
|
||||
@@ -52,7 +52,7 @@ bool App::init(bool daemon, bool forceShow, bool showTodo, bool showChat)
|
||||
} else if (data.contains("todo")) {
|
||||
showTodoWindow();
|
||||
} else if (data.contains("show")) {
|
||||
showPanel();
|
||||
togglePanel();
|
||||
}
|
||||
});
|
||||
connect(socket, &QLocalSocket::disconnected, socket,
|
||||
@@ -88,25 +88,43 @@ bool App::init(bool daemon, bool forceShow, bool showTodo, bool showChat)
|
||||
return;
|
||||
}
|
||||
m_dragging = true;
|
||||
if (m_panel) {
|
||||
m_panel->setDragging(true);
|
||||
}
|
||||
if (!m_model->isEmpty()) {
|
||||
if (m_panel && m_panel->isVisible()) {
|
||||
m_panel->suppressPortalIfUnderCursor();
|
||||
}
|
||||
return;
|
||||
}
|
||||
QTimer::singleShot(120, this, [this]() {
|
||||
QTimer::singleShot(80, 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_model->isEmpty() && m_panel && m_panel->isVisible()) {
|
||||
if (m_panel) {
|
||||
m_panel->stopPortalSuppression();
|
||||
m_panel->setDragging(false);
|
||||
}
|
||||
if (m_model->isEmpty() && !m_panelPinned && m_panel && m_panel->isVisible()) {
|
||||
m_panel->dismiss();
|
||||
}
|
||||
});
|
||||
|
||||
connect(m_model, &StagingModel::countChanged, this, [this]() {
|
||||
if (!m_daemon && !m_model->isEmpty() && m_panel) {
|
||||
if (!m_model->isEmpty() && m_panel) {
|
||||
m_panel->popup();
|
||||
return;
|
||||
}
|
||||
/* cleared: the panel exists for the staging list, hide it again */
|
||||
if (!m_dragging && !m_panelPinned && m_panel && m_panel->isVisible()) {
|
||||
m_panel->dismiss();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -118,6 +136,7 @@ bool App::init(bool daemon, bool forceShow, bool showTodo, bool showChat)
|
||||
}
|
||||
if (!daemon || forceShow || !m_model->isEmpty()) {
|
||||
showPanel();
|
||||
m_panelPinned = forceShow;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -131,6 +150,18 @@ void App::ensurePanel()
|
||||
|
||||
m_panel = new PortalPanel(m_model);
|
||||
connect(m_panel, &PortalPanel::openChatRequested, this, &App::showChatWindow);
|
||||
connect(m_panel, &PortalPanel::hideRequested, this, [this]() {
|
||||
m_panelPinned = false;
|
||||
m_panel->dismiss();
|
||||
});
|
||||
connect(m_panel, &PortalPanel::todoAdded, this, &App::showTodoWindow);
|
||||
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()
|
||||
@@ -139,6 +170,18 @@ void App::showPanel()
|
||||
m_panel->popup();
|
||||
}
|
||||
|
||||
void App::togglePanel()
|
||||
{
|
||||
ensurePanel();
|
||||
if (m_panel->isVisible() && m_panelPinned) {
|
||||
m_panel->dismiss();
|
||||
m_panelPinned = false;
|
||||
return;
|
||||
}
|
||||
m_panelPinned = true;
|
||||
m_panel->popup();
|
||||
}
|
||||
|
||||
void App::showChatWindow()
|
||||
{
|
||||
if (!m_chatWindow) {
|
||||
|
||||
@@ -32,6 +32,7 @@ public:
|
||||
private:
|
||||
void ensurePanel();
|
||||
void showPanel();
|
||||
void togglePanel();
|
||||
void showTodoWindow();
|
||||
void showChatWindow();
|
||||
|
||||
@@ -42,4 +43,5 @@ private:
|
||||
QLocalServer *m_server = nullptr;
|
||||
bool m_daemon = false;
|
||||
bool m_dragging = false;
|
||||
bool m_panelPinned = false;
|
||||
};
|
||||
|
||||
@@ -182,15 +182,16 @@ public:
|
||||
connect(fork, &QToolButton::clicked, this,
|
||||
[this]() { Q_EMIT forkRequested(m_index); });
|
||||
actions->addWidget(fork);
|
||||
if (m_isLast) {
|
||||
auto *retry = actionButton(tr("重试"), m_actions);
|
||||
connect(retry, &QToolButton::clicked, this,
|
||||
[this]() { Q_EMIT retryRequested(); });
|
||||
actions->addWidget(retry);
|
||||
}
|
||||
auto *edit = actionButton(tr("编辑"), m_actions);
|
||||
connect(edit, &QToolButton::clicked, this,
|
||||
[this]() { Q_EMIT editRequested(m_index); });
|
||||
actions->addWidget(edit);
|
||||
auto *retry = actionButton(tr("重试"), m_actions);
|
||||
connect(retry, &QToolButton::clicked, this,
|
||||
[this]() { Q_EMIT retryRequested(m_index); });
|
||||
actions->addWidget(retry);
|
||||
}
|
||||
actions->addStretch();
|
||||
m_actions->hide();
|
||||
column->addWidget(m_actions);
|
||||
|
||||
if (m_isUser) {
|
||||
@@ -220,21 +221,10 @@ signals:
|
||||
void copyRequested(int index);
|
||||
void editRequested(int index);
|
||||
void forkRequested(int index);
|
||||
void retryRequested();
|
||||
void retryRequested(int index);
|
||||
void linkActivated(const QUrl &url);
|
||||
|
||||
protected:
|
||||
void enterEvent(QEnterEvent *event) override
|
||||
{
|
||||
m_actions->show();
|
||||
QWidget::enterEvent(event);
|
||||
}
|
||||
|
||||
void leaveEvent(QEvent *event) override
|
||||
{
|
||||
m_actions->hide();
|
||||
QWidget::leaveEvent(event);
|
||||
}
|
||||
|
||||
private:
|
||||
ChatMessage m_message;
|
||||
|
||||
@@ -36,7 +36,7 @@ signals:
|
||||
void copyRequested(int index);
|
||||
void editRequested(int index);
|
||||
void forkRequested(int index);
|
||||
void retryRequested();
|
||||
void retryRequested(int index);
|
||||
void linkActivated(const QUrl &url);
|
||||
|
||||
protected:
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
#include "config.h"
|
||||
#include "chatview.h"
|
||||
|
||||
#include "../ui/aboutdialog.h"
|
||||
#include "markdown.h"
|
||||
#include "mcpclient.h"
|
||||
|
||||
@@ -555,7 +557,9 @@ void ChatWindow::setupUi()
|
||||
});
|
||||
connect(m_messages, &ChatMessageList::editRequested, this, &ChatWindow::editMessage);
|
||||
connect(m_messages, &ChatMessageList::forkRequested, this, &ChatWindow::forkFrom);
|
||||
connect(m_messages, &ChatMessageList::retryRequested, this, &ChatWindow::retryLast);
|
||||
connect(m_messages, &ChatMessageList::retryRequested, this, [this](int index) {
|
||||
retryFrom(index);
|
||||
});
|
||||
|
||||
auto *menu = new QMenu(menuButton);
|
||||
menu->addAction(tr("压缩上下文"), this, [this]() { beginCompression(false); });
|
||||
@@ -925,9 +929,6 @@ void ChatWindow::editMessage(int index)
|
||||
if (index < 0 || index >= conversation.messages.size()) {
|
||||
return;
|
||||
}
|
||||
if (conversation.messages.at(index).role != QStringLiteral("user")) {
|
||||
return;
|
||||
}
|
||||
|
||||
QDialog dialog(this);
|
||||
dialog.setWindowTitle(tr("编辑消息"));
|
||||
@@ -959,6 +960,27 @@ void ChatWindow::editMessage(int index)
|
||||
requestCompletion();
|
||||
}
|
||||
|
||||
void ChatWindow::retryFrom(int index)
|
||||
{
|
||||
if (m_client->busy() || m_compressing || m_current < 0) {
|
||||
return;
|
||||
}
|
||||
ChatConversation &conversation = m_conversations[m_current];
|
||||
if (index < 0 || index >= conversation.messages.size()) {
|
||||
return;
|
||||
}
|
||||
if (conversation.messages.at(index).role != QStringLiteral("assistant")) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* drop everything from the assistant answer on, then regenerate it */
|
||||
conversation.messages.resize(index);
|
||||
conversation.updatedAt = QDateTime::currentMSecsSinceEpoch();
|
||||
save();
|
||||
renderConversation();
|
||||
requestCompletion();
|
||||
}
|
||||
|
||||
void ChatWindow::retryLast()
|
||||
{
|
||||
if (m_client->busy() || m_compressing || m_current < 0) {
|
||||
@@ -1028,15 +1050,7 @@ void ChatWindow::handleAnchor(const QUrl &url)
|
||||
|
||||
void ChatWindow::showAbout()
|
||||
{
|
||||
QMessageBox::about(
|
||||
this, tr("关于小埃同学"),
|
||||
tr("<h3>GXDE 小埃同学</h3>"
|
||||
"<p>版本 %1</p>"
|
||||
"<p>GXDE 桌面的 AI 助手:传送门、内容工厂、待办与对话。</p>"
|
||||
"<p>许可证:GPL-3.0-or-later<br/>"
|
||||
"仓库:<a href='https://gitee.com/GXDE-OS/gxde-ai-see'>"
|
||||
"gitee.com/GXDE-OS/gxde-ai-see</a></p>")
|
||||
.arg(QCoreApplication::applicationVersion()));
|
||||
showAboutDialog(this, tr("GXDE 桌面的 AI 助手:传送门、内容工厂、待办与对话。"));
|
||||
}
|
||||
|
||||
void ChatWindow::appendError(const QString &message)
|
||||
@@ -1256,6 +1270,33 @@ 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();
|
||||
}
|
||||
|
||||
void ChatWindow::activateForAsk()
|
||||
{
|
||||
show();
|
||||
|
||||
@@ -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);
|
||||
@@ -65,6 +68,7 @@ private:
|
||||
void sendPayload();
|
||||
void editMessage(int index);
|
||||
void retryLast();
|
||||
void retryFrom(int index);
|
||||
void forkFrom(int index);
|
||||
void beginCompression(bool thenSend);
|
||||
void handleAnchor(const QUrl &url);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "segmentdialog.h"
|
||||
|
||||
#include "flowlayout.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QPalette>
|
||||
#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);
|
||||
|
||||
const bool dark = palette().color(QPalette::Window).lightness() < 128;
|
||||
const QColor capsuleBg = dark ? QColor(255, 255, 255, 20) : QColor(0, 0, 0, 18);
|
||||
const QColor capsuleBorder = dark ? QColor(255, 255, 255, 30) : QColor(0, 0, 0, 28);
|
||||
const QColor textColor = palette().color(QPalette::WindowText);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(16, 14, 16, 14);
|
||||
layout->setSpacing(10);
|
||||
|
||||
auto *hint = new QLabel(tr("按住并滑过词语可以连续选择,选中后复制。"), this);
|
||||
hint->setStyleSheet(QStringLiteral("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: %1; background: %2; border: 1px solid %3;"
|
||||
" border-radius: 11px; padding: 3px 10px; font-size: 13px; }"
|
||||
"QPushButton:hover { background: %4; }"
|
||||
"QPushButton:checked { background: rgba(74,144,255,0.85);"
|
||||
" border-color: rgba(74,144,255,1.0); color: white; }")
|
||||
.arg(textColor.name(),
|
||||
QStringLiteral("rgba(%1,%2,%3,%4)")
|
||||
.arg(capsuleBg.red())
|
||||
.arg(capsuleBg.green())
|
||||
.arg(capsuleBg.blue())
|
||||
.arg(capsuleBg.alpha()),
|
||||
capsuleBorder.name(),
|
||||
dark ? QStringLiteral("rgba(255,255,255,30)")
|
||||
: QStringLiteral("rgba(0,0,0,28)")));
|
||||
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("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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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("已停止"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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)
|
||||
@@ -260,6 +317,45 @@ void ContentFactory::paintEvent(QPaintEvent *event)
|
||||
painter.drawPath(path);
|
||||
}
|
||||
|
||||
void ContentFactory::dragEnterEvent(QDragEnterEvent *event)
|
||||
{
|
||||
if (!event->mimeData() ||
|
||||
!(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
|
||||
event->mimeData()->hasImage())) {
|
||||
event->ignore();
|
||||
return;
|
||||
}
|
||||
Q_EMIT dragContent(event->mimeData());
|
||||
Q_EMIT dragActivity();
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
|
||||
void ContentFactory::dragMoveEvent(QDragMoveEvent *event)
|
||||
{
|
||||
if (event->mimeData() &&
|
||||
(event->mimeData()->hasUrls() || event->mimeData()->hasText() ||
|
||||
event->mimeData()->hasImage())) {
|
||||
Q_EMIT dragContent(event->mimeData());
|
||||
Q_EMIT dragActivity();
|
||||
event->acceptProposedAction();
|
||||
return;
|
||||
}
|
||||
event->ignore();
|
||||
}
|
||||
|
||||
void ContentFactory::dragLeaveEvent(QDragLeaveEvent *event)
|
||||
{
|
||||
Q_UNUSED(event)
|
||||
Q_EMIT dragActivity();
|
||||
}
|
||||
|
||||
void ContentFactory::dropEvent(QDropEvent *event)
|
||||
{
|
||||
if (event->mimeData()) {
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
}
|
||||
|
||||
bool ContentFactory::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (event->type() == QEvent::DragEnter || event->type() == QEvent::DragMove) {
|
||||
|
||||
@@ -33,17 +33,26 @@ signals:
|
||||
void actionRequested(const QString &id, const DropContent &content);
|
||||
void heightChanged();
|
||||
void dragActivity();
|
||||
void dragContent(const QMimeData *mime);
|
||||
void askRequested();
|
||||
void askDropped(const QMimeData *mime);
|
||||
|
||||
public:
|
||||
QSize sizeHint() const override;
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
void dragEnterEvent(QDragEnterEvent *event) override;
|
||||
void dragMoveEvent(QDragMoveEvent *event) override;
|
||||
void dragLeaveEvent(QDragLeaveEvent *event) override;
|
||||
void dropEvent(QDropEvent *event) override;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -19,7 +19,7 @@ int main(int argc, char *argv[])
|
||||
app.setOrganizationName(QStringLiteral("GXDE"));
|
||||
app.setApplicationName(QStringLiteral("gxde-ai-see"));
|
||||
app.setApplicationDisplayName(QStringLiteral("GXDE 小埃同学"));
|
||||
app.setApplicationVersion(QStringLiteral("0.1.0"));
|
||||
app.setApplicationVersion(QStringLiteral("1.0.0~beta"));
|
||||
QIcon appIcon = QIcon::fromTheme(QStringLiteral("gxde-ai-see"));
|
||||
if (appIcon.isNull()) {
|
||||
appIcon = QIcon(QStringLiteral("/usr/share/icons/hicolor/256x256/apps/gxde-ai-see.png"));
|
||||
|
||||
@@ -50,15 +50,19 @@ PortalClient::PortalClient(QObject *parent)
|
||||
bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath),
|
||||
QString::fromLatin1(kInterface), QStringLiteral("PortalDropped"), this,
|
||||
SLOT(onDropped(QString, bool)));
|
||||
bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath),
|
||||
QString::fromLatin1(kInterface), QStringLiteral("PortalClosed"), this,
|
||||
SLOT(onPortalClosed()));
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -92,6 +96,11 @@ void PortalClient::onDragStarted(const QString &mimeTypes)
|
||||
Q_EMIT dragStarted(mimeTypes);
|
||||
}
|
||||
|
||||
void PortalClient::onPortalClosed()
|
||||
{
|
||||
Q_EMIT portalClosed();
|
||||
}
|
||||
|
||||
void PortalClient::onDragEnded()
|
||||
{
|
||||
Q_EMIT dragEnded();
|
||||
|
||||
@@ -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. */
|
||||
@@ -32,11 +33,13 @@ public:
|
||||
signals:
|
||||
void dragStarted(const QString &mimeTypes);
|
||||
void dragEnded();
|
||||
void portalClosed();
|
||||
void dropped(const QString &uuid, bool accepted);
|
||||
|
||||
private slots:
|
||||
void onDragStarted(const QString &mimeTypes);
|
||||
void onDragEnded();
|
||||
void onPortalClosed();
|
||||
void onDropped(const QString &uuid, bool accepted);
|
||||
|
||||
private:
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <QDialogButtonBox>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QIcon>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListView>
|
||||
@@ -145,6 +146,9 @@ TodoWindow::TodoWindow(TodoModel *model, QWidget *parent)
|
||||
, m_model(model)
|
||||
{
|
||||
setWindowTitle(tr("待办"));
|
||||
setWindowIcon(QIcon::fromTheme(
|
||||
QStringLiteral("gxde-ai-see-todo"),
|
||||
QIcon(QStringLiteral("/usr/share/icons/hicolor/256x256/apps/gxde-ai-see-todo.png"))));
|
||||
setWindowFlags(Qt::FramelessWindowHint | Qt::Tool);
|
||||
setAttribute(Qt::WA_TranslucentBackground);
|
||||
setAttribute(Qt::WA_ShowWithoutActivating);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "aboutdialog.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QIcon>
|
||||
|
||||
#include <DAboutDialog>
|
||||
|
||||
void showAboutDialog(QWidget *parent, const QString &description)
|
||||
{
|
||||
auto *dialog = new Dtk::Widget::DAboutDialog(parent);
|
||||
dialog->setAttribute(Qt::WA_DeleteOnClose);
|
||||
dialog->setWindowTitle(QObject::tr("关于小埃同学"));
|
||||
dialog->setProductIcon(QApplication::windowIcon());
|
||||
dialog->setProductName(QObject::tr("GXDE 小埃同学"));
|
||||
dialog->setVersion(QCoreApplication::applicationVersion());
|
||||
dialog->setDescription(description);
|
||||
dialog->setLicense(QStringLiteral("GPL-3.0-or-later"));
|
||||
dialog->setWebsiteName(QStringLiteral("gitee.com/GXDE-OS/gxde-ai-see"));
|
||||
dialog->setWebsiteLink(QStringLiteral("https://gitee.com/GXDE-OS/gxde-ai-see"));
|
||||
dialog->show();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
class QWidget;
|
||||
|
||||
/** DTK native about dialog shared by the panel and the chat window. */
|
||||
void showAboutDialog(QWidget *parent, const QString &description);
|
||||
@@ -10,6 +10,10 @@
|
||||
#include "actions/texttools.h"
|
||||
#include "factory/contentclassifier.h"
|
||||
#include "factory/contentfactory.h"
|
||||
|
||||
#include "aboutdialog.h"
|
||||
#include "dialogs/segmentdialog.h"
|
||||
#include "dialogs/speechdialog.h"
|
||||
#include "todo/todomodel.h"
|
||||
|
||||
#include <QApplication>
|
||||
@@ -577,9 +581,9 @@ public:
|
||||
setMinimumHeight(190);
|
||||
|
||||
m_portalTimer.setSingleShot(true);
|
||||
m_portalTimer.setInterval(150);
|
||||
m_portalTimer.setInterval(80);
|
||||
connect(&m_portalTimer, &QTimer::timeout, this, [this]() {
|
||||
if (m_active) {
|
||||
if (m_active && !m_suppressed) {
|
||||
Q_EMIT portalRequested();
|
||||
}
|
||||
});
|
||||
@@ -598,6 +602,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 +700,7 @@ protected:
|
||||
}
|
||||
m_active = true;
|
||||
update();
|
||||
if (!m_portalActive) {
|
||||
if (!m_portalActive && !m_suppressed) {
|
||||
m_portalTimer.start();
|
||||
}
|
||||
Q_EMIT dragContent(event->mimeData());
|
||||
@@ -736,6 +752,7 @@ private:
|
||||
bool m_active = false;
|
||||
bool m_hover = false;
|
||||
bool m_portalActive = false;
|
||||
bool m_suppressed = false;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
@@ -768,6 +785,7 @@ PortalPanel::PortalPanel(StagingModel *model, QWidget *parent)
|
||||
if (m_countLabel) {
|
||||
m_countLabel->setText(tr("%1 项").arg(m_model->count()));
|
||||
}
|
||||
updateMode();
|
||||
refreshLayout();
|
||||
});
|
||||
connect(m_model, &StagingModel::countChanged, m_view->viewport(),
|
||||
@@ -815,7 +833,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 +858,32 @@ 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()) {
|
||||
QScreen *scr = screen() ? screen() : QGuiApplication::primaryScreen();
|
||||
QRect panelRect = geometry();
|
||||
if (scr) {
|
||||
const QRect avail = scr->availableGeometry();
|
||||
panelRect = QRect(avail.x() + avail.width() - width() - m_rightMargin,
|
||||
avail.y() + avail.height() - height() - m_bottomMargin,
|
||||
width(), height());
|
||||
}
|
||||
if (portal->beginPortal(panelRect)) {
|
||||
m_zone->setPortalActive(true);
|
||||
m_zone->showMessage(tr("移动到目标窗口上松手"));
|
||||
} else {
|
||||
@@ -855,6 +896,8 @@ void PortalPanel::setupUi()
|
||||
});
|
||||
connect(PortalClient::instance(), &PortalClient::dragEnded, this,
|
||||
[this]() { m_zone->setPortalActive(false); });
|
||||
connect(PortalClient::instance(), &PortalClient::portalClosed, this,
|
||||
[this]() { m_zone->setPortalActive(false); });
|
||||
|
||||
auto *right = new QVBoxLayout;
|
||||
right->setContentsMargins(0, 0, 0, 0);
|
||||
@@ -912,7 +955,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 +979,6 @@ void PortalPanel::setupUi()
|
||||
return;
|
||||
}
|
||||
m_factory->clearContent();
|
||||
m_factory->hide();
|
||||
refreshLayout();
|
||||
});
|
||||
|
||||
@@ -946,7 +988,21 @@ 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::dragContent, this, &PortalPanel::noteDragContent);
|
||||
connect(m_factory, &ContentFactory::dragActivity, this, [this]() {
|
||||
if (m_factoryTimer) {
|
||||
m_factoryTimer->start();
|
||||
@@ -959,6 +1015,32 @@ void PortalPanel::refreshLayout()
|
||||
updateGeometryForContent();
|
||||
}
|
||||
|
||||
void PortalPanel::setDragging(bool dragging)
|
||||
{
|
||||
if (m_dragging == dragging) {
|
||||
return;
|
||||
}
|
||||
m_dragging = dragging;
|
||||
updateMode();
|
||||
}
|
||||
|
||||
void PortalPanel::updateMode()
|
||||
{
|
||||
const bool stagingOnly = !m_dragging && m_model && m_model->count() > 0;
|
||||
if (stagingOnly == m_stagingOnly) {
|
||||
return;
|
||||
}
|
||||
m_stagingOnly = stagingOnly;
|
||||
if (m_zone) {
|
||||
m_zone->setVisible(!stagingOnly);
|
||||
}
|
||||
if (m_factory) {
|
||||
m_factory->setVisible(!stagingOnly);
|
||||
}
|
||||
setFixedWidth(stagingOnly ? 360 : kPanelWidth);
|
||||
refreshLayout();
|
||||
}
|
||||
|
||||
void PortalPanel::updateGeometryForContent()
|
||||
{
|
||||
QScreen *scr = screen();
|
||||
@@ -975,18 +1057,13 @@ void PortalPanel::updateGeometryForContent()
|
||||
|
||||
const int headerHeight = 26;
|
||||
const int selectionHeight = (m_selectionBar && m_selectionBar->isVisible()) ? 32 : 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->isVisible()) ? m_factory->sizeHint().height() + 8 : 0;
|
||||
(!m_stagingOnly && 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();
|
||||
}
|
||||
resize(m_stagingOnly ? 360 : kPanelWidth, totalHeight);
|
||||
}
|
||||
|
||||
void PortalPanel::noteDragContent(const QMimeData *mime)
|
||||
@@ -1008,8 +1085,6 @@ void PortalPanel::noteDragContent(const QMimeData *mime)
|
||||
}
|
||||
|
||||
m_factory->setContent(content);
|
||||
m_factory->show();
|
||||
refreshLayout();
|
||||
}
|
||||
|
||||
void PortalPanel::noteDragFinished()
|
||||
@@ -1106,7 +1181,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 +1193,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")) {
|
||||
@@ -1129,6 +1204,7 @@ void PortalPanel::runFactoryAction(const QString &id, const DropContent &content
|
||||
: content.summary();
|
||||
TodoModel::instance()->add(text);
|
||||
toast(tr("已添加到待办"));
|
||||
Q_EMIT todoAdded();
|
||||
return;
|
||||
}
|
||||
if (id == QLatin1String("save_image")) {
|
||||
@@ -1202,7 +1278,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 +1286,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1327,6 +1403,7 @@ void PortalPanel::showContextMenu(const QPoint &globalPos)
|
||||
|
||||
menu.addSeparator();
|
||||
QAction *clear = menu.addAction(tr("清空暂存区"));
|
||||
QAction *hide = menu.addAction(tr("隐藏"));
|
||||
QAction *about = menu.addAction(tr("关于"));
|
||||
QAction *quit = menu.addAction(tr("退出"));
|
||||
|
||||
@@ -1346,11 +1423,22 @@ void PortalPanel::showContextMenu(const QPoint &globalPos)
|
||||
menuPos.setX(available.right() - menuSize.width());
|
||||
}
|
||||
}
|
||||
menuPos.setX(globalPos.x() - menuSize.width() - 10);
|
||||
if (scr) {
|
||||
const QRect available = scr->availableGeometry();
|
||||
if (menuPos.x() < available.left()) {
|
||||
menuPos.setX(available.left());
|
||||
}
|
||||
}
|
||||
QAction *chosen = menu.exec(menuPos);
|
||||
if (!chosen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chosen == hide) {
|
||||
Q_EMIT hideRequested();
|
||||
return;
|
||||
}
|
||||
if (chosen == fixedPosition) {
|
||||
m_fixedPosition = fixedPosition->isChecked();
|
||||
saveWindowState();
|
||||
@@ -1395,18 +1483,36 @@ void PortalPanel::showContextMenu(const QPoint &globalPos)
|
||||
} else if (chosen == clear) {
|
||||
m_model->clearAll();
|
||||
} else if (chosen == about) {
|
||||
QMessageBox::about(
|
||||
this, tr("关于小埃同学"),
|
||||
tr("<h3>GXDE 小埃同学</h3><p>版本 %1</p>"
|
||||
"<p>传送门、内容工厂、待办与小埃同学对话。</p>"
|
||||
"<p>GPL-3.0-or-later · "
|
||||
"<a href='https://gitee.com/GXDE-OS/gxde-ai-see'>gxde-ai-see</a></p>")
|
||||
.arg(QCoreApplication::applicationVersion()));
|
||||
showAboutDialog(nullptr, tr("传送门、内容工厂、待办与小埃同学对话。"));
|
||||
} else if (chosen == quit) {
|
||||
QCoreApplication::quit();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -33,10 +33,16 @@ public:
|
||||
|
||||
void refreshLayout();
|
||||
void popup();
|
||||
void suppressPortalIfUnderCursor();
|
||||
void setDragging(bool dragging);
|
||||
void stopPortalSuppression();
|
||||
void dismiss();
|
||||
|
||||
signals:
|
||||
void openChatRequested();
|
||||
void askWithContent(const QString &text, const QStringList &files);
|
||||
void hideRequested();
|
||||
void todoAdded();
|
||||
|
||||
protected:
|
||||
void showEvent(QShowEvent *event) override;
|
||||
@@ -49,6 +55,7 @@ private:
|
||||
void setupUi();
|
||||
void setupWindow();
|
||||
void updateGeometryForContent();
|
||||
void updateMode();
|
||||
void updateSelectionBar();
|
||||
void noteDragContent(const QMimeData *mime);
|
||||
void noteDragFinished();
|
||||
@@ -75,7 +82,9 @@ private:
|
||||
int m_rightMargin = 16;
|
||||
int m_bottomMargin = 16;
|
||||
int m_manualListHeight = 0;
|
||||
int m_factoryOffset = 0;
|
||||
QTimer *m_suppressWatch = nullptr;
|
||||
bool m_stagingOnly = false;
|
||||
bool m_dragging = false;
|
||||
bool m_fixedPosition = true;
|
||||
bool m_fixedSize = false;
|
||||
int m_dragMode = 0; // 0 none, 1 move, 2 resize
|
||||
|
||||