Stepped keyboard zoom and a steady tab strip (12.9b)

This commit is contained in:
雷电芽衣 committed 2026-09-28 13:18:10 -04:00
1 parent fdb62978c5
commit 9ac3b1b81c
17 files changed
+533 -38

No files matched your search

+230
View File
@@ -0,0 +1,230 @@
#include "Rendering/EditorCanvas.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/ProjectWorkspaceView.h"
#include <QLineEdit>
#include <QStandardPaths>
#include <QtTest>
// Swift's stepped keyboard zoom: the stops, the centre, the keys.
namespace {
void expectClose(double actual, double expected)
{
QVERIFY2(std::abs(actual - expected) < 0.000001, qPrintable(QString::number(actual, 'g', 12)));
}
}
class KeyboardZoomTests : public QObject {
Q_OBJECT
private slots:
void initTestCase();
void keyboardZoomKeepsPannedViewportCenterFixed();
void keyboardZoomUsesStableStopsAndClampsAtTheEnds();
void keyboardZoomRoundTripDoesNotDriftAfterTenSteps();
void keyboardZoomKeepsSmallCanvasCentered();
void keyboardZoomFromFitUsesNextStopAndLeavesFitMode();
void commandZoomUpdatesOnKeyDownAndRepeat();
};
void KeyboardZoomTests::initTestCase()
{
// Remapped keys live in QSettings: the tests' own folder.
QStandardPaths::setTestModeEnabled(true);
ShortcutSettings::shared().save({});
}
void KeyboardZoomTests::keyboardZoomKeepsPannedViewportCenterFixed()
{
EditorSession session;
session.viewport.resize(QSizeF(1000, 800), 1, std::nullopt);
session.createDocument(3000, 2000);
session.zoom(1);
session.viewport.translate(QSizeF(-620, 185));
const QPointF center = session.viewport.center();
const QSizeF canvas(3000, 2000);
const QPointF before = session.viewport.documentPoint(center, canvas);
session.zoomKeyboard(1);
const QPointF after = session.viewport.documentPoint(center, canvas);
QCOMPARE(session.viewport.zoom(), 1.25);
expectClose(before.x(), after.x());
expectClose(before.y(), after.y());
expectClose(session.viewport.viewPoint(after, canvas).x(), center.x());
expectClose(session.viewport.viewPoint(after, canvas).y(), center.y());
}
void KeyboardZoomTests::keyboardZoomUsesStableStopsAndClampsAtTheEnds()
{
EditorSession session;
session.viewport.resize(QSizeF(1000, 800), 1, std::nullopt);
session.createDocument(3000, 2000);
session.zoom(0.5);
session.zoomKeyboard(1);
expectClose(session.viewport.zoom(), 2.0 / 3.0);
session.zoomKeyboard(1);
QCOMPARE(session.viewport.zoom(), 1.0);
session.zoomKeyboard(1);
QCOMPARE(session.viewport.zoom(), 1.25);
session.zoom(CanvasViewport::keyboardZoomLevels.front());
session.zoomKeyboard(-1);
QCOMPARE(session.viewport.zoom(), CanvasViewport::keyboardZoomLevels.front());
session.zoom(CanvasViewport::keyboardZoomLevels.back());
session.zoomKeyboard(1);
QCOMPARE(session.viewport.zoom(), CanvasViewport::keyboardZoomLevels.back());
// Every stop, both ways; past the end, nothing.
const std::array expected{0.125, 1.0 / 6.0, 0.25, 1.0 / 3.0, 0.5, 2.0 / 3.0, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 12.0, 16.0};
QVERIFY(CanvasViewport::keyboardZoomLevels == expected);
session.zoom(0.1);
for (const double stop : expected) {
session.zoomKeyboard(1);
QCOMPARE(session.viewport.zoom(), stop);
}
QSignalSpy changed(&session, &EditorSession::changed);
session.zoomKeyboard(1);
session.zoomKeyboard(0);
QCOMPARE(changed.count(), 0);
session.zoom(20);
for (auto stop = expected.rbegin(); stop != expected.rend(); ++stop) {
session.zoomKeyboard(-1);
QCOMPARE(session.viewport.zoom(), *stop);
}
// Step 0 stays; between stops, the next each way.
session.zoom(0.7);
QCOMPARE(session.viewport.keyboardZoomTarget(0), 0.7);
QCOMPARE(session.viewport.keyboardZoomTarget(-1), 2.0 / 3.0);
QCOMPARE(session.viewport.keyboardZoomTarget(1), 1.0);
session.zoom(1.0000000001);
QCOMPARE(session.viewport.keyboardZoomTarget(1), 1.25);
QCOMPARE(session.viewport.keyboardZoomTarget(-1), 2.0 / 3.0);
// The tolerance from each side: its floor and its share.
session.zoom(0.9999999995);
QCOMPARE(session.viewport.keyboardZoomTarget(1), 1.25);
session.zoom(12.000000006);
QCOMPARE(session.viewport.keyboardZoomTarget(-1), 8.0);
session.zoom(0.49999999925);
QCOMPARE(session.viewport.keyboardZoomTarget(1), 2.0 / 3.0);
// No document, nothing.
EditorSession empty;
empty.zoomKeyboard(1);
QCOMPARE(empty.viewport.zoom(), 1.0);
}
void KeyboardZoomTests::keyboardZoomRoundTripDoesNotDriftAfterTenSteps()
{
EditorSession session;
session.viewport.resize(QSizeF(1200, 900), 2, std::nullopt);
session.createDocument(4000, 3000);
session.zoom(0.5);
session.viewport.translate(QSizeF(-430, 275));
const QPointF center = session.viewport.center();
const QSizeF canvas(4000, 3000);
const QPointF before = session.viewport.documentPoint(center, canvas);
for (int step = 0; step < 10; ++step)
session.zoomKeyboard(1);
for (int step = 0; step < 10; ++step)
session.zoomKeyboard(-1);
const QPointF after = session.viewport.documentPoint(center, canvas);
QCOMPARE(session.viewport.zoom(), 0.5);
expectClose(before.x(), after.x());
expectClose(before.y(), after.y());
}
void KeyboardZoomTests::keyboardZoomKeepsSmallCanvasCentered()
{
EditorSession session;
session.viewport.resize(QSizeF(1200, 900), 1, std::nullopt);
session.createDocument(200, 100);
session.zoom(0.5);
const QPointF center = session.viewport.center();
const QSizeF canvas(200, 100);
const QPointF before = session.viewport.documentPoint(center, canvas);
session.zoomKeyboard(1);
const QPointF after = session.viewport.documentPoint(center, canvas);
expectClose(session.viewport.zoom(), 2.0 / 3.0);
expectClose(before.x(), 100);
expectClose(before.y(), 50);
expectClose(before.x(), after.x());
expectClose(before.y(), after.y());
}
void KeyboardZoomTests::keyboardZoomFromFitUsesNextStopAndLeavesFitMode()
{
EditorSession session;
session.viewport.resize(QSizeF(1000, 800), 1, std::nullopt);
session.createDocument(1500, 1000);
session.fit();
const QPointF center = session.viewport.center();
const QSizeF canvas(1500, 1000);
const QPointF before = session.viewport.documentPoint(center, canvas);
session.zoomKeyboard(1);
const QPointF after = session.viewport.documentPoint(center, canvas);
expectClose(session.viewport.zoom(), 2.0 / 3.0);
QVERIFY(!session.viewport.followsFit());
expectClose(before.x(), after.x());
expectClose(before.y(), after.y());
}
void KeyboardZoomTests::commandZoomUpdatesOnKeyDownAndRepeat()
{
// The app: its menu holds Ctrl+=, Ctrl+- and keypad minus.
ProjectWorkspace workspace;
ProjectWorkspaceView window(workspace);
window.resize(900, 600);
window.show();
QVERIFY(QTest::qWaitForWindowActive(&window));
EditorSession &session = workspace.current().session;
session.createDocument(3000, 2000);
session.zoom(1);
CanvasView &canvas = *window.findChild<CanvasView *>();
canvas.setFocus();
QTRY_VERIFY(canvas.hasFocus());
QTest::keyClick(&canvas, Qt::Key_Equal, Qt::ControlModifier);
QCOMPARE(session.viewport.zoom(), 1.25);
// Ctrl++ and its repeat, and keypad plus: the canvas's filter.
QTest::keyClick(&canvas, Qt::Key_Plus, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(session.viewport.zoom(), 1.5);
QKeyEvent repeat(QEvent::KeyPress, Qt::Key_Plus, Qt::ControlModifier | Qt::ShiftModifier, QStringLiteral("+"), true);
QApplication::sendEvent(&canvas, &repeat);
QCOMPARE(session.viewport.zoom(), 2.0);
QTest::keyClick(&canvas, Qt::Key_Plus, Qt::ControlModifier | Qt::KeypadModifier);
QCOMPARE(session.viewport.zoom(), 3.0);
QTest::keyClick(&canvas, Qt::Key_Minus, Qt::ControlModifier);
QCOMPARE(session.viewport.zoom(), 2.0);
QTest::keyClick(&canvas, Qt::Key_Minus, Qt::ControlModifier | Qt::KeypadModifier);
QCOMPARE(session.viewport.zoom(), 1.5);
// Shift-minus, Alt or Meta, or no Ctrl: no zoom.
QTest::keyClick(&canvas, Qt::Key_Underscore, Qt::ControlModifier | Qt::ShiftModifier);
QTest::keyClick(&canvas, Qt::Key_Plus, Qt::ControlModifier | Qt::AltModifier);
QTest::keyClick(&canvas, Qt::Key_Plus, Qt::ControlModifier | Qt::MetaModifier);
QTest::keyClick(&canvas, Qt::Key_Plus, Qt::ShiftModifier);
QCOMPARE(session.viewport.zoom(), 1.5);
// A remapped Zoom In leaves Ctrl++ alone.
QVERIFY(ShortcutSettings::shared().save({{QStringLiteral("Menus:Zoom In"), ShortcutChord(QStringLiteral("k"), 1)}}));
QTest::keyClick(&canvas, Qt::Key_Plus, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(session.viewport.zoom(), 1.5);
ShortcutSettings::shared().save({});
// A text field keeps its keys.
auto *field = new QLineEdit(&window);
field->show();
field->setFocus();
QTRY_VERIFY(field->hasFocus());
QTest::keyClick(field, Qt::Key_Plus, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(session.viewport.zoom(), 1.5);
// No document: Ctrl++ passes on to whoever holds focus.
struct Keys : QWidget {
int count = 0;
void keyPressEvent(QKeyEvent *event) override { count += event->key() == Qt::Key_Plus; }
} *keys = new Keys;
keys->setParent(&window);
keys->setFocusPolicy(Qt::StrongFocus);
keys->show();
keys->setFocus();
QTRY_VERIFY(keys->hasFocus());
QTest::keyClick(keys, Qt::Key_Plus, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(session.viewport.zoom(), 2.0);
QCOMPARE(keys->count, 0);
session.clearProject();
QTest::keyClick(keys, Qt::Key_Plus, Qt::ControlModifier | Qt::ShiftModifier);
QCOMPARE(keys->count, 1);
}
QTEST_MAIN(KeyboardZoomTests)
#include "KeyboardZoomTests.moc"
+146
View File
@@ -0,0 +1,146 @@
#include "UI/ProjectTabs.h"
#include <QFontMetricsF>
#include <QScrollBar>
#include <QToolButton>
#include <QtTest>
// Swift 1.2.4's steady tab strip: fixed widths, a kept offset.
namespace {
QToolButton &select(ProjectTabButton &button)
{
return *button.findChild<QToolButton *>(QStringLiteral("selectTab"));
}
// Swift's projectTabLabelWidth, from the font Swift names.
int label(const QString &title, bool active)
{
QFont font;
font.setPixelSize(12);
font.setWeight(active ? QFont::DemiBold : QFont::Medium);
return int(std::min(155.0, std::max(35.0, std::ceil(QFontMetricsF(font).horizontalAdvance(title)))));
}
}
class ProjectTabStripTests : public QObject {
Q_OBJECT
private slots:
void eachTabIsAsWideAsItsTitle();
void aTitleMovesOnlyTheTabsAfterIt();
void onlyANewFrontTabScrollsIntoView();
};
void ProjectTabStripTests::eachTabIsAsWideAsItsTitle()
{
ProjectWorkspace workspace;
workspace.current().session.setProjectPath(QStringLiteral("/p/Ab.comp"));
workspace.newCanvas();
workspace.current().session.setProjectPath(QStringLiteral("/p/A title long enough to pass the pill's widest width.comp"));
workspace.newCanvas();
ProjectTabStrip strip(workspace);
const QList<ProjectTabButton *> buttons = strip.buttons();
QCOMPARE(buttons.size(), 3);
// Short: 35 at least. Long: 155, elided. Front: semibold.
QCOMPARE(select(*buttons[0]).width(), 35 + 8);
QCOMPARE(select(*buttons[1]).width(), 155 + 8);
QVERIFY(select(*buttons[1]).text().endsWith(QChar(0x2026)));
QCOMPARE(select(*buttons[2]).width(), label(QStringLiteral("Untitled 3"), true) + 8);
QCOMPARE(select(*buttons[2]).text(), QString("Untitled 3"));
// Swift's pill: label, 11 before, 8 after, 16, 5.
buttons[2]->adjustSize();
QCOMPARE(buttons[2]->width(), label(QStringLiteral("Untitled 3"), true) + 40);
// The dot: 10 for the title, its character's own width.
workspace.current().session.createDocument(8, 8);
workspace.current().session.addBlankLayer();
QVERIFY(workspace.current().session.isModified());
const QFontMetrics metrics(select(*buttons[2]).font());
QCOMPARE(select(*buttons[2]).text(), QString("● Untitled 3"));
QCOMPARE(select(*buttons[2]).width(), label(QStringLiteral("Untitled 3"), true) + 10 + 8 + metrics.horizontalAdvance(QStringLiteral("● ")) - 10);
// An unsaved long title elides within Swift's width less 10.
workspace.select(workspace.tabs()[1]->id);
workspace.current().session.createDocument(8, 8);
workspace.current().session.addBlankLayer();
const QString long_ = workspace.current().title();
const QFontMetrics bold(select(*buttons[1]).font());
QCOMPARE(select(*buttons[1]).text(), QStringLiteral("● ") + bold.elidedText(long_, Qt::ElideRight, 145));
// Chosen, a tab's weight changes its width.
workspace.select(workspace.tabs()[0]->id);
QCOMPARE(select(*buttons[2]).width(), label(QStringLiteral("Untitled 3"), false) + 10 + 8 + QFontMetrics(select(*buttons[2]).font()).horizontalAdvance(QStringLiteral("● ")) - 10);
}
void ProjectTabStripTests::aTitleMovesOnlyTheTabsAfterIt()
{
ProjectWorkspace workspace;
workspace.newCanvas();
workspace.newCanvas();
ProjectTabStrip strip(workspace);
strip.resize(1000, 34);
strip.show();
QVERIFY(QTest::qWaitForWindowExposed(&strip));
const QList<ProjectTabButton *> buttons = strip.buttons();
// Swift's leading label: a short title's ink starts left.
workspace.tabs()[0]->session.setProjectPath(QStringLiteral("/p/I.comp"));
QToolButton &title = select(*buttons[0]);
QTRY_COMPARE(title.text(), QString("I"));
const QImage drawn = title.grab().toImage();
const QColor ground = drawn.pixelColor(title.width() - 3, 2);
int left = drawn.width();
for (int y = 0; y < drawn.height(); ++y) {
for (int x = 0; x < drawn.width(); ++x) {
if (drawn.pixelColor(x, y) != ground)
left = std::min(left, x);
}
}
QVERIFY2(left <= 3 && title.width() == 43, qPrintable(QString::number(left)));
QTRY_COMPARE(buttons[0]->width(), 35 + 40);
QTRY_COMPARE(buttons[1]->x(), 75 + 6);
const int first = buttons[0]->x(), second = buttons[1]->x(), third = buttons[2]->x();
QCOMPARE(first, 0);
workspace.tabs()[1]->session.setProjectPath(QStringLiteral("/p/A much longer title here.comp"));
QTRY_VERIFY(buttons[2]->x() > third);
QCOMPARE(buttons[0]->x(), first);
QCOMPARE(buttons[1]->x(), second);
// Tabs sit six apart.
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"
+13 -13
View File
@@ -156,13 +156,14 @@ void ProjectWorkspaceViewTests::tabsShowTheirTitlesDotsAndState()
QCOMPARE(buttons[0]->height(), 28);
QCOMPARE(buttons[0]->sizeHint().height(), 28);
QCOMPARE(buttons[0]->findChild<QToolButton *>("closeTab")->size(), QSize(16, 28));
// Titles stop at 155 and start at 35.
// Titles span 35 to 155, plus 8 and the dot.
QToolButton &title = *buttons[0]->findChild<QToolButton *>("selectTab");
const int dot = QFontMetrics(title.font()).horizontalAdvance(QStringLiteral("● ")) - 10;
workspace.tabs()[0]->session.setProjectPath(QString("/x/") + QString(80, QLatin1Char('W')) + ".comp");
QTRY_COMPARE(title.width(), 155);
QTRY_COMPARE(title.width(), 155 + 8 + dot);
QCOMPARE(buttons[0]->height(), 28);
workspace.tabs()[0]->session.setProjectPath(QString("/x/I.comp"));
QTRY_COMPARE(title.width(), 35);
QTRY_COMPARE(title.width(), 35 + 8 + dot);
workspace.tabs()[0]->session.setProjectPath(std::nullopt);
QCOMPARE(buttons[1]->findChild<QToolButton *>("selectTab")->font().weight(), QFont::DemiBold);
QCOMPARE(buttons[0]->findChild<QToolButton *>("selectTab")->font().weight(), QFont::Medium);
@@ -210,22 +211,21 @@ void ProjectWorkspaceViewTests::tabsShowTheirTitlesDotsAndState()
const auto wholeInView = [&](const ProjectTabButton *button) {
return strip.viewport()->rect().contains(QRect(button->mapTo(strip.viewport(), QPoint(0, 0)), button->size()));
};
// In view at once, and still after the layout settles.
QVERIFY(last->tab->id == workspace.selectedID() && wholeInView(last));
// 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<QToolButton *>("selectTab")->width() >= 35 && strip.horizontalScrollBar()->maximum() > 0);
QCoreApplication::processEvents();
QVERIFY(wholeInView(last));
// The front title grows: the scroll follows the layout.
// Since 1.2.4 a growing title keeps the offset.
const int offset = strip.horizontalScrollBar()->value();
workspace.tabs().back()->session.setProjectPath(QString("/x/") + QString(60, QLatin1Char('W')) + ".comp");
QCoreApplication::processEvents();
QCoreApplication::processEvents();
QVERIFY(last->width() > 180 && wholeInView(last));
QVERIFY(last->width() > 180);
QCOMPARE(strip.horizontalScrollBar()->value(), offset);
workspace.select(workspace.tabs()[0]->id);
const ProjectTabButton *head = strip.buttons().first();
QVERIFY(wholeInView(head));
QCoreApplication::processEvents();
QVERIFY(wholeInView(head));
QTRY_VERIFY(wholeInView(head));
}
void ProjectWorkspaceViewTests::tabButtonsSelectAndClose()
@@ -271,7 +271,7 @@ void ProjectWorkspaceViewTests::theToolbarFollowsTheFrontSession()
QVERIFY(fit.isEnabled() && actual.isEnabled() && in.isEnabled() && out.isEnabled());
actual.trigger();
out.trigger();
QCOMPARE(session.viewport.zoom(), 0.8);
QCOMPARE(session.viewport.zoom(), 2.0 / 3.0);
in.trigger();
in.trigger();
QCOMPARE(session.viewport.zoom(), 1.25);
+2
View File
@@ -150,6 +150,8 @@ compositor_test(ProjectWorkspaceFlowTests)
compositor_test(ProjectWorkspaceSignalTests)
compositor_test(ProjectWorkspaceTests)
compositor_test(ProjectWorkspaceViewTests)
compositor_test(ProjectTabStripTests)
compositor_test(KeyboardZoomTests)
# It starts the real app beside itself.
add_dependencies(ProjectWorkspaceViewTests omaphoto)
compositor_test(ProjectSessionTests)
+26 -1
View File
@@ -1,4 +1,6 @@
#include "MenuFixtures.h"
#include "Rendering/EditorCanvas.h"
#include <QLineEdit>
// The View menu: zoom, the pixel grid and Snap.
class ViewMenuTests : public QObject {
@@ -24,9 +26,32 @@ void ViewMenuTests::viewEntriesZoomTheFrontSession()
QCOMPARE(bar.session().viewport.zoom(), 1.25);
bar.action("zoomOut").trigger();
bar.action("zoomOut").trigger();
QCOMPARE(bar.session().viewport.zoom(), 0.8);
QCOMPARE(bar.session().viewport.zoom(), 2.0 / 3.0);
bar.action("fit").trigger();
QVERIFY(bar.session().viewport.followsFit());
// A text field or open text keeps the keys.
bar.window.show();
QVERIFY(QTest::qWaitForWindowActive(&bar.window));
bar.session().zoom(1);
auto *field = new QLineEdit(&bar.window);
field->show();
field->setFocus();
QTRY_VERIFY(field->hasFocus());
bar.action("zoomIn").trigger();
QCOMPARE(bar.session().viewport.zoom(), 1.0);
auto &canvas = *bar.window.findChild<CanvasView *>();
canvas.setFocus();
QTRY_VERIFY(canvas.hasFocus());
bar.session().selectTool(NavigationTool::type);
bar.session().beginText(QPointF(10, 10), true);
QVERIFY(bar.session().textDraft());
canvas.setFocus();
QTRY_VERIFY(canvas.hasFocus());
bar.action("zoomIn").trigger();
QCOMPARE(bar.session().viewport.zoom(), 1.0);
bar.session().cancelText();
bar.action("zoomIn").trigger();
QCOMPARE(bar.session().viewport.zoom(), 1.25);
}
void ViewMenuTests::thePixelGridEntryTogglesTheSession()