Project tabs drag to reorder; tabs that don't fit gather in a menu (13.14)
This commit is contained in:
1 parent
0616f9b006
commit
3f2b99f045
15 files changed
+957
-138
No files matched your search
@@ -156,6 +156,8 @@ add_library(compositor_core STATIC
|
|||||||
src/UI/RawDevelopSheet.cpp
|
src/UI/RawDevelopSheet.cpp
|
||||||
src/UI/OmarchyTheme.cpp
|
src/UI/OmarchyTheme.cpp
|
||||||
src/UI/ProjectTabs.cpp
|
src/UI/ProjectTabs.cpp
|
||||||
|
src/UI/ProjectTabs+Strip.cpp
|
||||||
|
src/UI/ProjectTabLayout.cpp
|
||||||
src/UI/ProjectWorkspaceView.cpp
|
src/UI/ProjectWorkspaceView.cpp
|
||||||
src/UI/ToolHeaderStyle.cpp
|
src/UI/ToolHeaderStyle.cpp
|
||||||
src/UI/ToolIcons.cpp
|
src/UI/ToolIcons.cpp
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
const QString ProjectWorkspace::layerType = QStringLiteral("com.compositor.layer-row");
|
const QString ProjectWorkspace::layerType = QStringLiteral("com.compositor.layer-row");
|
||||||
|
|
||||||
@@ -96,6 +97,20 @@ void ProjectWorkspace::select(QUuid id)
|
|||||||
emit changed();
|
emit changed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ProjectWorkspace::moveTab(QUuid id, int index)
|
||||||
|
{
|
||||||
|
const auto from = std::ranges::find_if(m_tabs, [id](const std::shared_ptr<ProjectTab> &tab) { return tab->id == id; });
|
||||||
|
if (from == m_tabs.end())
|
||||||
|
return;
|
||||||
|
const auto target = std::clamp<std::ptrdiff_t>(index, 0, std::ptrdiff_t(m_tabs.size()) - 1);
|
||||||
|
if (target == from - m_tabs.begin())
|
||||||
|
return;
|
||||||
|
const std::shared_ptr<ProjectTab> tab = *from;
|
||||||
|
m_tabs.erase(from);
|
||||||
|
m_tabs.insert(m_tabs.begin() + target, tab);
|
||||||
|
emit changed();
|
||||||
|
}
|
||||||
|
|
||||||
void ProjectWorkspace::newCanvas()
|
void ProjectWorkspace::newCanvas()
|
||||||
{
|
{
|
||||||
if (!canSwitch())
|
if (!canSwitch())
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ public:
|
|||||||
bool canSwitch() const;
|
bool canSwitch() const;
|
||||||
ProjectTab &addTab(bool reuseEmpty = true);
|
ProjectTab &addTab(bool reuseEmpty = true);
|
||||||
void select(QUuid id);
|
void select(QUuid id);
|
||||||
|
// A dragged tab lands at `index`, clamped; chrome, never undone.
|
||||||
|
void moveTab(QUuid id, int index);
|
||||||
void newCanvas();
|
void newCanvas();
|
||||||
// Each `done` runs from the event loop, never inline.
|
// Each `done` runs from the event loop, never inline.
|
||||||
void open(std::optional<QString> path = std::nullopt, std::function<void(bool)> done = {});
|
void open(std::optional<QString> path = std::nullopt, std::function<void(bool)> done = {});
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#include "UI/ProjectTabLayout.h"
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
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<QUuid> &order, const std::map<QUuid, double> &widths, QUuid selectedID,
|
||||||
|
double availableWidth, const std::function<double(int)> &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<QUuid> &ids, double x) {
|
||||||
|
std::vector<ProjectTabSlot> 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<QUuid> 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}};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <QString>
|
||||||
|
#include <QUuid>
|
||||||
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// 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<ProjectTabSlot> visible;
|
||||||
|
std::vector<QUuid> hiddenIDs;
|
||||||
|
std::optional<ProjectTabPillSlot> 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<QUuid> &order, const std::map<QUuid, double> &widths, QUuid selectedID,
|
||||||
|
double availableWidth, const std::function<double(int)> &pillWidth);
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
#include "UI/ProjectTabs.h"
|
||||||
|
#include <QPropertyAnimation>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
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<QUuid> order;
|
||||||
|
for (const std::shared_ptr<ProjectTab> &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<ProjectTabButton *> ProjectTabStrip::buttons() const
|
||||||
|
{
|
||||||
|
return QList<ProjectTabButton *>(m_buttons.begin(), m_buttons.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProjectTabStrip::synchronize()
|
||||||
|
{
|
||||||
|
// Buttons follow the tabs: kept where the tab stays.
|
||||||
|
std::vector<ProjectTabButton *> kept;
|
||||||
|
for (const std::shared_ptr<ProjectTab> &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<QUuid, double> ProjectTabStrip::widths() const
|
||||||
|
{
|
||||||
|
std::map<QUuid, double> result;
|
||||||
|
for (const ProjectTabButton *button : m_buttons)
|
||||||
|
result.insert({button->tab->id, button->sizeHint().width()});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectTabOverflow ProjectTabStrip::overflow() const
|
||||||
|
{
|
||||||
|
std::vector<QUuid> order;
|
||||||
|
for (const std::shared_ptr<ProjectTab> &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<QPropertyAnimation *>(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<double> 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<QUuid> order;
|
||||||
|
for (const std::shared_ptr<ProjectTab> &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<qsizetype> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
+87
-71
@@ -6,11 +6,12 @@
|
|||||||
#include <QEvent>
|
#include <QEvent>
|
||||||
#include <QFontMetricsF>
|
#include <QFontMetricsF>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QMouseEvent>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
#include <QScrollBar>
|
#include <QPainterPath>
|
||||||
#include <QStyleOptionToolButton>
|
#include <QStyleOptionToolButton>
|
||||||
#include <QStylePainter>
|
#include <QStylePainter>
|
||||||
#include <QTimer>
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
QFont tabFont(bool active)
|
QFont tabFont(bool active)
|
||||||
@@ -47,8 +48,10 @@ int labelWidth(const QString &title, bool active, bool modified)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, QWidget *parent)
|
ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, std::function<void(TabDragPhase, double)> onReorder,
|
||||||
: QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_select(new TabTitle(this)), m_close(new QToolButton(this))
|
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.
|
// 28 high: the strip's 34 less its margins.
|
||||||
m_select->setObjectName(QStringLiteral("selectTab"));
|
m_select->setObjectName(QStringLiteral("selectTab"));
|
||||||
@@ -65,6 +68,7 @@ ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<
|
|||||||
row->addWidget(m_select);
|
row->addWidget(m_select);
|
||||||
row->addWidget(m_close);
|
row->addWidget(m_close);
|
||||||
setAcceptDrops(true);
|
setAcceptDrops(true);
|
||||||
|
m_select->installEventFilter(this);
|
||||||
connect(m_select, &QToolButton::clicked, this, [this] { m_workspace.select(this->tab->id); });
|
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); });
|
connect(m_close, &QToolButton::clicked, this, [this] { m_workspace.close(this->tab->id); });
|
||||||
// The strip syncs every button on the workspace's news.
|
// The strip syncs every button on the workspace's news.
|
||||||
@@ -93,6 +97,26 @@ void ProjectTabButton::synchronize()
|
|||||||
setProperty("active", active);
|
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<QMouseEvent *>(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.
|
// Another tab's layer, unless Alt duplicates it, or files.
|
||||||
bool ProjectTabButton::acceptsDrop(const QMimeData &data) const
|
bool ProjectTabButton::acceptsDrop(const QMimeData &data) const
|
||||||
{
|
{
|
||||||
@@ -237,82 +261,74 @@ void NewCanvasButton::dropEvent(QDropEvent *event)
|
|||||||
m_workspace.receiveProviders(*event->mimeData(), std::nullopt);
|
m_workspace.receiveProviders(*event->mimeData(), std::nullopt);
|
||||||
}
|
}
|
||||||
|
|
||||||
ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent)
|
OverflowTabsPill::OverflowTabsPill(ProjectWorkspace &workspace, QWidget *parent) : QToolButton(parent), m_workspace(workspace)
|
||||||
: QScrollArea(parent), m_workspace(workspace), m_row(new QWidget(this))
|
|
||||||
{
|
{
|
||||||
setObjectName(QStringLiteral("projectTabs"));
|
setObjectName(QStringLiteral("projectTabsOverflow"));
|
||||||
setAccessibleName(QStringLiteral("Project tabs"));
|
setFocusPolicy(Qt::NoFocus);
|
||||||
setFixedHeight(34);
|
QFont font;
|
||||||
setFrameShape(QFrame::NoFrame);
|
font.setPixelSize(12);
|
||||||
// The toolbar shows through; a stylesheet would freeze the palette.
|
font.setWeight(QFont::Medium);
|
||||||
setAutoFillBackground(false);
|
setFont(font);
|
||||||
viewport()->setAutoFillBackground(false);
|
connect(this, &QToolButton::pressed, this, &OverflowTabsPill::showMenu);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QList<ProjectTabButton *> ProjectTabStrip::buttons() const
|
double OverflowTabsPill::pillWidth(int hiddenCount)
|
||||||
{
|
{
|
||||||
QList<ProjectTabButton *> result;
|
QFont font;
|
||||||
for (int index = 0; index < m_row->layout()->count(); ++index) {
|
font.setPixelSize(12);
|
||||||
if (auto *button = qobject_cast<ProjectTabButton *>(m_row->layout()->itemAt(index)->widget()))
|
font.setWeight(QFont::Medium);
|
||||||
result << button;
|
// Leading, gap before the chevron, chevron, trailing.
|
||||||
}
|
return std::ceil(QFontMetricsF(font).horizontalAdvance(projectTabOverflowLabel(hiddenCount))) + 11 + 4 + 10 + 11;
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProjectTabStrip::synchronize()
|
void OverflowTabsPill::setHiddenIDs(std::vector<QUuid> hiddenIDs)
|
||||||
{
|
{
|
||||||
// Buttons follow the tabs: kept where the tab stays.
|
m_hiddenIDs = std::move(hiddenIDs);
|
||||||
auto *row = static_cast<QHBoxLayout *>(m_row->layout());
|
const QString label = projectTabOverflowLabel(int(m_hiddenIDs.size()));
|
||||||
QList<ProjectTabButton *> shown = buttons();
|
setText(label);
|
||||||
int position = 0;
|
setToolTip(label);
|
||||||
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs()) {
|
setAccessibleName(label);
|
||||||
const auto kept = std::find_if(shown.begin(), shown.end(), [&](ProjectTabButton *button) { return button->tab == tab; });
|
update();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
setDown(false);
|
||||||
if (event->type() == QEvent::LayoutRequest)
|
if (!m_workspace.canSwitch())
|
||||||
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())
|
|
||||||
return;
|
return;
|
||||||
// Posted, as Swift's: the row's layout request runs first.
|
auto *menu = new QMenu(this);
|
||||||
QTimer::singleShot(0, this, [this] {
|
menu->setObjectName(QStringLiteral("projectTabsOverflowMenu"));
|
||||||
for (ProjectTabButton *button : buttons()) {
|
menu->setAttribute(Qt::WA_DeleteOnClose);
|
||||||
if (button->tab->id == m_workspace.selectedID())
|
for (const QUuid &id : m_hiddenIDs) {
|
||||||
ensureWidgetVisible(button, 0, 0);
|
const std::shared_ptr<ProjectTab> 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);
|
||||||
}
|
}
|
||||||
+62
-9
@@ -1,14 +1,18 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "Document/ProjectWorkspace.h"
|
#include "Document/ProjectWorkspace.h"
|
||||||
#include <QScrollArea>
|
#include "UI/ProjectTabLayout.h"
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
|
// 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.
|
// One tab: its title, the unsaved dot, its close button.
|
||||||
class ProjectTabButton : public QWidget {
|
class ProjectTabButton : public QWidget {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, QWidget *parent = nullptr);
|
ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, std::function<void(TabDragPhase, double)> onReorder,
|
||||||
|
QWidget *parent = nullptr);
|
||||||
|
|
||||||
const std::shared_ptr<ProjectTab> tab;
|
const std::shared_ptr<ProjectTab> tab;
|
||||||
// What the session and the workspace hold, shown.
|
// What the session and the workspace hold, shown.
|
||||||
@@ -16,6 +20,7 @@ public:
|
|||||||
bool isActive() const;
|
bool isActive() const;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||||
void paintEvent(QPaintEvent *event) override;
|
void paintEvent(QPaintEvent *event) override;
|
||||||
void dragEnterEvent(QDragEnterEvent *event) override;
|
void dragEnterEvent(QDragEnterEvent *event) override;
|
||||||
void dragMoveEvent(QDragMoveEvent *event) override;
|
void dragMoveEvent(QDragMoveEvent *event) override;
|
||||||
@@ -27,9 +32,13 @@ private:
|
|||||||
void setTargeted(bool targeted);
|
void setTargeted(bool targeted);
|
||||||
|
|
||||||
ProjectWorkspace &m_workspace;
|
ProjectWorkspace &m_workspace;
|
||||||
|
const std::function<void(TabDragPhase, double)> m_onReorder;
|
||||||
QToolButton *const m_select;
|
QToolButton *const m_select;
|
||||||
QToolButton *const m_close;
|
QToolButton *const m_close;
|
||||||
bool m_targeted = false;
|
bool m_targeted = false;
|
||||||
|
// Swift's DragGesture: the press, in the window's space.
|
||||||
|
std::optional<QPointF> m_press;
|
||||||
|
bool m_dragging = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Swift's NewProjectDropTarget: the New canvas button takes drops.
|
// Swift's NewProjectDropTarget: the New canvas button takes drops.
|
||||||
@@ -53,23 +62,67 @@ private:
|
|||||||
bool m_targeted = false;
|
bool m_targeted = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The row of tabs, one a project, scrolling when long.
|
// Swift's OverflowTabsPill: the tabs that do not fit, a menu.
|
||||||
class ProjectTabStrip : public QScrollArea {
|
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<QUuid> hiddenIDs);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void paintEvent(QPaintEvent *event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void showMenu();
|
||||||
|
|
||||||
|
ProjectWorkspace &m_workspace;
|
||||||
|
std::vector<QUuid> m_hiddenIDs;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The row of tabs; the rest gather in a menu.
|
||||||
|
class ProjectTabStrip : public QWidget {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
explicit ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent = nullptr);
|
explicit ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent = nullptr);
|
||||||
|
|
||||||
QList<ProjectTabButton *> buttons() const;
|
QList<ProjectTabButton *> buttons() const;
|
||||||
|
OverflowTabsPill *pill() const { return m_pill; }
|
||||||
|
|
||||||
|
QSize sizeHint() const override;
|
||||||
|
QSize minimumSizeHint() const override;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
void resizeEvent(QResizeEvent *event) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Swift's TabReorderState, taken once as the drag starts.
|
||||||
|
struct Reorder {
|
||||||
|
QUuid id;
|
||||||
|
std::vector<QUuid> others;
|
||||||
|
std::map<QUuid, double> widths;
|
||||||
|
std::map<QUuid, double> compactedX;
|
||||||
|
double startX;
|
||||||
|
double originX;
|
||||||
|
double translation = 0;
|
||||||
|
int targetIndex = 0;
|
||||||
|
int nearestSlot() const;
|
||||||
|
};
|
||||||
|
|
||||||
void synchronize();
|
void synchronize();
|
||||||
void showFront();
|
std::map<QUuid, double> 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;
|
ProjectWorkspace &m_workspace;
|
||||||
QWidget *const m_row;
|
OverflowTabsPill *const m_pill;
|
||||||
// Swift's lastSelectedID: only a new front tab scrolls.
|
std::vector<ProjectTabButton *> m_buttons;
|
||||||
std::optional<QUuid> m_shownFront;
|
std::optional<Reorder> m_reorder;
|
||||||
};
|
};
|
||||||
@@ -26,11 +26,8 @@ ProjectWorkspaceView::ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget
|
|||||||
auto *newButton = new NewCanvasButton(m_workspace, m_toolbar);
|
auto *newButton = new NewCanvasButton(m_workspace, m_toolbar);
|
||||||
newButton->setDefaultAction(m_newCanvas);
|
newButton->setDefaultAction(m_newCanvas);
|
||||||
m_toolbar->addWidget(newButton);
|
m_toolbar->addWidget(newButton);
|
||||||
m_toolbar->addWidget(m_tabs);
|
|
||||||
// The strip takes the toolbar's free width; zooms stay right.
|
// The strip takes the toolbar's free width; zooms stay right.
|
||||||
auto *spacer = new QWidget(m_toolbar);
|
m_toolbar->addWidget(m_tabs);
|
||||||
spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
|
||||||
m_toolbar->addWidget(spacer);
|
|
||||||
m_fit = action(m_toolbar, QStringLiteral("Fit"), QStringLiteral("Fit canvas in window (Ctrl+0)"), QStringLiteral("fitCanvas"));
|
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_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"));
|
m_zoomIn = action(m_toolbar, QStringLiteral("+"), QStringLiteral("Zoom in (Ctrl++)"), QStringLiteral("zoomIn"));
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
#include "UI/ProjectTabs.h"
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QPropertyAnimation>
|
||||||
|
#include <QtTest>
|
||||||
|
|
||||||
|
// Swift 1.3's strip: tabs drag to reorder; others gather.
|
||||||
|
namespace {
|
||||||
|
QToolButton &select(ProjectTabButton &button)
|
||||||
|
{
|
||||||
|
return *button.findChild<QToolButton *>(QStringLiteral("selectTab"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList titles(const ProjectWorkspace &workspace)
|
||||||
|
{
|
||||||
|
QStringList result;
|
||||||
|
for (const std::shared_ptr<ProjectTab> &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<QPropertyAnimation *>().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<QPropertyAnimation *>();
|
||||||
|
QVERIFY(slide && slide->duration() == 150 && slide->easingCurve() == QEasingCurve::OutQuad);
|
||||||
|
QCOMPARE(slide->endValue().toPoint(), QPoint(0, 3));
|
||||||
|
QVERIFY(!first.findChild<QPropertyAnimation *>() && !third.findChild<QPropertyAnimation *>());
|
||||||
|
// 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<ProjectTabButton *> 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<QMenu *>("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<QMenu *>("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<QMenu *>("projectTabsOverflowMenu"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QTEST_MAIN(ProjectTabDragTests)
|
||||||
|
#include "ProjectTabDragTests.moc"
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#include "UI/ProjectTabLayout.h"
|
||||||
|
#include <QtTest>
|
||||||
|
|
||||||
|
// 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<QUuid, double> widths(const std::vector<QUuid> &ids)
|
||||||
|
{
|
||||||
|
std::map<QUuid, double> result;
|
||||||
|
for (const QUuid &id : ids)
|
||||||
|
result.insert({id, 100});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::function<double(int)> pill = [](int) { return 80.0; };
|
||||||
|
|
||||||
|
std::vector<QUuid> ids(const ProjectTabOverflow &layout)
|
||||||
|
{
|
||||||
|
std::vector<QUuid> result;
|
||||||
|
for (const ProjectTabSlot &slot : layout.visible)
|
||||||
|
result.push_back(slot.id);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<double> xs(const ProjectTabOverflow &layout)
|
||||||
|
{
|
||||||
|
std::vector<double> 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<QUuid> 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<double>{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<QUuid> 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<QUuid>{a, b, c}));
|
||||||
|
QVERIFY((ids(result) == std::vector<QUuid>{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<QUuid>{e}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProjectTabLayoutTests::selectedTabInTheHiddenSetIsPinnedRightOfThePill()
|
||||||
|
{
|
||||||
|
const std::vector<QUuid> 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<QUuid>{a, e}));
|
||||||
|
QVERIFY((result.hiddenIDs == std::vector<QUuid>{b, c, d}));
|
||||||
|
QCOMPARE(result.pill.value().width, 80.0);
|
||||||
|
QVERIFY((xs(result) == std::vector<double>{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<QUuid> 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<QUuid> order;
|
||||||
|
std::map<QUuid, double> 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<QUuid>{order[0], order[6], order[7]}));
|
||||||
|
QVERIFY((layout.hiddenIDs == std::vector<QUuid>{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<QUuid> five{a, b, c, d, e};
|
||||||
|
std::map<QUuid, double> wide = widths(five);
|
||||||
|
wide[a] = 290;
|
||||||
|
const ProjectTabOverflow alone = projectTabOverflow(five, wide, a, 400, pill);
|
||||||
|
QVERIFY((ids(alone) == std::vector<QUuid>{a}));
|
||||||
|
QVERIFY((alone.hiddenIDs == std::vector<QUuid>{b, c, d, e}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pill's width is asked by its hidden count.
|
||||||
|
void ProjectTabLayoutTests::thePillsWidthFollowsTheHiddenCount()
|
||||||
|
{
|
||||||
|
const std::vector<QUuid> order{a, b, c, d, e};
|
||||||
|
std::vector<int> 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<int>{0, 1, 2, 3, 3}));
|
||||||
|
QVERIFY((ids(result) == std::vector<QUuid>{d, e}));
|
||||||
|
QCOMPARE(result.pill.value().width, 53.0);
|
||||||
|
QCOMPARE(result.visible.front().x, 59.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
QTEST_GUILESS_MAIN(ProjectTabLayoutTests)
|
||||||
|
#include "ProjectTabLayoutTests.moc"
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
#include "UI/ProjectTabs.h"
|
#include "UI/ProjectTabs.h"
|
||||||
#include <QFontMetricsF>
|
#include <QFontMetricsF>
|
||||||
#include <QScrollBar>
|
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
#include <QtTest>
|
#include <QtTest>
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ class ProjectTabStripTests : public QObject {
|
|||||||
private slots:
|
private slots:
|
||||||
void eachTabIsAsWideAsItsTitle();
|
void eachTabIsAsWideAsItsTitle();
|
||||||
void aTitleMovesOnlyTheTabsAfterIt();
|
void aTitleMovesOnlyTheTabsAfterIt();
|
||||||
void onlyANewFrontTabScrollsIntoView();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
void ProjectTabStripTests::eachTabIsAsWideAsItsTitle()
|
void ProjectTabStripTests::eachTabIsAsWideAsItsTitle()
|
||||||
@@ -103,44 +101,5 @@ void ProjectTabStripTests::aTitleMovesOnlyTheTabsAfterIt()
|
|||||||
QCOMPARE(buttons[1]->x() - (buttons[0]->x() + buttons[0]->width()), 6);
|
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)
|
QTEST_MAIN(ProjectTabStripTests)
|
||||||
#include "ProjectTabStripTests.moc"
|
#include "ProjectTabStripTests.moc"
|
||||||
@@ -42,6 +42,7 @@ private slots:
|
|||||||
void everyChangeIsAnnouncedAndRefusalsAreSilent();
|
void everyChangeIsAnnouncedAndRefusalsAreSilent();
|
||||||
void aDropThatIsNoLayerIdIsIgnored();
|
void aDropThatIsNoLayerIdIsIgnored();
|
||||||
void anOpenFilterHoldsTheSwitch();
|
void anOpenFilterHoldsTheSwitch();
|
||||||
|
void moveTabReordersWithoutTouchingSelectionOrDocuments();
|
||||||
};
|
};
|
||||||
|
|
||||||
void ProjectWorkspaceTests::newCanvasOpensAnEmptyTabWithoutAModal()
|
void ProjectWorkspaceTests::newCanvasOpensAnEmptyTabWithoutAModal()
|
||||||
@@ -358,5 +359,36 @@ void ProjectWorkspaceTests::anOpenFilterHoldsTheSwitch()
|
|||||||
QVERIFY(workspace.canSwitch());
|
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<QUuid> ids;
|
||||||
|
for (const std::shared_ptr<ProjectTab> &tab : workspace.tabs())
|
||||||
|
ids.push_back(tab->id);
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
QSignalSpy changed(&workspace, &ProjectWorkspace::changed);
|
||||||
|
workspace.moveTab(c, 0);
|
||||||
|
QVERIFY((order() == std::vector<QUuid>{c, a, b}));
|
||||||
|
workspace.moveTab(a, 2);
|
||||||
|
QVERIFY((order() == std::vector<QUuid>{c, b, a}));
|
||||||
|
QCOMPARE(changed.size(), 2);
|
||||||
|
// Out of range clamps to the ends.
|
||||||
|
workspace.moveTab(c, 99);
|
||||||
|
QVERIFY((order() == std::vector<QUuid>{b, a, c}));
|
||||||
|
workspace.moveTab(c, -4);
|
||||||
|
QVERIFY((order() == std::vector<QUuid>{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<QUuid>{c, b, a}));
|
||||||
|
QCOMPARE(changed.size(), 4);
|
||||||
|
// Chrome: the selection stays.
|
||||||
|
QCOMPARE(workspace.selectedID(), c);
|
||||||
|
}
|
||||||
|
|
||||||
QTEST_MAIN(ProjectWorkspaceTests)
|
QTEST_MAIN(ProjectWorkspaceTests)
|
||||||
#include "ProjectWorkspaceTests.moc"
|
#include "ProjectWorkspaceTests.moc"
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QTemporaryDir>
|
#include <QTemporaryDir>
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
|
#include <QToolBar>
|
||||||
#include <QtTest>
|
#include <QtTest>
|
||||||
|
|
||||||
// The window: tabs, the toolbar, closing and quitting.
|
// The window: tabs, the toolbar, closing and quitting.
|
||||||
@@ -203,29 +204,27 @@ void ProjectWorkspaceViewTests::tabsShowTheirTitlesDotsAndState()
|
|||||||
late.session.setProjectPath(QString("/x/Late.comp"));
|
late.session.setProjectPath(QString("/x/Late.comp"));
|
||||||
workspace.select(workspace.tabs()[0]->id);
|
workspace.select(workspace.tabs()[0]->id);
|
||||||
QCOMPARE(titles(strip), (QStringList{"● Untitled", "Untitled 2", "Late"}));
|
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));
|
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));
|
workspace.addTab(false).session.setProjectPath(QString("/x/Wide project number %1.comp").arg(index));
|
||||||
const ProjectTabButton *last = strip.buttons().last();
|
const ProjectTabButton *last = strip.buttons().last();
|
||||||
const auto wholeInView = [&](const ProjectTabButton *button) {
|
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());
|
QVERIFY(last->tab->id == workspace.selectedID());
|
||||||
QTRY_VERIFY(wholeInView(last));
|
QVERIFY(wholeInView(last));
|
||||||
QVERIFY(strip.widget()->width() > strip.viewport()->width());
|
QVERIFY(strip.pill()->isVisible() && !strip.buttons().first()->isVisible());
|
||||||
QVERIFY(last->findChild<QToolButton *>("selectTab")->width() >= 35 && strip.horizontalScrollBar()->maximum() > 0);
|
// The strip ends before the zoom buttons.
|
||||||
// Since 1.2.4 a growing title keeps the offset.
|
const QWidget &fitButton = *window.findChild<QToolBar *>("toolbar")->widgetForAction(&action(window, "fitCanvas"));
|
||||||
const int offset = strip.horizontalScrollBar()->value();
|
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");
|
workspace.tabs().back()->session.setProjectPath(QString("/x/") + QString(60, QLatin1Char('W')) + ".comp");
|
||||||
QCoreApplication::processEvents();
|
|
||||||
QCoreApplication::processEvents();
|
|
||||||
QVERIFY(last->width() > 180);
|
QVERIFY(last->width() > 180);
|
||||||
QCOMPARE(strip.horizontalScrollBar()->value(), offset);
|
QVERIFY(wholeInView(last));
|
||||||
workspace.select(workspace.tabs()[0]->id);
|
workspace.select(workspace.tabs()[0]->id);
|
||||||
const ProjectTabButton *head = strip.buttons().first();
|
const ProjectTabButton *head = strip.buttons().first();
|
||||||
QTRY_VERIFY(wholeInView(head));
|
QVERIFY(wholeInView(head));
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProjectWorkspaceViewTests::tabButtonsSelectAndClose()
|
void ProjectWorkspaceViewTests::tabButtonsSelectAndClose()
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ compositor_test(ProjectWorkspaceSignalTests)
|
|||||||
compositor_test(ProjectWorkspaceTests)
|
compositor_test(ProjectWorkspaceTests)
|
||||||
compositor_test(ProjectWorkspaceViewTests)
|
compositor_test(ProjectWorkspaceViewTests)
|
||||||
compositor_test(ProjectTabStripTests)
|
compositor_test(ProjectTabStripTests)
|
||||||
|
compositor_test(ProjectTabDragTests)
|
||||||
|
compositor_test(ProjectTabLayoutTests)
|
||||||
compositor_test(KeyboardZoomTests)
|
compositor_test(KeyboardZoomTests)
|
||||||
# It starts the real app beside itself.
|
# It starts the real app beside itself.
|
||||||
add_dependencies(ProjectWorkspaceViewTests omaphoto)
|
add_dependencies(ProjectWorkspaceViewTests omaphoto)
|
||||||
|
|||||||
Reference in new issue
Block a user