14.17: the Layers panel grays only for what lasts

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 08:30:40 -04:00
1 parent ffee8ff178
commit 98f18f1221
12 files changed
+156 -12

No files matched your search

+1
View File
@@ -196,6 +196,7 @@ Platform adaptations, each forced by a missing Qt analog:
- 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` (each bar narrow and wide, and each note where Swift puts it); `ColorSwatchTests` and `GradientCanvasTests` widen their bars, since a scrolling bar's own size hint no longer holds every control.
- Revealing the chosen layer (14.15, Swift 1.4.8: `d2b8aaf`, `b4e147b`): `selectLayer` and `selectLayers` end with `revealActiveLayer` (declared in `LayerGroups+Session.h`, as Swift's extension), which opens every folder above the active layer and says whether one opened; `selectLayer` then signals again, since its setter signalled before, so the last signal sees the folders open. Swift's `seen` set, a guard against a parent cycle, is left out: a session's document always passes `LayerHierarchy::validate`, so the chain ends at the root. `NativeLayerList::update` remembers the active layer it last scrolled to and scrolls a newly active row into view once its row exists (`ensureWidgetVisible`, no margin), so a later rename or refresh leaves a scroll made by hand: the session's setter signals while a shut folder still hides the row, where SwiftUI sees the selection and the open folders in one update, so the list waits for the row, and first hands the scroll area its posted layout request, since rows made anew have not yet grown the scroll range. Swift's own tests went with `b4e147b`; the twin keeps them. Equivalent mutant: the guard on an empty collapsed set (the walk then removes nothing; Swift's too). Tests: `LayerRevealTests` (Swift's five, the last signal with both and only the inner folder shut, a primary outside every folder opening nothing, a child of a shut folder below the view scrolled to by either call); `ProjectSessionTests` now collapses its folder after choosing a layer inside it.
- Camera Raw's Shadows and Highlights (14.16, Swift 1.4.8: `0b4c56c`): `kernels/AdjustPixels.c` is 0b4c56c's, unchanged: each follows a bump, u(1 − 2u)², over its half of the tones (Shadows from black, Highlights from white), two and a half times the bump to lift Shadows or pull Highlights down and once the other way, so tones never swap, and black, middle gray and white stay. Tests: `CameraRawToneOrderTests` (Swift's ramp kept in order at ±100 and the dark tones lifted; every level of a gray ramp against the curve written apart, each way at full and partial amounts and both together, Highlights first).
- The Layers panel's gray (14.17, Swift 1.4.8: `06313a9`): `layersLookEditable` is Swift's: `canEditLayers` without what lasts only while the mouse is down (a stroke, a warp, a drag's transform edit, a pixel move) or a moment (`isProjectBusy`, read as `showsBusy`, which shows after 250 ms). The list's rows and eyes, the footer's buttons (new layer, folder, adjustments, delete with an active layer), the effects menu (with a layer that has pixels) and the mask button (one layer selected, without a mask) follow it, so nothing flashes gray for a click; every action still asks the session's own gate, which refuses meanwhile. The effects menu's folder term is left out as dead: a folder never holds pixels, so the pixel term refuses it. Tests: `LayersLookEditableTests` (each moment looks editable while the gate stays shut, each lasting state grays, the footer and list mid-stroke and under a pending transform, the effects menu's and mask button's own terms); the busy cases in `BlendModePickerTests`, `LayersPanelTests`, `NativeLayerListTests` and `LayerEffectRowTests` now wait for the indicator, and a click before it changes nothing.
- 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 <title>"), 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.
+7
View File
@@ -263,6 +263,13 @@ void EditorSession::endEdit()
notify();
}
bool EditorSession::layersLookEditable() const
{
return !m_selectionAmountOperation && !m_colorRange && !m_textDraft && m_document && !showsBusy() && !m_isImporting && !m_showsNewDocument
&& !m_showsImporter && !m_renamingLayerID && !(m_transformEdit && m_transformEdit->persistent) && !m_cropRect && !m_gradientEdit && !m_hueSaturation
&& !m_levels && !m_filterEdit && !m_adjustmentEditingID;
}
bool EditorSession::canEditLayers() const
{
return !m_selectionAmountOperation && !m_colorRange && !m_textDraft && m_document && !m_brushStroke && !m_warpStroke && !m_isProjectBusy && !m_isImporting
+2
View File
@@ -217,6 +217,8 @@ public:
void beginEdit(const QString &name);
void endEdit();
bool canEditLayers() const;
// The panel grays for what lasts, not for a stroke.
bool layersLookEditable() const;
void addBlankLayer();
void deleteLayer(QUuid id);
void deleteActiveLayer();
+1 -1
View File
@@ -21,7 +21,7 @@ LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolBut
void LayerMaskMenu::synchronize()
{
const std::optional<ImageLayer> active = m_session.activeLayer();
setEnabled(m_session.canEditMask() && !(active && active->mask));
setEnabled(m_session.layersLookEditable() && m_session.selectedLayerIDs().size() == 1 && active && !active->mask);
setToolTip(m_session.selection() ? QStringLiteral("Add layer mask revealing the selection (Alt-click to hide it)")
: QStringLiteral("Add layer mask (Alt-click for a black mask)"));
setIcon(footerIcon(LayerIcon::addMask, *this));
+8 -5
View File
@@ -160,10 +160,13 @@ void LayersPanel::synchronize()
m_count->setText(QString::number(count));
m_body->setCurrentIndex(count > 0 ? 0 : 1);
m_emptyHint->setText(document ? QStringLiteral("Import an image or add a blank layer.") : QStringLiteral("Create a canvas or import an image."));
m_addBlankLayer->setEnabled(m_session.canEditLayers());
m_group->setEnabled(m_session.canEditLayers());
m_effects->setEnabled(m_session.canEditEffects());
m_adjustments->setEnabled(m_session.canEditLayers());
// Swift 1.4.8: no flash for a stroke; the session refuses.
const bool editable = m_session.layersLookEditable();
const std::optional<ImageLayer> active = m_session.activeLayer();
m_addBlankLayer->setEnabled(editable);
m_group->setEnabled(editable);
m_effects->setEnabled(editable && active && active->asset);
m_adjustments->setEnabled(editable);
const bool mask = m_session.isMaskSelected(), several = m_session.selectedLayerIDs().size() > 1;
const QString what = m_session.selectedEffect() ? QStringLiteral("Delete selected effect")
: mask ? QStringLiteral("Delete layer mask")
@@ -171,7 +174,7 @@ void LayersPanel::synchronize()
: QStringLiteral("Delete selected layer");
m_delete->setToolTip(what);
m_delete->setAccessibleName(what);
m_delete->setEnabled(m_session.canEditLayers() && m_session.activeLayer().has_value());
m_delete->setEnabled(editable && active);
// Swift's .task(id:): a new request begins its editor.
if (m_session.adjustmentEditingID() != m_adjustmentTask) {
m_adjustmentTask = m_session.adjustmentEditingID();
+2 -1
View File
@@ -44,7 +44,8 @@ void NativeLayerList::update()
next.push_back(layers[index]);
m_details.insert_or_assign(entry.layer.id, entry);
}
m_editingEnabled = m_session.canEditLayers();
// Swift 1.4.8: gray for what lasts; actions ask the gates.
m_editingEnabled = m_session.layersLookEditable();
// The rows are made anew when the ids change.
bool same = next.size() == m_rows.size();
for (size_t row = 0; same && row < next.size(); ++row)
+3 -1
View File
@@ -160,8 +160,10 @@ void BlendModePickerTests::theMaskButtonAddsAWhiteMaskOnce()
QVERIFY(!button.isEnabled());
session.undo();
QVERIFY(!session.activeLayer().value().mask.has_value() && button.isEnabled());
// A moment's work does not gray it; lasting work does.
session.setIsProjectBusy(true);
QVERIFY(!button.isEnabled());
QVERIFY(button.isEnabled());
QTRY_VERIFY(session.showsBusy() && !button.isEnabled());
session.setIsProjectBusy(false);
QVERIFY(button.isEnabled());
}
+1 -1
View File
@@ -243,7 +243,7 @@ void LayerEffectRowTests::theEyeShowsOrHidesItsEffect()
QVERIFY(layerWith(listed.session, top).effects.value().stroke.value().isEnabled() && listed.session.history.undoName() == "Show Stroke");
// It rests while layers cannot be edited.
listed.session.setIsProjectBusy(true);
QVERIFY(!listed.eye(stroke).isEnabled());
QTRY_VERIFY(listed.session.showsBusy() && !listed.eye(stroke).isEnabled());
listed.click(listed.eye(stroke), QPoint(10, 11));
QVERIFY(layerWith(listed.session, top).effects.value().stroke.value().isEnabled() && !listed.chosen());
listed.session.setIsProjectBusy(false);
+122
View File
@@ -0,0 +1,122 @@
#include "UI/LayersPanel.h"
#include "UI/NativeLayerList.h"
#include <QtTest>
// Swift 1.4.8: the panel grays for what lasts, not moments.
namespace {
std::unique_ptr<EditorSession> withLayer()
{
auto session = std::make_unique<EditorSession>();
session->createDocument(20, 20);
QImage pixels(20, 20, QImage::Format_RGBA8888_Premultiplied);
pixels.fill(Qt::white);
session->insert(ImportedImage(pixels, pixels, QStringLiteral("White")));
return session;
}
using Steps = std::pair<std::function<void(EditorSession &)>, std::function<void(EditorSession &)>>;
}
class LayersLookEditableTests : public QObject {
Q_OBJECT
private slots:
void aMomentLooksEditableWhileTheGateStaysShut();
void whatLastsGraysThePanel();
void theFooterAndListFollowTheLook();
};
void LayersLookEditableTests::aMomentLooksEditableWhileTheGateStaysShut()
{
const std::vector<std::pair<const char *, Steps>> moments{
{"stroke", {[](EditorSession &s) { s.selectTool(NavigationTool::brush); s.beginBrush(QPointF(5, 5)); }, [](EditorSession &s) { s.cancelBrush(); }}},
{"warp", {[](EditorSession &s) { s.selectTool(NavigationTool::blur); s.setBlurMode(BlurToolMode::liquify); s.beginBrush(QPointF(5, 5)); },
[](EditorSession &s) { s.cancelBrush(); }}},
{"drag", {[](EditorSession &s) { s.selectTool(NavigationTool::move); s.beginTransform(false); }, [](EditorSession &s) { s.cancelTransform(); }}},
{"pixels", {[](EditorSession &s) { s.selectAll(); s.beginPixelMove(); }, [](EditorSession &s) { s.cancelPixelMove(); s.deselect(); }}},
{"busy", {[](EditorSession &s) { s.setIsProjectBusy(true); }, [](EditorSession &s) { s.setIsProjectBusy(false); }}}};
const auto session = withLayer();
QVERIFY(session->layersLookEditable() && session->canEditLayers());
for (const auto &[name, steps] : moments) {
steps.first(*session);
QVERIFY2(session->layersLookEditable() && !session->canEditLayers(), name);
steps.second(*session);
QVERIFY2(session->layersLookEditable() && session->canEditLayers(), name);
}
// Busy long enough to show it: then it grays.
session->setIsProjectBusy(true);
QTRY_VERIFY(session->showsBusy());
QVERIFY(!session->layersLookEditable());
session->setIsProjectBusy(false);
QVERIFY(session->layersLookEditable());
}
void LayersLookEditableTests::whatLastsGraysThePanel()
{
QVERIFY(!EditorSession().layersLookEditable());
const std::vector<std::pair<const char *, Steps>> lasting{
{"amount", {[](EditorSession &s) { s.setSelectionAmountOperation(SelectionAmountOperation::feather); },
[](EditorSession &s) { s.setSelectionAmountOperation(std::nullopt); }}},
{"range", {[](EditorSession &s) { s.beginColorRange(); }, [](EditorSession &s) { s.cancelColorRange(); }}},
{"text", {[](EditorSession &s) { s.selectTool(NavigationTool::type); s.beginText(QPointF(5, 5), true); }, [](EditorSession &s) { s.cancelText(); }}},
{"importing", {[](EditorSession &s) { s.setIsImporting(true); }, [](EditorSession &s) { s.setIsImporting(false); }}},
{"sheet", {[](EditorSession &s) { s.setShowsNewDocument(true); }, [](EditorSession &s) { s.setShowsNewDocument(false); }}},
{"importer", {[](EditorSession &s) { s.setShowsImporter(true); }, [](EditorSession &s) { s.setShowsImporter(false); }}},
{"rename", {[](EditorSession &s) { s.setRenamingLayerID(s.activeLayerID()); }, [](EditorSession &s) { s.setRenamingLayerID(std::nullopt); }}},
{"transform", {[](EditorSession &s) { s.selectTool(NavigationTool::move); s.beginTransform(); }, [](EditorSession &s) { s.cancelTransform(); }}},
{"crop", {[](EditorSession &s) { s.selectTool(NavigationTool::crop); }, [](EditorSession &s) { s.cancelCrop(); }}},
{"gradient", {[](EditorSession &s) { s.selectTool(NavigationTool::gradient); s.beginGradient(QPointF(2, 2)); s.moveGradient(std::nullopt, QPointF(12, 2)); },
[](EditorSession &s) { s.cancelGradient(); }}},
{"hue", {[](EditorSession &s) { s.beginHueSaturation(); }, [](EditorSession &s) { s.cancelHueSaturation(); }}},
{"levels", {[](EditorSession &s) { s.beginLevels(); }, [](EditorSession &s) { s.cancelLevels(); }}},
{"filter", {[](EditorSession &s) { s.beginFilter(FilterKind::gaussianBlur); }, [](EditorSession &s) { s.cancelFilter(); }}},
{"adjustment", {[](EditorSession &s) { s.setAdjustmentEditingID(s.activeLayerID()); }, [](EditorSession &s) { s.setAdjustmentEditingID(std::nullopt); }}}};
const auto session = withLayer();
for (const auto &[name, steps] : lasting) {
steps.first(*session);
QVERIFY2(!session->layersLookEditable(), name);
steps.second(*session);
QVERIFY2(session->layersLookEditable(), name);
}
}
void LayersLookEditableTests::theFooterAndListFollowTheLook()
{
const auto session = withLayer();
LayersPanel panel(*session);
panel.show();
QVERIFY(QTest::qWaitForWindowExposed(&panel));
auto *add = panel.findChild<QToolButton *>("addBlankLayer");
auto *effects = panel.findChild<QToolButton *>("layerEffects");
auto *mask = panel.findChild<QToolButton *>("addLayerMask");
auto *eye = panel.list().cells().at(0)->findChild<QToolButton *>("layerEye");
// Mid-stroke nothing flashes, and a click changes nothing.
session->selectTool(NavigationTool::brush);
session->beginBrush(QPointF(5, 5));
QVERIFY(add->isEnabled() && effects->isEnabled() && mask->isEnabled() && eye->isEnabled());
QTest::mouseClick(add, Qt::LeftButton);
QCOMPARE(session->document().value().layers.size(), size_t(1));
session->cancelBrush();
// A pending transform grays them.
session->selectTool(NavigationTool::move);
session->beginTransform();
QVERIFY(!add->isEnabled() && !effects->isEnabled() && !mask->isEnabled() && !eye->isEnabled());
session->cancelTransform();
// Effects want pixels: no folder, no adjustment, no layer.
session->addGroup();
QVERIFY(add->isEnabled() && !effects->isEnabled());
session->addAdjustment(AdjustmentKind::invert);
QVERIFY(!effects->isEnabled());
session->selectLayer(std::nullopt);
QVERIFY(!effects->isEnabled() && !mask->isEnabled() && !panel.findChild<QToolButton *>("deleteLayer")->isEnabled());
// The mask button takes one layer, without a mask.
const QUuid white = session->document().value().layers.front().id;
session->selectLayers({white, session->document().value().layers.back().id}, white);
QVERIFY(!mask->isEnabled());
session->selectLayer(white);
QVERIFY(mask->isEnabled() && effects->isEnabled());
session->addMask();
QVERIFY(!mask->isEnabled());
}
QTEST_MAIN(LayersLookEditableTests)
#include "LayersLookEditableTests.moc"
+6 -1
View File
@@ -137,8 +137,12 @@ void LayersPanelTests::theFooterAddsGroupsAndDeletesWithSwiftsWords()
QTest::mouseClick(&group, Qt::LeftButton);
QCOMPARE(session.document().value().layers.size(), size_t(2));
QVERIFY(session.activeLayer().value().isGroup);
// Busy, the footer waits.
// Busy a moment: no flash, and the session refuses.
session.setIsProjectBusy(true);
QVERIFY(add.isEnabled() && group.isEnabled() && remove.isEnabled());
QTest::mouseClick(&add, Qt::LeftButton);
QCOMPARE(session.document().value().layers.size(), size_t(2));
QTRY_VERIFY(session.showsBusy());
QVERIFY(!add.isEnabled() && !group.isEnabled() && !remove.isEnabled() && !mask.isEnabled());
}
@@ -231,6 +235,7 @@ void LayersPanelTests::footerButtonsTakeThePrimaryInkAndDimAsTheMenus()
QTRY_COMPARE(brightest(find<QToolButton>(live, "addBlankLayer").icon(), QIcon::Normal).rgb(), primary.rgb());
QVERIFY(find<QToolButton>(live, "addBlankLayer").isEnabled() && find<QToolButton>(live, "addLayerMask").isEnabled());
working.setIsProjectBusy(true);
QTRY_VERIFY(working.showsBusy());
for (const auto &[name, shape] : buttons) {
QToolButton &button = find<QToolButton>(live, name);
QVERIFY2(!button.isEnabled(), name);
+2 -2
View File
@@ -241,9 +241,9 @@ void NativeLayerListTests::theEyeTogglesAndSwipesInOneStep()
QCOMPARE(session->history.undoCount(), steps + 1);
session->undo();
QVERIFY(!layerWith(*session, shown.id(0)).isVisible && !layerWith(*session, shown.id(1)).isVisible);
// Busy, the eye is disabled.
// Busy, the eye rests once the indicator shows.
session->setIsProjectBusy(true);
QVERIFY(!eye->isEnabled());
QTRY_VERIFY(session->showsBusy() && !eye->isEnabled());
}
void NativeLayerListTests::disclosureLinkAndTheMenuReachTheSession()
+1
View File
@@ -311,6 +311,7 @@ compositor_test(CameraRawToneOrderTests)
compositor_test(LastFilterTests)
compositor_test(ToolHeaderScrollTests)
compositor_test(LayerRevealTests)
compositor_test(LayersLookEditableTests)
compositor_test(CameraRawSessionTests)
compositor_test(CameraRawToolsTests)
compositor_test(CameraRawRulesTests)