diff --git a/AGENTS.md b/AGENTS.md index 05ea49a..46416d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,6 +193,7 @@ Platform adaptations, each forced by a missing Qt analog: - The Layers footer's ink (14.11, Swift 1.4.7: `7fb40a9`, `790195f`): `footerIcon` (in `UI/LayersPanel`, Swift's `FooterIcon`) gives the plain buttons (new layer, folder, mask, delete) the primary ink, `WindowText`, and for their disabled state the menus' own: the shape in `PlaceholderText` dimmed by the style's `generatedIconPixmap`, as Qt dims the effects and adjustment menus' icons, so a disabled button is exactly as dim as a disabled menu (Swift measures a quarter-strength white for the same end). Both inks are read from the active group, since a disabled widget's palette answers with its disabled group. The menus keep `PlaceholderText`. The panel redraws its icons from the event loop on a palette change, once its buttons carry the new palette. Tests: `LayersPanelTests::footerButtonsTakeThePrimaryInkAndDimAsTheMenus` (disabled from the start and after a busy project disables them), and the inks after a new palette in `LayerListFocusTests::aNewPaletteRedrawsEveryIcon`. - Don't Save (14.12, Swift 1.4.7: `283d360`, `4e035bf`) was held: Swift marked the button destructive and took it back the next day, so its net change is none, Save the one coloured button. The twin's alert keeps `QMessageBox::DestructiveRole` for Don't Save, which in Qt sets only the platform's button order, no colour; Save, the first accepting button, is the default, and Cancel and Don't Save are both plain. `ProjectControllerTests::saveIsTheOneDefaultAndDontSaveIsAsPlainAsCancel` holds it. - The name field (14.13, Swift 1.4.7: `8f7d344`): a rename's `QLineEdit` has no frame and the name label's height and top, two points further left and wider, since a `QLineEdit` insets its text by two where the `QLabel` does not, so the text stays where the name was; its ground (`Base`, set as the rename begins) is the active `WindowText` at a tenth, Swift's quaternary label colour, and the name label hides beneath it until the rename ends, since Swift edits the label itself. `LayerListFocusTests::theNameFieldKeepsTheNamesSizeAndPlace`. +- Tool bars that scroll (14.14, Swift 1.4.7: `68396ff`): `ToolHeaderBar::scrolling` is Swift's horizontal `ScrollView` with hidden indicators, as the Move and Type bars already had it: the controls in a frameless `QScrollArea` without bars that takes the row's spare room, their content packed at its own size at the leading edge (a stretch of its own), so a narrow window scrolls them (a sideways wheel or trackpad, or the focus moving in) rather than squeezing them. The Brush, Gradient and selection bars use it (`brushScroll`, `gradientScroll`, `selectionScroll`); the Brush bar's clone and mask notes now scroll after the controls, as Swift's moved them inside; the Gradient's Mask note, Cancel and Apply and the selection's Empty selection and Deselect stay at the bar's end. Tests: `ToolHeaderScrollTests`; `ColorSwatchTests` and `GradientCanvasTests` widen their bars, since a scrolling bar's own size hint no longer holds every control. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/src/UI/BrushControls.cpp b/src/UI/BrushControls.cpp index 8c96ee9..1119928 100644 --- a/src/UI/BrushControls.cpp +++ b/src/UI/BrushControls.cpp @@ -165,10 +165,9 @@ BrushControls::BrushControls(EditorSession &session, QWidget *parent) widgets.insert(widgets.end(), {size, m_size, label(QStringLiteral("px"), this), hardness, m_hardnessSlider, m_hardness, label(QStringLiteral("%"), this), m_opacityLabel, m_opacitySlider, m_opacity, label(QStringLiteral("%"), this), m_radiusLabel, m_radiusSlider, m_radius, m_radiusUnit, m_smoothingLabel, m_smoothingSlider, m_smoothing, m_paintLabel, m_maskPaint, m_colourLabel, m_colour}); - for (QWidget *widget : widgets) - row->insertWidget(row->count() - 1, widget); - row->addWidget(m_cloneNote); - row->addWidget(m_mask); + // Swift 1.4.7: the notes scroll with the controls. + widgets.insert(widgets.end(), {m_cloneNote, m_mask}); + scrolling(widgets, QStringLiteral("brushScroll")); connect(&m_session, &EditorSession::changed, this, &BrushControls::synchronize); synchronize(); } diff --git a/src/UI/GradientControls.cpp b/src/UI/GradientControls.cpp index f6202dd..183e54c 100644 --- a/src/UI/GradientControls.cpp +++ b/src/UI/GradientControls.cpp @@ -95,9 +95,8 @@ GradientControls::GradientControls(EditorSession &session, QWidget *parent) changeGradient(m_session, [value](GradientSettings &gradient) { gradient.opacity = value; }); m_opacity->showValue(); }}); - for (QWidget *widget : std::initializer_list<QWidget *>{m_linear, m_radial, m_swatch, m_style, m_reverse, opacity, - m_opacitySlider, m_opacity, new QLabel(QStringLiteral("%"), this)}) - row->insertWidget(row->count() - 1, widget); + scrolling({m_linear, m_radial, m_swatch, m_style, m_reverse, opacity, m_opacitySlider, m_opacity, new QLabel(QStringLiteral("%"), this)}, + QStringLiteral("gradientScroll")); for (QWidget *widget : std::initializer_list<QWidget *>{m_mask, m_cancel, m_apply}) row->addWidget(widget); connect(&m_session, &EditorSession::changed, this, &GradientControls::synchronize); diff --git a/src/UI/LassoControls.cpp b/src/UI/LassoControls.cpp index af9bcaa..ad1fd75 100644 --- a/src/UI/LassoControls.cpp +++ b/src/UI/LassoControls.cpp @@ -193,12 +193,10 @@ LassoControls::LassoControls(EditorSession &session, QWidget *parent) scrub(m_featherUnit, m_featherAmount, 1, 250, [this] { return double(m_session.selectionFeatherAmount()); }, [this](double amount) { m_session.setSelectionFeatherAmount(int(amount)); }); // Swift's `unitSuffix`: a unit sits by each field. - for (QWidget *widget : std::initializer_list<QWidget *>{m_rectangle, m_ellipse, m_wandMode, m_objectMode, m_freehand, m_polygonal, m_replace, m_add, - m_subtract, m_toleranceLabel, m_tolerance, m_sampleSize, m_thisLayer, m_allLayers, m_contiguous, - m_objectThisLayer, m_objectAllLayers, m_edgeLabel, m_edge, m_edgeUnit, m_antialias, - divider, m_expand, m_expandAmount, m_expandUnit, m_contract, m_contractAmount, - m_contractUnit, m_feather, m_featherAmount, m_featherUnit}) - row->insertWidget(row->count() - 1, widget); + scrolling({m_rectangle, m_ellipse, m_wandMode, m_objectMode, m_freehand, m_polygonal, m_replace, m_add, m_subtract, m_toleranceLabel, m_tolerance, + m_sampleSize, m_thisLayer, m_allLayers, m_contiguous, m_objectThisLayer, m_objectAllLayers, m_edgeLabel, m_edge, m_edgeUnit, m_antialias, + divider, m_expand, m_expandAmount, m_expandUnit, m_contract, m_contractAmount, m_contractUnit, m_feather, m_featherAmount, m_featherUnit}, + QStringLiteral("selectionScroll")); row->addWidget(m_empty); row->addWidget(m_deselect); connect(&m_session, &EditorSession::changed, this, &LassoControls::synchronize); diff --git a/src/UI/ToolHeaderStyle.cpp b/src/UI/ToolHeaderStyle.cpp index 37c2172..7e7d579 100644 --- a/src/UI/ToolHeaderStyle.cpp +++ b/src/UI/ToolHeaderStyle.cpp @@ -27,3 +27,26 @@ ToolHeaderBar::ToolHeaderBar(const QString &text, QWidget *parent) row->addWidget(title); row->addStretch(1); } + +QScrollArea *ToolHeaderBar::scrolling(const std::vector<QWidget *> &controls, const QString &name) +{ + auto *content = new QWidget; + auto *line = new QHBoxLayout(content); + line->setContentsMargins(0, 0, 0, 0); + line->setSpacing(row->spacing()); + for (QWidget *control : controls) + line->addWidget(control); + // The content keeps its size at the leading edge. + line->addStretch(1); + auto *area = new QScrollArea(this); + area->setObjectName(name); + area->setWidget(content); + area->setWidgetResizable(true); + area->setFrameShape(QFrame::NoFrame); + area->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + area->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + // It takes the spare room, as SwiftUI's does. + row->insertWidget(row->count() - 1, area, 1); + row->setStretch(row->count() - 1, 0); + return area; +} diff --git a/src/UI/ToolHeaderStyle.h b/src/UI/ToolHeaderStyle.h index 8c8a763..a246315 100644 --- a/src/UI/ToolHeaderStyle.h +++ b/src/UI/ToolHeaderStyle.h @@ -2,7 +2,9 @@ #include <QFont> #include <QHBoxLayout> #include <QLabel> +#include <QScrollArea> #include <QWidget> +#include <vector> // Shared metrics: switching tools never moves the canvas. namespace ToolHeaderStyle { @@ -21,4 +23,6 @@ public: QLabel *const title; // Controls go before the stretch that ends the row. QHBoxLayout *const row; + // Swift's horizontal ScrollView: these scroll, not squeeze, when narrow. + QScrollArea *scrolling(const std::vector<QWidget *> &controls, const QString &name); }; diff --git a/tests/ColorSwatchTests.cpp b/tests/ColorSwatchTests.cpp index 0ebc7ec..e952a67 100644 --- a/tests/ColorSwatchTests.cpp +++ b/tests/ColorSwatchTests.cpp @@ -43,6 +43,8 @@ void ColorSwatchTests::theBrushBarPicksTheForeground() { const std::unique_ptr<EditorSession> session = sessionWithLayer(NavigationTool::brush); BrushControls bar(*session); + // Wide enough that nothing scrolls out of sight. + bar.resize(1600, bar.height()); bar.show(); QVERIFY(QTest::qWaitForWindowExposed(&bar)); QAbstractButton &colour = swatch(bar, "brushColor"); diff --git a/tests/GradientCanvasTests.cpp b/tests/GradientCanvasTests.cpp index 0c5b797..0bc2c32 100644 --- a/tests/GradientCanvasTests.cpp +++ b/tests/GradientCanvasTests.cpp @@ -297,8 +297,8 @@ void GradientCanvasTests::theBarSetsTheGradient() session.beginGradient(QPointF(1, 1)); session.moveGradient(std::nullopt, QPointF(30, 1)); QVERIFY(apply.isVisible() && cancel.isVisible()); - bar.resize(bar.sizeHint().width() + 300, bar.height()); - QTRY_VERIFY(cancel.x() - field.geometry().right() > 250 && apply.x() > cancel.x()); + bar.resize(1600, bar.height()); + QTRY_VERIFY(cancel.x() - field.mapTo(&bar, QPoint(field.width(), 0)).x() > 250 && apply.x() > cancel.x()); const int count = session.history.undoCount(); cancel.click(); QVERIFY(!session.gradientEdit()); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index dffb629..1ee47dc 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -308,6 +308,7 @@ compositor_test(ColorAdjustmentTests) compositor_test(CameraRawTests) compositor_test(CameraRawColorNoiseTests) compositor_test(LastFilterTests) +compositor_test(ToolHeaderScrollTests) compositor_test(CameraRawSessionTests) compositor_test(CameraRawToolsTests) compositor_test(CameraRawRulesTests) diff --git a/tests/ToolHeaderScrollTests.cpp b/tests/ToolHeaderScrollTests.cpp new file mode 100644 index 0000000..bed3c2d --- /dev/null +++ b/tests/ToolHeaderScrollTests.cpp @@ -0,0 +1,105 @@ +#include "Document/EditorSession.h" +#include "UI/BrushControls.h" +#include "UI/GradientControls.h" +#include "UI/LassoControls.h" +#include <QPushButton> +#include <QScrollBar> +#include <QtTest> + +// Swift 1.4.7: narrow tool bars scroll their controls, never squeeze. +namespace { +std::unique_ptr<EditorSession> withLayer(NavigationTool tool) +{ + auto session = std::make_unique<EditorSession>(); + session->createDocument(40, 20); + QImage pixels(40, 20, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(Qt::white); + session->insert(ImportedImage(pixels, pixels, QStringLiteral("White"))); + session->selectTool(tool); + return session; +} + +// The control's place inside the scroll area's view, or none. +bool inView(QScrollArea &area, QWidget &control) +{ + const QRect place(control.mapTo(area.viewport(), QPoint(0, 0)), control.size()); + return area.viewport()->rect().contains(place); +} + +// Too narrow a bar: its controls keep their size, scrolling. +void scrolls(ToolHeaderBar &bar, const char *name, QWidget &last) +{ + bar.resize(440, bar.height()); + bar.show(); + QVERIFY(QTest::qWaitForWindowExposed(&bar)); + auto &area = *bar.findChild<QScrollArea *>(QString::fromLatin1(name)); + QVERIFY2(bar.minimumSizeHint().width() < 440, name); + QCOMPARE(bar.height(), 42); + QVERIFY2(area.widget()->width() >= area.widget()->minimumSizeHint().width(), name); + QVERIFY2(area.horizontalScrollBar()->maximum() > 0 && !area.horizontalScrollBar()->isVisible(), name); + QVERIFY2(!inView(area, last) && bar.title->isVisible(), name); + // A sideways wheel scrolls it; the last control comes in. + for (int notch = 0; notch < 40 && !inView(area, last); ++notch) { + const QPoint middle = area.viewport()->rect().center(); + QWheelEvent wheel(middle, area.viewport()->mapToGlobal(middle), QPoint(), QPoint(-120, 0), Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false); + QApplication::sendEvent(area.viewport(), &wheel); + } + QVERIFY2(inView(area, last), name); + // Wide, nothing scrolls and the content starts at the edge. + bar.resize(2400, bar.height()); + QTRY_COMPARE(area.horizontalScrollBar()->maximum(), 0); + QCOMPARE(area.widget()->layout()->itemAt(0)->widget()->mapTo(&area, QPoint(0, 0)).x(), 0); + // Packed at its own size: the room is after it. + QVERIFY2(last.mapTo(&area, QPoint(last.width(), 0)).x() < area.width() - 500, name); +} +} + +class ToolHeaderScrollTests : public QObject { + Q_OBJECT +private slots: + void theBrushBarScrolls(); + void theGradientBarScrollsAndKeepsItsButtons(); + void theSelectionBarScrollsAndKeepsDeselect(); +}; + +void ToolHeaderScrollTests::theBrushBarScrolls() +{ + const auto session = withLayer(NavigationTool::brush); + BrushControls bar(*session); + // The mask note scrolls with the controls, after them. + session->addMask(); + QWidget ¬e = *bar.findChild<QWidget *>("brushMaskNote"); + scrolls(bar, "brushScroll", note); + QVERIFY(note.isVisible() && note.parentWidget() == bar.findChild<QScrollArea *>("brushScroll")->widget()); +} + +void ToolHeaderScrollTests::theGradientBarScrollsAndKeepsItsButtons() +{ + const auto session = withLayer(NavigationTool::gradient); + GradientControls bar(*session); + session->beginGradient(QPointF(1, 1)); + session->moveGradient(std::nullopt, QPointF(30, 1)); + scrolls(bar, "gradientScroll", *bar.findChild<QWidget *>("gradientOpacity")); + // Cancel and Apply stay at the end, outside the scroll. + bar.resize(440, bar.height()); + auto &apply = *bar.findChild<QPushButton *>("gradientApply"); + QTRY_VERIFY(apply.isVisible() && apply.geometry().right() < 440 && apply.parentWidget() == &bar); +} + +void ToolHeaderScrollTests::theSelectionBarScrollsAndKeepsDeselect() +{ + const auto session = withLayer(NavigationTool::marquee); + LassoControls bar(*session); + session->selectAll(); + scrolls(bar, "selectionScroll", *bar.findChild<QWidget *>("selectionFeatherAmount")); + bar.resize(440, bar.height()); + QPushButton *deselect = nullptr; + for (QPushButton *button : bar.findChildren<QPushButton *>()) + if (button->text() == "Deselect") + deselect = button; + QVERIFY(deselect); + QTRY_VERIFY(deselect->isVisible() && deselect->geometry().right() < 440 && deselect->parentWidget() == &bar); +} + +QTEST_MAIN(ToolHeaderScrollTests) +#include "ToolHeaderScrollTests.moc"