mirror of
https://github.com/logos-co/logos-basecamp.git
synced 2026-08-27 14:51:07 +00:00
837 lines
30 KiB
C++
837 lines
30 KiB
C++
#include "WorkspaceArea.h"
|
||
|
||
#include <QApplication>
|
||
#include <QBoxLayout>
|
||
#include <QDebug>
|
||
#include <QDir>
|
||
#include <QDockWidget>
|
||
#include <QEasingCurve>
|
||
#include <QEvent>
|
||
#include <QFile>
|
||
#include <QFileInfo>
|
||
#include <QHideEvent>
|
||
#include <QIcon>
|
||
#include <QKeySequence>
|
||
#include <QMouseEvent>
|
||
#include <QPainterPath>
|
||
#include <QPixmap>
|
||
#include <QQmlContext>
|
||
#include <QQmlEngine>
|
||
#include <QQuickItem>
|
||
#include <QQuickWidget>
|
||
#include <QResizeEvent>
|
||
#include <QScroller>
|
||
#include <QScrollerProperties>
|
||
#include <QShortcut>
|
||
#include <QShowEvent>
|
||
#include <QPainter>
|
||
#include <QStyle>
|
||
#include <QTabBar>
|
||
#include <QTimer>
|
||
#include <QToolButton>
|
||
#include <QUrl>
|
||
#include <QWheelEvent>
|
||
|
||
namespace {
|
||
class ZeroTitleWidget : public QWidget {
|
||
public:
|
||
explicit ZeroTitleWidget(QWidget* parent = nullptr) : QWidget(parent) {
|
||
setFixedHeight(0);
|
||
}
|
||
QSize sizeHint() const override { return {0, 0}; }
|
||
QSize minimumSizeHint() const override { return {0, 0}; }
|
||
};
|
||
|
||
// Overlay that "punches" antialiased rounded corners into the dock
|
||
// content. Sits above the plugin widget. In the 4 corner regions it
|
||
// paints the shell color, hiding the plugin's square corners; the
|
||
// interior is left transparent so the plugin shows through unmodified.
|
||
// This is why the rounded edges look smooth — QPainter antialiasing on
|
||
// the QPainterPath — instead of the staircase you get with setMask
|
||
// (which is 1-bit region clipping, no antialiasing).
|
||
//
|
||
// Transparent-for-mouse so clicks pass through to the plugin.
|
||
class CornerCutOverlay : public QWidget {
|
||
public:
|
||
explicit CornerCutOverlay(QWidget* parent = nullptr,
|
||
QColor cutColor = QColor("#171717"),
|
||
qreal radius = 16.0)
|
||
: QWidget(parent), m_cutColor(cutColor), m_radius(radius)
|
||
{
|
||
setAttribute(Qt::WA_TransparentForMouseEvents);
|
||
setAttribute(Qt::WA_TranslucentBackground);
|
||
setAutoFillBackground(false);
|
||
}
|
||
|
||
protected:
|
||
void paintEvent(QPaintEvent*) override {
|
||
QPainter p(this);
|
||
p.setRenderHint(QPainter::Antialiasing);
|
||
|
||
// Full rect minus rounded interior = 4 corner slivers.
|
||
QPainterPath outer;
|
||
outer.addRect(rect());
|
||
QPainterPath inner;
|
||
inner.addRoundedRect(rect(), m_radius, m_radius);
|
||
|
||
p.fillPath(outer.subtracted(inner), m_cutColor);
|
||
}
|
||
|
||
private:
|
||
QColor m_cutColor;
|
||
qreal m_radius;
|
||
};
|
||
|
||
// Transparent wrapper around the plugin widget inside a dock. Gives us:
|
||
// * 4px vertical gap between the tab bar and the plugin content —
|
||
// via the layout's top contentsMargin.
|
||
// * Antialiased 16px rounded corners — via CornerCutOverlay (see above).
|
||
// Not using setMask because it's binary/staircase; not using
|
||
// stylesheet border-radius because it doesn't clip children.
|
||
class DockCard : public QWidget {
|
||
public:
|
||
explicit DockCard(QWidget* pluginWidget, QWidget* parent = nullptr)
|
||
: QWidget(parent)
|
||
{
|
||
setObjectName(QStringLiteral("dockCard"));
|
||
setAutoFillBackground(false);
|
||
setAttribute(Qt::WA_TranslucentBackground);
|
||
|
||
auto* lay = new QVBoxLayout(this);
|
||
lay->setContentsMargins(0, 4, 0, 0); // 4px gap under the tab strip
|
||
lay->setSpacing(0);
|
||
if (pluginWidget) lay->addWidget(pluginWidget);
|
||
|
||
m_cornerOverlay = new CornerCutOverlay(this);
|
||
m_cornerOverlay->raise();
|
||
}
|
||
|
||
QWidget* pluginWidget() const {
|
||
if (!layout() || layout()->count() == 0) return nullptr;
|
||
return layout()->itemAt(0)->widget();
|
||
}
|
||
|
||
// Detach the plugin widget so the caller (UIPluginManager) retains
|
||
// ownership — mirrors the ownership contract WorkspaceArea gives
|
||
// its consumers.
|
||
QWidget* releasePluginWidget() {
|
||
if (!layout() || layout()->count() == 0) return nullptr;
|
||
QLayoutItem* item = layout()->takeAt(0);
|
||
QWidget* w = item ? item->widget() : nullptr;
|
||
delete item;
|
||
if (w) w->setParent(nullptr);
|
||
return w;
|
||
}
|
||
|
||
protected:
|
||
void resizeEvent(QResizeEvent* e) override {
|
||
QWidget::resizeEvent(e);
|
||
if (m_cornerOverlay) {
|
||
// Cover only the plugin area (below the 4px top gap). If
|
||
// the overlay covered the full rect, its top corner slivers
|
||
// would extend into the 4px gap AND clip the plugin's real
|
||
// top edge with shell-colored fills.
|
||
m_cornerOverlay->setGeometry(0, kTopGap, width(), height() - kTopGap);
|
||
m_cornerOverlay->raise(); // stay on top of plugin
|
||
}
|
||
}
|
||
|
||
static constexpr int kTopGap = 4;
|
||
|
||
private:
|
||
CornerCutOverlay* m_cornerOverlay = nullptr;
|
||
};
|
||
|
||
// DEV_QML_PATH helpers — mirror the ones in MainContainer.cpp so
|
||
// WelcomePage.qml participates in the same live-edit flow. If we grow
|
||
// more shell-level QQuickWidget hosts, extract to a shared header.
|
||
QString devQmlRoot() {
|
||
const QString dev = QString::fromUtf8(qgetenv("DEV_QML_PATH")).trimmed();
|
||
if (dev.isEmpty()) return QString();
|
||
if (!QFileInfo(dev).isDir()) return QString();
|
||
return dev;
|
||
}
|
||
QUrl resolveQmlView(const QString& relPath, const QString& qrcFallback) {
|
||
const QString root = devQmlRoot();
|
||
if (root.isEmpty()) return QUrl(qrcFallback);
|
||
const QString fullPath = QDir(root).absoluteFilePath(relPath);
|
||
if (!QFile::exists(fullPath)) return QUrl(qrcFallback);
|
||
return QUrl::fromLocalFile(fullPath);
|
||
}
|
||
void applyDevQmlImportPath(QQmlEngine* engine) {
|
||
const QString root = devQmlRoot();
|
||
if (!root.isEmpty()) engine->addImportPath(root);
|
||
}
|
||
|
||
constexpr int kTabBarInsetPx = 24;
|
||
} // namespace
|
||
|
||
WorkspaceArea::WorkspaceArea(QObject* backend, QWidget* parent)
|
||
: QMainWindow(parent)
|
||
{
|
||
setWindowFlags(Qt::Widget);
|
||
|
||
setDockOptions(QMainWindow::AllowNestedDocks
|
||
| QMainWindow::AllowTabbedDocks
|
||
| QMainWindow::AnimatedDocks
|
||
| QMainWindow::GroupedDragging);
|
||
setTabPosition(Qt::AllDockWidgetAreas, QTabWidget::North);
|
||
|
||
setAutoFillBackground(true);
|
||
{
|
||
QPalette pal = palette();
|
||
pal.setColor(QPalette::Window, QColor("#171717"));
|
||
setPalette(pal);
|
||
}
|
||
setContentsMargins(0, 0, 0, 0);
|
||
|
||
if (backend) {
|
||
m_welcomeWidget = new QQuickWidget(this);
|
||
m_welcomeWidget->setResizeMode(QQuickWidget::SizeRootObjectToView);
|
||
m_welcomeWidget->setClearColor(QColor("#171717"));
|
||
m_welcomeWidget->setMinimumSize(300, 200);
|
||
applyDevQmlImportPath(m_welcomeWidget->engine());
|
||
m_welcomeWidget->rootContext()->setContextProperty("backend", backend);
|
||
m_welcomeWidget->setSource(resolveQmlView(
|
||
QStringLiteral("Basecamp/Shell/WelcomePage.qml"),
|
||
QStringLiteral("qrc:/qt/qml/Basecamp/Shell/Basecamp/Shell/WelcomePage.qml")));
|
||
|
||
if (m_welcomeWidget->status() == QQuickWidget::Error) {
|
||
qWarning() << "WorkspaceArea: WelcomePage.qml failed to load:"
|
||
<< m_welcomeWidget->errors();
|
||
}
|
||
|
||
setCentralWidget(m_welcomeWidget);
|
||
|
||
// Forward the QML installClicked signal up to consumers.
|
||
if (QObject* rootObj = m_welcomeWidget->rootObject()) {
|
||
connect(rootObj, SIGNAL(installClicked()),
|
||
this, SIGNAL(installClicked()));
|
||
} else {
|
||
qWarning() << "WorkspaceArea: WelcomePage.qml loaded but "
|
||
"rootObject is null — installClicked not wired.";
|
||
}
|
||
} else {
|
||
auto* placeholder = new QWidget(this);
|
||
placeholder->setMaximumSize(0, 0);
|
||
setCentralWidget(placeholder);
|
||
}
|
||
installEventFilter(this);
|
||
|
||
// DEV: Ctrl+Shift+L flips between tabbed and side-by-side. Hidden
|
||
// toggle, no UI surface — wire a button to toggleLayoutModeForTesting()
|
||
// once we're happy with the behavior.
|
||
auto* sc = new QShortcut(QKeySequence("Ctrl+Shift+L"), this);
|
||
connect(sc, &QShortcut::activated, this,
|
||
&WorkspaceArea::toggleLayoutModeForTesting);
|
||
}
|
||
|
||
void WorkspaceArea::toggleLayoutModeForTesting()
|
||
{
|
||
QTimer::singleShot(0, this, [this]() {
|
||
if (m_dockOrder.size() < 2) return;
|
||
m_sideBySide = !m_sideBySide;
|
||
|
||
QMainWindow::DockOptions opts = QMainWindow::AllowNestedDocks
|
||
| QMainWindow::AnimatedDocks
|
||
| QMainWindow::GroupedDragging;
|
||
if (!m_sideBySide) opts |= QMainWindow::AllowTabbedDocks;
|
||
setDockOptions(opts);
|
||
|
||
for (const QString& n : m_dockOrder)
|
||
if (auto* d = m_docks.value(n)) removeDockWidget(d);
|
||
|
||
QDockWidget* prev = nullptr;
|
||
for (int i = 0; i < m_dockOrder.size(); ++i) {
|
||
auto* d = m_docks.value(m_dockOrder.at(i));
|
||
if (!d) continue;
|
||
|
||
// Swap the title bar to match the new mode: Qt's default in
|
||
// grid (built-in drag + close ×), ZeroTitleWidget in tabbed.
|
||
if (auto* prevBar = d->titleBarWidget()) prevBar->deleteLater();
|
||
d->setTitleBarWidget(m_sideBySide
|
||
? nullptr
|
||
: static_cast<QWidget*>(new ZeroTitleWidget(d)));
|
||
|
||
if (!prev) {
|
||
addDockWidget(Qt::LeftDockWidgetArea, d);
|
||
} else if (m_sideBySide) {
|
||
placeDockInGrid(d, i);
|
||
} else {
|
||
tabifyDockWidget(prev, d);
|
||
}
|
||
d->setVisible(true); // removeDockWidget hides as a side effect
|
||
if (auto* w = d->widget()) w->show();
|
||
prev = d;
|
||
}
|
||
m_firstDock = m_docks.value(m_dockOrder.first());
|
||
|
||
if (m_sideBySide) {
|
||
// Equalize row widths + row heights so each cell is roughly the
|
||
// same size. Rows aren't uniform on remove (Qt collapses splits),
|
||
// but on toggle we get a clean grid.
|
||
const int rows = (m_dockOrder.size() + kColsPerRow - 1) / kColsPerRow;
|
||
QList<QDockWidget*> firstOfEachRow;
|
||
for (int r = 0; r < rows; ++r) {
|
||
if (auto* d = m_docks.value(m_dockOrder.at(r * kColsPerRow)))
|
||
firstOfEachRow.append(d);
|
||
}
|
||
if (rows > 1) {
|
||
const int rowH = qMax(1, height() / rows);
|
||
QList<int> rowHeights(firstOfEachRow.size(), rowH);
|
||
resizeDocks(firstOfEachRow, rowHeights, Qt::Vertical);
|
||
}
|
||
// Equalize columns within each row.
|
||
for (int r = 0; r < rows; ++r) {
|
||
QList<QDockWidget*> row;
|
||
const int start = r * kColsPerRow;
|
||
const int end = qMin(start + kColsPerRow, int(m_dockOrder.size()));
|
||
for (int i = start; i < end; ++i)
|
||
if (auto* d = m_docks.value(m_dockOrder.at(i)))
|
||
row.append(d);
|
||
if (row.size() > 1) {
|
||
const int colW = qMax(1, width() / row.size());
|
||
QList<int> colWidths(row.size(), colW);
|
||
resizeDocks(row, colWidths, Qt::Horizontal);
|
||
}
|
||
}
|
||
}
|
||
|
||
QTimer::singleShot(0, this, [this]() { styleAllTabBars(); });
|
||
});
|
||
}
|
||
|
||
WorkspaceArea::~WorkspaceArea() = default;
|
||
|
||
void WorkspaceArea::placeDockInGrid(QDockWidget* dock, int gridIndex)
|
||
{
|
||
// Precondition: caller guarantees gridIndex >= 1 (index 0 is the
|
||
// anchor and goes through addDockWidget). gridIndex is the target
|
||
// position — i.e., where `dock` will land — not the current
|
||
// m_dockOrder size at the callsite. When toggling from tabbed to
|
||
// grid we iterate all existing docks and pass their new index.
|
||
const int col = gridIndex % kColsPerRow;
|
||
const int row = gridIndex / kColsPerRow;
|
||
|
||
if (col == 0) {
|
||
// Start of a new row — split vertically off the first dock
|
||
// of the previous row so this row anchors below it.
|
||
const int prevRowFirst = (row - 1) * kColsPerRow;
|
||
if (auto* anchor = m_docks.value(m_dockOrder.value(prevRowFirst))) {
|
||
splitDockWidget(anchor, dock, Qt::Vertical);
|
||
} else {
|
||
// Fallback: no previous row (shouldn't happen when
|
||
// gridIndex >= kColsPerRow) — just addDockWidget so we
|
||
// don't leave the dock unparented.
|
||
addDockWidget(Qt::LeftDockWidgetArea, dock);
|
||
}
|
||
} else {
|
||
// Continue the current row — horizontal split against the
|
||
// previous dock in this row (its m_dockOrder index is
|
||
// gridIndex - 1).
|
||
if (auto* leftNeighbor = m_docks.value(m_dockOrder.value(gridIndex - 1))) {
|
||
splitDockWidget(leftNeighbor, dock, Qt::Horizontal);
|
||
} else {
|
||
addDockWidget(Qt::LeftDockWidgetArea, dock);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Public API
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void WorkspaceArea::ensurePhantomTab()
|
||
{
|
||
if (m_sideBySide) return; // no tab bar in grid mode
|
||
if (m_phantomDock) return; // already present
|
||
if (!m_firstDock) return; // no real dock to tabify with
|
||
// Only meaningful when there's exactly one real dock — otherwise
|
||
// Qt's real second tab already gives us a tab bar.
|
||
if (m_dockOrder.size() != 1) return;
|
||
|
||
m_phantomDock = new QDockWidget(this);
|
||
m_phantomDock->setObjectName(QStringLiteral("__phantom_tab_placeholder__"));
|
||
m_phantomDock->setFeatures(QDockWidget::NoDockWidgetFeatures);
|
||
m_phantomDock->setTitleBarWidget(new ZeroTitleWidget(m_phantomDock));
|
||
auto* placeholder = new QWidget;
|
||
placeholder->setMaximumSize(0, 0);
|
||
m_phantomDock->setWidget(placeholder);
|
||
|
||
tabifyDockWidget(m_firstDock, m_phantomDock);
|
||
m_firstDock->raise(); // keep the real dock's content shown
|
||
m_firstDock->setFeatures(QDockWidget::DockWidgetClosable);
|
||
setDockOptions(dockOptions() & ~QMainWindow::GroupedDragging);
|
||
|
||
QTimer::singleShot(0, this, [this]() {
|
||
if (!m_phantomDock) return;
|
||
for (QTabBar* bar : findChildren<QTabBar*>()) {
|
||
for (int i = 0; i < bar->count(); ++i) {
|
||
if (bar->tabText(i).isEmpty()) {
|
||
bar->setTabVisible(i, false);
|
||
bar->setTabEnabled(i, false); // block drag/click
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
void WorkspaceArea::removePhantom()
|
||
{
|
||
if (!m_phantomDock) return;
|
||
QDockWidget* p = m_phantomDock;
|
||
m_phantomDock.clear(); // null before destroying, so any concurrent
|
||
// deferred callback sees "no phantom".
|
||
removeDockWidget(p);
|
||
delete p;
|
||
|
||
if (m_firstDock) {
|
||
m_firstDock->setFeatures(QDockWidget::DockWidgetMovable
|
||
| QDockWidget::DockWidgetClosable);
|
||
}
|
||
setDockOptions(dockOptions() | QMainWindow::GroupedDragging);
|
||
}
|
||
|
||
void WorkspaceArea::addPluginDock(QWidget* pluginWidget,
|
||
const QString& moduleName,
|
||
const QString& displayLabel)
|
||
{
|
||
if (!pluginWidget || moduleName.isEmpty()) return;
|
||
if (m_docks.contains(moduleName)) {
|
||
activatePluginDock(moduleName);
|
||
return;
|
||
}
|
||
|
||
const QString title = displayLabel.isEmpty() ? moduleName : displayLabel;
|
||
auto* dock = new QDockWidget(title, this);
|
||
dock->setObjectName(moduleName);
|
||
dock->setWidget(new DockCard(pluginWidget, dock));
|
||
dock->setAllowedAreas(Qt::AllDockWidgetAreas);
|
||
dock->setFeatures(QDockWidget::DockWidgetMovable
|
||
| QDockWidget::DockWidgetClosable);
|
||
if (m_sideBySide) {
|
||
dock->setTitleBarWidget(nullptr);
|
||
} else {
|
||
dock->setTitleBarWidget(new ZeroTitleWidget(dock));
|
||
}
|
||
dock->installEventFilter(this);
|
||
pluginWidget->installEventFilter(this);
|
||
|
||
const QIcon icon = pluginWidget->windowIcon();
|
||
if (!icon.isNull()) dock->setWindowIcon(icon);
|
||
|
||
if (!m_firstDock) {
|
||
addDockWidget(Qt::LeftDockWidgetArea, dock);
|
||
m_firstDock = dock;
|
||
} else if (m_sideBySide) {
|
||
placeDockInGrid(dock, m_dockOrder.size());
|
||
} else {
|
||
removePhantom();
|
||
tabifyDockWidget(m_firstDock, dock);
|
||
dock->raise();
|
||
}
|
||
|
||
m_docks[moduleName] = dock;
|
||
m_dockOrder.append(moduleName);
|
||
|
||
QTimer::singleShot(0, this, [this, moduleName]() {
|
||
styleAllTabBars();
|
||
activatePluginDock(moduleName);
|
||
});
|
||
ensurePhantomTab();
|
||
updateQmlPluginActiveStates();
|
||
updateWelcomeVisibility();
|
||
}
|
||
|
||
void WorkspaceArea::removePluginDock(const QString& name)
|
||
{
|
||
auto it = m_docks.find(name);
|
||
if (it == m_docks.end()) return;
|
||
|
||
QDockWidget* dock = it.value();
|
||
m_docks.erase(it);
|
||
m_dockOrder.removeAll(name);
|
||
|
||
removeDockWidget(dock);
|
||
dock->deleteLater();
|
||
|
||
if (m_firstDock == dock) {
|
||
m_firstDock = m_dockOrder.isEmpty() ? nullptr
|
||
: m_docks.value(m_dockOrder.first());
|
||
}
|
||
|
||
QTimer::singleShot(0, this, [this]() { styleAllTabBars(); });
|
||
if (m_dockOrder.isEmpty()) removePhantom();
|
||
else ensurePhantomTab();
|
||
updateQmlPluginActiveStates();
|
||
updateWelcomeVisibility();
|
||
}
|
||
|
||
void WorkspaceArea::activatePluginDock(const QString& moduleName)
|
||
{
|
||
QDockWidget* dock = m_docks.value(moduleName);
|
||
if (!dock) return;
|
||
|
||
dock->show();
|
||
dock->raise();
|
||
|
||
const QString tabText = dock->windowTitle();
|
||
for (QTabBar* bar : findChildren<QTabBar*>()) {
|
||
for (int i = 0; i < bar->count(); ++i) {
|
||
if (bar->tabText(i) == tabText) {
|
||
bar->setCurrentIndex(i);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
updateQmlPluginActiveStates();
|
||
}
|
||
|
||
QString WorkspaceArea::moduleNameForTabText(const QString& tabText) const
|
||
{
|
||
if (tabText.isEmpty()) return {};
|
||
for (auto it = m_docks.cbegin(); it != m_docks.cend(); ++it) {
|
||
if (it.value() && it.value()->windowTitle() == tabText) return it.key();
|
||
}
|
||
return {};
|
||
}
|
||
|
||
QString WorkspaceArea::nameForWidget(QWidget* w) const
|
||
{
|
||
if (!w) return {};
|
||
for (auto it = m_docks.cbegin(); it != m_docks.cend(); ++it) {
|
||
QWidget* dockChild = it.value()->widget();
|
||
if (dockChild == w) return it.key();
|
||
if (auto* card = dynamic_cast<DockCard*>(dockChild)) {
|
||
if (card->pluginWidget() == w) return it.key();
|
||
}
|
||
}
|
||
return {};
|
||
}
|
||
|
||
void WorkspaceArea::removePluginDock(QWidget* pluginWidget)
|
||
{
|
||
const QString name = nameForWidget(pluginWidget);
|
||
if (!name.isEmpty()) removePluginDock(name);
|
||
}
|
||
|
||
void WorkspaceArea::activatePluginDock(QWidget* pluginWidget)
|
||
{
|
||
const QString name = nameForWidget(pluginWidget);
|
||
if (!name.isEmpty()) activatePluginDock(name);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tab-bar styling (ported from MdiView::customizeTabBarStyle +
|
||
// insetTabBarGeometry).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void WorkspaceArea::customizeTabBarStyle(QTabBar* tabBar)
|
||
{
|
||
if (!tabBar) return;
|
||
|
||
tabBar->setDocumentMode(true);
|
||
tabBar->setDrawBase(false);
|
||
tabBar->setAutoHide(false);
|
||
tabBar->setAutoFillBackground(false);
|
||
tabBar->setElideMode(Qt::ElideRight);
|
||
tabBar->setUsesScrollButtons(false);
|
||
tabBar->setExpanding(false);
|
||
tabBar->setIconSize(QSize(15, 15));
|
||
tabBar->setMovable(true);
|
||
QScroller::grabGesture(tabBar, QScroller::TouchGesture);
|
||
QScrollerProperties props = QScroller::scroller(tabBar)->scrollerProperties();
|
||
props.setScrollMetric(QScrollerProperties::HorizontalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
|
||
props.setScrollMetric(QScrollerProperties::VerticalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
|
||
props.setScrollMetric(QScrollerProperties::ScrollingCurve, QEasingCurve::OutCubic);
|
||
QScroller::scroller(tabBar)->setScrollerProperties(props);
|
||
|
||
tabBar->setStyleSheet(QStringLiteral(R"(
|
||
QTabBar {
|
||
background: #171717;
|
||
border: none;
|
||
qproperty-drawBase: false;
|
||
}
|
||
|
||
QTabBar::tab {
|
||
background: #262626;
|
||
color: #A4A4A4;
|
||
|
||
padding: 0px 8px 0px 4px;
|
||
margin-right: 10px;
|
||
|
||
border-top-left-radius: 10px;
|
||
border-top-right-radius: 10px;
|
||
height: 20px;
|
||
min-width: 120px;
|
||
}
|
||
|
||
QTabBar::tab:!selected {
|
||
background: rgba(38, 38, 38, 0.6);
|
||
color: #626262;
|
||
}
|
||
|
||
QTabBar::tab:hover {
|
||
background: #262626;
|
||
}
|
||
)"));
|
||
|
||
connect(tabBar, &QTabBar::tabCloseRequested, this,
|
||
[this, tabBar](int index) {
|
||
const QString moduleName = moduleNameForTabText(tabBar->tabText(index));
|
||
if (moduleName.isEmpty()) return;
|
||
emit pluginClosed(moduleName);
|
||
});
|
||
|
||
// Track tab-current changes so we can update plugin isActiveTab and
|
||
// sync the sidebar's active-app highlight (backend.currentVisibleApp).
|
||
connect(tabBar, &QTabBar::currentChanged, this,
|
||
[this, tabBar](int index) {
|
||
updateQmlPluginActiveStates();
|
||
const QString name = index >= 0
|
||
? moduleNameForTabText(tabBar->tabText(index))
|
||
: QString();
|
||
emit activeAppChanged(name);
|
||
});
|
||
}
|
||
|
||
void WorkspaceArea::installTabBarCloseButtons(QTabBar* tabBar)
|
||
{
|
||
if (!tabBar) return;
|
||
const QTabBar::ButtonPosition closeSide = QTabBar::LeftSide;
|
||
for (int i = 0; i < tabBar->count(); ++i) {
|
||
if (tabBar->tabText(i).isEmpty()) continue;
|
||
QWidget* oldBtn = tabBar->tabButton(i, closeSide);
|
||
if (oldBtn) {
|
||
tabBar->setTabButton(i, closeSide, nullptr);
|
||
oldBtn->deleteLater();
|
||
}
|
||
auto* btn = new QToolButton(tabBar);
|
||
btn->setIcon(qApp->style()->standardIcon(QStyle::SP_TitleBarCloseButton));
|
||
btn->setIconSize(QSize(12, 12));
|
||
btn->setFixedSize(12, 12);
|
||
btn->setCursor(Qt::PointingHandCursor);
|
||
btn->setStyleSheet(QStringLiteral(R"(
|
||
QToolButton { background: transparent; border: none; }
|
||
QToolButton:hover { background: rgba(255,255,255,0.1); border-radius: 6px; }
|
||
)"));
|
||
connect(btn, &QToolButton::clicked, this, [tabBar, btn, closeSide]() {
|
||
for (int j = 0; j < tabBar->count(); ++j) {
|
||
if (tabBar->tabButton(j, closeSide) == btn) {
|
||
emit tabBar->tabCloseRequested(j);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
btn->setVisible(false); // hover-show only
|
||
btn->installEventFilter(this);
|
||
tabBar->setTabButton(i, closeSide, btn);
|
||
}
|
||
tabBar->setMouseTracking(true);
|
||
}
|
||
|
||
void WorkspaceArea::insetTabBarGeometry(QTabBar* tabBar, int insetPx)
|
||
{
|
||
if (!tabBar) return;
|
||
QWidget* p = tabBar->parentWidget();
|
||
if (!p) return;
|
||
|
||
QRect g = tabBar->geometry();
|
||
tabBar->setGeometry(insetPx, g.y(),
|
||
p->width() - insetPx - kTabBarInsetPx, g.height());
|
||
}
|
||
|
||
void WorkspaceArea::styleAllTabBars()
|
||
{
|
||
static const char* kStyledMarker = "logosStyled";
|
||
for (auto* tabBar : findChildren<QTabBar*>()) {
|
||
if (!tabBar->property(kStyledMarker).toBool()) {
|
||
tabBar->setProperty(kStyledMarker, true);
|
||
customizeTabBarStyle(tabBar);
|
||
tabBar->installEventFilter(this);
|
||
}
|
||
// Re-run close-button install in case Qt grew the tab count.
|
||
installTabBarCloseButtons(tabBar);
|
||
|
||
for (int i = 0; i < tabBar->count(); ++i) {
|
||
const QString moduleName = moduleNameForTabText(tabBar->tabText(i));
|
||
if (auto* dock = m_docks.value(moduleName)) {
|
||
const QIcon icon = dock->windowIcon();
|
||
if (!icon.isNull()) tabBar->setTabIcon(i, icon);
|
||
}
|
||
}
|
||
|
||
if (tabBar->x() != kTabBarInsetPx)
|
||
insetTabBarGeometry(tabBar, kTabBarInsetPx);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Plugin active-state propagation (port of MdiView::updateQmlPluginActiveStates)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void WorkspaceArea::updateWelcomeVisibility()
|
||
{
|
||
if (!m_welcomeWidget) return;
|
||
m_welcomeWidget->setVisible(m_docks.isEmpty());
|
||
}
|
||
|
||
QQuickWidget* WorkspaceArea::activeDockWidget() const
|
||
{
|
||
for (auto it = m_docks.cbegin(); it != m_docks.cend(); ++it) {
|
||
QDockWidget* dock = it.value();
|
||
if (!dock || dock->visibleRegion().isEmpty()) continue;
|
||
QWidget* dockChild = dock->widget();
|
||
if (auto* card = dynamic_cast<DockCard*>(dockChild))
|
||
dockChild = card->pluginWidget();
|
||
if (auto* qw = qobject_cast<QQuickWidget*>(dockChild))
|
||
return qw;
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
void WorkspaceArea::updateQmlPluginActiveStates()
|
||
{
|
||
const bool workspaceVisible = isVisible();
|
||
for (auto it = m_docks.cbegin(); it != m_docks.cend(); ++it) {
|
||
QDockWidget* dock = it.value();
|
||
QWidget* dockChild = dock->widget();
|
||
// Plugin widgets are wrapped in DockCard; unwrap before casting.
|
||
if (auto* card = dynamic_cast<DockCard*>(dockChild))
|
||
dockChild = card->pluginWidget();
|
||
auto* qmlWidget = qobject_cast<QQuickWidget*>(dockChild);
|
||
if (!qmlWidget) continue;
|
||
const bool isActive = workspaceVisible
|
||
&& !dock->visibleRegion().isEmpty();
|
||
qmlWidget->rootContext()->setContextProperty(
|
||
"isActiveTab", isActive);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Event filter — hover-show for close buttons + wheel-scroll between tabs.
|
||
// Ported from MdiView's eventFilter, minus the "+" add-button branch.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
bool WorkspaceArea::eventFilter(QObject* watched, QEvent* event)
|
||
{
|
||
if (watched == this && event->type() == QEvent::ChildAdded) {
|
||
QTimer::singleShot(0, this, [this]() { styleAllTabBars(); });
|
||
}
|
||
|
||
if (auto* tabBar = qobject_cast<QTabBar*>(watched)) {
|
||
switch (event->type()) {
|
||
case QEvent::Resize:
|
||
case QEvent::Move: {
|
||
if (tabBar->x() != kTabBarInsetPx)
|
||
insetTabBarGeometry(tabBar, kTabBarInsetPx);
|
||
break;
|
||
}
|
||
case QEvent::MouseMove: {
|
||
const QPoint pos = static_cast<QMouseEvent*>(event)->position().toPoint();
|
||
for (int i = 0; i < tabBar->count(); ++i) {
|
||
if (!tabBar->isTabVisible(i)) continue; // skip phantom
|
||
QWidget* closeBtn = tabBar->tabButton(i, QTabBar::LeftSide);
|
||
if (closeBtn) {
|
||
const QRect tabRect = tabBar->tabRect(i);
|
||
const bool overTabOrButton = tabRect.contains(pos)
|
||
|| closeBtn->geometry().contains(pos);
|
||
closeBtn->setVisible(overTabOrButton);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
case QEvent::Leave: {
|
||
for (int i = 0; i < tabBar->count(); ++i) {
|
||
if (!tabBar->isTabVisible(i)) continue; // skip phantom
|
||
QWidget* closeBtn = tabBar->tabButton(i, QTabBar::LeftSide);
|
||
if (closeBtn) closeBtn->setVisible(false);
|
||
}
|
||
break;
|
||
}
|
||
case QEvent::Wheel: {
|
||
if (tabBar->count() <= 1) break;
|
||
auto* wheelEvent = static_cast<QWheelEvent*>(event);
|
||
int delta = 0;
|
||
if (!wheelEvent->pixelDelta().isNull())
|
||
delta = wheelEvent->pixelDelta().x();
|
||
else if (!wheelEvent->angleDelta().isNull())
|
||
delta = wheelEvent->angleDelta().x() / 2;
|
||
if (delta != 0) {
|
||
const int next = qBound(0,
|
||
tabBar->currentIndex() + (delta > 0 ? -1 : 1),
|
||
tabBar->count() - 1);
|
||
if (next != tabBar->currentIndex()) {
|
||
tabBar->setCurrentIndex(next);
|
||
updateQmlPluginActiveStates();
|
||
return true;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Close button hover persistence — show when pointer enters the
|
||
// button itself, hide on leave.
|
||
if (event->type() == QEvent::Enter || event->type() == QEvent::Leave) {
|
||
if (auto* btn = qobject_cast<QToolButton*>(watched)) {
|
||
if (qobject_cast<QTabBar*>(btn->parent())) {
|
||
btn->setVisible(event->type() == QEvent::Enter);
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (event->type() == QEvent::Close) {
|
||
if (auto* dock = qobject_cast<QDockWidget*>(watched)) {
|
||
const QString moduleName = m_docks.key(dock);
|
||
if (!moduleName.isEmpty()) {
|
||
emit pluginClosed(moduleName);
|
||
event->ignore();
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Plugin widget's windowIcon was refreshed
|
||
if (event->type() == QEvent::WindowIconChange) {
|
||
if (auto* widget = qobject_cast<QWidget*>(watched)) {
|
||
for (auto it = m_docks.cbegin(); it != m_docks.cend(); ++it) {
|
||
QWidget* dw = it.value()->widget();
|
||
if (auto* card = dynamic_cast<DockCard*>(dw))
|
||
dw = card->pluginWidget();
|
||
if (dw != widget) continue;
|
||
|
||
const QIcon icon = widget->windowIcon();
|
||
it.value()->setWindowIcon(icon);
|
||
const QString& name = it.key();
|
||
for (QTabBar* tabBar : findChildren<QTabBar*>()) {
|
||
for (int i = 0; i < tabBar->count(); ++i) {
|
||
if (moduleNameForTabText(tabBar->tabText(i)) == name)
|
||
tabBar->setTabIcon(i, icon);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return QMainWindow::eventFilter(watched, event);
|
||
}
|
||
|
||
void WorkspaceArea::hideEvent(QHideEvent* event)
|
||
{
|
||
QMainWindow::hideEvent(event);
|
||
updateQmlPluginActiveStates();
|
||
}
|
||
|
||
void WorkspaceArea::showEvent(QShowEvent* event)
|
||
{
|
||
QMainWindow::showEvent(event);
|
||
updateQmlPluginActiveStates();
|
||
}
|