Files
Qt_DesktopPet/src/ui/WorkspacePanel.cpp
T

507 lines
17 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "WorkspacePanel.h"
#include <QAbstractItemView>
#include <QFrame>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QLabel>
#include <QListWidget>
#include <QListWidgetItem>
#include <QMouseEvent>
#include <QPushButton>
#include <QScreen>
#include <QSplitter>
#include <QTextEdit>
#include <QVBoxLayout>
#include <QtGlobal>
#include <limits>
#include <utility>
namespace
{
constexpr int PanelWidth = 780;
constexpr int PanelHeight = 560;
constexpr int PanelGap = 16;
constexpr int ScreenMargin = 8;
constexpr int MaxTextMatchesInDetails = 30;
int boundedCoordinate(int value, int minimum, int maximum)
{
if (maximum < minimum)
{
return minimum;
}
return qBound(minimum, value, maximum);
}
qint64 overlapArea(const QRect &first, const QRect &second)
{
const QRect intersection = first.intersected(second);
if (intersection.isEmpty())
{
return 0;
}
return static_cast<qint64>(intersection.width()) * static_cast<qint64>(intersection.height());
}
qint64 squaredDistance(const QPoint &first, const QPoint &second)
{
const qint64 dx = first.x() - second.x();
const qint64 dy = first.y() - second.y();
return dx * dx + dy * dy;
}
QString compactPath(const WorkspaceFileInfo &file)
{
return file.relativePath + (file.isDirectory ? QStringLiteral(" [目录]") : QString());
}
QString fileTypeText(const WorkspaceFileInfo &file)
{
if (file.isDirectory)
{
return QStringLiteral("目录");
}
if (file.isTextFile)
{
return QStringLiteral("文本文件");
}
return QStringLiteral("文件");
}
QString htmlLine(QString text)
{
return text.toHtmlEscaped().replace(QStringLiteral("\n"), QStringLiteral("<br>"));
}
QFrame *card(QWidget *parent)
{
auto *frame = new QFrame(parent);
frame->setObjectName(QStringLiteral("WorkspaceCard"));
auto *layout = new QVBoxLayout(frame);
layout->setContentsMargins(14, 12, 14, 12);
layout->setSpacing(8);
return frame;
}
QLabel *cardTitle(const QString &text, QWidget *parent)
{
auto *label = new QLabel(text, parent);
label->setObjectName(QStringLiteral("CardTitle"));
return label;
}
QTextEdit *readOnlyTextEdit(QWidget *parent)
{
auto *edit = new QTextEdit(parent);
edit->setReadOnly(true);
edit->setFrameShape(QFrame::NoFrame);
edit->setLineWrapMode(QTextEdit::WidgetWidth);
return edit;
}
}
WorkspacePanel::WorkspacePanel(QWidget *parent)
: QDialog(parent)
, m_workspaceLabel(new QLabel(this))
, m_scanLabel(new QLabel(this))
, m_summaryLabel(new QLabel(this))
, m_resultList(new QListWidget(this))
, m_selectedDetailEdit(readOnlyTextEdit(this))
, m_textMatchesEdit(readOnlyTextEdit(this))
, m_recentReadEdit(readOnlyTextEdit(this))
, m_readButton(new QPushButton(QStringLiteral("读取"), this))
, m_openButton(new QPushButton(QStringLiteral("打开"), this))
, m_revealButton(new QPushButton(QStringLiteral("位置"), this))
, m_closeButton(new QPushButton(QStringLiteral("×"), this))
{
setWindowTitle(QStringLiteral("本地工作区 Agent"));
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
setModal(false);
resize(PanelWidth, PanelHeight);
m_workspaceLabel->setObjectName(QStringLiteral("WorkspaceTitle"));
m_workspaceLabel->setWordWrap(true);
m_scanLabel->setObjectName(QStringLiteral("MetaLabel"));
m_summaryLabel->setObjectName(QStringLiteral("MetaLabel"));
m_summaryLabel->setWordWrap(true);
m_closeButton->setObjectName(QStringLiteral("TitleCloseButton"));
m_closeButton->setFixedSize(30, 30);
m_resultList->setObjectName(QStringLiteral("ResultList"));
m_resultList->setSelectionMode(QAbstractItemView::SingleSelection);
auto *titleLayout = new QHBoxLayout;
titleLayout->setContentsMargins(0, 0, 0, 0);
titleLayout->setSpacing(10);
titleLayout->addWidget(m_workspaceLabel, 1);
titleLayout->addWidget(m_closeButton);
auto *headerLayout = new QVBoxLayout;
headerLayout->setSpacing(6);
headerLayout->addLayout(titleLayout);
headerLayout->addWidget(m_scanLabel);
headerLayout->addWidget(m_summaryLabel);
auto *leftCard = card(this);
leftCard->setMinimumWidth(260);
leftCard->layout()->addWidget(cardTitle(QStringLiteral("上一轮结果"), leftCard));
leftCard->layout()->addWidget(m_resultList);
auto *selectedCard = card(this);
selectedCard->layout()->addWidget(cardTitle(QStringLiteral("选中项"), selectedCard));
selectedCard->layout()->addWidget(m_selectedDetailEdit);
auto *matchesCard = card(this);
matchesCard->layout()->addWidget(cardTitle(QStringLiteral("文本命中"), matchesCard));
matchesCard->layout()->addWidget(m_textMatchesEdit);
auto *recentReadCard = card(this);
recentReadCard->layout()->addWidget(cardTitle(QStringLiteral("最近读取"), recentReadCard));
recentReadCard->layout()->addWidget(m_recentReadEdit);
auto *rightLayout = new QVBoxLayout;
rightLayout->setContentsMargins(0, 0, 0, 0);
rightLayout->setSpacing(12);
rightLayout->addWidget(selectedCard, 1);
rightLayout->addWidget(matchesCard, 2);
rightLayout->addWidget(recentReadCard, 2);
auto *rightContainer = new QWidget(this);
rightContainer->setLayout(rightLayout);
auto *splitter = new QSplitter(Qt::Horizontal, this);
splitter->setObjectName(QStringLiteral("WorkspaceSplitter"));
splitter->addWidget(leftCard);
splitter->addWidget(rightContainer);
splitter->setStretchFactor(0, 0);
splitter->setStretchFactor(1, 1);
splitter->setSizes({280, 470});
auto *buttonLayout = new QHBoxLayout;
buttonLayout->setSpacing(10);
buttonLayout->addWidget(m_readButton);
buttonLayout->addWidget(m_openButton);
buttonLayout->addWidget(m_revealButton);
buttonLayout->addStretch(1);
auto *bottomCloseButton = new QPushButton(QStringLiteral("关闭"), this);
buttonLayout->addWidget(bottomCloseButton);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 16, 16, 16);
layout->setSpacing(14);
layout->addLayout(headerLayout);
layout->addWidget(splitter, 1);
layout->addLayout(buttonLayout);
setStyleSheet(QStringLiteral(
"QDialog { background: #111827; color: #e5e7eb; }"
"QLabel#WorkspaceTitle { color: #f8fafc; font-size: 17px; font-weight: 700; }"
"QLabel#MetaLabel { color: #93a4bd; font-size: 12px; }"
"QLabel#CardTitle { color: #dbeafe; font-size: 13px; font-weight: 700; }"
"QFrame#WorkspaceCard { background: #172235; border: 1px solid #2a3a55; border-radius: 8px; }"
"QListWidget#ResultList { background: #0f172a; color: #dbe4f0; border: 1px solid #26364f; border-radius: 6px; padding: 4px; }"
"QListWidget#ResultList::item { min-height: 28px; padding: 6px 8px; border-radius: 5px; }"
"QListWidget#ResultList::item:selected { background: #1d4ed8; color: #ffffff; }"
"QTextEdit { background: #0f172a; color: #dbe4f0; border: 1px solid #26364f; border-radius: 6px; padding: 8px; selection-background-color: #2563eb; }"
"QPushButton {"
"background: #1f2d44;"
"border: 1px solid #35506f;"
"border-radius: 6px;"
"padding: 5px 14px;"
"min-height: 30px;"
"color: #e5e7eb;"
"}"
"QPushButton:hover:enabled { background: #263b5f; }"
"QPushButton:disabled { color: #64748b; background: #162033; border-color: #243247; }"
"QPushButton#TitleCloseButton { min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px; border-radius: 15px; padding: 0; font-size: 18px; }"
"QPushButton#TitleCloseButton:hover:enabled { background: #7f1d1d; border-color: #991b1b; }"
"QSplitter::handle { background: transparent; width: 8px; }"));
connect(m_resultList, &QListWidget::currentRowChanged, this, [this]() {
updateDetails();
updateActionButtons();
});
connect(m_readButton, &QPushButton::clicked, this, [this]() {
const int index = selectedResultIndex();
if (index >= 0 && m_readCallback)
{
m_readCallback(index);
}
});
connect(m_openButton, &QPushButton::clicked, this, [this]() {
const int index = selectedResultIndex();
if (index >= 0 && m_openCallback)
{
m_openCallback(index);
}
});
connect(m_revealButton, &QPushButton::clicked, this, [this]() {
const int index = selectedResultIndex();
if (index >= 0 && m_revealCallback)
{
m_revealCallback(index);
}
});
connect(m_closeButton, &QPushButton::clicked, this, &QDialog::hide);
connect(bottomCloseButton, &QPushButton::clicked, this, &QDialog::hide);
updateDetails();
updateActionButtons();
}
void WorkspacePanel::setSnapshot(const WorkspacePanelSnapshot &snapshot)
{
m_snapshot = snapshot;
updateSummary();
updateResults();
updateDetails();
updateActionButtons();
}
void WorkspacePanel::showNear(const QRect &avoidRect)
{
move(smartTopLeftNear(avoidRect));
show();
raise();
activateWindow();
}
void WorkspacePanel::setReadCallback(ResultActionCallback callback)
{
m_readCallback = std::move(callback);
}
void WorkspacePanel::setOpenCallback(ResultActionCallback callback)
{
m_openCallback = std::move(callback);
}
void WorkspacePanel::setRevealCallback(ResultActionCallback callback)
{
m_revealCallback = std::move(callback);
}
void WorkspacePanel::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton && event->position().y() <= 72)
{
m_dragging = true;
m_dragStartPosition = event->globalPosition().toPoint();
m_dragWindowPosition = pos();
event->accept();
return;
}
QDialog::mousePressEvent(event);
}
void WorkspacePanel::mouseMoveEvent(QMouseEvent *event)
{
if (m_dragging && (event->buttons() & Qt::LeftButton))
{
move(m_dragWindowPosition + (event->globalPosition().toPoint() - m_dragStartPosition));
event->accept();
return;
}
QDialog::mouseMoveEvent(event);
}
void WorkspacePanel::mouseReleaseEvent(QMouseEvent *event)
{
m_dragging = false;
QDialog::mouseReleaseEvent(event);
}
QRect WorkspacePanel::availableGeometryForRect(const QRect &rect) const
{
const QScreen *screen = QGuiApplication::screenAt(rect.center());
if (screen == nullptr)
{
screen = QGuiApplication::screenAt(rect.topLeft());
}
if (screen == nullptr)
{
screen = QGuiApplication::primaryScreen();
}
return screen == nullptr ? QRect() : screen->availableGeometry();
}
QPoint WorkspacePanel::constrainedTopLeft(const QPoint &preferredTopLeft, const QRect &availableGeometry) const
{
if (!availableGeometry.isValid())
{
return preferredTopLeft;
}
return QPoint(
boundedCoordinate(preferredTopLeft.x(), availableGeometry.left() + ScreenMargin, availableGeometry.right() - width() - ScreenMargin),
boundedCoordinate(preferredTopLeft.y(), availableGeometry.top() + ScreenMargin, availableGeometry.bottom() - height() - ScreenMargin));
}
QPoint WorkspacePanel::smartTopLeftNear(const QRect &avoidRect) const
{
if (!avoidRect.isValid())
{
return pos();
}
const QRect availableGeometry = availableGeometryForRect(avoidRect);
const QVector<QPoint> preferredTopLefts = {
QPoint(avoidRect.right() + 1 + PanelGap, avoidRect.top()),
QPoint(avoidRect.left() - PanelGap - width(), avoidRect.top()),
QPoint(avoidRect.center().x() - width() / 2, avoidRect.top() - PanelGap - height()),
QPoint(avoidRect.center().x() - width() / 2, avoidRect.bottom() + 1 + PanelGap),
};
QPoint bestTopLeft = constrainedTopLeft(preferredTopLefts.first(), availableGeometry);
qint64 bestOverlap = std::numeric_limits<qint64>::max();
qint64 bestDistance = std::numeric_limits<qint64>::max();
int bestIndex = preferredTopLefts.size();
for (int index = 0; index < preferredTopLefts.size(); ++index)
{
const QPoint candidateTopLeft = constrainedTopLeft(preferredTopLefts.at(index), availableGeometry);
const QRect candidateRect(candidateTopLeft, size());
const qint64 candidateOverlap = overlapArea(candidateRect, avoidRect);
const qint64 candidateDistance = squaredDistance(candidateTopLeft, preferredTopLefts.at(index));
if (candidateOverlap < bestOverlap
|| (candidateOverlap == bestOverlap && candidateDistance < bestDistance)
|| (candidateOverlap == bestOverlap && candidateDistance == bestDistance && index < bestIndex))
{
bestTopLeft = candidateTopLeft;
bestOverlap = candidateOverlap;
bestDistance = candidateDistance;
bestIndex = index;
}
}
return bestTopLeft;
}
void WorkspacePanel::updateSummary()
{
const QString workspaceText = m_snapshot.workspaceRoot.trimmed().isEmpty()
? QStringLiteral("未选择工作区")
: m_snapshot.workspaceRoot;
const QString scanText = m_snapshot.lastScanAt.isValid()
? m_snapshot.lastScanAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"))
: QStringLiteral("尚未扫描");
m_workspaceLabel->setText(QStringLiteral("本地工作区 Agent"));
m_scanLabel->setText(QStringLiteral("工作区:%1").arg(workspaceText));
m_summaryLabel->setText(QStringLiteral("索引项:%1 最近扫描:%2")
.arg(m_snapshot.indexedItemCount)
.arg(scanText));
}
void WorkspacePanel::updateResults()
{
const int currentRow = m_resultList->currentRow();
m_resultList->clear();
if (m_snapshot.lastResults.isEmpty())
{
auto *item = new QListWidgetItem(QStringLiteral("暂无上一轮结果"));
item->setFlags(Qt::NoItemFlags);
m_resultList->addItem(item);
return;
}
for (int index = 0; index < m_snapshot.lastResults.size(); ++index)
{
m_resultList->addItem(QStringLiteral("%1. %2").arg(index + 1).arg(compactPath(m_snapshot.lastResults.at(index))));
}
if (currentRow >= 0 && currentRow < m_resultList->count())
{
m_resultList->setCurrentRow(currentRow);
}
else
{
m_resultList->setCurrentRow(0);
}
}
void WorkspacePanel::updateSelectedDetails()
{
const int index = selectedResultIndex();
if (index < 0)
{
m_selectedDetailEdit->setHtml(QStringLiteral("<div style=\"color:#93a4bd;\">请选择左侧结果。</div>"));
return;
}
const WorkspaceFileInfo &file = m_snapshot.lastResults.at(index);
const QString html = QStringLiteral(
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1</div>"
"<div style=\"color:#cbd5e1;\">类型:%2<br>大小:%3 字节<br>修改时间:%4</div>")
.arg(htmlLine(compactPath(file)))
.arg(fileTypeText(file))
.arg(file.sizeBytes)
.arg(file.lastModified.isValid() ? file.lastModified.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")) : QStringLiteral("未知"));
m_selectedDetailEdit->setHtml(html);
}
void WorkspacePanel::updateTextMatches()
{
if (m_snapshot.lastTextMatches.isEmpty())
{
m_textMatchesEdit->setHtml(QStringLiteral("<div style=\"color:#93a4bd;\">暂无文本搜索命中。</div>"));
return;
}
QString html;
const int matchCount = qMin(m_snapshot.lastTextMatches.size(), MaxTextMatchesInDetails);
for (int i = 0; i < matchCount; ++i)
{
const WorkspaceTextMatch &match = m_snapshot.lastTextMatches.at(i);
html += QStringLiteral("<div style=\"margin-bottom:9px;\"><b style=\"color:#bfdbfe;\">%1:%2</b><br><span style=\"color:#dbe4f0;\">%3</span></div>")
.arg(htmlLine(match.relativePath))
.arg(match.lineNumber)
.arg(htmlLine(match.lineText));
}
if (m_snapshot.lastTextMatches.size() > matchCount)
{
html += QStringLiteral("<div style=\"color:#93a4bd;\">仅显示前 %1 条文本命中。</div>").arg(matchCount);
}
m_textMatchesEdit->setHtml(html);
}
void WorkspacePanel::updateRecentRead()
{
if (!m_snapshot.hasLastReadFile)
{
m_recentReadEdit->setHtml(QStringLiteral("<div style=\"color:#93a4bd;\">暂无最近读取文件。</div>"));
return;
}
const QString html = QStringLiteral(
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1</div>"
"<pre style=\"white-space:pre-wrap; color:#dbe4f0; font-family:Consolas,'Microsoft YaHei',monospace; font-size:12px; margin:0;\">%2</pre>")
.arg(htmlLine(m_snapshot.lastReadFile.relativePath))
.arg(m_snapshot.lastReadPreview.toHtmlEscaped());
m_recentReadEdit->setHtml(html);
}
void WorkspacePanel::updateDetails()
{
updateSelectedDetails();
updateTextMatches();
updateRecentRead();
}
void WorkspacePanel::updateActionButtons()
{
const int index = selectedResultIndex();
const bool hasSelection = index >= 0;
m_openButton->setEnabled(hasSelection);
m_revealButton->setEnabled(hasSelection);
m_readButton->setEnabled(hasSelection && !m_snapshot.lastResults.at(index).isDirectory);
}
int WorkspacePanel::selectedResultIndex() const
{
const int row = m_resultList->currentRow();
if (row < 0 || row >= m_snapshot.lastResults.size())
{
return -1;
}
return row;
}