From 3f2b99f045f45998af0660b4cc57560a97a76b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 15:59:07 -0400 Subject: [PATCH] Project tabs drag to reorder; tabs that don't fit gather in a menu (13.14) --- CMakeLists.txt | 2 + src/Document/ProjectWorkspace.cpp | 15 ++ src/Document/ProjectWorkspace.h | 2 + src/UI/ProjectTabLayout.cpp | 58 ++++++ src/UI/ProjectTabLayout.h | 41 ++++ src/UI/ProjectTabs+Strip.cpp | 213 +++++++++++++++++++++ src/UI/ProjectTabs.cpp | 158 +++++++++------- src/UI/ProjectTabs.h | 71 ++++++- src/UI/ProjectWorkspaceView.cpp | 5 +- tests/ProjectTabDragTests.cpp | 278 ++++++++++++++++++++++++++++ tests/ProjectTabLayoutTests.cpp | 152 +++++++++++++++ tests/ProjectTabStripTests.cpp | 41 ---- tests/ProjectWorkspaceTests.cpp | 32 ++++ tests/ProjectWorkspaceViewTests.cpp | 25 ++- tests/Tests.cmake | 2 + 15 files changed, 957 insertions(+), 138 deletions(-) create mode 100644 src/UI/ProjectTabLayout.cpp create mode 100644 src/UI/ProjectTabLayout.h create mode 100644 src/UI/ProjectTabs+Strip.cpp create mode 100644 tests/ProjectTabDragTests.cpp create mode 100644 tests/ProjectTabLayoutTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8983403..afb2fc5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -156,6 +156,8 @@ add_library(compositor_core STATIC src/UI/RawDevelopSheet.cpp src/UI/OmarchyTheme.cpp src/UI/ProjectTabs.cpp + src/UI/ProjectTabs+Strip.cpp + src/UI/ProjectTabLayout.cpp src/UI/ProjectWorkspaceView.cpp src/UI/ToolHeaderStyle.cpp src/UI/ToolIcons.cpp diff --git a/src/Document/ProjectWorkspace.cpp b/src/Document/ProjectWorkspace.cpp index 6fe8a5f..d54f56a 100644 --- a/src/Document/ProjectWorkspace.cpp +++ b/src/Document/ProjectWorkspace.cpp @@ -4,6 +4,7 @@ #include #include #include +#include const QString ProjectWorkspace::layerType = QStringLiteral("com.compositor.layer-row"); @@ -96,6 +97,20 @@ void ProjectWorkspace::select(QUuid id) emit changed(); } +void ProjectWorkspace::moveTab(QUuid id, int index) +{ + const auto from = std::ranges::find_if(m_tabs, [id](const std::shared_ptr &tab) { return tab->id == id; }); + if (from == m_tabs.end()) + return; + const auto target = std::clamp(index, 0, std::ptrdiff_t(m_tabs.size()) - 1); + if (target == from - m_tabs.begin()) + return; + const std::shared_ptr tab = *from; + m_tabs.erase(from); + m_tabs.insert(m_tabs.begin() + target, tab); + emit changed(); +} + void ProjectWorkspace::newCanvas() { if (!canSwitch()) diff --git a/src/Document/ProjectWorkspace.h b/src/Document/ProjectWorkspace.h index f0609c7..e9795a1 100644 --- a/src/Document/ProjectWorkspace.h +++ b/src/Document/ProjectWorkspace.h @@ -36,6 +36,8 @@ public: bool canSwitch() const; ProjectTab &addTab(bool reuseEmpty = true); void select(QUuid id); + // A dragged tab lands at `index`, clamped; chrome, never undone. + void moveTab(QUuid id, int index); void newCanvas(); // Each `done` runs from the event loop, never inline. void open(std::optional path = std::nullopt, std::function done = {}); diff --git a/src/UI/ProjectTabLayout.cpp b/src/UI/ProjectTabLayout.cpp new file mode 100644 index 0000000..b7c5a59 --- /dev/null +++ b/src/UI/ProjectTabLayout.cpp @@ -0,0 +1,58 @@ +#include "UI/ProjectTabLayout.h" +#include + +double ProjectTabOverflow::contentWidth() const +{ + // A pill always has a tab after it. + return visible.empty() ? 0 : visible.back().x + visible.back().width; +} + +QString projectTabOverflowLabel(int hiddenCount) +{ + return hiddenCount == 1 ? QStringLiteral("1 more tab") : QStringLiteral("%1 more tabs").arg(hiddenCount); +} + +ProjectTabOverflow projectTabOverflow(const std::vector &order, const std::map &widths, QUuid selectedID, + double availableWidth, const std::function &pillWidth) +{ + if (order.empty()) + return {}; + const auto span = [&](auto first, auto last) { + double total = 0; + for (auto id = first; id != last; ++id) + total += widths.at(*id); + // Never asked of no tabs. + return total + projectTabSpacing * double(std::distance(first, last) - 1); + }; + const auto place = [&](const std::vector &ids, double x) { + std::vector placed; + for (const QUuid &id : ids) { + placed.push_back({id, x, widths.at(id)}); + x += widths.at(id) + projectTabSpacing; + } + return placed; + }; + // Not yet measured, or everything fits: no pill. + if (availableWidth <= 0 || span(order.begin(), order.end()) <= availableWidth) + return {place(order, 0), {}, std::nullopt}; + qsizetype shown = qsizetype(order.size()); + while (shown > 1) { + const qsizetype hidden = qsizetype(order.size()) - shown; + if (pillWidth(int(hidden)) + projectTabSpacing + span(order.end() - shown, order.end()) <= availableWidth) + break; + shown -= 1; + } + std::vector visibleIDs(order.end() - shown, order.end()), hiddenIDs(order.begin(), order.end() - shown); + if (const auto selected = std::ranges::find(hiddenIDs, selectedID); selected != hiddenIDs.end()) { + hiddenIDs.erase(selected); + hiddenIDs.push_back(visibleIDs[0]); + visibleIDs[0] = selectedID; + // A wider selected tab: the tabs after it make room. + while (visibleIDs.size() > 1 && pillWidth(int(hiddenIDs.size())) + projectTabSpacing + span(visibleIDs.begin(), visibleIDs.end()) > availableWidth) { + hiddenIDs.push_back(visibleIDs[1]); + visibleIDs.erase(visibleIDs.begin() + 1); + } + } + const double pill = pillWidth(int(hiddenIDs.size())); + return {place(visibleIDs, pill + projectTabSpacing), hiddenIDs, ProjectTabPillSlot{0, pill}}; +} diff --git a/src/UI/ProjectTabLayout.h b/src/UI/ProjectTabLayout.h new file mode 100644 index 0000000..96a835d --- /dev/null +++ b/src/UI/ProjectTabLayout.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// Between pills, and after the overflow pill. +inline constexpr double projectTabSpacing = 6; + +// A tab's place in the strip. +struct ProjectTabSlot { + QUuid id; + double x; + double width; + bool operator==(const ProjectTabSlot &) const = default; +}; + +// The overflow pill's place: it is no tab. +struct ProjectTabPillSlot { + double x; + double width; + bool operator==(const ProjectTabPillSlot &) const = default; +}; + +// What the strip draws: tabs that fit, the rest. +struct ProjectTabOverflow { + std::vector visible; + std::vector hiddenIDs; + std::optional pill; + double contentWidth() const; + bool operator==(const ProjectTabOverflow &) const = default; +}; + +// "N more tabs", singular for one. +QString projectTabOverflowLabel(int hiddenCount); + +// Drops tabs from the front until the rest fit. +ProjectTabOverflow projectTabOverflow(const std::vector &order, const std::map &widths, QUuid selectedID, + double availableWidth, const std::function &pillWidth); diff --git a/src/UI/ProjectTabs+Strip.cpp b/src/UI/ProjectTabs+Strip.cpp new file mode 100644 index 0000000..bc1c05f --- /dev/null +++ b/src/UI/ProjectTabs+Strip.cpp @@ -0,0 +1,213 @@ +#include "UI/ProjectTabs.h" +#include +#include +#include + +ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent) + : QWidget(parent), m_workspace(workspace), m_pill(new OverflowTabsPill(workspace, this)) +{ + setObjectName(QStringLiteral("projectTabs")); + setAccessibleName(QStringLiteral("Project tabs")); + setFixedHeight(34); + // The toolbar's free width; the toolbar shows through. + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectTabStrip::synchronize); + synchronize(); +} + +// Every tab in a row, as if unmeasured. +QSize ProjectTabStrip::sizeHint() const +{ + std::vector order; + for (const std::shared_ptr &tab : m_workspace.tabs()) + order.push_back(tab->id); + return {int(std::ceil(projectTabOverflow(order, widths(), m_workspace.selectedID(), 0, &OverflowTabsPill::pillWidth).contentWidth())), 34}; +} + +QSize ProjectTabStrip::minimumSizeHint() const +{ + return {0, 34}; +} + +QList ProjectTabStrip::buttons() const +{ + return QList(m_buttons.begin(), m_buttons.end()); +} + +void ProjectTabStrip::synchronize() +{ + // Buttons follow the tabs: kept where the tab stays. + std::vector kept; + for (const std::shared_ptr &tab : m_workspace.tabs()) { + const auto found = std::ranges::find(m_buttons, tab, &ProjectTabButton::tab); + ProjectTabButton *button = nullptr; + if (found == m_buttons.end()) { + const QUuid id = tab->id; + button = new ProjectTabButton(m_workspace, tab, [this, id](TabDragPhase phase, double translation) { handleReorder(id, phase, translation); }, this); + // A title or dot changes the widths. + connect(&tab->session, &EditorSession::changed, button, [this] { layOut(); }); + } else { + button = *found; + m_buttons.erase(found); + } + button->synchronize(); + kept.push_back(button); + } + for (ProjectTabButton *gone : m_buttons) + delete gone; + m_buttons = std::move(kept); + // A drag whose tab closed under it is over. + if (m_reorder && !m_workspace.tab(m_reorder->id)) + m_reorder.reset(); + layOut(); +} + +std::map ProjectTabStrip::widths() const +{ + std::map result; + for (const ProjectTabButton *button : m_buttons) + result.insert({button->tab->id, button->sizeHint().width()}); + return result; +} + +ProjectTabOverflow ProjectTabStrip::overflow() const +{ + std::vector order; + for (const std::shared_ptr &tab : m_workspace.tabs()) + order.push_back(tab->id); + return projectTabOverflow(order, widths(), m_workspace.selectedID(), width(), &OverflowTabsPill::pillWidth); +} + +void ProjectTabStrip::layOut(bool animated) +{ + const ProjectTabOverflow layout = overflow(); + m_pill->setVisible(layout.pill.has_value()); + if (layout.pill) { + m_pill->setHiddenIDs(layout.hiddenIDs); + m_pill->setGeometry(int(layout.pill->x), 3, int(layout.pill->width), 28); + } + for (ProjectTabButton *button : m_buttons) { + const auto slot = std::ranges::find(layout.visible, button->tab->id, &ProjectTabSlot::id); + button->setVisible(slot != layout.visible.end()); + if (slot == layout.visible.end()) + continue; + button->resize(int(slot->width), 28); + const QPoint target(int(std::round(renderX(*slot, layout.contentWidth()))), 3); + // The dragged tab follows the pointer; the others ease aside. + auto *slide = button->findChild(QString(), Qt::FindDirectChildrenOnly); + const bool eases = animated && !(m_reorder && m_reorder->id == button->tab->id); + if (slide && (!eases || slide->endValue() != QVariant(target))) + delete std::exchange(slide, nullptr); + if (!eases) + button->move(target); + else if (!slide && button->pos() != target) { + slide = new QPropertyAnimation(button, "pos", button); + slide->setDuration(150); + slide->setEasingCurve(QEasingCurve::OutQuad); + slide->setEndValue(target); + slide->start(QAbstractAnimation::DeleteWhenStopped); + } + } + updateGeometry(); +} + +double ProjectTabStrip::renderX(const ProjectTabSlot &slot, double contentWidth) const +{ + if (!m_reorder) + return slot.x; + if (slot.id == m_reorder->id) + return std::min(std::max(m_reorder->originX + m_reorder->translation, m_reorder->startX), std::max(m_reorder->startX, contentWidth - slot.width)); + const auto index = std::ranges::find(m_reorder->others, slot.id); + if (index == m_reorder->others.end()) + return slot.x; + const double x = m_reorder->compactedX.at(slot.id); + return index - m_reorder->others.begin() >= m_reorder->targetIndex ? x + m_reorder->widths.at(m_reorder->id) + projectTabSpacing : x; +} + +// The gap nearest the dragged tab: where it lands. +int ProjectTabStrip::Reorder::nearestSlot() const +{ + const double x = originX + translation; + std::vector gaps; + for (const QUuid &other : others) + gaps.push_back(compactedX.at(other)); + gaps.push_back(others.empty() ? startX : compactedX.at(others.back()) + widths.at(others.back()) + projectTabSpacing); + return int(std::ranges::min_element(gaps, {}, [x](double slot) { return std::abs(slot - x); }) - gaps.begin()); +} + +void ProjectTabStrip::handleReorder(QUuid id, TabDragPhase phase, double translation) +{ + if (phase == TabDragPhase::changed) { + // Another tab's drag lost its release, as Swift's timer finds. + if (m_reorder && m_reorder->id != id) + m_reorder.reset(); + if (!m_reorder) { + if (!m_workspace.canSwitch() || std::abs(translation) < 3) + return; + // Dragging a tab selects it, as in Safari. + m_workspace.select(id); + m_reorder = makeReorder(id); + // Above the tabs it crosses. + for (ProjectTabButton *button : m_buttons) { + if (button->tab->id == id) + button->raise(); + } + } + m_reorder->translation = translation; + m_reorder->targetIndex = m_reorder->nearestSlot(); + layOut(true); + return; + } + // Busy by the drop: the tabs go back. + if (m_reorder && m_reorder->id == id && m_workspace.canSwitch()) + commitReorder(*m_reorder); + m_reorder.reset(); + layOut(true); +} + +ProjectTabStrip::Reorder ProjectTabStrip::makeReorder(QUuid id) const +{ + const ProjectTabOverflow layout = overflow(); + Reorder state{.id = id, .others = {}, .widths = widths(), .compactedX = {}, .startX = layout.visible.empty() ? 0 : layout.visible.front().x, + .originX = 0}; + double x = state.startX; + state.originX = state.startX; + for (const ProjectTabSlot &slot : layout.visible) { + if (slot.id == id) { + state.originX = slot.x; + continue; + } + state.others.push_back(slot.id); + state.compactedX.insert({slot.id, x}); + x += state.widths.at(slot.id) + projectTabSpacing; + } + return state; +} + +// Only the tabs visible as the drag began ever move. +void ProjectTabStrip::commitReorder(const Reorder &state) +{ + std::vector order; + for (const std::shared_ptr &tab : m_workspace.tabs()) + order.push_back(tab->id); + // A tab closed mid-drag has no place. + const auto position = [&order](QUuid id) -> std::optional { + const auto found = std::ranges::find(order, id); + return found == order.end() ? std::nullopt : std::optional(found - order.begin()); + }; + const qsizetype from = position(state.id).value(); + qsizetype target = from; + if (const auto neighbor = state.targetIndex < int(state.others.size()) ? position(state.others[size_t(state.targetIndex)]) : std::nullopt) + target = *neighbor; + else if (const auto last = state.others.empty() ? std::nullopt : position(state.others.back())) + target = *last + 1; + if (from < target) + target -= 1; + m_workspace.moveTab(state.id, int(target)); +} + +void ProjectTabStrip::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + layOut(); +} diff --git a/src/UI/ProjectTabs.cpp b/src/UI/ProjectTabs.cpp index 5475051..77f9cfd 100644 --- a/src/UI/ProjectTabs.cpp +++ b/src/UI/ProjectTabs.cpp @@ -6,11 +6,12 @@ #include #include #include +#include +#include #include -#include +#include #include #include -#include namespace { QFont tabFont(bool active) @@ -47,8 +48,10 @@ int labelWidth(const QString &title, bool active, bool modified) } } -ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr tab, QWidget *parent) - : QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_select(new TabTitle(this)), m_close(new QToolButton(this)) +ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr tab, std::function onReorder, + QWidget *parent) + : QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_onReorder(std::move(onReorder)), m_select(new TabTitle(this)), + m_close(new QToolButton(this)) { // 28 high: the strip's 34 less its margins. m_select->setObjectName(QStringLiteral("selectTab")); @@ -65,6 +68,7 @@ ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr< row->addWidget(m_select); row->addWidget(m_close); setAcceptDrops(true); + m_select->installEventFilter(this); connect(m_select, &QToolButton::clicked, this, [this] { m_workspace.select(this->tab->id); }); connect(m_close, &QToolButton::clicked, this, [this] { m_workspace.close(this->tab->id); }); // The strip syncs every button on the workspace's news. @@ -93,6 +97,26 @@ void ProjectTabButton::synchronize() setProperty("active", active); } +// A press moved three points reorders; measured in the window. +bool ProjectTabButton::eventFilter(QObject *watched, QEvent *event) +{ + const auto *mouse = static_cast(event); + if (event->type() == QEvent::MouseButtonPress && mouse->button() == Qt::LeftButton) { + m_press = mouse->globalPosition(); + m_dragging = false; + } else if (event->type() == QEvent::MouseMove && m_press && mouse->buttons().testFlag(Qt::LeftButton)) { + m_dragging = m_dragging || QLineF(*m_press, mouse->globalPosition()).length() >= 3; + if (m_dragging) + m_onReorder(TabDragPhase::changed, mouse->globalPosition().x() - m_press->x()); + } else if (event->type() == QEvent::MouseButtonRelease && mouse->button() == Qt::LeftButton && m_press) { + const double translation = mouse->globalPosition().x() - m_press->x(); + m_press.reset(); + if (std::exchange(m_dragging, false)) + m_onReorder(TabDragPhase::ended, translation); + } + return QWidget::eventFilter(watched, event); +} + // Another tab's layer, unless Alt duplicates it, or files. bool ProjectTabButton::acceptsDrop(const QMimeData &data) const { @@ -237,82 +261,74 @@ void NewCanvasButton::dropEvent(QDropEvent *event) m_workspace.receiveProviders(*event->mimeData(), std::nullopt); } -ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent) - : QScrollArea(parent), m_workspace(workspace), m_row(new QWidget(this)) +OverflowTabsPill::OverflowTabsPill(ProjectWorkspace &workspace, QWidget *parent) : QToolButton(parent), m_workspace(workspace) { - setObjectName(QStringLiteral("projectTabs")); - setAccessibleName(QStringLiteral("Project tabs")); - setFixedHeight(34); - setFrameShape(QFrame::NoFrame); - // The toolbar shows through; a stylesheet would freeze the palette. - setAutoFillBackground(false); - viewport()->setAutoFillBackground(false); - setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - auto *row = new QHBoxLayout(m_row); - row->setContentsMargins(0, 3, 0, 3); - row->setSpacing(6); - row->addStretch(1); - setWidget(m_row); - // setWidget turns the row's fill on; the toolbar shows through. - m_row->setAutoFillBackground(false); - m_row->installEventFilter(this); - connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectTabStrip::synchronize); - synchronize(); + setObjectName(QStringLiteral("projectTabsOverflow")); + setFocusPolicy(Qt::NoFocus); + QFont font; + font.setPixelSize(12); + font.setWeight(QFont::Medium); + setFont(font); + connect(this, &QToolButton::pressed, this, &OverflowTabsPill::showMenu); } -QList ProjectTabStrip::buttons() const +double OverflowTabsPill::pillWidth(int hiddenCount) { - QList result; - for (int index = 0; index < m_row->layout()->count(); ++index) { - if (auto *button = qobject_cast(m_row->layout()->itemAt(index)->widget())) - result << button; - } - return result; + QFont font; + font.setPixelSize(12); + font.setWeight(QFont::Medium); + // Leading, gap before the chevron, chevron, trailing. + return std::ceil(QFontMetricsF(font).horizontalAdvance(projectTabOverflowLabel(hiddenCount))) + 11 + 4 + 10 + 11; } -void ProjectTabStrip::synchronize() +void OverflowTabsPill::setHiddenIDs(std::vector hiddenIDs) { - // Buttons follow the tabs: kept where the tab stays. - auto *row = static_cast(m_row->layout()); - QList shown = buttons(); - int position = 0; - for (const std::shared_ptr &tab : m_workspace.tabs()) { - const auto kept = std::find_if(shown.begin(), shown.end(), [&](ProjectTabButton *button) { return button->tab == tab; }); - ProjectTabButton *button = kept == shown.end() ? new ProjectTabButton(m_workspace, tab, m_row) : *kept; - if (kept != shown.end()) - shown.erase(kept); - row->removeWidget(button); - row->insertWidget(position, button); - button->show(); - button->synchronize(); - position += 1; - } - for (ProjectTabButton *gone : shown) - delete gone; - // The row is as wide as its tabs, never squeezed. - m_row->resize(m_row->sizeHint()); - showFront(); + m_hiddenIDs = std::move(hiddenIDs); + const QString label = projectTabOverflowLabel(int(m_hiddenIDs.size())); + setText(label); + setToolTip(label); + setAccessibleName(label); + update(); } -bool ProjectTabStrip::eventFilter(QObject *watched, QEvent *event) +// Below the pill: hidden tabs in order, unsaved marked. +void OverflowTabsPill::showMenu() { - // A title changed size: the row takes its new width. - if (event->type() == QEvent::LayoutRequest) - m_row->resize(m_row->sizeHint()); - return QScrollArea::eventFilter(watched, event); -} - -// Only a new front tab scrolls into view. -void ProjectTabStrip::showFront() -{ - if (std::exchange(m_shownFront, m_workspace.selectedID()) == m_workspace.selectedID()) + setDown(false); + if (!m_workspace.canSwitch()) return; - // Posted, as Swift's: the row's layout request runs first. - QTimer::singleShot(0, this, [this] { - for (ProjectTabButton *button : buttons()) { - if (button->tab->id == m_workspace.selectedID()) - ensureWidgetVisible(button, 0, 0); - } - }); + auto *menu = new QMenu(this); + menu->setObjectName(QStringLiteral("projectTabsOverflowMenu")); + menu->setAttribute(Qt::WA_DeleteOnClose); + for (const QUuid &id : m_hiddenIDs) { + const std::shared_ptr tab = m_workspace.tab(id); + const QString title = (tab->session.isModified() ? QStringLiteral("• ") : QString()) + tab->title(); + connect(menu->addAction(title), &QAction::triggered, this, [this, id] { m_workspace.select(id); }); + } + menu->popup(mapToGlobal(QPoint(0, height() + 4))); +} + +void OverflowTabsPill::paintEvent(QPaintEvent *) +{ + // An inactive tab's capsule; the label dims while busy. + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + QColor fill = palette().color(QPalette::WindowText), edge = fill; + fill.setAlphaF(0.035); + edge.setAlphaF(0.08); + painter.setPen(QPen(edge, 1)); + painter.setBrush(fill); + painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 14, 14); + painter.setOpacity(m_workspace.canSwitch() ? 1 : 0.5); + painter.setPen(palette().color(QPalette::ButtonText)); + painter.drawText(QRectF(11, 0, width() - 36, height()), Qt::AlignLeft | Qt::AlignVCenter, text()); + // Swift's chevron.down, nine points, medium. + QPainterPath chevron; + const double left = width() - 21, middle = height() / 2.0; + chevron.moveTo(left + 1, middle - 2); + chevron.lineTo(left + 5, middle + 2); + chevron.lineTo(left + 9, middle - 2); + painter.setPen(QPen(palette().color(QPalette::ButtonText), 1.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + painter.drawPath(chevron); } diff --git a/src/UI/ProjectTabs.h b/src/UI/ProjectTabs.h index 300f4eb..5e12842 100644 --- a/src/UI/ProjectTabs.h +++ b/src/UI/ProjectTabs.h @@ -1,14 +1,18 @@ #pragma once #include "Document/ProjectWorkspace.h" -#include +#include "UI/ProjectTabLayout.h" #include #include +// Swift's TabDragPhase: a reorder drag goes on, or ends. +enum class TabDragPhase { changed, ended }; + // One tab: its title, the unsaved dot, its close button. class ProjectTabButton : public QWidget { Q_OBJECT public: - ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr tab, QWidget *parent = nullptr); + ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr tab, std::function onReorder, + QWidget *parent = nullptr); const std::shared_ptr tab; // What the session and the workspace hold, shown. @@ -16,6 +20,7 @@ public: bool isActive() const; protected: + bool eventFilter(QObject *watched, QEvent *event) override; void paintEvent(QPaintEvent *event) override; void dragEnterEvent(QDragEnterEvent *event) override; void dragMoveEvent(QDragMoveEvent *event) override; @@ -27,9 +32,13 @@ private: void setTargeted(bool targeted); ProjectWorkspace &m_workspace; + const std::function m_onReorder; QToolButton *const m_select; QToolButton *const m_close; bool m_targeted = false; + // Swift's DragGesture: the press, in the window's space. + std::optional m_press; + bool m_dragging = false; }; // Swift's NewProjectDropTarget: the New canvas button takes drops. @@ -53,23 +62,67 @@ private: bool m_targeted = false; }; -// The row of tabs, one a project, scrolling when long. -class ProjectTabStrip : public QScrollArea { +// Swift's OverflowTabsPill: the tabs that do not fit, a menu. +class OverflowTabsPill : public QToolButton { + Q_OBJECT +public: + explicit OverflowTabsPill(ProjectWorkspace &workspace, QWidget *parent = nullptr); + + // Swift's projectTabOverflowPillWidth. + static double pillWidth(int hiddenCount); + void setHiddenIDs(std::vector hiddenIDs); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + void showMenu(); + + ProjectWorkspace &m_workspace; + std::vector m_hiddenIDs; +}; + +// The row of tabs; the rest gather in a menu. +class ProjectTabStrip : public QWidget { Q_OBJECT public: explicit ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent = nullptr); QList buttons() const; + OverflowTabsPill *pill() const { return m_pill; } + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; protected: - bool eventFilter(QObject *watched, QEvent *event) override; + void resizeEvent(QResizeEvent *event) override; private: + // Swift's TabReorderState, taken once as the drag starts. + struct Reorder { + QUuid id; + std::vector others; + std::map widths; + std::map compactedX; + double startX; + double originX; + double translation = 0; + int targetIndex = 0; + int nearestSlot() const; + }; + void synchronize(); - void showFront(); + std::map widths() const; + ProjectTabOverflow overflow() const; + // Places the pill and the tabs; others slide when `animated`. + void layOut(bool animated = false); + double renderX(const ProjectTabSlot &slot, double contentWidth) const; + void handleReorder(QUuid id, TabDragPhase phase, double translation); + Reorder makeReorder(QUuid id) const; + void commitReorder(const Reorder &state); ProjectWorkspace &m_workspace; - QWidget *const m_row; - // Swift's lastSelectedID: only a new front tab scrolls. - std::optional m_shownFront; + OverflowTabsPill *const m_pill; + std::vector m_buttons; + std::optional m_reorder; }; diff --git a/src/UI/ProjectWorkspaceView.cpp b/src/UI/ProjectWorkspaceView.cpp index 20fb486..30ee8ff 100644 --- a/src/UI/ProjectWorkspaceView.cpp +++ b/src/UI/ProjectWorkspaceView.cpp @@ -26,11 +26,8 @@ ProjectWorkspaceView::ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget auto *newButton = new NewCanvasButton(m_workspace, m_toolbar); newButton->setDefaultAction(m_newCanvas); m_toolbar->addWidget(newButton); - m_toolbar->addWidget(m_tabs); // The strip takes the toolbar's free width; zooms stay right. - auto *spacer = new QWidget(m_toolbar); - spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - m_toolbar->addWidget(spacer); + m_toolbar->addWidget(m_tabs); m_fit = action(m_toolbar, QStringLiteral("Fit"), QStringLiteral("Fit canvas in window (Ctrl+0)"), QStringLiteral("fitCanvas")); m_actualPixels = action(m_toolbar, QStringLiteral("100%"), QStringLiteral("Actual pixels (Ctrl+1)"), QStringLiteral("actualPixels")); m_zoomIn = action(m_toolbar, QStringLiteral("+"), QStringLiteral("Zoom in (Ctrl++)"), QStringLiteral("zoomIn")); diff --git a/tests/ProjectTabDragTests.cpp b/tests/ProjectTabDragTests.cpp new file mode 100644 index 0000000..da95801 --- /dev/null +++ b/tests/ProjectTabDragTests.cpp @@ -0,0 +1,278 @@ +#include "UI/ProjectTabs.h" +#include +#include +#include + +// Swift 1.3's strip: tabs drag to reorder; others gather. +namespace { +QToolButton &select(ProjectTabButton &button) +{ + return *button.findChild(QStringLiteral("selectTab")); +} + +QStringList titles(const ProjectWorkspace &workspace) +{ + QStringList result; + for (const std::shared_ptr &tab : workspace.tabs()) + result << tab->title(); + return result; +} + +ProjectTabButton &button(ProjectTabStrip &strip, const QString &title) +{ + for (ProjectTabButton *button : strip.buttons()) { + if (button->tab->title() == title) + return *button; + } + throw std::runtime_error("no such tab"); +} + +// Three tabs, Untitled to Untitled 3, the last in front. +struct Strip { + ProjectWorkspace workspace; + ProjectTabStrip strip{workspace}; + Strip(int tabs = 3, int width = 1000) + { + for (int index = 1; index < tabs; ++index) + workspace.newCanvas(); + strip.resize(width, 34); + strip.show(); + if (!QTest::qWaitForWindowExposed(&strip)) + throw std::runtime_error("the strip never showed"); + } + // QTest sends to a widget, not to the press's grab. + QToolButton *pressed = nullptr; + // Presses a tab; returns the point, in the strip. + QPoint press(const QString &title) + { + ProjectTabButton &tab = button(strip, title); + const QPoint point = tab.mapTo(&strip, QPoint(20, 14)); + pressed = &select(tab); + QTest::mousePress(pressed, Qt::LeftButton, {}, QPoint(20 - pressed->x(), 14)); + return point; + } + void move(QPoint point) { QTest::mouseMove(pressed, pressed->mapFrom(&strip, point)); } + // The release goes to the pressed title wherever it is. + void release(const QString &title, QPoint point) + { + QToolButton &title_ = select(button(strip, title)); + QTest::mouseRelease(&title_, Qt::LeftButton, {}, title_.mapFrom(&strip, point)); + } +}; + +// Every slide finished: tabs rest at their slots. +bool settled(const ProjectTabStrip &strip) +{ + return strip.findChildren().isEmpty(); +} +} + +class ProjectTabDragTests : public QObject { + Q_OBJECT +private slots: + void aDraggedTabFollowsThePointerAndLandsInTheGap(); + void aSmallOrUpwardMoveIsAClick(); + void aBusyProjectHoldsTheOrder(); + void aTabClosedMidDragEndsIt(); + void tabsThatDoNotFitGatherInAMenu(); + void theMenuSwitchesAndPinsTheChosenTab(); + void aBusyProjectOpensNoMenu(); +}; + +void ProjectTabDragTests::aDraggedTabFollowsThePointerAndLandsInTheGap() +{ + Strip fixture; + ProjectTabButton &first = button(fixture.strip, "Untitled"), &second = button(fixture.strip, "Untitled 2"), + &third = button(fixture.strip, "Untitled 3"); + QCOMPARE(second.x(), first.width() + 6); + const QPoint start = fixture.press("Untitled"); + // Two points stay a press; three drag, and select. + fixture.move(start + QPoint(2, 0)); + QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[2]->id); + fixture.move(start + QPoint(40, 0)); + QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[0]->id); + QCOMPARE(first.x(), 40); + // Chosen, it is set semibold: widths read now. + const int w1 = first.width(), w2 = second.width(), w3 = third.width(); + // Nearest the second's compacted place still: nothing slides. + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(second.x(), w1 + 6); + // Past the middle of the gap: the second slides left. + fixture.move(start + QPoint(w2 / 2 + 10, 0)); + const auto *slide = second.findChild(); + QVERIFY(slide && slide->duration() == 150 && slide->easingCurve() == QEasingCurve::OutQuad); + QCOMPARE(slide->endValue().toPoint(), QPoint(0, 3)); + QVERIFY(!first.findChild() && !third.findChild()); + // The dragged tab lies over the one it crosses. + QWidget *hit = fixture.strip.childAt(first.x() + 2, 14); + QVERIFY(hit == &first || first.isAncestorOf(hit)); + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(second.x(), 0); + QCOMPARE(third.x(), w1 + 6 + w2 + 6); + // Far right: held at the row's end, the others compacted. + fixture.move(start + QPoint(5000, 0)); + QTRY_VERIFY(settled(fixture.strip)); + const int end = w1 + w2 + w3 + 12; + QCOMPARE(first.x(), end - w1); + QCOMPARE(third.x(), w2 + 6); + // Far left: held at the row's start. + fixture.move(start + QPoint(-5000, 0)); + QCOMPARE(first.x(), 0); + fixture.move(start + QPoint(5000, 0)); + QVERIFY(first.isVisible() && first.y() == 3); + fixture.release("Untitled", start + QPoint(5000, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 2", "Untitled 3", "Untitled"})); + QCOMPARE(fixture.workspace.selectedID(), first.tab->id); + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(second.x(), 0); + QCOMPARE(third.x(), second.width() + 6); + QCOMPARE(first.x(), third.x() + third.width() + 6); + // The front tab, dragged one place left. + const QPoint back = fixture.press("Untitled"); + fixture.move(back - QPoint(third.width() / 2 + 10, 0)); + fixture.release("Untitled", back - QPoint(third.width() / 2 + 10, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 2", "Untitled", "Untitled 3"})); + // Let go where it began: the order stays. + const QPoint still = fixture.press("Untitled 3"); + fixture.move(still + QPoint(5, 0)); + fixture.release("Untitled 3", still + QPoint(5, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 2", "Untitled", "Untitled 3"})); + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(third.x(), second.width() + first.width() + 12); +} + +void ProjectTabDragTests::aSmallOrUpwardMoveIsAClick() +{ + Strip fixture; + const QPoint start = fixture.press("Untitled"); + // Three points up, two across: no drag, no selection yet. + fixture.move(start + QPoint(2, -3)); + QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[2]->id); + QCOMPARE(button(fixture.strip, "Untitled").x(), 0); + fixture.release("Untitled", start + QPoint(2, -3)); + // The click selects; the order stays. + QCOMPARE(fixture.workspace.selectedID(), fixture.workspace.tabs()[0]->id); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"})); + // A busy project starts no drag and selects nothing. + fixture.workspace.current().session.setIsProjectBusy(true); + const QPoint busy = fixture.press("Untitled"); + fixture.move(busy + QPoint(200, 0)); + QCOMPARE(button(fixture.strip, "Untitled").x(), 0); + fixture.release("Untitled", busy + QPoint(200, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"})); +} + +void ProjectTabDragTests::aBusyProjectHoldsTheOrder() +{ + Strip fixture; + const QPoint start = fixture.press("Untitled"); + fixture.move(start + QPoint(5000, 0)); + QVERIFY(button(fixture.strip, "Untitled").x() > 100); + // Busy by the drop: the tabs go back. + fixture.workspace.current().session.setIsProjectBusy(true); + fixture.release("Untitled", start + QPoint(5000, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled", "Untitled 2", "Untitled 3"})); + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(button(fixture.strip, "Untitled").x(), 0); + QCOMPARE(button(fixture.strip, "Untitled 2").x(), button(fixture.strip, "Untitled").width() + 6); +} + +void ProjectTabDragTests::aTabClosedMidDragEndsIt() +{ + Strip fixture; + const QPoint start = fixture.press("Untitled 2"); + fixture.move(start + QPoint(5000, 0)); + QVERIFY(button(fixture.strip, "Untitled 2").x() > 100); + fixture.workspace.removeTab(fixture.workspace.tabs()[1]->id); + QCOMPARE(fixture.strip.buttons().size(), 2); + QTRY_VERIFY(settled(fixture.strip)); + QCOMPARE(button(fixture.strip, "Untitled 3").x(), button(fixture.strip, "Untitled").width() + 6); + // Another drag works at once. + const QPoint next = fixture.press("Untitled 3"); + fixture.move(next - QPoint(5000, 0)); + fixture.release("Untitled 3", next - QPoint(5000, 0)); + QCOMPARE(titles(fixture.workspace), (QStringList{"Untitled 3", "Untitled"})); +} + +void ProjectTabDragTests::tabsThatDoNotFitGatherInAMenu() +{ + Strip fixture(12, 400); + OverflowTabsPill &pill = *fixture.strip.pill(); + QVERIFY(pill.isVisible()); + QCOMPARE(pill.objectName(), QString("projectTabsOverflow")); + QList shown; + for (ProjectTabButton *tab : fixture.strip.buttons()) { + if (tab->isVisible()) + shown << tab; + } + const int hidden = 12 - int(shown.size()); + QVERIFY(hidden > 0 && shown.size() >= 2); + QCOMPARE(pill.text(), QStringLiteral("%1 more tabs").arg(hidden)); + QCOMPARE(pill.toolTip(), pill.text()); + QCOMPARE(pill.accessibleName(), pill.text()); + QCOMPARE(pill.geometry(), QRect(0, 3, int(OverflowTabsPill::pillWidth(hidden)), 28)); + // The right-most tabs stay, the front one last. + QCOMPARE(shown.last()->tab->id, fixture.workspace.selectedID()); + QCOMPARE(shown.first()->tab->id, fixture.workspace.tabs()[size_t(hidden)]->id); + QCOMPARE(shown.first()->x(), pill.width() + 6); + QVERIFY(shown.last()->geometry().right() < 400); + // Swift's measure: the label, 11, 4, 10, 11. + QFont font; + font.setPixelSize(12); + font.setWeight(QFont::Medium); + QCOMPARE(OverflowTabsPill::pillWidth(3), std::ceil(QFontMetricsF(font).horizontalAdvance("3 more tabs")) + 36); + // Wide enough, every tab shows and the pill goes. + fixture.strip.resize(3000, 34); + QVERIFY(!pill.isVisible()); + for (ProjectTabButton *tab : fixture.strip.buttons()) + QVERIFY(tab->isVisible()); + QCOMPARE(fixture.strip.buttons().first()->x(), 0); +} + +void ProjectTabDragTests::theMenuSwitchesAndPinsTheChosenTab() +{ + Strip fixture(12, 400); + OverflowTabsPill &pill = *fixture.strip.pill(); + fixture.workspace.tabs()[1]->session.createDocument(8, 8); + QVERIFY(fixture.workspace.tabs()[1]->session.isModified()); + QTest::mousePress(&pill, Qt::LeftButton); + auto *menu = pill.findChild("projectTabsOverflowMenu"); + QVERIFY(menu && menu->isVisible()); + QStringList entries; + for (const QAction *action : menu->actions()) + entries << action->text(); + const int hidden = int(entries.size()); + QCOMPARE(pill.text(), QStringLiteral("%1 more tabs").arg(hidden)); + QCOMPARE(entries.mid(0, 3), (QStringList{"Untitled", "• Untitled 2", "Untitled 3"})); + QCOMPARE(menu->mapToGlobal(QPoint(0, 0)), pill.mapToGlobal(QPoint(0, pill.height() + 4))); + menu->actions()[1]->trigger(); + menu->close(); + QTest::mouseRelease(&pill, Qt::LeftButton); + const QUuid chosen = fixture.workspace.tabs()[1]->id; + QCOMPARE(fixture.workspace.selectedID(), chosen); + // Pinned after the pill; the row stays inside. + ProjectTabButton &pinned = button(fixture.strip, "Untitled 2"); + QVERIFY(pinned.isVisible()); + QCOMPARE(pinned.x(), pill.width() + 6); + for (ProjectTabButton *tab : fixture.strip.buttons()) + QVERIFY(!tab->isVisible() || tab->geometry().right() < 400); + QTRY_VERIFY(!pill.findChild("projectTabsOverflowMenu")); +} + +void ProjectTabDragTests::aBusyProjectOpensNoMenu() +{ + Strip fixture(12, 400); + OverflowTabsPill &pill = *fixture.strip.pill(); + const QImage free = pill.grab().toImage(); + fixture.workspace.current().session.setIsProjectBusy(true); + pill.update(); + // The label dims; the capsule stays. + const QImage busy = pill.grab().toImage(); + QVERIFY(busy != free); + QCOMPARE(busy.pixelColor(pill.width() / 2, 1), free.pixelColor(pill.width() / 2, 1)); + QTest::mouseClick(&pill, Qt::LeftButton); + QVERIFY(!pill.findChild("projectTabsOverflowMenu")); +} + +QTEST_MAIN(ProjectTabDragTests) +#include "ProjectTabDragTests.moc" diff --git a/tests/ProjectTabLayoutTests.cpp b/tests/ProjectTabLayoutTests.cpp new file mode 100644 index 0000000..f3ba070 --- /dev/null +++ b/tests/ProjectTabLayoutTests.cpp @@ -0,0 +1,152 @@ +#include "UI/ProjectTabLayout.h" +#include + +// projectTabOverflow is geometry: made-up ids and widths. +namespace { +const QUuid a = QUuid::createUuid(), b = QUuid::createUuid(), c = QUuid::createUuid(), d = QUuid::createUuid(), e = QUuid::createUuid(); + +// Every tab 100 wide. +std::map widths(const std::vector &ids) +{ + std::map result; + for (const QUuid &id : ids) + result.insert({id, 100}); + return result; +} + +const std::function pill = [](int) { return 80.0; }; + +std::vector ids(const ProjectTabOverflow &layout) +{ + std::vector result; + for (const ProjectTabSlot &slot : layout.visible) + result.push_back(slot.id); + return result; +} + +std::vector xs(const ProjectTabOverflow &layout) +{ + std::vector result; + for (const ProjectTabSlot &slot : layout.visible) + result.push_back(slot.x); + return result; +} +} + +class ProjectTabLayoutTests : public QObject { + Q_OBJECT +private slots: + void everythingFitsShowsAllTabsWithNoPill(); + void overflowHidesTheLeftmostTabsAndPillsThem(); + void selectedTabInTheHiddenSetIsPinnedRightOfThePill(); + void overflowLabelIsSingularForOneTab(); + void unmeasuredWidthShowsEverythingRatherThanGuessing(); + void aWiderSelectedTabStillFits(); + void thePillsWidthFollowsTheHiddenCount(); +}; + +void ProjectTabLayoutTests::everythingFitsShowsAllTabsWithNoPill() +{ + const std::vector order{a, b, c}; + const ProjectTabOverflow result = projectTabOverflow(order, widths(order), c, 400, pill); + QVERIFY(result.hiddenIDs.empty()); + QVERIFY(!result.pill); + QVERIFY(ids(result) == order); + QVERIFY((xs(result) == std::vector{0, 106, 212})); + QCOMPARE(result.contentWidth(), 312.0); + // Exactly the room it needs still fits. + QVERIFY(!projectTabOverflow(order, widths(order), c, 312, pill).pill); + QVERIFY(projectTabOverflow(order, widths(order), c, 311.5, pill).pill); + QVERIFY(projectTabOverflow({}, {}, c, 400, pill) == ProjectTabOverflow()); + QCOMPARE(ProjectTabOverflow().contentWidth(), 0.0); +} + +void ProjectTabLayoutTests::overflowHidesTheLeftmostTabsAndPillsThem() +{ + const std::vector order{a, b, c, d, e}; + // The pill and two tabs: 292; a third needs 398. + const ProjectTabOverflow result = projectTabOverflow(order, widths(order), e, 300, pill); + QVERIFY((result.hiddenIDs == std::vector{a, b, c})); + QVERIFY((ids(result) == std::vector{d, e})); + QVERIFY((result.pill.value() == ProjectTabPillSlot{0, 80})); + QCOMPARE(result.visible.front().x, 86.0); + QCOMPARE(result.visible.front().width, 100.0); + QCOMPARE(result.contentWidth(), 292.0); + // 292 exactly still holds two; a third at 398. + QCOMPARE(projectTabOverflow(order, widths(order), e, 292, pill).visible.size(), size_t(2)); + QCOMPARE(projectTabOverflow(order, widths(order), e, 398, pill).visible.size(), size_t(3)); + // Too narrow for even one: one tab stays. + QVERIFY((ids(projectTabOverflow(order, widths(order), e, 50, pill)) == std::vector{e})); +} + +void ProjectTabLayoutTests::selectedTabInTheHiddenSetIsPinnedRightOfThePill() +{ + const std::vector order{a, b, c, d, e}; + // a takes the first slot; d, bumped, hides in order. + const ProjectTabOverflow result = projectTabOverflow(order, widths(order), a, 300, pill); + QVERIFY((ids(result) == std::vector{a, e})); + QVERIFY((result.hiddenIDs == std::vector{b, c, d})); + QCOMPARE(result.pill.value().width, 80.0); + QVERIFY((xs(result) == std::vector{86, 192})); +} + +void ProjectTabLayoutTests::overflowLabelIsSingularForOneTab() +{ + QCOMPARE(projectTabOverflowLabel(1), QString("1 more tab")); + QCOMPARE(projectTabOverflowLabel(2), QString("2 more tabs")); + QCOMPARE(projectTabOverflowLabel(11), QString("11 more tabs")); +} + +void ProjectTabLayoutTests::unmeasuredWidthShowsEverythingRatherThanGuessing() +{ + const std::vector order{a, b, c, d, e}; + const ProjectTabOverflow result = projectTabOverflow(order, widths(order), a, 0, pill); + QVERIFY(result.hiddenIDs.empty()); + QVERIFY(!result.pill); + QCOMPARE(result.visible.size(), size_t(5)); +} + +// A long-titled hidden tab still leaves a row that fits. +void ProjectTabLayoutTests::aWiderSelectedTabStillFits() +{ + std::vector order; + std::map sizes; + for (int index = 0; index < 8; ++index) { + order.push_back(QUuid::createUuid()); + sizes.insert({order.back(), 80}); + } + sizes[order[0]] = 190; + const ProjectTabOverflow layout = projectTabOverflow(order, sizes, order[0], 500, [](int) { return 90.0; }); + QVERIFY2(layout.contentWidth() <= 500, qPrintable(QString::number(layout.contentWidth()))); + QCOMPARE(layout.visible.front().id, order[0]); + // Pill 90, 190, two of 80; three would make 522. + QVERIFY((ids(layout) == std::vector{order[0], order[6], order[7]})); + QVERIFY((layout.hiddenIDs == std::vector{order[1], order[2], order[3], order[4], order[5]})); + QCOMPARE(layout.contentWidth(), 90 + 6 + 190 + 6 + 80 + 6 + 80.0); + // Wide enough, it stands alone after the pill. + const std::vector five{a, b, c, d, e}; + std::map wide = widths(five); + wide[a] = 290; + const ProjectTabOverflow alone = projectTabOverflow(five, wide, a, 400, pill); + QVERIFY((ids(alone) == std::vector{a})); + QVERIFY((alone.hiddenIDs == std::vector{b, c, d, e})); +} + +// The pill's width is asked by its hidden count. +void ProjectTabLayoutTests::thePillsWidthFollowsTheHiddenCount() +{ + const std::vector order{a, b, c, d, e}; + std::vector asked; + const ProjectTabOverflow result = projectTabOverflow(order, widths(order), e, 300, [&](int hidden) { + asked.push_back(hidden); + return 50.0 + hidden; + }); + // Three hidden fits (265); the last ask places it. + QVERIFY((asked == std::vector{0, 1, 2, 3, 3})); + QVERIFY((ids(result) == std::vector{d, e})); + QCOMPARE(result.pill.value().width, 53.0); + QCOMPARE(result.visible.front().x, 59.0); +} + +QTEST_GUILESS_MAIN(ProjectTabLayoutTests) +#include "ProjectTabLayoutTests.moc" diff --git a/tests/ProjectTabStripTests.cpp b/tests/ProjectTabStripTests.cpp index d2a5d6e..2345943 100644 --- a/tests/ProjectTabStripTests.cpp +++ b/tests/ProjectTabStripTests.cpp @@ -1,6 +1,5 @@ #include "UI/ProjectTabs.h" #include -#include #include #include @@ -26,7 +25,6 @@ class ProjectTabStripTests : public QObject { private slots: void eachTabIsAsWideAsItsTitle(); void aTitleMovesOnlyTheTabsAfterIt(); - void onlyANewFrontTabScrollsIntoView(); }; void ProjectTabStripTests::eachTabIsAsWideAsItsTitle() @@ -103,44 +101,5 @@ void ProjectTabStripTests::aTitleMovesOnlyTheTabsAfterIt() QCOMPARE(buttons[1]->x() - (buttons[0]->x() + buttons[0]->width()), 6); } -void ProjectTabStripTests::onlyANewFrontTabScrollsIntoView() -{ - ProjectWorkspace workspace; - for (int tab = 0; tab < 11; ++tab) - workspace.newCanvas(); - ProjectTabStrip strip(workspace); - strip.resize(300, 34); - strip.show(); - QVERIFY(QTest::qWaitForWindowExposed(&strip)); - QScrollBar &bar = *strip.horizontalScrollBar(); - // Scrolled back, another tab's change keeps the offset. - QTRY_VERIFY(bar.value() == bar.maximum() && bar.maximum() > 0); - // Scrolled back by hand, another tab's change keeps the offset. - bar.setValue(40); - workspace.tabs()[3]->session.setProjectPath(QStringLiteral("/p/Renamed.comp")); - QCoreApplication::processEvents(); - QCOMPARE(bar.value(), 40); - // The front tab turns unsaved: a dot, the offset kept. - workspace.current().session.createDocument(8, 8); - workspace.current().session.history.markSaved(); - QVERIFY(!workspace.current().session.isModified()); - workspace.current().session.addBlankLayer(); - QVERIFY(workspace.current().session.isModified()); - QCoreApplication::processEvents(); - QVERIFY(select(*strip.buttons().last()).text().startsWith(QStringLiteral("●"))); - QCOMPARE(bar.value(), 40); - // A tab closed behind is no new front tab either. - const QUuid front = workspace.selectedID(); - workspace.removeTab(workspace.tabs()[5]->id); - QCOMPARE(workspace.selectedID(), front); - QCoreApplication::processEvents(); - QCOMPARE(bar.value(), 40); - // Choosing another tab brings it in, as little as needed. - workspace.select(workspace.tabs()[0]->id); - QTRY_COMPARE(bar.value(), 0); - workspace.select(workspace.tabs()[1]->id); - QCOMPARE(bar.value(), 0); -} - QTEST_MAIN(ProjectTabStripTests) #include "ProjectTabStripTests.moc" diff --git a/tests/ProjectWorkspaceTests.cpp b/tests/ProjectWorkspaceTests.cpp index 393529e..87f26ab 100644 --- a/tests/ProjectWorkspaceTests.cpp +++ b/tests/ProjectWorkspaceTests.cpp @@ -42,6 +42,7 @@ private slots: void everyChangeIsAnnouncedAndRefusalsAreSilent(); void aDropThatIsNoLayerIdIsIgnored(); void anOpenFilterHoldsTheSwitch(); + void moveTabReordersWithoutTouchingSelectionOrDocuments(); }; void ProjectWorkspaceTests::newCanvasOpensAnEmptyTabWithoutAModal() @@ -358,5 +359,36 @@ void ProjectWorkspaceTests::anOpenFilterHoldsTheSwitch() QVERIFY(workspace.canSwitch()); } +void ProjectWorkspaceTests::moveTabReordersWithoutTouchingSelectionOrDocuments() +{ + ProjectWorkspace workspace; + const QUuid a = workspace.current().id, b = workspace.addTab(false).id, c = workspace.addTab(false).id; + const auto order = [&] { + std::vector ids; + for (const std::shared_ptr &tab : workspace.tabs()) + ids.push_back(tab->id); + return ids; + }; + QSignalSpy changed(&workspace, &ProjectWorkspace::changed); + workspace.moveTab(c, 0); + QVERIFY((order() == std::vector{c, a, b})); + workspace.moveTab(a, 2); + QVERIFY((order() == std::vector{c, b, a})); + QCOMPARE(changed.size(), 2); + // Out of range clamps to the ends. + workspace.moveTab(c, 99); + QVERIFY((order() == std::vector{b, a, c})); + workspace.moveTab(c, -4); + QVERIFY((order() == std::vector{c, b, a})); + QCOMPARE(changed.size(), 4); + // Where it is, or no tab: nothing, silently. + workspace.moveTab(a, 2); + workspace.moveTab(QUuid::createUuid(), 0); + QVERIFY((order() == std::vector{c, b, a})); + QCOMPARE(changed.size(), 4); + // Chrome: the selection stays. + QCOMPARE(workspace.selectedID(), c); +} + QTEST_MAIN(ProjectWorkspaceTests) #include "ProjectWorkspaceTests.moc" diff --git a/tests/ProjectWorkspaceViewTests.cpp b/tests/ProjectWorkspaceViewTests.cpp index a62448a..696cd78 100644 --- a/tests/ProjectWorkspaceViewTests.cpp +++ b/tests/ProjectWorkspaceViewTests.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include // The window: tabs, the toolbar, closing and quitting. @@ -203,29 +204,27 @@ void ProjectWorkspaceViewTests::tabsShowTheirTitlesDotsAndState() late.session.setProjectPath(QString("/x/Late.comp")); workspace.select(workspace.tabs()[0]->id); QCOMPARE(titles(strip), (QStringList{"● Untitled", "Untitled 2", "Late"})); - // More tabs than fit: the front one scrolls into view. + // More tabs than fit: the rest gather under the pill. QVERIFY(QTest::qWaitForWindowExposed(&window)); - for (int index = 0; index < 8; ++index) + for (int index = 0; index < 12; ++index) workspace.addTab(false).session.setProjectPath(QString("/x/Wide project number %1.comp").arg(index)); const ProjectTabButton *last = strip.buttons().last(); const auto wholeInView = [&](const ProjectTabButton *button) { - return strip.viewport()->rect().contains(QRect(button->mapTo(strip.viewport(), QPoint(0, 0)), button->size())); + return button->isVisible() && strip.rect().contains(button->geometry()); }; - // In view once laid out, as Swift posts its reveal. QVERIFY(last->tab->id == workspace.selectedID()); - QTRY_VERIFY(wholeInView(last)); - QVERIFY(strip.widget()->width() > strip.viewport()->width()); - QVERIFY(last->findChild("selectTab")->width() >= 35 && strip.horizontalScrollBar()->maximum() > 0); - // Since 1.2.4 a growing title keeps the offset. - const int offset = strip.horizontalScrollBar()->value(); + QVERIFY(wholeInView(last)); + QVERIFY(strip.pill()->isVisible() && !strip.buttons().first()->isVisible()); + // The strip ends before the zoom buttons. + const QWidget &fitButton = *window.findChild("toolbar")->widgetForAction(&action(window, "fitCanvas")); + QVERIFY(strip.mapTo(&window, QPoint(strip.width(), 0)).x() <= fitButton.mapTo(&window, QPoint()).x()); + // A growing title keeps the front tab whole. workspace.tabs().back()->session.setProjectPath(QString("/x/") + QString(60, QLatin1Char('W')) + ".comp"); - QCoreApplication::processEvents(); - QCoreApplication::processEvents(); QVERIFY(last->width() > 180); - QCOMPARE(strip.horizontalScrollBar()->value(), offset); + QVERIFY(wholeInView(last)); workspace.select(workspace.tabs()[0]->id); const ProjectTabButton *head = strip.buttons().first(); - QTRY_VERIFY(wholeInView(head)); + QVERIFY(wholeInView(head)); } void ProjectWorkspaceViewTests::tabButtonsSelectAndClose() diff --git a/tests/Tests.cmake b/tests/Tests.cmake index ea6e3a5..a62c962 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -198,6 +198,8 @@ compositor_test(ProjectWorkspaceSignalTests) compositor_test(ProjectWorkspaceTests) compositor_test(ProjectWorkspaceViewTests) compositor_test(ProjectTabStripTests) +compositor_test(ProjectTabDragTests) +compositor_test(ProjectTabLayoutTests) compositor_test(KeyboardZoomTests) # It starts the real app beside itself. add_dependencies(ProjectWorkspaceViewTests omaphoto)